diff --git a/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemap.test.ts b/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemap.test.ts index f1f152ecb1..8bece909f3 100644 --- a/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemap.test.ts +++ b/packages/docusaurus-plugin-sitemap/src/__tests__/createSitemap.test.ts @@ -5,7 +5,6 @@ * LICENSE file in the root directory of this source tree. */ -import {createElement} from 'react'; import {fromPartial} from '@total-typescript/shoehorn'; import createSitemap from '../createSitemap'; import type {PluginOptions} from '../options'; @@ -39,7 +38,7 @@ describe('createSitemap', () => { const sitemap = await createSitemap({ siteConfig, routes: routes(['/', '/test']), - head: {}, + routesBuildMetadata: {}, options, }); expect(sitemap).toContain( @@ -51,7 +50,7 @@ describe('createSitemap', () => { const sitemap = await createSitemap({ siteConfig, routes: routes([]), - head: {}, + routesBuildMetadata: {}, options, }); expect(sitemap).toBeNull(); @@ -67,7 +66,7 @@ describe('createSitemap', () => { '/search/foo', '/tags/foo/bar', ]), - head: {}, + routesBuildMetadata: {}, options: { ...options, ignorePatterns: [ @@ -94,7 +93,7 @@ describe('createSitemap', () => { '/search/foo', '/tags/foo/bar', ]), - head: {}, + routesBuildMetadata: {}, options: { ...options, createSitemapItems: async (params) => { @@ -119,7 +118,7 @@ describe('createSitemap', () => { const sitemap = await createSitemap({ siteConfig, routes: routes(['/', '/docs/myDoc/', '/blog/post']), - head: {}, + routesBuildMetadata: {}, options: { ...options, createSitemapItems: async () => { @@ -135,7 +134,7 @@ describe('createSitemap', () => { const sitemap = await createSitemap({ siteConfig, routes: routes(['/', '/test', '/nested/test', '/nested/test2/']), - head: {}, + routesBuildMetadata: {}, options, }); @@ -149,7 +148,7 @@ describe('createSitemap', () => { const sitemap = await createSitemap({ siteConfig: {...siteConfig, trailingSlash: true}, routes: routes(['/', '/test', '/nested/test', '/nested/test2/']), - head: {}, + routesBuildMetadata: {}, options, }); @@ -167,7 +166,7 @@ describe('createSitemap', () => { trailingSlash: false, }, routes: routes(['/', '/test', '/nested/test', '/nested/test2/']), - head: {}, + routesBuildMetadata: {}, options, }); @@ -180,19 +179,10 @@ describe('createSitemap', () => { it('filters pages with noindex', async () => { const sitemap = await createSitemap({ siteConfig, - routesPaths: ['/', '/noindex', '/nested/test', '/nested/test2/'], routes: routes(['/', '/noindex', '/nested/test', '/nested/test2/']), - head: { + routesBuildMetadata: { '/noindex': { - meta: { - // @ts-expect-error: bad lib def - toComponent: () => [ - createElement('meta', { - name: 'robots', - content: 'NoFolloW, NoiNDeX', - }), - ], - }, + noIndex: true, }, }, options, @@ -204,24 +194,13 @@ describe('createSitemap', () => { it('does not generate anything for all pages with noindex', async () => { const sitemap = await createSitemap({ siteConfig, - routesPaths: ['/', '/noindex'], routes: routes(['/', '/noindex']), - head: { + routesBuildMetadata: { '/': { - meta: { - // @ts-expect-error: bad lib def - toComponent: () => [ - createElement('meta', {name: 'robots', content: 'noindex'}), - ], - }, + noIndex: true, }, '/noindex': { - meta: { - // @ts-expect-error: bad lib def - toComponent: () => [ - createElement('meta', {name: 'robots', content: 'noindex'}), - ], - }, + noIndex: true, }, }, options, diff --git a/packages/docusaurus-plugin-sitemap/src/createSitemap.ts b/packages/docusaurus-plugin-sitemap/src/createSitemap.ts index f3f3aace18..aa0f960e8d 100644 --- a/packages/docusaurus-plugin-sitemap/src/createSitemap.ts +++ b/packages/docusaurus-plugin-sitemap/src/createSitemap.ts @@ -10,22 +10,26 @@ import {sitemapItemsToXmlString} from './xml'; import {createSitemapItem} from './createSitemapItem'; import {isNoIndexMetaRoute} from './head'; import type {CreateSitemapItemsFn, CreateSitemapItemsParams} from './types'; -import type {RouteConfig} from '@docusaurus/types'; +import type {RouteConfig, RouteBuildMetadata} from '@docusaurus/types'; import type {PluginOptions} from './options'; -import type {HelmetServerState} from 'react-helmet-async'; // Not all routes should appear in the sitemap, and we should filter: // - parent routes, used for layouts // - routes matching options.ignorePatterns // - routes with no index metadata -function getSitemapRoutes({routes, head, options}: CreateSitemapParams) { +function getSitemapRoutes({ + routes, + routesBuildMetadata, + options, +}: CreateSitemapParams) { const {ignorePatterns} = options; const ignoreMatcher = createMatcher(ignorePatterns); function isRouteExcluded(route: RouteConfig) { return ( - ignoreMatcher(route.path) || isNoIndexMetaRoute({head, route: route.path}) + ignoreMatcher(route.path) || + isNoIndexMetaRoute({routesBuildMetadata, route: route.path}) ); } @@ -33,9 +37,8 @@ function getSitemapRoutes({routes, head, options}: CreateSitemapParams) { } // Our default implementation receives some additional parameters on purpose -// Params such as "head" are "messy" and not directly exposed to the user function createDefaultCreateSitemapItems( - internalParams: Pick, + internalParams: Pick, ): CreateSitemapItemsFn { return async (params) => { const sitemapRoutes = getSitemapRoutes({...params, ...internalParams}); @@ -55,17 +58,17 @@ function createDefaultCreateSitemapItems( } type CreateSitemapParams = CreateSitemapItemsParams & { - head: {[location: string]: HelmetServerState}; + routesBuildMetadata: {[location: string]: RouteBuildMetadata}; options: PluginOptions; }; export default async function createSitemap( params: CreateSitemapParams, ): Promise { - const {head, options, routes, siteConfig} = params; + const {routesBuildMetadata, options, routes, siteConfig} = params; const defaultCreateSitemapItems: CreateSitemapItemsFn = - createDefaultCreateSitemapItems({head, options}); + createDefaultCreateSitemapItems({routesBuildMetadata, options}); const sitemapItems = params.options.createSitemapItems ? await params.options.createSitemapItems({ diff --git a/packages/docusaurus-plugin-sitemap/src/head.ts b/packages/docusaurus-plugin-sitemap/src/head.ts index ed16fdf852..f491a66468 100644 --- a/packages/docusaurus-plugin-sitemap/src/head.ts +++ b/packages/docusaurus-plugin-sitemap/src/head.ts @@ -5,43 +5,21 @@ * LICENSE file in the root directory of this source tree. */ -import type {ReactElement} from 'react'; -import type {HelmetServerState} from 'react-helmet-async'; +import type {RouteBuildMetadata} from '@docusaurus/types'; // Maybe we want to add a routeConfig.metadata.noIndex instead? // But using Helmet is more reliable for third-party plugins... export function isNoIndexMetaRoute({ - head, + routesBuildMetadata, route, }: { - head: {[location: string]: HelmetServerState}; + routesBuildMetadata: {[location: string]: RouteBuildMetadata}; route: string; }): boolean { - const isNoIndexMetaTag = ({ - name, - content, - }: { - name?: string; - content?: string; - }): boolean => { - if (!name || !content) { - return false; - } - return ( - // meta name is not case-sensitive - name.toLowerCase() === 'robots' && - // Robots directives are not case-sensitive - content.toLowerCase().includes('noindex') - ); - }; + const routeBuildMetadata = routesBuildMetadata[route]; - // https://github.com/staylor/react-helmet-async/pull/167 - const meta = head[route]?.meta.toComponent() as unknown as - | ReactElement<{name?: string; content?: string}>[] - | undefined; - return ( - meta?.some((tag) => - isNoIndexMetaTag({name: tag.props.name, content: tag.props.content}), - ) ?? false - ); + if (routeBuildMetadata) { + return routeBuildMetadata.noIndex; + } + return false; } diff --git a/packages/docusaurus-plugin-sitemap/src/index.ts b/packages/docusaurus-plugin-sitemap/src/index.ts index 3bd27f9c26..5bada9d472 100644 --- a/packages/docusaurus-plugin-sitemap/src/index.ts +++ b/packages/docusaurus-plugin-sitemap/src/index.ts @@ -28,7 +28,7 @@ export default function pluginSitemap( return { name: PluginName, - async postBuild({siteConfig, routes, outDir, head}) { + async postBuild({siteConfig, routes, outDir, routesBuildMetadata}) { if (siteConfig.noIndex) { return; } @@ -36,7 +36,7 @@ export default function pluginSitemap( const generatedSitemap = await createSitemap({ siteConfig, routes, - head, + routesBuildMetadata, options, }); if (!generatedSitemap) { diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index 8289e8b222..4e646cc51f 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -132,7 +132,16 @@ export type FasterConfig = { rspackBundler: boolean; }; +export type FutureV4Config = { + removeLegacyPostBuildHeadAttribute: boolean; +}; + export type FutureConfig = { + /** + * Turns v4 future flags on + */ + v4: FutureV4Config; + experimental_faster: FasterConfig; experimental_storage: StorageConfig; @@ -451,6 +460,7 @@ export type Config = Overwrite< future?: Overwrite< DeepPartial, { + v4?: boolean | FutureV4Config; experimental_faster?: boolean | FasterConfig; } >; diff --git a/packages/docusaurus-types/src/index.d.ts b/packages/docusaurus-types/src/index.d.ts index 9592bc00d4..a6cb0b00b4 100644 --- a/packages/docusaurus-types/src/index.d.ts +++ b/packages/docusaurus-types/src/index.d.ts @@ -67,6 +67,7 @@ export { Validate, ValidationSchema, AllContent, + RouteBuildMetadata, ConfigureWebpackUtils, PostCssOptions, HtmlTagObject, diff --git a/packages/docusaurus-types/src/plugin.d.ts b/packages/docusaurus-types/src/plugin.d.ts index 0e428e2bbd..7d8a91e8a6 100644 --- a/packages/docusaurus-types/src/plugin.d.ts +++ b/packages/docusaurus-types/src/plugin.d.ts @@ -114,6 +114,12 @@ export type ConfigureWebpackResult = WebpackConfiguration & { }; }; +export type RouteBuildMetadata = { + // We'll add extra metadata on a case by case basis here + // For now the only need is our sitemap plugin to filter noindex pages + noIndex: boolean; +}; + export type Plugin = { name: string; loadContent?: () => Promise | Content; @@ -129,7 +135,11 @@ export type Plugin = { postBuild?: ( props: Props & { content: Content; + // TODO Docusaurus v4: remove old messy unserializable "head" API + // breaking change, replaced by routesBuildMetadata + // Reason: https://github.com/facebook/docusaurus/pull/10826 head: {[location: string]: HelmetServerState}; + routesBuildMetadata: {[location: string]: RouteBuildMetadata}; }, ) => Promise | void; // TODO Docusaurus v4 ? diff --git a/packages/docusaurus/src/client/serverEntry.tsx b/packages/docusaurus/src/client/serverEntry.tsx index 569be29d94..75e7258d76 100644 --- a/packages/docusaurus/src/client/serverEntry.tsx +++ b/packages/docusaurus/src/client/serverEntry.tsx @@ -16,9 +16,13 @@ import { createStatefulBrokenLinks, BrokenLinksProvider, } from './BrokenLinksContext'; +import {toPageCollectedMetadata} from './serverHelmetUtils'; import type {PageCollectedData, AppRenderer} from '../common'; -const render: AppRenderer['render'] = async ({pathname}) => { +const render: AppRenderer['render'] = async ({ + pathname, + v4RemoveLegacyPostBuildHeadAttribute, +}) => { await preload(pathname); const modules = new Set(); @@ -41,11 +45,18 @@ const render: AppRenderer['render'] = async ({pathname}) => { const html = await renderToHtml(app); - const collectedData: PageCollectedData = { - // TODO Docusaurus v4 refactor: helmet state is non-serializable - // this makes it impossible to run SSG in a worker thread - helmet: (helmetContext as FilledContext).helmet, + const {helmet} = helmetContext as FilledContext; + const metadata = toPageCollectedMetadata({helmet}); + + // TODO Docusaurus v4 remove with deprecated postBuild({head}) API + // the returned collectedData must be serializable to run in workers + if (v4RemoveLegacyPostBuildHeadAttribute) { + metadata.helmet = null; + } + + const collectedData: PageCollectedData = { + metadata, anchors: statefulBrokenLinks.getCollectedAnchors(), links: statefulBrokenLinks.getCollectedLinks(), modules: Array.from(modules), diff --git a/packages/docusaurus/src/client/serverHelmetUtils.tsx b/packages/docusaurus/src/client/serverHelmetUtils.tsx new file mode 100644 index 0000000000..14d062785f --- /dev/null +++ b/packages/docusaurus/src/client/serverHelmetUtils.tsx @@ -0,0 +1,55 @@ +/** + * 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 type {ReactElement} from 'react'; +import type {PageCollectedMetadata} from '../common'; +import type {HelmetServerState} from 'react-helmet-async'; + +type BuildMetaTag = {name?: string; content?: string}; + +function getBuildMetaTags(helmet: HelmetServerState): BuildMetaTag[] { + // @ts-expect-error: see https://github.com/staylor/react-helmet-async/pull/167 + const metaElements: ReactElement[] = + helmet.meta.toComponent() ?? []; + return metaElements.map((el) => el.props); +} + +function isNoIndexTag(tag: BuildMetaTag): boolean { + if (!tag.name || !tag.content) { + return false; + } + return ( + // meta name is not case-sensitive + tag.name.toLowerCase() === 'robots' && + // Robots directives are not case-sensitive + tag.content.toLowerCase().includes('noindex') + ); +} + +export function toPageCollectedMetadata({ + helmet, +}: { + helmet: HelmetServerState; +}): PageCollectedMetadata { + const tags = getBuildMetaTags(helmet); + const noIndex = tags.some(isNoIndexTag); + + return { + helmet, // TODO Docusaurus v4 remove + public: { + noIndex, + }, + internal: { + htmlAttributes: helmet.htmlAttributes.toString(), + bodyAttributes: helmet.bodyAttributes.toString(), + title: helmet.title.toString(), + meta: helmet.meta.toString(), + link: helmet.link.toString(), + script: helmet.script.toString(), + }, + }; +} diff --git a/packages/docusaurus/src/commands/build/buildLocale.ts b/packages/docusaurus/src/commands/build/buildLocale.ts index 83d6557b62..46e44a5215 100644 --- a/packages/docusaurus/src/commands/build/buildLocale.ts +++ b/packages/docusaurus/src/commands/build/buildLocale.ts @@ -126,7 +126,15 @@ async function executePluginsPostBuild({ props: Props; collectedData: SiteCollectedData; }) { - const head = _.mapValues(collectedData, (d) => d.helmet); + const head = props.siteConfig.future.v4.removeLegacyPostBuildHeadAttribute + ? {} + : _.mapValues(collectedData, (d) => d.metadata.helmet!); + + const routesBuildMetadata = _.mapValues( + collectedData, + (d) => d.metadata.public, + ); + await Promise.all( plugins.map(async (plugin) => { if (!plugin.postBuild) { @@ -135,6 +143,7 @@ async function executePluginsPostBuild({ await plugin.postBuild({ ...props, head, + routesBuildMetadata, content: plugin.content, }); }), diff --git a/packages/docusaurus/src/common.d.ts b/packages/docusaurus/src/common.d.ts index b6db9b2e55..5ad881b29f 100644 --- a/packages/docusaurus/src/common.d.ts +++ b/packages/docusaurus/src/common.d.ts @@ -9,6 +9,7 @@ // In particular the interface between SSG and serverEntry code import type {HelmetServerState} from 'react-helmet-async'; +import type {RouteBuildMetadata} from '@docusaurus/types'; export type AppRenderResult = { html: string; @@ -16,18 +17,41 @@ export type AppRenderResult = { }; export type AppRenderer = { - render: (params: {pathname: string}) => Promise; + render: (params: { + pathname: string; + + // TODO Docusaurus v4: remove deprecated postBuild({head}) API + v4RemoveLegacyPostBuildHeadAttribute: boolean; + }) => Promise; // It's important to shut down the app renderer // Otherwise Node.js require cache leaks memory shutdown: () => Promise; }; -export type PageCollectedData = { - // TODO Docusaurus v4 refactor: helmet state is non-serializable - // this makes it impossible to run SSG in a worker thread - helmet: HelmetServerState; +// Attributes we need internally, for the SSG html template +// They are not exposed to the user in postBuild({routesBuildMetadata}) +export type RouteBuildMetadataInternal = { + htmlAttributes: string; + bodyAttributes: string; + title: string; + meta: string; + link: string; + script: string; +}; +// This data structure must remain serializable! +// See why: https://github.com/facebook/docusaurus/pull/10826 +export type PageCollectedMetadata = { + public: RouteBuildMetadata; + internal: RouteBuildMetadataInternal; + // TODO Docusaurus v4 remove legacy unserializable helmet data structure + // See https://github.com/facebook/docusaurus/pull/10850 + helmet: HelmetServerState | null; +}; + +export type PageCollectedData = { + metadata: PageCollectedMetadata; links: string[]; anchors: string[]; modules: string[]; 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 0e0a790f0e..4d93bd7aee 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/config.test.ts.snap @@ -21,6 +21,9 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -90,6 +93,9 @@ exports[`loadSiteConfig website with ts + js config 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -159,6 +165,9 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -228,6 +237,9 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -297,6 +309,9 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -366,6 +381,9 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -435,6 +453,9 @@ exports[`loadSiteConfig website with valid async config 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -506,6 +527,9 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -577,6 +601,9 @@ exports[`loadSiteConfig website with valid config creator function 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { @@ -651,6 +678,9 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { 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 af2a47aa5c..d1dc993c62 100644 --- a/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap +++ b/packages/docusaurus/src/server/__tests__/__snapshots__/site.test.ts.snap @@ -95,6 +95,9 @@ exports[`load loads props for site with custom i18n path 1`] = ` "namespace": false, "type": "localStorage", }, + "v4": { + "removeLegacyPostBuildHeadAttribute": false, + }, }, "headTags": [], "i18n": { diff --git a/packages/docusaurus/src/server/__tests__/configValidation.test.ts b/packages/docusaurus/src/server/__tests__/configValidation.test.ts index c8784efc2c..63ea0c294d 100644 --- a/packages/docusaurus/src/server/__tests__/configValidation.test.ts +++ b/packages/docusaurus/src/server/__tests__/configValidation.test.ts @@ -11,12 +11,15 @@ import { DEFAULT_FASTER_CONFIG, DEFAULT_FASTER_CONFIG_TRUE, DEFAULT_FUTURE_CONFIG, + DEFAULT_FUTURE_V4_CONFIG, + DEFAULT_FUTURE_V4_CONFIG_TRUE, DEFAULT_STORAGE_CONFIG, validateConfig, } from '../configValidation'; import type { FasterConfig, FutureConfig, + FutureV4Config, StorageConfig, } from '@docusaurus/types/src/config'; import type {Config, DocusaurusConfig, PluginConfig} from '@docusaurus/types'; @@ -45,6 +48,9 @@ describe('normalizeConfig', () => { ...DEFAULT_CONFIG, ...baseConfig, future: { + v4: { + removeLegacyPostBuildHeadAttribute: true, + }, experimental_faster: { swcJsLoader: true, swcJsMinimizer: true, @@ -744,6 +750,9 @@ describe('future', () => { it('accepts future - full', () => { const future: DocusaurusConfig['future'] = { + v4: { + removeLegacyPostBuildHeadAttribute: true, + }, experimental_faster: { swcJsLoader: true, swcJsMinimizer: true, @@ -1571,4 +1580,149 @@ describe('future', () => { }); }); }); + + describe('v4', () => { + function v4Containing(v4: Partial) { + return futureContaining({ + v4: expect.objectContaining(v4), + }); + } + + it('accepts v4 - undefined', () => { + expect( + normalizeConfig({ + future: { + v4: undefined, + }, + }), + ).toEqual(futureContaining(DEFAULT_FUTURE_CONFIG)); + }); + + it('accepts v4 - empty', () => { + expect( + normalizeConfig({ + future: {v4: {}}, + }), + ).toEqual(futureContaining(DEFAULT_FUTURE_CONFIG)); + }); + + it('accepts v4 - full', () => { + const v4: FutureV4Config = { + removeLegacyPostBuildHeadAttribute: true, + }; + expect( + normalizeConfig({ + future: { + v4, + }, + }), + ).toEqual(v4Containing(v4)); + }); + + it('accepts v4 - false', () => { + expect( + normalizeConfig({ + future: {v4: false}, + }), + ).toEqual(v4Containing(DEFAULT_FUTURE_V4_CONFIG)); + }); + + it('accepts v4 - true', () => { + expect( + normalizeConfig({ + future: {v4: true}, + }), + ).toEqual(v4Containing(DEFAULT_FUTURE_V4_CONFIG_TRUE)); + }); + + it('rejects v4 - number', () => { + // @ts-expect-error: invalid + const v4: Partial = 42; + expect(() => + normalizeConfig({ + future: { + v4, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.v4" must be one of [object, boolean] + " + `); + }); + + describe('removeLegacyPostBuildHeadAttribute', () => { + it('accepts - undefined', () => { + const v4: Partial = { + removeLegacyPostBuildHeadAttribute: undefined, + }; + expect( + normalizeConfig({ + future: { + v4, + }, + }), + ).toEqual(v4Containing({removeLegacyPostBuildHeadAttribute: false})); + }); + + it('accepts - true', () => { + const v4: Partial = { + removeLegacyPostBuildHeadAttribute: true, + }; + expect( + normalizeConfig({ + future: { + v4, + }, + }), + ).toEqual(v4Containing({removeLegacyPostBuildHeadAttribute: true})); + }); + + it('accepts - false', () => { + const v4: Partial = { + removeLegacyPostBuildHeadAttribute: false, + }; + expect( + normalizeConfig({ + future: { + v4, + }, + }), + ).toEqual(v4Containing({removeLegacyPostBuildHeadAttribute: false})); + }); + + it('rejects - null', () => { + const v4: Partial = { + // @ts-expect-error: invalid + removeLegacyPostBuildHeadAttribute: 42, + }; + expect(() => + normalizeConfig({ + future: { + v4, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.v4.removeLegacyPostBuildHeadAttribute" must be a boolean + " + `); + }); + + it('rejects - number', () => { + const v4: Partial = { + // @ts-expect-error: invalid + removeLegacyPostBuildHeadAttribute: 42, + }; + expect(() => + normalizeConfig({ + future: { + v4, + }, + }), + ).toThrowErrorMatchingInlineSnapshot(` + ""future.v4.removeLegacyPostBuildHeadAttribute" must be a boolean + " + `); + }); + }); + }); }); diff --git a/packages/docusaurus/src/server/configValidation.ts b/packages/docusaurus/src/server/configValidation.ts index 17682cd914..049dd819c3 100644 --- a/packages/docusaurus/src/server/configValidation.ts +++ b/packages/docusaurus/src/server/configValidation.ts @@ -19,6 +19,7 @@ import { import type { FasterConfig, FutureConfig, + FutureV4Config, StorageConfig, } from '@docusaurus/types/src/config'; import type { @@ -60,7 +61,17 @@ export const DEFAULT_FASTER_CONFIG_TRUE: FasterConfig = { rspackBundler: true, }; +export const DEFAULT_FUTURE_V4_CONFIG: FutureV4Config = { + removeLegacyPostBuildHeadAttribute: false, +}; + +// When using the "v4: true" shortcut +export const DEFAULT_FUTURE_V4_CONFIG_TRUE: FutureV4Config = { + removeLegacyPostBuildHeadAttribute: true, +}; + export const DEFAULT_FUTURE_CONFIG: FutureConfig = { + v4: DEFAULT_FUTURE_V4_CONFIG, experimental_faster: DEFAULT_FASTER_CONFIG, experimental_storage: DEFAULT_STORAGE_CONFIG, experimental_router: 'browser', @@ -242,6 +253,22 @@ const FASTER_CONFIG_SCHEMA = Joi.alternatives() .optional() .default(DEFAULT_FASTER_CONFIG); +const FUTURE_V4_SCHEMA = Joi.alternatives() + .try( + Joi.object({ + removeLegacyPostBuildHeadAttribute: Joi.boolean().default( + DEFAULT_FUTURE_V4_CONFIG.removeLegacyPostBuildHeadAttribute, + ), + }), + Joi.boolean() + .required() + .custom((bool) => + bool ? DEFAULT_FUTURE_V4_CONFIG_TRUE : DEFAULT_FUTURE_V4_CONFIG, + ), + ) + .optional() + .default(DEFAULT_FUTURE_V4_CONFIG); + const STORAGE_CONFIG_SCHEMA = Joi.object({ type: Joi.string() .equal('localStorage', 'sessionStorage') @@ -254,6 +281,7 @@ const STORAGE_CONFIG_SCHEMA = Joi.object({ .default(DEFAULT_STORAGE_CONFIG); const FUTURE_CONFIG_SCHEMA = Joi.object({ + v4: FUTURE_V4_SCHEMA, experimental_faster: FASTER_CONFIG_SCHEMA, experimental_storage: STORAGE_CONFIG_SCHEMA, experimental_router: Joi.string() diff --git a/packages/docusaurus/src/ssg/ssg.ts b/packages/docusaurus/src/ssg/ssg.ts index baaaeaf644..52cd7b2d4c 100644 --- a/packages/docusaurus/src/ssg/ssg.ts +++ b/packages/docusaurus/src/ssg/ssg.ts @@ -251,6 +251,8 @@ async function generateStaticFile({ // This only renders the app HTML const result = await renderer.render({ pathname, + v4RemoveLegacyPostBuildHeadAttribute: + params.v4RemoveLegacyPostBuildHeadAttribute, }); // This renders the full page HTML, including head tags... const fullPageHtml = renderSSGTemplate({ diff --git a/packages/docusaurus/src/ssg/ssgParams.ts b/packages/docusaurus/src/ssg/ssgParams.ts index a417fa1323..90f2190796 100644 --- a/packages/docusaurus/src/ssg/ssgParams.ts +++ b/packages/docusaurus/src/ssg/ssgParams.ts @@ -30,6 +30,9 @@ export type SSGParams = { htmlMinifierType: HtmlMinifierType; serverBundlePath: string; ssgTemplateContent: string; + + // TODO Docusaurus v4: remove deprecated postBuild({head}) API + v4RemoveLegacyPostBuildHeadAttribute: boolean; }; export async function createSSGParams({ @@ -62,6 +65,9 @@ export async function createSSGParams({ .swcHtmlMinimizer ? 'swc' : 'terser', + + v4RemoveLegacyPostBuildHeadAttribute: + props.siteConfig.future.v4.removeLegacyPostBuildHeadAttribute, }; // Useless but ensures that SSG params remain serializable diff --git a/packages/docusaurus/src/ssg/ssgTemplate.ts b/packages/docusaurus/src/ssg/ssgTemplate.ts index f0ecd5e997..101f996ea5 100644 --- a/packages/docusaurus/src/ssg/ssgTemplate.ts +++ b/packages/docusaurus/src/ssg/ssgTemplate.ts @@ -83,18 +83,17 @@ export function renderSSGTemplate({ } = params; const { html: appHtml, - collectedData: {modules, helmet}, + collectedData: {modules, metadata}, } = result; const {scripts, stylesheets} = getScriptsAndStylesheets({manifest, modules}); - const htmlAttributes = helmet.htmlAttributes.toString(); - const bodyAttributes = helmet.bodyAttributes.toString(); + const {htmlAttributes, bodyAttributes} = metadata.internal; const metaStrings = [ - helmet.title.toString(), - helmet.meta.toString(), - helmet.link.toString(), - helmet.script.toString(), + metadata.internal.title, + metadata.internal.meta, + metadata.internal.link, + metadata.internal.script, ]; const metaAttributes = metaStrings.filter(Boolean); diff --git a/website/docs/api/docusaurus.config.js.mdx b/website/docs/api/docusaurus.config.js.mdx index fb358955dc..1ead788b14 100644 --- a/website/docs/api/docusaurus.config.js.mdx +++ b/website/docs/api/docusaurus.config.js.mdx @@ -186,7 +186,7 @@ It is also a way to opt-in for upcoming breaking changes coming in the next majo 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. +Features namespaced 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. @@ -197,6 +197,9 @@ Example: ```js title="docusaurus.config.js" export default { future: { + v4: { + removeLegacyPostBuildHeadAttribute: true, + }, experimental_faster: { swcJsLoader: true, swcJsMinimizer: true, @@ -214,6 +217,8 @@ export default { }; ``` +- `v4`: Permits to opt-in for upcoming Docusaurus v4 breaking changes and features, to prepare your site in advance for this new version. Use `true` as a shorthand to enable all the flags. + - [`removeLegacyPostBuildHeadAttribute`](https://github.com/facebook/docusaurus/pull/10435): Removes the legacy `plugin.postBuild({head})` API that prevents us from applying useful SSG optimizations ([explanations](https://github.com/facebook/docusaurus/pull/10850)). - `experimental_faster`: An object containing feature flags to make the Docusaurus build faster. This requires adding the `@docusaurus/faster` package to your site's dependencies. Use `true` as a shorthand to enable all flags. Read more on the [Docusaurus Faster](https://github.com/facebook/docusaurus/issues/10556) issue. Available feature flags: - [`swcJsLoader`](https://github.com/facebook/docusaurus/pull/10435): Use [SWC](https://swc.rs/) to transpile JS (instead of [Babel](https://babeljs.io/)). - [`swcJsMinimizer`](https://github.com/facebook/docusaurus/pull/10441): Use [SWC](https://swc.rs/) to minify JS (instead of [Terser](https://github.com/terser/terser)). diff --git a/website/docs/api/plugin-methods/lifecycle-apis.mdx b/website/docs/api/plugin-methods/lifecycle-apis.mdx index 4606eb6775..bc6c1f77aa 100644 --- a/website/docs/api/plugin-methods/lifecycle-apis.mdx +++ b/website/docs/api/plugin-methods/lifecycle-apis.mdx @@ -369,6 +369,7 @@ interface Props { preBodyTags: string; postBodyTags: string; routesPaths: string[]; + routesBuildMetadata: {[location: string]: {noIndex: boolean}}; plugins: Plugin[]; content: Content; } diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index c335b12735..8956350056 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -163,6 +163,7 @@ export default async function createConfigAsync() { baseUrlIssueBanner: true, url: 'https://docusaurus.io', future: { + v4: !isSlower, // Not accurate, but good enough experimental_faster: !isSlower, experimental_storage: { namespace: true,