From 620e46350a57bf1588fb20ce8622a3c871a366de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Fri, 10 May 2024 14:41:51 +0200 Subject: [PATCH] feat(core): site storage config options (experimental) (#10121) --- .../src/index.d.ts | 7 + .../docusaurus-theme-classic/src/index.ts | 91 +++--- .../src/utils/storageUtils.ts | 12 +- packages/docusaurus-types/src/config.d.ts | 15 + packages/docusaurus-types/src/context.d.ts | 24 ++ packages/docusaurus-types/src/index.d.ts | 3 + .../__snapshots__/config.test.ts.snap | 60 ++++ .../__tests__/__snapshots__/site.test.ts.snap | 10 + .../server/__tests__/configValidation.test.ts | 306 +++++++++++++++++- .../src/server/__tests__/site.test.ts | 4 + .../src/server/__tests__/storage.test.ts | 165 ++++++++++ .../docusaurus/src/server/codegen/codegen.ts | 17 + .../docusaurus/src/server/configValidation.ts | 30 ++ packages/docusaurus/src/server/site.ts | 12 +- packages/docusaurus/src/server/storage.ts | 44 +++ website/_dogfooding/dogfooding.config.ts | 1 + .../_dogfooding/migrateStorageNamespace.ts | 31 ++ website/community/5-release-process.mdx | 6 + website/docs/api/docusaurus.config.js.mdx | 35 ++ website/docusaurus.config.ts | 5 + 20 files changed, 824 insertions(+), 54 deletions(-) create mode 100644 packages/docusaurus/src/server/__tests__/storage.test.ts create mode 100644 packages/docusaurus/src/server/storage.ts create mode 100644 website/_dogfooding/migrateStorageNamespace.ts diff --git a/packages/docusaurus-module-type-aliases/src/index.d.ts b/packages/docusaurus-module-type-aliases/src/index.d.ts index 1c4fb78360c6..8da692e648dc 100644 --- a/packages/docusaurus-module-type-aliases/src/index.d.ts +++ b/packages/docusaurus-module-type-aliases/src/index.d.ts @@ -26,6 +26,13 @@ declare module '@generated/site-metadata' { export = siteMetadata; } +declare module '@generated/site-storage' { + import type {SiteStorage} from '@docusaurus/types'; + + const siteStorage: SiteStorage; + export = siteStorage; +} + declare module '@generated/registry' { import type {Registry} from '@docusaurus/types'; diff --git a/packages/docusaurus-theme-classic/src/index.ts b/packages/docusaurus-theme-classic/src/index.ts index 48a80606ac10..f366c5a4a318 100644 --- a/packages/docusaurus-theme-classic/src/index.ts +++ b/packages/docusaurus-theme-classic/src/index.ts @@ -10,7 +10,7 @@ import {createRequire} from 'module'; import rtlcss from 'rtlcss'; import {readDefaultCodeTranslationMessages} from '@docusaurus/theme-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 {Plugin as PostCssPlugin} from 'postcss'; import type {PluginOptions} from '@docusaurus/theme-classic'; @@ -23,58 +23,66 @@ const ContextReplacementPlugin = requireFromDocusaurusCore( 'webpack/lib/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 const ThemeQueryStringKey = 'docusaurus-theme'; // Support for ?docusaurus-data-mode=embed&docusaurus-data-myAttr=42 const DataQueryStringPrefixKey = 'docusaurus-data-'; const noFlashColorMode = ({ - defaultMode, - respectPrefersColorScheme, -}: ThemeConfig['colorMode']) => + colorMode: {defaultMode, respectPrefersColorScheme}, + siteStorage, +}: { + 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 */ - `(function() { - var defaultMode = '${defaultMode}'; - var respectPrefersColorScheme = ${respectPrefersColorScheme}; + return `(function() { + var defaultMode = '${defaultMode}'; + var respectPrefersColorScheme = ${respectPrefersColorScheme}; - function setDataThemeAttribute(theme) { - document.documentElement.setAttribute('data-theme', theme); - } + function setDataThemeAttribute(theme) { + document.documentElement.setAttribute('data-theme', theme); + } - function getQueryStringTheme() { - try { - return new URLSearchParams(window.location.search).get('${ThemeQueryStringKey}') - } catch(e) {} - } + function getQueryStringTheme() { + try { + return new URLSearchParams(window.location.search).get('${ThemeQueryStringKey}') + } catch (e) { + } + } - function getStoredTheme() { - try { - return localStorage.getItem('${ThemeStorageKey}'); - } catch (err) {} - } + 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'); + var initialTheme = getQueryStringTheme() || getStoredTheme(); + if (initialTheme !== null) { + setDataThemeAttribute(initialTheme); } else { - setDataThemeAttribute(defaultMode === 'dark' ? 'dark' : 'light'); + 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 */ const DataAttributeQueryStringInlineJavaScript = ` @@ -126,6 +134,7 @@ export default function themeClassic( ): Plugin { const { i18n: {currentLocale, localeConfigs}, + siteStorage, } = context; const themeConfig = context.siteConfig.themeConfig as ThemeConfig; const { @@ -218,7 +227,7 @@ export default function themeClassic( { tagName: 'script', innerHTML: ` -${noFlashColorMode(colorMode)} +${noFlashColorMode({colorMode, siteStorage})} ${DataAttributeQueryStringInlineJavaScript} ${announcementBar ? AnnouncementBarInlineJavaScript : ''} `, diff --git a/packages/docusaurus-theme-common/src/utils/storageUtils.ts b/packages/docusaurus-theme-common/src/utils/storageUtils.ts index 680dc1c4f721..a8c8825b2267 100644 --- a/packages/docusaurus-theme-common/src/utils/storageUtils.ts +++ b/packages/docusaurus-theme-common/src/utils/storageUtils.ts @@ -6,12 +6,15 @@ */ 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... // 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 */ export function createStorageSlot( - key: string, + keyInput: string, options?: {persistence?: StorageType}, ): StorageSlot { + const key = applyNamespace(keyInput); if (typeof window === 'undefined') { return createServerStorageSlot(key); } diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index 62627281cc66..422ce7a574ee 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -5,6 +5,7 @@ * LICENSE file in the root directory of this source tree. */ +import type {SiteStorage} from './context'; import type {RuleSetRule} from 'webpack'; import type {Required as RequireKeys, DeepPartial} from 'utility-types'; import type {I18nConfig} from './i18n'; @@ -115,6 +116,15 @@ export type MarkdownConfig = { anchors: MarkdownAnchorsConfig; }; +export type StorageConfig = { + type: SiteStorage['type']; + namespace: boolean | string; +}; + +export type FutureConfig = { + experimental_storage: StorageConfig; +}; + /** * Docusaurus config, after validation/normalization. */ @@ -171,6 +181,11 @@ export type DocusaurusConfig = { * @see https://docusaurus.io/docs/api/docusaurus-config#i18n */ 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 `` to * every page to tell search engines to avoid indexing your site. diff --git a/packages/docusaurus-types/src/context.d.ts b/packages/docusaurus-types/src/context.d.ts index 68c4f78d6ecd..e399c5342191 100644 --- a/packages/docusaurus-types/src/context.d.ts +++ b/packages/docusaurus-types/src/context.d.ts @@ -27,6 +27,25 @@ export type SiteMetadata = { 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 LoadContext = { @@ -50,6 +69,11 @@ export type LoadContext = { baseUrl: string; i18n: I18n; codeTranslations: CodeTranslations; + + /** + * Defines the default browser storage behavior for a site + */ + siteStorage: SiteStorage; }; export type Props = LoadContext & { diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index a67446410180..1504e6d03450 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -12,6 +12,8 @@ export { DefaultParseFrontMatter, ParseFrontMatter, DocusaurusConfig, + FutureConfig, + StorageConfig, Config, } from './config'; @@ -20,6 +22,7 @@ export { DocusaurusContext, GlobalData, LoadContext, + SiteStorage, Props, } from './context'; diff --git a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap index 72d6a58aa5a8..47a2827d2d29 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -7,6 +7,12 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -61,6 +67,12 @@ exports[`loadSiteConfig website with ts + js config 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -115,6 +127,12 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -169,6 +187,12 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -223,6 +247,12 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -277,6 +307,12 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -331,6 +367,12 @@ exports[`loadSiteConfig website with valid async config 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -387,6 +429,12 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -443,6 +491,12 @@ exports[`loadSiteConfig website with valid config creator function 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -502,6 +556,12 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = ` ], "customFields": {}, "favicon": "img/docusaurus.ico", + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", 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 c2562812b70f..a7837a67e18c 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap @@ -77,6 +77,12 @@ exports[`load loads props for site with custom i18n path 1`] = ` "baseUrlIssueBanner": true, "clientModules": [], "customFields": {}, + "future": { + "experimental_storage": { + "namespace": false, + "type": "localStorage", + }, + }, "headTags": [], "i18n": { "defaultLocale": "en", @@ -137,6 +143,10 @@ exports[`load loads props for site with custom i18n path 1`] = ` "pluginVersions": {}, "siteVersion": undefined, }, + "siteStorage": { + "namespace": "", + "type": "localStorage", + }, "siteVersion": undefined, } `; diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index c20ff6431493..cf7fee326970 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -8,8 +8,10 @@ import { ConfigSchema, DEFAULT_CONFIG, + DEFAULT_STORAGE_CONFIG, validateConfig, } from '../configValidation'; +import type {StorageConfig} from '@docusaurus/types/src/config'; import type {Config, DocusaurusConfig} from '@docusaurus/types'; import type {DeepPartial} from 'utility-types'; @@ -35,6 +37,12 @@ describe('normalizeConfig', () => { const userConfig: Config = { ...DEFAULT_CONFIG, ...baseConfig, + future: { + experimental_storage: { + type: 'sessionStorage', + namespace: true, + }, + }, tagline: 'my awesome site', organizationName: 'facebook', projectName: 'docusaurus', @@ -588,12 +596,8 @@ describe('markdown', () => { }); it('throw for bad markdown format', () => { - expect(() => - normalizeConfig( - // @ts-expect-error: bad value - {markdown: {format: null}}, - ), - ).toThrowErrorMatchingInlineSnapshot(` + expect(() => normalizeConfig({markdown: {format: null}})) + .toThrowErrorMatchingInlineSnapshot(` ""markdown.format" must be one of [mdx, md, detect] "markdown.format" must be a string " @@ -612,7 +616,6 @@ describe('markdown', () => { it('throw for null object', () => { expect(() => { normalizeConfig({ - // @ts-expect-error: test markdown: null, }); }).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 = 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 = 42; + expect(() => + normalizeConfig({ + future: { + experimental_storage: storage, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_storage" must be of type object + " + `); + }); + + describe('type', () => { + it('accepts type', () => { + const storage: Partial = { + 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 = { + 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 = {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 = {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 = {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 = { + 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 = { + 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 = {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 = {namespace: 42}; + expect(() => + normalizeConfig({ + future: { + experimental_storage: storage, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.experimental_storage.namespace" must be one of [string, boolean] + " + `); + }); + }); + }); +}); diff --git a/packages/docusaurus/src/server/__tests__/site.test.ts b/packages/docusaurus/src/server/__tests__/site.test.ts index 235060c3e445..dc6027c780da 100644 --- a/packages/docusaurus/src/server/__tests__/site.test.ts +++ b/packages/docusaurus/src/server/__tests__/site.test.ts @@ -38,6 +38,10 @@ describe('load', () => { siteConfig: { baseUrl: '/zh-Hans/', }, + siteStorage: { + namespace: '', + type: 'localStorage', + }, plugins: site2.props.plugins, }), ); diff --git a/packages/docusaurus/src/server/__tests__/storage.test.ts b/packages/docusaurus/src/server/__tests__/storage.test.ts new file mode 100644 index 000000000000..f91b9ddbef3e --- /dev/null +++ b/packages/docusaurus/src/server/__tests__/storage.test.ts @@ -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; +}): 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', + }); + }); + }); +}); diff --git a/packages/docusaurus/src/server/codegen/codegen.ts b/packages/docusaurus/src/server/codegen/codegen.ts index d6df2de34edd..cc8d458a6679 100644 --- a/packages/docusaurus/src/server/codegen/codegen.ts +++ b/packages/docusaurus/src/server/codegen/codegen.ts @@ -18,6 +18,7 @@ import type { I18n, PluginRouteConfig, SiteMetadata, + SiteStorage, } from '@docusaurus/types'; 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 = { generatedFilesDir: string; siteConfig: DocusaurusConfig; @@ -140,6 +155,7 @@ type CodegenParams = { i18n: I18n; codeTranslations: CodeTranslations; siteMetadata: SiteMetadata; + siteStorage: SiteStorage; routes: PluginRouteConfig[]; }; @@ -151,6 +167,7 @@ export async function generateSiteFiles(params: CodegenParams): Promise { generateRouteFiles(params), genGlobalData(params), genSiteMetadata(params), + genSiteStorage(params), genI18n(params), genCodeTranslations(params), ]); diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index 3f6fac5fb174..bf581897a4d4 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -16,6 +16,7 @@ import { addLeadingSlash, removeTrailingSlash, } from '@docusaurus/utils-common'; +import type {FutureConfig, StorageConfig} from '@docusaurus/types/src/config'; import type { DocusaurusConfig, I18nConfig, @@ -31,6 +32,15 @@ export const DEFAULT_I18N_CONFIG: I18nConfig = { 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 = { format: 'mdx', // TODO change this to "detect" in Docusaurus v4? mermaid: false, @@ -50,6 +60,7 @@ export const DEFAULT_MARKDOWN_CONFIG: MarkdownConfig = { export const DEFAULT_CONFIG: Pick< DocusaurusConfig, | 'i18n' + | 'future' | 'onBrokenLinks' | 'onBrokenAnchors' | 'onBrokenMarkdownLinks' @@ -71,6 +82,7 @@ export const DEFAULT_CONFIG: Pick< | 'markdown' > = { i18n: DEFAULT_I18N_CONFIG, + future: DEFAULT_FUTURE_CONFIG, onBrokenLinks: 'throw', onBrokenAnchors: 'warn', // TODO Docusaurus v4: change to throw onBrokenMarkdownLinks: 'warn', @@ -181,6 +193,23 @@ const I18N_CONFIG_SCHEMA = Joi.object({ .optional() .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({ + experimental_storage: STORAGE_CONFIG_SCHEMA, +}) + .optional() + .default(DEFAULT_FUTURE_CONFIG); + const SiteUrlSchema = Joi.string() .required() .custom((value: string, helpers) => { @@ -215,6 +244,7 @@ export const ConfigSchema = Joi.object({ url: SiteUrlSchema, trailingSlash: Joi.boolean(), // No default value! undefined = retrocompatible legacy behavior! i18n: I18N_CONFIG_SCHEMA, + future: FUTURE_CONFIG_SCHEMA, onBrokenLinks: Joi.string() .equal('ignore', 'log', 'warn', 'throw') .default(DEFAULT_CONFIG.onBrokenLinks), diff --git a/packages/docusaurus/src/server/site.ts b/packages/docusaurus/src/server/site.ts index 55dcb743c8a8..19e18b3f176c 100644 --- a/packages/docusaurus/src/server/site.ts +++ b/packages/docusaurus/src/server/site.ts @@ -25,6 +25,7 @@ import { import {PerfLogger} from '../utils'; import {generateSiteFiles} from './codegen/codegen'; import {getRoutesPaths, handleDuplicateRoutes} from './routes'; +import {createSiteStorage} from './storage'; import type {LoadPluginsResult} from './plugins/plugins'; import type { DocusaurusConfig, @@ -111,9 +112,12 @@ export async function loadContext( const codeTranslations = await loadSiteCodeTranslations({localizationDir}); + const siteStorage = createSiteStorage(siteConfig); + return { siteDir, siteVersion, + siteStorage, generatedFilesDir, localizationDir, siteConfig, @@ -135,6 +139,7 @@ function createSiteProps( siteVersion, siteConfig, siteConfigPath, + siteStorage, outDir, baseUrl, i18n, @@ -159,6 +164,7 @@ function createSiteProps( siteConfigPath, siteMetadata, siteVersion, + siteStorage, siteDir, outDir, baseUrl, @@ -190,6 +196,7 @@ async function createSiteFiles({ generatedFilesDir, siteConfig, siteMetadata, + siteStorage, i18n, codeTranslations, routes, @@ -202,6 +209,7 @@ async function createSiteFiles({ clientModules, siteConfig, siteMetadata, + siteStorage, i18n, codeTranslations, globalData, @@ -224,7 +232,7 @@ export async function loadSite(params: LoadContextParams): Promise { 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}; @@ -253,7 +261,7 @@ export async function reloadSitePlugin( context: site.props, }); - const newProps = await createSiteProps({ + const newProps = createSiteProps({ plugins, routes, globalData, diff --git a/packages/docusaurus/src/server/storage.ts b/packages/docusaurus/src/server/storage.ts new file mode 100644 index 000000000000..657f4a359c2d --- /dev/null +++ b/packages/docusaurus/src/server/storage.ts @@ -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; + +type PartialConfig = Pick & { + 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, + }; +} diff --git a/website/_dogfooding/dogfooding.config.ts b/website/_dogfooding/dogfooding.config.ts index ebb6b22885c9..7bcc7babea24 100644 --- a/website/_dogfooding/dogfooding.config.ts +++ b/website/_dogfooding/dogfooding.config.ts @@ -104,6 +104,7 @@ export const dogfoodingPluginInstances: PluginConfig[] = [ return [ require.resolve('./clientModuleExample.ts'), require.resolve('./clientModuleCSS.css'), + require.resolve('./migrateStorageNamespace.ts'), ]; }, }; diff --git a/website/_dogfooding/migrateStorageNamespace.ts b/website/_dogfooding/migrateStorageNamespace.ts new file mode 100644 index 000000000000..b0d7d81e13e1 --- /dev/null +++ b/website/_dogfooding/migrateStorageNamespace.ts @@ -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); +} diff --git a/website/community/5-release-process.mdx b/website/community/5-release-process.mdx index 8185f7a4d320..fae6a1a046cf 100644 --- a/website/community/5-release-process.mdx +++ b/website/community/5-release-process.mdx @@ -156,6 +156,12 @@ We will outline what accounts as the public API surface. - `@docusaurus/types` TypeScript types - 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_` (`v6_` `v7_`, etc.) + :::tip For non-theme APIs, any documented API is considered public (and will be stable); any undocumented API is considered internal. diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index 572a2bc6d13b..295774c412b5 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -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`). - `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_` (`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} - Type: `boolean` diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index c63bd9aa3cd3..aae27b1a4d8e 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -147,6 +147,11 @@ export default async function createConfigAsync() { baseUrl, baseUrlIssueBanner: true, url: 'https://docusaurus.io', + future: { + experimental_storage: { + namespace: true, + }, + }, // Dogfood both settings: // - force trailing slashes for deploy previews // - avoid trailing slashes in prod