diff --git a/packages/docusaurus-theme-classic/codeTranslations/fr.json b/packages/docusaurus-theme-classic/codeTranslations/fr.json new file mode 100644 index 0000000000..e0a625fe91 --- /dev/null +++ b/packages/docusaurus-theme-classic/codeTranslations/fr.json @@ -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" +} diff --git a/packages/docusaurus-theme-classic/src/index.ts b/packages/docusaurus-theme-classic/src/index.ts index 961582d48d..c35f4086b3 100644 --- a/packages/docusaurus-theme-classic/src/index.ts +++ b/packages/docusaurus-theme-classic/src/index.ts @@ -11,6 +11,7 @@ import path from 'path'; import Module from 'module'; import postcss from 'postcss'; import rtlcss from 'rtlcss'; +import {readDefaultCodeTranslationMessages} from '@docusaurus/utils'; const createRequire = Module.createRequire || Module.createRequireFromPath; const requireFromDocusaurusCore = createRequire( @@ -103,6 +104,13 @@ export default function docusaurusThemeClassic( getTranslationFiles: async () => getTranslationFiles({themeConfig}), translateThemeConfig, + getDefaultCodeTranslationMessages: () => { + return readDefaultCodeTranslationMessages({ + dirPath: path.resolve(__dirname, '..', 'codeTranslations'), + locale: currentLocale, + }); + }, + getClientModules() { const modules = [ require.resolve(getInfimaCSSFile(direction)), diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 0d784c7d0c..021201e495 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -240,6 +240,12 @@ export interface Plugin { // translations getTranslationFiles?(): Promise; + getDefaultCodeTranslationMessages?(): Promise< + Record< + string, // id + string // message + > + >; translateContent?({ content, translationFiles, diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/en.json b/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/en.json new file mode 100644 index 0000000000..e0bfbb2022 --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/en.json @@ -0,0 +1,4 @@ +{ + "id1": "message 1 en", + "id2": "message 2 en" +} diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/fr.json b/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/fr.json new file mode 100644 index 0000000000..30a174f946 --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/fr.json @@ -0,0 +1,4 @@ +{ + "id1": "message 1 fr", + "id2": "message 2 fr" +} diff --git a/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/fr_FR.json b/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/fr_FR.json new file mode 100644 index 0000000000..d6b796f4b4 --- /dev/null +++ b/packages/docusaurus-utils/src/__tests__/__fixtures__/defaultCodeTranslations/fr_FR.json @@ -0,0 +1,5 @@ +{ + "id1": "message 1 fr_FR", + "id2": "message 2 fr_FR", + "id3": "message 3 fr_FR" +} diff --git a/packages/docusaurus-utils/src/__tests__/index.test.ts b/packages/docusaurus-utils/src/__tests__/index.test.ts index 7ad612a0c3..f0ddf80f96 100644 --- a/packages/docusaurus-utils/src/__tests__/index.test.ts +++ b/packages/docusaurus-utils/src/__tests__/index.test.ts @@ -6,6 +6,7 @@ */ import path from 'path'; +import fs from 'fs-extra'; import { fileToPath, simpleHash, @@ -33,6 +34,7 @@ import { findFolderContainingFile, getFolderContainingFile, updateTranslationFileMessages, + readDefaultCodeTranslationMessages, } from '../index'; 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')); + }); +}); diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index bcb670bef1..06b5e41d8e 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -602,3 +602,35 @@ export function updateTranslationFileMessages( })), }; } + +export async function readDefaultCodeTranslationMessages({ + dirPath, + locale, +}: { + dirPath: string; + locale: string; +}): Promise> { + 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 {}; +} diff --git a/packages/docusaurus/src/commands/writeTranslations.ts b/packages/docusaurus/src/commands/writeTranslations.ts index 1c33f11f06..749eaeb0bd 100644 --- a/packages/docusaurus/src/commands/writeTranslations.ts +++ b/packages/docusaurus/src/commands/writeTranslations.ts @@ -11,6 +11,8 @@ import { writePluginTranslations, writeCodeTranslations, WriteTranslationsOptions, + getPluginsDefaultCodeTranslationMessages, + applyDefaultCodeTranslations, } from '../server/translations/translations'; import {extractPluginsSourceCodeTranslations} from '../server/translations/translationsExtractor'; import {getCustomBabelConfigFilePath, getBabelOptions} from '../webpack/utils'; @@ -47,7 +49,7 @@ export default async function writeTranslations( siteDir: string, options: WriteTranslationsOptions & {locale?: string}, ): Promise { - const context = await loadContext(siteDir); + const context = await loadContext(siteDir, {locale: options.locale}); const pluginConfigs = loadPluginConfigs(context); const plugins = initPlugins({ pluginConfigs, @@ -68,10 +70,19 @@ Available locales=[${context.i18n.locales.join(',')}]`, isServer: true, babelOptions: getCustomBabelConfigFilePath(siteDir), }); - const codeTranslations = await extractPluginsSourceCodeTranslations( + const extractedCodeTranslations = await extractPluginsSourceCodeTranslations( plugins, babelOptions, ); + const defaultCodeMessages = await getPluginsDefaultCodeTranslationMessages( + plugins, + ); + + const codeTranslations = applyDefaultCodeTranslations({ + extractedCodeTranslations, + defaultCodeMessages, + }); + await writeCodeTranslations({siteDir, locale}, codeTranslations, options); await Promise.all( diff --git a/packages/docusaurus/src/server/index.ts b/packages/docusaurus/src/server/index.ts index 2bba811841..9a5d90b56b 100644 --- a/packages/docusaurus/src/server/index.ts +++ b/packages/docusaurus/src/server/index.ts @@ -32,7 +32,10 @@ import {loadHtmlTags} from './html-tags'; import {getPackageJsonVersion} from './versions'; import {handleDuplicateRoutes} from './duplicateRoutes'; import {loadI18n, localizePath} from './i18n'; -import {readCodeTranslationFileContent} from './translations/translations'; +import { + readCodeTranslationFileContent, + getPluginsDefaultCodeTranslationMessages, +} from './translations/translations'; import {mapValues} from 'lodash'; type LoadContextOptions = { @@ -267,10 +270,15 @@ ${Object.keys(registry) JSON.stringify(i18n, null, 2), ); + const codeTranslationsWithFallbacks: Record = { + ...(await getPluginsDefaultCodeTranslationMessages(plugins)), + ...codeTranslations, + }; + const genCodeTranslations = generate( generatedFilesDir, 'codeTranslations.json', - JSON.stringify(codeTranslations, null, 2), + JSON.stringify(codeTranslationsWithFallbacks, null, 2), ); // Version metadata. diff --git a/packages/docusaurus/src/server/translations/__tests__/translations.test.ts b/packages/docusaurus/src/server/translations/__tests__/translations.test.ts index 529a913e9c..e18efe3e68 100644 --- a/packages/docusaurus/src/server/translations/__tests__/translations.test.ts +++ b/packages/docusaurus/src/server/translations/__tests__/translations.test.ts @@ -12,11 +12,14 @@ import { readTranslationFileContent, WriteTranslationsOptions, localizePluginTranslationFile, + getPluginsDefaultCodeTranslationMessages, + applyDefaultCodeTranslations, } from '../translations'; import fs from 'fs-extra'; import tmp from 'tmp-promise'; import {TranslationFile, TranslationFileContent} from '@docusaurus/types'; import path from 'path'; +import {InitPlugin} from '../../plugins/init'; async function createTmpSiteDir() { 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/); + }); +}); diff --git a/packages/docusaurus/src/server/translations/translations.ts b/packages/docusaurus/src/server/translations/translations.ts index dde540bb7d..dff7e5d6e9 100644 --- a/packages/docusaurus/src/server/translations/translations.ts +++ b/packages/docusaurus/src/server/translations/translations.ts @@ -8,7 +8,11 @@ import path from 'path'; import fs from 'fs-extra'; import {InitPlugin} from '../plugins/init'; 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 * as Joi from 'joi'; import chalk from 'chalk'; @@ -259,3 +263,46 @@ export async function localizePluginTranslationFile({ return translationFile; } } + +export async function getPluginsDefaultCodeTranslationMessages( + plugins: InitPlugin[], +): Promise> { + 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; + defaultCodeMessages: Record; +}): Record { + 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, + }; + }, + ); +}