mirror of
https://github.com/facebook/docusaurus.git
synced 2025-04-28 17:57:48 +02:00
refactor(core): refactor SSG infrastructure (#10593)
This commit is contained in:
parent
14579cbda8
commit
c9f231afb3
16 changed files with 356 additions and 309 deletions
|
@ -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';
|
||||
|
|
|
@ -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<HtmlMinifier> {
|
||||
if (SkipHtmlMinification) {
|
||||
return NoopMinifier;
|
||||
}
|
||||
if (siteConfig.future.experimental_faster.swcHtmlMinimizer) {
|
||||
if (type === 'swc') {
|
||||
return getSwcMinifier();
|
||||
} else {
|
||||
return getTerserMinifier();
|
||||
|
|
|
@ -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 {
|
||||
|
|
1
packages/docusaurus-types/src/config.d.ts
vendored
1
packages/docusaurus-types/src/config.d.ts
vendored
|
@ -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 `<title>` tag.
|
||||
|
|
|
@ -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!;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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({
|
||||
|
|
3
packages/docusaurus/src/common.d.ts
vendored
3
packages/docusaurus/src/common.d.ts
vendored
|
@ -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[];
|
||||
|
|
|
@ -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);
|
||||
}
|
50
packages/docusaurus/src/ssg/ssgExecutor.ts
Normal file
50
packages/docusaurus/src/ssg/ssgExecutor.ts
Normal 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;
|
||||
}
|
69
packages/docusaurus/src/ssg/ssgParams.ts
Normal file
69
packages/docusaurus/src/ssg/ssgParams.ts
Normal 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);
|
||||
}
|
|
@ -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);
|
||||
}
|
80
packages/docusaurus/src/ssg/ssgUtils.ts
Normal file
80
packages/docusaurus/src/ssg/ssgUtils.ts
Normal 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);
|
||||
}
|
|
@ -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',
|
||||
|
|
Loading…
Add table
Reference in a new issue