feat(markdown): support per-locale markdown strings

Strings baked into pages by the markdown renderer can now be overridden
per locale via `locales.<index>.markdown` in the site config: container
titles (including GitHub-flavored alerts and custom containers) and the
code copy button title. Values resolve against `env.localeIndex` at
render time and fall back to the root markdown options, so the single
shared renderer keeps working unchanged.

close #4431

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
pull/5335/head
Divyansh Singh 1 day ago
parent e90178997a
commit faaa4a124e

@ -3,14 +3,19 @@ import {
disposeMdItInstance,
type MarkdownOptions
} from 'node/markdown/markdown'
import type { MarkdownEnv } from 'node/shared'
async function render(src: string, options: MarkdownOptions = {}) {
async function render(
src: string,
options: MarkdownOptions = {},
env?: Partial<MarkdownEnv>
) {
disposeMdItInstance()
const md = await createMarkdownRenderer('.', {
highlight: (code) => code,
...options
})
return md.renderAsync(src)
return md.renderAsync(src, env)
}
describe('node/markdown/plugins/containers', () => {
@ -479,3 +484,103 @@ describe('node/markdown/plugins/containers (github alerts)', () => {
`)
})
})
describe('node/markdown/plugins/containers (locales)', () => {
const options: MarkdownOptions = {
container: {
tipLabel: 'ROOT TIP',
customContainers: { success: 'SUCCESS' }
},
locales: {
zh: {
container: {
tipLabel: '提示',
detailsLabel: '详细信息',
customContainers: { success: '成功' }
}
}
}
}
test('resolves container titles for the active locale', async () => {
const src =
'::: tip\n内容\n:::\n\n::: details\n内容\n:::\n\n::: success\n内容\n:::'
expect(await render(src, options, { localeIndex: 'zh' }))
.toMatchInlineSnapshot(`
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default"></p>
<p></p>
</div>
<details class="details custom-block"><summary></summary>
<p></p>
</details>
<div class="success custom-block"><p class="custom-block-title custom-block-title-default"></p>
<p></p>
</div>
"
`)
})
test('falls back to root titles for other locales', async () => {
const src = '::: tip\ncontent\n:::'
const root = await render(src, options)
expect(await render(src, options, { localeIndex: 'es' })).toBe(root)
expect(root).toMatchInlineSnapshot(`
"<div class="tip custom-block"><p class="custom-block-title custom-block-title-default">ROOT TIP</p>
<p>content</p>
</div>
"
`)
})
test('explicit titles win over locale defaults', async () => {
expect(
await render('::: tip Custom Title\n内容\n:::', options, {
localeIndex: 'zh'
})
).toMatchInlineSnapshot(`
"<div class="tip custom-block"><p class="custom-block-title">Custom Title</p>
<p></p>
</div>
"
`)
})
test('resolves alert titles for the active locale', async () => {
expect(
await render('> [!TIP]\n> 内容\n\n> [!SUCCESS]\n> 内容', options, {
localeIndex: 'zh'
})
).toMatchInlineSnapshot(`
"<div class="tip custom-block github-alert"><p class="custom-block-title"></p>
<p></p>
</div>
<div class="success custom-block github-alert"><p class="custom-block-title"></p>
<p></p>
</div>
"
`)
})
test('rejects locale titles for unregistered containers', async () => {
await expect(
render('text', {
locales: { zh: { container: { customContainers: { nope: 'X' } } } }
})
).rejects.toThrow('is not registered in the root markdown config')
})
test('resolves the code copy button title for the active locale', async () => {
const src = '```js\nconst a = 1\n```'
const opts: MarkdownOptions = {
codeCopyButtonTitle: 'Copy Code',
locales: { zh: { codeCopyButtonTitle: '复制代码' } }
}
expect(await render(src, opts, { localeIndex: 'zh' })).toContain(
'title="复制代码"'
)
expect(await render(src, opts, { localeIndex: 'es' })).toContain(
'title="Copy Code"'
)
expect(await render(src, opts)).toContain('title="Copy Code"')
})
})

@ -57,6 +57,34 @@ Refer [`DefaultTheme.Config`](https://github.com/vuejs/vitepress/blob/main/types
**Pro tip:** Config file can be stored at `docs/.vitepress/config/index.ts` too. It might help you organize stuff by creating a configuration file per locale and then merge and export them from `index.ts`.
## Per-locale Markdown Strings
Strings baked into pages by the markdown renderer - the default titles of [custom containers](./markdown#custom-containers) and [GitHub-flavored alerts](./markdown#github-flavored-alerts), and the tooltip of the code copy button - can be overridden per locale with the `markdown` key of a locale entry:
```ts [docs/.vitepress/config.ts]
import { defineConfig } from 'vitepress'
export default defineConfig({
locales: {
root: { label: 'English', lang: 'en' },
zh: {
label: '简体中文',
lang: 'zh-Hans',
markdown: {
container: {
tipLabel: '提示',
warningLabel: '警告'
// ...the other labels, and titles of `customContainers`
},
codeCopyButtonTitle: '复制代码'
}
}
}
})
```
Values fall back to the root-level `markdown` options when a locale leaves them unset. Locale entries can only override the titles of containers registered at the root level - registering new containers per locale is not supported. Also note that since the markdown renderer is created once for the whole site, these can only be declared in the main config file, not in additional configs.
## Separate directory for each locale
The following is a perfectly fine structure:

@ -243,6 +243,8 @@ export default defineConfig({
})
```
On multilingual sites, these labels can also be overridden per locale - see [Per-locale Markdown Strings](./i18n#per-locale-markdown-strings).
### Registering New Containers
Beyond the built-in types, you can register additional containers by mapping their names to their default titles:

@ -4,7 +4,10 @@ import path from 'node:path'
import pMap from 'p-map'
import { normalizePath } from 'vite'
import type { SiteConfig } from './config'
import { createMarkdownRenderer } from './markdown/markdown'
import {
createMarkdownRenderer,
mergeMarkdownLocales
} from './markdown/markdown'
import type { Awaitable } from './shared'
import { glob, normalizeGlob, type GlobOptions } from './utils/glob'
@ -102,7 +105,7 @@ export function createContentLoader<T = ContentData[]>(
const md = await createMarkdownRenderer(
config.srcDir,
config.markdown,
mergeMarkdownLocales(config.markdown, config.site.locales),
config.site.base,
config.logger,
config.publicDir

@ -17,4 +17,9 @@ export * from './server'
export * from './utils/getGitTimestamp'
// shared types
export type { HeadConfig, Header, SiteData } from '../../types/shared'
export type {
HeadConfig,
Header,
MarkdownLocaleOptions,
SiteData
} from '../../types/shared'

@ -35,7 +35,7 @@ import mditCjkFriendly from 'markdown-it-cjk-friendly'
import path from 'node:path'
import type { BuiltinLanguage, BuiltinTheme, Highlighter } from 'shiki'
import type { Logger } from 'vite'
import type { Awaitable } from '../shared'
import type { Awaitable, LocaleConfig, MarkdownLocaleOptions } from '../shared'
import {
containerPlugin,
gitHubAlertsPlugin,
@ -91,6 +91,13 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
* @default { target: '_blank', rel: 'noreferrer' }
*/
externalLinks?: Record<string, string>
/**
* Per-locale overrides for build-time markdown strings (container titles
* and the code copy button title), keyed by locale index. Populated
* automatically from `locales.<index>.markdown` in the site config - pass
* directly only when using `createMarkdownRenderer` standalone.
*/
locales?: Record<string, MarkdownLocaleOptions>
/* ==================== Syntax Highlighting ==================== */
@ -298,6 +305,22 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions {
export type MarkdownRenderer = MarkdownItAsync
// folds `locales.<index>.markdown` entries from the site config into
// `MarkdownOptions.locales` so per-locale strings reach the renderer -
// site config entries win over directly passed ones
export function mergeMarkdownLocales(
options: MarkdownOptions = {},
locales?: LocaleConfig
): MarkdownOptions {
const entries = Object.entries(locales ?? {}).filter(([, l]) => l?.markdown)
if (!entries.length) return options
const merged = { ...options.locales }
for (const [index, { markdown }] of entries) {
merged[index] = { ...merged[index], ...markdown }
}
return { ...options, locales: merged }
}
// highlight is marked as any to avoid type conflicts with plugins expecting
// regular markdown-it which has sync highlight function. Such plugins will fail
// if they access highlight directly but currently none of the ones we use do that.
@ -350,7 +373,8 @@ export async function createMarkdownRenderer(
if (options.preWrapper !== false) {
preWrapperPlugin(md, {
codeCopyButtonTitle,
languageLabel: options.languageLabel
languageLabel: options.languageLabel,
locales: options.locales
})
// must be applied after preWrapper as it augments its output
lineNumberPlugin(md, options.lineNumbers)
@ -358,9 +382,12 @@ export async function createMarkdownRenderer(
if (options.snippet !== false) {
snippetPlugin(md, srcDir)
}
containerPlugin(md, options.container, options.attrs !== false)
containerPlugin(md, options.container, {
attrs: options.attrs !== false,
locales: options.locales
})
if (options.gfmAlerts !== false) {
gitHubAlertsPlugin(md, options.container)
gitHubAlertsPlugin(md, options.container, { locales: options.locales })
}
if (options.image !== false) {
imagePlugin(md, publicDir, normalizePluginOptions(options.image))

@ -2,32 +2,31 @@ import { container } from '@mdit/plugin-container'
import type { MarkdownItAsync } from 'markdown-it-async'
import type { RenderRule } from 'markdown-it/lib/renderer.mjs'
import type Token from 'markdown-it/lib/token.mjs'
import type { MarkdownEnv } from '../../shared'
import type {
ContainerOptions,
MarkdownEnv,
MarkdownLocaleOptions
} from '../../shared'
import { extractTitle } from './preWrapper'
export interface ContainerOptions {
infoLabel?: string
noteLabel?: string
tipLabel?: string
warningLabel?: string
dangerLabel?: string
detailsLabel?: string
importantLabel?: string
cautionLabel?: string
export type { ContainerOptions } from '../../shared'
export interface ContainerPluginOptions {
/**
* Whether fence-line `{...}` attributes are parsed. Mirrors the state of
* the `markdown.attrs` option.
*/
attrs?: boolean
/**
* Additional containers to register, mapping the container name to its
* default title. Registered names work both as `::: name` blocks and as
* GitHub-style alerts (`> [!NAME]`), and are styleable in the theme via
* `.custom-block.name`. Names must be lowercase and may only contain
* letters, numbers, hyphens, and underscores.
* Per-locale overrides for container titles, keyed by locale index.
*/
customContainers?: Record<string, string>
locales?: Record<string, MarkdownLocaleOptions | undefined>
}
export const containerPlugin = (
md: MarkdownItAsync,
options?: ContainerOptions,
attrs = true
{ attrs = true, locales }: ContainerPluginOptions = {}
) => {
md
// explicitly escape Vue syntax
@ -47,25 +46,47 @@ export const containerPlugin = (
closeRender: () => `</div></div>\n`
})
for (const [name, defaultTitle] of Object.entries(resolveTitles(options))) {
const titles = resolveTitlesByLocale(options, locales)
for (const name of Object.keys(titles.base)) {
md.use(container, {
name,
openRender: createOpenRender(md, name, defaultTitle, attrs),
openRender: createOpenRender(md, name, titles, attrs),
closeRender: () => (name === 'details' ? `</details>\n` : `</div>\n`)
})
}
}
function resolveTitles(options?: ContainerOptions): Record<string, string> {
const titles: Record<string, string> = {
tip: options?.tipLabel || 'TIP',
info: options?.infoLabel || 'INFO',
warning: options?.warningLabel || 'WARNING',
danger: options?.dangerLabel || 'DANGER',
details: options?.detailsLabel || 'Details',
note: options?.noteLabel || 'NOTE',
important: options?.importantLabel || 'IMPORTANT',
caution: options?.cautionLabel || 'CAUTION'
interface LocaleTitles {
base: Record<string, string>
byLocale: Record<string, Record<string, string>>
}
function titlesFor(
titles: LocaleTitles,
localeIndex: string | undefined
): Record<string, string> {
return (localeIndex && titles.byLocale[localeIndex]) || titles.base
}
const containerLabels = [
['tip', 'tipLabel', 'TIP'],
['info', 'infoLabel', 'INFO'],
['warning', 'warningLabel', 'WARNING'],
['danger', 'dangerLabel', 'DANGER'],
['details', 'detailsLabel', 'Details'],
['note', 'noteLabel', 'NOTE'],
['important', 'importantLabel', 'IMPORTANT'],
['caution', 'cautionLabel', 'CAUTION']
] as const
function resolveTitlesByLocale(
options?: ContainerOptions,
locales?: Record<string, MarkdownLocaleOptions | undefined>
): LocaleTitles {
const base: Record<string, string> = {}
for (const [name, key, defaultTitle] of containerLabels) {
base[name] = options?.[key] || defaultTitle
}
for (const [name, title] of Object.entries(options?.customContainers ?? {})) {
if (
@ -76,15 +97,38 @@ function resolveTitles(options?: ContainerOptions): Record<string, string> {
`Invalid custom container name: "${name}". Names must be lowercase ` +
`([a-z0-9_-]) and cannot be "v-pre", "raw", or "code-group".`
)
titles[name] = title
base[name] = title
}
return titles
const byLocale: Record<string, Record<string, string>> = {}
for (const [localeIndex, localeOptions] of Object.entries(locales ?? {})) {
const overrides = localeOptions?.container
if (!overrides) continue
const titles = { ...base }
for (const [name, key] of containerLabels) {
if (overrides[key]) titles[name] = overrides[key]
}
for (const [name, title] of Object.entries(
overrides.customContainers ?? {}
)) {
if (!Object.hasOwn(base, name))
throw new Error(
`Custom container "${name}" in locale "${localeIndex}" is not ` +
`registered in the root markdown config. Locales can only ` +
`override titles of existing containers.`
)
titles[name] = title
}
byLocale[localeIndex] = titles
}
return { base, byLocale }
}
function createOpenRender(
md: MarkdownItAsync,
name: string,
defaultTitle: string,
titles: LocaleTitles,
attrs: boolean
): RenderRule {
return (tokens, idx, _options, env: MarkdownEnv & { references?: any }) => {
@ -96,9 +140,10 @@ function createOpenRender(
token.attrJoin('class', `${name} custom-block`)
const renderedAttrs = md.renderer.renderAttrs(token)
if (noTitle) return `<div ${renderedAttrs}>\n`
const title = md.renderInline(info || defaultTitle, {
references: env.references
})
const title = md.renderInline(
info || titlesFor(titles, env.localeIndex)[name],
{ references: env.references }
)
if (name === 'details')
return `<details ${renderedAttrs}><summary>${title}</summary>\n`
const titleClass =
@ -172,11 +217,10 @@ const alertMarkerRE = /^\[!([\w-]+)\]([^\n\r]*)/
export const gitHubAlertsPlugin = (
md: MarkdownItAsync,
options?: ContainerOptions
options?: ContainerOptions,
{ locales }: Pick<ContainerPluginOptions, 'locales'> = {}
) => {
const titles = resolveTitles(options)
// details makes no sense as a blockquote-style alert
delete titles.details
const titles = resolveTitlesByLocale(options, locales)
md.core.ruler.after('block', 'github-alerts', (state) => {
const tokens = state.tokens
@ -200,8 +244,11 @@ export const gitHubAlertsPlugin = (
const match = firstContent.content.match(alertMarkerRE)
if (!match) continue
const type = match[1].toLowerCase()
if (!Object.hasOwn(titles, type)) continue
const title = match[2].trim() || titles[type]
// details makes no sense as a blockquote-style alert
if (type === 'details' || !Object.hasOwn(titles.base, type)) continue
const title =
match[2].trim() ||
titlesFor(titles, (state.env as MarkdownEnv)?.localeIndex)[type]
firstContent.content = firstContent.content
.slice(match[0].length)
.trimStart()

@ -1,8 +1,13 @@
import type { MarkdownItAsync } from 'markdown-it-async'
import type { MarkdownEnv, MarkdownLocaleOptions } from '../../shared'
export interface Options {
codeCopyButtonTitle: string
languageLabel?: Record<string, string>
/**
* Per-locale overrides for the copy button title, keyed by locale index.
*/
locales?: Record<string, MarkdownLocaleOptions | undefined>
}
export function preWrapperPlugin(md: MarkdownItAsync, options: Options) {
@ -13,7 +18,7 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) {
const fence = md.renderer.rules.fence!
md.renderer.rules.fence = (...args) => {
const [tokens, idx] = args
const [tokens, idx, , env] = args
const token = tokens[idx]
// remove title from info
@ -25,9 +30,14 @@ export function preWrapperPlugin(md: MarkdownItAsync, options: Options) {
const lang = extractLang(token.info)
const label = langLabel[lang.toLowerCase()] || lang.replace(/_/g, ' ')
const { localeIndex } = (env ?? {}) as MarkdownEnv
const codeCopyButtonTitle =
(localeIndex && options.locales?.[localeIndex]?.codeCopyButtonTitle) ||
options.codeCopyButtonTitle
return (
`<div class="language-${lang}${active}">` +
`<button title="${options.codeCopyButtonTitle}" class="copy"></button>` +
`<button title="${codeCopyButtonTitle}" class="copy"></button>` +
`<span class="lang">${label}</span>` +
fence(...args) +
'</div>'

@ -6,6 +6,7 @@ import { createDebug } from 'obug'
import type { SiteConfig } from './config'
import {
createMarkdownRenderer,
mergeMarkdownLocales,
type MarkdownOptions,
type MarkdownRenderer
} from './markdown/markdown'
@ -94,7 +95,7 @@ export async function createMarkdownToVueRenderFn(
) {
const md = await createMarkdownRenderer(
srcDir,
options,
mergeMarkdownLocales(options, siteConfig?.site.locales),
base,
siteConfig?.logger,
siteConfig?.publicDir

@ -10,12 +10,14 @@ export type {
AdditionalConfigDict,
AdditionalConfigLoader,
Awaitable,
ContainerOptions,
DefaultTheme,
HeadConfig,
Header,
LocaleConfig,
LocaleSpecificConfig,
MarkdownEnv,
MarkdownLocaleOptions,
PageData,
PageDataPayload,
Route,
@ -109,7 +111,8 @@ export function resolveSiteDataByRoute(
relativePath: string
): SiteData {
const localeIndex = getLocaleForPath(siteData, relativePath)
const { label, link, ...localeConfig } = siteData.locales[localeIndex] ?? {}
const { label, link, markdown, ...localeConfig } =
siteData.locales[localeIndex] ?? {}
Object.assign(localeConfig, { localeIndex })
const additionalConfigs = resolveAdditionalConfig(siteData, relativePath)

45
types/shared.d.ts vendored

@ -208,9 +208,52 @@ export interface LocaleSpecificConfig<ThemeConfig = any> {
themeConfig?: DeepPartial<ThemeConfig>
}
export interface ContainerOptions {
infoLabel?: string
noteLabel?: string
tipLabel?: string
warningLabel?: string
dangerLabel?: string
detailsLabel?: string
importantLabel?: string
cautionLabel?: string
/**
* Additional containers to register, mapping the container name to its
* default title. Registered names work both as `::: name` blocks and as
* GitHub-style alerts (`> [!NAME]`), and are styleable in the theme via
* `.custom-block.name`. Names must be lowercase and may only contain
* letters, numbers, hyphens, and underscores.
*
* In locale-specific overrides only the titles of containers registered
* at the root level can be changed - new names cannot be added there.
*/
customContainers?: Record<string, string>
}
/**
* Build-time markdown strings that can be overridden per locale. Set them
* under `locales.<index>.markdown` in the site config; values fall back to
* the root `markdown` options when a locale leaves them unset.
*/
export interface MarkdownLocaleOptions {
/**
* Locale-specific labels for containers (`::: tip` etc.) and
* GitHub-flavored alerts.
*/
container?: ContainerOptions
/**
* Locale-specific tooltip text for the copy button in code blocks.
*/
codeCopyButtonTitle?: string
}
export type LocaleConfig<ThemeConfig = any> = Record<
string,
LocaleSpecificConfig<ThemeConfig> & { label: string; link?: string }
LocaleSpecificConfig<ThemeConfig> & {
label: string
link?: string
markdown?: MarkdownLocaleOptions
}
>
export type AdditionalConfig<ThemeConfig = any> =

Loading…
Cancel
Save