feat(v2): Add i18n default code translation bundles (#4215)

* Add default code translation bundles

* fix tests
This commit is contained in:
Sébastien Lorber 2021-02-12 11:35:09 +01:00 committed by GitHub
parent 1b3c9be530
commit 6a94ad989c
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 430 additions and 5 deletions

View file

@ -0,0 +1,18 @@
{
"theme.NotFound.title": "Page introuvable",
"theme.NotFound.p1": "Nous n'avons pas trouvé ce que vous recherchez.",
"theme.NotFound.p2": "Veuillez contacter le propriétaire du site qui vous a lié à l'URL d'origine et leur faire savoir que leur lien est cassé.",
"theme.BlogListPaginator.newerEntries": "Nouvelles entrées",
"theme.BlogListPaginator.olderEntries": "Anciennes entrées",
"theme.BlogPostItem.readMore": "Lire plus",
"theme.BlogPostPaginator.newerPost": "Article plus récent",
"theme.BlogPostPaginator.olderPost": "Article plus ancien",
"theme.CodeBlock.copied": "Copié",
"theme.CodeBlock.copy": "Copier",
"theme.DocPaginator.previous": "Précédent",
"theme.DocPaginator.next": "Suivant",
"theme.EditThisPage.editThisPage": "Éditer cette page",
"theme.SkipToContent.skipToMainContent": "Aller au contenu principal",
"theme.Playground.liveEditor": "Éditeur en direct",
"theme.Playground.result": "Résultat"
}

View file

@ -11,6 +11,7 @@ import path from 'path';
import Module from 'module'; import Module from 'module';
import postcss from 'postcss'; import postcss from 'postcss';
import rtlcss from 'rtlcss'; import rtlcss from 'rtlcss';
import {readDefaultCodeTranslationMessages} from '@docusaurus/utils';
const createRequire = Module.createRequire || Module.createRequireFromPath; const createRequire = Module.createRequire || Module.createRequireFromPath;
const requireFromDocusaurusCore = createRequire( const requireFromDocusaurusCore = createRequire(
@ -103,6 +104,13 @@ export default function docusaurusThemeClassic(
getTranslationFiles: async () => getTranslationFiles({themeConfig}), getTranslationFiles: async () => getTranslationFiles({themeConfig}),
translateThemeConfig, translateThemeConfig,
getDefaultCodeTranslationMessages: () => {
return readDefaultCodeTranslationMessages({
dirPath: path.resolve(__dirname, '..', 'codeTranslations'),
locale: currentLocale,
});
},
getClientModules() { getClientModules() {
const modules = [ const modules = [
require.resolve(getInfimaCSSFile(direction)), require.resolve(getInfimaCSSFile(direction)),

View file

@ -240,6 +240,12 @@ export interface Plugin<T, U = unknown> {
// translations // translations
getTranslationFiles?(): Promise<TranslationFiles>; getTranslationFiles?(): Promise<TranslationFiles>;
getDefaultCodeTranslationMessages?(): Promise<
Record<
string, // id
string // message
>
>;
translateContent?({ translateContent?({
content, content,
translationFiles, translationFiles,

View file

@ -0,0 +1,4 @@
{
"id1": "message 1 en",
"id2": "message 2 en"
}

View file

@ -0,0 +1,4 @@
{
"id1": "message 1 fr",
"id2": "message 2 fr"
}

View file

@ -0,0 +1,5 @@
{
"id1": "message 1 fr_FR",
"id2": "message 2 fr_FR",
"id3": "message 3 fr_FR"
}

View file

@ -6,6 +6,7 @@
*/ */
import path from 'path'; import path from 'path';
import fs from 'fs-extra';
import { import {
fileToPath, fileToPath,
simpleHash, simpleHash,
@ -33,6 +34,7 @@ import {
findFolderContainingFile, findFolderContainingFile,
getFolderContainingFile, getFolderContainingFile,
updateTranslationFileMessages, updateTranslationFileMessages,
readDefaultCodeTranslationMessages,
} from '../index'; } from '../index';
import {sum} from 'lodash'; import {sum} from 'lodash';
@ -718,3 +720,89 @@ describe('updateTranslationFileMessages', () => {
}); });
}); });
}); });
describe('readDefaultCodeTranslationMessages', () => {
const dirPath = path.resolve(
__dirname,
'__fixtures__',
'defaultCodeTranslations',
);
async function readAsJSON(filename: string) {
return JSON.parse(
await fs.readFile(path.resolve(dirPath, filename), 'utf8'),
);
}
test('for empty locale', async () => {
await expect(
readDefaultCodeTranslationMessages({
locale: '',
dirPath,
}),
).resolves.toEqual({});
});
test('for unexisting locale', async () => {
await expect(
readDefaultCodeTranslationMessages({
locale: 'es',
dirPath,
}),
).resolves.toEqual({});
});
test('for fr but bad folder', async () => {
await expect(
readDefaultCodeTranslationMessages({
locale: '',
dirPath: __dirname,
}),
).resolves.toEqual({});
});
test('for fr', async () => {
await expect(
readDefaultCodeTranslationMessages({
locale: 'fr',
dirPath,
}),
).resolves.toEqual(await readAsJSON('fr.json'));
});
test('for fr_FR', async () => {
await expect(
readDefaultCodeTranslationMessages({
locale: 'fr_FR',
dirPath,
}),
).resolves.toEqual(await readAsJSON('fr_FR.json'));
});
test('for en', async () => {
await expect(
readDefaultCodeTranslationMessages({
locale: 'en',
dirPath,
}),
).resolves.toEqual(await readAsJSON('en.json'));
});
test('for en_US', async () => {
await expect(
readDefaultCodeTranslationMessages({
locale: 'en_US',
dirPath,
}),
).resolves.toEqual(await readAsJSON('en.json'));
});
test('for en_WHATEVER', async () => {
await expect(
readDefaultCodeTranslationMessages({
locale: 'en_WHATEVER',
dirPath,
}),
).resolves.toEqual(await readAsJSON('en.json'));
});
});

View file

@ -602,3 +602,35 @@ export function updateTranslationFileMessages(
})), })),
}; };
} }
export async function readDefaultCodeTranslationMessages({
dirPath,
locale,
}: {
dirPath: string;
locale: string;
}): Promise<Record<string, string>> {
const fileNamesToTry = [locale];
if (locale.includes('_')) {
const language = locale.split('_')[0];
if (language) {
fileNamesToTry.push(language);
}
}
// Return the content of the first file that match
// fr_FR.json => fr.json => nothing
for (const fileName of fileNamesToTry) {
const filePath = path.resolve(dirPath, `${fileName}.json`);
// eslint-disable-next-line no-await-in-loop
if (await fs.pathExists(filePath)) {
// eslint-disable-next-line no-await-in-loop
const fileContent = await fs.readFile(filePath, 'utf8');
return JSON.parse(fileContent);
}
}
return {};
}

