feat(core): Add i18n.localeConfigs[locale].{url,baseUrl} config options (#11316)

Co-authored-by: slorber <749374+slorber@users.noreply.github.com>
This commit is contained in:
Sébastien Lorber 2025-07-28 17:04:34 +02:00 committed by GitHub
parent 12bcad9837
commit 2febb76fae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
29 changed files with 800 additions and 301 deletions

2
.eslintrc.js vendored
View file

@ -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,

View file

@ -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;

View file

@ -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 (
<DropdownNavbarItem

View file

@ -46,6 +46,7 @@
"devDependencies": {
"@docusaurus/core": "3.8.1",
"@docusaurus/types": "3.8.1",
"@total-typescript/shoehorn": "^0.1.2",
"fs-extra": "^11.1.1",
"lodash": "^4.17.21"
},

View file

@ -124,6 +124,8 @@ export {
useQueryString,
useQueryStringList,
useClearQueryString,
mergeSearchParams,
mergeSearchStrings,
} from './utils/historyUtils';
export {

View file

@ -0,0 +1,80 @@
/**
* 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 {mergeSearchParams, mergeSearchStrings} from '../historyUtils';
describe('mergeSearchParams', () => {
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('');
});
});

View file

@ -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}) => (
<Context.Provider value={context}>
<StaticRouter location={location}>{children}</StaticRouter>
</Context.Provider>
),
}).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}) => (
<Context.Provider value={context}>
<StaticRouter location={location}>{children}</StaticRouter>
</Context.Provider>
),
}).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');
});
});

View file

@ -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;
}

View file

@ -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};

View file

@ -37,6 +37,25 @@ export type I18nLocaleConfig = {
* By default, it will only be run if the `./i18n/<locale>` 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}/<locale>/`
*
* Exception: when using the CLI with a single `--locale` parameter, the
* `/<locale>/` path segment is not included. This is a better default for
* sites looking to deploy each locale to a different subdomain, such as
* `https://<locale>.docusaurus.io`
*/
baseUrl: string;
};
export type I18nConfig = {

View file

@ -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>): I18n {

View file

@ -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

View file

@ -33,7 +33,6 @@ export {
mergeTranslations,
updateTranslationFileMessages,
getPluginI18nPath,
localizePath,
getLocaleConfig,
} from './i18nUtils';
export {mapAsyncSequential, findAsyncSequential} from './jsUtils';

View file

@ -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]);

View file

@ -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<LoadContextParams, 'config' | 'outDir'> & {
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;

View file

@ -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),
}),
);

View file

@ -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;
}

View file

@ -90,7 +90,6 @@ async function createLoadSiteParams({
siteDir,
config: cliOptions.config,
locale: cliOptions.locale,
localizePath: undefined, // Should this be configurable?
};
}

View file

@ -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

View file

@ -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": "<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",
"outDir": "<PROJECT_ROOT>/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",
},
},

View file

@ -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>): 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<I18nLocaleConfig>,
): Partial<I18nLocaleConfig> {
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<MarkdownConfig>,
@ -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
"
`);
});
});

View file

@ -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: {

View file

@ -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({

View file

@ -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<I18nLocaleConfig>({
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<I18nConfig>({
@ -313,38 +341,13 @@ const FUTURE_CONFIG_SCHEMA = Joi.object<FutureConfig>({
.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<DocusaurusConfig>({
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,

View file

@ -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<I18nLocaleConfig, 'translate'> {
): Omit<I18nLocaleConfig, 'translate' | 'url' | 'baseUrl'> {
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<I18n> {
const {i18n: i18nConfig} = config;
@ -123,7 +126,10 @@ Note: Docusaurus only support running one locale at a time.`;
locale: string,
): Promise<I18nLocaleConfig> {
const localeConfigInput = i18nConfig.localeConfigs[locale] ?? {};
const localeConfig: Omit<I18nLocaleConfig, 'translate'> = {
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,
};
}

View file

@ -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 `/<siteBaseUrl>/` with a `/<locale>/` 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 /<siteConfig.baseUrl>/
// eventually including the /<locale>/ 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});

View file

@ -334,7 +334,6 @@ Unavatar
unlinkable
Unlisteds
unlisteds
Unlocalized
unlocalized
unswizzle
upvotes

View file

@ -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[<locale>].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 `/<siteBaseUrl>/`
- For other locales, `baseUrl` will be `/<siteBaseUrl>/<locale>/`
- When building a single locale at a time (with `docusaurus build --locale <locale>`), `baseUrl` will be `/<siteBaseUrl>/`, 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[<locale>].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/<locale>`). 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/<locale>` 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}

View file

@ -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 `/<locale>/` 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[<locale>].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 `<link rel="alternate" hreflang="<locale>"/>` 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**.