refactor(core): refactor SSG infrastructure (#10593)

This commit is contained in:
Sébastien Lorber 2024-10-18 18:55:09 +02:00 committed by GitHub
parent 14579cbda8
commit c9f231afb3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 356 additions and 309 deletions

View file

@ -15,6 +15,10 @@ export {
} from './currentBundler'; } from './currentBundler';
export {getMinimizers} from './minification'; export {getMinimizers} from './minification';
export {getHtmlMinifier, type HtmlMinifier} from './minifyHtml'; export {
getHtmlMinifier,
type HtmlMinifier,
type HtmlMinifierType,
} from './minifyHtml';
export {createJsLoaderFactory} from './loaders/jsLoader'; export {createJsLoaderFactory} from './loaders/jsLoader';
export {createStyleLoadersFactory} from './loaders/styleLoader'; export {createStyleLoadersFactory} from './loaders/styleLoader';

View file

@ -7,11 +7,12 @@
import {minify as terserHtmlMinifier} from 'html-minifier-terser'; import {minify as terserHtmlMinifier} from 'html-minifier-terser';
import {importSwcHtmlMinifier} from './importFaster'; import {importSwcHtmlMinifier} from './importFaster';
import type {DocusaurusConfig} from '@docusaurus/types';
// Historical env variable // Historical env variable
const SkipHtmlMinification = process.env.SKIP_HTML_MINIFICATION === 'true'; const SkipHtmlMinification = process.env.SKIP_HTML_MINIFICATION === 'true';
export type HtmlMinifierType = 'swc' | 'terser';
export type HtmlMinifierResult = { export type HtmlMinifierResult = {
code: string; code: string;
warnings: string[]; warnings: string[];
@ -25,24 +26,15 @@ const NoopMinifier: HtmlMinifier = {
minify: async (html: string) => ({code: html, warnings: []}), minify: async (html: string) => ({code: html, warnings: []}),
}; };
type SiteConfigSlice = {
future: {
experimental_faster: Pick<
DocusaurusConfig['future']['experimental_faster'],
'swcHtmlMinimizer'
>;
};
};
export async function getHtmlMinifier({ export async function getHtmlMinifier({
siteConfig, type,
}: { }: {
siteConfig: SiteConfigSlice; type: HtmlMinifierType;
}): Promise<HtmlMinifier> { }): Promise<HtmlMinifier> {
if (SkipHtmlMinification) { if (SkipHtmlMinification) {
return NoopMinifier; return NoopMinifier;
} }
if (siteConfig.future.experimental_faster.swcHtmlMinimizer) { if (type === 'swc') {
return getSwcMinifier(); return getSwcMinifier();
} else { } else {
return getTerserMinifier(); return getTerserMinifier();

View file

@ -9,7 +9,8 @@ import logger from './logger';
// For now this is a private env variable we use internally // For now this is a private env variable we use internally
// But we'll want to expose this feature officially some day // 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 = { const Thresholds = {
min: 5, min: 5,
@ -17,7 +18,7 @@ const Thresholds = {
red: 1000, red: 1000,
}; };
const PerfPrefix = logger.yellow(`[PERF] `); const PerfPrefix = logger.yellow(`[PERF]`);
// This is what enables to "see the parent stack" for each log // This is what enables to "see the parent stack" for each log
// Parent1 > Parent2 > Parent3 > child trace // Parent1 > Parent2 > Parent3 > child trace
@ -42,6 +43,14 @@ type Memory = {
after: NodeJS.MemoryUsage; 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 { function createPerfLogger(): PerfLoggerAPI {
if (!PerfDebuggingEnabled) { if (!PerfDebuggingEnabled) {
const noop = () => {}; 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 = ({ const printPerfLog = ({
label, label,
duration, duration,
memory, memory,
error,
}: { }: {
label: string; label: string;
duration: number; duration: number;
memory: Memory; memory: Memory;
error: Error | undefined;
}) => { }) => {
if (duration < Thresholds.min) { if (duration < Thresholds.min) {
return; return;
} }
console.log( console.log(
`${PerfPrefix + label} - ${formatDuration(duration)} - ${formatMemory( `${PerfPrefix}${formatStatus(error)} ${label} - ${formatDuration(
memory, duration,
)}`, )} - ${formatMemory(memory)}`,
); );
}; };
const start: PerfLoggerAPI['start'] = (label) => const start: PerfLoggerAPI['start'] = (label) =>
performance.mark(label, { performance.mark(label, {
detail: { detail: {
memoryUsage: process.memoryUsage(), memoryUsage: getMemory(),
}, },
}); });
@ -110,22 +125,25 @@ function createPerfLogger(): PerfLoggerAPI {
duration, duration,
memory: { memory: {
before: memoryUsage, before: memoryUsage,
after: process.memoryUsage(), after: getMemory(),
}, },
error: undefined,
}); });
}; };
const log: PerfLoggerAPI['log'] = (label: string) => const log: PerfLoggerAPI['log'] = (label: string) =>
console.log(PerfPrefix + applyParentPrefix(label)); console.log(`${PerfPrefix} ${applyParentPrefix(label)}`);
const async: PerfLoggerAPI['async'] = async (label, asyncFn) => { const async: PerfLoggerAPI['async'] = async (label, asyncFn) => {
const finalLabel = applyParentPrefix(label); const finalLabel = applyParentPrefix(label);
const before = performance.now(); const before = performance.now();
const memoryBefore = process.memoryUsage(); const memoryBefore = getMemory();
const result = await ParentPrefix.run(finalLabel, () => asyncFn());
const memoryAfter = process.memoryUsage(); const asyncEnd = ({error}: {error: Error | undefined}) => {
const memoryAfter = getMemory();
const duration = performance.now() - before; const duration = performance.now() - before;
printPerfLog({ printPerfLog({
error,
label: finalLabel, label: finalLabel,
duration, duration,
memory: { memory: {
@ -133,7 +151,16 @@ function createPerfLogger(): PerfLoggerAPI {
after: memoryAfter, after: memoryAfter,
}, },
}); });
};
try {
const result = await ParentPrefix.run(finalLabel, () => asyncFn());
asyncEnd({error: undefined});
return result; return result;
} catch (e) {
asyncEnd({error: e as Error});
throw e;
}
}; };
return { return {

View file

@ -403,6 +403,7 @@ export type DocusaurusConfig = {
* *
* @see https://docusaurus.io/docs/api/docusaurus-config#ssrTemplate * @see https://docusaurus.io/docs/api/docusaurus-config#ssrTemplate
*/ */
// TODO Docusaurus v4 - rename to ssgTemplate?
ssrTemplate?: string; ssrTemplate?: string;
/** /**
* Will be used as title delimiter in the generated `<title>` tag. * Will be used as title delimiter in the generated `<title>` tag.

View file

@ -7,74 +7,22 @@
import type {ReactNode} from 'react'; import type {ReactNode} from 'react';
import {renderToPipeableStream} from 'react-dom/server'; 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> { export async function renderToHtml(app: ReactNode): Promise<string> {
// Inspired from return new Promise((resolve, reject) => {
// https://react.dev/reference/react-dom/server/renderToPipeableStream#waiting-for-all-content-to-load-for-crawlers-and-static-generation const passThrough = new PassThrough();
// https://github.com/gatsbyjs/gatsby/blob/master/packages/gatsby/cache-dir/static-entry.js
const writableStream = new WritableAsPromise();
const {pipe} = renderToPipeableStream(app, { const {pipe} = renderToPipeableStream(app, {
onError(error) { onError(error) {
writableStream.destroy(error as Error); reject(error);
}, },
onAllReady() { onAllReady() {
pipe(writableStream); pipe(passThrough);
text(passThrough).then(resolve, reject);
}, },
}); });
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;
}); });
}
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!;
}
} }

View file

@ -42,7 +42,10 @@ const render: AppRenderer = async ({pathname}) => {
const html = await renderToHtml(app); const html = await renderToHtml(app);
const collectedData: PageCollectedData = { 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, helmet: (helmetContext as FilledContext).helmet,
anchors: statefulBrokenLinks.getCollectedAnchors(), anchors: statefulBrokenLinks.getCollectedAnchors(),
links: statefulBrokenLinks.getCollectedLinks(), links: statefulBrokenLinks.getCollectedLinks(),
modules: Array.from(modules), modules: Array.from(modules),

View file

@ -8,9 +8,9 @@
import fs from 'fs-extra'; import fs from 'fs-extra';
import path from 'path'; import path from 'path';
import _ from 'lodash'; import _ from 'lodash';
import {compile, getHtmlMinifier} from '@docusaurus/bundler'; import {compile} from '@docusaurus/bundler';
import logger, {PerfLogger} from '@docusaurus/logger'; 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 {loadSite, loadContext, type LoadContextParams} from '../server/site';
import {handleBrokenLinks} from '../server/brokenLinks'; import {handleBrokenLinks} from '../server/brokenLinks';
import {createBuildClientConfig} from '../webpack/client'; import {createBuildClientConfig} from '../webpack/client';
@ -19,26 +19,12 @@ import {
createConfigureWebpackUtils, createConfigureWebpackUtils,
executePluginsConfigureWebpack, executePluginsConfigureWebpack,
} from '../webpack/configure'; } from '../webpack/configure';
import {loadI18n} from '../server/i18n'; import {loadI18n} from '../server/i18n';
import { import {executeSSG} from '../ssg/ssgExecutor';
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 type { import type {
ConfigureWebpackUtils, ConfigureWebpackUtils,
LoadedPlugin, LoadedPlugin,
Props, Props,
RouterType,
} from '@docusaurus/types'; } from '@docusaurus/types';
import type {SiteCollectedData} from '../common'; import type {SiteCollectedData} from '../common';
@ -147,7 +133,7 @@ async function buildLocale({
siteDir: string; siteDir: string;
locale: string; locale: string;
cliOptions: Partial<BuildCLIOptions>; cliOptions: Partial<BuildCLIOptions>;
}): Promise<string> { }): Promise<void> {
// Temporary workaround to unlock the ability to translate the site config // Temporary workaround to unlock the ability to translate the site config
// We'll remove it if a better official API can be designed // We'll remove it if a better official API can be designed
// See https://github.com/facebook/docusaurus/issues/4542 // See https://github.com/facebook/docusaurus/issues/4542
@ -225,72 +211,6 @@ async function buildLocale({
process.cwd(), process.cwd(),
outDir, 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({ async function executePluginsPostBuild({

View file

@ -20,7 +20,10 @@ export type AppRenderer = (params: {
}) => Promise<AppRenderResult>; }) => Promise<AppRenderResult>;
export type PageCollectedData = { 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; helmet: HelmetServerState;
links: string[]; links: string[];
anchors: string[]; anchors: string[];
modules: string[]; modules: string[];

View file

@ -12,34 +12,37 @@ import _ from 'lodash';
import evaluate from 'eval'; import evaluate from 'eval';
import pMap from 'p-map'; import pMap from 'p-map';
import logger, {PerfLogger} from '@docusaurus/logger'; import logger, {PerfLogger} from '@docusaurus/logger';
import {renderSSRTemplate} from './templates/templates'; import {getHtmlMinifier} from '@docusaurus/bundler';
import type {AppRenderer, AppRenderResult, SiteCollectedData} from './common'; 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 {HtmlMinifier} from '@docusaurus/bundler';
import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber'; type SSGSuccessResult = {
import type {SSRTemplateCompiled} from './templates/templates'; collectedData: AppRenderResult['collectedData'];
// html: we don't include it on purpose!
export type SSGParams = { // we don't need to aggregate all html contents in memory!
trailingSlash: boolean | undefined; // html contents can be GC as soon as they are written to disk
manifest: Manifest;
headTags: string;
preBodyTags: string;
postBodyTags: string;
outDir: string;
baseUrl: string;
noIndex: boolean;
DOCUSAURUS_VERSION: string;
ssrTemplate: SSRTemplateCompiled;
}; };
// Secret way to set SSR plugin concurrency option type SSGSuccess = {
// Waiting for feedback before documenting this officially? pathname: string;
const Concurrency = process.env.DOCUSAURUS_SSR_CONCURRENCY error: null;
? parseInt(process.env.DOCUSAURUS_SSR_CONCURRENCY, 10) result: SSGSuccessResult;
: // Not easy to define a reasonable option default warnings: string[];
// Will still be better than Infinity };
// See also https://github.com/sindresorhus/p-map/issues/24 type SSGError = {
32; pathname: string;
error: Error;
result: null;
warnings: string[];
};
type SSGResult = SSGSuccess | SSGError;
export async function loadAppRenderer({ export async function loadAppRenderer({
serverBundlePath, serverBundlePath,
@ -86,30 +89,6 @@ export async function loadAppRenderer({
return serverEntry.default; 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( export function printSSGWarnings(
results: { results: {
pathname: string; pathname: string;
@ -163,28 +142,26 @@ Troubleshooting guide: https://github.com/facebook/docusaurus/discussions/10580
export async function generateStaticFiles({ export async function generateStaticFiles({
pathnames, pathnames,
renderer,
params, params,
htmlMinifier,
}: { }: {
pathnames: string[]; pathnames: string[];
renderer: AppRenderer;
params: SSGParams; params: SSGParams;
htmlMinifier: HtmlMinifier;
}): Promise<{collectedData: SiteCollectedData}> { }): Promise<{collectedData: SiteCollectedData}> {
type SSGSuccess = { const [renderer, htmlMinifier, ssgTemplate] = await Promise.all([
pathname: string; PerfLogger.async('Load App renderer', () =>
error: null; loadAppRenderer({
result: AppRenderResult; serverBundlePath: params.serverBundlePath,
warnings: string[]; }),
}; ),
type SSGError = { PerfLogger.async('Load HTML minifier', () =>
pathname: string; getHtmlMinifier({
error: Error; type: params.htmlMinifierType,
result: null; }),
warnings: string[]; ),
}; PerfLogger.async('Compile SSG template', () =>
type SSGResult = SSGSuccess | SSGError; compileSSGTemplate(params.ssgTemplateContent),
),
]);
// Note that we catch all async errors on purpose // Note that we catch all async errors on purpose
// Docusaurus presents all the SSG errors to the user, not just the first one // Docusaurus presents all the SSG errors to the user, not just the first one
@ -196,6 +173,7 @@ export async function generateStaticFiles({
renderer, renderer,
params, params,
htmlMinifier, htmlMinifier,
ssgTemplate,
}).then( }).then(
(result) => ({ (result) => ({
pathname, pathname,
@ -210,7 +188,7 @@ export async function generateStaticFiles({
warnings: [], warnings: [],
}), }),
), ),
{concurrency: Concurrency}, {concurrency: SSGConcurrency},
); );
printSSGWarnings(results); printSSGWarnings(results);
@ -247,21 +225,24 @@ async function generateStaticFile({
renderer, renderer,
params, params,
htmlMinifier, htmlMinifier,
ssgTemplate,
}: { }: {
pathname: string; pathname: string;
renderer: AppRenderer; renderer: AppRenderer;
params: SSGParams; params: SSGParams;
htmlMinifier: HtmlMinifier; htmlMinifier: HtmlMinifier;
}): Promise<AppRenderResult & {warnings: string[]}> { ssgTemplate: SSGTemplateCompiled;
}): Promise<SSGSuccessResult & {warnings: string[]}> {
try { try {
// This only renders the app HTML // This only renders the app HTML
const result = await renderer({ const result = await renderer({
pathname, pathname,
}); });
// This renders the full page HTML, including head tags... // This renders the full page HTML, including head tags...
const fullPageHtml = renderSSRTemplate({ const fullPageHtml = renderSSGTemplate({
params, params,
result, result,
ssgTemplate,
}); });
const minifierResult = await htmlMinifier.minify(fullPageHtml); const minifierResult = await htmlMinifier.minify(fullPageHtml);
await writeStaticFile({ await writeStaticFile({
@ -270,7 +251,7 @@ async function generateStaticFile({
params, params,
}); });
return { return {
...result, collectedData: result.collectedData,
// As of today, only the html minifier can emit SSG warnings // As of today, only the html minifier can emit SSG warnings
warnings: minifierResult.warnings, warnings: minifierResult.warnings,
}; };
@ -307,40 +288,3 @@ It might also require to wrap your client code in ${logger.code(
return parts.join('\n'); 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);
}

View file

@ -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;
}

View file

@ -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);
}

View file

@ -7,14 +7,15 @@
import * as eta from 'eta'; import * as eta from 'eta';
import {getBundles} from 'react-loadable-ssr-addon-v5-slorber'; 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 {AppRenderResult} from '../common';
import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber'; 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 // that does not look super clean nor typesafe
// Note: changing it is a breaking change because template is configurable // Note: changing it is a breaking change because template is configurable
export type SSRTemplateData = { export type SSGTemplateData = {
appHtml: string; appHtml: string;
baseUrl: string; baseUrl: string;
htmlAttributes: string; htmlAttributes: string;
@ -29,16 +30,16 @@ export type SSRTemplateData = {
version: string; version: string;
}; };
export type SSRTemplateCompiled = (data: SSRTemplateData) => string; export type SSGTemplateCompiled = (data: SSGTemplateData) => string;
export async function compileSSRTemplate( export async function compileSSGTemplate(
template: string, template: string,
): Promise<SSRTemplateCompiled> { ): Promise<SSGTemplateCompiled> {
const compiledTemplate = eta.compile(template.trim(), { const compiledTemplate = eta.compile(template.trim(), {
rmWhitespace: true, 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}; return {scripts, stylesheets};
} }
export function renderSSRTemplate({ export function renderSSGTemplate({
params, params,
result, result,
ssgTemplate,
}: { }: {
params: SSGParams; params: SSGParams;
result: AppRenderResult; result: AppRenderResult;
ssgTemplate: SSGTemplateCompiled;
}): string { }): string {
const { const {
baseUrl, baseUrl,
@ -77,7 +80,6 @@ export function renderSSRTemplate({
manifest, manifest,
noIndex, noIndex,
DOCUSAURUS_VERSION, DOCUSAURUS_VERSION,
ssrTemplate,
} = params; } = params;
const { const {
html: appHtml, html: appHtml,
@ -96,7 +98,7 @@ export function renderSSRTemplate({
]; ];
const metaAttributes = metaStrings.filter(Boolean); const metaAttributes = metaStrings.filter(Boolean);
const data: SSRTemplateData = { const data: SSGTemplateData = {
appHtml, appHtml,
baseUrl, baseUrl,
htmlAttributes, htmlAttributes,
@ -111,14 +113,14 @@ export function renderSSRTemplate({
version: DOCUSAURUS_VERSION, version: DOCUSAURUS_VERSION,
}; };
return ssrTemplate(data); return ssgTemplate(data);
} }
export function renderHashRouterTemplate({ export async function renderHashRouterTemplate({
params, params,
}: { }: {
params: SSGParams; params: SSGParams;
}): string { }): Promise<string> {
const { const {
// baseUrl, // baseUrl,
headTags, headTags,
@ -126,15 +128,19 @@ export function renderHashRouterTemplate({
postBodyTags, postBodyTags,
manifest, manifest,
DOCUSAURUS_VERSION, DOCUSAURUS_VERSION,
ssrTemplate, ssgTemplateContent,
} = params; } = params;
const ssgTemplate = await PerfLogger.async('Compile SSG template', () =>
compileSSGTemplate(ssgTemplateContent),
);
const {scripts, stylesheets} = getScriptsAndStylesheets({ const {scripts, stylesheets} = getScriptsAndStylesheets({
manifest, manifest,
modules: [], modules: [],
}); });
const data: SSRTemplateData = { const data: SSGTemplateData = {
appHtml: '', appHtml: '',
baseUrl: './', baseUrl: './',
htmlAttributes: '', htmlAttributes: '',
@ -149,5 +155,5 @@ export function renderHashRouterTemplate({
version: DOCUSAURUS_VERSION, version: DOCUSAURUS_VERSION,
}; };
return ssrTemplate(data); return ssgTemplate(data);
} }

View file

@ -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);
}

View file

@ -109,7 +109,7 @@ export async function createStartClientConfig({
plugins: [ plugins: [
// Generates an `index.html` file with the <script> injected. // Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({ 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. // So we can define the position where the scripts are injected.
inject: false, inject: false,
filename: 'index.html', filename: 'index.html',