View file

@ -11,6 +11,8 @@ import {
writePluginTranslations, writePluginTranslations,
writeCodeTranslations, writeCodeTranslations,
WriteTranslationsOptions, WriteTranslationsOptions,
getPluginsDefaultCodeTranslationMessages,
applyDefaultCodeTranslations,
} from '../server/translations/translations'; } from '../server/translations/translations';
import {extractPluginsSourceCodeTranslations} from '../server/translations/translationsExtractor'; import {extractPluginsSourceCodeTranslations} from '../server/translations/translationsExtractor';
import {getCustomBabelConfigFilePath, getBabelOptions} from '../webpack/utils'; import {getCustomBabelConfigFilePath, getBabelOptions} from '../webpack/utils';
@ -47,7 +49,7 @@ export default async function writeTranslations(
siteDir: string, siteDir: string,
options: WriteTranslationsOptions & {locale?: string}, options: WriteTranslationsOptions & {locale?: string},
): Promise<void> { ): Promise<void> {
const context = await loadContext(siteDir); const context = await loadContext(siteDir, {locale: options.locale});
const pluginConfigs = loadPluginConfigs(context); const pluginConfigs = loadPluginConfigs(context);
const plugins = initPlugins({ const plugins = initPlugins({
pluginConfigs, pluginConfigs,
@ -68,10 +70,19 @@ Available locales=[${context.i18n.locales.join(',')}]`,
isServer: true, isServer: true,
babelOptions: getCustomBabelConfigFilePath(siteDir), babelOptions: getCustomBabelConfigFilePath(siteDir),
}); });
const codeTranslations = await extractPluginsSourceCodeTranslations( const extractedCodeTranslations = await extractPluginsSourceCodeTranslations(
plugins, plugins,
babelOptions, babelOptions,
); );
const defaultCodeMessages = await getPluginsDefaultCodeTranslationMessages(
plugins,
);
const codeTranslations = applyDefaultCodeTranslations({
extractedCodeTranslations,
defaultCodeMessages,
});
await writeCodeTranslations({siteDir, locale}, codeTranslations, options); await writeCodeTranslations({siteDir, locale}, codeTranslations, options);
await Promise.all( await Promise.all(

View file

@ -32,7 +32,10 @@ import {loadHtmlTags} from './html-tags';
import {getPackageJsonVersion} from './versions'; import {getPackageJsonVersion} from './versions';
import {handleDuplicateRoutes} from './duplicateRoutes'; import {handleDuplicateRoutes} from './duplicateRoutes';
import {loadI18n, localizePath} from './i18n'; import {loadI18n, localizePath} from './i18n';
import {readCodeTranslationFileContent} from './translations/translations'; import {
readCodeTranslationFileContent,
getPluginsDefaultCodeTranslationMessages,
} from './translations/translations';
import {mapValues} from 'lodash'; import {mapValues} from 'lodash';
type LoadContextOptions = { type LoadContextOptions = {
@ -267,10 +270,15 @@ ${Object.keys(registry)
JSON.stringify(i18n, null, 2), JSON.stringify(i18n, null, 2),
); );
const codeTranslationsWithFallbacks: Record<string, string> = {
...(await getPluginsDefaultCodeTranslationMessages(plugins)),
...codeTranslations,
};
const genCodeTranslations = generate( const genCodeTranslations = generate(
generatedFilesDir, generatedFilesDir,
'codeTranslations.json', 'codeTranslations.json',
JSON.stringify(codeTranslations, null, 2), JSON.stringify(codeTranslationsWithFallbacks, null, 2),
); );
// Version metadata. // Version metadata.

View file

@ -12,11 +12,14 @@ import {
readTranslationFileContent, readTranslationFileContent,
WriteTranslationsOptions, WriteTranslationsOptions,
localizePluginTranslationFile, localizePluginTranslationFile,
getPluginsDefaultCodeTranslationMessages,
applyDefaultCodeTranslations,
} from '../translations'; } from '../translations';
import fs from 'fs-extra'; import fs from 'fs-extra';
import tmp from 'tmp-promise'; import tmp from 'tmp-promise';
import {TranslationFile, TranslationFileContent} from '@docusaurus/types'; import {TranslationFile, TranslationFileContent} from '@docusaurus/types';
import path from 'path'; import path from 'path';
import {InitPlugin} from '../../plugins/init';
async function createTmpSiteDir() { async function createTmpSiteDir() {
const {path: siteDirPath} = await tmp.dir({ const {path: siteDirPath} = await tmp.dir({
@ -461,3 +464,194 @@ describe('localizePluginTranslationFile', () => {
}); });
}); });
}); });
describe('getPluginsDefaultCodeTranslationMessages', () => {
function createTestPlugin(
fn: InitPlugin['getDefaultCodeTranslationMessages'],
): InitPlugin {
return {getDefaultCodeTranslationMessages: fn} as InitPlugin;
}
test('for empty plugins', async () => {
const plugins: InitPlugin[] = [];
await expect(
getPluginsDefaultCodeTranslationMessages(plugins),
).resolves.toEqual({});
});
test('for 1 plugin without lifecycle', async () => {
const plugins: InitPlugin[] = [createTestPlugin(undefined)];
await expect(
getPluginsDefaultCodeTranslationMessages(plugins),
).resolves.toEqual({});
});
test('for 1 plugin with lifecycle', async () => {
const plugins: InitPlugin[] = [
createTestPlugin(async () => ({
a: '1',
b: '2',
})),
];
await expect(
getPluginsDefaultCodeTranslationMessages(plugins),
).resolves.toEqual({
a: '1',
b: '2',
});
});
test('for 2 plugins with lifecycles', async () => {
const plugins: InitPlugin[] = [
createTestPlugin(async () => ({
a: '1',
b: '2',
})),
createTestPlugin(async () => ({
c: '3',
d: '4',
})),
];
await expect(
getPluginsDefaultCodeTranslationMessages(plugins),
).resolves.toEqual({
a: '1',
b: '2',
c: '3',
d: '4',
});
});
test('for realistic use-case', async () => {
const plugins: InitPlugin[] = [
createTestPlugin(undefined),
createTestPlugin(async () => ({
a: '1',
b: '2',
})),
createTestPlugin(undefined),
createTestPlugin(undefined),
createTestPlugin(async () => ({
a: '2',
d: '4',
})),
createTestPlugin(async () => ({
d: '5',
})),
createTestPlugin(undefined),
];
await expect(
getPluginsDefaultCodeTranslationMessages(plugins),
).resolves.toEqual({
// merge, last plugin wins
b: '2',
a: '2',
d: '5',
});
});
});
describe('applyDefaultCodeTranslations', () => {
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() as any;
beforeEach(() => {
consoleSpy.mockClear();
});
test('for no code and message', () => {
expect(
applyDefaultCodeTranslations({
extractedCodeTranslations: {},
defaultCodeMessages: {},
}),
).toEqual({});
expect(consoleSpy).toHaveBeenCalledTimes(0);
});
test('for code and message', () => {
expect(
applyDefaultCodeTranslations({
extractedCodeTranslations: {
id: {
message: 'extracted message',
description: 'description',
},
},
defaultCodeMessages: {
id: 'default message',
},
}),
).toEqual({
id: {
message: 'default message',
description: 'description',
},
});
expect(consoleSpy).toHaveBeenCalledTimes(0);
});
test('for code and message mismatch', () => {
expect(
applyDefaultCodeTranslations({
extractedCodeTranslations: {
id: {
message: 'extracted message',
description: 'description',
},
},
defaultCodeMessages: {
unknownId: 'default message',
},
}),
).toEqual({
id: {
message: 'extracted message',
description: 'description',
},
});
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy.mock.calls[0][0]).toMatch(/unknownId/);
});
test('for realistic scenario', () => {
expect(
applyDefaultCodeTranslations({
extractedCodeTranslations: {
id1: {
message: 'extracted message 1',
description: 'description 1',
},
id2: {
message: 'extracted message 2',
description: 'description 2',
},
id3: {
message: 'extracted message 3',
description: 'description 3',
},
},
defaultCodeMessages: {
id2: 'default message id2',
id3: 'default message id3',
idUnknown1: 'default message idUnknown1',
idUnknown2: 'default message idUnknown2',
},
}),
).toEqual({
id1: {
message: 'extracted message 1',
description: 'description 1',
},
id2: {
message: 'default message id2',
description: 'description 2',
},
id3: {
message: 'default message id3',
description: 'description 3',
},
});
expect(consoleSpy).toHaveBeenCalledTimes(1);
expect(consoleSpy.mock.calls[0][0]).toMatch(/idUnknown1/);
expect(consoleSpy.mock.calls[0][0]).toMatch(/idUnknown2/);
});
});

View file

@ -8,7 +8,11 @@ import path from 'path';
import fs from 'fs-extra'; import fs from 'fs-extra';
import {InitPlugin} from '../plugins/init'; import {InitPlugin} from '../plugins/init';
import {mapValues, difference} from 'lodash'; import {mapValues, difference} from 'lodash';
import {TranslationFileContent, TranslationFile} from '@docusaurus/types'; import {
TranslationFileContent,
TranslationFile,
TranslationMessage,
} from '@docusaurus/types';
import {getPluginI18nPath, toMessageRelativeFilePath} from '@docusaurus/utils'; import {getPluginI18nPath, toMessageRelativeFilePath} from '@docusaurus/utils';
import * as Joi from 'joi'; import * as Joi from 'joi';
import chalk from 'chalk'; import chalk from 'chalk';
@ -259,3 +263,46 @@ export async function localizePluginTranslationFile({
return translationFile; return translationFile;
} }
} }
export async function getPluginsDefaultCodeTranslationMessages(
plugins: InitPlugin[],
): Promise<Record<string, string>> {
const pluginsMessages = await Promise.all(
plugins.map((plugin) => plugin.getDefaultCodeTranslationMessages?.() ?? {}),
);
return pluginsMessages.reduce((allMessages, pluginMessages) => {
return {...allMessages, ...pluginMessages};
}, {});
}
export function applyDefaultCodeTranslations({
extractedCodeTranslations,
defaultCodeMessages,
}: {
extractedCodeTranslations: Record<string, TranslationMessage>;
defaultCodeMessages: Record<string, string>;
}): Record<string, TranslationMessage> {
const unusedDefaultCodeMessages = difference(
Object.keys(defaultCodeMessages),
Object.keys(extractedCodeTranslations),
);
if (unusedDefaultCodeMessages.length > 0) {
console.warn(
chalk.yellow(`Unused default message codes found.
Please report this Docusaurus issue.
- ${unusedDefaultCodeMessages.join('\n- ')}
`),
);
}
return mapValues(
extractedCodeTranslations,
(messageTranslation, messageId) => {
return {
...messageTranslation,
message: defaultCodeMessages[messageId] ?? messageTranslation.message,
};
},
);
}