MediaWiki:Gadget-tidy-on-save.js

Definition från Wiktionary, den fria ordlistan.
Hoppa till navigering Hoppa till sök

OBS: Efter du har publicerat sidan kan du behöva tömma din webbläsares cache för att se ändringarna.

  • Firefox / Safari: Håll ned Skift och klicka på Uppdatera sidan eller tryck Ctrl-F5 eller Ctrl-R (⌘-R på Mac)
  • Google Chrome: Tryck Ctrl-Skift-R (⌘-Skift-R på Mac)
  • Internet Explorer / Edge: Håll ned Ctrl och klicka på Uppdatera eller tryck Ctrl-F5
  • Opera: Tryck Ctrl-F5.
// @ts-check

module.exports = processOnSave;

/** Transformation categories. */
var transformCats = /** @type const */ ({
  NONE: undefined,
  INVISIBLE: "osynliga tecken",
  SPACE: "mellanslag",
  LINE: "radbrytningar",
  LANG_CODE: "språkkoder",
  TPL_NAME: "mallnamn",
  WARN: "spåra varning",
});
var importantTransformCats = [
  transformCats.WARN,
  transformCats.LANG_CODE,
  transformCats.TPL_NAME,
];

/** @returns {Operation[]} */
function getOperations() {
  return [
    // 💾 Lägg till varningar i {{tidy}} (steg 1: ta bort)
    trackWarnings("remove"),

    // 💾 Inga hårda mellanslag eller tabbtecken
    replace("INVISIBLE", /[\xa0\t]/g, " "),

    // 💾 Max 1 mellanslag i följd
    replace("SPACE", / {2,}/g, " "),

    // 💾 Trimma bort mellanslag på rader
    replace("SPACE", /^ | $/gm, ""),

    // 💾 Max 1 tom rad
    replace("LINE", /\n{3,}/g, "\n\n"),

    // 💾 Ingen tom rad i början på en sida
    replace("LINE", /^\n/, ""),

    opacify("html comment"), // <!-- --> => OPAQUE
    opacify("nowiki"), // <nowiki>...</nowiki> => OPAQUE

    // 💾 Trimma bort mellanslag i mallnamn
    replace("SPACE", /{{ ?([^|}]+?) ?(}}|\|)/g, "{{$1$2"),

    // 💾 Korrigera mallnamn
    {
      cat: "TPL_NAME",
      op: "template name",
      process: correctTemplateNames,
    },

    // 💾 Mellanslag före genus- och numerusmall m.fl.
    replace(
      "SPACE",
      /([\],;)}])({{(?:[fmunsdp]|mf|[fmun](?:s|pl)|pf|impf|impfpf|bf|okomp|oböjl|oräkn|peri)(?=\||}}))/g,
      "$1 $2"
    ),

    // 💾 Inget mellanslag i början på rader i listor
    replace("SPACE", /^([#*:]+) /gm, "$1"),

    opacify("link"), // [[link|text]] => OPAQUE
    opacify("tag"), // <div style="...">...</div> => OPAQUE
    opacify("template param"), // {{mall|a=b}} => {{mall|OPAQUE}} (recursively)
    opacify("table"), // {| ... |} => OPAQUE

    // 💾 Trimma bort mellanslag i rubriknamn
    replace("SPACE", /^(==+) ?([^=]+?) ?(==+)$/gm, "$1$2$3"),

    replaceAll("LINE", [
      // 💾 Tom rad ovanför rubrik
      { search: /([^=\n]\n)(==+[^=]+==+)$/gm, replace: "$1\n$2" },
      // 💾 Ingen tom rad under rubrik
      { search: /^(==+[^=]+==+\n)\n/gm, replace: "$1" },
    ]),

    // ⚠️ Varna om felaktiga rubriker
    { cat: "NONE", op: "check headings", process: checkHeadings },

    // 💾 Tom rad mellan översättningsavsnitt
    replace(
      "LINE",
      /^({{(ö-botten|ö-se\|[^}]+)}})\n(?={{(ö-topp|ö-topp-även|ö-topp-granska|ö-se)[|}])/gm,
      "$1\n\n"
    ),

    // 💾 Ingen tom rad ovanför listrader
    replace("LINE", /\n(\n[*#:])/g, "$1"),

    // 💾 Mellanslag på fetstilsrad
    replace(
      "SPACE",
      /^('''+)( ?)(.+?)( ?)('''+)([ ,!?\0]?)(.*)$/gm,
      replaceSpaceOnHeadwordLine
    ),

    // 💾 Mellanslag efter {{tagg}}
    replace("SPACE", /^(#{{tagg\|[^}]+}})([^\n ])/gm, "$1 $2"),

    opacify("inner template"), // {{mall|a {{mall}} b}} => {{mall|a OPAQUE b}}

    // 💾 Trimma bort mellanslag i mallparametrar
    {
      cat: "SPACE",
      op: "template param space",
      process: removeTemplateParamSpace,
    },

    // 💾 Fixa språkkod
    { op: "lang code", cat: "LANG_CODE", process: processLangCodes },
    { op: "trans lang code", cat: "LANG_CODE", process: processTransLangCodes },

    // ⚠️ Varna om upprepad parameter
    { cat: "NONE", op: "check template param", process: checkTemplateParam },

    // 💾 Lägg till varningar i {{tidy}} (steg 2: lägg till)
    trackWarnings("add"),
  ];
}

var maxDepth = 10;

/**
 * @typedef {import("./tidy-data.js").Data} Data
 * @typedef {import("./tidy-data.js").LangNameLc} LangNameLc
 * @typedef {import("./tidy-data.js").LangNameUcfirst} LangNameUcfirst
 * @typedef {import("./tidy-data.js").Template} Template
 *
 * @typedef Operation
 * @property {OperationName} op Operation name
 * @property {string} [desc] Description
 * @property {TransformationCategory} cat Transformation category
 * @property {boolean} [alwaysProcess] Whether to process this operation, even when there are errors.
 * @property {(context: Context) => void} process
 *
 * @typedef {(
 *  | "replace"
 *  | "opacify"
 *  | "lang code"
 *  | "trans lang code"
 *  | "check headings"
 *  | "check template param"
 *  | "template name"
 *  | "template param space"
 *  | "track warnings"
 * )} OperationName
 *
 * @typedef Context
 * @property {Context} rootContext
 * @property {Context[]} allContexts
 * @property {string} _wikitext Private
 * @property {string} wikitext
 * @property {string} replacement The replacement string (`\0${number}\0`). Empty string if not a replacement.
 * @property {OpacifyNames} [creator]
 * @property {string} [creatorTemplate]
 * @property {boolean} aborted
 * @property {string[]} transformCats Transformation categories, to be displayed in a notification.
 * @property {Set<string>} _transformCats Transformation categories, to be displayed in a notification.
 * @property {number} revCount Revision index. Changes when *any* of the contexts changes.
 * @property {Set<string>} warnings
 * @property {(...args: InterpolateArgs) => void} warn
 * @property {(this: Context, ...args: InterpolateArgs) => void} error
 * @property {Set<OperationName>} warnOps Operations that have caused warnings.
 * @property {(str: string) => string} unopaque
 * @property {(creator: OpacifyNames, wikitext: string, creatorTemplate?: string) => Context} createChild
 * @property {(operations: Operation[]) => Context} process
 * @property {(replacement: string) => Context} getByReplacement
 * @property {number} processCounter To prevent infinite loops due to coding bugs
 * @property {Data} data
 * @property {(langName: string) => { langName: string; replacement: string } | undefined} getLangReplacement
 *
 * @typedef {keyof typeof transformCats} TransformationCategory
 */

/** @type {Operation[] | undefined} */
var operationsCache;

/**
 * @param {string} wikitext
 * @param {Data} data
 */
function processOnSave(wikitext, data) {
  if (!operationsCache) {
    operationsCache = getOperations();
  }
  return processInternal(createContext(wikitext, data), operationsCache);
}

/**
 * @param {string} wikitext
 * @param {Data} data
 * @returns {Context}
 */
function createContext(wikitext, data) {
  var regex = /\0([0-9]+)\0/g;

  /** @type {Context} */
  var context = {
    _wikitext: wikitext,
    get wikitext() {
      return this._wikitext;
    },
    set wikitext(value) {
      if (this._wikitext !== value) {
        this._wikitext = value;
        this.rootContext.revCount++;
      }
    },
    replacement: "",

    data: data,
    rootContext: /** @type {*} */ (undefined),
    allContexts: [],
    createChild: function (creator, wikitext, creatorTemplate) {
      /** @type {Context} */
      var child = Object.create(this);
      child._wikitext = wikitext;
      child.replacement = "\0" + this.allContexts.length + "\0";
      child.creator = creator;
      child.creatorTemplate = creatorTemplate;

      this.allContexts.push(child);

      return child;
    },

    processCounter: 0,
    process: function (operations) {
      if (this.rootContext.processCounter > 1000) {
        throw new Error("Infinite process loop?");
      }

      this.rootContext.processCounter++;
      return processInternal(this, operations);
    },

    aborted: false,
    warnings: new Set(),
    _transformCats: new Set(),
    get transformCats() {
      var t = this._transformCats;

      // Find only the important transformation categories, if any.
      var important = importantTransformCats.filter(function (x) {
        return t.has(x);
      });

      if (important.length) {
        return important;
      }

      // If there are no important categories, return them all.
      return Array.from(this._transformCats);
    },

    revCount: 0,

    warn: function () {
      var text = interpolate(/** @type {*} */ (arguments));
      text = this.unopaque(text);
      this.warnings.add(text);
    },

    error: function () {
      arguments[0] += " Processningen avbröts.";
      this.warn.apply(this, /** @type {*} */ (arguments));
      this.aborted = true;
    },

    warnOps: new Set(),

    unopaque: function (str) {
      var opaque = this.allContexts;

      /**
       * @param {string} _match
       * @param {string} index
       */
      function replacer(_match, index) {
        return opaque[index].wikitext;
      }

      for (var i = 0; reTest(regex, str); i++) {
        if (i > maxDepth) {
          throw new Error("[unopaque] Max depth exceeded");
        }

        str = str.replace(regex, replacer);
      }

      return str;
    },

    getByReplacement: function (replacement) {
      regex.lastIndex = 0;
      var match = regex.exec(replacement);
      return match && match[0].length === replacement.length
        ? this.allContexts[match[1]]
        : undefined;
    },

    getLangReplacement: function (name) {
      name = name.toLowerCase();
      var repl = this.data.langReplacements.get(
        /** @type {LangNameLc} */ (name)
      );

      if (repl) {
        return { langName: name, replacement: repl };
      }
    },
  };
  context.rootContext = context;
  context.allContexts.push(context);
  return context;
}

/**
 * @param {Context} context
 * @param {Operation[]} operations
 * @returns {Context}
 */
function processInternal(context, operations) {
  operations.forEach(function (op) {
    if (!context.aborted || op.alwaysProcess) {
      var revCountBefore = context.revCount;
      var warnCountBefore = context.warnings.size;

      op.process(context);

      if (context.revCount !== revCountBefore && op.cat !== "NONE") {
        context._transformCats.add(transformCats[op.cat]);
      }
      if (context.warnings.size !== warnCountBefore) {
        context.warnOps.add(op.op);
      }
    }
  });

  return context;
}

/**
 * @typedef {[template: string, ...args: string[]]} InterpolateArgs
 *
 * @param {InterpolateArgs} args
 */
function interpolate(args) {
  var i = 0;

  return args.length === 1
    ? args[0]
    : args[0].replace(/%s/g, function () {
        i++;
        return args[i];
      });
}

/**
 * @param {string} str
 * @param {RegExp} regex
 * @param {(match: RegExpExecArray) => void} fn
 */
function regexForEach(str, regex, fn) {
  regex.lastIndex = 0;

  for (;;) {
    var match = regex.exec(str);
    if (!match) break;

    fn(match);
  }
}

/**
 * @typedef {"html comment" | "nowiki" | "tag" | "template param" | "inner template" | "link" | "table"} TopLevelOpacifyNames
 * @typedef {TopLevelOpacifyNames | "lang codes"} OpacifyNames
 */
/** @type {Record<TopLevelOpacifyNames, (context: Context) => void>} */
var opacifySetup = {
  "html comment": processOpacifySimple.bind(
    null,
    "html comment",
    /<!--[\s\S]*?-->/g,
    /<!--|-->/g
  ),

  // Ignored: <noinclude>, <includeonly>, <syntaxhighlight>
  nowiki: processOpacifySimple.bind(
    null,
    "nowiki",
    /<(nowiki|pre|ref|math|gallery|hiero)(?:\s[^>/]*)?>[\s\S]*?<\/\1>|<(nowiki|ref)(?:\s[^>/]*)?\/>/g,
    /<\/?(nowiki|pre|ref|math|gallery|hiero)(?:\s[^>/]*)?>/g
  ),

  // Ignored: Self-closing <references>, <br>, <hr>
  tag: processOpacifyTag.bind(
    null,
    /<\/?(div|span|sup|sub|big|small|code|u|s|b|p)(?:\s[^>]*)?>/gi
  ),

  table: processOpacifySimple.bind(
    null,
    "table",
    /^:*{\|[\s\S]*?^\|}$/gm,
    /^{\||^\|}$/gm
  ),

  // Match internal links.
  link: processOpacifyLink.bind(
    null,
    // In the link target, don't support []{}|<>.
    /\[\[([^[\]{}|<>]+)(\|[\s\S]+?)?\]\]/g
  ),

  // Match all templates and parser functions.
  "template param": processOpacifyTemplateParams.bind(
    null,
    // Assume that templates don't have ':' in their name, so that we can match
    // parser functions.
    /{{[^}|:\0]+[|:]?|}}/g
  ),

  // Match all templates and parser functions.
  "inner template": processOpacifyInnerTemplate.bind(null, /{{[^}]+}}/g),
};
/**
 * @param {TopLevelOpacifyNames} name
 * @returns {Operation}
 */
function opacify(name) {
  return {
    cat: "NONE",
    op: "opacify",
    desc: name,
    process: opacifySetup[name],
  };
}

/**
 * @param {TransformationCategory} cat
 * @param {RegExp} search
 * @param {Replacement} replace
 * @returns {Operation}
 */
function replace(cat, search, replace) {
  return replaceAll(cat, [{ search: search, replace: replace }]);
}

/**
 * @typedef {string | ((...args: string[]) => string)} Replacement
 *
 * @param {TransformationCategory} cat
 * @param {{search: RegExp, replace: Replacement}[]} replacements
 * @returns {Operation}
 */
function replaceAll(cat, replacements) {
  return {
    cat: cat,
    op: "replace",
    desc: replacements
      .map(function (x) {
        return x.search + " => " + x.replace;
      })
      .join("; "),
    process: function (context) {
      replacements.forEach(function (x) {
        context.wikitext = context.wikitext.replace(
          x.search,
          /** @type {string} */ (x.replace)
        );
      });
    },
  };
}

/**
 * @param {OpacifyNames} name
 * @param {RegExp} regex Regex for replacing.
 * @param {RegExp} regexCheck Regex checking for any stray starts or ends.
 * @param {Context} context
 */
function processOpacifySimple(name, regex, regexCheck, context) {
  context.wikitext = context.wikitext.replace(regex, function (match) {
    return context.createChild(name, match).replacement;
  });

  // Get first stray.
  var stray = (context.wikitext.match(regexCheck) || [])[0];
  if (stray) {
    // Missing match.
    context.error("Start/avslut för %s hittades inte.", stray);
  }
}

/**
 * @param {RegExp} regex
 * @param {Context} context
 */
function processOpacifyLink(regex, context) {
  // In the link text, don't support link or template syntax.
  var reBadText = /\[\[|\]\]|{{|}}/;
  var msg =
    "Sidor innehållande [[länkar]], {{mallar}} eller i vissa fall <taggar> som placerats innanför [[ och ]] kan inte tolkas korrekt.";

  context.wikitext = context.wikitext.replace(
    regex,
    /**
     * @param {string} match
     * @param {string} target
     * @param {string} text
     */
    function (match, target, text) {
      if (reTest(reBadText, text)) {
        context.error(msg);
        return match;
      }

      if (match.includes("\n")) {
        if (target.includes("\n")) {
          context.error("Radbrytningar är inte tillåtna i länkar.");
          return match;
        }

        // The newline is in the text, which *is* allowed. However, let's
        // normalize it to just a space.
        match = match.replace(/\n/g, " ");
      }

      return context.createChild("link", match).replacement;
    }
  );

  var stray = context.wikitext.match(/\[\[|\]\]/g);
  if (!context.aborted && stray) {
    var balance = stray.reduce(function (sum, x) {
      return sum + (x === "[[" ? 1 : -1);
    }, 0);

    if (balance === 0) {
      // We've found an equal number of '[[' and ']]'. Assume that this means
      // that the page is using syntax that we don't support, such as links in
      // images or templates in images.
      context.error(msg);
    } else {
      // Missing match.
      context.error(
        "%s hittades inte.",
        balance < 0 ? "Start för ]]" : "Avslut för [["
      );
    }
  }
}

/**
 * @param {RegExp} regex
 * @param {Context} context
 */
function processOpacifyTag(regex, context) {
  regex.lastIndex = 0;

  /**
   * A stack of starts, i.e. `<div style="...">`.
   */
  var stack = [];
  var parts = "";
  var lastIndex = 0;

  for (;;) {
    var match = regex.exec(context.wikitext);
    if (!match) break;

    var isStart = !match[0].startsWith("</");
    if (isStart) {
      stack.push(match);
      continue;
    }

    var matchingStart = stack.pop();

    if (!matchingStart) {
      // Unexpected end tag.
      context.error('Oväntad "%s" (ingen matchande start).', match[0]);
      stack.length = 0;
      break;
    }

    // Tag names must match.
    if (match[1] !== matchingStart[1]) {
      // Tag doesn't match.
      context.error(
        "%s har en %s som inte matchar.",
        matchingStart[0],
        match[0]
      );
      stack.length = 0;
      break;
    }

    // Only opacify the outermost tag.
    if (stack.length === 0) {
      parts +=
        context.wikitext.substring(lastIndex, matchingStart.index) +
        context.createChild(
          "tag",
          context.wikitext.substring(
            matchingStart.index,
            match.index + match[0].length
          )
        ).replacement;

      lastIndex = match.index + match[0].length;
    }
  }

  if (stack.length !== 0) {
    // Missing end tag.
    context.error(
      "Avslut för %s hittades inte.",
      stack
        .map(function (x) {
          return x[0];
        })
        .join(", ")
    );
  }

  parts += context.wikitext.substring(lastIndex);
  context.wikitext = parts;
}

/**
 * @param {RegExp} regex
 * @param {Context} context
 */
function processOpacifyTemplateParams(regex, context) {
  // It is important to clone the regex, so as to not get stuck in an infinite loop.
  regex = new RegExp(regex);

  /**
   * A stack of starts, i.e. `{{template|`.
   */
  var stack = [];
  var parts = "";
  var lastIndex = 0;

  for (;;) {
    var match = regex.exec(context.wikitext);
    if (!match) break;

    var isStart = match[0].startsWith("{{");
    if (isStart) {
      stack.push(match);
      continue;
    }

    var matchingStart = stack.pop();

    if (!matchingStart) {
      // Unexpected template end.
      context.error('Oväntad "}}" (ingen matchande start).');
      break;
    }

    // Handle the outermost template by reprocessing inner templates.
    if (stack.length === 0) {
      var paramsStartIndex = matchingStart.index + matchingStart[0].length;

      // Nothing to do, if there are no params.
      if (paramsStartIndex === match.index) {
        continue;
      }

      parts +=
        context.wikitext.substring(
          lastIndex,
          matchingStart.index + matchingStart[0].length
        ) +
        context
          .createChild(
            "template param",
            context.wikitext.substring(paramsStartIndex, match.index),
            matchingStart[0].slice(2, -1)
          )
          .process([opacify("template param")]).replacement +
        "}}";

      lastIndex = match.index + match[0].length;
    }
  }

  if (stack.length !== 0) {
    // Unexpected template end.
    context.error('Avslut för "%s" hittades inte.', stack[0][0].trim());
  }

  parts += context.wikitext.substring(lastIndex);
  context.wikitext = parts;
}

/**
 * Requires {@link processOpacifyTemplateParams} to run before this function.
 * @param {RegExp} regex Regex for replacing.
 * @param {Context} context
 */
function processOpacifyInnerTemplate(regex, context) {
  context.allContexts.forEach(function (innerContext) {
    // Only process inner templates, inside template params.
    if (innerContext.creator !== "template param") {
      return;
    }

    innerContext.wikitext = innerContext.wikitext.replace(
      regex,
      function (match) {
        return innerContext.createChild("inner template", match).replacement;
      }
    );

    if (innerContext.wikitext.includes("{{")) {
      // Missing template end.
      context.error('Avslut för "{{" hittades inte.');
    } else if (innerContext.wikitext.includes("}}")) {
      // Unexpected template end.
      context.error('Oväntad "}}" (ingen matchande start).');
    }
  });
}

/**
 * @param {"remove" | "add"} type
 * @returns {Operation}
 */
function trackWarnings(type) {
  if (type === "remove") {
    return replace("NONE", /{{tidy}}/g, "");
  }

  return {
    cat: "WARN",
    op: "track warnings",
    process: function (context) {
      if (context.warnings.size) {
        context.wikitext =
          context.wikitext.replace(/\n+$/, "") + "\n\n{{tidy}}\n";
      }
    },
    alwaysProcess: true,
  };
}

/**
 * @param {string} match The whole line.
 * @param {string} apos1 Apostrophies before the word.
 * @param {string} sp1 Space before the word.
 * @param {string} word The word.
 * @param {string} sp2 Space after the word.
 * @param {string} apos2 Apostrophies after the word.
 * @param {string} sep
 * Separator between the bolded word and the rest of the line.
 *
 * It's a space (or missing) in the normal case, but can also be one of
 * a few punctuation marks (`,`, `!`, `?`) or anything opaque.
 *
 * @param {string} rest The rest of the line.
 */
function replaceSpaceOnHeadwordLine(
  match,
  apos1,
  sp1,
  word,
  sp2,
  apos2,
  sep,
  rest
) {
  // If the word itself contains syntax for italic or bold, something is
  // wrong. Just return the original string without making any changes.
  if (word.includes("''")) {
    return match;
  }

  /** Whether the word starts with an apostrophy. */
  var aStart = word[0] === "'";
  /** Whether the word end with an apostrophy. */
  var aEnd = word[word.length - 1] === "'";

  // 3: ''' is bold.
  // 5: ''''' is bold+italic.
  // 4: '''' is bold + apos as part of word (start or end).
  // 6: '''''' is bold+italic + apos as part of word (start or end).

  return (
    // Apostrophies before the word.
    (apos1 === "'''" || apos1 === "'''''"
      ? // apos1 doesn't contain a part of the word. Add a space if the
        // word starts with an apostrophy.
        apos1 + (aStart ? " " : "")
      : // The final ' of apos1 is part of the word. Split apos1 by
        // adding a space.
        apos1.slice(1) + " '" + sp1) +
    //
    // The word itself.
    word +
    //
    // Apostrophies after the word.
    (apos2 === "'''" || apos2 === "'''''"
      ? // apos2 doesn't contain a part of the word. Add a space if the
        // word ends with an apostrophy.
        (aEnd ? " " : "") + apos2
      : // The first ' of apos2 is part of the word. Split apos2 by
        // adding a space.
        sp2 + "' " + apos2.slice(1)) +
    //
    // The rest of the headword line. Add a space if necessary.
    (rest ? (sep || " ") + rest : sep)
  );
}

/** @type {string[] | undefined} */
var lcLangNamesCache;

/**
 * Process lang codes in the translation section, {{ö}} and {{ö+}}.
 * @param {Context} context
 */
function processTransLangCodes(context) {
  var langCodesByName = context.data.langCodesByLcName;
  if (!lcLangNamesCache) {
    lcLangNamesCache = Array.from(langCodesByName.keys());
  }
  var langNames = lcLangNamesCache;

  /** Language context. */
  var langCtx = {
    /** @type {LangNameLc | undefined} */
    _name: undefined,
    /** @type {LangNameLc | undefined} */
    _subName: undefined,
    /** @type {string | undefined} */
    _code: undefined,

    reset: function () {
      this._name = this._subName = this._code = undefined;
    },

    /** @param {string} x */
    set name(x) {
      this._subName = this._code = undefined;
      this._name = /** @type {LangNameLc} */ (x);
    },

    get hasName() {
      return !!this._name;
    },

    /** @param {string} x */
    set subName(x) {
      this._code = undefined;
      this._subName = /** @type {LangNameLc} */ (x);
    },

    /** @param {string} origCode */
    _getCode: function (origCode) {
      var name = this._name;
      var subName = this._subName;

      if (!name) {
        // `name` is guaranteed to exist - the case when `name` is missing is
        // handled elsewhere.
        throw new Error("name invariant");
      }

      var mainCode = langCodesByName.get(name);

      var subCode = subName && langCodesByName.get(subName);

      // If there's a valid code to use, use that.
      var mostSpecific = subCode || mainCode;
      if (mostSpecific) {
        return mostSpecific;
      }

      // Suppress any warnings by setting the param value to an empty string.
      var suppressedWarning = origCode === "";
      var suppress =
        "För att undertrycka varningen, lämna språkkodsparametern tom.";

      if (subName) {
        if (!suppressedWarning) {
          context.warn(
            'Kombinationen av språknamnen "%s" och "%s" i översättningsavsnitt är inte tillåten eftersom båda språknamnen saknar en motsvarande språkkod. %s',
            name,
            subName,
            suppress
          );
        }
      } else {
        // No `subName`.

        // If there's a suggested replacement, use that.
        var replacement = context.getLangReplacement(name);
        if (replacement) {
          context.warn(
            'Ogiltigt språknamn "%s" i översättningsavsnitt. Använd %s.',
            replacement.langName,
            replacement.replacement
          );
        } else if (!suppressedWarning) {
          // Suggest fuzzy match if it's good enough.
          var bestMatch = levenshteinBest(langNames, name);

          // Allowed fuzziness: 0.6.
          if (bestMatch.distance < 0.6) {
            context.warn(
              'Ogiltigt språknamn "%s" i översättningsavsnitt: Menade du "%s"? %s',
              bestMatch.search,
              bestMatch.match,
              suppress
            );
          } else {
            context.warn(
              'Ogiltigt språknamn "%s" i översättningsavsnitt. %s',
              /** @type {string} */ (name || subName),
              suppress
            );
          }
        }
      }

      return origCode || "";
    },

    /**
     * @this {typeof langCtx} For whatever reason, this must be given to make TypeScript happy.
     * @param {string} origCode
     */
    getCode: function (origCode) {
      if (!this._code) {
        this._code = this._getCode(origCode);
      }

      return this._code;
    },
  };

  context.wikitext = context.wikitext.replace(
    /((?:^|\n)====Översättningar====\n)([\s\S]+?)(\n==|$)/g,
    /**
     * @param {string} match
     * @param {string} curHeading
     * @param {string} section
     * @param {string} nextHeading
     */
    function (match, curHeading, section, nextHeading) {
      langCtx.reset();

      var didChange = false;
      var lines = section.split("\n");

      for (var i = 0; i < lines.length; i++) {
        var line = lines[i];
        var didLineChange = false;

        // Parse translation line.
        var parts = /^([*:]+)([^:]+):(.*)/.exec(line);

        if (!parts) {
          // If the line starts with a bullet or contains {{ö}}, parsing should
          // have succeeded.
          if (/^[*:]|{{ö\+?[|?]/.test(line)) {
            context.warn(
              'Rad i översättningsavsnittet ska börja med "*språknamn:". Hittade "%s".',
              line
            );
          }

          langCtx.reset();
          continue;
        }

        var bullet = parts[1];
        var lang = parts[2];
        var rest = parts[3];

        if (rest.includes("*")) {
          context.warn(
            'Rad i översättningsavsnittet har "*" i mitten, saknas en radbrytning? Hittade "%s".',
            line
          );
          langCtx.reset();
          continue;
        }

        // Normalize lines beginning with `:`, `:*`, and `::`.
        if (bullet.includes(":")) {
          bullet = bullet.length === 1 ? "*" : "**";
          didChange = didLineChange = true;
        }

        if (bullet === "*") {
          langCtx.name = lang;
        } else {
          // bullet === "**"
          if (!langCtx.hasName) {
            context.warn(
              'Rad i översättningsavsnittet ska börja med "*språknamn:". Hittade "%s".',
              line
            );
            continue;
          }

          langCtx.subName = lang;
        }

        regexForEach(rest, /{{ö\+?(?:\|([^}]*))?}}/g, function (match) {
          var paramsContext = context.getByReplacement(match[1]);
          var params = parseParams(paramsContext);
          var p1 = params.find(function (x) {
            return x.name === "1";
          });
          var minParams = 2;
          var maxParams = 4;
          var didParamsChange = handleFirstParamLangCode(
            context,
            match[0],
            params,
            minParams,
            maxParams,
            langCtx.getCode(p1 ? p1.val : "")
          );

          if (didParamsChange) {
            var paramsStr = params
              .map(function (p) {
                return p.full;
              })
              .join("|");

            // If we are able to change the params, the `paramsContext`
            // necessarily already exists. Update within that context.
            paramsContext.wikitext = paramsStr;
          }
        });

        if (didLineChange) {
          lines[i] = bullet + lang + ":" + rest;
        }
      }

      if (didChange) {
        return curHeading + lines.join("\n") + nextHeading;
      }

      return match;
    }
  );
}

/**
 * @param {Context} context
 * @param {string} [curLangCode] Only set when called recursively.
 *    - `undefined`: no valid language heading found yet
 *    - _other_: a valid lang code
 * @param {number} [depthIn]
 */
function processLangCodes(context, curLangCode, depthIn) {
  var depth = (depthIn || 0) + 1;

  if (depth > maxDepth) {
    context.error("För många nivåer av mallar i mallar.");
    return;
  }

  var langCodesByName = context.data.langCodesByUcfirstName;
  var langCodeTemplates = context.data.langCodeTemplates;

  // Process also inner templates when we know the lang code.
  if (curLangCode) {
    var replacements = context.wikitext.match(/\0\d+\0/g) || [];
    replacements.forEach(function (replacement) {
      var innerContext = context.getByReplacement(replacement);
      if (innerContext.creator === "inner template") {
        processLangCodes(innerContext, curLangCode, depth);
      }
    });
  }

  context.wikitext =
    // Pseudocode: / ==$1== | {{$2|$3}} /
    context.wikitext.replace(
      /^==([^=\n]+)==$|{{([^|}]+)\|?([^}]*)}}/gm,
      /**
       * @param {string} match
       * @param {string} heading
       * @param {string} template
       * @param {string} paramsOpaque
       */
      function (match, heading, template, paramsOpaque) {
        if (heading) {
          curLangCode = langCodesByName.get(
            /** @type {LangNameUcfirst} */ (heading)
          );
          return match;
        }

        // TODO Exception: {{uttal|en}} under ==Tvärspråkligt==
        // https://sv.wiktionary.org/w/index.php?title=echo&oldid=3718468
        if (template === "uttal" && curLangCode === "--") {
          return match;
        }

        var paramsContext = context.getByReplacement(paramsOpaque);

        // Process lang codes inside the opaque value.
        if (paramsContext) {
          processLangCodes(paramsContext, curLangCode, depth);
        } else {
          // If we thought that we had an opaque value, but didn't, abort
          // processing. E.g., maybe we thought that we got
          // {{template|OPAQUE}}, but got {{template|something}}.
          if (paramsOpaque) {
            return match;
          }
        }

        var paramInfo = langCodeTemplates.get(
          /** @type {Template} */ (template)
        );
        if (!paramInfo) {
          // The template doesn't use lang code.
          return match;
        }

        // We have a template that requires a lang code, but there was no
        // previous heading. Warn about this.
        if (curLangCode === undefined) {
          // Only warn if there haven't been previous warnings about headings.
          if (!context.warnOps.has("check headings")) {
            // Missing lang heading.
            context.warn("Språkrubrik saknas.");
          }
          return match;
        }

        var params = parseParams(paramsContext);

        var didChange = false;
        if (paramInfo === "språk") {
          // The {{tagg}} and {{homofoner}} templates.
          var param = params.find(function (p) {
            return p.name === "språk";
          });
          var shouldUseParam =
            curLangCode !== "sv" &&
            params.some(function (p) {
              return p.name === "1" || p.name === "kat";
            });

          if (!param && shouldUseParam) {
            params.push({
              implicit: false,
              name: "",
              val: "",
              full: "språk=" + curLangCode,
            });
            didChange = true;
          } else if (param && !shouldUseParam) {
            params = params.filter(function (p) {
              return p.name !== "språk";
            });
            didChange = true;
          } else if (param && param.val !== curLangCode) {
            param.full = "språk=" + curLangCode;
            didChange = true;
          }
        } else {
          var minParams = paramInfo[0];
          var maxParams = paramInfo[1];
          didChange = handleFirstParamLangCode(
            context,
            match,
            params,
            minParams,
            maxParams,
            curLangCode
          );
        }

        if (didChange) {
          var paramsStr = params
            .map(function (p) {
              return p.full;
            })
            .join("|");

          // If the params are opaque, update within that context, otherwise
          // create a new context for the params.
          if (paramsContext) {
            paramsContext.wikitext = paramsStr;
            return match;
          } else {
            paramsContext = context.createChild("lang codes", paramsStr);
            return "{{" + template + "|" + paramsContext.replacement + "}}";
          }
        } else {
          return match;
        }
      }
    );
}

/**
 * @typedef {{
 *  implicit: boolean;
 *  name: string;
 *  val: string;
 *  full: string;
 * }} ParsedParam
 *
 * @param {Context | undefined} paramsContext
 * @returns {ParsedParam[]}
 */
function parseParams(paramsContext) {
  // Split by `|`. Inner templates and links should already be opaque, so
  // all instances of `|` should be param separators.
  var implicitParamNo = 1;

  return !paramsContext
    ? []
    : paramsContext.wikitext.split("|").map(function (param) {
        var eq = param.indexOf("=");
        if (eq === -1) {
          return {
            implicit: true,
            name: "" + implicitParamNo++,
            val: param,
            full: param,
          };
        }

        return {
          implicit: false,
          name: param.substring(0, eq).trim(),
          val: param.substring(eq + 1).trim(),
          full: param,
        };
      });
}

/**
 * @param {Context} context
 * @param {string} fullTpl Full matched template (including opaque parts).
 * @param {ParsedParam[]} params These params will be mutated if needed.
 * @param {number} minParams
 * @param {number} maxParams
 * @param {string} langCode Expected lang code.
 * @returns {boolean} Whether any changes were made.
 */
function handleFirstParamLangCode(
  context,
  fullTpl,
  params,
  minParams,
  maxParams,
  langCode
) {
  var p1 = params.find(function (p) {
    return p.name === "1";
  });
  var numFound = params.reduce(function (max, p) {
    return +p.name ? Math.max(max, +p.name) : max;
  }, 0);
  var firstExplicitNumeric = params.find(function (p) {
    return !p.implicit && !Number.isNaN(+p.name);
  });

  if (firstExplicitNumeric) {
    // Missing or bad lang code.
    context.warn(
      "Mall med språkkod får inte ha explicit numerisk parameter %s: %s",
      firstExplicitNumeric.name + "=",
      fullTpl
    );
  } else {
    if (minParams <= numFound && numFound <= maxParams) {
      // Expected number of params.
      if (!p1) {
        // `p1` is guaranteed to exist.
        throw new Error("p1 invariant");
      }

      if (p1.val !== langCode) {
        if (
          // Can't add another param, so just fix it.
          numFound === maxParams ||
          // The first param is empty, so just fix it.
          p1.val === ""
        ) {
          p1.full = langCode;
          return true;
        } else {
          // Either the first param is missing or it is incorrect. Impossible to know which.
          context.warn(
            'Mall saknar språkkod "%s": %s',
            /** @type {string} */ (langCode),
            fullTpl
          );
        }
      }
    } else if (
      // Missing just one param...
      minParams === numFound + 1 &&
      // ...and the first param isn't the lang code
      (!p1 || p1.val !== langCode)
    ) {
      // Missing the lang param (presumably).
      params.unshift({
        implicit: true,
        name: "",
        val: "",
        full: langCode,
      });
      return true;
    } else {
      // Too few or too many params.
      context.warn(
        "Mall har för %s parametrar: %s",
        numFound < minParams ? "få" : "många",
        fullTpl
      );
    }
  }

  // No changes made.
  return false;
}

/**
 * @typedef {2 | 3 | 4} HeadingLevel
 *
 * @typedef {{
 *  ok: Record<HeadingLevel, string[]>;
 *  exceptions: Record<HeadingLevel, string[]>;
 *  eq: Record<HeadingLevel, string>;
 *  fuzziness: Record<HeadingLevel, number>;
 * }} AllHeadings
 * @type {AllHeadings | undefined}
 */
var allHeadings;

/** @param {Context} context */
function checkHeadings(context) {
  var regex = /^(=+)([^\n]+?)(=+)$/gm;

  if (!allHeadings) {
    allHeadings = {
      // Valid headings.
      ok: {
        2: /** @type {string[]} */ ([]).concat(
          Array.from(context.data.langCodesByUcfirstName.keys()),
          context.data.headings.h2
        ),
        3: Array.from(context.data.h3TemplatesByName.keys()),
        4: context.data.headings.h4,
      },
      // Grandfathered exceptions by heading level.
      exceptions: {
        2: context.data.headings.h2Exceptions,
        3: context.data.headings.h3Exceptions,
        4: context.data.headings.h4Exceptions,
      },
      // Equal signs.
      eq: { 2: "==", 3: "===", 4: "====" },

      // Sensitivity when fuzzy matching: 0=exact match, 1=match anything.
      fuzziness: {
        2: 0.6,
        3: 0.3,
        4: 0.6,
      },
    };
  }

  var ok = allHeadings.ok;
  var exceptions = allHeadings.exceptions;
  var eq = allHeadings.eq;
  var fuzziness = allHeadings.fuzziness;

  for (;;) {
    var match = regex.exec(context.wikitext);
    if (!match) break;

    var heading = match[0];
    var level = match[1] === match[3] ? match[1].length : 0;
    var name = match[2];

    if (level === 2 || level === 3 || level === 4) {
      // Valid heading or grandfathered exception.
      if (ok[level].includes(name) || exceptions[level].includes(name)) {
        continue;
      }
    }

    if (level === 2) {
      var replacement = context.getLangReplacement(name);
      if (replacement) {
        // Invalid heading.
        context.warn(
          'Ogiltigt språk "%s". Använd %s.',
          replacement.langName,
          replacement.replacement
        );
        continue;
      }
    }

    var didYouMean = undefined;

    // Find the correct heading level, if any.
    var correctLevel = ok[2].includes(name)
      ? 2
      : ok[3].includes(name)
      ? 3
      : ok[4].includes(name)
      ? 4
      : 0;

    if (correctLevel) {
      // Suggest correct level.
      didYouMean = eq[correctLevel] + name + eq[correctLevel];
    } else if (level === 2 || level === 3 || level === 4) {
      // Suggest fuzzy match of the same heading level, if it's good enough.
      var bestMatch = levenshteinBest(ok[level], name);

      if (bestMatch.distance < fuzziness[level]) {
        didYouMean = eq[level] + bestMatch.match + eq[level];
      }
    }

    // Invalid heading.
    if (didYouMean) {
      context.warn('Ogiltig rubrik "%s". Menade du "%s"?', heading, didYouMean);
    } else {
      context.warn('Ogiltig rubrik "%s".', heading);
    }
  }
}

/** @param {Context} context */
function correctTemplateNames(context) {
  var replacements = {
    c: "u",
    pl: "p",
    radera: "raderas",
    delete: "raderas",
    Delete: "raderas",
    verifiera: "verifieras",
  };
  context.wikitext = context.wikitext.replace(
    /{{(c|pl|radera|[dD]elete|verifiera)}}/g,
    function (_match, template) {
      return "{{" + replacements[template] + "}}";
    }
  );
}

/** @param {Context} context */
function removeTemplateParamSpace(context) {
  context.allContexts.forEach(function (ctx) {
    if (ctx.creator === "template param") {
      ctx.wikitext = ctx.wikitext
        .replace(/([^}]) $/, "$1")
        // Remove the spaces in:
        // - Initial ` param = `
        // - Subsequent ` | param = `
        .replace(/(?:^| ?([|])) ?(?:([^|=]+?) ?(=) ?)?/g, "$1$2$3");
    }
  });
}

/** @param {Context} context */
function checkTemplateParam(context) {
  context.allContexts.forEach(function (ctx) {
    if (ctx.creator === "template param") {
      var implicitParamNo = 1;
      /** @type {string | undefined} */
      var duplicate;
      /** @type {string[]} */
      var params = [];

      ctx.wikitext.split("|").some(function (x) {
        var eq = x.indexOf("=");
        var name = eq === -1 ? "" + implicitParamNo++ : x.slice(0, eq);
        if (params.includes(name)) {
          duplicate = name;
          return true;
        }

        params.push(name);
      });

      if (duplicate) {
        context.warn(
          "Ogiltig mallsyntax - upprepad parameter %s=: {{%s|%s}}",
          duplicate,
          ctx.creatorTemplate || "",
          ctx.wikitext
        );
      }
    }
  });
}

/**
 * @param {RegExp} re
 * @param {string} str
 */
function reTest(re, str) {
  re.lastIndex = 0;
  return re.test(str);
}

/**
 * Finds the best match in the array, using the Levenshtein algorithm. The distance returned is a value normalized by dividing by the length of the searched string.
 * @param {string[]} arr
 * @param {string} search
 */
function levenshteinBest(arr, search) {
  var best = arr.reduce(
    function (best, x) {
      var dist = levenshtein(search, x);
      return dist < best.dist ? { match: x, dist: dist } : best;
    },
    { dist: Infinity, match: "" }
  );
  return {
    distance: best.dist / search.length,
    match: best.match,
    search: search,
  };
}

/**
 * Calculate the Levenshtein distance between two strings.
 * Refactored from https://stackoverflow.com/a/18514751.
 * CC BY-SA 3.0 by Marco de Wit
 * @param {string} s1
 * @param {string} s2
 */
function levenshtein(s1, s2) {
  if (s1 === s2) {
    return 0;
  } else {
    var s1_len = s1.length,
      s2_len = s2.length;
    if (s1_len && s2_len) {
      var i1 = 0,
        i2 = 0,
        a,
        b,
        c,
        c2,
        row = [];
      while (i1 < s1_len) row[i1] = ++i1;
      while (i2 < s2_len) {
        c2 = s2.charCodeAt(i2);
        a = i2;
        ++i2;
        b = i2;
        for (i1 = 0; i1 < s1_len; ++i1) {
          c = a + (s1.charCodeAt(i1) === c2 ? 0 : 1);
          a = row[i1];
          b = b < a ? (b < c ? b + 1 : c) : a < c ? a + 1 : c;
          row[i1] = b;
        }
      }
      return /** @type {number} */ (b);
    } else {
      return s1_len + s2_len;
    }
  }
}