diff --git a/__tests__/unit/node/markdown/plugins/containers.test.ts b/__tests__/unit/node/markdown/plugins/containers.test.ts index d49d7c11f..ee71e2925 100644 --- a/__tests__/unit/node/markdown/plugins/containers.test.ts +++ b/__tests__/unit/node/markdown/plugins/containers.test.ts @@ -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 +) { 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(` + "

提示

+

内容

+
+
详细信息 +

内容

+
+

成功

+

内容

+
+ " + `) + }) + + 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(` + "

ROOT TIP

+

content

+
+ " + `) + }) + + test('explicit titles win over locale defaults', async () => { + expect( + await render('::: tip Custom Title\n内容\n:::', options, { + localeIndex: 'zh' + }) + ).toMatchInlineSnapshot(` + "

Custom Title

+

内容

+
+ " + `) + }) + + test('resolves alert titles for the active locale', async () => { + expect( + await render('> [!TIP]\n> 内容\n\n> [!SUCCESS]\n> 内容', options, { + localeIndex: 'zh' + }) + ).toMatchInlineSnapshot(` + "

提示

+

内容

+
+

成功

+

内容

+
+ " + `) + }) + + 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"') + }) +}) diff --git a/docs/en/guide/i18n.md b/docs/en/guide/i18n.md index 8a57bb78a..ac3fd22f4 100644 --- a/docs/en/guide/i18n.md +++ b/docs/en/guide/i18n.md @@ -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: diff --git a/docs/en/guide/markdown.md b/docs/en/guide/markdown.md index bf5f278b3..23f7e6a20 100644 --- a/docs/en/guide/markdown.md +++ b/docs/en/guide/markdown.md @@ -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: diff --git a/src/node/contentLoader.ts b/src/node/contentLoader.ts index e162d8583..1b80233ca 100644 --- a/src/node/contentLoader.ts +++ b/src/node/contentLoader.ts @@ -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( const md = await createMarkdownRenderer( config.srcDir, - config.markdown, + mergeMarkdownLocales(config.markdown, config.site.locales), config.site.base, config.logger, config.publicDir diff --git a/src/node/index.ts b/src/node/index.ts index 3c19a5660..a86f49b20 100644 --- a/src/node/index.ts +++ b/src/node/index.ts @@ -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' diff --git a/src/node/markdown/markdown.ts b/src/node/markdown/markdown.ts index 6fe1d6f14..dc7f8dd79 100644 --- a/src/node/markdown/markdown.ts +++ b/src/node/markdown/markdown.ts @@ -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 + /** + * Per-locale overrides for build-time markdown strings (container titles + * and the code copy button title), keyed by locale index. Populated + * automatically from `locales..markdown` in the site config - pass + * directly only when using `createMarkdownRenderer` standalone. + */ + locales?: Record /* ==================== Syntax Highlighting ==================== */ @@ -298,6 +305,22 @@ export interface MarkdownOptions extends MarkdownItAsyncOptions { export type MarkdownRenderer = MarkdownItAsync +// folds `locales..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)) diff --git a/src/node/markdown/plugins/containers.ts b/src/node/markdown/plugins/containers.ts index a466e2655..47124fefc 100644 --- a/src/node/markdown/plugins/containers.ts +++ b/src/node/markdown/plugins/containers.ts @@ -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 + locales?: Record } 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: () => `\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' ? `\n` : `\n`) }) } } -function resolveTitles(options?: ContainerOptions): Record { - const titles: Record = { - 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 + byLocale: Record> +} + +function titlesFor( + titles: LocaleTitles, + localeIndex: string | undefined +): Record { + 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 +): LocaleTitles { + const base: Record = {} + 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 { `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> = {} + 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 `
\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 `
${title}\n` const titleClass = @@ -172,11 +217,10 @@ const alertMarkerRE = /^\[!([\w-]+)\]([^\n\r]*)/ export const gitHubAlertsPlugin = ( md: MarkdownItAsync, - options?: ContainerOptions + options?: ContainerOptions, + { locales }: Pick = {} ) => { - 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() diff --git a/src/node/markdown/plugins/preWrapper.ts b/src/node/markdown/plugins/preWrapper.ts index 324cbabb0..b27aa1d8b 100644 --- a/src/node/markdown/plugins/preWrapper.ts +++ b/src/node/markdown/plugins/preWrapper.ts @@ -1,8 +1,13 @@ import type { MarkdownItAsync } from 'markdown-it-async' +import type { MarkdownEnv, MarkdownLocaleOptions } from '../../shared' export interface Options { codeCopyButtonTitle: string languageLabel?: Record + /** + * Per-locale overrides for the copy button title, keyed by locale index. + */ + locales?: Record } 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 ( `
` + - `` + + `` + `${label}` + fence(...args) + '
' diff --git a/src/node/markdownToVue.ts b/src/node/markdownToVue.ts index 98cf027ea..559d6ebf2 100644 --- a/src/node/markdownToVue.ts +++ b/src/node/markdownToVue.ts @@ -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 diff --git a/src/shared/shared.ts b/src/shared/shared.ts index 87635e1bb..ac5810d80 100644 --- a/src/shared/shared.ts +++ b/src/shared/shared.ts @@ -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) diff --git a/types/shared.d.ts b/types/shared.d.ts index d049f2e6a..4dda66fc4 100644 --- a/types/shared.d.ts +++ b/types/shared.d.ts @@ -208,9 +208,52 @@ export interface LocaleSpecificConfig { themeConfig?: DeepPartial } +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 +} + +/** + * Build-time markdown strings that can be overridden per locale. Set them + * under `locales..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 = Record< string, - LocaleSpecificConfig & { label: string; link?: string } + LocaleSpecificConfig & { + label: string + link?: string + markdown?: MarkdownLocaleOptions + } > export type AdditionalConfig =