From 806fdbaf275f3bdf01f4bfa16acdf3d32f9b69d6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Thu, 18 Mar 2021 11:43:07 +0100 Subject: [PATCH] feat(v2): infer default i18n locale config from locale code (#4449) * improve locale default config * remove localeConfigs from i18n tutorial * better i18n types, tests and Intl.DisplayNames integration --- .../NavbarItem/LocaleDropdownNavbarItem.tsx | 1 + packages/docusaurus-types/src/index.d.ts | 7 +- packages/docusaurus/package.json | 1 + packages/docusaurus/src/constants.ts | 5 + .../__tests__/__snapshots__/i18n.test.ts.snap | 7 - .../src/server/__tests__/i18n.test.ts | 170 ++++++++++++------ packages/docusaurus/src/server/i18n.ts | 68 +++++-- website/docs/i18n/i18n-tutorial.md | 8 - website/docusaurus.config.js | 27 +-- yarn.lock | 5 + 10 files changed, 189 insertions(+), 110 deletions(-) delete mode 100644 packages/docusaurus/src/server/__tests__/__snapshots__/i18n.test.ts.snap diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem.tsx index 38b7328e4e..800abdcca1 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem.tsx @@ -39,6 +39,7 @@ export default function LocaleDropdownNavbarItem({ target: '_self', autoAddBaseUrl: false, className: locale === currentLocale ? 'dropdown__link--active' : '', + style: {textTransform: 'capitalize'}, }; }); diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index cec7c1ee4b..aad332f1c9 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -101,11 +101,14 @@ export type I18nLocaleConfig = { export type I18nConfig = { defaultLocale: string; locales: [string, ...string[]]; - localeConfigs: Record; + localeConfigs: Record>; }; -export type I18n = I18nConfig & { +export type I18n = { + defaultLocale: string; + locales: [string, ...string[]]; currentLocale: string; + localeConfigs: Record; }; export interface DocusaurusContext { diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json index ada4dbe087..159b938505 100644 --- a/packages/docusaurus/package.json +++ b/packages/docusaurus/package.json @@ -102,6 +102,7 @@ "react-router-config": "^5.1.1", "react-router-dom": "^5.2.0", "resolve-pathname": "^3.0.0", + "rtl-detect": "^1.0.2", "semver": "^7.3.4", "serve-handler": "^6.1.3", "shelljs": "^0.8.4", diff --git a/packages/docusaurus/src/constants.ts b/packages/docusaurus/src/constants.ts index 924afaa670..f6f9ccc76b 100644 --- a/packages/docusaurus/src/constants.ts +++ b/packages/docusaurus/src/constants.ts @@ -5,6 +5,11 @@ * LICENSE file in the root directory of this source tree. */ +export const NODE_MAJOR_VERSION = parseInt( + process.versions.node.split('.')[0], + 10, +); + // Can be overridden with cli option --out-dir export const DEFAULT_BUILD_DIR_NAME = 'build'; diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/i18n.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/i18n.test.ts.snap deleted file mode 100644 index ba00e92bb5..0000000000 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/i18n.test.ts.snap +++ /dev/null @@ -1,7 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`loadI18n should throw when trying to load undeclared locale 1`] = ` -"It is not possible to load Docusaurus with locale=\\"it\\". -This locale is not in the available locales of your site configuration: config.i18n.locales=[en,fr,de] -Note: Docusaurus only support running one locale at a time." -`; diff --git a/packages/docusaurus/src/server/__tests__/i18n.test.ts b/packages/docusaurus/src/server/__tests__/i18n.test.ts index 7cf62035b3..5a9869aa68 100644 --- a/packages/docusaurus/src/server/__tests__/i18n.test.ts +++ b/packages/docusaurus/src/server/__tests__/i18n.test.ts @@ -5,25 +5,100 @@ * LICENSE file in the root directory of this source tree. */ -import {loadI18n, localizePath, defaultLocaleConfig} from '../i18n'; +import { + loadI18n, + localizePath, + getDefaultLocaleConfig, + shouldWarnAboutNodeVersion, +} from '../i18n'; import {DEFAULT_I18N_CONFIG} from '../configValidation'; import path from 'path'; import {chain, identity} from 'lodash'; +import {I18nConfig} from '@docusaurus/types'; function testLocaleConfigsFor(locales: string[]) { - return chain(locales).keyBy(identity).mapValues(defaultLocaleConfig).value(); + return chain(locales) + .keyBy(identity) + .mapValues(getDefaultLocaleConfig) + .value(); } +function loadI18nTest(i18nConfig: I18nConfig, locale?: string) { + return loadI18n( + // @ts-expect-error: enough for this test + { + i18n: i18nConfig, + }, + {locale}, + ); +} + +describe('defaultLocaleConfig', () => { + // @ts-expect-error: wait for TS support of ES2021 feature + const canComputeLabel = typeof Intl.DisplayNames !== 'undefined'; + + test('returns correct labels', () => { + expect(getDefaultLocaleConfig('fr')).toEqual({ + label: canComputeLabel ? 'français' : 'fr', + direction: 'ltr', + }); + expect(getDefaultLocaleConfig('fr-FR')).toEqual({ + label: canComputeLabel ? 'français (France)' : 'fr-FR', + direction: 'ltr', + }); + expect(getDefaultLocaleConfig('en')).toEqual({ + label: canComputeLabel ? 'English' : 'en', + direction: 'ltr', + }); + expect(getDefaultLocaleConfig('en-US')).toEqual({ + label: canComputeLabel ? 'American English' : 'en-US', + direction: 'ltr', + }); + expect(getDefaultLocaleConfig('zh')).toEqual({ + label: canComputeLabel ? '中文' : 'zh', + direction: 'ltr', + }); + expect(getDefaultLocaleConfig('zh-CN')).toEqual({ + label: canComputeLabel ? '中文(中国)' : 'zh-CN', + direction: 'ltr', + }); + expect(getDefaultLocaleConfig('en-US')).toEqual({ + label: canComputeLabel ? 'American English' : 'en-US', + direction: 'ltr', + }); + expect(getDefaultLocaleConfig('fa')).toEqual({ + label: canComputeLabel ? 'فارسی' : 'fa', + direction: 'rtl', + }); + expect(getDefaultLocaleConfig('fa-IR')).toEqual({ + label: canComputeLabel ? 'فارسی (ایران)' : 'fa-IR', + direction: 'rtl', + }); + }); +}); + +describe('shouldWarnAboutNodeVersion', () => { + test('warns for old NodeJS version and [en,fr]', () => { + expect(shouldWarnAboutNodeVersion(12, ['en', 'fr'])).toEqual(true); + }); + + test('not warn for old NodeJS version and [en]', () => { + expect(shouldWarnAboutNodeVersion(12, ['en'])).toEqual(false); + }); + + test('not warn for recent NodeJS version and [en,fr]', () => { + expect(shouldWarnAboutNodeVersion(14, ['en', 'fr'])).toEqual(false); + }); +}); + describe('loadI18n', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + beforeEach(() => { + consoleSpy.mockClear(); + }); + test('should load I18n for default config', async () => { - await expect( - loadI18n( - // @ts-expect-error: enough for this test - { - i18n: DEFAULT_I18N_CONFIG, - }, - ), - ).resolves.toEqual({ + await expect(loadI18nTest(DEFAULT_I18N_CONFIG)).resolves.toEqual({ defaultLocale: 'en', locales: ['en'], currentLocale: 'en', @@ -33,16 +108,11 @@ describe('loadI18n', () => { test('should load I18n for multi-lang config', async () => { await expect( - loadI18n( - // @ts-expect-error: enough for this test - { - i18n: { - defaultLocale: 'fr', - locales: ['en', 'fr', 'de'], - localeConfigs: {}, - }, - }, - ), + loadI18nTest({ + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + localeConfigs: {}, + }), ).resolves.toEqual({ defaultLocale: 'fr', locales: ['en', 'fr', 'de'], @@ -53,16 +123,13 @@ describe('loadI18n', () => { test('should load I18n for multi-locale config with specified locale', async () => { await expect( - loadI18n( - // @ts-expect-error: enough for this test + loadI18nTest( { - i18n: { - defaultLocale: 'fr', - locales: ['en', 'fr', 'de'], - localeConfigs: {}, - }, + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + localeConfigs: {}, }, - {locale: 'de'}, + 'de', ), ).resolves.toEqual({ defaultLocale: 'fr', @@ -74,19 +141,16 @@ describe('loadI18n', () => { test('should load I18n for multi-locale config with some xcustom locale configs', async () => { await expect( - loadI18n( + loadI18nTest( { - i18n: { - defaultLocale: 'fr', - locales: ['en', 'fr', 'de'], - localeConfigs: { - fr: {label: 'Français'}, - // @ts-expect-error: empty on purpose - en: {}, - }, + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + localeConfigs: { + fr: {label: 'Français'}, + en: {}, }, }, - {locale: 'de'}, + 'de', ), ).resolves.toEqual({ defaultLocale: 'fr', @@ -94,26 +158,24 @@ describe('loadI18n', () => { currentLocale: 'de', localeConfigs: { fr: {label: 'Français', direction: 'ltr'}, - en: defaultLocaleConfig('en'), - de: defaultLocaleConfig('de'), + en: getDefaultLocaleConfig('en'), + de: getDefaultLocaleConfig('de'), }, }); }); - test('should throw when trying to load undeclared locale', async () => { - await expect( - loadI18n( - // @ts-expect-error: enough for this test - { - i18n: { - defaultLocale: 'fr', - locales: ['en', 'fr', 'de'], - localeConfigs: {}, - }, - }, - {locale: 'it'}, - ), - ).rejects.toThrowErrorMatchingSnapshot(); + test('should warn when trying to load undeclared locale', async () => { + await loadI18nTest( + { + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + localeConfigs: {}, + }, + 'it', + ); + expect(consoleSpy.mock.calls[0][0]).toMatch( + /The locale=it was not found in your site configuration/, + ); }); }); diff --git a/packages/docusaurus/src/server/i18n.ts b/packages/docusaurus/src/server/i18n.ts index bec9250191..db26fc6345 100644 --- a/packages/docusaurus/src/server/i18n.ts +++ b/packages/docusaurus/src/server/i18n.ts @@ -7,45 +7,79 @@ import {I18n, DocusaurusConfig, I18nLocaleConfig} from '@docusaurus/types'; import path from 'path'; import {normalizeUrl} from '@docusaurus/utils'; +import {getLangDir} from 'rtl-detect'; +import {NODE_MAJOR_VERSION} from '../constants'; +import chalk from 'chalk'; -export function defaultLocaleConfig(locale: string): I18nLocaleConfig { +function getDefaultLocaleLabel(locale: string) { + // Intl.DisplayNames is ES2021 - Node14+ + // https://v8.dev/features/intl-displaynames + // @ts-expect-error: wait for TS support of ES2021 feature + if (typeof Intl.DisplayNames !== 'undefined') { + // @ts-expect-error: wait for TS support of ES2021 feature + return new Intl.DisplayNames([locale], {type: 'language'}).of(locale); + } + return locale; +} + +export function getDefaultLocaleConfig(locale: string): I18nLocaleConfig { return { - label: locale, - direction: 'ltr', + label: getDefaultLocaleLabel(locale), + direction: getLangDir(locale), }; } +export function shouldWarnAboutNodeVersion(version: number, locales: string[]) { + const isOnlyEnglish = locales.length === 1 && locales.includes('en'); + const isOlderNodeVersion = version < 14; + return isOlderNodeVersion && !isOnlyEnglish; +} + export async function loadI18n( config: DocusaurusConfig, options: {locale?: string} = {}, ): Promise { - const i18nConfig = config.i18n; + const {i18n: i18nConfig} = config; + const currentLocale = options.locale ?? i18nConfig.defaultLocale; - if (currentLocale && !i18nConfig.locales.includes(currentLocale)) { - throw new Error( - `It is not possible to load Docusaurus with locale="${currentLocale}". -This locale is not in the available locales of your site configuration: config.i18n.locales=[${i18nConfig.locales.join( - ',', - )}] + if (!i18nConfig.locales.includes(currentLocale)) { + console.warn( + chalk.yellow( + `The locale=${currentLocale} was not found in your site configuration: config.i18n.locales=[${i18nConfig.locales.join( + ',', + )}] Note: Docusaurus only support running one locale at a time.`, + ), + ); + } + + const locales = i18nConfig.locales.includes(currentLocale) + ? i18nConfig.locales + : (i18nConfig.locales.concat(currentLocale) as [string, ...string[]]); + + if (shouldWarnAboutNodeVersion(NODE_MAJOR_VERSION, locales)) { + console.warn( + chalk.yellow( + `To use Docusaurus i18n, it is strongly advised to use NodeJS >= 14 (instead of ${NODE_MAJOR_VERSION})`, + ), ); } function getLocaleConfig(locale: string): I18nLocaleConfig { - // User provided values - const localeConfigOptions: Partial = - i18nConfig.localeConfigs[locale]; - - return {...defaultLocaleConfig(locale), ...localeConfigOptions}; + return { + ...getDefaultLocaleConfig(locale), + ...i18nConfig.localeConfigs[locale], + }; } - const localeConfigs = i18nConfig.locales.reduce((acc, locale) => { + const localeConfigs = locales.reduce((acc, locale) => { return {...acc, [locale]: getLocaleConfig(locale)}; }, {}); return { - ...i18nConfig, + defaultLocale: i18nConfig.defaultLocale, + locales, currentLocale, localeConfigs, }; diff --git a/website/docs/i18n/i18n-tutorial.md b/website/docs/i18n/i18n-tutorial.md index 1846682227..78626c1d15 100644 --- a/website/docs/i18n/i18n-tutorial.md +++ b/website/docs/i18n/i18n-tutorial.md @@ -24,14 +24,6 @@ module.exports = { i18n: { defaultLocale: 'en', locales: ['en', 'fr'], - localeConfigs: { - en: { - label: 'English', - }, - fr: { - label: 'Français', - }, - }, }, }; ``` diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 7cb3ffc07b..71181030b5 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -46,26 +46,6 @@ const isVersioningDisabled = !!process.env.DISABLE_VERSIONING; // https://docusaurus-i18n-staging.netlify.app/ const isI18nStaging = process.env.I18N_STAGING === 'true'; -const LocaleConfigs = isI18nStaging - ? // Staging locales (https://docusaurus-i18n-staging.netlify.app/) - { - en: { - label: 'English', - }, - 'zh-CN': { - label: '简体中文', - }, - } - : // Production locales - { - en: { - label: 'English', - }, - fr: { - label: 'Français', - }, - }; - /** @type {import('@docusaurus/types').DocusaurusConfig} */ (module.exports = { title: 'Docusaurus', @@ -77,8 +57,11 @@ const LocaleConfigs = isI18nStaging url: 'https://v2.docusaurus.io', i18n: { defaultLocale: 'en', - locales: Object.keys(LocaleConfigs), - localeConfigs: LocaleConfigs, + locales: isI18nStaging + ? // Staging locales (https://docusaurus-i18n-staging.netlify.app/) + ['en', 'zh-CN'] + : // Production locales + ['en', 'fr'], }, onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', diff --git a/yarn.lock b/yarn.lock index 96d103e2df..2d5451e209 100644 --- a/yarn.lock +++ b/yarn.lock @@ -17724,6 +17724,11 @@ rsvp@^4.8.4: resolved "https://registry.yarnpkg.com/rsvp/-/rsvp-4.8.5.tgz#c8f155311d167f68f21e168df71ec5b083113734" integrity sha512-nfMOlASu9OnRJo1mbEk2cz0D56a1MBNrJ7orjRZQG10XDyuvwksKbuXNp6qa+kbn839HwjwhBzhFmdsaEAfauA== +rtl-detect@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/rtl-detect/-/rtl-detect-1.0.2.tgz#8eca316f5c6563d54df4e406171dd7819adda67f" + integrity sha512-5X1422hvphzg2a/bo4tIDbjFjbJUOaPZwqE6dnyyxqwFqfR+tBcvfqapJr0o0VygATVCGKiODEewhZtKF+90AA== + rtlcss@^2.6.2: version "2.6.2" resolved "https://registry.yarnpkg.com/rtlcss/-/rtlcss-2.6.2.tgz#55b572b52c70015ba6e03d497e5c5cb8137104b4"