feat(core): add `i18n.localeConfigs.translate` + skip translation process if `i18n/<locale>` dir doesn't exist (#11304)

This commit is contained in:
Sébastien Lorber 2025-07-07 14:55:46 +02:00 committed by GitHub
parent e0524a5c84
commit 1808945c1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
36 changed files with 1061 additions and 366 deletions

View File

@ -6,12 +6,13 @@
*/
import {jest} from '@jest/globals';
import path from 'path';
import * as path from 'path';
import {normalizePluginOptions} from '@docusaurus/utils-validation';
import {
posixPath,
getFileCommitDate,
LAST_UPDATE_FALLBACK,
getLocaleConfig,
} from '@docusaurus/utils';
import {DEFAULT_FUTURE_CONFIG} from '@docusaurus/core/src/server/configValidation';
import pluginContentBlog from '../index';
@ -22,6 +23,7 @@ import type {
I18n,
Validate,
MarkdownConfig,
I18nLocaleConfig,
} from '@docusaurus/types';
import type {
BlogPost,
@ -67,7 +69,10 @@ Available blog post titles are:\n- ${blogPosts
return post;
}
function getI18n(locale: string): I18n {
function getI18n(
locale: string,
localeConfigOptions?: Partial<I18nLocaleConfig>,
): I18n {
return {
currentLocale: locale,
locales: [locale],
@ -80,6 +85,8 @@ function getI18n(locale: string): I18n {
htmlLang: locale,
direction: 'ltr',
path: locale,
translate: true,
...localeConfigOptions,
},
},
};
@ -94,13 +101,14 @@ const BaseEditUrl = 'https://baseEditUrl.com/edit';
const getPlugin = async (
siteDir: string,
pluginOptions: Partial<PluginOptions> = {},
i18n: I18n = DefaultI18N,
i18nOptions: Partial<I18n> = {},
) => {
const i18n = {...DefaultI18N, ...i18nOptions};
const generatedFilesDir: string = path.resolve(siteDir, '.docusaurus');
const localizationDir = path.join(
siteDir,
i18n.path,
i18n.localeConfigs[i18n.currentLocale]!.path,
getLocaleConfig(i18n).path,
);
const siteConfig = {
title: 'Hello',
@ -153,20 +161,34 @@ const getBlogTags = async (
};
describe('blog plugin', () => {
it('getPathsToWatch returns right files', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const plugin = await getPlugin(siteDir);
const pathsToWatch = plugin.getPathsToWatch!();
const relativePathsToWatch = pathsToWatch.map((p) =>
posixPath(path.relative(siteDir, p)),
);
expect(relativePathsToWatch).toEqual([
'i18n/en/docusaurus-plugin-content-blog/authors.yml',
'i18n/en/docusaurus-plugin-content-blog/tags.yml',
'blog/tags.yml',
'i18n/en/docusaurus-plugin-content-blog/**/*.{md,mdx}',
'blog/**/*.{md,mdx}',
]);
describe('getPathsToWatch', () => {
async function runTest({translate}: {translate: boolean}) {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const plugin = await getPlugin(siteDir, {}, getI18n('en', {translate}));
const pathsToWatch = plugin.getPathsToWatch!();
return pathsToWatch.map((p) => posixPath(path.relative(siteDir, p)));
}
it('getPathsToWatch returns right files', async () => {
const relativePathsToWatch = await runTest({translate: true});
expect(relativePathsToWatch).toEqual([
'i18n/en/docusaurus-plugin-content-blog/authors.yml',
'i18n/en/docusaurus-plugin-content-blog/tags.yml',
// 'blog/authors.yml', // TODO weird that it's not here but tags is?
'blog/tags.yml',
'i18n/en/docusaurus-plugin-content-blog/**/*.{md,mdx}',
'blog/**/*.{md,mdx}',
]);
});
it('getPathsToWatch returns right files (translate: false)', async () => {
const relativePathsToWatch = await runTest({translate: false});
expect(relativePathsToWatch).toEqual([
'blog/authors.yml',
'blog/tags.yml',
'blog/**/*.{md,mdx}',
]);
});
});
it('builds a simple website', async () => {
@ -377,6 +399,54 @@ describe('blog plugin', () => {
});
});
describe('i18n config translate is wired properly', () => {
async function runTest({translate}: {translate: boolean}) {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const blogPosts = await getBlogPosts(
siteDir,
{},
getI18n('en', {translate}),
);
// Simpler to snapshot
return blogPosts.map((post) => post.metadata.title);
}
it('works with translate: false', async () => {
await expect(runTest({translate: false})).resolves.toMatchInlineSnapshot(`
[
"test links",
"MDX Blog Sample with require calls",
"Full Blog Sample",
"Complex Slug",
"Simple Slug",
"draft",
"unlisted",
"some heading",
"date-matter",
"Happy 1st Birthday Slash!",
]
`);
});
it('works with translate: true', async () => {
await expect(runTest({translate: true})).resolves.toMatchInlineSnapshot(`
[
"test links",
"MDX Blog Sample with require calls",
"Full Blog Sample",
"Complex Slug",
"Simple Slug",
"draft",
"unlisted",
"some heading",
"date-matter",
"Happy 1st Birthday Slash! (translated)",
]
`);
});
});
it('handles edit URL with editLocalizedBlogs: true', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const blogPosts = await getBlogPosts(siteDir, {editLocalizedFiles: true});
@ -390,6 +460,23 @@ describe('blog plugin', () => {
);
});
it('handles edit URL with editLocalizedBlogs: true and translate: false', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const blogPosts = await getBlogPosts(
siteDir,
{editLocalizedFiles: true},
getI18n('en', {translate: false}),
);
const localizedBlogPost = blogPosts.find(
(v) => v.metadata.title === 'Happy 1st Birthday Slash!',
)!;
expect(localizedBlogPost.metadata.editUrl).toBe(
`${BaseEditUrl}/blog/2018-12-14-Happy-First-Birthday-Slash.md`,
);
});
it('handles edit URL with editUrl function', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');

View File

@ -323,7 +323,9 @@ async function processBlogSourceFile(
} else if (typeof editUrl === 'string') {
const isLocalized = blogDirPath === contentPaths.contentPathLocalized;
const fileContentPath =
isLocalized && options.editLocalizedFiles
isLocalized &&
options.editLocalizedFiles &&
contentPaths.contentPathLocalized
? contentPaths.contentPathLocalized
: contentPaths.contentPath;

View File

@ -19,6 +19,7 @@ import {
getDataFilePath,
DEFAULT_PLUGIN_ID,
resolveMarkdownLinkPathname,
getLocaleConfig,
} from '@docusaurus/utils';
import {getTagsFilePathsToWatch} from '@docusaurus/utils-validation';
import {createMDXLoaderItem} from '@docusaurus/mdx-loader';
@ -73,13 +74,16 @@ export default async function pluginContentBlog(
const {baseUrl} = siteConfig;
const shouldTranslate = getLocaleConfig(context.i18n).translate;
const contentPaths: BlogContentPaths = {
contentPath: path.resolve(siteDir, options.path),
contentPathLocalized: getPluginI18nPath({
localizationDir,
pluginName: PluginName,
pluginId: options.id,
}),
contentPathLocalized: shouldTranslate
? getPluginI18nPath({
localizationDir,
pluginName: PluginName,
pluginId: options.id,
})
: undefined,
};
const pluginId = options.id ?? DEFAULT_PLUGIN_ID;

View File

@ -2005,17 +2005,6 @@ exports[`simple website content: route config 1`] = `
]
`;
exports[`simple website getPathToWatch 1`] = `
[
"sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/current/**/*.{md,mdx}",
"docs/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/current/tags.yml",
"docs/tags.yml",
"docs/**/_category_.{json,yml,yaml}",
]
`;
exports[`site with custom sidebar items generator sidebar is autogenerated according to a custom sidebarItemsGenerator 1`] = `
{
"defaultSidebar": [
@ -3327,23 +3316,6 @@ exports[`versioned website (community) content: route config 1`] = `
]
`;
exports[`versioned website (community) getPathToWatch 1`] = `
[
"community_sidebars.json",
"i18n/en/docusaurus-plugin-content-docs-community/current/**/*.{md,mdx}",
"community/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs-community/current/tags.yml",
"community/tags.yml",
"community/**/_category_.{json,yml,yaml}",
"community_versioned_sidebars/version-1.0.0-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs-community/version-1.0.0/**/*.{md,mdx}",
"community_versioned_docs/version-1.0.0/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs-community/version-1.0.0/tags.yml",
"community_versioned_docs/version-1.0.0/tags.yml",
"community_versioned_docs/version-1.0.0/**/_category_.{json,yml,yaml}",
]
`;
exports[`versioned website content 1`] = `
{
"description": "This is next version of bar.",
@ -5209,32 +5181,3 @@ exports[`versioned website content: withSlugs version sidebars 1`] = `
],
}
`;
exports[`versioned website getPathToWatch 1`] = `
[
"sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/current/**/*.{md,mdx}",
"docs/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/current/tags.yml",
"docs/tags.yml",
"docs/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-1.0.1-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.1/**/*.{md,mdx}",
"versioned_docs/version-1.0.1/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.1/tags.yml",
"versioned_docs/version-1.0.1/tags.yml",
"versioned_docs/version-1.0.1/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-1.0.0-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.0/**/*.{md,mdx}",
"versioned_docs/version-1.0.0/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.0/tags.yml",
"versioned_docs/version-1.0.0/tags.yml",
"versioned_docs/version-1.0.0/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-withSlugs-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-withSlugs/**/*.{md,mdx}",
"versioned_docs/version-withSlugs/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/version-withSlugs/tags.yml",
"versioned_docs/version-withSlugs/tags.yml",
"versioned_docs/version-withSlugs/**/_category_.{json,yml,yaml}",
]
`;

View File

@ -13,6 +13,7 @@ import {
posixPath,
DEFAULT_PLUGIN_ID,
LAST_UPDATE_FALLBACK,
getLocaleConfig,
} from '@docusaurus/utils';
import {getTagsFile} from '@docusaurus/utils-validation';
import {createSidebarsUtils} from '../sidebars/utils';
@ -842,7 +843,11 @@ describe('simple site', () => {
describe('versioned site', () => {
async function loadSite(
loadSiteOptions: {options: Partial<PluginOptions>; locale?: string} = {
loadSiteOptions: {
options?: Partial<PluginOptions>;
locale?: string;
translate?: boolean;
} = {
options: {},
},
) {
@ -851,6 +856,10 @@ describe('versioned site', () => {
siteDir,
locale: loadSiteOptions.locale,
});
// hacky but gets the job done
getLocaleConfig(context.i18n).translate = loadSiteOptions.translate ?? true;
const options = {
id: DEFAULT_PLUGIN_ID,
...DEFAULT_OPTIONS,
@ -1055,6 +1064,43 @@ describe('versioned site', () => {
});
});
it('versioned docs - translate: false', async () => {
const {version100TestUtils} = await loadSite({
translate: false,
});
// This doc is translated, but we still read the original
await version100TestUtils.testMeta(path.join('hello.md'), {
id: 'hello',
sourceDirName: '.',
permalink: '/docs/1.0.0/',
slug: '/',
title: 'hello',
description: 'Hello 1.0.0 !',
frontMatter: {
slug: '/',
tags: ['inlineTag-v1.0.0', 'globalTag-v1.0.0'],
},
version: '1.0.0',
source: '@site/versioned_docs/version-1.0.0/hello.md',
tags: [
{
description: undefined,
inline: true,
label: 'inlineTag-v1.0.0',
permalink: '/docs/1.0.0/tags/inline-tag-v-1-0-0',
},
{
description: 'globalTag-v1.0.0 description',
inline: false,
label: 'globalTag-v1.0.0 label',
permalink: '/docs/1.0.0/tags/globalTag-v1.0.0 permalink',
},
],
unlisted: false,
});
});
it('next doc slugs', async () => {
const {currentVersionTestUtils} = await loadSite();

View File

@ -18,7 +18,7 @@ import {
createConfigureWebpackUtils,
} from '@docusaurus/core/src/webpack/configure';
import {sortRoutes} from '@docusaurus/core/src/server/plugins/routeConfig';
import {posixPath} from '@docusaurus/utils';
import {getLocaleConfig, posixPath} from '@docusaurus/utils';
import {normalizePluginOptions} from '@docusaurus/utils-validation';
import {fromPartial} from '@total-typescript/shoehorn';
@ -219,9 +219,13 @@ describe('empty/no docs website', () => {
});
describe('simple website', () => {
async function loadSite() {
async function loadSite({translate}: {translate?: boolean} = {}) {
const siteDir = path.join(__dirname, '__fixtures__', 'simple-site');
const context = await loadContext({siteDir});
// hacky but gets the job done
getLocaleConfig(context.i18n).translate = translate ?? true;
const sidebarPath = path.join(siteDir, 'sidebars.json');
const options = validateOptions({
validate: normalizePluginOptions as Validate<Options, PluginOptions>,
@ -233,7 +237,20 @@ describe('simple website', () => {
const plugin = await pluginContentDocs(context, options);
const pluginContentDir = path.join(context.generatedFilesDir, plugin.name);
return {siteDir, context, sidebarPath, plugin, options, pluginContentDir};
return {
siteDir,
context,
sidebarPath,
plugin,
options,
pluginContentDir,
getPathsToWatch: () => {
const pathToWatch = plugin.getPathsToWatch!();
return pathToWatch.map((filepath) =>
posixPath(path.relative(siteDir, filepath)),
);
},
};
}
it('extendCli - docsVersion', async () => {
@ -242,8 +259,6 @@ describe('simple website', () => {
.spyOn(cliDocs, 'cliDocsVersionCommand')
.mockImplementation(async () => {});
const cli = new commander.Command();
// @ts-expect-error: in actual usage, we pass the static commander instead
// of the new command
plugin.extendCli!(cli);
cli.parse(['node', 'test', 'docs:version', '1.0.0']);
expect(mock).toHaveBeenCalledTimes(1);
@ -251,25 +266,48 @@ describe('simple website', () => {
mock.mockRestore();
});
it('getPathToWatch', async () => {
const {siteDir, plugin} = await loadSite();
describe('getPathToWatch', () => {
it('translate: false', async () => {
const {getPathsToWatch} = await loadSite({translate: false});
expect(getPathsToWatch()).toMatchInlineSnapshot(`
[
"sidebars.json",
"docs/**/*.{md,mdx}",
"docs/tags.yml",
"docs/**/_category_.{json,yml,yaml}",
]
`);
});
const pathToWatch = plugin.getPathsToWatch!();
const matchPattern = pathToWatch.map((filepath) =>
posixPath(path.relative(siteDir, filepath)),
);
expect(matchPattern).toMatchSnapshot();
expect(isMatch('docs/hello.md', matchPattern)).toBe(true);
expect(isMatch('docs/hello.mdx', matchPattern)).toBe(true);
expect(isMatch('docs/foo/bar.md', matchPattern)).toBe(true);
expect(isMatch('docs/hello.js', matchPattern)).toBe(false);
expect(isMatch('docs/super.mdl', matchPattern)).toBe(false);
expect(isMatch('docs/mdx', matchPattern)).toBe(false);
expect(isMatch('docs/headingAsTitle.md', matchPattern)).toBe(true);
expect(isMatch('sidebars.json', matchPattern)).toBe(true);
expect(isMatch('versioned_docs/hello.md', matchPattern)).toBe(false);
expect(isMatch('hello.md', matchPattern)).toBe(false);
expect(isMatch('super/docs/hello.md', matchPattern)).toBe(false);
it('translate: true', async () => {
const {getPathsToWatch} = await loadSite({translate: true});
expect(getPathsToWatch()).toMatchInlineSnapshot(`
[
"sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/current/**/*.{md,mdx}",
"docs/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/current/tags.yml",
"docs/tags.yml",
"docs/**/_category_.{json,yml,yaml}",
]
`);
});
it('returns patterns matching docs', async () => {
const {getPathsToWatch} = await loadSite();
const matchPattern = getPathsToWatch();
expect(isMatch('docs/hello.md', matchPattern)).toBe(true);
expect(isMatch('docs/hello.mdx', matchPattern)).toBe(true);
expect(isMatch('docs/foo/bar.md', matchPattern)).toBe(true);
expect(isMatch('docs/hello.js', matchPattern)).toBe(false);
expect(isMatch('docs/super.mdl', matchPattern)).toBe(false);
expect(isMatch('docs/mdx', matchPattern)).toBe(false);
expect(isMatch('docs/headingAsTitle.md', matchPattern)).toBe(true);
expect(isMatch('sidebars.json', matchPattern)).toBe(true);
expect(isMatch('versioned_docs/hello.md', matchPattern)).toBe(false);
expect(isMatch('hello.md', matchPattern)).toBe(false);
expect(isMatch('super/docs/hello.md', matchPattern)).toBe(false);
});
});
it('configureWebpack', async () => {
@ -329,9 +367,13 @@ describe('simple website', () => {
});
describe('versioned website', () => {
async function loadSite() {
async function loadSite({translate}: {translate?: boolean} = {}) {
const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site');
const context = await loadContext({siteDir});
// hacky but gets the job done
getLocaleConfig(context.i18n).translate = translate ?? true;
const sidebarPath = path.join(siteDir, 'sidebars.json');
const routeBasePath = 'docs';
const options = validateOptions({
@ -356,6 +398,13 @@ describe('versioned website', () => {
options,
plugin,
pluginContentDir,
getPathsToWatch: () => {
const pathToWatch = plugin.getPathsToWatch!();
return pathToWatch.map((filepath) =>
posixPath(path.relative(siteDir, filepath)),
);
},
};
}
@ -365,8 +414,6 @@ describe('versioned website', () => {
.spyOn(cliDocs, 'cliDocsVersionCommand')
.mockImplementation(async () => {});
const cli = new commander.Command();
// @ts-expect-error: in actual usage, we pass the static commander instead
// of the new command
plugin.extendCli!(cli);
cli.parse(['node', 'test', 'docs:version', '2.0.0']);
expect(mock).toHaveBeenCalledTimes(1);
@ -374,48 +421,101 @@ describe('versioned website', () => {
mock.mockRestore();
});
it('getPathToWatch', async () => {
const {siteDir, plugin} = await loadSite();
const pathToWatch = plugin.getPathsToWatch!();
const matchPattern = pathToWatch.map((filepath) =>
posixPath(path.relative(siteDir, filepath)),
);
expect(matchPattern).not.toEqual([]);
expect(matchPattern).toMatchSnapshot();
expect(isMatch('docs/hello.md', matchPattern)).toBe(true);
expect(isMatch('docs/hello.mdx', matchPattern)).toBe(true);
expect(isMatch('docs/foo/bar.md', matchPattern)).toBe(true);
expect(isMatch('sidebars.json', matchPattern)).toBe(true);
expect(isMatch('versioned_docs/version-1.0.0/hello.md', matchPattern)).toBe(
true,
);
expect(
isMatch('versioned_docs/version-1.0.0/foo/bar.md', matchPattern),
).toBe(true);
expect(
isMatch('versioned_sidebars/version-1.0.0-sidebars.json', matchPattern),
).toBe(true);
describe('getPathToWatch', () => {
it('translate: false', async () => {
const {getPathsToWatch} = await loadSite({translate: false});
expect(getPathsToWatch()).toMatchInlineSnapshot(`
[
"sidebars.json",
"docs/**/*.{md,mdx}",
"docs/tags.yml",
"docs/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-1.0.1-sidebars.json",
"versioned_docs/version-1.0.1/**/*.{md,mdx}",
"versioned_docs/version-1.0.1/tags.yml",
"versioned_docs/version-1.0.1/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-1.0.0-sidebars.json",
"versioned_docs/version-1.0.0/**/*.{md,mdx}",
"versioned_docs/version-1.0.0/tags.yml",
"versioned_docs/version-1.0.0/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-withSlugs-sidebars.json",
"versioned_docs/version-withSlugs/**/*.{md,mdx}",
"versioned_docs/version-withSlugs/tags.yml",
"versioned_docs/version-withSlugs/**/_category_.{json,yml,yaml}",
]
`);
});
// Non existing version
expect(
isMatch('versioned_docs/version-2.0.0/foo/bar.md', matchPattern),
).toBe(false);
expect(isMatch('versioned_docs/version-2.0.0/hello.md', matchPattern)).toBe(
false,
);
expect(
isMatch('versioned_sidebars/version-2.0.0-sidebars.json', matchPattern),
).toBe(false);
it('translate: true', async () => {
const {getPathsToWatch} = await loadSite({translate: true});
expect(getPathsToWatch()).toMatchInlineSnapshot(`
[
"sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/current/**/*.{md,mdx}",
"docs/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/current/tags.yml",
"docs/tags.yml",
"docs/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-1.0.1-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.1/**/*.{md,mdx}",
"versioned_docs/version-1.0.1/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.1/tags.yml",
"versioned_docs/version-1.0.1/tags.yml",
"versioned_docs/version-1.0.1/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-1.0.0-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.0/**/*.{md,mdx}",
"versioned_docs/version-1.0.0/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/version-1.0.0/tags.yml",
"versioned_docs/version-1.0.0/tags.yml",
"versioned_docs/version-1.0.0/**/_category_.{json,yml,yaml}",
"versioned_sidebars/version-withSlugs-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs/version-withSlugs/**/*.{md,mdx}",
"versioned_docs/version-withSlugs/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs/version-withSlugs/tags.yml",
"versioned_docs/version-withSlugs/tags.yml",
"versioned_docs/version-withSlugs/**/_category_.{json,yml,yaml}",
]
`);
});
expect(isMatch('docs/hello.js', matchPattern)).toBe(false);
expect(isMatch('docs/super.mdl', matchPattern)).toBe(false);
expect(isMatch('docs/mdx', matchPattern)).toBe(false);
expect(isMatch('hello.md', matchPattern)).toBe(false);
expect(isMatch('super/docs/hello.md', matchPattern)).toBe(false);
it('returns patterns matching docs', async () => {
const {getPathsToWatch} = await loadSite();
const matchPattern = getPathsToWatch();
expect(isMatch('docs/hello.md', matchPattern)).toBe(true);
expect(isMatch('docs/hello.mdx', matchPattern)).toBe(true);
expect(isMatch('docs/foo/bar.md', matchPattern)).toBe(true);
expect(isMatch('sidebars.json', matchPattern)).toBe(true);
expect(
isMatch('versioned_docs/version-1.0.0/hello.md', matchPattern),
).toBe(true);
expect(
isMatch('versioned_docs/version-1.0.0/foo/bar.md', matchPattern),
).toBe(true);
expect(
isMatch('versioned_sidebars/version-1.0.0-sidebars.json', matchPattern),
).toBe(true);
// Non existing version
expect(
isMatch('versioned_docs/version-2.0.0/foo/bar.md', matchPattern),
).toBe(false);
expect(
isMatch('versioned_docs/version-2.0.0/hello.md', matchPattern),
).toBe(false);
expect(
isMatch('versioned_sidebars/version-2.0.0-sidebars.json', matchPattern),
).toBe(false);
expect(isMatch('docs/hello.js', matchPattern)).toBe(false);
expect(isMatch('docs/super.mdl', matchPattern)).toBe(false);
expect(isMatch('docs/mdx', matchPattern)).toBe(false);
expect(isMatch('hello.md', matchPattern)).toBe(false);
expect(isMatch('super/docs/hello.md', matchPattern)).toBe(false);
});
});
it('content', async () => {
const {plugin, pluginContentDir} = await loadSite();
const {plugin, pluginContentDir} = await loadSite({translate: true});
const content = await plugin.loadContent!();
expect(content.loadedVersions).toHaveLength(4);
const [currentVersion, version101, version100, versionWithSlugs] =
@ -453,9 +553,13 @@ describe('versioned website', () => {
});
describe('versioned website (community)', () => {
async function loadSite() {
async function loadSite({translate}: {translate?: boolean} = {}) {
const siteDir = path.join(__dirname, '__fixtures__', 'versioned-site');
const context = await loadContext({siteDir});
// hacky but gets the job done
getLocaleConfig(context.i18n).translate = translate ?? true;
const sidebarPath = path.join(siteDir, 'community_sidebars.json');
const routeBasePath = 'community';
const pluginId = 'community';
@ -479,6 +583,13 @@ describe('versioned website (community)', () => {
options,
plugin,
pluginContentDir,
getPathsToWatch: () => {
const pathToWatch = plugin.getPathsToWatch!();
return pathToWatch.map((filepath) =>
posixPath(path.relative(siteDir, filepath)),
);
},
};
}
@ -488,8 +599,6 @@ describe('versioned website (community)', () => {
.spyOn(cliDocs, 'cliDocsVersionCommand')
.mockImplementation(async () => {});
const cli = new commander.Command();
// @ts-expect-error: in actual usage, we pass the static commander instead
// of the new command
plugin.extendCli!(cli);
cli.parse(['node', 'test', `docs:version:${pluginId}`, '2.0.0']);
expect(mock).toHaveBeenCalledTimes(1);
@ -497,34 +606,67 @@ describe('versioned website (community)', () => {
mock.mockRestore();
});
it('getPathToWatch', async () => {
const {siteDir, plugin} = await loadSite();
const pathToWatch = plugin.getPathsToWatch!();
const matchPattern = pathToWatch.map((filepath) =>
posixPath(path.relative(siteDir, filepath)),
);
expect(matchPattern).not.toEqual([]);
expect(matchPattern).toMatchSnapshot();
expect(isMatch('community/team.md', matchPattern)).toBe(true);
expect(
isMatch('community_versioned_docs/version-1.0.0/team.md', matchPattern),
).toBe(true);
describe('getPathToWatch', () => {
it('translate: false', async () => {
const {getPathsToWatch} = await loadSite({translate: false});
expect(getPathsToWatch()).toMatchInlineSnapshot(`
[
"community_sidebars.json",
"community/**/*.{md,mdx}",
"community/tags.yml",
"community/**/_category_.{json,yml,yaml}",
"community_versioned_sidebars/version-1.0.0-sidebars.json",
"community_versioned_docs/version-1.0.0/**/*.{md,mdx}",
"community_versioned_docs/version-1.0.0/tags.yml",
"community_versioned_docs/version-1.0.0/**/_category_.{json,yml,yaml}",
]
`);
});
// Non existing version
expect(
isMatch('community_versioned_docs/version-2.0.0/team.md', matchPattern),
).toBe(false);
expect(
isMatch(
'community_versioned_sidebars/version-2.0.0-sidebars.json',
matchPattern,
),
).toBe(false);
it('translate: true', async () => {
const {getPathsToWatch} = await loadSite({translate: true});
expect(getPathsToWatch()).toMatchInlineSnapshot(`
[
"community_sidebars.json",
"i18n/en/docusaurus-plugin-content-docs-community/current/**/*.{md,mdx}",
"community/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs-community/current/tags.yml",
"community/tags.yml",
"community/**/_category_.{json,yml,yaml}",
"community_versioned_sidebars/version-1.0.0-sidebars.json",
"i18n/en/docusaurus-plugin-content-docs-community/version-1.0.0/**/*.{md,mdx}",
"community_versioned_docs/version-1.0.0/**/*.{md,mdx}",
"i18n/en/docusaurus-plugin-content-docs-community/version-1.0.0/tags.yml",
"community_versioned_docs/version-1.0.0/tags.yml",
"community_versioned_docs/version-1.0.0/**/_category_.{json,yml,yaml}",
]
`);
});
expect(isMatch('community/team.js', matchPattern)).toBe(false);
expect(
isMatch('community_versioned_docs/version-1.0.0/team.js', matchPattern),
).toBe(false);
it('returns patterns matching docs', async () => {
const {getPathsToWatch} = await loadSite();
const matchPattern = getPathsToWatch();
expect(isMatch('community/team.md', matchPattern)).toBe(true);
expect(
isMatch('community_versioned_docs/version-1.0.0/team.md', matchPattern),
).toBe(true);
// Non existing version
expect(
isMatch('community_versioned_docs/version-2.0.0/team.md', matchPattern),
).toBe(false);
expect(
isMatch(
'community_versioned_sidebars/version-2.0.0-sidebars.json',
matchPattern,
),
).toBe(false);
expect(isMatch('community/team.js', matchPattern)).toBe(false);
expect(
isMatch('community_versioned_docs/version-1.0.0/team.js', matchPattern),
).toBe(false);
});
});
it('content', async () => {

View File

@ -8,7 +8,7 @@
import fs from 'fs-extra';
import path from 'path';
import logger from '@docusaurus/logger';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils';
import {DEFAULT_PLUGIN_ID, getLocaleConfig} from '@docusaurus/utils';
import {
getVersionsFilePath,
getVersionDocsDirPath,
@ -89,7 +89,7 @@ async function cliDocsVersionCommand(
const localizationDir = path.resolve(
siteDir,
i18n.path,
i18n.localeConfigs[locale]!.path,
getLocaleConfig(i18n, locale).path,
);
// Copy docs files.
const docsDir =

View File

@ -196,7 +196,9 @@ async function doProcessDocMetadata({
locale: context.i18n.currentLocale,
});
} else if (typeof options.editUrl === 'string') {
const isLocalized = contentPath === versionMetadata.contentPathLocalized;
const isLocalized =
typeof versionMetadata.contentPathLocalized !== 'undefined' &&
contentPath === versionMetadata.contentPathLocalized;
const baseVersionEditUrl =
isLocalized && options.editLocalizedFiles
? versionMetadata.editUrlLocalized

View File

@ -5,7 +5,7 @@
* LICENSE file in the root directory of this source tree.
*/
import path from 'path';
import * as path from 'path';
import {fromPartial} from '@total-typescript/shoehorn';
import {DEFAULT_PARSE_FRONT_MATTER} from '@docusaurus/utils/src';
import {readVersionsMetadata} from '../version';
@ -19,7 +19,7 @@ const DefaultI18N: I18n = {
currentLocale: 'en',
locales: ['en'],
defaultLocale: 'en',
localeConfigs: {},
localeConfigs: {en: fromPartial({translate: true})},
};
async function siteFixture(fixture: string) {

View File

@ -6,27 +6,44 @@
*/
import {jest} from '@jest/globals';
import path from 'path';
import * as path from 'path';
import {DEFAULT_PLUGIN_ID} from '@docusaurus/utils';
import {readVersionsMetadata} from '../version';
import {DEFAULT_OPTIONS} from '../../options';
import type {I18n, LoadContext} from '@docusaurus/types';
import type {I18n, I18nLocaleConfig, LoadContext} from '@docusaurus/types';
import type {
PluginOptions,
VersionMetadata,
} from '@docusaurus/plugin-content-docs';
const DefaultI18N: I18n = {
path: 'i18n',
currentLocale: 'en',
locales: ['en'],
defaultLocale: 'en',
localeConfigs: {},
};
function getI18n(
locale: string,
localeConfigOptions?: Partial<I18nLocaleConfig>,
): I18n {
return {
path: 'i18n',
currentLocale: locale,
locales: ['en'],
defaultLocale: locale,
localeConfigs: {
[locale]: {
path: locale,
label: locale,
translate: true,
calendar: 'calendar',
htmlLang: locale,
direction: 'rtl',
...localeConfigOptions,
},
},
};
}
const DefaultI18N: I18n = getI18n('en');
describe('readVersionsMetadata', () => {
describe('simple site', () => {
async function loadSite() {
async function loadSite({context}: {context?: Partial<LoadContext>} = {}) {
const simpleSiteDir = path.resolve(
path.join(__dirname, '../../__tests__/__fixtures__', 'simple-site'),
);
@ -39,6 +56,7 @@ describe('readVersionsMetadata', () => {
baseUrl: '/',
i18n: DefaultI18N,
localizationDir: path.join(simpleSiteDir, 'i18n/en'),
...context,
} as LoadContext;
const vCurrent: VersionMetadata = {
@ -73,6 +91,26 @@ describe('readVersionsMetadata', () => {
expect(versionsMetadata).toEqual([vCurrent]);
});
it('works with translate: false', async () => {
const {defaultOptions, defaultContext, vCurrent} = await loadSite({
context: {
i18n: getI18n('en', {translate: false}),
},
});
const versionsMetadata = await readVersionsMetadata({
options: defaultOptions,
context: defaultContext,
});
expect(versionsMetadata).toEqual([
{
...vCurrent,
contentPathLocalized: undefined,
},
]);
});
it('works with base url', async () => {
const {defaultOptions, defaultContext, vCurrent} = await loadSite();
@ -188,7 +226,7 @@ describe('readVersionsMetadata', () => {
});
describe('versioned site, pluginId=default', () => {
async function loadSite() {
async function loadSite({context}: {context?: Partial<LoadContext>} = {}) {
const versionedSiteDir = path.resolve(
path.join(__dirname, '../../__tests__/__fixtures__', 'versioned-site'),
);
@ -202,6 +240,7 @@ describe('readVersionsMetadata', () => {
baseUrl: '/',
i18n: DefaultI18N,
localizationDir: path.join(versionedSiteDir, 'i18n/en'),
...context,
} as LoadContext;
const vCurrent: VersionMetadata = {
@ -436,6 +475,54 @@ describe('readVersionsMetadata', () => {
]);
});
it('works with editUrl and translate=false', async () => {
const {defaultOptions, defaultContext, vCurrent, v101, v100, vWithSlugs} =
await loadSite({
context: {
i18n: getI18n('en', {translate: false}),
},
});
const versionsMetadata = await readVersionsMetadata({
options: {
...defaultOptions,
editUrl: 'https://github.com/facebook/docusaurus/edit/main/website/',
},
context: defaultContext,
});
expect(versionsMetadata).toEqual([
{
...vCurrent,
contentPathLocalized: undefined,
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/docs',
editUrlLocalized: undefined,
},
{
...v101,
contentPathLocalized: undefined,
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/versioned_docs/version-1.0.1',
editUrlLocalized: undefined,
},
{
...v100,
contentPathLocalized: undefined,
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/versioned_docs/version-1.0.0',
editUrlLocalized: undefined,
},
{
...vWithSlugs,
contentPathLocalized: undefined,
editUrl:
'https://github.com/facebook/docusaurus/edit/main/website/versioned_docs/version-withSlugs',
editUrlLocalized: undefined,
},
]);
});
it('works with editUrl and editCurrentVersion=true', async () => {
const {defaultOptions, defaultContext, vCurrent, v101, v100, vWithSlugs} =
await loadSite();

View File

@ -7,7 +7,11 @@
import path from 'path';
import fs from 'fs-extra';
import {getPluginI18nPath, DEFAULT_PLUGIN_ID} from '@docusaurus/utils';
import {
getPluginI18nPath,
getLocaleConfig,
DEFAULT_PLUGIN_ID,
} from '@docusaurus/utils';
import {
VERSIONS_JSON_FILE,
VERSIONED_DOCS_DIR,
@ -186,11 +190,16 @@ export async function getVersionMetadataPaths({
>
> {
const isCurrent = versionName === CURRENT_VERSION_NAME;
const contentPathLocalized = getDocsDirPathLocalized({
localizationDir: context.localizationDir,
pluginId: options.id,
versionName,
});
const shouldTranslate = getLocaleConfig(context.i18n).translate;
const contentPathLocalized = shouldTranslate
? getDocsDirPathLocalized({
localizationDir: context.localizationDir,
pluginId: options.id,
versionName,
})
: undefined;
const contentPath = isCurrent
? path.resolve(context.siteDir, options.path)
: getVersionDocsDirPath(context.siteDir, options.id, versionName);

View File

@ -50,33 +50,47 @@ function getVersionEditUrls({
return {editUrl: undefined, editUrlLocalized: undefined};
}
const editDirPath = options.editCurrentVersion ? options.path : contentPath;
const editDirPathLocalized = options.editCurrentVersion
? getDocsDirPathLocalized({
localizationDir: context.localizationDir,
versionName: CURRENT_VERSION_NAME,
pluginId: options.id,
})
: contentPathLocalized;
// Intermediate var just to please TS not narrowing to "string"
const editUrlOption = options.editUrl;
const versionPathSegment = posixPath(
path.relative(context.siteDir, path.resolve(context.siteDir, editDirPath)),
);
const versionPathSegmentLocalized = posixPath(
path.relative(
context.siteDir,
path.resolve(context.siteDir, editDirPathLocalized),
),
);
const getEditUrl = () => {
const editDirPath = options.editCurrentVersion ? options.path : contentPath;
const editUrl = normalizeUrl([options.editUrl, versionPathSegment]);
return normalizeUrl([
editUrlOption,
posixPath(
path.relative(
context.siteDir,
path.resolve(context.siteDir, editDirPath),
),
),
]);
};
const editUrlLocalized = normalizeUrl([
options.editUrl,
versionPathSegmentLocalized,
]);
const getEditUrlLocalized = () => {
if (!contentPathLocalized) {
return undefined;
}
const editDirPathLocalized = options.editCurrentVersion
? getDocsDirPathLocalized({
localizationDir: context.localizationDir,
versionName: CURRENT_VERSION_NAME,
pluginId: options.id,
})
: contentPathLocalized;
return {editUrl, editUrlLocalized};
return normalizeUrl([
editUrlOption,
posixPath(
path.relative(
context.siteDir,
path.resolve(context.siteDir, editDirPathLocalized),
),
),
]);
};
return {editUrl: getEditUrl(), editUrlLocalized: getEditUrlLocalized()};
}
/**

View File

@ -70,6 +70,76 @@ exports[`docusaurus-plugin-content-pages loads simple pages 1`] = `
]
`;
exports[`docusaurus-plugin-content-pages loads simple pages with french translations (translate: false) 1`] = `
[
{
"permalink": "/fr/",
"source": "@site/src/pages/index.js",
"type": "jsx",
},
{
"permalink": "/fr/typescript",
"source": "@site/src/pages/typescript.tsx",
"type": "jsx",
},
{
"description": "Markdown index page",
"editUrl": undefined,
"frontMatter": {
"custom_frontMatter": "added by parseFrontMatter",
},
"lastUpdatedAt": undefined,
"lastUpdatedBy": undefined,
"permalink": "/fr/hello/",
"source": "@site/src/pages/hello/index.md",
"title": "Index",
"type": "mdx",
"unlisted": false,
},
{
"description": "my MDX page",
"editUrl": undefined,
"frontMatter": {
"custom_frontMatter": "added by parseFrontMatter",
"description": "my MDX page",
"slug": "/custom-mdx/slug",
"title": "MDX page",
},
"lastUpdatedAt": undefined,
"lastUpdatedBy": undefined,
"permalink": "/fr/custom-mdx/slug",
"source": "@site/src/pages/hello/mdxPage.mdx",
"title": "MDX page",
"type": "mdx",
"unlisted": false,
},
{
"permalink": "/fr/hello/translatedJs",
"source": "@site/src/pages/hello/translatedJs.js",
"type": "jsx",
},
{
"description": "translated Markdown page",
"editUrl": undefined,
"frontMatter": {
"custom_frontMatter": "added by parseFrontMatter",
},
"lastUpdatedAt": undefined,
"lastUpdatedBy": undefined,
"permalink": "/fr/hello/translatedMd",
"source": "@site/src/pages/hello/translatedMd.md",
"title": undefined,
"type": "mdx",
"unlisted": false,
},
{
"permalink": "/fr/hello/world",
"source": "@site/src/pages/hello/world.js",
"type": "jsx",
},
]
`;
exports[`docusaurus-plugin-content-pages loads simple pages with french translations 1`] = `
[
{

View File

@ -47,6 +47,25 @@ describe('docusaurus-plugin-content-pages', () => {
expect(pagesMetadata).toMatchSnapshot();
});
it('loads simple pages with french translations (translate: false)', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const context = await loadContext({siteDir, locale: 'fr'});
context.i18n.localeConfigs.fr.translate = false;
const plugin = await pluginContentPages(
context,
validateOptions({
validate: normalizePluginOptions,
options: {
path: 'src/pages',
},
}),
);
const pagesMetadata = await plugin.loadContent!();
expect(pagesMetadata).toMatchSnapshot();
});
it('loads simple pages with last update', async () => {
const siteDir = path.join(__dirname, '__fixtures__', 'website');
const context = await loadContext({siteDir});

View File

@ -21,10 +21,12 @@ import {
getEditUrl,
posixPath,
getPluginI18nPath,
getContentPathList,
getLocaleConfig,
type ContentPaths,
} from '@docusaurus/utils';
import {validatePageFrontMatter} from './frontMatter';
import type {LoadContext} from '@docusaurus/types';
import type {PagesContentPaths} from './types';
import type {
PluginOptions,
Metadata,
@ -37,29 +39,29 @@ export function createPagesContentPaths({
}: {
context: LoadContext;
options: PluginOptions;
}): PagesContentPaths {
}): ContentPaths {
const {siteDir, localizationDir} = context;
const shouldTranslate = getLocaleConfig(context.i18n).translate;
return {
contentPath: path.resolve(siteDir, options.path),
contentPathLocalized: getPluginI18nPath({
localizationDir,
pluginName: 'docusaurus-plugin-content-pages',
pluginId: options.id,
}),
contentPathLocalized: shouldTranslate
? getPluginI18nPath({
localizationDir,
pluginName: 'docusaurus-plugin-content-pages',
pluginId: options.id,
})
: undefined,
};
}
export function getContentPathList(contentPaths: PagesContentPaths): string[] {
return [contentPaths.contentPathLocalized, contentPaths.contentPath];
}
const isMarkdownSource = (source: string) =>
source.endsWith('.md') || source.endsWith('.mdx');
type LoadContentParams = {
context: LoadContext;
options: PluginOptions;
contentPaths: PagesContentPaths;
contentPaths: ContentPaths;
};
export async function loadPagesContent(
@ -158,7 +160,9 @@ async function processPageSourceFile(
} else if (typeof editUrl === 'string') {
const isLocalized = pagesDirPath === contentPaths.contentPathLocalized;
const fileContentPath =
isLocalized && options.editLocalizedFiles
isLocalized &&
options.editLocalizedFiles &&
contentPaths.contentPathLocalized
? contentPaths.contentPathLocalized
: contentPaths.contentPath;

View File

@ -12,15 +12,12 @@ import {
docuHash,
addTrailingPathSeparator,
createAbsoluteFilePathMatcher,
getContentPathList,
DEFAULT_PLUGIN_ID,
} from '@docusaurus/utils';
import {createMDXLoaderRule} from '@docusaurus/mdx-loader';
import {createAllRoutes} from './routes';
import {
createPagesContentPaths,
getContentPathList,
loadPagesContent,
} from './content';
import {createPagesContentPaths, loadPagesContent} from './content';
import type {LoadContext, Plugin} from '@docusaurus/types';
import type {
PluginOptions,

View File

@ -1,11 +0,0 @@
/**
* Copyright (c) Facebook, Inc. and its affiliates.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*/
export type PagesContentPaths = {
contentPath: string;
contentPathLocalized: string;
};

View File

@ -32,6 +32,11 @@ export type I18nLocaleConfig = {
* name.
*/
path: string;
/**
* Should we attempt to translate this locale?
* By default, it will only be run if the `./i18n/<locale>` exists.
*/
translate: boolean;
};
export type I18nConfig = {

View File

@ -5,13 +5,15 @@
* LICENSE file in the root directory of this source tree.
*/
import path from 'path';
import * as path from 'path';
import {
mergeTranslations,
updateTranslationFileMessages,
getPluginI18nPath,
localizePath,
getLocaleConfig,
} from '../i18nUtils';
import type {I18n, I18nLocaleConfig} from '@docusaurus/types';
describe('mergeTranslations', () => {
it('works', () => {
@ -179,3 +181,77 @@ describe('localizePath', () => {
).toBe('/baseUrl/');
});
});
describe('getLocaleConfig', () => {
const localeConfigEn: I18nLocaleConfig = {
path: 'path',
direction: 'rtl',
htmlLang: 'en',
calendar: 'calendar',
label: 'EN',
translate: true,
};
const localeConfigFr: I18nLocaleConfig = {
path: 'path',
direction: 'rtl',
htmlLang: 'fr',
calendar: 'calendar',
label: 'FR',
translate: true,
};
function i18n(params: Partial<I18n>): I18n {
return {
defaultLocale: 'en',
localeConfigs: {},
locales: ['en'],
path: 'path',
currentLocale: 'en',
...params,
};
}
it('returns single locale config', () => {
expect(
getLocaleConfig(
i18n({currentLocale: 'en', localeConfigs: {en: localeConfigEn}}),
),
).toEqual(localeConfigEn);
});
it('returns correct locale config among 2', () => {
expect(
getLocaleConfig(
i18n({
currentLocale: 'fr',
localeConfigs: {en: localeConfigEn, fr: localeConfigFr},
}),
),
).toEqual(localeConfigFr);
});
it('accepts locale to look for as param', () => {
expect(
getLocaleConfig(
i18n({
currentLocale: 'fr',
localeConfigs: {en: localeConfigEn, fr: localeConfigFr},
}),
'en',
),
).toEqual(localeConfigEn);
});
it('throws for locale config that does not exist', () => {
expect(() =>
getLocaleConfig(
i18n({
currentLocale: 'fr',
localeConfigs: {en: localeConfigEn},
}),
),
).toThrowErrorMatchingInlineSnapshot(
`"Can't find locale config for locale \`fr\`"`,
);
});
});

View File

@ -74,7 +74,9 @@ export async function readDataFile(params: DataFileParams): Promise<unknown> {
* in priority.
*/
export function getContentPathList(contentPaths: ContentPaths): string[] {
return [contentPaths.contentPathLocalized, contentPaths.contentPath];
return [contentPaths.contentPathLocalized, contentPaths.contentPath].filter(
(p) => p !== undefined,
);
}
/**

View File

@ -7,12 +7,14 @@
import path from 'path';
import _ from 'lodash';
import logger from '@docusaurus/logger';
import {DEFAULT_PLUGIN_ID} from './constants';
import {normalizeUrl} from './urlUtils';
import type {
TranslationFileContent,
TranslationFile,
I18n,
I18nLocaleConfig,
} from '@docusaurus/types';
/**
@ -112,3 +114,17 @@ export function localizePath({
// Url paths; add a trailing slash so it's a valid base URL
return normalizeUrl([originalPath, i18n.currentLocale, '/']);
}
// TODO we may extract this to a separate package
// we want to use it on the frontend too
// but "docusaurus-utils-common" (agnostic utils) is not an ideal place since
export function getLocaleConfig(i18n: I18n, locale?: string): I18nLocaleConfig {
const localeToLookFor = locale ?? i18n.currentLocale;
const localeConfig = i18n.localeConfigs[localeToLookFor];
if (!localeConfig) {
throw new Error(
`Can't find locale config for locale ${logger.code(localeToLookFor)}`,
);
}
return localeConfig;
}

View File

@ -34,6 +34,7 @@ export {
updateTranslationFileMessages,
getPluginI18nPath,
localizePath,
getLocaleConfig,
} from './i18nUtils';
export {mapAsyncSequential, findAsyncSequential} from './jsUtils';
export {

View File

@ -20,9 +20,11 @@ export type ContentPaths = {
contentPath: string;
/**
* The absolute path to the localized content directory, like
* `"<siteDir>/i18n/zh-Hans/plugin-content-docs"`.
* `"<siteDir>/i18n/zh-Hans/plugin-content-blog"`.
*
* Undefined when the locale has `translate: false` config
*/
contentPathLocalized: string;
contentPathLocalized: string | undefined;
};
/** Data structure representing each broken Markdown link to be reported. */

View File

@ -91,7 +91,11 @@ async function getLocalesToBuild({
localizePath,
});
const i18n = await loadI18n(context.siteConfig);
const i18n = await loadI18n({
siteDir,
config: context.siteConfig,
currentLocale: context.siteConfig.i18n.defaultLocale // Awkward but ok
});
const locales = cliOptions.locale ?? i18n.locales;

View File

@ -0,0 +1 @@
Since i18n/zh-Hans-custom folder exists, zh-Hans locale should infer to translate = true

View File

@ -0,0 +1 @@
Since i18n/de folder exists, de locale should infer to translate = true

View File

@ -0,0 +1 @@
Since i18n/fr folder exists, fr locale should infer to translate = true

View File

@ -20,6 +20,7 @@ exports[`load loads props for site with custom i18n path 1`] = `
"htmlLang": "en",
"label": "English",
"path": "en-custom",
"translate": false,
},
"zh-Hans": {
"calendar": "gregory",
@ -27,6 +28,7 @@ exports[`load loads props for site with custom i18n path 1`] = `
"htmlLang": "zh-Hans",
"label": "简体中文",
"path": "zh-Hans-custom",
"translate": true,
},
},
"locales": [

View File

@ -6,23 +6,33 @@
*/
import {jest} from '@jest/globals';
import path from 'path';
import {loadI18n, getDefaultLocaleConfig} from '../i18n';
import {DEFAULT_I18N_CONFIG} from '../configValidation';
import type {DocusaurusConfig, I18nConfig} from '@docusaurus/types';
function testLocaleConfigsFor(locales: string[]) {
return Object.fromEntries(
locales.map((locale) => [locale, getDefaultLocaleConfig(locale)]),
);
}
const loadI18nSiteDir = path.resolve(
__dirname,
'__fixtures__',
'load-i18n-site',
);
function loadI18nTest(i18nConfig: I18nConfig, locale?: string) {
return loadI18n(
{
function loadI18nTest({
siteDir = loadI18nSiteDir,
i18nConfig,
currentLocale,
}: {
siteDir?: string;
i18nConfig: I18nConfig;
currentLocale: string;
}) {
return loadI18n({
siteDir,
config: {
i18n: i18nConfig,
} as DocusaurusConfig,
{locale},
);
currentLocale,
});
}
describe('defaultLocaleConfig', () => {
@ -109,66 +119,106 @@ describe('loadI18n', () => {
});
it('loads I18n for default config', async () => {
await expect(loadI18nTest(DEFAULT_I18N_CONFIG)).resolves.toEqual({
await expect(
loadI18nTest({
i18nConfig: DEFAULT_I18N_CONFIG,
currentLocale: 'en',
}),
).resolves.toEqual({
path: 'i18n',
defaultLocale: 'en',
locales: ['en'],
currentLocale: 'en',
localeConfigs: testLocaleConfigsFor(['en']),
localeConfigs: {
en: {
...getDefaultLocaleConfig('en'),
translate: false,
},
},
});
});
it('loads I18n for multi-lang config', async () => {
await expect(
loadI18nTest({
path: 'i18n',
defaultLocale: 'fr',
locales: ['en', 'fr', 'de'],
localeConfigs: {},
i18nConfig: {
path: 'i18n',
defaultLocale: 'fr',
locales: ['en', 'fr', 'de'],
localeConfigs: {},
},
currentLocale: 'fr',
}),
).resolves.toEqual({
defaultLocale: 'fr',
path: 'i18n',
locales: ['en', 'fr', 'de'],
currentLocale: 'fr',
localeConfigs: testLocaleConfigsFor(['en', 'fr', 'de']),
localeConfigs: {
en: {
...getDefaultLocaleConfig('en'),
translate: false,
},
fr: {
...getDefaultLocaleConfig('fr'),
translate: true,
},
de: {
...getDefaultLocaleConfig('de'),
translate: true,
},
},
});
});
it('loads I18n for multi-locale config with specified locale', async () => {
await expect(
loadI18nTest(
{
loadI18nTest({
i18nConfig: {
path: 'i18n',
defaultLocale: 'fr',
locales: ['en', 'fr', 'de'],
localeConfigs: {},
},
'de',
),
currentLocale: 'de',
}),
).resolves.toEqual({
defaultLocale: 'fr',
path: 'i18n',
locales: ['en', 'fr', 'de'],
currentLocale: 'de',
localeConfigs: testLocaleConfigsFor(['en', 'fr', 'de']),
localeConfigs: {
en: {
...getDefaultLocaleConfig('en'),
translate: false,
},
fr: {
...getDefaultLocaleConfig('fr'),
translate: true,
},
de: {
...getDefaultLocaleConfig('de'),
translate: true,
},
},
});
});
it('loads I18n for multi-locale config with some custom locale configs', async () => {
await expect(
loadI18nTest(
{
loadI18nTest({
i18nConfig: {
path: 'i18n',
defaultLocale: 'fr',
locales: ['en', 'fr', 'de'],
localeConfigs: {
fr: {label: 'Français'},
en: {},
fr: {label: 'Français', translate: false},
en: {translate: true},
de: {translate: false},
},
},
'de',
),
currentLocale: 'de',
}),
).resolves.toEqual({
defaultLocale: 'fr',
path: 'i18n',
@ -181,23 +231,30 @@ describe('loadI18n', () => {
htmlLang: 'fr',
calendar: 'gregory',
path: 'fr',
translate: false,
},
en: {
...getDefaultLocaleConfig('en'),
translate: true,
},
de: {
...getDefaultLocaleConfig('de'),
translate: false,
},
en: getDefaultLocaleConfig('en'),
de: getDefaultLocaleConfig('de'),
},
});
});
it('warns when trying to load undeclared locale', async () => {
await loadI18nTest(
{
await loadI18nTest({
i18nConfig: {
path: 'i18n',
defaultLocale: 'fr',
locales: ['en', 'fr', 'de'],
localeConfigs: {},
},
'it',
);
currentLocale: 'it',
});
expect(consoleSpy.mock.calls[0]![0]).toMatch(
/The locale .*it.* was not found in your site configuration/,
);

View File

@ -5,9 +5,11 @@
* LICENSE file in the root directory of this source tree.
*/
import path from 'path';
import fs from 'fs-extra';
import logger from '@docusaurus/logger';
import combinePromises from 'combine-promises';
import type {I18n, DocusaurusConfig, I18nLocaleConfig} from '@docusaurus/types';
import type {LoadContextParams} from './site';
function inferLanguageDisplayName(locale: string) {
const tryLocale = (l: string) => {
@ -78,7 +80,9 @@ function getDefaultDirection(localeStr: string) {
return textInto.direction;
}
export function getDefaultLocaleConfig(locale: string): I18nLocaleConfig {
export function getDefaultLocaleConfig(
locale: string,
): Omit<I18nLocaleConfig, 'translate'> {
try {
return {
label: getDefaultLocaleLabel(locale),
@ -95,14 +99,17 @@ export function getDefaultLocaleConfig(locale: string): I18nLocaleConfig {
}
}
export async function loadI18n(
config: DocusaurusConfig,
options?: Pick<LoadContextParams, 'locale'>,
): Promise<I18n> {
export async function loadI18n({
siteDir,
config,
currentLocale,
}: {
siteDir: string;
config: DocusaurusConfig;
currentLocale: string;
}): Promise<I18n> {
const {i18n: i18nConfig} = config;
const currentLocale = options?.locale ?? i18nConfig.defaultLocale;
if (!i18nConfig.locales.includes(currentLocale)) {
logger.warn`The locale name=${currentLocale} was not found in your site configuration: Available locales are: ${i18nConfig.locales}
Note: Docusaurus only support running one locale at a time.`;
@ -112,15 +119,36 @@ Note: Docusaurus only support running one locale at a time.`;
? i18nConfig.locales
: (i18nConfig.locales.concat(currentLocale) as [string, ...string[]]);
function getLocaleConfig(locale: string): I18nLocaleConfig {
return {
async function getFullLocaleConfig(
locale: string,
): Promise<I18nLocaleConfig> {
const localeConfigInput = i18nConfig.localeConfigs[locale] ?? {};
const localeConfig: Omit<I18nLocaleConfig, 'translate'> = {
...getDefaultLocaleConfig(locale),
...i18nConfig.localeConfigs[locale],
...localeConfigInput,
};
// By default, translations will be enabled if i18n/<locale> dir exists
async function inferTranslate() {
const localizationDir = path.resolve(
siteDir,
i18nConfig.path,
localeConfig.path,
);
return fs.pathExists(localizationDir);
}
const translate = localeConfigInput.translate ?? (await inferTranslate());
return {
...localeConfig,
translate,
};
}
const localeConfigs = Object.fromEntries(
locales.map((locale) => [locale, getLocaleConfig(locale)]),
const localeConfigs = await combinePromises(
Object.fromEntries(
locales.map((locale) => [locale, getFullLocaleConfig(locale)]),
),
);
return {

View File

@ -11,12 +11,16 @@ import {loadPlugins, reloadPlugin} from '../plugins';
import {DEFAULT_FUTURE_CONFIG} from '../../configValidation';
import type {LoadContext, Plugin, PluginConfig} from '@docusaurus/types';
type TestOptions = {translate?: boolean};
async function testLoad({
plugins,
themes,
options = {},
}: {
plugins: PluginConfig<any>[];
themes: PluginConfig<any>[];
options?: TestOptions;
}) {
const siteDir = path.join(__dirname, '__fixtures__/site-with-plugin');
@ -25,6 +29,13 @@ async function testLoad({
siteConfigPath: path.join(siteDir, 'docusaurus.config.js'),
generatedFilesDir: path.join(siteDir, '.docusaurus'),
outDir: path.join(siteDir, 'build'),
i18n: {
path: 'i18n',
locales: ['en'],
currentLocale: 'en',
defaultLocale: 'en',
localeConfigs: {en: {translate: options.translate ?? true}},
},
siteConfig: {
baseUrl: '/',
trailingSlash: true,
@ -49,10 +60,12 @@ const SyntheticPluginNames = [
async function testPlugin<Content = unknown>(
pluginConfig: PluginConfig<Content>,
options?: TestOptions,
) {
const {context, plugins, routes, globalData} = await testLoad({
plugins: [pluginConfig],
themes: [],
options,
});
const nonSyntheticPlugins = plugins.filter(
@ -86,65 +99,120 @@ describe('loadPlugins', () => {
expect(globalData).toEqual({});
});
it('typical plugin', async () => {
const {plugin, routes, globalData} = await testPlugin(() => ({
name: 'plugin-name',
loadContent: () => ({name: 'Toto', age: 42}),
translateContent: ({content}) => ({
...content,
name: `${content.name} (translated)`,
}),
contentLoaded({content, actions}) {
actions.addRoute({
path: '/foo',
component: 'Comp',
modules: {someModule: 'someModulePath'},
context: {someContext: 'someContextPath'},
});
actions.setGlobalData({
globalName: content.name,
globalAge: content.age,
});
},
}));
describe('typical plugin', () => {
function typicalPlugin(options: TestOptions) {
return testPlugin(
() => ({
name: 'plugin-name',
loadContent: () => ({name: 'Toto', age: 42}),
translateContent: ({content}) => ({
...content,
name: `${content.name} (translated)`,
}),
contentLoaded({content, actions}) {
actions.addRoute({
path: '/foo',
component: 'Comp',
modules: {someModule: 'someModulePath'},
context: {someContext: 'someContextPath'},
});
actions.setGlobalData({
globalName: content.name,
globalAge: content.age,
});
},
}),
options,
);
}
expect(plugin.content).toMatchInlineSnapshot(`
{
"age": 42,
"name": "Toto (translated)",
}
`);
expect(routes).toMatchInlineSnapshot(`
[
it('translated: true', async () => {
const {plugin, routes, globalData} = await typicalPlugin({
translate: true,
});
expect(plugin.content).toMatchInlineSnapshot(`
{
"age": 42,
"name": "Toto (translated)",
}
`);
expect(routes).toMatchInlineSnapshot(`
[
{
"component": "Comp",
"context": {
"data": {
"someContext": "someContextPath",
},
"plugin": "@generated/plugin-name/default/__plugin.json",
},
"modules": {
"someModule": "someModulePath",
},
"path": "/foo/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
},
]
`);
expect(globalData).toMatchInlineSnapshot(`
{
"plugin-name": {
"default": {
"globalAge": 42,
"globalName": "Toto (translated)",
},
},
}
`);
});
it('translated: false', async () => {
const {plugin, routes, globalData} = await typicalPlugin({
translate: false,
});
expect(plugin.content).toMatchInlineSnapshot(`
{
"component": "Comp",
"context": {
"data": {
"someContext": "someContextPath",
"age": 42,
"name": "Toto",
}
`);
expect(routes).toMatchInlineSnapshot(`
[
{
"component": "Comp",
"context": {
"data": {
"someContext": "someContextPath",
},
"plugin": "@generated/plugin-name/default/__plugin.json",
},
"modules": {
"someModule": "someModulePath",
},
"path": "/foo/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
},
]
`);
expect(globalData).toMatchInlineSnapshot(`
{
"plugin-name": {
"default": {
"globalAge": 42,
"globalName": "Toto",
},
"plugin": "@generated/plugin-name/default/__plugin.json",
},
"modules": {
"someModule": "someModulePath",
},
"path": "/foo/",
"plugin": {
"id": "default",
"name": "plugin-name",
},
},
]
`);
expect(globalData).toMatchInlineSnapshot(`
{
"plugin-name": {
"default": {
"globalAge": 42,
"globalName": "Toto (translated)",
},
},
}
`);
}
`);
});
});
it('plugin with options', async () => {

View File

@ -6,6 +6,7 @@
*/
import {PerfLogger} from '@docusaurus/logger';
import {getLocaleConfig} from '@docusaurus/utils';
import {initPlugins} from './init';
import {createBootstrapPlugin, createMDXFallbackPlugin} from './synthetic';
import {localizePluginTranslationFile} from '../translations/translations';
@ -81,14 +82,20 @@ async function executePluginContentLoading({
plugin.loadContent?.(),
);
content = await PerfLogger.async('translatePluginContent()', () =>
translatePluginContent({
plugin,
content,
context,
}),
);
const shouldTranslate = getLocaleConfig(context.i18n).translate;
if (shouldTranslate) {
content = await PerfLogger.async('translatePluginContent()', () =>
translatePluginContent({
plugin,
content,
context,
}),
);
}
// If shouldTranslate === false, we still need the code translations
// Otherwise an unlocalized French site would show code strings in English
const defaultCodeTranslations =
(await PerfLogger.async('getDefaultCodeTranslationMessages()', () =>
plugin.getDefaultCodeTranslationMessages?.(),

View File

@ -10,6 +10,7 @@ import {
localizePath,
DEFAULT_BUILD_DIR_NAME,
GENERATED_FILES_DIR_NAME,
getLocaleConfig,
} from '@docusaurus/utils';
import {PerfLogger} from '@docusaurus/logger';
import combinePromises from 'combine-promises';
@ -96,7 +97,11 @@ export async function loadContext(
siteConfig: initialSiteConfig,
});
const i18n = await loadI18n(initialSiteConfig, {locale});
const i18n = await loadI18n({
siteDir,
config: initialSiteConfig,
currentLocale: locale ?? initialSiteConfig.i18n.defaultLocale,
});
const baseUrl = localizePath({
path: initialSiteConfig.baseUrl,
@ -113,7 +118,7 @@ export async function loadContext(
const localizationDir = path.resolve(
siteDir,
i18n.path,
i18n.localeConfigs[i18n.currentLocale]!.path,
getLocaleConfig(i18n).path,
);
const siteConfig: DocusaurusConfig = {...initialSiteConfig, baseUrl};

View File

@ -11,6 +11,7 @@ import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer';
import ReactLoadableSSRAddon from 'react-loadable-ssr-addon-v5-slorber';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import {getProgressBarPlugin} from '@docusaurus/bundler';
import {getLocaleConfig} from '@docusaurus/utils';
import {createBaseConfig} from './base';
import ChunkAssetPlugin from './plugins/ChunkAssetPlugin';
import ForceTerminatePlugin from './plugins/ForceTerminatePlugin';
@ -117,7 +118,7 @@ export async function createStartClientConfig({
headTags,
preBodyTags,
postBodyTags,
lang: props.i18n.localeConfigs[props.i18n.currentLocale]!.htmlLang,
lang: getLocaleConfig(props.i18n).htmlLang,
}),
],
},

View File

@ -151,6 +151,7 @@ export default {
htmlLang: 'en-US',
calendar: 'gregory',
path: 'en',
translate: false,
},
fa: {
label: 'فارسی',
@ -158,6 +159,7 @@ export default {
htmlLang: 'fa-IR',
calendar: 'persian',
path: 'fa',
translate: true,
},
},
},
@ -172,7 +174,8 @@ export default {
- `direction`: `ltr` (default) or `rtl` (for [right-to-left languages](https://developer.mozilla.org/en-US/docs/Glossary/rtl) like Farsi, Arabic, Hebrew, etc.). Used to select the locale's CSS and HTML meta attribute.
- `htmlLang`: BCP 47 language tag to use in `<html lang="...">` (or any other DOM tag name) and in `<link ... hreflang="...">`
- `calendar`: the [calendar](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/calendar) used to calculate the date era. Note that it doesn't control the actual string displayed: `MM/DD/YYYY` and `DD/MM/YYYY` are both `gregory`. To choose the format (`DD/MM/YYYY` or `MM/DD/YYYY`), set your locale name to `en-GB` or `en-US` (`en` means `en-US`).
- `path`: Root folder that all plugin localization folders of this locale are relative to. Will be resolved against `i18n.path`. Defaults to the locale's name. Note: this has no effect on the locale's `baseUrl`—customization of base URL is a work-in-progress.
- `path`: Root folder that all plugin localization folders of this locale are relative to. Will be resolved against `i18n.path`. Defaults to the locale's name (`i18n/<locale>`). Note: this has no effect on the locale's `baseUrl`—customization of base URL is a work-in-progress.
- `translate`: Should we run the translation process for this locale? By default, it is enabled if the `i18n/<locale>` folder exists
### `future` {#future}