feat(core): site storage config options (experimental) (#10121)

This commit is contained in:
Sébastien Lorber 2024-05-10 14:41:51 +02:00 committed by GitHub
parent cb6895197d
commit 620e46350a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 828 additions and 58 deletions

View file

@ -26,6 +26,13 @@ declare module '@generated/site-metadata' {
export = siteMetadata; export = siteMetadata;
} }
declare module '@generated/site-storage' {
import type {SiteStorage} from '@docusaurus/types';
const siteStorage: SiteStorage;
export = siteStorage;
}
declare module '@generated/registry' { declare module '@generated/registry' {
import type {Registry} from '@docusaurus/types'; import type {Registry} from '@docusaurus/types';

View file

@ -10,7 +10,7 @@ import {createRequire} from 'module';
import rtlcss from 'rtlcss'; import rtlcss from 'rtlcss';
import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-translations'; import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-translations';
import {getTranslationFiles, translateThemeConfig} from './translations'; import {getTranslationFiles, translateThemeConfig} from './translations';
import type {LoadContext, Plugin} from '@docusaurus/types'; import type {LoadContext, Plugin, SiteStorage} from '@docusaurus/types';
import type {ThemeConfig} from '@docusaurus/theme-common'; import type {ThemeConfig} from '@docusaurus/theme-common';
import type {Plugin as PostCssPlugin} from 'postcss'; import type {Plugin as PostCssPlugin} from 'postcss';
import type {PluginOptions} from '@docusaurus/theme-classic'; import type {PluginOptions} from '@docusaurus/theme-classic';
@ -23,58 +23,66 @@ const ContextReplacementPlugin = requireFromDocusaurusCore(
'webpack/lib/ContextReplacementPlugin', 'webpack/lib/ContextReplacementPlugin',
) as typeof webpack.ContextReplacementPlugin; ) as typeof webpack.ContextReplacementPlugin;
// Need to be inlined to prevent dark mode FOUC
// Make sure the key is the same as the one in `/theme/hooks/useTheme.js`
const ThemeStorageKey = 'theme';
// Support for ?docusaurus-theme=dark // Support for ?docusaurus-theme=dark
const ThemeQueryStringKey = 'docusaurus-theme'; const ThemeQueryStringKey = 'docusaurus-theme';
// Support for ?docusaurus-data-mode=embed&docusaurus-data-myAttr=42 // Support for ?docusaurus-data-mode=embed&docusaurus-data-myAttr=42
const DataQueryStringPrefixKey = 'docusaurus-data-'; const DataQueryStringPrefixKey = 'docusaurus-data-';
const noFlashColorMode = ({ const noFlashColorMode = ({
defaultMode, colorMode: {defaultMode, respectPrefersColorScheme},
respectPrefersColorScheme, siteStorage,
}: ThemeConfig['colorMode']) => }: {
colorMode: ThemeConfig['colorMode'];
siteStorage: SiteStorage;
}) => {
// Need to be inlined to prevent dark mode FOUC
// Make sure the key is the same as the one in the color mode React context
// Currently defined in: `docusaurus-theme-common/src/contexts/colorMode.tsx`
const themeStorageKey = `theme${siteStorage.namespace}`;
/* language=js */ /* language=js */
`(function() { return `(function() {
var defaultMode = '${defaultMode}'; var defaultMode = '${defaultMode}';
var respectPrefersColorScheme = ${respectPrefersColorScheme}; var respectPrefersColorScheme = ${respectPrefersColorScheme};
function setDataThemeAttribute(theme) { function setDataThemeAttribute(theme) {
document.documentElement.setAttribute('data-theme', theme); document.documentElement.setAttribute('data-theme', theme);
}
function getQueryStringTheme() {
try {
return new URLSearchParams(window.location.search).get('${ThemeQueryStringKey}')
} catch(e) {}
}
function getStoredTheme() {
try {
return localStorage.getItem('${ThemeStorageKey}');
} catch (err) {}
}
var initialTheme = getQueryStringTheme() || getStoredTheme();
if (initialTheme !== null) {
setDataThemeAttribute(initialTheme);
} else {
if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setDataThemeAttribute('dark');
} else if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: light)').matches
) {
setDataThemeAttribute('light');
} else {
setDataThemeAttribute(defaultMode === 'dark' ? 'dark' : 'light');
} }
}
})();`; function getQueryStringTheme() {
try {
return new URLSearchParams(window.location.search).get('${ThemeQueryStringKey}')
} catch (e) {
}
}
function getStoredTheme() {
try {
return window['${siteStorage.type}'].getItem('${themeStorageKey}');
} catch (err) {
}
}
var initialTheme = getQueryStringTheme() || getStoredTheme();
if (initialTheme !== null) {
setDataThemeAttribute(initialTheme);
} else {
if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
setDataThemeAttribute('dark');
} else if (
respectPrefersColorScheme &&
window.matchMedia('(prefers-color-scheme: light)').matches
) {
setDataThemeAttribute('light');
} else {
setDataThemeAttribute(defaultMode === 'dark' ? 'dark' : 'light');
}
}
})();`;
};
/* language=js */ /* language=js */
const DataAttributeQueryStringInlineJavaScript = ` const DataAttributeQueryStringInlineJavaScript = `
@ -126,6 +134,7 @@ export default function themeClassic(
): Plugin<undefined> { ): Plugin<undefined> {
const { const {
i18n: {currentLocale, localeConfigs}, i18n: {currentLocale, localeConfigs},
siteStorage,
} = context; } = context;
const themeConfig = context.siteConfig.themeConfig as ThemeConfig; const themeConfig = context.siteConfig.themeConfig as ThemeConfig;
const { const {
@ -218,7 +227,7 @@ export default function themeClassic(
{ {
tagName: 'script', tagName: 'script',
innerHTML: ` innerHTML: `
${noFlashColorMode(colorMode)} ${noFlashColorMode({colorMode, siteStorage})}
${DataAttributeQueryStringInlineJavaScript} ${DataAttributeQueryStringInlineJavaScript}
${announcementBar ? AnnouncementBarInlineJavaScript : ''} ${announcementBar ? AnnouncementBarInlineJavaScript : ''}
`, `,

View file

@ -6,12 +6,15 @@
*/ */
import {useCallback, useRef, useSyncExternalStore} from 'react'; import {useCallback, useRef, useSyncExternalStore} from 'react';
import SiteStorage from '@generated/site-storage';
const StorageTypes = ['localStorage', 'sessionStorage', 'none'] as const; export type StorageType = (typeof SiteStorage)['type'] | 'none';
export type StorageType = (typeof StorageTypes)[number]; const DefaultStorageType: StorageType = SiteStorage.type;
const DefaultStorageType: StorageType = 'localStorage'; function applyNamespace(storageKey: string): string {
return `${storageKey}${SiteStorage.namespace}`;
}
// window.addEventListener('storage') only works for different windows... // window.addEventListener('storage') only works for different windows...
// so for current window we have to dispatch the event manually // so for current window we have to dispatch the event manually
@ -134,9 +137,10 @@ Please only call storage APIs in effects and event handlers.`);
* this API can be a no-op. See also https://github.com/facebook/docusaurus/issues/6036 * this API can be a no-op. See also https://github.com/facebook/docusaurus/issues/6036
*/ */
export function createStorageSlot( export function createStorageSlot(
key: string, keyInput: string,
options?: {persistence?: StorageType}, options?: {persistence?: StorageType},
): StorageSlot { ): StorageSlot {
const key = applyNamespace(keyInput);
if (typeof window === 'undefined') { if (typeof window === 'undefined') {
return createServerStorageSlot(key); return createServerStorageSlot(key);
} }

View file

@ -5,6 +5,7 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import type {SiteStorage} from './context';
import type {RuleSetRule} from 'webpack'; import type {RuleSetRule} from 'webpack';
import type {Required as RequireKeys, DeepPartial} from 'utility-types'; import type {Required as RequireKeys, DeepPartial} from 'utility-types';
import type {I18nConfig} from './i18n'; import type {I18nConfig} from './i18n';
@ -115,6 +116,15 @@ export type MarkdownConfig = {
anchors: MarkdownAnchorsConfig; anchors: MarkdownAnchorsConfig;
}; };
export type StorageConfig = {
type: SiteStorage['type'];
namespace: boolean | string;
};
export type FutureConfig = {
experimental_storage: StorageConfig;
};
/** /**
* Docusaurus config, after validation/normalization. * Docusaurus config, after validation/normalization.
*/ */
@ -171,6 +181,11 @@ export type DocusaurusConfig = {
* @see https://docusaurus.io/docs/api/docusaurus-config#i18n * @see https://docusaurus.io/docs/api/docusaurus-config#i18n
*/ */
i18n: I18nConfig; i18n: I18nConfig;
/**
* Docusaurus future flags and experimental features.
* Similar to Remix future flags, see https://remix.run/blog/future-flags
*/
future: FutureConfig;
/** /**
* This option adds `<meta name="robots" content="noindex, nofollow">` to * This option adds `<meta name="robots" content="noindex, nofollow">` to
* every page to tell search engines to avoid indexing your site. * every page to tell search engines to avoid indexing your site.

View file

@ -27,6 +27,25 @@ export type SiteMetadata = {
readonly pluginVersions: {[pluginName: string]: PluginVersionInformation}; readonly pluginVersions: {[pluginName: string]: PluginVersionInformation};
}; };
export type SiteStorage = {
/**
* Which browser storage do you want to use?
* Between "localStorage" and "sessionStorage".
* The default is "localStorage".
*/
type: 'localStorage' | 'sessionStorage';
/**
* Applies a namespace to the theme storage key
* For readability, the namespace is applied at the end of the key
* The final storage key will be = `${key}${namespace}`
*
* The default namespace is "" for retro-compatibility reasons
* If you want a separator, the namespace should contain it ("-myNamespace")
*/
namespace: string;
};
export type GlobalData = {[pluginName: string]: {[pluginId: string]: unknown}}; export type GlobalData = {[pluginName: string]: {[pluginId: string]: unknown}};
export type LoadContext = { export type LoadContext = {
@ -50,6 +69,11 @@ export type LoadContext = {
baseUrl: string; baseUrl: string;
i18n: I18n; i18n: I18n;
codeTranslations: CodeTranslations; codeTranslations: CodeTranslations;
/**
* Defines the default browser storage behavior for a site
*/
siteStorage: SiteStorage;
}; };
export type Props = LoadContext & { export type Props = LoadContext & {

View file

@ -12,6 +12,8 @@ export {
DefaultParseFrontMatter, DefaultParseFrontMatter,
ParseFrontMatter, ParseFrontMatter,
DocusaurusConfig, DocusaurusConfig,
FutureConfig,
StorageConfig,
Config, Config,
} from './config'; } from './config';
@ -20,6 +22,7 @@ export {
DocusaurusContext, DocusaurusContext,
GlobalData, GlobalData,
LoadContext, LoadContext,
SiteStorage,
Props, Props,
} from './context'; } from './context';

View file

@ -7,6 +7,12 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -61,6 +67,12 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -115,6 +127,12 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -169,6 +187,12 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -223,6 +247,12 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -277,6 +307,12 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -331,6 +367,12 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -387,6 +429,12 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -443,6 +491,12 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -502,6 +556,12 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
], ],
"customFields": {}, "customFields": {},
"favicon": "img/docusaurus.ico", "favicon": "img/docusaurus.ico",
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",

View file

@ -77,6 +77,12 @@ exports[`load loads props for site with custom i18n path 1`] = `
"baseUrlIssueBanner": true, "baseUrlIssueBanner": true,
"clientModules": [], "clientModules": [],
"customFields": {}, "customFields": {},
"future": {
"experimental_storage": {
"namespace": false,
"type": "localStorage",
},
},
"headTags": [], "headTags": [],
"i18n": { "i18n": {
"defaultLocale": "en", "defaultLocale": "en",
@ -137,6 +143,10 @@ exports[`load loads props for site with custom i18n path 1`] = `
"pluginVersions": {}, "pluginVersions": {},
"siteVersion": undefined, "siteVersion": undefined,
}, },
"siteStorage": {
"namespace": "",
"type": "localStorage",
},
"siteVersion": undefined, "siteVersion": undefined,
} }
`; `;

View file

@ -8,8 +8,10 @@
import { import {
ConfigSchema, ConfigSchema,
DEFAULT_CONFIG, DEFAULT_CONFIG,
DEFAULT_STORAGE_CONFIG,
validateConfig, validateConfig,
} from '../configValidation'; } from '../configValidation';
import type {StorageConfig} from '@docusaurus/types/src/config';
import type {Config, DocusaurusConfig} from '@docusaurus/types'; import type {Config, DocusaurusConfig} from '@docusaurus/types';
import type {DeepPartial} from 'utility-types'; import type {DeepPartial} from 'utility-types';
@ -35,6 +37,12 @@ describe('normalizeConfig', () => {
const userConfig: Config = { const userConfig: Config = {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
...baseConfig, ...baseConfig,
future: {
experimental_storage: {
type: 'sessionStorage',
namespace: true,
},
},
tagline: 'my awesome site', tagline: 'my awesome site',
organizationName: 'facebook', organizationName: 'facebook',
projectName: 'docusaurus', projectName: 'docusaurus',
@ -588,12 +596,8 @@ describe('markdown', () => {
}); });
it('throw for bad markdown format', () => { it('throw for bad markdown format', () => {
expect(() => expect(() => normalizeConfig({markdown: {format: null}}))
normalizeConfig( .toThrowErrorMatchingInlineSnapshot(`
// @ts-expect-error: bad value
{markdown: {format: null}},
),
).toThrowErrorMatchingInlineSnapshot(`
""markdown.format" must be one of [mdx, md, detect] ""markdown.format" must be one of [mdx, md, detect]
"markdown.format" must be a string "markdown.format" must be a string
" "
@ -612,7 +616,6 @@ describe('markdown', () => {
it('throw for null object', () => { it('throw for null object', () => {
expect(() => { expect(() => {
normalizeConfig({ normalizeConfig({
// @ts-expect-error: test
markdown: null, markdown: null,
}); });
}).toThrowErrorMatchingInlineSnapshot(` }).toThrowErrorMatchingInlineSnapshot(`
@ -621,3 +624,292 @@ describe('markdown', () => {
`); `);
}); });
}); });
describe('future', () => {
it('accepts future - undefined', () => {
expect(
normalizeConfig({
future: undefined,
}),
).toEqual(expect.objectContaining({future: DEFAULT_CONFIG.future}));
});
it('accepts future - empty', () => {
expect(
normalizeConfig({
future: {},
}),
).toEqual(expect.objectContaining({future: DEFAULT_CONFIG.future}));
});
it('accepts future', () => {
const future: DocusaurusConfig['future'] = {
experimental_storage: {
type: 'sessionStorage',
namespace: 'myNamespace',
},
};
expect(
normalizeConfig({
future,
}),
).toEqual(expect.objectContaining({future}));
});
it('rejects future - unknown key', () => {
const future: DocusaurusConfig['future'] = {
// @ts-expect-error: invalid
doesNotExistKey: {
type: 'sessionStorage',
namespace: 'myNamespace',
},
};
expect(() =>
normalizeConfig({
future,
}),
).toThrowErrorMatchingInlineSnapshot(`
"These field(s) ("future.doesNotExistKey",) are not recognized in docusaurus.config.js.
If you still want these fields to be in your configuration, put them in the "customFields" field.
See https://docusaurus.io/docs/api/docusaurus-config/#customfields"
`);
});
describe('storage', () => {
it('accepts storage - undefined', () => {
expect(
normalizeConfig({
future: {
experimental_storage: undefined,
},
}),
).toEqual(expect.objectContaining({future: DEFAULT_CONFIG.future}));
});
it('accepts storage - empty', () => {
expect(
normalizeConfig({
future: {experimental_storage: {}},
}),
).toEqual(expect.objectContaining({future: DEFAULT_CONFIG.future}));
});
it('accepts storage - full', () => {
const storage: StorageConfig = {
type: 'sessionStorage',
namespace: 'myNamespace',
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: storage,
},
}),
);
});
it('rejects storage - boolean', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = true;
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage" must be of type object
"
`);
});
it('rejects storage - number', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = 42;
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage" must be of type object
"
`);
});
describe('type', () => {
it('accepts type', () => {
const storage: Partial<StorageConfig> = {
type: 'sessionStorage',
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
);
});
it('accepts type - undefined', () => {
const storage: Partial<StorageConfig> = {
type: undefined,
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
type: 'localStorage',
},
},
}),
);
});
it('rejects type - null', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = {type: 42};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.type" must be one of [localStorage, sessionStorage]
"future.experimental_storage.type" must be a string
"
`);
});
it('rejects type - number', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = {type: 42};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.type" must be one of [localStorage, sessionStorage]
"future.experimental_storage.type" must be a string
"
`);
});
it('rejects type - invalid enum value', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = {type: 'badType'};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.type" must be one of [localStorage, sessionStorage]
"
`);
});
});
describe('namespace', () => {
it('accepts namespace - boolean', () => {
const storage: Partial<StorageConfig> = {
namespace: true,
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
);
});
it('accepts namespace - string', () => {
const storage: Partial<StorageConfig> = {
namespace: 'myNamespace',
};
expect(
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toEqual(
expect.objectContaining({
future: {
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
},
}),
);
});
it('rejects namespace - null', () => {
const storage: Partial<StorageConfig> = {namespace: null};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.namespace" must be one of [string, boolean]
"
`);
});
it('rejects namespace - number', () => {
// @ts-expect-error: invalid
const storage: Partial<StorageConfig> = {namespace: 42};
expect(() =>
normalizeConfig({
future: {
experimental_storage: storage,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.experimental_storage.namespace" must be one of [string, boolean]
"
`);
});
});
});
});

