npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

markdown-it-i18n

v2.1.0

Published

Plugin to support single-page multilingual format in markdown

Readme

markdown-it-i18n

npm GitHub License: MIT

A markdown-it plugin that provides a custom single-page multilingual format. Instead of maintaining separate files for each language, all translations live together in one file, making it much easier to spot and fix errors across languages simultaneously.

Why Use It?

If a document contains 7 languages, when you need to fix a mistake that exists in multiple languages, the traditional approach requires you to:

  1. Open 7 separate files (one per language).
  2. Find the corresponding line in each file.
  3. Make the same fix 7 times.

With the single-page format, translations sit right next to each other, so you can fix everything in one place.

Syntax

Line-Level Multilingual

For translating individual lines, use the @ prefix followed by a language tag, a space, and the translated content:

@en This is English content.
@zh 这是中文内容。
@ja これは日本語の内容です。

Block-Level Multilingual

For large blocks of content — such as entire paragraphs with complex formatting, tables, or admonition blocks — use @@@ delimiters:

@@@en
This is a large block of English content.
It can span multiple lines and include **formatting**.
@@@zh
这是一大段中文内容。
它可以跨越多行并包含**格式**。
@@@
  • Start a block with @@@ followed by a language tag.
  • End the entire multilingual block with a bare @@@ on its own line.

Escaping the Macro Syntax

If you need to render a literal @ followed by a language tag (e.g., in a sentence explaining how the plugin works), escape it with a backslash:

\@en This will render as literal "@en" text, not as a translation macro.
\@@@en
This entire block will be treated as literal text, not a multilingual block.
\@@@

