feat(core): new postBuild({routesBuildMetadata}) API, deprecate head attribute + v4 future flag (#10850)

Co-authored-by: slorber <749374+slorber@users.noreply.github.com>
This commit is contained in:
Sébastien Lorber 2025-01-17 17:26:48 +01:00 committed by GitHub
parent 67207bc5e5
commit 9df5aae6de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 403 additions and 94 deletions

View file

@ -5,7 +5,6 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import {createElement} from 'react';
import {fromPartial} from '@total-typescript/shoehorn'; import {fromPartial} from '@total-typescript/shoehorn';
import createSitemap from '../createSitemap'; import createSitemap from '../createSitemap';
import type {PluginOptions} from '../options'; import type {PluginOptions} from '../options';
@ -39,7 +38,7 @@ describe('createSitemap', () => {
const sitemap = await createSitemap({ const sitemap = await createSitemap({
siteConfig, siteConfig,
routes: routes(['/', '/test']), routes: routes(['/', '/test']),
head: {}, routesBuildMetadata: {},
options, options,
}); });
expect(sitemap).toContain( expect(sitemap).toContain(
@ -51,7 +50,7 @@ describe('createSitemap', () => {
const sitemap = await createSitemap({ const sitemap = await createSitemap({
siteConfig, siteConfig,
routes: routes([]), routes: routes([]),
head: {}, routesBuildMetadata: {},
options, options,
}); });
expect(sitemap).toBeNull(); expect(sitemap).toBeNull();
@ -67,7 +66,7 @@ describe('createSitemap', () => {
'/search/foo', '/search/foo',
'/tags/foo/bar', '/tags/foo/bar',
]), ]),
head: {}, routesBuildMetadata: {},
options: { options: {
...options, ...options,
ignorePatterns: [ ignorePatterns: [
@ -94,7 +93,7 @@ describe('createSitemap', () => {
'/search/foo', '/search/foo',
'/tags/foo/bar', '/tags/foo/bar',
]), ]),
head: {}, routesBuildMetadata: {},
options: { options: {
...options, ...options,
createSitemapItems: async (params) => { createSitemapItems: async (params) => {
@ -119,7 +118,7 @@ describe('createSitemap', () => {
const sitemap = await createSitemap({ const sitemap = await createSitemap({
siteConfig, siteConfig,
routes: routes(['/', '/docs/myDoc/', '/blog/post']), routes: routes(['/', '/docs/myDoc/', '/blog/post']),
head: {}, routesBuildMetadata: {},
options: { options: {
...options, ...options,
createSitemapItems: async () => { createSitemapItems: async () => {
@ -135,7 +134,7 @@ describe('createSitemap', () => {
const sitemap = await createSitemap({ const sitemap = await createSitemap({
siteConfig, siteConfig,
routes: routes(['/', '/test', '/nested/test', '/nested/test2/']), routes: routes(['/', '/test', '/nested/test', '/nested/test2/']),
head: {}, routesBuildMetadata: {},
options, options,
}); });
@ -149,7 +148,7 @@ describe('createSitemap', () => {
const sitemap = await createSitemap({ const sitemap = await createSitemap({
siteConfig: {...siteConfig, trailingSlash: true}, siteConfig: {...siteConfig, trailingSlash: true},
routes: routes(['/', '/test', '/nested/test', '/nested/test2/']), routes: routes(['/', '/test', '/nested/test', '/nested/test2/']),
head: {}, routesBuildMetadata: {},
options, options,
}); });
@ -167,7 +166,7 @@ describe('createSitemap', () => {
trailingSlash: false, trailingSlash: false,
}, },
routes: routes(['/', '/test', '/nested/test', '/nested/test2/']), routes: routes(['/', '/test', '/nested/test', '/nested/test2/']),
head: {}, routesBuildMetadata: {},
options, options,
}); });
@ -180,19 +179,10 @@ describe('createSitemap', () => {
it('filters pages with noindex', async () => { it('filters pages with noindex', async () => {
const sitemap = await createSitemap({ const sitemap = await createSitemap({
siteConfig, siteConfig,
routesPaths: ['/', '/noindex', '/nested/test', '/nested/test2/'],
routes: routes(['/', '/noindex', '/nested/test', '/nested/test2/']), routes: routes(['/', '/noindex', '/nested/test', '/nested/test2/']),
head: { routesBuildMetadata: {
'/noindex': { '/noindex': {
meta: { noIndex: true,
// @ts-expect-error: bad lib def
toComponent: () => [
createElement('meta', {
name: 'robots',
content: 'NoFolloW, NoiNDeX',
}),
],
},
}, },
}, },
options, options,
@ -204,24 +194,13 @@ describe('createSitemap', () => {
it('does not generate anything for all pages with noindex', async () => { it('does not generate anything for all pages with noindex', async () => {
const sitemap = await createSitemap({ const sitemap = await createSitemap({
siteConfig, siteConfig,
routesPaths: ['/', '/noindex'],
routes: routes(['/', '/noindex']), routes: routes(['/', '/noindex']),
head: { routesBuildMetadata: {
'/': { '/': {
meta: { noIndex: true,
// @ts-expect-error: bad lib def
toComponent: () => [
createElement('meta', {name: 'robots', content: 'noindex'}),
],
},
}, },
'/noindex': { '/noindex': {
meta: { noIndex: true,
// @ts-expect-error: bad lib def
toComponent: () => [
createElement('meta', {name: 'robots', content: 'noindex'}),
],
},
}, },
}, },
options, options,

View file

@ -10,22 +10,26 @@ import {sitemapItemsToXmlString} from './xml';
import {createSitemapItem} from './createSitemapItem'; import {createSitemapItem} from './createSitemapItem';
import {isNoIndexMetaRoute} from './head'; import {isNoIndexMetaRoute} from './head';
import type {CreateSitemapItemsFn, CreateSitemapItemsParams} from './types'; 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 {PluginOptions} from './options';
import type {HelmetServerState} from 'react-helmet-async';
// Not all routes should appear in the sitemap, and we should filter: // Not all routes should appear in the sitemap, and we should filter:
// - parent routes, used for layouts // - parent routes, used for layouts
// - routes matching options.ignorePatterns // - routes matching options.ignorePatterns
// - routes with no index metadata // - routes with no index metadata
function getSitemapRoutes({routes, head, options}: CreateSitemapParams) { function getSitemapRoutes({
routes,
routesBuildMetadata,
options,
}: CreateSitemapParams) {
const {ignorePatterns} = options; const {ignorePatterns} = options;
const ignoreMatcher = createMatcher(ignorePatterns); const ignoreMatcher = createMatcher(ignorePatterns);
function isRouteExcluded(route: RouteConfig) { function isRouteExcluded(route: RouteConfig) {
return ( 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 // Our default implementation receives some additional parameters on purpose
// Params such as "head" are "messy" and not directly exposed to the user
function createDefaultCreateSitemapItems( function createDefaultCreateSitemapItems(
internalParams: Pick<CreateSitemapParams, 'head' | 'options'>, internalParams: Pick<CreateSitemapParams, 'routesBuildMetadata' | 'options'>,
): CreateSitemapItemsFn { ): CreateSitemapItemsFn {
return async (params) => { return async (params) => {
const sitemapRoutes = getSitemapRoutes({...params, ...internalParams}); const sitemapRoutes = getSitemapRoutes({...params, ...internalParams});
@ -55,17 +58,17 @@ function createDefaultCreateSitemapItems(
} }
type CreateSitemapParams = CreateSitemapItemsParams & { type CreateSitemapParams = CreateSitemapItemsParams & {
head: {[location: string]: HelmetServerState}; routesBuildMetadata: {[location: string]: RouteBuildMetadata};
options: PluginOptions; options: PluginOptions;
}; };
export default async function createSitemap( export default async function createSitemap(
params: CreateSitemapParams, params: CreateSitemapParams,
): Promise<string | null> { ): Promise<string | null> {
const {head, options, routes, siteConfig} = params; const {routesBuildMetadata, options, routes, siteConfig} = params;
const defaultCreateSitemapItems: CreateSitemapItemsFn = const defaultCreateSitemapItems: CreateSitemapItemsFn =
createDefaultCreateSitemapItems({head, options}); createDefaultCreateSitemapItems({routesBuildMetadata, options});
const sitemapItems = params.options.createSitemapItems const sitemapItems = params.options.createSitemapItems
? await params.options.createSitemapItems({ ? await params.options.createSitemapItems({

View file

@ -5,43 +5,21 @@
* LICENSE file in the root directory of this source tree. * LICENSE file in the root directory of this source tree.
*/ */
import type {ReactElement} from 'react'; import type {RouteBuildMetadata} from '@docusaurus/types';
import type {HelmetServerState} from 'react-helmet-async';
// Maybe we want to add a routeConfig.metadata.noIndex instead? // Maybe we want to add a routeConfig.metadata.noIndex instead?
// But using Helmet is more reliable for third-party plugins... // But using Helmet is more reliable for third-party plugins...
export function isNoIndexMetaRoute({ export function isNoIndexMetaRoute({
head, routesBuildMetadata,
route, route,
}: { }: {
head: {[location: string]: HelmetServerState}; routesBuildMetadata: {[location: string]: RouteBuildMetadata};
route: string; route: string;
}): boolean { }): boolean {
const isNoIndexMetaTag = ({ const routeBuildMetadata = routesBuildMetadata[route];
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')
);
};
// https://github.com/staylor/react-helmet-async/pull/167 if (routeBuildMetadata) {
const meta = head[route]?.meta.toComponent() as unknown as return routeBuildMetadata.noIndex;
| ReactElement<{name?: string; content?: string}>[] }
| undefined; return false;
return (
meta?.some((tag) =>
isNoIndexMetaTag({name: tag.props.name, content: tag.props.content}),
) ?? false
);
} }

View file

@ -28,7 +28,7 @@ export default function pluginSitemap(
return { return {
name: PluginName, name: PluginName,
async postBuild({siteConfig, routes, outDir, head}) { async postBuild({siteConfig, routes, outDir, routesBuildMetadata}) {
if (siteConfig.noIndex) { if (siteConfig.noIndex) {
return; return;
} }
@ -36,7 +36,7 @@ export default function pluginSitemap(
const generatedSitemap = await createSitemap({ const generatedSitemap = await createSitemap({
siteConfig, siteConfig,
routes, routes,
head, routesBuildMetadata,
options, options,
}); });
if (!generatedSitemap) { if (!generatedSitemap) {

View file

@ -132,7 +132,16 @@ export type FasterConfig = {
rspackBundler: boolean; rspackBundler: boolean;
}; };
export type FutureV4Config = {
removeLegacyPostBuildHeadAttribute: boolean;
};
export type FutureConfig = { export type FutureConfig = {
/**
* Turns v4 future flags on
*/
v4: FutureV4Config;
experimental_faster: FasterConfig; experimental_faster: FasterConfig;
experimental_storage: StorageConfig; experimental_storage: StorageConfig;
@ -451,6 +460,7 @@ export type Config = Overwrite<
future?: Overwrite< future?: Overwrite<
DeepPartial<FutureConfig>, DeepPartial<FutureConfig>,
{ {
v4?: boolean | FutureV4Config;
experimental_faster?: boolean | FasterConfig; experimental_faster?: boolean | FasterConfig;
} }
>; >;

View file

@ -67,6 +67,7 @@ export {
Validate, Validate,
ValidationSchema, ValidationSchema,
AllContent, AllContent,
RouteBuildMetadata,
ConfigureWebpackUtils, ConfigureWebpackUtils,
PostCssOptions, PostCssOptions,
HtmlTagObject, HtmlTagObject,

View file

@ -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<Content = unknown> = { export type Plugin<Content = unknown> = {
name: string; name: string;
loadContent?: () => Promise<Content> | Content; loadContent?: () => Promise<Content> | Content;
@ -129,7 +135,11 @@ export type Plugin<Content = unknown> = {
postBuild?: ( postBuild?: (
props: Props & { props: Props & {
content: Content; 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}; head: {[location: string]: HelmetServerState};
routesBuildMetadata: {[location: string]: RouteBuildMetadata};
}, },
) => Promise<void> | void; ) => Promise<void> | void;
// TODO Docusaurus v4 ? // TODO Docusaurus v4 ?

View file

@ -16,9 +16,13 @@ import {
createStatefulBrokenLinks, createStatefulBrokenLinks,
BrokenLinksProvider, BrokenLinksProvider,
} from './BrokenLinksContext'; } from './BrokenLinksContext';
import {toPageCollectedMetadata} from './serverHelmetUtils';
import type {PageCollectedData, AppRenderer} from '../common'; import type {PageCollectedData, AppRenderer} from '../common';
const render: AppRenderer['render'] = async ({pathname}) => { const render: AppRenderer['render'] = async ({
pathname,
v4RemoveLegacyPostBuildHeadAttribute,
}) => {
await preload(pathname); await preload(pathname);
const modules = new Set<string>(); const modules = new Set<string>();
@ -41,11 +45,18 @@ const render: AppRenderer['render'] = async ({pathname}) => {
const html = await renderToHtml(app); const html = await renderToHtml(app);
const collectedData: PageCollectedData = { const {helmet} = helmetContext as FilledContext;
// 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 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(), anchors: statefulBrokenLinks.getCollectedAnchors(),
links: statefulBrokenLinks.getCollectedLinks(), links: statefulBrokenLinks.getCollectedLinks(),
modules: Array.from(modules), modules: Array.from(modules),

View file

@ -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<BuildMetaTag>[] =
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(),
},
};
}

View file

@ -126,7 +126,15 @@ async function executePluginsPostBuild({
props: Props; props: Props;
collectedData: SiteCollectedData; 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( await Promise.all(
plugins.map(async (plugin) => { plugins.map(async (plugin) => {
if (!plugin.postBuild) { if (!plugin.postBuild) {
@ -135,6 +143,7 @@ async function executePluginsPostBuild({
await plugin.postBuild({ await plugin.postBuild({
...props, ...props,
head, head,
routesBuildMetadata,
content: plugin.content, content: plugin.content,
}); });
}), }),

View file

@ -9,6 +9,7 @@
// In particular the interface between SSG and serverEntry code // In particular the interface between SSG and serverEntry code
import type {HelmetServerState} from 'react-helmet-async'; import type {HelmetServerState} from 'react-helmet-async';
import type {RouteBuildMetadata} from '@docusaurus/types';
export type AppRenderResult = { export type AppRenderResult = {
html: string; html: string;
@ -16,18 +17,41 @@ export type AppRenderResult = {
}; };
export type AppRenderer = { export type AppRenderer = {
render: (params: {pathname: string}) => Promise<AppRenderResult>; render: (params: {
pathname: string;
// TODO Docusaurus v4: remove deprecated postBuild({head}) API
v4RemoveLegacyPostBuildHeadAttribute: boolean;
}) => Promise<AppRenderResult>;
// It's important to shut down the app renderer // It's important to shut down the app renderer
// Otherwise Node.js require cache leaks memory // Otherwise Node.js require cache leaks memory
shutdown: () => Promise<void>; shutdown: () => Promise<void>;
}; };
export type PageCollectedData = { // Attributes we need internally, for the SSG html template
// TODO Docusaurus v4 refactor: helmet state is non-serializable // They are not exposed to the user in postBuild({routesBuildMetadata})
// this makes it impossible to run SSG in a worker thread export type RouteBuildMetadataInternal = {
helmet: HelmetServerState; 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[]; links: string[];
anchors: string[]; anchors: string[];
modules: string[]; modules: string[];

View file

@ -21,6 +21,9 @@ exports[`loadSiteConfig website with .cjs siteConfig 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -90,6 +93,9 @@ exports[`loadSiteConfig website with ts + js config 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -159,6 +165,9 @@ exports[`loadSiteConfig website with valid JS CJS config 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -228,6 +237,9 @@ exports[`loadSiteConfig website with valid JS ESM config 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -297,6 +309,9 @@ exports[`loadSiteConfig website with valid TypeScript CJS config 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -366,6 +381,9 @@ exports[`loadSiteConfig website with valid TypeScript ESM config 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -435,6 +453,9 @@ exports[`loadSiteConfig website with valid async config 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -506,6 +527,9 @@ exports[`loadSiteConfig website with valid async config creator function 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -577,6 +601,9 @@ exports[`loadSiteConfig website with valid config creator function 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {
@ -651,6 +678,9 @@ exports[`loadSiteConfig website with valid siteConfig 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {

View file

@ -95,6 +95,9 @@ exports[`load loads props for site with custom i18n path 1`] = `
"namespace": false, "namespace": false,
"type": "localStorage", "type": "localStorage",
}, },
"v4": {
"removeLegacyPostBuildHeadAttribute": false,
},
}, },
"headTags": [], "headTags": [],
"i18n": { "i18n": {

View file

@ -11,12 +11,15 @@ import {
DEFAULT_FASTER_CONFIG, DEFAULT_FASTER_CONFIG,
DEFAULT_FASTER_CONFIG_TRUE, DEFAULT_FASTER_CONFIG_TRUE,
DEFAULT_FUTURE_CONFIG, DEFAULT_FUTURE_CONFIG,
DEFAULT_FUTURE_V4_CONFIG,
DEFAULT_FUTURE_V4_CONFIG_TRUE,
DEFAULT_STORAGE_CONFIG, DEFAULT_STORAGE_CONFIG,
validateConfig, validateConfig,
} from '../configValidation'; } from '../configValidation';
import type { import type {
FasterConfig, FasterConfig,
FutureConfig, FutureConfig,
FutureV4Config,
StorageConfig, StorageConfig,
} from '@docusaurus/types/src/config'; } from '@docusaurus/types/src/config';
import type {Config, DocusaurusConfig, PluginConfig} from '@docusaurus/types'; import type {Config, DocusaurusConfig, PluginConfig} from '@docusaurus/types';
@ -45,6 +48,9 @@ describe('normalizeConfig', () => {
...DEFAULT_CONFIG, ...DEFAULT_CONFIG,
...baseConfig, ...baseConfig,
future: { future: {
v4: {
removeLegacyPostBuildHeadAttribute: true,
},
experimental_faster: { experimental_faster: {
swcJsLoader: true, swcJsLoader: true,
swcJsMinimizer: true, swcJsMinimizer: true,
@ -744,6 +750,9 @@ describe('future', () => {
it('accepts future - full', () => { it('accepts future - full', () => {
const future: DocusaurusConfig['future'] = { const future: DocusaurusConfig['future'] = {
v4: {
removeLegacyPostBuildHeadAttribute: true,
},
experimental_faster: { experimental_faster: {
swcJsLoader: true, swcJsLoader: true,
swcJsMinimizer: true, swcJsMinimizer: true,
@ -1571,4 +1580,149 @@ describe('future', () => {
}); });
}); });
}); });
describe('v4', () => {
function v4Containing(v4: Partial<FutureV4Config>) {
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<FutureV4Config> = 42;
expect(() =>
normalizeConfig({
future: {
v4,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.v4" must be one of [object, boolean]
"
`);
});
describe('removeLegacyPostBuildHeadAttribute', () => {
it('accepts - undefined', () => {
const v4: Partial<FutureV4Config> = {
removeLegacyPostBuildHeadAttribute: undefined,
};
expect(
normalizeConfig({
future: {
v4,
},
}),
).toEqual(v4Containing({removeLegacyPostBuildHeadAttribute: false}));
});
it('accepts - true', () => {
const v4: Partial<FutureV4Config> = {
removeLegacyPostBuildHeadAttribute: true,
};
expect(
normalizeConfig({
future: {
v4,
},
}),
).toEqual(v4Containing({removeLegacyPostBuildHeadAttribute: true}));
});
it('accepts - false', () => {
const v4: Partial<FutureV4Config> = {
removeLegacyPostBuildHeadAttribute: false,
};
expect(
normalizeConfig({
future: {
v4,
},
}),
).toEqual(v4Containing({removeLegacyPostBuildHeadAttribute: false}));
});
it('rejects - null', () => {
const v4: Partial<FutureV4Config> = {
// @ts-expect-error: invalid
removeLegacyPostBuildHeadAttribute: 42,
};
expect(() =>
normalizeConfig({
future: {
v4,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.v4.removeLegacyPostBuildHeadAttribute" must be a boolean
"
`);
});
it('rejects - number', () => {
const v4: Partial<FutureV4Config> = {
// @ts-expect-error: invalid
removeLegacyPostBuildHeadAttribute: 42,
};
expect(() =>
normalizeConfig({
future: {
v4,
},
}),
).toThrowErrorMatchingInlineSnapshot(`
""future.v4.removeLegacyPostBuildHeadAttribute" must be a boolean
"
`);
});
});
});
}); });

View file

@ -19,6 +19,7 @@ import {
import type { import type {
FasterConfig, FasterConfig,
FutureConfig, FutureConfig,
FutureV4Config,
StorageConfig, StorageConfig,
} from '@docusaurus/types/src/config'; } from '@docusaurus/types/src/config';
import type { import type {
@ -60,7 +61,17 @@ export const DEFAULT_FASTER_CONFIG_TRUE: FasterConfig = {
rspackBundler: true, 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 = { export const DEFAULT_FUTURE_CONFIG: FutureConfig = {
v4: DEFAULT_FUTURE_V4_CONFIG,
experimental_faster: DEFAULT_FASTER_CONFIG, experimental_faster: DEFAULT_FASTER_CONFIG,
experimental_storage: DEFAULT_STORAGE_CONFIG, experimental_storage: DEFAULT_STORAGE_CONFIG,
experimental_router: 'browser', experimental_router: 'browser',
@ -242,6 +253,22 @@ const FASTER_CONFIG_SCHEMA = Joi.alternatives()
.optional() .optional()
.default(DEFAULT_FASTER_CONFIG); .default(DEFAULT_FASTER_CONFIG);
const FUTURE_V4_SCHEMA = Joi.alternatives()
.try(
Joi.object<FutureV4Config>({
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({ const STORAGE_CONFIG_SCHEMA = Joi.object({
type: Joi.string() type: Joi.string()
.equal('localStorage', 'sessionStorage') .equal('localStorage', 'sessionStorage')
@ -254,6 +281,7 @@ const STORAGE_CONFIG_SCHEMA = Joi.object({
.default(DEFAULT_STORAGE_CONFIG); .default(DEFAULT_STORAGE_CONFIG);
const FUTURE_CONFIG_SCHEMA = Joi.object<FutureConfig>({ const FUTURE_CONFIG_SCHEMA = Joi.object<FutureConfig>({
v4: FUTURE_V4_SCHEMA,
experimental_faster: FASTER_CONFIG_SCHEMA, experimental_faster: FASTER_CONFIG_SCHEMA,
experimental_storage: STORAGE_CONFIG_SCHEMA, experimental_storage: STORAGE_CONFIG_SCHEMA,
experimental_router: Joi.string() experimental_router: Joi.string()

View file

@ -251,6 +251,8 @@ async function generateStaticFile({
// This only renders the app HTML // This only renders the app HTML
const result = await renderer.render({ const result = await renderer.render({
pathname, pathname,
v4RemoveLegacyPostBuildHeadAttribute:
params.v4RemoveLegacyPostBuildHeadAttribute,
}); });
// This renders the full page HTML, including head tags... // This renders the full page HTML, including head tags...
const fullPageHtml = renderSSGTemplate({ const fullPageHtml = renderSSGTemplate({

View file

@ -30,6 +30,9 @@ export type SSGParams = {
htmlMinifierType: HtmlMinifierType; htmlMinifierType: HtmlMinifierType;
serverBundlePath: string; serverBundlePath: string;
ssgTemplateContent: string; ssgTemplateContent: string;
// TODO Docusaurus v4: remove deprecated postBuild({head}) API
v4RemoveLegacyPostBuildHeadAttribute: boolean;
}; };
export async function createSSGParams({ export async function createSSGParams({
@ -62,6 +65,9 @@ export async function createSSGParams({
.swcHtmlMinimizer .swcHtmlMinimizer
? 'swc' ? 'swc'
: 'terser', : 'terser',
v4RemoveLegacyPostBuildHeadAttribute:
props.siteConfig.future.v4.removeLegacyPostBuildHeadAttribute,
}; };
// Useless but ensures that SSG params remain serializable // Useless but ensures that SSG params remain serializable

View file

@ -83,18 +83,17 @@ export function renderSSGTemplate({
} = params; } = params;
const { const {
html: appHtml, html: appHtml,
collectedData: {modules, helmet}, collectedData: {modules, metadata},
} = result; } = result;
const {scripts, stylesheets} = getScriptsAndStylesheets({manifest, modules}); const {scripts, stylesheets} = getScriptsAndStylesheets({manifest, modules});
const htmlAttributes = helmet.htmlAttributes.toString(); const {htmlAttributes, bodyAttributes} = metadata.internal;
const bodyAttributes = helmet.bodyAttributes.toString();
const metaStrings = [ const metaStrings = [
helmet.title.toString(), metadata.internal.title,
helmet.meta.toString(), metadata.internal.meta,
helmet.link.toString(), metadata.internal.link,
helmet.script.toString(), metadata.internal.script,
]; ];
const metaAttributes = metaStrings.filter(Boolean); const metaAttributes = metaStrings.filter(Boolean);

View file

@ -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 `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. Features namespaced 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. `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" ```js title="docusaurus.config.js"
export default { export default {
future: { future: {
v4: {
removeLegacyPostBuildHeadAttribute: true,
},
experimental_faster: { experimental_faster: {
swcJsLoader: true, swcJsLoader: true,
swcJsMinimizer: 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: - `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/)). - [`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)). - [`swcJsMinimizer`](https://github.com/facebook/docusaurus/pull/10441): Use [SWC](https://swc.rs/) to minify JS (instead of [Terser](https://github.com/terser/terser)).

View file

@ -369,6 +369,7 @@ interface Props {
preBodyTags: string; preBodyTags: string;
postBodyTags: string; postBodyTags: string;
routesPaths: string[]; routesPaths: string[];
routesBuildMetadata: {[location: string]: {noIndex: boolean}};
plugins: Plugin<any>[]; plugins: Plugin<any>[];
content: Content; content: Content;
} }

View file

@ -163,6 +163,7 @@ export default async function createConfigAsync() {
baseUrlIssueBanner: true, baseUrlIssueBanner: true,
url: 'https://docusaurus.io', url: 'https://docusaurus.io',
future: { future: {
v4: !isSlower, // Not accurate, but good enough
experimental_faster: !isSlower, experimental_faster: !isSlower,
experimental_storage: { experimental_storage: {
namespace: true, namespace: true,