View file

@ -38,6 +38,10 @@ describe('load', () => {
siteConfig: { siteConfig: {
baseUrl: '/zh-Hans/', baseUrl: '/zh-Hans/',
}, },
siteStorage: {
namespace: '',
type: 'localStorage',
},
plugins: site2.props.plugins, plugins: site2.props.plugins,
}), }),
); );

View file

@ -0,0 +1,165 @@
/**
* 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 {createSiteStorage} from '../storage';
import {
DEFAULT_FUTURE_CONFIG,
DEFAULT_STORAGE_CONFIG,
} from '../configValidation';
import type {FutureConfig, StorageConfig, SiteStorage} from '@docusaurus/types';
function test({
url = 'https://docusaurus.io',
baseUrl = '/',
storage = {},
}: {
url?: string;
baseUrl?: string;
storage?: Partial<StorageConfig>;
}): SiteStorage {
const future: FutureConfig = {
...DEFAULT_FUTURE_CONFIG,
experimental_storage: {
...DEFAULT_STORAGE_CONFIG,
...storage,
},
};
return createSiteStorage({url, baseUrl, future});
}
const DefaultSiteStorage: SiteStorage = {
type: 'localStorage',
namespace: '',
};
describe('storage', () => {
it('default', () => {
expect(test({})).toEqual(DefaultSiteStorage);
});
describe('type', () => {
it('localStorage', () => {
expect(test({storage: {type: 'localStorage'}})).toEqual({
...DefaultSiteStorage,
type: 'localStorage',
});
});
it('sessionStorage', () => {
expect(test({storage: {type: 'sessionStorage'}})).toEqual({
...DefaultSiteStorage,
type: 'sessionStorage',
});
});
});
describe('namespace', () => {
describe('true', () => {
function testAutomaticNamespace(
{
url,
baseUrl,
}: {
url: string;
baseUrl: string;
},
expectedNamespace: string,
) {
return expect(test({url, baseUrl, storage: {namespace: true}})).toEqual(
expect.objectContaining({namespace: expectedNamespace}),
);
}
it('automatic namespace - https://docusaurus.io/', () => {
testAutomaticNamespace(
{
url: 'https://docusaurus.io',
baseUrl: '/',
},
'-189',
);
});
it('automatic namespace - https://docusaurus.io/baseUrl/', () => {
testAutomaticNamespace(
{
url: 'https://docusaurus.io',
baseUrl: '/baseUrl/',
},
'-b21',
);
});
it('automatic namespace - https://example.com/', () => {
testAutomaticNamespace(
{
url: 'https://example.com',
baseUrl: '/',
},
'-182',
);
});
it('automatic namespace - https://example.com/baseUrl/', () => {
testAutomaticNamespace(
{
url: 'https://example.com',
baseUrl: '/baseUrl/',
},
'-ad6',
);
});
it('automatic namespace - is not slash sensitive', () => {
const expectedNamespace = '-b21';
testAutomaticNamespace(
{
url: 'https://docusaurus.io',
baseUrl: '/baseUrl/',
},
expectedNamespace,
);
testAutomaticNamespace(
{
url: 'https://docusaurus.io/',
baseUrl: '/baseUrl/',
},
expectedNamespace,
);
testAutomaticNamespace(
{
url: 'https://docusaurus.io/',
baseUrl: '/baseUrl',
},
expectedNamespace,
);
testAutomaticNamespace(
{
url: 'https://docusaurus.io',
baseUrl: 'baseUrl',
},
expectedNamespace,
);
});
});
it('false', () => {
expect(test({storage: {namespace: false}})).toEqual({
...DefaultSiteStorage,
namespace: '',
});
});
it('string', () => {
expect(test({storage: {namespace: 'my-namespace'}})).toEqual({
...DefaultSiteStorage,
namespace: '-my-namespace',
});
});
});
});

View file

@ -18,6 +18,7 @@ import type {
I18n, I18n,
PluginRouteConfig, PluginRouteConfig,
SiteMetadata, SiteMetadata,
SiteStorage,
} from '@docusaurus/types'; } from '@docusaurus/types';
function genWarning({generatedFilesDir}: {generatedFilesDir: string}) { function genWarning({generatedFilesDir}: {generatedFilesDir: string}) {
@ -131,6 +132,20 @@ function genSiteMetadata({
); );
} }
function genSiteStorage({
generatedFilesDir,
siteStorage,
}: {
generatedFilesDir: string;
siteStorage: SiteStorage;
}) {
return generate(
generatedFilesDir,
'site-storage.json',
JSON.stringify(siteStorage, null, 2),
);
}
type CodegenParams = { type CodegenParams = {
generatedFilesDir: string; generatedFilesDir: string;
siteConfig: DocusaurusConfig; siteConfig: DocusaurusConfig;
@ -140,6 +155,7 @@ type CodegenParams = {
i18n: I18n; i18n: I18n;
codeTranslations: CodeTranslations; codeTranslations: CodeTranslations;
siteMetadata: SiteMetadata; siteMetadata: SiteMetadata;
siteStorage: SiteStorage;
routes: PluginRouteConfig[]; routes: PluginRouteConfig[];
}; };
@ -151,6 +167,7 @@ export async function generateSiteFiles(params: CodegenParams): Promise<void> {
generateRouteFiles(params), generateRouteFiles(params),
genGlobalData(params), genGlobalData(params),
genSiteMetadata(params), genSiteMetadata(params),
genSiteStorage(params),
genI18n(params), genI18n(params),
genCodeTranslations(params), genCodeTranslations(params),
]); ]);

View file

@ -16,6 +16,7 @@ import {
addLeadingSlash, addLeadingSlash,
removeTrailingSlash, removeTrailingSlash,
} from '@docusaurus/utils-common'; } from '@docusaurus/utils-common';
import type {FutureConfig, StorageConfig} from '@docusaurus/types/src/config';
import type { import type {
DocusaurusConfig, DocusaurusConfig,
I18nConfig, I18nConfig,
@ -31,6 +32,15 @@ export const DEFAULT_I18N_CONFIG: I18nConfig = {
localeConfigs: {}, localeConfigs: {},
}; };
export const DEFAULT_STORAGE_CONFIG: StorageConfig = {
type: 'localStorage',
namespace: false,
};
export const DEFAULT_FUTURE_CONFIG: FutureConfig = {
experimental_storage: DEFAULT_STORAGE_CONFIG,
};
export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = { export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
format: 'mdx', // TODO change this to "detect" in Docusaurus v4? format: 'mdx', // TODO change this to "detect" in Docusaurus v4?
mermaid: false, mermaid: false,
@ -50,6 +60,7 @@ export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = {
export const DEFAULT_CONFIG: Pick< export const DEFAULT_CONFIG: Pick<
DocusaurusConfig, DocusaurusConfig,
| 'i18n' | 'i18n'
| 'future'
| 'onBrokenLinks' | 'onBrokenLinks'
| 'onBrokenAnchors' | 'onBrokenAnchors'
| 'onBrokenMarkdownLinks' | 'onBrokenMarkdownLinks'
@ -71,6 +82,7 @@ export const DEFAULT_CONFIG: Pick<
| 'markdown' | 'markdown'
> = { > = {
i18n: DEFAULT_I18N_CONFIG, i18n: DEFAULT_I18N_CONFIG,
future: DEFAULT_FUTURE_CONFIG,
onBrokenLinks: 'throw', onBrokenLinks: 'throw',
onBrokenAnchors: 'warn', // TODO Docusaurus v4: change to throw onBrokenAnchors: 'warn', // TODO Docusaurus v4: change to throw
onBrokenMarkdownLinks: 'warn', onBrokenMarkdownLinks: 'warn',
@ -181,6 +193,23 @@ const I18N_CONFIG_SCHEMA = Joi.object<I18nConfig>({
.optional() .optional()
.default(DEFAULT_I18N_CONFIG); .default(DEFAULT_I18N_CONFIG);
const STORAGE_CONFIG_SCHEMA = Joi.object({
type: Joi.string()
.equal('localStorage', 'sessionStorage')
.default(DEFAULT_STORAGE_CONFIG.type),
namespace: Joi.alternatives()
.try(Joi.string(), Joi.boolean())
.default(DEFAULT_STORAGE_CONFIG.namespace),
})
.optional()
.default(DEFAULT_STORAGE_CONFIG);
const FUTURE_CONFIG_SCHEMA = Joi.object<FutureConfig>({
experimental_storage: STORAGE_CONFIG_SCHEMA,
})
.optional()
.default(DEFAULT_FUTURE_CONFIG);
const SiteUrlSchema = Joi.string() const SiteUrlSchema = Joi.string()
.required() .required()
.custom((value: string, helpers) => { .custom((value: string, helpers) => {
@ -215,6 +244,7 @@ export const ConfigSchema = Joi.object<DocusaurusConfig>({
url: SiteUrlSchema, url: SiteUrlSchema,
trailingSlash: Joi.boolean(), // No default value! undefined = retrocompatible legacy behavior! trailingSlash: Joi.boolean(), // No default value! undefined = retrocompatible legacy behavior!
i18n: I18N_CONFIG_SCHEMA, i18n: I18N_CONFIG_SCHEMA,
future: FUTURE_CONFIG_SCHEMA,
onBrokenLinks: Joi.string() onBrokenLinks: Joi.string()
.equal('ignore', 'log', 'warn', 'throw') .equal('ignore', 'log', 'warn', 'throw')
.default(DEFAULT_CONFIG.onBrokenLinks), .default(DEFAULT_CONFIG.onBrokenLinks),

View file

@ -25,6 +25,7 @@ import {
import {PerfLogger} from '../utils'; import {PerfLogger} from '../utils';
import {generateSiteFiles} from './codegen/codegen'; import {generateSiteFiles} from './codegen/codegen';
import {getRoutesPaths, handleDuplicateRoutes} from './routes'; import {getRoutesPaths, handleDuplicateRoutes} from './routes';
import {createSiteStorage} from './storage';
import type {LoadPluginsResult} from './plugins/plugins'; import type {LoadPluginsResult} from './plugins/plugins';
import type { import type {
DocusaurusConfig, DocusaurusConfig,
@ -111,9 +112,12 @@ export async function loadContext(
const codeTranslations = await loadSiteCodeTranslations({localizationDir}); const codeTranslations = await loadSiteCodeTranslations({localizationDir});
const siteStorage = createSiteStorage(siteConfig);
return { return {
siteDir, siteDir,
siteVersion, siteVersion,
siteStorage,
generatedFilesDir, generatedFilesDir,
localizationDir, localizationDir,
siteConfig, siteConfig,
@ -135,6 +139,7 @@ function createSiteProps(
siteVersion, siteVersion,
siteConfig, siteConfig,
siteConfigPath, siteConfigPath,
siteStorage,
outDir, outDir,
baseUrl, baseUrl,
i18n, i18n,
@ -159,6 +164,7 @@ function createSiteProps(
siteConfigPath, siteConfigPath,
siteMetadata, siteMetadata,
siteVersion, siteVersion,
siteStorage,
siteDir, siteDir,
outDir, outDir,
baseUrl, baseUrl,
@ -190,6 +196,7 @@ async function createSiteFiles({
generatedFilesDir, generatedFilesDir,
siteConfig, siteConfig,
siteMetadata, siteMetadata,
siteStorage,
i18n, i18n,
codeTranslations, codeTranslations,
routes, routes,
@ -202,6 +209,7 @@ async function createSiteFiles({
clientModules, clientModules,
siteConfig, siteConfig,
siteMetadata, siteMetadata,
siteStorage,
i18n, i18n,
codeTranslations, codeTranslations,
globalData, globalData,
@ -224,7 +232,7 @@ export async function loadSite(params: LoadContextParams): Promise<Site> {
const {plugins, routes, globalData} = await loadPlugins(context); const {plugins, routes, globalData} = await loadPlugins(context);
const props = await createSiteProps({plugins, routes, globalData, context}); const props = createSiteProps({plugins, routes, globalData, context});
const site: Site = {props, params}; const site: Site = {props, params};
@ -253,7 +261,7 @@ export async function reloadSitePlugin(
context: site.props, context: site.props,
}); });
const newProps = await createSiteProps({ const newProps = createSiteProps({
plugins, plugins,
routes, routes,
globalData, globalData,

View file

@ -0,0 +1,44 @@
/**
* 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 {normalizeUrl, simpleHash} from '@docusaurus/utils';
import {addTrailingSlash} from '@docusaurus/utils-common';
import type {DocusaurusConfig, SiteStorage} from '@docusaurus/types';
type PartialFuture = Pick<DocusaurusConfig['future'], 'experimental_storage'>;
type PartialConfig = Pick<DocusaurusConfig, 'url' | 'baseUrl'> & {
future: PartialFuture;
};
function automaticNamespace(config: PartialConfig): string {
const normalizedUrl = addTrailingSlash(
normalizeUrl([config.url, config.baseUrl]),
);
return simpleHash(normalizedUrl, 3);
}
function getNamespaceString(config: PartialConfig): string | null {
if (config.future.experimental_storage.namespace === true) {
return automaticNamespace(config);
} else if (config.future.experimental_storage.namespace === false) {
return null;
} else {
return config.future.experimental_storage.namespace;
}
}
export function createSiteStorage(config: PartialConfig): SiteStorage {
const {type} = config.future.experimental_storage;
const namespaceString = getNamespaceString(config);
const namespace = namespaceString ? `-${namespaceString}` : '';
return {
type,
namespace,
};
}

View file

@ -104,6 +104,7 @@ export const dogfoodingPluginInstances: PluginConfig[] = [
return [ return [
require.resolve('./clientModuleExample.ts'), require.resolve('./clientModuleExample.ts'),
require.resolve('./clientModuleCSS.css'), require.resolve('./clientModuleCSS.css'),
require.resolve('./migrateStorageNamespace.ts'),
]; ];
}, },
}; };

View file

@ -0,0 +1,31 @@
/**
* 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 ExecutionEnvironment from '@docusaurus/ExecutionEnvironment';
import SiteStorage from '@generated/site-storage';
// The purpose is to test a migration script for storage namespacing
// See also: https://github.com/facebook/docusaurus/pull/10121
if (ExecutionEnvironment.canUseDOM) {
const migrateStorageKey = (key: string) => {
const value = localStorage.getItem(key);
if (value !== null && SiteStorage.namespace) {
const newKey = `${key}${SiteStorage.namespace}`;
console.log(`Updating storage key [${key} => ${newKey}], value=${value}`);
localStorage.setItem(newKey, value);
localStorage.removeItem(key);
}
};
const storageKeys = [
'theme',
'docusaurus.announcement.id',
'docusaurus.announcement.dismiss',
'docs-preferred-version-default',
];
storageKeys.forEach(migrateStorageKey);
}

View file

@ -156,6 +156,12 @@ We will outline what accounts as the public API surface.
- `@docusaurus/types` TypeScript types - `@docusaurus/types` TypeScript types
- We still retain the freedom to make types stricter (which may break type-checking). - We still retain the freedom to make types stricter (which may break type-checking).
❌ Our public API **excludes**:
- Docusaurus config `future`
- All features prefixed by `experimental_` or `unstable_`
- All features prefixed by `v<MajorVersion>_` (`v6_` `v7_`, etc.)
:::tip :::tip
For non-theme APIs, any documented API is considered public (and will be stable); any undocumented API is considered internal. For non-theme APIs, any documented API is considered public (and will be stable); any undocumented API is considered internal.

View file

@ -174,6 +174,41 @@ 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`). - `calendar`: the [calendar](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/calendar) used to calculate the date era. Note that it doesn't control the actual string displayed: `MM/DD/YYYY` and `DD/MM/YYYY` are both `gregory`. To choose the format (`DD/MM/YYYY` or `MM/DD/YYYY`), set your locale name to `en-GB` or `en-US` (`en` means `en-US`).
- `path`: Root folder that all plugin localization folders of this locale are relative to. Will be resolved against `i18n.path`. Defaults to the locale's name. Note: this has no effect on the locale's `baseUrl`—customization of base URL is a work-in-progress. - `path`: Root folder that all plugin localization folders of this locale are relative to. Will be resolved against `i18n.path`. Defaults to the locale's name. Note: this has no effect on the locale's `baseUrl`—customization of base URL is a work-in-progress.
### `future` {#future}
- Type: `Object`
The `future` configuration object permits to opt-in for upcoming/unstable/experimental Docusaurus features that are not ready for prime time.
It is also a way to opt-in for upcoming breaking changes coming in the next major versions, enabling you to prepare your site for the next version while staying on the previous one. The [Remix Future Flags blog post](https://remix.run/blog/future-flags) greatly explains this idea.
:::danger Breaking changes in minor versions
Features prefixed by `experimental_` or `unstable_` are subject to changes in **minor versions**, and not considered as [Semantic Versioning breaking changes](/community/release-process).
Features prefixed by `v<MajorVersion>_` (`v6_` `v7_`, etc.) are future flags that are expected to be turned on by default in the next major versions. These are less likely to change, but we keep the possibility to do so.
`future` API breaking changes should be easy to handle, and will be documented in minor/major version blog posts.
:::
Example:
```js title="docusaurus.config.js"
export default {
future: {
experimental_storage: {
type: 'localStorage',
namespace: true,
},
},
};
```
- `experimental_storage`: Site-wide browser storage options that theme authors should strive to respect.
- `type`: The browser storage theme authors should use. Possible values are `localStorage` and `sessionStorage`. Defaults to `localStorage`.
- `namespace`: Whether to namespace the browser storage keys to avoid storage key conflicts when Docusaurus sites are hosted under the same domain, or on localhost. Possible values are `string | boolean`. The namespace is appended at the end of the storage keys `key-namespace`. Use `true` to automatically generate a random namespace from your site `url + baseUrl`. Defaults to `false` (no namespace, historical behavior).
### `noIndex` {#noIndex} ### `noIndex` {#noIndex}
- Type: `boolean` - Type: `boolean`

View file

@ -147,6 +147,11 @@ export default async function createConfigAsync() {
baseUrl, baseUrl,
baseUrlIssueBanner: true, baseUrlIssueBanner: true,
url: 'https://docusaurus.io', url: 'https://docusaurus.io',
future: {
experimental_storage: {
namespace: true,
},
},
// Dogfood both settings: // Dogfood both settings:
// - force trailing slashes for deploy previews // - force trailing slashes for deploy previews
// - avoid trailing slashes in prod // - avoid trailing slashes in prod