Within code blocks, backslash pairs (\\) are automatically reduced to a single backslash, preserving the expected behavior for programming languages that use @ annotations (e.g., Java's @Deprecated).

Fallback Behavior

If a particular language is missing for a line or block, the plugin will automatically fall back to the source language (defaults to English). This means you only need to write translations for languages you know — missing ones will safely display the source language content instead.

The fallback is powered by a built-in Intl.LocaleMatcher (via @formatjs/intl-localematcher), which intelligently matches mutually intelligible languages even when an exact match is unavailable. For example:

| Requested Language | Available Languages | Matched Result | Reason | |---|---|---|---| | zh-HK | zh-CN, zh-TW | zh-TW | Both are Traditional Chinese and mutually intelligible. | | zh-MO | zh-CN, zh-HK, zh-TW | zh-HK | Cantonese-speaking regions map to each other. | | ms (Malay) | zh, en, vi, id, th, lo, my, km | id (Indonesian) | Malay and Indonesian are mutually intelligible. | | da (Danish) | zh, en, no, sv, nl | no (Norwegian) | Danish and Norwegian are mutually intelligible. | | fa (Persian) | ar (Arabic) | (falls back to rootLang) | These languages are not mutually intelligible, so no automatic matching occurs. |

This means you do not need to exhaustively list every possible locale variant in your document — the matcher handles it for you.

Intentionally Skipping Translations

In some cases, a phrase may be inherently redundant when translated literally into a certain language, resulting in unnatural or tautological text. For example, when a definition already encapsulates the meaning in the term itself:

  • "Beef is the meat of cattle." — In Chinese, the literal translation would be「牛肉是牛的肉」, which reads as an awkward tautology because「牛肉」already contains「牛」(cattle) and「肉」(meat).
  • 「西瓜是水分十足的瓜」 — The literal English rendering "Watermelon is a melon full of water." is similarly redundant since "watermelon" already embeds "water" and "melon".

For these situations, you can deliberately leave a translation empty. The plugin will omit the content entirely for that language, while still displaying it normally for languages where the phrase is not redundant:

@en Beef is the meat of cattle.
@zh

Or for blocks:

@@@en
Beef is the meat of cattle.
@@@zh
@@@

When the source language content itself is redundant and you want to drop it while keeping translations, leave the source language entry empty:

@en
@zh 西瓜是水分十足的瓜。

In this case, when rendering in English, the output will be empty (no content displayed), while rendering in Chinese will show「西瓜是水分十足的瓜。」.

The same applies to fallback logic — if both the current language and the source language entries are empty, the plugin produces an empty result, effectively removing the redundant segment from the output.

Important Rules

  • Do not mix line-level (@) and block-level (@@@) syntax for the same content — pick one approach and stay consistent.
  • The @ or @@@ markers must appear at the very beginning of a line (no indentation), and they should not be escaped unless you intend to render them literally.
  • For line-level translations, languages can appear in any order, but keeping them consistent (e.g., always @en first, then @zh) helps readability.
  • The language tag must be a valid Unicode BCP 47 Locale Identifier, which may contain letters, digits, and hyphens (e.g., fa, es-MX, zh-Hant-TW). Do not use underscores!
    • pt_BR
    • ☑️ pt-BR
  • Empty lines between multilingual groups signal separate content blocks and will affect list rendering in markdown-it.

Installation

# npm
npm install markdown-it-i18n

# yarn
yarn add markdown-it-i18n

# pnpm
pnpm add markdown-it-i18n

Usage

As a markdown-it Plugin

import MarkdownIt from "markdown-it";
import i18nMacroPlugin from "markdown-it-i18n";

const md = MarkdownIt();
md.use(i18nMacroPlugin);

// Render with the current language (defaults to "en"):
const html = md.render(markdownSource);

Specifying the Current Language

There are two ways to tell the plugin which language to render:

Option 1: Via the environment object (default — compatible with VitePress):

const html = md.render(markdownSource, { localeIndex: "zh" });

When using VitePress, the default locale "root" is automatically mapped to "en" for convenience.

Option 2: Via a custom getCurrentLang function:

const md = MarkdownIt();
md.use(i18nMacroPlugin, {
  getCurrentLang: (state) => state.env.currentLang, // Read from a custom env key.
});
const html = md.render(markdownSource, { currentLang: "ja" });

Language Aliases (langAlias)

If you prefer to use short, custom aliases instead of standard BCP 47 language tags in your markdown source, you can configure the langAlias option:

const md = MarkdownIt();
md.use(i18nMacroPlugin, {
  langAlias: {
    zhs: "zh-CN",       // Simplified Chinese
    zht: "zh-TW",       // Traditional Chinese
    // If you believe that both Spanish and Portuguese speakers can understand Italian, you can put them in an array.
    // Note that Spanish, Portuguese, and Italian are not actually mutually intelligible. This is just an example of manually using the array due to it cannot be recognized by the locale matcher.
    it: ["es", "pt"],
  },
});
const html = md.render(markdownSource, { localeIndex: "zh-TW" });

Now you can use your custom aliases in the markdown source:

<!-- Without `langAlias` -->
@en This is English content.
@zh-CN 这是简体中文内容。
@zh-TW 這是繁體中文內容。

<!-- With `langAlias` -->
@en This is English content.
@zhs 这是简体中文内容。
@zht 這是繁體中文內容。

Note: The langAlias option accepts an object mapping alias names to standard locale identifiers. Unlike earlier versions, it no longer supports a callback function. Thanks to the built-in locale matcher, you do not need to enumerate every possible locale variant — for example, { zht: "zh-TW" } is sufficient; it will automatically match zh-HK, zh-MO, and other mutually intelligible Traditional Chinese locales. Writing { zht: ["zh-TW", "zh-HK", "zh-MO", "zh-Hant"] } is redundant and unnecessary.

Please don't do this! Languages that are mutually intelligible will be automatically converted.

const md = MarkdownIt();
md.use(i18nMacroPlugin, {
  langAlias: {
    zht: ["zh-TW", "zh-HK", "zh-MO", "zh-Hant", "zh-Hant-TW", "zh-Hant-HK", "zh-Hant-MO", "zh-Hant-CN", "yue", "yue-HK", "yue-MO", "yue-Hant-HK", "yue-Hant-MO"],
  },
});
const html = md.render(markdownSource, { localeIndex: "zh-TW" });

Changing the Source (Root) Language

The source language is the fallback language used when a translation for the current language is missing. It defaults to "en":

md.use(i18nMacroPlugin, {
  rootLang: "fr", // Use French as the source language.
});

You can also pass a function to resolve the root language dynamically at render time:

md.use(i18nMacroPlugin, {
  rootLang: (state) => state.env.rootLang || "en",
});

Consistent Heading IDs (consistentHeadingId)

This option is disabled by default.

When enabled, this option ensures that the same heading title has a consistent HTML id attribute across all languages. This greatly improves the user experience when switching languages — the URL hash (anchor) remains the same, so the page automatically scrolls to the same heading position instead of resetting to the top because the target ID disappeared.

The ID is generated from the heading text in a specified language (defaults to English) and is appended using the {#id} attribute syntax, which is compatible with markdown-it-attrs / @mdit/plugin-attrs. You can use markdown-it-attrs / @mdit/plugin-attrs (or any other plugin with compatible syntax) to process these attributes, but it is not mandatory — the attributes are simply added to the markdown output.

const md = MarkdownIt();
md.use(i18nMacroPlugin, {
  consistentHeadingId: true,
});

Without consistentHeadingId — headings in different languages produce different IDs, causing broken anchors on language switch:

@en # This is English title    → <h1 id="this-is-english-title">
@zh # 这是中文标题              → <h1 id="这是中文标题">

With consistentHeadingId — all headings share the same ID based on English text, so anchors work in every language:

@en # This is English title    → <h1 id="this-is-english-title">
@zh # 这是中文标题              → <h1 id="this-is-english-title">

The actual output adds the {#id} syntax for downstream plugins to consume:

@en # This is English title {#this-is-english-title}
@zh # 这是中文标题 {#this-is-english-title}

Customizing the slug source language:

md.use(i18nMacroPlugin, {
  consistentHeadingId: {
    useLang: "fr", // Generate IDs from French text instead.
  },
});

You can also resolve useLang dynamically via a function in the plugin context:

md.use(i18nMacroPlugin, {
  consistentHeadingId: {
    useLang: (state) => state.env.headingIdLang || "en",
  },
});

Important notes on consistentHeadingId:

  • It only works with line-level multilingual syntax (@). Block-level (@@@) is not supported for this feature.
  • If a heading already has an explicitly specified ID (e.g., # Title {#my-custom-id}), the plugin will not override it — user-specified IDs are always preserved.

Plugin Options Reference

| Option | Type | Default | Description | |---|---|---|---| | getCurrentLang | (state: StateCore) => string \| undefined | state => state.env.localeIndex | Returns the target language for rendering. Compatible with VitePress by default. "root" maps to "en". | | langAlias | Record<string, string \| string[]> | {} | Maps custom alias names to standard BCP 47 locale identifiers. Values can be a single locale string or an array of locales. No longer supports callback functions. | | rootLang | string \| ((state: StateCore) => string) | "en" | The source root language. When the current language is missing a translation, the plugin automatically falls back to this language. | | consistentHeadingId | boolean \| { useLang?: string \| ((state: StateCore) => string) } | false | Ensures consistent heading IDs across all languages. Pass true for defaults (IDs based on English), or an object with useLang to customize the slug source language. |

Standalone Utility (No markdown-it Required)

You can use the parseI18nMacro utility function directly to convert markdown with i18n macros into standard single-language markdown — without markdown-it or any other markdown rendering plugin:

import { parseI18nMacro } from "markdown-it-i18n/utils";

const src = `@en This is English content.
@zh 这是中文内容。
@ja これは日本語の内容です。`;

const pureMarkdown = parseI18nMacro(src, "zh");
// Result: "这是中文内容。"

This is useful when you want to preprocess i18n-marked content before feeding it to any markdown parser, or when you are building a custom pipeline that only needs the raw single-language markdown text.

Function Signature

function parseI18nMacro(
  src: string,
  currentLang?: string,
  options?: {
    rootLang?: string;
    consistentHeadingId?: boolean | { useLang?: string };
    langAlias?: Record<string, string | string[]>;
    md?: MarkdownIt;
    env?: any;
  }
): string;

| Parameter | Type | Default | Description | |---|---|---|---| | src | string | (required) | The markdown source string containing i18n macro syntax. | | currentLang | string \| undefined | rootLang | The target language to extract. If omitted or undefined, falls back to rootLang. | | options.rootLang | string | "en" | The source root language. Used as fallback when currentLang is not found in the translation. | | options.consistentHeadingId | boolean \| { useLang?: string } | false | Ensures consistent heading IDs across languages. Only useLang as a string is supported (no function), since there is no markdown-it state in standalone mode. | | options.langAlias | Record<string, string \| string[]> | {} | Maps custom alias names to standard BCP 47 locale identifiers. | | options.md | MarkdownIt \| undefined | undefined | Optional markdown-it instance for more accurate heading content extraction (used by consistentHeadingId). If omitted, a simpler regex-based extraction is used. | | options.env | any | undefined | Optional environment variables passed to the markdown-it renderer (used by consistentHeadingId when md is provided). |

Breaking change from 1.x: The third parameter is now an options object instead of a plain rootLang string. To migrate, replace parseI18nMacro(src, lang, "zh") with parseI18nMacro(src, lang, { rootLang: "zh" }).

How It Works

The plugin registers a core rule called i18n_macro_preprocessor that runs before the block rule. At that stage, state.src is still a raw string, so the plugin preprocesses the i18n macro syntax and reduces it to a single-language markdown string. A second core rule handles backslash pair escaping within code blocks. All other markdown-it rules (block, inline, renderer) then process the result as usual. This means the plugin is fully compatible with any other markdown-it plugin and all standard markdown syntax.

License

MIT