From 2febb76fae9a353778d9d995ced18f9515dab8de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Mon, 28 Jul 2025 17:04:34 +0200 Subject: [PATCH] feat(core): Add `i18n.localeConfigs[locale].{url,baseUrl}` config options (#11316) Co-authored-by: slorber <749374+slorber@users.noreply.github.com> --- .eslintrc.js | 2 +- .../docusaurus-plugin-pwa/src/registerSw.ts | 3 +- .../LocaleDropdownNavbarItem/index.tsx | 87 +++++++-- packages/docusaurus-theme-common/package.json | 1 + packages/docusaurus-theme-common/src/index.ts | 2 + .../src/utils/__tests__/historyUtils.test.ts | 80 +++++++++ .../__tests__/useAlternatePageUtils.test.tsx | 169 ++++++++++++------ .../src/utils/historyUtils.ts | 29 +++ .../src/utils/useAlternatePageUtils.ts | 32 ++-- packages/docusaurus-types/src/i18n.d.ts | 19 ++ .../src/__tests__/i18nUtils.test.ts | 89 +-------- packages/docusaurus-utils/src/i18nUtils.ts | 49 ----- packages/docusaurus-utils/src/index.ts | 1 - .../src/client/exports/ComponentCreator.tsx | 1 - .../docusaurus/src/commands/build/build.ts | 12 +- .../src/commands/build/buildLocale.ts | 3 +- .../src/commands/build/buildUtils.ts | 18 ++ .../docusaurus/src/commands/start/utils.ts | 1 - .../configValidation.test.ts.snap | 4 +- .../__tests__/__snapshots__/site.test.ts.snap | 8 +- .../server/__tests__/configValidation.test.ts | 123 ++++++++++++- .../src/server/__tests__/i18n.test.ts | 146 ++++++++++++++- .../src/server/__tests__/site.test.ts | 2 +- .../docusaurus/src/server/configValidation.ts | 61 ++++--- packages/docusaurus/src/server/i18n.ts | 36 +++- packages/docusaurus/src/server/site.ts | 48 +++-- project-words.txt | 1 - website/docs/api/docusaurus.config.js.mdx | 33 +++- website/docs/i18n/i18n-tutorial.mdx | 41 ++++- 29 files changed, 800 insertions(+), 301 deletions(-) create mode 100644 packages/docusaurus-theme-common/src/utils/__tests__/historyUtils.test.ts create mode 100644 packages/docusaurus/src/commands/build/buildUtils.ts diff --git a/.eslintrc.js b/.eslintrc.js index 502c615068..e8e026c966 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -214,7 +214,7 @@ module.exports = { ], 'no-useless-escape': WARNING, 'no-void': [ERROR, {allowAsStatement: true}], - 'prefer-destructuring': WARNING, + 'prefer-destructuring': OFF, 'prefer-named-capture-group': WARNING, 'prefer-template': WARNING, yoda: WARNING, diff --git a/packages/docusaurus-plugin-pwa/src/registerSw.ts b/packages/docusaurus-plugin-pwa/src/registerSw.ts index d367df63a3..69a6cf3d82 100644 --- a/packages/docusaurus-plugin-pwa/src/registerSw.ts +++ b/packages/docusaurus-plugin-pwa/src/registerSw.ts @@ -9,12 +9,11 @@ import ExecutionEnvironment from '@docusaurus/ExecutionEnvironment'; import {createStorageSlot} from '@docusaurus/theme-common'; // First: read the env variables (provided by Webpack) -/* eslint-disable prefer-destructuring */ + const PWA_SERVICE_WORKER_URL = process.env.PWA_SERVICE_WORKER_URL!; const PWA_OFFLINE_MODE_ACTIVATION_STRATEGIES = process.env .PWA_OFFLINE_MODE_ACTIVATION_STRATEGIES as unknown as (keyof typeof OfflineModeActivationStrategiesImplementations)[]; const PWA_DEBUG = process.env.PWA_DEBUG; -/* eslint-enable prefer-destructuring */ const MAX_MOBILE_WIDTH = 996; diff --git a/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem/index.tsx b/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem/index.tsx index 21b14d417e..9c16b5387c 100644 --- a/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem/index.tsx +++ b/packages/docusaurus-theme-classic/src/theme/NavbarItem/LocaleDropdownNavbarItem/index.tsx @@ -9,7 +9,7 @@ import React, {type ReactNode} from 'react'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useAlternatePageUtils} from '@docusaurus/theme-common/internal'; import {translate} from '@docusaurus/Translate'; -import {useHistorySelector} from '@docusaurus/theme-common'; +import {mergeSearchStrings, useHistorySelector} from '@docusaurus/theme-common'; import DropdownNavbarItem from '@theme/NavbarItem/DropdownNavbarItem'; import IconLanguage from '@theme/Icon/Language'; import type {LinkLikeNavbarItemProps} from '@theme/NavbarItem'; @@ -17,31 +17,80 @@ import type {Props} from '@theme/NavbarItem/LocaleDropdownNavbarItem'; import styles from './styles.module.css'; -export default function LocaleDropdownNavbarItem({ - mobile, - dropdownItemsBefore, - dropdownItemsAfter, - queryString = '', - ...props -}: Props): ReactNode { +function useLocaleDropdownUtils() { const { - i18n: {currentLocale, locales, localeConfigs}, + siteConfig, + i18n: {localeConfigs}, } = useDocusaurusContext(); const alternatePageUtils = useAlternatePageUtils(); const search = useHistorySelector((history) => history.location.search); const hash = useHistorySelector((history) => history.location.hash); - const localeItems = locales.map((locale): LinkLikeNavbarItemProps => { - const baseTo = `pathname://${alternatePageUtils.createUrl({ + const getLocaleConfig = (locale: string) => { + const localeConfig = localeConfigs[locale]; + if (!localeConfig) { + throw new Error( + `Docusaurus bug, no locale config found for locale=${locale}`, + ); + } + return localeConfig; + }; + + const getBaseURLForLocale = (locale: string) => { + const localeConfig = getLocaleConfig(locale); + const isSameDomain = localeConfig.url === siteConfig.url; + if (isSameDomain) { + // Shorter paths if localized sites are hosted on the same domain + // This reduces HTML size a bit + return `pathname://${alternatePageUtils.createUrl({ + locale, + fullyQualified: false, + })}`; + } + return alternatePageUtils.createUrl({ locale, - fullyQualified: false, - })}`; - // preserve ?search#hash suffix on locale switches - const to = `${baseTo}${search}${hash}${queryString}`; + fullyQualified: true, + }); + }; + + return { + getURL: (locale: string, options: {queryString: string | undefined}) => { + // We have 2 query strings because + // - there's the current one + // - there's one user can provide through navbar config + // see https://github.com/facebook/docusaurus/pull/8915 + const finalSearch = mergeSearchStrings( + [search, options.queryString], + 'append', + ); + return `${getBaseURLForLocale(locale)}${finalSearch}${hash}`; + }, + getLabel: (locale: string) => { + return getLocaleConfig(locale).label; + }, + getLang: (locale: string) => { + return getLocaleConfig(locale).htmlLang; + }, + }; +} + +export default function LocaleDropdownNavbarItem({ + mobile, + dropdownItemsBefore, + dropdownItemsAfter, + queryString, + ...props +}: Props): ReactNode { + const utils = useLocaleDropdownUtils(); + + const { + i18n: {currentLocale, locales}, + } = useDocusaurusContext(); + const localeItems = locales.map((locale): LinkLikeNavbarItemProps => { return { - label: localeConfigs[locale]!.label, - lang: localeConfigs[locale]!.htmlLang, - to, + label: utils.getLabel(locale), + lang: utils.getLang(locale), + to: utils.getURL(locale, {queryString}), target: '_self', autoAddBaseUrl: false, className: @@ -66,7 +115,7 @@ export default function LocaleDropdownNavbarItem({ id: 'theme.navbar.mobileLanguageDropdown.label', description: 'The label for the mobile language switcher dropdown', }) - : localeConfigs[currentLocale]!.label; + : utils.getLabel(currentLocale); return ( { + it('can append search params', () => { + expect( + mergeSearchParams( + [ + new URLSearchParams('?key1=val1&key2=val2'), + new URLSearchParams('key2=val2-bis&key3=val3'), + new URLSearchParams(''), + new URLSearchParams('?key3=val3-bis&key4=val4'), + ], + 'append', + ).toString(), + ).toBe( + 'key1=val1&key2=val2&key2=val2-bis&key3=val3&key3=val3-bis&key4=val4', + ); + }); + + it('can overwrite search params', () => { + expect( + mergeSearchParams( + [ + new URLSearchParams('?key1=val1&key2=val2'), + new URLSearchParams('key2=val2-bis&key3=val3'), + new URLSearchParams(''), + new URLSearchParams('?key3=val3-bis&key4=val4'), + ], + 'set', + ).toString(), + ).toBe('key1=val1&key2=val2-bis&key3=val3-bis&key4=val4'); + }); +}); + +describe('mergeSearchStrings', () => { + it('can append search params', () => { + expect( + mergeSearchStrings( + [ + '?key1=val1&key2=val2', + 'key2=val2-bis&key3=val3', + '', + '?key3=val3-bis&key4=val4', + ], + 'append', + ), + ).toBe( + '?key1=val1&key2=val2&key2=val2-bis&key3=val3&key3=val3-bis&key4=val4', + ); + }); + + it('can overwrite search params', () => { + expect( + mergeSearchStrings( + [ + '?key1=val1&key2=val2', + 'key2=val2-bis&key3=val3', + '', + '?key3=val3-bis&key4=val4', + ], + 'set', + ), + ).toBe('?key1=val1&key2=val2-bis&key3=val3-bis&key4=val4'); + }); + + it('automatically adds ? if there are params', () => { + expect(mergeSearchStrings(['key1=val1'], 'append')).toBe('?key1=val1'); + }); + + it('automatically removes ? if there are no params', () => { + expect(mergeSearchStrings([undefined, ''], 'append')).toBe(''); + }); +}); diff --git a/packages/docusaurus-theme-common/src/utils/__tests__/useAlternatePageUtils.test.tsx b/packages/docusaurus-theme-common/src/utils/__tests__/useAlternatePageUtils.test.tsx index fb964b015f..a16832a648 100644 --- a/packages/docusaurus-theme-common/src/utils/__tests__/useAlternatePageUtils.test.tsx +++ b/packages/docusaurus-theme-common/src/utils/__tests__/useAlternatePageUtils.test.tsx @@ -9,116 +9,185 @@ import React from 'react'; import {renderHook} from '@testing-library/react-hooks'; import {StaticRouter} from 'react-router-dom'; import {Context} from '@docusaurus/core/src/client/docusaurusContext'; +import {fromPartial} from '@total-typescript/shoehorn'; import {useAlternatePageUtils} from '../useAlternatePageUtils'; import type {DocusaurusContext} from '@docusaurus/types'; describe('useAlternatePageUtils', () => { - const createUseAlternatePageUtilsMock = - (context: DocusaurusContext) => (location: string) => - renderHook(() => useAlternatePageUtils(), { - wrapper: ({children}) => ( - - {children} - - ), - }).result.current; - it('works for baseUrl: / and currentLocale = defaultLocale', () => { - const mockUseAlternatePageUtils = createUseAlternatePageUtilsMock({ - siteConfig: {baseUrl: '/', url: 'https://example.com'}, - i18n: {defaultLocale: 'en', currentLocale: 'en'}, - } as DocusaurusContext); + const createTestUtils = (context: DocusaurusContext) => { + return { + forLocation: (location: string) => { + return renderHook(() => useAlternatePageUtils(), { + wrapper: ({children}) => ( + + {children} + + ), + }).result.current; + }, + }; + }; + + it('works for baseUrl: / and currentLocale === defaultLocale', () => { + const testUtils = createTestUtils( + fromPartial({ + siteConfig: { + url: 'https://example.com', + baseUrl: '/', + }, + i18n: { + defaultLocale: 'en', + currentLocale: 'en', + localeConfigs: { + en: { + url: 'https://example.com', + baseUrl: '/', + }, + 'zh-Hans': { + url: 'https://zh.example.com', + baseUrl: '/zh-Hans-baseUrl/', + }, + }, + }, + }), + ); + expect( - mockUseAlternatePageUtils('/').createUrl({ + testUtils.forLocation('/').createUrl({ locale: 'zh-Hans', fullyQualified: false, }), - ).toBe('/zh-Hans/'); + ).toBe('/zh-Hans-baseUrl/'); expect( - mockUseAlternatePageUtils('/foo').createUrl({ + testUtils.forLocation('/foo').createUrl({ locale: 'zh-Hans', fullyQualified: false, }), - ).toBe('/zh-Hans/foo'); + ).toBe('/zh-Hans-baseUrl/foo'); expect( - mockUseAlternatePageUtils('/foo').createUrl({ + testUtils.forLocation('/foo').createUrl({ locale: 'zh-Hans', fullyQualified: true, }), - ).toBe('https://example.com/zh-Hans/foo'); + ).toBe('https://zh.example.com/zh-Hans-baseUrl/foo'); }); - it('works for baseUrl: / and currentLocale /= defaultLocale', () => { - const mockUseAlternatePageUtils = createUseAlternatePageUtilsMock({ - siteConfig: {baseUrl: '/zh-Hans/', url: 'https://example.com'}, - i18n: {defaultLocale: 'en', currentLocale: 'zh-Hans'}, - } as DocusaurusContext); + it('works for baseUrl: / and currentLocale !== defaultLocale', () => { + const testUtils = createTestUtils( + fromPartial({ + siteConfig: { + url: 'https://zh.example.com', + baseUrl: '/zh-Hans-baseUrl/', + }, + i18n: { + defaultLocale: 'en', + currentLocale: 'zh-Hans', + localeConfigs: { + en: {url: 'https://example.com', baseUrl: '/'}, + 'zh-Hans': { + url: 'https://zh.example.com', + baseUrl: '/zh-Hans-baseUrl/', + }, + }, + }, + }), + ); + expect( - mockUseAlternatePageUtils('/zh-Hans/').createUrl({ + testUtils.forLocation('/zh-Hans-baseUrl/').createUrl({ locale: 'en', fullyQualified: false, }), ).toBe('/'); expect( - mockUseAlternatePageUtils('/zh-Hans/foo').createUrl({ + testUtils.forLocation('/zh-Hans-baseUrl/foo').createUrl({ locale: 'en', fullyQualified: false, }), ).toBe('/foo'); expect( - mockUseAlternatePageUtils('/zh-Hans/foo').createUrl({ + testUtils.forLocation('/zh-Hans-baseUrl/foo').createUrl({ locale: 'en', fullyQualified: true, }), ).toBe('https://example.com/foo'); }); - it('works for non-root base URL and currentLocale = defaultLocale', () => { - const mockUseAlternatePageUtils = createUseAlternatePageUtilsMock({ - siteConfig: {baseUrl: '/base/', url: 'https://example.com'}, - i18n: {defaultLocale: 'en', currentLocale: 'en'}, - } as DocusaurusContext); + it('works for non-root base URL and currentLocale === defaultLocale', () => { + const testUtils = createTestUtils( + fromPartial({ + siteConfig: {baseUrl: '/en/', url: 'https://example.com'}, + i18n: { + defaultLocale: 'en', + currentLocale: 'en', + localeConfigs: { + en: {url: 'https://example.com', baseUrl: '/base/'}, + 'zh-Hans': { + url: 'https://zh.example.com', + baseUrl: '/zh-Hans-baseUrl/', + }, + }, + }, + }), + ); expect( - mockUseAlternatePageUtils('/base/').createUrl({ + testUtils.forLocation('/en/').createUrl({ locale: 'zh-Hans', fullyQualified: false, }), - ).toBe('/base/zh-Hans/'); + ).toBe('/zh-Hans-baseUrl/'); expect( - mockUseAlternatePageUtils('/base/foo').createUrl({ + testUtils.forLocation('/en/foo').createUrl({ locale: 'zh-Hans', fullyQualified: false, }), - ).toBe('/base/zh-Hans/foo'); + ).toBe('/zh-Hans-baseUrl/foo'); expect( - mockUseAlternatePageUtils('/base/foo').createUrl({ + testUtils.forLocation('/en/foo').createUrl({ locale: 'zh-Hans', fullyQualified: true, }), - ).toBe('https://example.com/base/zh-Hans/foo'); + ).toBe('https://zh.example.com/zh-Hans-baseUrl/foo'); }); - it('works for non-root base URL and currentLocale /= defaultLocale', () => { - const mockUseAlternatePageUtils = createUseAlternatePageUtilsMock({ - siteConfig: {baseUrl: '/base/zh-Hans/', url: 'https://example.com'}, - i18n: {defaultLocale: 'en', currentLocale: 'zh-Hans'}, - } as DocusaurusContext); + it('works for non-root base URL and currentLocale !== defaultLocale', () => { + const testUtils = createTestUtils( + fromPartial({ + siteConfig: { + baseUrl: '/zh-Hans-baseUrl/', + url: 'https://zh.example.com', + }, + i18n: { + defaultLocale: 'en', + currentLocale: 'zh-Hans', + localeConfigs: { + en: {url: 'https://en.example.com', baseUrl: '/en/'}, + 'zh-Hans': { + url: 'https://zh.example.com', + baseUrl: '/zh-Hans-baseUrl/', + }, + }, + }, + }), + ); + expect( - mockUseAlternatePageUtils('/base/zh-Hans/').createUrl({ + testUtils.forLocation('/zh-Hans-baseUrl/').createUrl({ locale: 'en', fullyQualified: false, }), - ).toBe('/base/'); + ).toBe('/en/'); expect( - mockUseAlternatePageUtils('/base/zh-Hans/foo').createUrl({ + testUtils.forLocation('/zh-Hans-baseUrl/foo').createUrl({ locale: 'en', fullyQualified: false, }), - ).toBe('/base/foo'); + ).toBe('/en/foo'); expect( - mockUseAlternatePageUtils('/base/zh-Hans/foo').createUrl({ + testUtils.forLocation('/zh-Hans-baseUrl/foo').createUrl({ locale: 'en', fullyQualified: true, }), - ).toBe('https://example.com/base/foo'); + ).toBe('https://en.example.com/en/foo'); }); }); diff --git a/packages/docusaurus-theme-common/src/utils/historyUtils.ts b/packages/docusaurus-theme-common/src/utils/historyUtils.ts index eea45351d8..45f9cc22dd 100644 --- a/packages/docusaurus-theme-common/src/utils/historyUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/historyUtils.ts @@ -168,3 +168,32 @@ export function useClearQueryString(): () => void { }); }, [history]); } + +export function mergeSearchParams( + params: URLSearchParams[], + strategy: 'append' | 'set', +): URLSearchParams { + const result = new URLSearchParams(); + for (const item of params) { + for (const [key, value] of item.entries()) { + if (strategy === 'append') { + result.append(key, value); + } else { + result.set(key, value); + } + } + } + return result; +} + +export function mergeSearchStrings( + searchStrings: (string | undefined)[], + strategy: 'append' | 'set', +): string { + const params = mergeSearchParams( + searchStrings.map((s) => new URLSearchParams(s ?? '')), + strategy, + ); + const str = params.toString(); + return str ? `?${str}` : str; +} diff --git a/packages/docusaurus-theme-common/src/utils/useAlternatePageUtils.ts b/packages/docusaurus-theme-common/src/utils/useAlternatePageUtils.ts index 073aa8956f..fd537163f1 100644 --- a/packages/docusaurus-theme-common/src/utils/useAlternatePageUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/useAlternatePageUtils.ts @@ -8,6 +8,7 @@ import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useLocation} from '@docusaurus/router'; import {applyTrailingSlash} from '@docusaurus/utils-common'; +import type {I18nLocaleConfig} from '@docusaurus/types'; /** * Permits to obtain the url of the current page in another locale, useful to @@ -36,8 +37,8 @@ export function useAlternatePageUtils(): { }) => string; } { const { - siteConfig: {baseUrl, url, trailingSlash}, - i18n: {defaultLocale, currentLocale}, + siteConfig: {baseUrl, trailingSlash}, + i18n: {localeConfigs}, } = useDocusaurusContext(); // TODO using useLocation().pathname is not a super idea @@ -49,21 +50,19 @@ export function useAlternatePageUtils(): { baseUrl, }); - const baseUrlUnlocalized = - currentLocale === defaultLocale - ? baseUrl - : baseUrl.replace(`/${currentLocale}/`, '/'); - + // Canonical pathname, without the baseUrl of the current locale const pathnameSuffix = canonicalPathname.replace(baseUrl, ''); - function getLocalizedBaseUrl(locale: string) { - return locale === defaultLocale - ? `${baseUrlUnlocalized}` - : `${baseUrlUnlocalized}${locale}/`; + function getLocaleConfig(locale: string): I18nLocaleConfig { + const localeConfig = localeConfigs[locale]; + if (!localeConfig) { + throw new Error( + `Unexpected Docusaurus bug, no locale config found for locale=${locale}`, + ); + } + return localeConfig; } - // TODO support correct alternate url when localized site is deployed on - // another domain function createUrl({ locale, fullyQualified, @@ -71,9 +70,10 @@ export function useAlternatePageUtils(): { locale: string; fullyQualified: boolean; }) { - return `${fullyQualified ? url : ''}${getLocalizedBaseUrl( - locale, - )}${pathnameSuffix}`; + const localeConfig = getLocaleConfig(locale); + const newUrl = `${fullyQualified ? localeConfig.url : ''}`; + const newBaseUrl = localeConfig.baseUrl; + return `${newUrl}${newBaseUrl}${pathnameSuffix}`; } return {createUrl}; diff --git a/packages/docusaurus-types/src/i18n.d.ts b/packages/docusaurus-types/src/i18n.d.ts index ea834788e5..5f1541a5b2 100644 --- a/packages/docusaurus-types/src/i18n.d.ts +++ b/packages/docusaurus-types/src/i18n.d.ts @@ -37,6 +37,25 @@ export type I18nLocaleConfig = { * By default, it will only be run if the `./i18n/` exists. */ translate: boolean; + + /** + * For i18n sites deployed to distinct domains, it is recommended to configure + * a site url on a per-locale basis. + */ + url: string; + + /** + * An explicit baseUrl to use for this locale, overriding the default one: + * Default values: + * - Default locale: `/${siteConfig.baseUrl}/` + * - Other locales: `/${siteConfig.baseUrl}//` + * + * Exception: when using the CLI with a single `--locale` parameter, the + * `//` path segment is not included. This is a better default for + * sites looking to deploy each locale to a different subdomain, such as + * `https://.docusaurus.io` + */ + baseUrl: string; }; export type I18nConfig = { diff --git a/packages/docusaurus-utils/src/__tests__/i18nUtils.test.ts b/packages/docusaurus-utils/src/__tests__/i18nUtils.test.ts index 527aa21379..7786001643 100644 --- a/packages/docusaurus-utils/src/__tests__/i18nUtils.test.ts +++ b/packages/docusaurus-utils/src/__tests__/i18nUtils.test.ts @@ -5,12 +5,10 @@ * LICENSE file in the root directory of this source tree. */ -import * as path from 'path'; import { mergeTranslations, updateTranslationFileMessages, getPluginI18nPath, - localizePath, getLocaleConfig, } from '../i18nUtils'; import type {I18n, I18nLocaleConfig} from '@docusaurus/types'; @@ -97,91 +95,6 @@ describe('getPluginI18nPath', () => { }); }); -describe('localizePath', () => { - it('localizes url path with current locale', () => { - expect( - localizePath({ - pathType: 'url', - path: '/baseUrl', - i18n: { - defaultLocale: 'en', - path: 'i18n', - locales: ['en', 'fr'], - currentLocale: 'fr', - localeConfigs: {}, - }, - options: {localizePath: true}, - }), - ).toBe('/baseUrl/fr/'); - }); - - it('localizes fs path with current locale', () => { - expect( - localizePath({ - pathType: 'fs', - path: '/baseFsPath', - i18n: { - defaultLocale: 'en', - path: 'i18n', - locales: ['en', 'fr'], - currentLocale: 'fr', - localeConfigs: {fr: {path: 'fr'}, en: {path: 'en'}}, - }, - options: {localizePath: true}, - }), - ).toBe(`${path.sep}baseFsPath${path.sep}fr`); - }); - - it('localizes path for default locale, if requested', () => { - expect( - localizePath({ - pathType: 'url', - path: '/baseUrl/', - i18n: { - defaultLocale: 'en', - path: 'i18n', - locales: ['en', 'fr'], - currentLocale: 'en', - localeConfigs: {fr: {path: 'fr'}, en: {path: 'en'}}, - }, - options: {localizePath: true}, - }), - ).toBe('/baseUrl/en/'); - }); - - it('does not localize path for default locale by default', () => { - expect( - localizePath({ - pathType: 'url', - path: '/baseUrl/', - i18n: { - defaultLocale: 'en', - path: 'i18n', - locales: ['en', 'fr'], - currentLocale: 'en', - localeConfigs: {fr: {path: 'fr'}, en: {path: 'en'}}, - }, - }), - ).toBe('/baseUrl/'); - }); - - it('localizes path for non-default locale by default', () => { - expect( - localizePath({ - pathType: 'url', - path: '/baseUrl/', - i18n: { - defaultLocale: 'en', - path: 'i18n', - locales: ['en', 'fr'], - currentLocale: 'en', - localeConfigs: {fr: {path: 'fr'}, en: {path: 'en'}}, - }, - }), - ).toBe('/baseUrl/'); - }); -}); - describe('getLocaleConfig', () => { const localeConfigEn: I18nLocaleConfig = { path: 'path', @@ -190,6 +103,7 @@ describe('getLocaleConfig', () => { calendar: 'calendar', label: 'EN', translate: true, + baseUrl: '/', }; const localeConfigFr: I18nLocaleConfig = { path: 'path', @@ -198,6 +112,7 @@ describe('getLocaleConfig', () => { calendar: 'calendar', label: 'FR', translate: true, + baseUrl: '/fr/', }; function i18n(params: Partial): I18n { diff --git a/packages/docusaurus-utils/src/i18nUtils.ts b/packages/docusaurus-utils/src/i18nUtils.ts index 8e7080b962..3c92381521 100644 --- a/packages/docusaurus-utils/src/i18nUtils.ts +++ b/packages/docusaurus-utils/src/i18nUtils.ts @@ -9,7 +9,6 @@ import path from 'path'; import _ from 'lodash'; import logger from '@docusaurus/logger'; import {DEFAULT_PLUGIN_ID} from './constants'; -import {normalizeUrl} from './urlUtils'; import type { TranslationFileContent, TranslationFile, @@ -67,54 +66,6 @@ export function getPluginI18nPath({ ); } -/** - * Takes a path and returns a localized a version (which is basically `path + - * 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({ - pathType, - path: originalPath, - i18n, - options = {}, -}: { - /** - * FS paths will treat Windows specially; URL paths will always have a - * trailing slash to make it a valid base URL. - */ - pathType: 'fs' | 'url'; - /** The path, URL or file path, to be localized. */ - path: string; - /** The current i18n context. */ - i18n: I18n; - options?: { - /** - * By default, we don't localize the path of defaultLocale. This option - * would override that behavior. Setting `false` is useful for `yarn build - * -l zh-Hans` to always emit into the root build directory. - */ - localizePath?: boolean; - }; -}): string { - const shouldLocalizePath: boolean = - options.localizePath ?? i18n.currentLocale !== i18n.defaultLocale; - - if (!shouldLocalizePath) { - return originalPath; - } - // 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') { - return path.join(originalPath, i18n.currentLocale); - } - // Url paths; add a trailing slash so it's a valid base URL - return normalizeUrl([originalPath, i18n.currentLocale, '/']); -} - // TODO we may extract this to a separate package // we want to use it on the frontend too // but "docusaurus-utils-common" (agnostic utils) is not an ideal place since diff --git a/packages/docusaurus-utils/src/index.ts b/packages/docusaurus-utils/src/index.ts index 74b66ca3a9..9370af8885 100644 --- a/packages/docusaurus-utils/src/index.ts +++ b/packages/docusaurus-utils/src/index.ts @@ -33,7 +33,6 @@ export { mergeTranslations, updateTranslationFileMessages, getPluginI18nPath, - localizePath, getLocaleConfig, } from './i18nUtils'; export {mapAsyncSequential, findAsyncSequential} from './jsUtils'; diff --git a/packages/docusaurus/src/client/exports/ComponentCreator.tsx b/packages/docusaurus/src/client/exports/ComponentCreator.tsx index e0664bec5e..48d6640385 100644 --- a/packages/docusaurus/src/client/exports/ComponentCreator.tsx +++ b/packages/docusaurus/src/client/exports/ComponentCreator.tsx @@ -60,7 +60,6 @@ export default function ComponentCreator( Object.entries(flatChunkNames).forEach(([keyPath, chunkName]) => { const chunkRegistry = registry[chunkName]; if (chunkRegistry) { - // eslint-disable-next-line prefer-destructuring loader[keyPath] = chunkRegistry[0]; modules.push(chunkRegistry[1]); optsWebpack.push(chunkRegistry[2]); diff --git a/packages/docusaurus/src/commands/build/build.ts b/packages/docusaurus/src/commands/build/build.ts index 4bfea69201..c3baf7940b 100644 --- a/packages/docusaurus/src/commands/build/build.ts +++ b/packages/docusaurus/src/commands/build/build.ts @@ -11,6 +11,7 @@ import {mapAsyncSequential} from '@docusaurus/utils'; import {loadContext, type LoadContextParams} from '../../server/site'; import {loadI18n} from '../../server/i18n'; import {buildLocale, type BuildLocaleParams} from './buildLocale'; +import {isAutomaticBaseUrlLocalizationDisabled} from './buildUtils'; export type BuildCLIOptions = Pick & { locale?: [string, ...string[]]; @@ -80,21 +81,20 @@ async function getLocalesToBuild({ siteDir: string; cliOptions: BuildCLIOptions; }): Promise<[string, ...string[]]> { - // We disable locale path localization if CLI has single "--locale" option - // yarn build --locale fr => baseUrl=/ instead of baseUrl=/fr/ - const localizePath = cliOptions.locale?.length === 1 ? false : undefined; - + // TODO we shouldn't need to load all context + i18n just to get that list + // only loading siteConfig should be enough const context = await loadContext({ siteDir, outDir: cliOptions.outDir, config: cliOptions.config, - localizePath, + automaticBaseUrlLocalizationDisabled: isAutomaticBaseUrlLocalizationDisabled(cliOptions), }); const i18n = await loadI18n({ siteDir, config: context.siteConfig, - currentLocale: context.siteConfig.i18n.defaultLocale // Awkward but ok + currentLocale: context.siteConfig.i18n.defaultLocale, // Awkward but ok + automaticBaseUrlLocalizationDisabled: false, }); const locales = cliOptions.locale ?? i18n.locales; diff --git a/packages/docusaurus/src/commands/build/buildLocale.ts b/packages/docusaurus/src/commands/build/buildLocale.ts index 33ae8d346e..8173991ba1 100644 --- a/packages/docusaurus/src/commands/build/buildLocale.ts +++ b/packages/docusaurus/src/commands/build/buildLocale.ts @@ -27,6 +27,7 @@ import type { import type {SiteCollectedData} from '../../common'; import {BuildCLIOptions} from './build'; import clearPath from '../utils/clearPath'; +import {isAutomaticBaseUrlLocalizationDisabled} from './buildUtils'; export type BuildLocaleParams = { siteDir: string; @@ -56,7 +57,7 @@ export async function buildLocale({ outDir: cliOptions.outDir, config: cliOptions.config, locale, - localizePath: cliOptions.locale?.length === 1 ? false : undefined, + automaticBaseUrlLocalizationDisabled: isAutomaticBaseUrlLocalizationDisabled(cliOptions), }), ); diff --git a/packages/docusaurus/src/commands/build/buildUtils.ts b/packages/docusaurus/src/commands/build/buildUtils.ts new file mode 100644 index 0000000000..4421c2646d --- /dev/null +++ b/packages/docusaurus/src/commands/build/buildUtils.ts @@ -0,0 +1,18 @@ +/** + * 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 {BuildCLIOptions} from './build'; + +/** + * We disable locale path localization if CLI has a single "--locale" option + * yarn build --locale fr => baseUrl=/ instead of baseUrl=/fr/ + * By default, this makes it easier to support multi-domain deployments + * See https://docusaurus.io/docs/i18n/tutorial#multi-domain-deployment + */ +export function isAutomaticBaseUrlLocalizationDisabled(cliOptions: BuildCLIOptions) { + return cliOptions.locale?.length === 1; +} diff --git a/packages/docusaurus/src/commands/start/utils.ts b/packages/docusaurus/src/commands/start/utils.ts index 4c2b3fdf15..505c701a7f 100644 --- a/packages/docusaurus/src/commands/start/utils.ts +++ b/packages/docusaurus/src/commands/start/utils.ts @@ -90,7 +90,6 @@ async function createLoadSiteParams({ siteDir, config: cliOptions.config, locale: cliOptions.locale, - localizePath: undefined, // Should this be configurable? }; } diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/configValidation.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/configValidation.test.ts.snap index d298a46eb8..c1fea8b6ec 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/configValidation.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/configValidation.test.ts.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`normalizeConfig throws error for required fields 1`] = ` -""baseUrl" is required +""url" is required +"baseUrl" is required "title" is required -"url" is required "themes" must be an array "presets" must be an array "scripts" must be an array diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap index 4b335e062b..b6b28fc939 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap @@ -15,20 +15,24 @@ exports[`load loads props for site 1`] = ` "defaultLocale": "en", "localeConfigs": { "en": { + "baseUrl": "/", "calendar": "gregory", "direction": "ltr", "htmlLang": "en", "label": "English", "path": "en-custom", "translate": false, + "url": "https://example.com", }, "zh-Hans": { + "baseUrl": "/zh-Hans/", "calendar": "gregory", "direction": "ltr", "htmlLang": "zh-Hans", "label": "简体中文", "path": "zh-Hans-custom", "translate": true, + "url": "https://example.com", }, }, "locales": [ @@ -38,7 +42,7 @@ exports[`load loads props for site 1`] = ` "path": "i18n", }, "localizationDir": "/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site/i18n/en-custom", - "outDir": "/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site/build", + "outDir": "/packages/docusaurus/src/server/__tests__/__fixtures__/custom-i18n-site/build/", "plugins": [ { "content": undefined, @@ -109,11 +113,9 @@ exports[`load loads props for site 1`] = ` "defaultLocale": "en", "localeConfigs": { "en": { - "direction": "ltr", "path": "en-custom", }, "zh-Hans": { - "direction": "ltr", "path": "zh-Hans-custom", }, }, diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index aaad1d3bae..815857d1ff 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -27,6 +27,8 @@ import type { Config, DocusaurusConfig, PluginConfig, + I18nConfig, + I18nLocaleConfig, } from '@docusaurus/types'; import type {DeepPartial} from 'utility-types'; @@ -366,6 +368,115 @@ describe('onBrokenLinks', () => { }); }); +describe('i18n', () => { + function normalizeI18n(i18n: DeepPartial): I18nConfig { + return normalizeConfig({i18n}).i18n; + } + + it('accepts undefined object', () => { + expect(normalizeI18n(undefined)).toEqual(DEFAULT_CONFIG.i18n); + }); + + it('rejects empty object', () => { + expect(() => normalizeI18n({})).toThrowErrorMatchingInlineSnapshot(` + ""i18n.defaultLocale" is required + "i18n.locales" is required + " + `); + }); + + it('accepts minimal i18n config', () => { + expect(normalizeI18n({defaultLocale: 'fr', locales: ['fr']})).toEqual({ + defaultLocale: 'fr', + localeConfigs: {}, + locales: ['fr'], + path: 'i18n', + }); + }); + + describe('locale config', () => { + function normalizeLocaleConfig( + localeConfig?: Partial, + ): Partial { + return normalizeConfig({ + i18n: { + defaultLocale: 'fr', + locales: ['fr'], + localeConfigs: { + fr: localeConfig, + }, + }, + }).i18n.localeConfigs.fr; + } + + it('accepts undefined locale config', () => { + expect(normalizeLocaleConfig(undefined)).toBeUndefined(); + }); + + it('accepts empty locale config', () => { + expect(normalizeLocaleConfig({})).toEqual({}); + }); + + describe('url', () => { + it('accepts undefined', () => { + expect(normalizeLocaleConfig({url: undefined})).toEqual({ + url: undefined, + }); + }); + + it('rejects empty', () => { + expect(() => normalizeLocaleConfig({url: ''})) + .toThrowErrorMatchingInlineSnapshot(` + ""i18n.localeConfigs.fr.url" is not allowed to be empty + " + `); + }); + + it('accepts valid url', () => { + expect( + normalizeLocaleConfig({url: 'https://fr.docusaurus.io'}), + ).toEqual({ + url: 'https://fr.docusaurus.io', + }); + }); + + it('accepts valid url and removes trailing slash', () => { + expect( + normalizeLocaleConfig({url: 'https://fr.docusaurus.io/'}), + ).toEqual({ + url: 'https://fr.docusaurus.io', + }); + }); + }); + + describe('baseUrl', () => { + it('accepts undefined baseUrl', () => { + expect(normalizeLocaleConfig({baseUrl: undefined})).toEqual({ + baseUrl: undefined, + }); + }); + + it('accepts empty baseUrl', () => { + expect(normalizeLocaleConfig({baseUrl: ''})).toEqual({ + baseUrl: '/', + }); + }); + + it('accepts regular baseUrl', () => { + expect(normalizeLocaleConfig({baseUrl: '/myBase/Url/'})).toEqual({ + baseUrl: '/myBase/Url/', + }); + }); + + it('accepts baseUrl without leading/trailing slashes', () => { + expect(normalizeLocaleConfig({baseUrl: 'myBase/Url'})).toEqual({ + baseUrl: '/myBase/Url/', + }); + }); + }); + }); +}); + describe('markdown', () => { function normalizeMarkdown( markdown: DeepPartial, @@ -508,9 +619,9 @@ describe('markdown', () => { emoji: 'yes', }), ).toThrowErrorMatchingInlineSnapshot(` - ""markdown.emoji" must be a boolean - " - `); + ""markdown.emoji" must be a boolean + " + `); }); it('throw for number emoji value', () => { @@ -522,9 +633,9 @@ describe('markdown', () => { }, }), ).toThrowErrorMatchingInlineSnapshot(` - ""markdown.emoji" must be a boolean - " - `); + ""markdown.emoji" must be a boolean + " + `); }); }); diff --git a/packages/docusaurus/src/server/__tests__/i18n.test.ts b/packages/docusaurus/src/server/__tests__/i18n.test.ts index fb075d8b2b..d7af30b224 100644 --- a/packages/docusaurus/src/server/__tests__/i18n.test.ts +++ b/packages/docusaurus/src/server/__tests__/i18n.test.ts @@ -17,21 +17,31 @@ const loadI18nSiteDir = path.resolve( 'load-i18n-site', ); +const siteUrl = 'https://example.com'; + function loadI18nTest({ siteDir = loadI18nSiteDir, + baseUrl = '/', i18nConfig, currentLocale, + automaticBaseUrlLocalizationDisabled, }: { siteDir?: string; + baseUrl?: string; i18nConfig: I18nConfig; currentLocale: string; + automaticBaseUrlLocalizationDisabled?: boolean; }) { return loadI18n({ siteDir, config: { i18n: i18nConfig, + url: siteUrl, + baseUrl, } as DocusaurusConfig, currentLocale, + automaticBaseUrlLocalizationDisabled: + automaticBaseUrlLocalizationDisabled ?? false, }); } @@ -133,6 +143,8 @@ describe('loadI18n', () => { en: { ...getDefaultLocaleConfig('en'), translate: false, + url: siteUrl, + baseUrl: '/', }, }, }); @@ -158,14 +170,60 @@ describe('loadI18n', () => { en: { ...getDefaultLocaleConfig('en'), translate: false, + url: siteUrl, + baseUrl: '/en/', }, fr: { ...getDefaultLocaleConfig('fr'), translate: true, + url: siteUrl, + baseUrl: '/', }, de: { ...getDefaultLocaleConfig('de'), translate: true, + url: siteUrl, + baseUrl: '/de/', + }, + }, + }); + }); + + it('loads I18n for multi-lang config - with automaticBaseUrlLocalizationDisabled=true', async () => { + await expect( + loadI18nTest({ + i18nConfig: { + path: 'i18n', + defaultLocale: 'fr', + locales: ['en', 'fr', 'de'], + localeConfigs: {}, + }, + currentLocale: 'fr', + automaticBaseUrlLocalizationDisabled: true, + }), + ).resolves.toEqual({ + defaultLocale: 'fr', + path: 'i18n', + locales: ['en', 'fr', 'de'], + currentLocale: 'fr', + localeConfigs: { + en: { + ...getDefaultLocaleConfig('en'), + translate: false, + url: siteUrl, + baseUrl: '/', + }, + fr: { + ...getDefaultLocaleConfig('fr'), + translate: true, + url: siteUrl, + baseUrl: '/', + }, + de: { + ...getDefaultLocaleConfig('de'), + translate: true, + url: siteUrl, + baseUrl: '/', }, }, }); @@ -191,14 +249,20 @@ describe('loadI18n', () => { en: { ...getDefaultLocaleConfig('en'), translate: false, + url: siteUrl, + baseUrl: '/en/', }, fr: { ...getDefaultLocaleConfig('fr'), translate: true, + url: siteUrl, + baseUrl: '/', }, de: { ...getDefaultLocaleConfig('de'), translate: true, + url: siteUrl, + baseUrl: '/de/', }, }, }); @@ -213,10 +277,11 @@ describe('loadI18n', () => { locales: ['en', 'fr', 'de'], localeConfigs: { fr: {label: 'Français', translate: false}, - en: {translate: true}, - de: {translate: false}, + en: {translate: true, baseUrl: 'en-EN/whatever/else'}, + de: {translate: false, baseUrl: '/de-DE/'}, }, }, + currentLocale: 'de', }), ).resolves.toEqual({ @@ -232,19 +297,96 @@ describe('loadI18n', () => { calendar: 'gregory', path: 'fr', translate: false, + url: siteUrl, + baseUrl: '/', }, en: { ...getDefaultLocaleConfig('en'), translate: true, + url: siteUrl, + baseUrl: '/en-EN/whatever/else/', }, de: { ...getDefaultLocaleConfig('de'), translate: false, + url: siteUrl, + baseUrl: '/de-DE/', }, }, }); }); + it('loads I18n for multi-locale config with baseUrl edge cases', async () => { + await expect( + loadI18nTest({ + baseUrl: 'siteBaseUrl', + i18nConfig: { + path: 'i18n', + defaultLocale: 'fr', + locales: ['en', 'fr', 'de', 'pt'], + localeConfigs: { + fr: {}, + en: {baseUrl: ''}, + de: {baseUrl: '/de-DE/'}, + }, + }, + currentLocale: 'de', + }), + ).resolves.toEqual( + expect.objectContaining({ + localeConfigs: { + fr: expect.objectContaining({ + baseUrl: '/siteBaseUrl/', + }), + en: expect.objectContaining({ + baseUrl: '/', + }), + de: expect.objectContaining({ + baseUrl: '/de-DE/', + }), + pt: expect.objectContaining({ + baseUrl: '/siteBaseUrl/pt/', + }), + }, + }), + ); + }); + + it('loads I18n for multi-locale config with custom urls', async () => { + await expect( + loadI18nTest({ + baseUrl: 'siteBaseUrl', + i18nConfig: { + path: 'i18n', + defaultLocale: 'fr', + locales: ['en', 'fr', 'de', 'pt'], + localeConfigs: { + fr: {url: 'https://fr.example.com'}, + en: {url: 'https://en.example.com'}, + }, + }, + currentLocale: 'de', + }), + ).resolves.toEqual( + expect.objectContaining({ + localeConfigs: { + fr: expect.objectContaining({ + url: 'https://fr.example.com', + }), + en: expect.objectContaining({ + url: 'https://en.example.com', + }), + de: expect.objectContaining({ + url: siteUrl, + }), + pt: expect.objectContaining({ + url: siteUrl, + }), + }, + }), + ); + }); + it('warns when trying to load undeclared locale', async () => { await loadI18nTest({ i18nConfig: { diff --git a/packages/docusaurus/src/server/__tests__/site.test.ts b/packages/docusaurus/src/server/__tests__/site.test.ts index 7ce18bfa4d..e62d721035 100644 --- a/packages/docusaurus/src/server/__tests__/site.test.ts +++ b/packages/docusaurus/src/server/__tests__/site.test.ts @@ -28,7 +28,7 @@ describe('load', () => { ), outDir: path.join( __dirname, - '__fixtures__/custom-i18n-site/build/zh-Hans', + '__fixtures__/custom-i18n-site/build/zh-Hans/', ), routesPaths: ['/zh-Hans/404.html'], siteConfig: expect.objectContaining({ diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index d62e7b14c8..5f6059bfa6 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -26,10 +26,36 @@ import type { I18nConfig, MarkdownConfig, MarkdownHooks, + I18nLocaleConfig, } from '@docusaurus/types'; const DEFAULT_I18N_LOCALE = 'en'; +const SiteUrlSchema = Joi.string() + .custom((value: string, helpers) => { + try { + const {pathname} = new URL(value); + if (pathname !== '/') { + return helpers.error('docusaurus.subPathError', {pathname}); + } + } catch { + return helpers.error('any.invalid'); + } + return removeTrailingSlash(value); + }) + .messages({ + 'any.invalid': + '"{#value}" does not look like a valid URL. Make sure it has a protocol; for example, "https://example.com".', + 'docusaurus.subPathError': + 'The url is not supposed to contain a sub-path like "{#pathname}". Please use the baseUrl field for sub-paths.', + }); + +const BaseUrlSchema = Joi + // Weird Joi trick needed, otherwise value '' is not normalized... + .alternatives() + .try(Joi.string().required().allow('')) + .custom((value: string) => addLeadingSlash(addTrailingSlash(value))); + export const DEFAULT_I18N_CONFIG: I18nConfig = { defaultLocale: DEFAULT_I18N_LOCALE, path: DEFAULT_I18N_DIR_NAME, @@ -220,12 +246,14 @@ const PresetSchema = Joi.alternatives() - A simple string, like \`"classic"\``, }); -const LocaleConfigSchema = Joi.object({ +const LocaleConfigSchema = Joi.object({ label: Joi.string(), htmlLang: Joi.string(), - direction: Joi.string().equal('ltr', 'rtl').default('ltr'), + direction: Joi.string().equal('ltr', 'rtl'), calendar: Joi.string(), path: Joi.string(), + url: SiteUrlSchema, + baseUrl: BaseUrlSchema, }); const I18N_CONFIG_SCHEMA = Joi.object({ @@ -313,38 +341,13 @@ const FUTURE_CONFIG_SCHEMA = Joi.object({ .optional() .default(DEFAULT_FUTURE_CONFIG); -const SiteUrlSchema = Joi.string() - .required() - .custom((value: string, helpers) => { - try { - const {pathname} = new URL(value); - if (pathname !== '/') { - return helpers.error('docusaurus.subPathError', {pathname}); - } - } catch { - return helpers.error('any.invalid'); - } - return removeTrailingSlash(value); - }) - .messages({ - 'any.invalid': - '"{#value}" does not look like a valid URL. Make sure it has a protocol; for example, "https://example.com".', - 'docusaurus.subPathError': - 'The url is not supposed to contain a sub-path like "{#pathname}". Please use the baseUrl field for sub-paths.', - }); - // TODO move to @docusaurus/utils-validation export const ConfigSchema = Joi.object({ - baseUrl: Joi - // Weird Joi trick needed, otherwise value '' is not normalized... - .alternatives() - .try(Joi.string().required().allow('')) - .required() - .custom((value: string) => addLeadingSlash(addTrailingSlash(value))), + url: SiteUrlSchema.required(), + baseUrl: BaseUrlSchema.required(), baseUrlIssueBanner: Joi.boolean().default(DEFAULT_CONFIG.baseUrlIssueBanner), favicon: Joi.string().optional(), title: Joi.string().required(), - url: SiteUrlSchema, trailingSlash: Joi.boolean(), // No default value! undefined = retrocompatible legacy behavior! i18n: I18N_CONFIG_SCHEMA, future: FUTURE_CONFIG_SCHEMA, diff --git a/packages/docusaurus/src/server/i18n.ts b/packages/docusaurus/src/server/i18n.ts index 9d97140b3a..9b0021ae87 100644 --- a/packages/docusaurus/src/server/i18n.ts +++ b/packages/docusaurus/src/server/i18n.ts @@ -9,6 +9,7 @@ import path from 'path'; import fs from 'fs-extra'; import logger from '@docusaurus/logger'; import combinePromises from 'combine-promises'; +import {normalizeUrl} from '@docusaurus/utils'; import type {I18n, DocusaurusConfig, I18nLocaleConfig} from '@docusaurus/types'; function inferLanguageDisplayName(locale: string) { @@ -82,7 +83,7 @@ function getDefaultDirection(localeStr: string) { export function getDefaultLocaleConfig( locale: string, -): Omit { +): Omit { try { return { label: getDefaultLocaleLabel(locale), @@ -103,10 +104,12 @@ export async function loadI18n({ siteDir, config, currentLocale, + automaticBaseUrlLocalizationDisabled, }: { siteDir: string; config: DocusaurusConfig; currentLocale: string; + automaticBaseUrlLocalizationDisabled: boolean; }): Promise { const {i18n: i18nConfig} = config; @@ -123,7 +126,10 @@ Note: Docusaurus only support running one locale at a time.`; locale: string, ): Promise { const localeConfigInput = i18nConfig.localeConfigs[locale] ?? {}; - const localeConfig: Omit = { + const localeConfig: Omit< + I18nLocaleConfig, + 'translate' | 'url' | 'baseUrl' + > = { ...getDefaultLocaleConfig(locale), ...localeConfigInput, }; @@ -138,10 +144,36 @@ Note: Docusaurus only support running one locale at a time.`; return fs.pathExists(localizationDir); } + function getInferredBaseUrl(): string { + const addLocaleSegment = + locale !== i18nConfig.defaultLocale && + !automaticBaseUrlLocalizationDisabled; + + return normalizeUrl([ + '/', + config.baseUrl, + addLocaleSegment ? locale : '', + '/', + ]); + } + const translate = localeConfigInput.translate ?? (await inferTranslate()); + + const url = + typeof localeConfigInput.url !== 'undefined' + ? localeConfigInput.url + : config.url; + + const baseUrl = + typeof localeConfigInput.baseUrl !== 'undefined' + ? normalizeUrl(['/', localeConfigInput.baseUrl, '/']) + : getInferredBaseUrl(); + return { ...localeConfig, translate, + url, + baseUrl, }; } diff --git a/packages/docusaurus/src/server/site.ts b/packages/docusaurus/src/server/site.ts index a48e68dd8a..9668a13cd9 100644 --- a/packages/docusaurus/src/server/site.ts +++ b/packages/docusaurus/src/server/site.ts @@ -7,7 +7,6 @@ import path from 'path'; import { - localizePath, DEFAULT_BUILD_DIR_NAME, GENERATED_FILES_DIR_NAME, getLocaleConfig, @@ -47,13 +46,21 @@ export type LoadContextParams = { config?: string; /** Default is `i18n.defaultLocale` */ locale?: string; + /** - * `true` means the paths will have the locale prepended; `false` means they - * won't (useful for `yarn build -l zh-Hans` where the output should be - * emitted into `build/` instead of `build/zh-Hans/`); `undefined` is like the - * "smart" option where only non-default locale paths are localized + * By default, we try to automatically infer a localized baseUrl. + * We prepend `//` with a `//` path segment, + * except for the default locale. + * + * This option permits opting out of this baseUrl localization process. + * It is mostly useful to simplify config for multi-domain i18n deployments. + * See https://docusaurus.io/docs/i18n/tutorial#multi-domain-deployment + * + * In all cases, this process doesn't happen if an explicit localized baseUrl + * has been provided using `i18n.localeConfigs[].baseUrl`. We always use the + * provided value over the inferred one, letting you override it. */ - localizePath?: boolean; + automaticBaseUrlLocalizationDisabled?: boolean; }; export type LoadSiteParams = LoadContextParams & { @@ -79,6 +86,7 @@ export async function loadContext( outDir: baseOutDir = DEFAULT_BUILD_DIR_NAME, locale, config: customConfigFilePath, + automaticBaseUrlLocalizationDisabled, } = params; const generatedFilesDir = path.resolve(siteDir, GENERATED_FILES_DIR_NAME); @@ -101,27 +109,29 @@ export async function loadContext( siteDir, config: initialSiteConfig, currentLocale: locale ?? initialSiteConfig.i18n.defaultLocale, + automaticBaseUrlLocalizationDisabled: + automaticBaseUrlLocalizationDisabled ?? false, }); - const baseUrl = localizePath({ - path: initialSiteConfig.baseUrl, - i18n, - options: params, - pathType: 'url', - }); - const outDir = localizePath({ - path: path.resolve(siteDir, baseOutDir), - i18n, - options: params, - pathType: 'fs', - }); + const localeConfig = getLocaleConfig(i18n); + + // We use the baseUrl from the locale config. + // By default, it is inferred as // + // eventually including the // suffix + const baseUrl = localeConfig.baseUrl; + + const outDir = path.join(path.resolve(siteDir, baseOutDir), baseUrl); + const localizationDir = path.resolve( siteDir, i18n.path, getLocaleConfig(i18n).path, ); - const siteConfig: DocusaurusConfig = {...initialSiteConfig, baseUrl}; + const siteConfig: DocusaurusConfig = { + ...initialSiteConfig, + baseUrl, + }; const codeTranslations = await loadSiteCodeTranslations({localizationDir}); diff --git a/project-words.txt b/project-words.txt index 467a8d0a2d..36c96b80c8 100644 --- a/project-words.txt +++ b/project-words.txt @@ -334,7 +334,6 @@ Unavatar unlinkable Unlisteds unlisteds -Unlocalized unlocalized unswizzle upvotes diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index 351da0421a..a1e5f1c09a 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -84,11 +84,24 @@ export default { }; ``` +:::info Special case for i18n sites + +If your site uses multiple locales, it is possible to provide a distinct `url` for each locale thanks to the [`siteConfig.i18n.localeConfigs[].url`](#i18n) attribute. This makes it possible to deploy a localized Docusaurus site [deploy a localized Docusaurus site over multiple domains](../i18n/i18n-tutorial.mdx#multi-domain-deployment). + +::: + ### `baseUrl` {#baseUrl} - Type: `string` -Base URL for your site. Can be considered as the path after the host. For example, `/metro/` is the base URL of https://facebook.github.io/metro/. For URLs that have no path, the baseUrl should be set to `/`. This field is related to the [`url`](#url) field. Always has both leading and trailing slash. +The base URL of your site is the path segment appearing just after the [`url`](#url), letting you eventually host your site under a subpath instead of at the root of the domain. + +For example, let's consider you want to host a site at https://facebook.github.io/metro/, then you must configure it accordingly: + +- [`url`](#url) should be `'https://facebook.github.io'` +- `baseUrl` should be `'/metro/'` + +By default, a Docusaurus site is hosted at the root of the domain: ```js title="docusaurus.config.js" export default { @@ -96,6 +109,18 @@ export default { }; ``` +:::info Special case for i18n sites + +If your site uses multiple locales, then Docusaurus will automatically localize the `baseUrl` of your site based on smart heuristics: + +- For the default locale, `baseUrl` will be `//` +- For other locales, `baseUrl` will be `///` +- When building a single locale at a time (with `docusaurus build --locale `), `baseUrl` will be `//`, assuming the intent is to [deploy each locale on distinct domains](../i18n/i18n-tutorial.mdx#multi-domain-deployment). + +When the localized `baseUrl` Docusaurus computes doesn't satisfy you, it's always possible to override it by providing an explicit localized `baseUrl` thanks to the [`siteConfig.i18n.localeConfigs[].baseUrl`](#i18n) attribute. + +::: + ## Optional fields {#optional-fields} ### `favicon` {#favicon} @@ -152,6 +177,8 @@ export default { calendar: 'gregory', path: 'en', translate: false, + url: 'https://en.example.com', + baseUrl: '/', }, fa: { label: 'فارسی', @@ -160,6 +187,8 @@ export default { calendar: 'persian', path: 'fa', translate: true, + url: 'https://fa.example.com', + baseUrl: '/', }, }, }, @@ -176,6 +205,8 @@ export default { - `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 (`i18n/`). Note: this has no effect on the locale's `baseUrl`—customization of base URL is a work-in-progress. - `translate`: Should we run the translation process for this locale? By default, it is enabled if the `i18n/` folder exists + - `url`: This lets you override the [`siteConfig.url`](#url), particularly useful if your site is [deployed over multiple domains](../i18n/i18n-tutorial.mdx#multi-domain-deployment). + - `baseUrl`: This lets you override the default localized `baseUrl` Docusaurus infers from your [`siteConfig.baseUrl`](#baseUrl), giving you more control to host your localized site in less common ways, in particularly [deployments over multi-domains](../i18n/i18n-tutorial.mdx#multi-domain-deployment) ### `future` {#future} diff --git a/website/docs/i18n/i18n-tutorial.mdx b/website/docs/i18n/i18n-tutorial.mdx index a88e2f0a38..b76768fae5 100644 --- a/website/docs/i18n/i18n-tutorial.mdx +++ b/website/docs/i18n/i18n-tutorial.mdx @@ -453,6 +453,14 @@ For localized sites, it is recommended to use **[explicit heading IDs](../guides You can choose to deploy your site under a **single domain** or use **multiple (sub)domains**. +:::tip About localized baseUrls + +Docusaurus will automatically add a `//` path segment to your site for locales except the default one. This heuristic works well for most sites but can be configured on a per-locale basis depending on your deployment requirements. + +Read more on the [`siteConfig.baseUrl`](../api/docusaurus.config.js.mdx#baseUrl) docs. + +::: + ### Single-domain deployment {#single-domain-deployment} Run the following command: @@ -495,7 +503,7 @@ You can also build your site for a single locale: npm run build -- --locale fr ``` -Docusaurus will not add the `/fr/` URL prefix. +When building a single locale at a time, Docusaurus will not add the `/fr/` URL prefix automatically, assuming you want to deploy each locale to a distinct domain. On your [static hosting provider](../deployment.mdx): @@ -503,6 +511,37 @@ On your [static hosting provider](../deployment.mdx): - configure the appropriate build command, using the `--locale` option - configure the (sub)domain of your choice for each deployment +:::tip Configuring URLs for each locale + +Use the [`siteConfig.i18n.localeConfigs[].url`](./../api/docusaurus.config.js.mdx#i18n) attribute to configure a distinct site URL for each locale: + +```ts title=docusaurus.config.js +const config = { + i18n: { + localeConfigs: { + // highlight-start + en: { + url: 'https://en.docusaurus.io', + baseUrl: '/', + }, + fr: { + url: 'https://fr.docusaurus.io', + baseUrl: '/', + }, + // highlight-end + }, + }, +}; +``` + +This helps [search engines like Google know about localized versions of your page](https://developers.google.com/search/docs/specialty/international/localized-versions) thanks to `` meta tags. + +This also permits Docusaurus themes to redirect users to the appropriate URL when they switch locale, usually through the [Navbar locale dropdown](../api/themes/theme-configuration.mdx#navbar-locale-dropdown). + +Read more on the [`siteConfig.url`](../api/docusaurus.config.js.mdx#baseUrl) and [`siteConfig.baseUrl`](../api/docusaurus.config.js.mdx#baseUrl) docs. + +::: + :::warning This strategy is **not possible** with GitHub Pages, as it is only possible to **have a single deployment**.