diff --git a/packages/docusaurus-module-type-aliases/src/index.d.ts b/packages/docusaurus-module-type-aliases/src/index.d.ts index aa70ea2cd691..a40b0cdd7f77 100644 --- a/packages/docusaurus-module-type-aliases/src/index.d.ts +++ b/packages/docusaurus-module-type-aliases/src/index.d.ts @@ -52,6 +52,7 @@ declare module '@generated/i18n' { defaultLocale: string; locales: [string, ...string[]]; currentLocale: string; + localeConfigs: Record; }; export default i18n; } diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem.tsx new file mode 100644 index 000000000000..93cdafe781eb --- /dev/null +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem.tsx @@ -0,0 +1,74 @@ +/** + * 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 React from 'react'; +import DefaultNavbarItem from './DefaultNavbarItem'; +import type {Props} from '@theme/NavbarItem/LocaleDropdownNavbarItem'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import {useLocation} from '@docusaurus/router'; + +export default function LocaleDropdownNavbarItem({ + mobile, + ...props +}: Props): JSX.Element { + const { + siteConfig: {baseUrl}, + i18n: {defaultLocale, currentLocale, locales, localeConfigs}, + } = useDocusaurusContext(); + const {pathname} = useLocation(); + + function getLocaleLabel(locale) { + return localeConfigs[locale].label; + } + + // TODO Docusaurus could offer some APIs to we should probably + const baseUrlUnlocalized = + currentLocale === defaultLocale + ? baseUrl + : baseUrl.replace(`/${currentLocale}/`, '/'); + + const pathnameSuffix = pathname.replace(baseUrl, ''); + + function getLocalizedBaseUrl(locale) { + return locale === defaultLocale + ? `${baseUrlUnlocalized}` + : `${baseUrlUnlocalized}${locale}/`; + } + + const items = locales.map((locale) => { + const to = `${getLocalizedBaseUrl(locale)}${pathnameSuffix}`; + console.log({ + locale, + to, + pathname, + baseUrl, + baseUrlUnlocalized, + pathnameSuffix, + }); + + return { + isNavLink: true, + label: getLocaleLabel(locale), + to: `pathname://${to}`, + target: '_self', + autoAddBaseUrl: false, + className: locale === currentLocale ? 'dropdown__link--active' : '', + }; + }); + + // Mobile is handled a bit differently + const dropdownLabel = mobile ? 'Languages' : getLocaleLabel(currentLocale); + + return ( + + ); +} diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/index.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/index.tsx index fbcfc6868cc3..802ee0f7a9bc 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/index.tsx @@ -7,10 +7,12 @@ import React from 'react'; import DefaultNavbarItem from '@theme/NavbarItem/DefaultNavbarItem'; +import LocaleDropdownNavbarItem from '@theme/NavbarItem/LocaleDropdownNavbarItem'; import type {Props} from '@theme/NavbarItem'; const NavbarItemComponents = { default: () => DefaultNavbarItem, + localeDropdown: () => LocaleDropdownNavbarItem, // Need to lazy load these items as we don't know for sure the docs plugin is loaded // See https://github.com/facebook/docusaurus/issues/3360 diff --git a/packages/docusaurus-theme-classic/src/types.d.ts b/packages/docusaurus-theme-classic/src/types.d.ts index c21206992910..45d4257d85b1 100644 --- a/packages/docusaurus-theme-classic/src/types.d.ts +++ b/packages/docusaurus-theme-classic/src/types.d.ts @@ -304,6 +304,15 @@ declare module '@theme/NavbarItem/DefaultNavbarItem' { export default DefaultNavbarItem; } +declare module '@theme/NavbarItem/LocaleDropdownNavbarItem' { + import type {Props as DefaultNavbarItemProps} from '@theme/NavbarItem/DefaultNavbarItem'; + + export type Props = DefaultNavbarItemProps; + + const LocaleDropdownNavbarItem: (props: Props) => JSX.Element; + export default LocaleDropdownNavbarItem; +} + declare module '@theme/NavbarItem/DocsVersionDropdownNavbarItem' { import type {Props as DefaultNavbarItemProps} from '@theme/NavbarItem/DefaultNavbarItem'; import type {NavLinkProps} from '@theme/NavbarItem/DefaultNavbarItem'; diff --git a/packages/docusaurus-theme-classic/src/validateThemeConfig.js b/packages/docusaurus-theme-classic/src/validateThemeConfig.js index 03f5c8dd332c..32355d73cd8c 100644 --- a/packages/docusaurus-theme-classic/src/validateThemeConfig.js +++ b/packages/docusaurus-theme-classic/src/validateThemeConfig.js @@ -96,6 +96,11 @@ const DocItemSchema = Joi.object({ activeSidebarClassName: Joi.string().default('navbar__link--active'), }); +const LocaleDropdownNavbarItemSchema = Joi.object({ + type: Joi.string().equal('localeDropdown').required(), + position: NavbarItemPosition, +}); + // Can this be made easier? :/ const isOfType = (type) => { let typeSchema = Joi.string().required(); @@ -124,10 +129,14 @@ const NavbarItemSchema = Joi.object().when({ is: isOfType('doc'), then: DocItemSchema, }, + { + is: isOfType('localeDropdown'), + then: LocaleDropdownNavbarItemSchema, + }, { is: isOfType(undefined), then: Joi.forbidden().messages({ - 'any.unknown': 'Bad nav item type {.type}', + 'any.unknown': 'Bad navbar item type {.type}', }), }, ], diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 4812739ffd3a..5612d4522548 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -92,9 +92,14 @@ export type TranslationFileContent = Record; export type TranslationFile = {path: string; content: TranslationFileContent}; export type TranslationFiles = TranslationFile[]; +export type I18nLocaleConfig = { + label: string; +}; + export type I18nConfig = { defaultLocale: string; locales: [string, ...string[]]; + localeConfigs: Record; }; export type I18n = I18nConfig & { diff --git a/packages/docusaurus/src/client/exports/Link.tsx b/packages/docusaurus/src/client/exports/Link.tsx index cada960b349e..c3ce4fae8b55 100644 --- a/packages/docusaurus/src/client/exports/Link.tsx +++ b/packages/docusaurus/src/client/exports/Link.tsx @@ -26,6 +26,7 @@ interface Props { readonly activeClassName?: string; readonly children?: ReactNode; readonly isActive?: () => boolean; + readonly autoAddBaseUrl?: boolean; // escape hatch in case broken links check is annoying for a specific link readonly 'data-noBrokenLinkCheck'?: boolean; @@ -45,6 +46,7 @@ function Link({ activeClassName, isActive, 'data-noBrokenLinkCheck': noBrokenLinkCheck, + autoAddBaseUrl = true, ...props }: Props): JSX.Element { const {withBaseUrl} = useBaseUrlUtils(); @@ -57,7 +59,9 @@ function Link({ const targetLinkUnprefixed = to || href; function maybeAddBaseUrl(str: string) { - return shouldAddBaseUrlAutomatically(str) ? withBaseUrl(str) : str; + return autoAddBaseUrl && shouldAddBaseUrlAutomatically(str) + ? withBaseUrl(str) + : str; } const isInternal = isInternalUrl(targetLinkUnprefixed); diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap index f68ff8b45978..d0d71639874e 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -23,6 +23,7 @@ Object { "favicon": "img/docusaurus.ico", "i18n": Object { "defaultLocale": "en", + "localeConfigs": Object {}, "locales": Array [ "en", ], diff --git a/packages/docusaurus/src/server/__tests__/i18n.test.ts b/packages/docusaurus/src/server/__tests__/i18n.test.ts index 39ab34c4b327..7014ac860124 100644 --- a/packages/docusaurus/src/server/__tests__/i18n.test.ts +++ b/packages/docusaurus/src/server/__tests__/i18n.test.ts @@ -5,9 +5,14 @@ * LICENSE file in the root directory of this source tree. */ -import {loadI18n, localizePath} from '../i18n'; +import {loadI18n, localizePath, defaultLocaleConfig} from '../i18n'; import {DEFAULT_I18N_CONFIG} from '../configValidation'; import path from 'path'; +import {chain, identity} from 'lodash'; + +function testLocaleConfigsFor(locales: string[]) { + return chain(locales).keyBy(identity).mapValues(defaultLocaleConfig).value(); +} describe('loadI18n', () => { test('should load I18n for default config', async () => { @@ -22,6 +27,7 @@ describe('loadI18n', () => { defaultLocale: 'en', locales: ['en'], currentLocale: 'en', + localeConfigs: testLocaleConfigsFor(['en']), }); }); @@ -33,6 +39,7 @@ describe('loadI18n', () => { i18n: { defaultLocale: 'fr', locales: ['en', 'fr', 'de'], + localeConfigs: {}, }, }, ), @@ -40,6 +47,7 @@ describe('loadI18n', () => { defaultLocale: 'fr', locales: ['en', 'fr', 'de'], currentLocale: 'fr', + localeConfigs: testLocaleConfigsFor(['en', 'fr', 'de']), }); }); @@ -51,6 +59,31 @@ describe('loadI18n', () => { i18n: { defaultLocale: 'fr', locales: ['en', 'fr', 'de'], + localeConfigs: {}, + }, + }, + {locale: 'de'}, + ), + ).resolves.toEqual({ + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + currentLocale: 'de', + localeConfigs: testLocaleConfigsFor(['en', 'fr', 'de']), + }); + }); + + test('should load I18n for multi-locale config with some xcustom locale configs', async () => { + await expect( + loadI18n( + { + i18n: { + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + localeConfigs: { + fr: {label: 'Français'}, + // @ts-expect-error: empty on purpose + en: {}, + }, }, }, {locale: 'de'}, @@ -59,6 +92,11 @@ describe('loadI18n', () => { defaultLocale: 'fr', locales: ['en', 'fr', 'de'], currentLocale: 'de', + localeConfigs: { + fr: {label: 'Français'}, + en: defaultLocaleConfig('en'), + de: defaultLocaleConfig('de'), + }, }); }); @@ -70,6 +108,7 @@ describe('loadI18n', () => { i18n: { defaultLocale: 'fr', locales: ['en', 'fr', 'de'], + localeConfigs: {}, }, }, {locale: 'it'}, @@ -88,6 +127,7 @@ describe('localizePath', () => { defaultLocale: 'en', locales: ['en', 'fr'], currentLocale: 'fr', + localeConfigs: {}, }, options: {localizePath: true}, }), @@ -103,6 +143,7 @@ describe('localizePath', () => { defaultLocale: 'en', locales: ['en', 'fr'], currentLocale: 'fr', + localeConfigs: {}, }, options: {localizePath: true}, }), @@ -118,6 +159,7 @@ describe('localizePath', () => { defaultLocale: 'en', locales: ['en', 'fr'], currentLocale: 'en', + localeConfigs: {}, }, options: {localizePath: true}, }), @@ -133,6 +175,7 @@ describe('localizePath', () => { defaultLocale: 'en', locales: ['en', 'fr'], currentLocale: 'en', + localeConfigs: {}, }, // options: {localizePath: true}, }), @@ -148,6 +191,7 @@ describe('localizePath', () => { defaultLocale: 'en', locales: ['en', 'fr'], currentLocale: 'en', + localeConfigs: {}, }, // options: {localizePath: true}, }), diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index 177dd9a0890e..bc419a1b4b50 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -19,6 +19,7 @@ const DEFAULT_I18N_LOCALE = 'en'; export const DEFAULT_I18N_CONFIG: I18nConfig = { defaultLocale: DEFAULT_I18N_LOCALE, locales: [DEFAULT_I18N_LOCALE], + localeConfigs: {}, }; export const DEFAULT_CONFIG: Pick< @@ -66,9 +67,16 @@ const PresetSchema = Joi.alternatives().try( Joi.array().items(Joi.string().required(), Joi.object().required()).length(2), ); +const LocaleConfigSchema = Joi.object({ + label: Joi.string(), +}); + const I18N_CONFIG_SCHEMA = Joi.object({ defaultLocale: Joi.string().required(), locales: Joi.array().items().min(1).items(Joi.string().required()).required(), + localeConfigs: Joi.object() + .pattern(/.*/, LocaleConfigSchema) + .default(DEFAULT_I18N_CONFIG.localeConfigs), }) .optional() .default(DEFAULT_I18N_CONFIG); diff --git a/packages/docusaurus/src/server/i18n.ts b/packages/docusaurus/src/server/i18n.ts index 636b639f2ff6..52a33e5271d9 100644 --- a/packages/docusaurus/src/server/i18n.ts +++ b/packages/docusaurus/src/server/i18n.ts @@ -4,10 +4,16 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. */ -import {I18n, DocusaurusConfig} from '@docusaurus/types'; +import {I18n, DocusaurusConfig, I18nLocaleConfig} from '@docusaurus/types'; import path from 'path'; import {normalizeUrl} from '@docusaurus/utils'; +export function defaultLocaleConfig(locale: string): I18nLocaleConfig { + return { + label: locale, + }; +} + export async function loadI18n( config: DocusaurusConfig, options: {locale?: string} = {}, @@ -25,9 +31,22 @@ Note: Docusaurus only support running one local at a time.`, ); } + function getLocaleConfig(locale: string): I18nLocaleConfig { + // User provided values + const localeConfigOptions: Partial = + i18nConfig.localeConfigs[locale]; + + return {...defaultLocaleConfig(locale), ...localeConfigOptions}; + } + + const localeConfigs = i18nConfig.locales.reduce((acc, locale) => { + return {...acc, [locale]: getLocaleConfig(locale)}; + }, {}); + return { ...i18nConfig, currentLocale, + localeConfigs, }; } diff --git a/website/docusaurus.config.js b/website/docusaurus.config.js index 528ff0aa421d..f5e25eb4b00e 100644 --- a/website/docusaurus.config.js +++ b/website/docusaurus.config.js @@ -52,6 +52,14 @@ module.exports = { i18n: { defaultLocale: 'en', locales: ['en', 'fr'], + localeConfigs: { + en: { + label: 'English', + }, + fr: { + label: 'Français', + }, + }, }, onBrokenLinks: 'throw', onBrokenMarkdownLinks: 'warn', @@ -303,6 +311,7 @@ module.exports = { }, ], }, + // {type: 'localeDropdown', position: 'right'}, { href: 'https://github.com/facebook/docusaurus', position: 'right',