feat: allow customizing localization path of each locale (#7624)

This commit is contained in:
Joshua Chen 2022-06-17 17:07:35 +08:00 committed by GitHub
parent 39e3e3715e
commit 1b9bec1042
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 254 additions and 30 deletions

View file

@ -26,6 +26,7 @@ const DefaultI18N: I18n = {
direction: 'ltr', direction: 'ltr',
htmlLang: 'en', htmlLang: 'en',
calendar: 'gregory', calendar: 'gregory',
path: 'en',
}, },
}, },
}; };

View file

@ -54,6 +54,7 @@ function getI18n(locale: string): I18n {
label: locale, label: locale,
htmlLang: locale, htmlLang: locale,
direction: 'ltr', direction: 'ltr',
path: locale,
}, },
}, },
}; };
@ -71,7 +72,11 @@ const getPlugin = async (
i18n: I18n = DefaultI18N, i18n: I18n = DefaultI18N,
) => { ) => {
const generatedFilesDir: string = path.resolve(siteDir, '.docusaurus'); const generatedFilesDir: string = path.resolve(siteDir, '.docusaurus');
const localizationDir = path.join(siteDir, i18n.path, i18n.currentLocale); const localizationDir = path.join(
siteDir,
i18n.path,
i18n.localeConfigs[i18n.currentLocale]!.path,
);
const siteConfig = { const siteConfig = {
title: 'Hello', title: 'Hello',
baseUrl: '/', baseUrl: '/',

View file

@ -0,0 +1 @@
[{ "type": "autogenerated", "dirName": "." }]

View file

@ -66,3 +66,12 @@ exports[`docsVersion second docs instance versioning 1`] = `
], ],
} }
`; `;
exports[`docsVersion works with custom i18n paths 1`] = `
[
{
"dirName": ".",
"type": "autogenerated",
},
]
`;

View file

@ -85,9 +85,11 @@ export async function cliDocsVersionCommand(
await Promise.all( await Promise.all(
i18n.locales.map(async (locale) => { i18n.locales.map(async (locale) => {
// TODO duplicated logic from core, so duplicate comment as well: we need const localizationDir = path.resolve(
// to support customization per-locale in the future siteDir,
const localizationDir = path.resolve(siteDir, i18n.path, locale); i18n.path,
i18n.localeConfigs[locale]!.path,
);
// Copy docs files. // Copy docs files.
const docsDir = const docsDir =
locale === i18n.defaultLocale locale === i18n.defaultLocale

View file

@ -61,6 +61,12 @@ export type I18nLocaleConfig = {
* or `en-US` (`en` means `en-US`). * or `en-US` (`en` means `en-US`).
*/ */
calendar: string; calendar: string;
/**
* 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.
*/
path: string;
}; };
export type I18nConfig = { export type I18nConfig = {

View file

@ -123,7 +123,7 @@ describe('localizePath', () => {
path: 'i18n', path: 'i18n',
locales: ['en', 'fr'], locales: ['en', 'fr'],
currentLocale: 'fr', currentLocale: 'fr',
localeConfigs: {}, localeConfigs: {fr: {path: 'fr'}, en: {path: 'en'}},
}, },
options: {localizePath: true}, options: {localizePath: true},
}), }),
@ -140,7 +140,7 @@ describe('localizePath', () => {
path: 'i18n', path: 'i18n',
locales: ['en', 'fr'], locales: ['en', 'fr'],
currentLocale: 'en', currentLocale: 'en',
localeConfigs: {}, localeConfigs: {fr: {path: 'fr'}, en: {path: 'en'}},
}, },
options: {localizePath: true}, options: {localizePath: true},
}), }),
@ -157,7 +157,7 @@ describe('localizePath', () => {
path: 'i18n', path: 'i18n',
locales: ['en', 'fr'], locales: ['en', 'fr'],
currentLocale: 'en', currentLocale: 'en',
localeConfigs: {}, localeConfigs: {fr: {path: 'fr'}, en: {path: 'en'}},
}, },
}), }),
).toBe('/baseUrl/'); ).toBe('/baseUrl/');
@ -173,7 +173,7 @@ describe('localizePath', () => {
path: 'i18n', path: 'i18n',
locales: ['en', 'fr'], locales: ['en', 'fr'],
currentLocale: 'en', currentLocale: 'en',
localeConfigs: {}, localeConfigs: {fr: {path: 'fr'}, en: {path: 'en'}},
}, },
}), }),
).toBe('/baseUrl/'); ).toBe('/baseUrl/');

View file

@ -68,6 +68,9 @@ export function getPluginI18nPath({
/** /**
* Takes a path and returns a localized a version (which is basically `path + * Takes a path and returns a localized a version (which is basically `path +
* i18n.currentLocale`). * i18n.currentLocale`).
*
* This is used to resolve the `outDir` and `baseUrl` of each locale; it is NOT
* used to determine plugin localization file locations.
*/ */
export function localizePath({ export function localizePath({
pathType, pathType,
@ -94,13 +97,15 @@ export function localizePath({
}; };
}): string { }): string {
const shouldLocalizePath: boolean = const shouldLocalizePath: boolean =
//
options.localizePath ?? i18n.currentLocale !== i18n.defaultLocale; options.localizePath ?? i18n.currentLocale !== i18n.defaultLocale;
if (!shouldLocalizePath) { if (!shouldLocalizePath) {
return originalPath; return originalPath;
} }
// FS paths need special care, for Windows support // FS paths need special care, for Windows support. Note: we don't use the
// locale config's `path` here, because this function is used for resolving
// outDir, which must be the same as baseUrl. When we have the baseUrl config,
// we need to sync the two.
if (pathType === 'fs') { if (pathType === 'fs') {
return path.join(originalPath, i18n.currentLocale); return path.join(originalPath, i18n.currentLocale);
} }

View file

@ -0,0 +1,17 @@
module.exports = {
title: 'Site',
url: 'https://example.com',
baseUrl: '/',
i18n: {
locales: ['en', 'zh-Hans'],
defaultLocale: 'en',
localeConfigs: {
en: {
path: 'en-custom'
},
'zh-Hans': {
path: 'zh-Hans-custom'
}
}
}
};

View file

@ -0,0 +1,118 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`load loads props for site with custom i18n path 1`] = `
{
"baseUrl": "/",
"codeTranslations": {},
"generatedFilesDir": "<PROJECT_ROOT>/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site/.docusaurus",
"headTags": "",
"i18n": {
"currentLocale": "en",
"defaultLocale": "en",
"localeConfigs": {
"en": {
"calendar": "gregory",
"direction": "ltr",
"htmlLang": "en",
"label": "English",
"path": "en-custom",
},
"zh-Hans": {
"calendar": "gregory",
"direction": "ltr",
"htmlLang": "zh-Hans",
"label": "简体中文",
"path": "zh-Hans-custom",
},
},
"locales": [
"en",
"zh-Hans",
],
"path": "i18n",
},
"localizationDir": "<PROJECT_ROOT>/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site/i18n/en-custom",
"outDir": "<PROJECT_ROOT>/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site/build",
"plugins": [
{
"content": undefined,
"getClientModules": [Function],
"injectHtmlTags": [Function],
"name": "docusaurus-bootstrap-plugin",
"options": {
"id": "default",
},
"path": "<PROJECT_ROOT>/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site",
"version": {
"type": "synthetic",
},
},
{
"configureWebpack": [Function],
"content": undefined,
"name": "docusaurus-mdx-fallback-plugin",
"options": {
"id": "default",
},
"path": ".",
"version": {
"type": "synthetic",
},
},
],
"postBodyTags": "",
"preBodyTags": "",
"routes": [],
"routesPaths": [
"/404.html",
],
"siteConfig": {
"baseUrl": "/",
"baseUrlIssueBanner": true,
"clientModules": [],
"customFields": {},
"i18n": {
"defaultLocale": "en",
"localeConfigs": {
"en": {
"direction": "ltr",
"path": "en-custom",
},
"zh-Hans": {
"direction": "ltr",
"path": "zh-Hans-custom",
},
},
"locales": [
"en",
"zh-Hans",
],
"path": "i18n",
},
"noIndex": false,
"onBrokenLinks": "throw",
"onBrokenMarkdownLinks": "warn",
"onDuplicateRoutes": "warn",
"plugins": [],
"presets": [],
"scripts": [],
"staticDirectories": [
"static",
],
"stylesheets": [],
"tagline": "",
"themeConfig": {},
"themes": [],
"title": "Site",
"titleDelimiter": "|",
"url": "https://example.com",
},
"siteConfigPath": "<PROJECT_ROOT>/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site/docusaurus.config.js",
"siteDir": "<PROJECT_ROOT>/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site",
"siteMetadata": {
"docusaurusVersion": "<CURRENT_VERSION>",
"pluginVersions": {},
"siteVersion": undefined,
},
}
`;

View file

@ -32,42 +32,49 @@ describe('defaultLocaleConfig', () => {
direction: 'ltr', direction: 'ltr',
htmlLang: 'fr', htmlLang: 'fr',
calendar: 'gregory', calendar: 'gregory',
path: 'fr',
}); });
expect(getDefaultLocaleConfig('fr-FR')).toEqual({ expect(getDefaultLocaleConfig('fr-FR')).toEqual({
label: 'Français (France)', label: 'Français (France)',
direction: 'ltr', direction: 'ltr',
htmlLang: 'fr-FR', htmlLang: 'fr-FR',
calendar: 'gregory', calendar: 'gregory',
path: 'fr-FR',
}); });
expect(getDefaultLocaleConfig('en')).toEqual({ expect(getDefaultLocaleConfig('en')).toEqual({
label: 'English', label: 'English',
direction: 'ltr', direction: 'ltr',
htmlLang: 'en', htmlLang: 'en',
calendar: 'gregory', calendar: 'gregory',
path: 'en',
}); });
expect(getDefaultLocaleConfig('en-US')).toEqual({ expect(getDefaultLocaleConfig('en-US')).toEqual({
label: 'American English', label: 'American English',
direction: 'ltr', direction: 'ltr',
htmlLang: 'en-US', htmlLang: 'en-US',
calendar: 'gregory', calendar: 'gregory',
path: 'en-US',
}); });
expect(getDefaultLocaleConfig('zh')).toEqual({ expect(getDefaultLocaleConfig('zh')).toEqual({
label: '中文', label: '中文',
direction: 'ltr', direction: 'ltr',
htmlLang: 'zh', htmlLang: 'zh',
calendar: 'gregory', calendar: 'gregory',
path: 'zh',
}); });
expect(getDefaultLocaleConfig('zh-CN')).toEqual({ expect(getDefaultLocaleConfig('zh-CN')).toEqual({
label: '中文(中国)', label: '中文(中国)',
direction: 'ltr', direction: 'ltr',
htmlLang: 'zh-CN', htmlLang: 'zh-CN',
calendar: 'gregory', calendar: 'gregory',
path: 'zh-CN',
}); });
expect(getDefaultLocaleConfig('en-US')).toEqual({ expect(getDefaultLocaleConfig('en-US')).toEqual({
label: 'American English', label: 'American English',
direction: 'ltr', direction: 'ltr',
htmlLang: 'en-US', htmlLang: 'en-US',
calendar: 'gregory', calendar: 'gregory',
path: 'en-US',
}); });
expect(getDefaultLocaleConfig('fa')).toEqual({ expect(getDefaultLocaleConfig('fa')).toEqual({
// cSpell:ignore فارسی // cSpell:ignore فارسی
@ -75,6 +82,7 @@ describe('defaultLocaleConfig', () => {
direction: 'rtl', direction: 'rtl',
htmlLang: 'fa', htmlLang: 'fa',
calendar: 'gregory', calendar: 'gregory',
path: 'fa',
}); });
expect(getDefaultLocaleConfig('fa-IR')).toEqual({ expect(getDefaultLocaleConfig('fa-IR')).toEqual({
// cSpell:ignore ایران فارسیا // cSpell:ignore ایران فارسیا
@ -82,12 +90,14 @@ describe('defaultLocaleConfig', () => {
direction: 'rtl', direction: 'rtl',
htmlLang: 'fa-IR', htmlLang: 'fa-IR',
calendar: 'gregory', calendar: 'gregory',
path: 'fa-IR',
}); });
expect(getDefaultLocaleConfig('en-US-u-ca-buddhist')).toEqual({ expect(getDefaultLocaleConfig('en-US-u-ca-buddhist')).toEqual({
label: 'American English', label: 'American English',
direction: 'ltr', direction: 'ltr',
htmlLang: 'en-US-u-ca-buddhist', htmlLang: 'en-US-u-ca-buddhist',
calendar: 'buddhist', calendar: 'buddhist',
path: 'en-US-u-ca-buddhist',
}); });
}); });
}); });
@ -170,6 +180,7 @@ describe('loadI18n', () => {
direction: 'ltr', direction: 'ltr',
htmlLang: 'fr', htmlLang: 'fr',
calendar: 'gregory', calendar: 'gregory',
path: 'fr',
}, },
en: getDefaultLocaleConfig('en'), en: getDefaultLocaleConfig('en'),
de: getDefaultLocaleConfig('de'), de: getDefaultLocaleConfig('de'),

View file

@ -0,0 +1,45 @@
/**
* 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.
*/
import path from 'path';
import {mergeWithCustomize} from 'webpack-merge';
import {loadSetup} from './testUtils';
import type {Props} from '@docusaurus/types';
import type {DeepPartial} from 'utility-types';
describe('load', () => {
it('loads props for site with custom i18n path', async () => {
const props = await loadSetup('custom-i18n-site');
expect(props).toMatchSnapshot();
const props2 = await loadSetup('custom-i18n-site', {locale: 'zh-Hans'});
expect(props2).toEqual(
mergeWithCustomize<DeepPartial<Props>>({
customizeArray(a, b, key) {
return ['routesPaths', 'plugins'].includes(key) ? b : undefined;
},
})(props, {
baseUrl: '/zh-Hans/',
i18n: {
currentLocale: 'zh-Hans',
},
localizationDir: path.join(
__dirname,
'__fixtures__/custom-i18n-site/i18n/zh-Hans-custom',
),
outDir: path.join(
__dirname,
'__fixtures__/custom-i18n-site/build/zh-Hans',
),
routesPaths: ['/zh-Hans/404.html'],
siteConfig: {
baseUrl: '/zh-Hans/',
},
plugins: props2.plugins,
}),
);
});
});

View file

@ -6,20 +6,14 @@
*/ */
import path from 'path'; import path from 'path';
import {load} from '../index'; import {load, type LoadContextOptions} from '../index';
import type {Props} from '@docusaurus/types'; import type {Props} from '@docusaurus/types';
// Helper methods to setup dummy/fake projects. // Helper methods to setup dummy/fake projects.
export default async function loadSetup(name: string): Promise<Props> { export async function loadSetup(
name: string,
options?: Partial<LoadContextOptions>,
): Promise<Props> {
const fixtures = path.join(__dirname, '__fixtures__'); const fixtures = path.join(__dirname, '__fixtures__');
const simpleSite = path.join(fixtures, 'simple-site'); return load({siteDir: path.join(fixtures, name), ...options});
const customSite = path.join(fixtures, 'custom-site');
switch (name) {
case 'custom':
return load({siteDir: customSite});
case 'simple':
default:
return load({siteDir: simpleSite});
}
} }

View file

@ -135,6 +135,7 @@ const LocaleConfigSchema = Joi.object({
htmlLang: Joi.string(), htmlLang: Joi.string(),
direction: Joi.string().equal('ltr', 'rtl').default('ltr'), direction: Joi.string().equal('ltr', 'rtl').default('ltr'),
calendar: Joi.string(), calendar: Joi.string(),
path: Joi.string(),
}); });
const I18N_CONFIG_SCHEMA = Joi.object<I18nConfig>({ const I18N_CONFIG_SCHEMA = Joi.object<I18nConfig>({

View file

@ -26,6 +26,7 @@ export function getDefaultLocaleConfig(locale: string): I18nLocaleConfig {
htmlLang: locale, htmlLang: locale,
// If the locale name includes -u-ca-xxx the calendar will be defined // If the locale name includes -u-ca-xxx the calendar will be defined
calendar: new Intl.Locale(locale).calendar ?? 'gregory', calendar: new Intl.Locale(locale).calendar ?? 'gregory',
path: locale,
}; };
} }

View file

@ -85,8 +85,11 @@ export async function loadContext(
const siteConfig: DocusaurusConfig = {...initialSiteConfig, baseUrl}; const siteConfig: DocusaurusConfig = {...initialSiteConfig, baseUrl};
// TODO allow customizing localizationDir per-locale const localizationDir = path.resolve(
const localizationDir = path.resolve(siteDir, i18n.path, i18n.currentLocale); siteDir,
i18n.path,
i18n.localeConfigs[i18n.currentLocale]!.path,
);
const codeTranslationFileContent = const codeTranslationFileContent =
(await readCodeTranslationFileContent({localizationDir})) ?? {}; (await readCodeTranslationFileContent({localizationDir})) ?? {};

View file

@ -8,18 +8,18 @@
import webpack from 'webpack'; import webpack from 'webpack';
import createClientConfig from '../client'; import createClientConfig from '../client';
import loadSetup from '../../server/__tests__/testUtils'; import {loadSetup} from '../../server/__tests__/testUtils';
describe('webpack dev config', () => { describe('webpack dev config', () => {
it('simple', async () => { it('simple', async () => {
const props = await loadSetup('simple'); const props = await loadSetup('simple-site');
const config = await createClientConfig(props); const config = await createClientConfig(props);
const errors = webpack.validate(config); const errors = webpack.validate(config);
expect(errors).toBeUndefined(); expect(errors).toBeUndefined();
}); });
it('custom', async () => { it('custom', async () => {
const props = await loadSetup('custom'); const props = await loadSetup('custom-site');
const config = await createClientConfig(props); const config = await createClientConfig(props);
const errors = webpack.validate(config); const errors = webpack.validate(config);
expect(errors).toBeUndefined(); expect(errors).toBeUndefined();

View file

@ -9,12 +9,12 @@ import {jest} from '@jest/globals';
import webpack from 'webpack'; import webpack from 'webpack';
import createServerConfig from '../server'; import createServerConfig from '../server';
import loadSetup from '../../server/__tests__/testUtils'; import {loadSetup} from '../../server/__tests__/testUtils';
describe('webpack production config', () => { describe('webpack production config', () => {
it('simple', async () => { it('simple', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'log').mockImplementation(() => {});
const props = await loadSetup('simple'); const props = await loadSetup('simple-site');
const config = await createServerConfig({ const config = await createServerConfig({
props, props,
onHeadTagsCollected: () => {}, onHeadTagsCollected: () => {},
@ -26,7 +26,7 @@ describe('webpack production config', () => {
it('custom', async () => { it('custom', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {}); jest.spyOn(console, 'log').mockImplementation(() => {});
const props = await loadSetup('custom'); const props = await loadSetup('custom-site');
const config = await createServerConfig({ const config = await createServerConfig({
props, props,
onHeadTagsCollected: () => {}, onHeadTagsCollected: () => {},

View file

@ -130,18 +130,21 @@ module.exports = {
i18n: { i18n: {
defaultLocale: 'en', defaultLocale: 'en',
locales: ['en', 'fa'], locales: ['en', 'fa'],
path: 'i18n',
localeConfigs: { localeConfigs: {
en: { en: {
label: 'English', label: 'English',
direction: 'ltr', direction: 'ltr',
htmlLang: 'en-US', htmlLang: 'en-US',
calendar: 'gregory', calendar: 'gregory',
path: 'en',
}, },
fa: { fa: {
label: 'فارسی', label: 'فارسی',
direction: 'rtl', direction: 'rtl',
htmlLang: 'fa-IR', htmlLang: 'fa-IR',
calendar: 'persian', calendar: 'persian',
path: 'fa',
}, },
}, },
}, },
@ -150,11 +153,13 @@ module.exports = {
- `defaultLocale`: The locale that (1) does not have its name in the base URL (2) gets started with `docusaurus start` without `--locale` option (3) will be used for the `<link hrefLang="x-default">` tag - `defaultLocale`: The locale that (1) does not have its name in the base URL (2) gets started with `docusaurus start` without `--locale` option (3) will be used for the `<link hrefLang="x-default">` tag
- `locales`: List of locales deployed on your site. Must contain `defaultLocale`. - `locales`: List of locales deployed on your site. Must contain `defaultLocale`.
- `path`: Root folder which all locale folders are relative to. Can be absolute or relative to the config file. Defaults to `i18n`.
- `localeConfigs`: Individual options for each locale. - `localeConfigs`: Individual options for each locale.
- `label`: The label displayed for this locale in the locales dropdown. - `label`: The label displayed for this locale in the locales dropdown.
- `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. - `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="...">` and in `<link ... hreflang="...">` - `htmlLang`: BCP 47 language tag to use in `<html lang="...">` 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`). - `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.
### `noIndex` {#noIndex} ### `noIndex` {#noIndex}