diff --git a/packages/docusaurus-bundler/src/index.ts b/packages/docusaurus-bundler/src/index.ts index 10995162d761..65b2d5803340 100644 --- a/packages/docusaurus-bundler/src/index.ts +++ b/packages/docusaurus-bundler/src/index.ts @@ -15,6 +15,10 @@ export { } from './currentBundler'; export {getMinimizers} from './minification'; -export {getHtmlMinifier, type HtmlMinifier} from './minifyHtml'; +export { + getHtmlMinifier, + type HtmlMinifier, + type HtmlMinifierType, +} from './minifyHtml'; export {createJsLoaderFactory} from './loaders/jsLoader'; export {createStyleLoadersFactory} from './loaders/styleLoader'; diff --git a/packages/docusaurus-bundler/src/minifyHtml.ts b/packages/docusaurus-bundler/src/minifyHtml.ts index f2c3eeec2a0c..d50809bcf673 100644 --- a/packages/docusaurus-bundler/src/minifyHtml.ts +++ b/packages/docusaurus-bundler/src/minifyHtml.ts @@ -7,11 +7,12 @@ import {minify as terserHtmlMinifier} from 'html-minifier-terser'; import {importSwcHtmlMinifier} from './importFaster'; -import type {DocusaurusConfig} from '@docusaurus/types'; // Historical env variable const SkipHtmlMinification = process.env.SKIP_HTML_MINIFICATION === 'true'; +export type HtmlMinifierType = 'swc' | 'terser'; + export type HtmlMinifierResult = { code: string; warnings: string[]; @@ -25,24 +26,15 @@ const NoopMinifier: HtmlMinifier = { minify: async (html: string) => ({code: html, warnings: []}), }; -type SiteConfigSlice = { - future: { - experimental_faster: Pick< - DocusaurusConfig['future']['experimental_faster'], - 'swcHtmlMinimizer' - >; - }; -}; - export async function getHtmlMinifier({ - siteConfig, + type, }: { - siteConfig: SiteConfigSlice; + type: HtmlMinifierType; }): Promise { if (SkipHtmlMinification) { return NoopMinifier; } - if (siteConfig.future.experimental_faster.swcHtmlMinimizer) { + if (type === 'swc') { return getSwcMinifier(); } else { return getTerserMinifier(); diff --git a/packages/docusaurus-logger/src/perfLogger.ts b/packages/docusaurus-logger/src/perfLogger.ts index ceac6353b3f1..77b911dd94ad 100644 --- a/packages/docusaurus-logger/src/perfLogger.ts +++ b/packages/docusaurus-logger/src/perfLogger.ts @@ -9,7 +9,8 @@ import logger from './logger'; // For now this is a private env variable we use internally // But we'll want to expose this feature officially some day -const PerfDebuggingEnabled: boolean = !!process.env.DOCUSAURUS_PERF_LOGGER; +const PerfDebuggingEnabled: boolean = + process.env.DOCUSAURUS_PERF_LOGGER === 'true'; const Thresholds = { min: 5, @@ -17,7 +18,7 @@ const Thresholds = { red: 1000, }; -const PerfPrefix = logger.yellow(`[PERF] `); +const PerfPrefix = logger.yellow(`[PERF]`); // This is what enables to "see the parent stack" for each log // Parent1 > Parent2 > Parent3 > child trace @@ -42,6 +43,14 @@ type Memory = { after: NodeJS.MemoryUsage; }; +function getMemory(): NodeJS.MemoryUsage { + // Before reading memory stats, we explicitly call the GC + // Note: this only works when Node.js option "--expose-gc" is provided + globalThis.gc?.(); + + return process.memoryUsage(); +} + function createPerfLogger(): PerfLoggerAPI { if (!PerfDebuggingEnabled) { const noop = () => {}; @@ -73,29 +82,35 @@ function createPerfLogger(): PerfLoggerAPI { ); }; + const formatStatus = (error: Error | undefined): string => { + return error ? logger.red('[KO]') : ''; // logger.green('[OK]'); + }; + const printPerfLog = ({ label, duration, memory, + error, }: { label: string; duration: number; memory: Memory; + error: Error | undefined; }) => { if (duration < Thresholds.min) { return; } console.log( - `${PerfPrefix + label} - ${formatDuration(duration)} - ${formatMemory( - memory, - )}`, + `${PerfPrefix}${formatStatus(error)} ${label} - ${formatDuration( + duration, + )} - ${formatMemory(memory)}`, ); }; const start: PerfLoggerAPI['start'] = (label) => performance.mark(label, { detail: { - memoryUsage: process.memoryUsage(), + memoryUsage: getMemory(), }, }); @@ -110,30 +125,42 @@ function createPerfLogger(): PerfLoggerAPI { duration, memory: { before: memoryUsage, - after: process.memoryUsage(), + after: getMemory(), }, + error: undefined, }); }; const log: PerfLoggerAPI['log'] = (label: string) => - console.log(PerfPrefix + applyParentPrefix(label)); + console.log(`${PerfPrefix} ${applyParentPrefix(label)}`); const async: PerfLoggerAPI['async'] = async (label, asyncFn) => { const finalLabel = applyParentPrefix(label); const before = performance.now(); - const memoryBefore = process.memoryUsage(); - const result = await ParentPrefix.run(finalLabel, () => asyncFn()); - const memoryAfter = process.memoryUsage(); - const duration = performance.now() - before; - printPerfLog({ - label: finalLabel, - duration, - memory: { - before: memoryBefore, - after: memoryAfter, - }, - }); - return result; + const memoryBefore = getMemory(); + + const asyncEnd = ({error}: {error: Error | undefined}) => { + const memoryAfter = getMemory(); + const duration = performance.now() - before; + printPerfLog({ + error, + label: finalLabel, + duration, + memory: { + before: memoryBefore, + after: memoryAfter, + }, + }); + }; + + try { + const result = await ParentPrefix.run(finalLabel, () => asyncFn()); + asyncEnd({error: undefined}); + return result; + } catch (e) { + asyncEnd({error: e as Error}); + throw e; + } }; return { diff --git a/packages/docusaurus-types/src/config.d.ts b/packages/docusaurus-types/src/config.d.ts index e8e4a4611547..575be4a6fd57 100644 --- a/packages/docusaurus-types/src/config.d.ts +++ b/packages/docusaurus-types/src/config.d.ts @@ -403,6 +403,7 @@ export type DocusaurusConfig = { * * @see https://docusaurus.io/docs/api/docusaurus-config#ssrTemplate */ + // TODO Docusaurus v4 - rename to ssgTemplate? ssrTemplate?: string; /** * Will be used as title delimiter in the generated `` tag. diff --git a/packages/docusaurus/src/client/renderToHtml.tsx b/packages/docusaurus/src/client/renderToHtml.tsx index 0f79eb5bbe7e..dcfcea16d678 100644 --- a/packages/docusaurus/src/client/renderToHtml.tsx +++ b/packages/docusaurus/src/client/renderToHtml.tsx @@ -7,74 +7,22 @@ import type {ReactNode} from 'react'; import {renderToPipeableStream} from 'react-dom/server'; -import {Writable} from 'stream'; +import {PassThrough} from 'node:stream'; +import {text} from 'node:stream/consumers'; +// See also https://github.com/facebook/react/issues/31134 +// See also https://github.com/facebook/docusaurus/issues/9985#issuecomment-2396367797 export async function renderToHtml(app: ReactNode): Promise<string> { - // Inspired from - // https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation - // https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby/cache-dir/static-entry.js - const writableStream = new WritableAsPromise(); - - const {pipe} = renderToPipeableStream(app, { - onError(error) { - writableStream.destroy(error as Error); - }, - onAllReady() { - pipe(writableStream); - }, - }); - - return writableStream.getPromise(); -} - -// WritableAsPromise inspired by https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby/cache-dir/server-utils/writable-as-promise.js - -/* eslint-disable no-underscore-dangle */ -class WritableAsPromise extends Writable { - private _output: string; - private _deferred: { - promise: Promise<string> | null; - resolve: (value: string) => void; - reject: (reason: Error) => void; - }; - - constructor() { - super(); - this._output = ``; - this._deferred = { - promise: null, - resolve: () => null, - reject: () => null, - }; - this._deferred.promise = new Promise((resolve, reject) => { - this._deferred.resolve = resolve; - this._deferred.reject = reject; + return new Promise((resolve, reject) => { + const passThrough = new PassThrough(); + const {pipe} = renderToPipeableStream(app, { + onError(error) { + reject(error); + }, + onAllReady() { + pipe(passThrough); + text(passThrough).then(resolve, reject); + }, }); - } - - override _write( - chunk: {toString: () => string}, - _enc: unknown, - next: () => void, - ) { - this._output += chunk.toString(); - next(); - } - - override _destroy(error: Error | null, next: (error?: Error | null) => void) { - if (error instanceof Error) { - this._deferred.reject(error); - } else { - next(); - } - } - - override end() { - this._deferred.resolve(this._output); - return this.destroy(); - } - - getPromise(): Promise<string> { - return this._deferred.promise!; - } + }); } diff --git a/packages/docusaurus/src/client/serverEntry.tsx b/packages/docusaurus/src/client/serverEntry.tsx index 84cea7e9bfb7..b06068c45816 100644 --- a/packages/docusaurus/src/client/serverEntry.tsx +++ b/packages/docusaurus/src/client/serverEntry.tsx @@ -42,7 +42,10 @@ const render: AppRenderer = 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, + anchors: statefulBrokenLinks.getCollectedAnchors(), links: statefulBrokenLinks.getCollectedLinks(), modules: Array.from(modules), diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index c886a0c8332d..7dbcd0ca83bf 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -8,9 +8,9 @@ import fs from 'fs-extra'; import path from 'path'; import _ from 'lodash'; -import {compile, getHtmlMinifier} from '@docusaurus/bundler'; +import {compile} from '@docusaurus/bundler'; import logger, {PerfLogger} from '@docusaurus/logger'; -import {DOCUSAURUS_VERSION, mapAsyncSequential} from '@docusaurus/utils'; +import {mapAsyncSequential} from '@docusaurus/utils'; import {loadSite, loadContext, type LoadContextParams} from '../server/site'; import {handleBrokenLinks} from '../server/brokenLinks'; import {createBuildClientConfig} from '../webpack/client'; @@ -19,26 +19,12 @@ import { createConfigureWebpackUtils, executePluginsConfigureWebpack, } from '../webpack/configure'; - import {loadI18n} from '../server/i18n'; -import { - generateHashRouterEntrypoint, - generateStaticFiles, - loadAppRenderer, -} from '../ssg'; -import { - compileSSRTemplate, - renderHashRouterTemplate, -} from '../templates/templates'; -import defaultSSRTemplate from '../templates/ssr.html.template'; -import type {SSGParams} from '../ssg'; - -import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber'; +import {executeSSG} from '../ssg/ssgExecutor'; import type { ConfigureWebpackUtils, LoadedPlugin, Props, - RouterType, } from '@docusaurus/types'; import type {SiteCollectedData} from '../common'; @@ -147,7 +133,7 @@ async function buildLocale({ siteDir: string; locale: string; cliOptions: Partial<BuildCLIOptions>; -}): Promise<string> { +}): Promise<void> { // Temporary workaround to unlock the ability to translate the site config // We'll remove it if a better official API can be designed // See https://github.com/facebook/docusaurus/issues/4542 @@ -225,72 +211,6 @@ async function buildLocale({ process.cwd(), outDir, )}.`; - - return outDir; -} - -async function executeSSG({ - props, - serverBundlePath, - clientManifestPath, - router, -}: { - props: Props; - serverBundlePath: string; - clientManifestPath: string; - router: RouterType; -}): Promise<{collectedData: SiteCollectedData}> { - const manifest: Manifest = await PerfLogger.async( - 'Read client manifest', - () => fs.readJSON(clientManifestPath, 'utf-8'), - ); - - const ssrTemplate = await PerfLogger.async('Compile SSR template', () => - compileSSRTemplate(props.siteConfig.ssrTemplate ?? defaultSSRTemplate), - ); - - const params: SSGParams = { - trailingSlash: props.siteConfig.trailingSlash, - outDir: props.outDir, - baseUrl: props.baseUrl, - manifest, - headTags: props.headTags, - preBodyTags: props.preBodyTags, - postBodyTags: props.postBodyTags, - ssrTemplate, - noIndex: props.siteConfig.noIndex, - DOCUSAURUS_VERSION, - }; - - if (router === 'hash') { - PerfLogger.start('Generate Hash Router entry point'); - const content = renderHashRouterTemplate({params}); - await generateHashRouterEntrypoint({content, params}); - PerfLogger.end('Generate Hash Router entry point'); - return {collectedData: {}}; - } - - const [renderer, htmlMinifier] = await Promise.all([ - PerfLogger.async('Load App renderer', () => - loadAppRenderer({ - serverBundlePath, - }), - ), - PerfLogger.async('Load HTML minifier', () => - getHtmlMinifier({siteConfig: props.siteConfig}), - ), - ]); - - const ssgResult = await PerfLogger.async('Generate static files', () => - generateStaticFiles({ - pathnames: props.routesPaths, - renderer, - params, - htmlMinifier, - }), - ); - - return ssgResult; } async function executePluginsPostBuild({ diff --git a/packages/docusaurus/src/common.d.ts b/packages/docusaurus/src/common.d.ts index 0355ef25b59c..c8271207da27 100644 --- a/packages/docusaurus/src/common.d.ts +++ b/packages/docusaurus/src/common.d.ts @@ -20,7 +20,10 @@ export type AppRenderer = (params: { }) => Promise<AppRenderResult>; 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; + links: string[]; anchors: string[]; modules: string[]; diff --git a/packages/docusaurus/src/ssg.ts b/packages/docusaurus/src/ssg/ssg.ts similarity index 70% rename from packages/docusaurus/src/ssg.ts rename to packages/docusaurus/src/ssg/ssg.ts index 78895e4b1c6e..d7de5b4f181e 100644 --- a/packages/docusaurus/src/ssg.ts +++ b/packages/docusaurus/src/ssg/ssg.ts @@ -12,34 +12,37 @@ import _ from 'lodash'; import evaluate from 'eval'; import pMap from 'p-map'; import logger, {PerfLogger} from '@docusaurus/logger'; -import {renderSSRTemplate} from './templates/templates'; -import type {AppRenderer, AppRenderResult, SiteCollectedData} from './common'; +import {getHtmlMinifier} from '@docusaurus/bundler'; +import { + compileSSGTemplate, + renderSSGTemplate, + type SSGTemplateCompiled, +} from './ssgTemplate'; +import {SSGConcurrency, writeStaticFile} from './ssgUtils'; +import type {SSGParams} from './ssgParams'; +import type {AppRenderer, AppRenderResult, SiteCollectedData} from '../common'; import type {HtmlMinifier} from '@docusaurus/bundler'; -import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber'; -import type {SSRTemplateCompiled} from './templates/templates'; - -export type SSGParams = { - trailingSlash: boolean | undefined; - manifest: Manifest; - headTags: string; - preBodyTags: string; - postBodyTags: string; - outDir: string; - baseUrl: string; - noIndex: boolean; - DOCUSAURUS_VERSION: string; - ssrTemplate: SSRTemplateCompiled; +type SSGSuccessResult = { + collectedData: AppRenderResult['collectedData']; + // html: we don't include it on purpose! + // we don't need to aggregate all html contents in memory! + // html contents can be GC as soon as they are written to disk }; -// Secret way to set SSR plugin concurrency option -// Waiting for feedback before documenting this officially? -const Concurrency = process.env.DOCUSAURUS_SSR_CONCURRENCY - ? parseInt(process.env.DOCUSAURUS_SSR_CONCURRENCY, 10) - : // Not easy to define a reasonable option default - // Will still be better than Infinity - // See also https://github.com/sindresorhus/p-map/issues/24 - 32; +type SSGSuccess = { + pathname: string; + error: null; + result: SSGSuccessResult; + warnings: string[]; +}; +type SSGError = { + pathname: string; + error: Error; + result: null; + warnings: string[]; +}; +type SSGResult = SSGSuccess | SSGError; export async function loadAppRenderer({ serverBundlePath, @@ -86,30 +89,6 @@ export async function loadAppRenderer({ return serverEntry.default; } -function pathnameToFilename({ - pathname, - trailingSlash, -}: { - pathname: string; - trailingSlash?: boolean; -}): string { - const outputFileName = pathname.replace(/^[/\\]/, ''); // Remove leading slashes for webpack-dev-server - // Paths ending with .html are left untouched - if (/\.html?$/i.test(outputFileName)) { - return outputFileName; - } - // Legacy retro-compatible behavior - if (typeof trailingSlash === 'undefined') { - return path.join(outputFileName, 'index.html'); - } - // New behavior: we can say if we prefer file/folder output - // Useful resource: https://github.com/slorber/trailing-slash-guide - if (pathname === '' || pathname.endsWith('/') || trailingSlash) { - return path.join(outputFileName, 'index.html'); - } - return `${outputFileName}.html`; -} - export function printSSGWarnings( results: { pathname: string; @@ -163,28 +142,26 @@ Troubleshooting guide: https://github.com/facebook/docusaurus/discussions/10580 export async function generateStaticFiles({ pathnames, - renderer, params, - htmlMinifier, }: { pathnames: string[]; - renderer: AppRenderer; params: SSGParams; - htmlMinifier: HtmlMinifier; }): Promise<{collectedData: SiteCollectedData}> { - type SSGSuccess = { - pathname: string; - error: null; - result: AppRenderResult; - warnings: string[]; - }; - type SSGError = { - pathname: string; - error: Error; - result: null; - warnings: string[]; - }; - type SSGResult = SSGSuccess | SSGError; + const [renderer, htmlMinifier, ssgTemplate] = await Promise.all([ + PerfLogger.async('Load App renderer', () => + loadAppRenderer({ + serverBundlePath: params.serverBundlePath, + }), + ), + PerfLogger.async('Load HTML minifier', () => + getHtmlMinifier({ + type: params.htmlMinifierType, + }), + ), + PerfLogger.async('Compile SSG template', () => + compileSSGTemplate(params.ssgTemplateContent), + ), + ]); // Note that we catch all async errors on purpose // Docusaurus presents all the SSG errors to the user, not just the first one @@ -196,6 +173,7 @@ export async function generateStaticFiles({ renderer, params, htmlMinifier, + ssgTemplate, }).then( (result) => ({ pathname, @@ -210,7 +188,7 @@ export async function generateStaticFiles({ warnings: [], }), ), - {concurrency: Concurrency}, + {concurrency: SSGConcurrency}, ); printSSGWarnings(results); @@ -247,21 +225,24 @@ async function generateStaticFile({ renderer, params, htmlMinifier, + ssgTemplate, }: { pathname: string; renderer: AppRenderer; params: SSGParams; htmlMinifier: HtmlMinifier; -}): Promise<AppRenderResult & {warnings: string[]}> { + ssgTemplate: SSGTemplateCompiled; +}): Promise<SSGSuccessResult & {warnings: string[]}> { try { // This only renders the app HTML const result = await renderer({ pathname, }); // This renders the full page HTML, including head tags... - const fullPageHtml = renderSSRTemplate({ + const fullPageHtml = renderSSGTemplate({ params, result, + ssgTemplate, }); const minifierResult = await htmlMinifier.minify(fullPageHtml); await writeStaticFile({ @@ -270,7 +251,7 @@ async function generateStaticFile({ params, }); return { - ...result, + collectedData: result.collectedData, // As of today, only the html minifier can emit SSG warnings warnings: minifierResult.warnings, }; @@ -307,40 +288,3 @@ It might also require to wrap your client code in ${logger.code( return parts.join('\n'); } - -export async function generateHashRouterEntrypoint({ - content, - params, -}: { - content: string; - params: SSGParams; -}): Promise<void> { - await writeStaticFile({ - pathname: '/', - content, - params, - }); -} - -async function writeStaticFile({ - content, - pathname, - params, -}: { - content: string; - pathname: string; - params: SSGParams; -}) { - function removeBaseUrl(p: string, baseUrl: string): string { - return baseUrl === '/' ? p : p.replace(new RegExp(`^${baseUrl}`), '/'); - } - - const filename = pathnameToFilename({ - pathname: removeBaseUrl(pathname, params.baseUrl), - trailingSlash: params.trailingSlash, - }); - - const filePath = path.join(params.outDir, filename); - await fs.ensureDir(path.dirname(filePath)); - await fs.writeFile(filePath, content); -} diff --git a/packages/docusaurus/src/ssg/ssgExecutor.ts b/packages/docusaurus/src/ssg/ssgExecutor.ts new file mode 100644 index 000000000000..32f10e258a4d --- /dev/null +++ b/packages/docusaurus/src/ssg/ssgExecutor.ts @@ -0,0 +1,50 @@ +/** + * 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 {PerfLogger} from '@docusaurus/logger'; +import {createSSGParams} from './ssgParams'; +import {generateStaticFiles} from './ssg'; +import {renderHashRouterTemplate} from './ssgTemplate'; +import {generateHashRouterEntrypoint} from './ssgUtils'; +import type {Props, RouterType} from '@docusaurus/types'; +import type {SiteCollectedData} from '../common'; + +// TODO Docusaurus v4 - introduce SSG worker threads +export async function executeSSG({ + props, + serverBundlePath, + clientManifestPath, + router, +}: { + props: Props; + serverBundlePath: string; + clientManifestPath: string; + router: RouterType; +}): Promise<{collectedData: SiteCollectedData}> { + const params = await createSSGParams({ + serverBundlePath, + clientManifestPath, + props, + }); + + if (router === 'hash') { + PerfLogger.start('Generate Hash Router entry point'); + const content = await renderHashRouterTemplate({params}); + await generateHashRouterEntrypoint({content, params}); + PerfLogger.end('Generate Hash Router entry point'); + return {collectedData: {}}; + } + + const ssgResult = await PerfLogger.async('Generate static files', () => + generateStaticFiles({ + pathnames: props.routesPaths, + params, + }), + ); + + return ssgResult; +} diff --git a/packages/docusaurus/src/ssg/ssgParams.ts b/packages/docusaurus/src/ssg/ssgParams.ts new file mode 100644 index 000000000000..a417fa1323d5 --- /dev/null +++ b/packages/docusaurus/src/ssg/ssgParams.ts @@ -0,0 +1,69 @@ +/** + * 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 fs from 'fs-extra'; +import {DOCUSAURUS_VERSION} from '@docusaurus/utils'; +import {PerfLogger} from '@docusaurus/logger'; +import DefaultSSGTemplate from './ssgTemplate.html'; +import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber'; +import type {Props} from '@docusaurus/types'; + +import type {HtmlMinifierType} from '@docusaurus/bundler'; + +// Keep these params serializable +// This makes it possible to use workers +export type SSGParams = { + trailingSlash: boolean | undefined; + manifest: Manifest; + headTags: string; + preBodyTags: string; + postBodyTags: string; + outDir: string; + baseUrl: string; + noIndex: boolean; + DOCUSAURUS_VERSION: string; + + htmlMinifierType: HtmlMinifierType; + serverBundlePath: string; + ssgTemplateContent: string; +}; + +export async function createSSGParams({ + props, + serverBundlePath, + clientManifestPath, +}: { + props: Props; + serverBundlePath: string; + clientManifestPath: string; +}): Promise<SSGParams> { + const manifest: Manifest = await PerfLogger.async( + 'Read client manifest', + () => fs.readJSON(clientManifestPath, 'utf-8'), + ); + + const params: SSGParams = { + trailingSlash: props.siteConfig.trailingSlash, + outDir: props.outDir, + baseUrl: props.baseUrl, + manifest, + headTags: props.headTags, + preBodyTags: props.preBodyTags, + postBodyTags: props.postBodyTags, + ssgTemplateContent: props.siteConfig.ssrTemplate ?? DefaultSSGTemplate, + noIndex: props.siteConfig.noIndex, + DOCUSAURUS_VERSION, + serverBundlePath, + htmlMinifierType: props.siteConfig.future.experimental_faster + .swcHtmlMinimizer + ? 'swc' + : 'terser', + }; + + // Useless but ensures that SSG params remain serializable + return structuredClone(params); +} diff --git a/packages/docusaurus/src/templates/ssr.html.template.ts b/packages/docusaurus/src/ssg/ssgTemplate.html.ts similarity index 100% rename from packages/docusaurus/src/templates/ssr.html.template.ts rename to packages/docusaurus/src/ssg/ssgTemplate.html.ts diff --git a/packages/docusaurus/src/templates/templates.ts b/packages/docusaurus/src/ssg/ssgTemplate.ts similarity index 77% rename from packages/docusaurus/src/templates/templates.ts rename to packages/docusaurus/src/ssg/ssgTemplate.ts index 3484b3d8e5e8..f0ecd5e99780 100644 --- a/packages/docusaurus/src/templates/templates.ts +++ b/packages/docusaurus/src/ssg/ssgTemplate.ts @@ -7,14 +7,15 @@ import * as eta from 'eta'; import {getBundles} from 'react-loadable-ssr-addon-v5-slorber'; -import type {SSGParams} from '../ssg'; +import {PerfLogger} from '@docusaurus/logger'; +import type {SSGParams} from './ssgParams'; import type {AppRenderResult} from '../common'; import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber'; -// TODO this is historical server template data +// TODO Docusaurus v4 breaking change - this is historical server template data // that does not look super clean nor typesafe // Note: changing it is a breaking change because template is configurable -export type SSRTemplateData = { +export type SSGTemplateData = { appHtml: string; baseUrl: string; htmlAttributes: string; @@ -29,16 +30,16 @@ export type SSRTemplateData = { version: string; }; -export type SSRTemplateCompiled = (data: SSRTemplateData) => string; +export type SSGTemplateCompiled = (data: SSGTemplateData) => string; -export async function compileSSRTemplate( +export async function compileSSGTemplate( template: string, -): Promise<SSRTemplateCompiled> { +): Promise<SSGTemplateCompiled> { const compiledTemplate = eta.compile(template.trim(), { rmWhitespace: true, }); - return (data: SSRTemplateData) => compiledTemplate(data, eta.defaultConfig); + return (data: SSGTemplateData) => compiledTemplate(data, eta.defaultConfig); } /** @@ -62,12 +63,14 @@ function getScriptsAndStylesheets({ return {scripts, stylesheets}; } -export function renderSSRTemplate({ +export function renderSSGTemplate({ params, result, + ssgTemplate, }: { params: SSGParams; result: AppRenderResult; + ssgTemplate: SSGTemplateCompiled; }): string { const { baseUrl, @@ -77,7 +80,6 @@ export function renderSSRTemplate({ manifest, noIndex, DOCUSAURUS_VERSION, - ssrTemplate, } = params; const { html: appHtml, @@ -96,7 +98,7 @@ export function renderSSRTemplate({ ]; const metaAttributes = metaStrings.filter(Boolean); - const data: SSRTemplateData = { + const data: SSGTemplateData = { appHtml, baseUrl, htmlAttributes, @@ -111,14 +113,14 @@ export function renderSSRTemplate({ version: DOCUSAURUS_VERSION, }; - return ssrTemplate(data); + return ssgTemplate(data); } -export function renderHashRouterTemplate({ +export async function renderHashRouterTemplate({ params, }: { params: SSGParams; -}): string { +}): Promise<string> { const { // baseUrl, headTags, @@ -126,15 +128,19 @@ export function renderHashRouterTemplate({ postBodyTags, manifest, DOCUSAURUS_VERSION, - ssrTemplate, + ssgTemplateContent, } = params; + const ssgTemplate = await PerfLogger.async('Compile SSG template', () => + compileSSGTemplate(ssgTemplateContent), + ); + const {scripts, stylesheets} = getScriptsAndStylesheets({ manifest, modules: [], }); - const data: SSRTemplateData = { + const data: SSGTemplateData = { appHtml: '', baseUrl: './', htmlAttributes: '', @@ -149,5 +155,5 @@ export function renderHashRouterTemplate({ version: DOCUSAURUS_VERSION, }; - return ssrTemplate(data); + return ssgTemplate(data); } diff --git a/packages/docusaurus/src/ssg/ssgUtils.ts b/packages/docusaurus/src/ssg/ssgUtils.ts new file mode 100644 index 000000000000..33f51d08bfc7 --- /dev/null +++ b/packages/docusaurus/src/ssg/ssgUtils.ts @@ -0,0 +1,80 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +import fs from 'fs-extra'; +import path from 'path'; +import type {SSGParams} from './ssgParams'; + +// Secret way to set SSR plugin concurrency option +// Waiting for feedback before documenting this officially? +export const SSGConcurrency = process.env.DOCUSAURUS_SSR_CONCURRENCY + ? parseInt(process.env.DOCUSAURUS_SSR_CONCURRENCY, 10) + : // Not easy to define a reasonable option default + // Will still be better than Infinity + // See also https://github.com/sindresorhus/p-map/issues/24 + 32; + +function pathnameToFilename({ + pathname, + trailingSlash, +}: { + pathname: string; + trailingSlash?: boolean; +}): string { + const outputFileName = pathname.replace(/^[/\\]/, ''); // Remove leading slashes for webpack-dev-server + // Paths ending with .html are left untouched + if (/\.html?$/i.test(outputFileName)) { + return outputFileName; + } + // Legacy retro-compatible behavior + if (typeof trailingSlash === 'undefined') { + return path.join(outputFileName, 'index.html'); + } + // New behavior: we can say if we prefer file/folder output + // Useful resource: https://github.com/slorber/trailing-slash-guide + if (pathname === '' || pathname.endsWith('/') || trailingSlash) { + return path.join(outputFileName, 'index.html'); + } + return `${outputFileName}.html`; +} + +export async function generateHashRouterEntrypoint({ + content, + params, +}: { + content: string; + params: SSGParams; +}): Promise<void> { + await writeStaticFile({ + pathname: '/', + content, + params, + }); +} + +export async function writeStaticFile({ + content, + pathname, + params, +}: { + content: string; + pathname: string; + params: SSGParams; +}): Promise<void> { + function removeBaseUrl(p: string, baseUrl: string): string { + return baseUrl === '/' ? p : p.replace(new RegExp(`^${baseUrl}`), '/'); + } + + const filename = pathnameToFilename({ + pathname: removeBaseUrl(pathname, params.baseUrl), + trailingSlash: params.trailingSlash, + }); + + const filePath = path.join(params.outDir, filename); + await fs.ensureDir(path.dirname(filePath)); + await fs.writeFile(filePath, content); +} diff --git a/packages/docusaurus/src/webpack/client.ts b/packages/docusaurus/src/webpack/client.ts index 102cf53aa1dd..59d982a13121 100644 --- a/packages/docusaurus/src/webpack/client.ts +++ b/packages/docusaurus/src/webpack/client.ts @@ -109,7 +109,7 @@ export async function createStartClientConfig({ plugins: [ // Generates an `index.html` file with the <script> injected. new HtmlWebpackPlugin({ - template: path.join(__dirname, '../templates/dev.html.template.ejs'), + template: path.join(__dirname, './templates/dev.html.template.ejs'), // So we can define the position where the scripts are injected. inject: false, filename: 'index.html', diff --git a/packages/docusaurus/src/templates/dev.html.template.ejs b/packages/docusaurus/src/webpack/templates/dev.html.template.ejs similarity index 100% rename from packages/docusaurus/src/templates/dev.html.template.ejs rename to packages/docusaurus/src/webpack/templates/dev.html.template.ejs