refactor(core): internalize, simplify and optimize the SSG logic (#9798)

This commit is contained in:
Sébastien Lorber 2024-02-08 18:44:45 +01:00 committed by GitHub
parent d740be0e9c
commit 34297bc56d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1263 additions and 722 deletions

View file

@ -8,6 +8,7 @@
// @ts-check
import {inspect} from 'node:util';
import logger from '@docusaurus/logger';
import cli from 'commander';
import {DOCUSAURUS_VERSION} from '@docusaurus/utils';
@ -61,8 +62,6 @@ cli
'--no-minify',
'build website without minimizing JS bundles (default: false)',
)
// @ts-expect-error: Promise<string> is not assignable to Promise<void>... but
// good enough here.
.action(build);
cli
@ -269,9 +268,11 @@ cli.parse(process.argv);
process.on('unhandledRejection', (err) => {
console.log('');
// Do not use logger.error here: it does not print error causes
console.error(err);
console.log('');
// We need to use inspect with increased depth to log the full causal chain
// By default Node logging has depth=2
// see also https://github.com/nodejs/node/issues/51637
logger.error(inspect(err, {depth: Infinity}));
logger.info`Docusaurus version: number=${DOCUSAURUS_VERSION}
Node version: number=${process.version}`;

View file

@ -50,7 +50,6 @@
"@docusaurus/utils": "3.0.0",
"@docusaurus/utils-common": "3.0.0",
"@docusaurus/utils-validation": "3.0.0",
"@slorber/static-site-generator-webpack-plugin": "^4.0.7",
"@svgr/webpack": "^6.5.1",
"autoprefixer": "^10.4.14",
"babel-loader": "^9.1.3",
@ -70,6 +69,7 @@
"del": "^6.1.1",
"detect-port": "^1.5.1",
"escape-html": "^1.0.3",
"eval": "^0.1.8",
"eta": "^2.2.0",
"file-loader": "^6.2.0",
"fs-extra": "^11.1.1",
@ -79,6 +79,7 @@
"leven": "^3.1.0",
"lodash": "^4.17.21",
"mini-css-extract-plugin": "^2.7.6",
"p-map": "^4.0.0",
"postcss": "^8.4.26",
"postcss-loader": "^7.3.3",
"prompts": "^2.4.2",

View file

@ -9,7 +9,7 @@ import type {ReactNode} from 'react';
import {renderToPipeableStream} from 'react-dom/server';
import {Writable} from 'stream';
export async function renderStaticApp(app: ReactNode): Promise<string> {
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

View file

@ -6,103 +6,31 @@
*/
import React from 'react';
import path from 'path';
import fs from 'fs-extra';
// eslint-disable-next-line no-restricted-imports
import _ from 'lodash';
import * as eta from 'eta';
import {StaticRouter} from 'react-router-dom';
import {HelmetProvider, type FilledContext} from 'react-helmet-async';
import {getBundles, type Manifest} from 'react-loadable-ssr-addon-v5-slorber';
import Loadable from 'react-loadable';
import {minify} from 'html-minifier-terser';
import {renderStaticApp} from './serverRenderer';
import {renderToHtml} from './renderToHtml';
import preload from './preload';
import App from './App';
import {
createStatefulBrokenLinks,
BrokenLinksProvider,
} from './BrokenLinksContext';
import type {Locals} from '@slorber/static-site-generator-webpack-plugin';
import type {PageCollectedData, AppRenderer} from '../common';
const getCompiledSSRTemplate = _.memoize((template: string) =>
eta.compile(template.trim(), {
rmWhitespace: true,
}),
);
const render: AppRenderer = async ({pathname}) => {
await preload(pathname);
function renderSSRTemplate(ssrTemplate: string, data: object) {
const compiled = getCompiledSSRTemplate(ssrTemplate);
return compiled(data, eta.defaultConfig);
}
function buildSSRErrorMessage({
error,
pathname,
}: {
error: Error;
pathname: string;
}): string {
const parts = [
`Docusaurus server-side rendering could not render static page with path ${pathname} because of error: ${error.message}`,
];
const isNotDefinedErrorRegex =
/(?:window|document|localStorage|navigator|alert|location|buffer|self) is not defined/i;
if (isNotDefinedErrorRegex.test(error.message)) {
// prettier-ignore
parts.push(`It looks like you are using code that should run on the client-side only.
To get around it, try using \`<BrowserOnly>\` (https://docusaurus.io/docs/docusaurus-core/#browseronly) or \`ExecutionEnvironment\` (https://docusaurus.io/docs/docusaurus-core/#executionenvironment).
It might also require to wrap your client code in \`useEffect\` hook and/or import a third-party library dynamically (if any).`);
}
return parts.join('\n');
}
export default async function render(
locals: Locals & {path: string},
): Promise<string> {
try {
return await doRender(locals);
} catch (errorUnknown) {
const error = errorUnknown as Error;
const message = buildSSRErrorMessage({error, pathname: locals.path});
const ssrError = new Error(message, {cause: error});
// It is important to log the error here because the stacktrace causal chain
// is not available anymore upper in the tree (this SSR runs in eval)
console.error(ssrError);
throw ssrError;
}
}
// Renderer for static-site-generator-webpack-plugin (async rendering).
async function doRender(locals: Locals & {path: string}) {
const {
routesLocation,
headTags,
preBodyTags,
postBodyTags,
onLinksCollected,
onHeadTagsCollected,
baseUrl,
ssrTemplate,
noIndex,
DOCUSAURUS_VERSION,
} = locals;
const location = routesLocation[locals.path]!;
await preload(location);
const modules = new Set<string>();
const routerContext = {};
const helmetContext = {};
const statefulBrokenLinks = createStatefulBrokenLinks();
const app = (
// @ts-expect-error: we are migrating away from react-loadable anyways
<Loadable.Capture report={(moduleName) => modules.add(moduleName)}>
<HelmetProvider context={helmetContext}>
<StaticRouter location={location} context={routerContext}>
<StaticRouter location={pathname} context={routerContext}>
<BrokenLinksProvider brokenLinks={statefulBrokenLinks}>
<App />
</BrokenLinksProvider>
@ -111,75 +39,16 @@ async function doRender(locals: Locals & {path: string}) {
</Loadable.Capture>
);
const appHtml = await renderStaticApp(app);
onLinksCollected({
staticPagePath: location,
const html = await renderToHtml(app);
const collectedData: PageCollectedData = {
helmet: (helmetContext as FilledContext).helmet,
anchors: statefulBrokenLinks.getCollectedAnchors(),
links: statefulBrokenLinks.getCollectedLinks(),
});
modules: Array.from(modules),
};
const {helmet} = helmetContext as FilledContext;
const htmlAttributes = helmet.htmlAttributes.toString();
const bodyAttributes = helmet.bodyAttributes.toString();
const metaStrings = [
helmet.title.toString(),
helmet.meta.toString(),
helmet.link.toString(),
helmet.script.toString(),
];
onHeadTagsCollected(location, helmet);
const metaAttributes = metaStrings.filter(Boolean);
return {html, collectedData};
};
const {generatedFilesDir} = locals;
const manifestPath = path.join(generatedFilesDir, 'client-manifest.json');
// Using readJSON seems to fail for users of some plugins, possibly because of
// the eval sandbox having a different `Buffer` instance (native one instead
// of polyfilled one)
const manifest = (await fs
.readFile(manifestPath, 'utf-8')
.then(JSON.parse)) as Manifest;
// Get all required assets for this particular page based on client
// manifest information.
const modulesToBeLoaded = [...manifest.entrypoints, ...Array.from(modules)];
const bundles = getBundles(manifest, modulesToBeLoaded);
const stylesheets = (bundles.css ?? []).map((b) => b.file);
const scripts = (bundles.js ?? []).map((b) => b.file);
const renderedHtml = renderSSRTemplate(ssrTemplate, {
appHtml,
baseUrl,
htmlAttributes,
bodyAttributes,
headTags,
preBodyTags,
postBodyTags,
metaAttributes,
scripts,
stylesheets,
noIndex,
version: DOCUSAURUS_VERSION,
});
try {
if (process.env.SKIP_HTML_MINIFICATION === 'true') {
return renderedHtml;
}
// Minify html with https://github.com/DanielRuf/html-minifier-terser
return await minify(renderedHtml, {
removeComments: false,
removeRedundantAttributes: true,
removeEmptyAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
minifyJS: true,
});
} catch (err) {
// prettier-ignore
console.error(`Minification of page ${locals.path} failed.`);
console.error(err);
throw err;
}
}
export default render;

View file

@ -7,27 +7,29 @@
import fs from 'fs-extra';
import path from 'path';
import _ from 'lodash';
import logger from '@docusaurus/logger';
import {mapAsyncSequential} from '@docusaurus/utils';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import ReactLoadableSSRAddon from 'react-loadable-ssr-addon-v5-slorber';
import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer';
import merge from 'webpack-merge';
import {DOCUSAURUS_VERSION, mapAsyncSequential} from '@docusaurus/utils';
import {load, loadContext, type LoadContextOptions} from '../server';
import {handleBrokenLinks} from '../server/brokenLinks';
import createClientConfig from '../webpack/client';
import {createBuildClientConfig} from '../webpack/client';
import createServerConfig from '../webpack/server';
import {
applyConfigurePostCss,
applyConfigureWebpack,
executePluginsConfigurePostCss,
executePluginsConfigureWebpack,
compile,
} from '../webpack/utils';
import CleanWebpackPlugin from '../webpack/plugins/CleanWebpackPlugin';
import {PerfLogger} from '../utils';
import {loadI18n} from '../server/i18n';
import type {HelmetServerState} from 'react-helmet-async';
import type {Configuration} from 'webpack';
import type {Props} from '@docusaurus/types';
import {generateStaticFiles, loadAppRenderer} from '../ssg';
import {compileSSRTemplate} from '../templates/templates';
import defaultSSRTemplate from '../templates/ssr.html.template';
import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber';
import type {LoadedPlugin, Props} from '@docusaurus/types';
import type {SiteCollectedData} from '../common';
export type BuildCLIOptions = Pick<
LoadContextOptions,
@ -46,7 +48,7 @@ export async function build(
// deploy, we have to let deploy finish.
// See https://github.com/facebook/docusaurus/pull/2496
forceTerminate: boolean = true,
): Promise<string> {
): Promise<void> {
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
process.env.DOCUSAURUS_CURRENT_LOCALE = cliOptions.locale;
@ -70,13 +72,15 @@ export async function build(
isLastLocale: boolean;
}) {
try {
return await buildLocale({
PerfLogger.start(`Building site for locale ${locale}`);
await buildLocale({
siteDir,
locale,
cliOptions,
forceTerminate,
isLastLocale,
});
PerfLogger.end(`Building site for locale ${locale}`);
} catch (err) {
throw new Error(
logger.interpolate`Unable to build website for locale name=${locale}.`,
@ -86,6 +90,34 @@ export async function build(
);
}
}
PerfLogger.start(`Get locales to build`);
const locales = await getLocalesToBuild({siteDir, cliOptions});
PerfLogger.end(`Get locales to build`);
if (locales.length > 1) {
logger.info`Website will be built for all these locales: ${locales}`;
}
PerfLogger.start(`Building ${locales.length} locales`);
await mapAsyncSequential(locales, (locale) => {
const isLastLocale = locales.indexOf(locale) === locales.length - 1;
return tryToBuildLocale({locale, isLastLocale});
});
PerfLogger.end(`Building ${locales.length} locales`);
}
async function getLocalesToBuild({
siteDir,
cliOptions,
}: {
siteDir: string;
cliOptions: BuildCLIOptions;
}): Promise<[string, ...string[]]> {
if (cliOptions.locale) {
return [cliOptions.locale];
}
const context = await loadContext({
siteDir,
outDir: cliOptions.outDir,
@ -96,26 +128,16 @@ export async function build(
const i18n = await loadI18n(context.siteConfig, {
locale: cliOptions.locale,
});
if (cliOptions.locale) {
return tryToBuildLocale({locale: cliOptions.locale, isLastLocale: true});
}
if (i18n.locales.length > 1) {
logger.info`Website will be built for all these locales: ${i18n.locales}`;
}
// We need the default locale to always be the 1st in the list. If we build it
// last, it would "erase" the localized sites built in sub-folders
const orderedLocales: [string, ...string[]] = [
return [
i18n.defaultLocale,
...i18n.locales.filter((locale) => locale !== i18n.defaultLocale),
];
const results = await mapAsyncSequential(orderedLocales, (locale) => {
const isLastLocale =
orderedLocales.indexOf(locale) === orderedLocales.length - 1;
return tryToBuildLocale({locale, isLastLocale});
});
return results[0]!;
}
async function buildLocale({
@ -138,6 +160,7 @@ async function buildLocale({
logger.info`name=${`[${locale}]`} Creating an optimized production build...`;
PerfLogger.start('Loading site');
const props: Props = await load({
siteDir,
outDir: cliOptions.outDir,
@ -145,156 +168,59 @@ async function buildLocale({
locale,
localizePath: cliOptions.locale ? false : undefined,
});
PerfLogger.end('Loading site');
// Apply user webpack config.
const {
outDir,
generatedFilesDir,
plugins,
siteConfig: {
onBrokenLinks,
onBrokenAnchors,
staticDirectories: staticDirectoriesOption,
},
routes,
} = props;
const {outDir, plugins} = props;
const clientManifestPath = path.join(
generatedFilesDir,
'client-manifest.json',
);
let clientConfig: Configuration = merge(
await createClientConfig(props, cliOptions.minify, true),
{
plugins: [
// Remove/clean build folders before building bundles.
new CleanWebpackPlugin({verbose: false}),
// Visualize size of webpack output files with an interactive zoomable
// tree map.
cliOptions.bundleAnalyzer && new BundleAnalyzerPlugin(),
// Generate client manifests file that will be used for server bundle.
new ReactLoadableSSRAddon({
filename: clientManifestPath,
}),
].filter(<T>(x: T | undefined | false): x is T => Boolean(x)),
},
);
const collectedLinks: {
[pathname: string]: {links: string[]; anchors: string[]};
} = {};
const headTags: {[location: string]: HelmetServerState} = {};
let serverConfig: Configuration = await createServerConfig({
props,
onLinksCollected: ({staticPagePath, links, anchors}) => {
collectedLinks[staticPagePath] = {links, anchors};
},
onHeadTagsCollected: (staticPagePath, tags) => {
headTags[staticPagePath] = tags;
},
});
// The staticDirectories option can contain empty directories, or non-existent
// directories (e.g. user deleted `static`). Instead of issuing an error, we
// just silently filter them out, because user could have never configured it
// in the first place (the default option should always "work").
const staticDirectories = (
await Promise.all(
staticDirectoriesOption.map(async (dir) => {
const staticDir = path.resolve(siteDir, dir);
if (
(await fs.pathExists(staticDir)) &&
(await fs.readdir(staticDir)).length > 0
) {
return staticDir;
}
return '';
// We can build the 2 configs in parallel
PerfLogger.start('Creating webpack configs');
const [{clientConfig, clientManifestPath}, {serverConfig, serverBundlePath}] =
await Promise.all([
getBuildClientConfig({
props,
cliOptions,
}),
)
).filter(Boolean);
getBuildServerConfig({
props,
}),
]);
PerfLogger.end('Creating webpack configs');
if (staticDirectories.length > 0) {
serverConfig = merge(serverConfig, {
plugins: [
new CopyWebpackPlugin({
patterns: staticDirectories.map((dir) => ({
from: dir,
to: outDir,
toType: 'dir',
})),
}),
],
});
}
// Plugin Lifecycle - configureWebpack and configurePostCss.
plugins.forEach((plugin) => {
const {configureWebpack, configurePostCss} = plugin;
if (configurePostCss) {
clientConfig = applyConfigurePostCss(
configurePostCss.bind(plugin),
clientConfig,
);
}
if (configureWebpack) {
clientConfig = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
clientConfig,
false,
props.siteConfig.webpack?.jsLoader,
plugin.content,
);
serverConfig = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
serverConfig,
true,
props.siteConfig.webpack?.jsLoader,
plugin.content,
);
}
});
// Make sure generated client-manifest is cleaned first so we don't reuse
// Make sure generated client-manifest is cleaned first, so we don't reuse
// the one from previous builds.
if (await fs.pathExists(clientManifestPath)) {
await fs.unlink(clientManifestPath);
}
// TODO do we really need this? .docusaurus folder is cleaned between builds
PerfLogger.start('Deleting previous client manifest');
await ensureUnlink(clientManifestPath);
PerfLogger.end('Deleting previous client manifest');
// Run webpack to build JS bundle (client) and static html files (server).
PerfLogger.start('Bundling');
await compile([clientConfig, serverConfig]);
PerfLogger.end('Bundling');
PerfLogger.start('Executing static site generation');
const {collectedData} = await executeSSG({
props,
serverBundlePath,
clientManifestPath,
});
PerfLogger.end('Executing static site generation');
// Remove server.bundle.js because it is not needed.
if (typeof serverConfig.output?.filename === 'string') {
const serverBundle = path.join(outDir, serverConfig.output.filename);
if (await fs.pathExists(serverBundle)) {
await fs.unlink(serverBundle);
}
}
PerfLogger.start('Deleting server bundle');
await ensureUnlink(serverBundlePath);
PerfLogger.end('Deleting server bundle');
// Plugin Lifecycle - postBuild.
await Promise.all(
plugins.map(async (plugin) => {
if (!plugin.postBuild) {
return;
}
await plugin.postBuild({
...props,
head: headTags,
content: plugin.content,
});
}),
);
PerfLogger.start('Executing postBuild()');
await executePluginsPostBuild({plugins, props, collectedData});
PerfLogger.end('Executing postBuild()');
await handleBrokenLinks({
collectedLinks,
routes,
onBrokenLinks,
onBrokenAnchors,
});
// TODO execute this in parallel to postBuild?
PerfLogger.start('Executing broken links checker');
await executeBrokenLinksCheck({props, collectedData});
PerfLogger.end('Executing broken links checker');
logger.success`Generated static files in path=${path.relative(
process.cwd(),
@ -311,3 +237,144 @@ async function buildLocale({
return outDir;
}
async function executeSSG({
props,
serverBundlePath,
clientManifestPath,
}: {
props: Props;
serverBundlePath: string;
clientManifestPath: string;
}) {
PerfLogger.start('Reading client manifest');
const manifest: Manifest = await fs.readJSON(clientManifestPath, 'utf-8');
PerfLogger.end('Reading client manifest');
PerfLogger.start('Compiling SSR template');
const ssrTemplate = await compileSSRTemplate(
props.siteConfig.ssrTemplate ?? defaultSSRTemplate,
);
PerfLogger.end('Compiling SSR template');
PerfLogger.start('Loading App renderer');
const renderer = await loadAppRenderer({
serverBundlePath,
});
PerfLogger.end('Loading App renderer');
PerfLogger.start('Generate static files');
const ssgResult = await generateStaticFiles({
pathnames: props.routesPaths,
renderer,
params: {
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,
},
});
PerfLogger.end('Generate static files');
return ssgResult;
}
async function executePluginsPostBuild({
plugins,
props,
collectedData,
}: {
plugins: LoadedPlugin[];
props: Props;
collectedData: SiteCollectedData;
}) {
const head = _.mapValues(collectedData, (d) => d.helmet);
await Promise.all(
plugins.map(async (plugin) => {
if (!plugin.postBuild) {
return;
}
await plugin.postBuild({
...props,
head,
content: plugin.content,
});
}),
);
}
async function executeBrokenLinksCheck({
props: {
routes,
siteConfig: {onBrokenLinks, onBrokenAnchors},
},
collectedData,
}: {
props: Props;
collectedData: SiteCollectedData;
}) {
const collectedLinks = _.mapValues(collectedData, (d) => ({
links: d.links,
anchors: d.anchors,
}));
await handleBrokenLinks({
collectedLinks,
routes,
onBrokenLinks,
onBrokenAnchors,
});
}
async function getBuildClientConfig({
props,
cliOptions,
}: {
props: Props;
cliOptions: BuildCLIOptions;
}) {
const {plugins} = props;
const result = await createBuildClientConfig({
props,
minify: cliOptions.minify ?? true,
bundleAnalyzer: cliOptions.bundleAnalyzer ?? false,
});
let {config} = result;
config = executePluginsConfigureWebpack({
plugins,
config,
isServer: false,
jsLoader: props.siteConfig.webpack?.jsLoader,
});
return {clientConfig: config, clientManifestPath: result.clientManifestPath};
}
async function getBuildServerConfig({props}: {props: Props}) {
const {plugins} = props;
const result = await createServerConfig({
props,
});
let {config} = result;
config = executePluginsConfigurePostCss({
plugins,
config,
});
config = executePluginsConfigureWebpack({
plugins,
config,
isServer: true,
jsLoader: props.siteConfig.webpack?.jsLoader,
});
return {serverConfig: config, serverBundlePath: result.serverBundlePath};
}
async function ensureUnlink(filepath: string) {
if (await fs.pathExists(filepath)) {
await fs.unlink(filepath);
}
}

View file

@ -254,7 +254,7 @@ You can also set the deploymentBranch property in docusaurus.config.js .`);
if (!cliOptions.skipBuild) {
// Build site, then push to deploymentBranch branch of specified repo.
try {
await build(siteDir, cliOptions, false).then(runDeploy);
await build(siteDir, cliOptions, false).then(() => runDeploy(outDir));
} catch (err) {
logger.error('Deployment of the build output failed.');
throw err;

View file

@ -31,14 +31,14 @@ export async function serve(
const siteDir = await fs.realpath(siteDirParam);
const buildDir = cliOptions.dir ?? DEFAULT_BUILD_DIR_NAME;
let dir = path.resolve(siteDir, buildDir);
const outDir = path.resolve(siteDir, buildDir);
if (cliOptions.build) {
dir = await build(
await build(
siteDir,
{
config: cliOptions.config,
outDir: dir,
outDir,
},
false,
);
@ -75,7 +75,7 @@ export async function serve(
serveHandler(req, res, {
cleanUrls: true,
public: dir,
public: outDir,
trailingSlash,
directoryListing: false,
});

View file

@ -11,7 +11,6 @@ import _ from 'lodash';
import logger from '@docusaurus/logger';
import {normalizeUrl, posixPath} from '@docusaurus/utils';
import chokidar from 'chokidar';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import openBrowser from 'react-dev-utils/openBrowser';
import {prepareUrls} from 'react-dev-utils/WebpackDevServerUtils';
import evalSourceMapMiddleware from 'react-dev-utils/evalSourceMapMiddleware';
@ -19,15 +18,18 @@ import webpack from 'webpack';
import WebpackDevServer from 'webpack-dev-server';
import merge from 'webpack-merge';
import {load, type LoadContextOptions} from '../server';
import createClientConfig from '../webpack/client';
import {createStartClientConfig} from '../webpack/client';
import {
applyConfigureWebpack,
applyConfigurePostCss,
getHttpsConfig,
formatStatsErrorMessage,
printStatsWarnings,
executePluginsConfigurePostCss,
executePluginsConfigureWebpack,
} from '../webpack/utils';
import {getHostPort, type HostPortOptions} from '../server/getHostPort';
import {PerfLogger} from '../utils';
import type {Compiler} from 'webpack';
import type {Props} from '@docusaurus/types';
export type StartCLIOptions = HostPortOptions &
Pick<LoadContextOptions, 'locale' | 'config'> & {
@ -50,29 +52,23 @@ export async function start(
logger.info('Starting the development server...');
function loadSite() {
return load({
async function loadSite() {
PerfLogger.start('Loading site');
const result = await load({
siteDir,
config: cliOptions.config,
locale: cliOptions.locale,
localizePath: undefined, // Should this be configurable?
});
PerfLogger.end('Loading site');
return result;
}
// Process all related files as a prop.
const props = await loadSite();
const protocol: string = process.env.HTTPS === 'true' ? 'https' : 'http';
const {host, port} = await getHostPort(cliOptions);
if (port === null) {
process.exit();
}
const {baseUrl, headTags, preBodyTags, postBodyTags} = props;
const urls = prepareUrls(protocol, host, port);
const openUrl = normalizeUrl([urls.localUrlForBrowser, baseUrl]);
const {host, port, getOpenUrl} = await createUrlUtils({cliOptions});
const openUrl = getOpenUrl({baseUrl: props.baseUrl});
logger.success`Docusaurus website is running at: url=${openUrl}`;
@ -80,7 +76,7 @@ export async function start(
const reload = _.debounce(() => {
loadSite()
.then(({baseUrl: newBaseUrl}) => {
const newOpenUrl = normalizeUrl([urls.localUrlForBrowser, newBaseUrl]);
const newOpenUrl = getOpenUrl({baseUrl: newBaseUrl});
if (newOpenUrl !== openUrl) {
logger.success`Docusaurus website is running at: url=${newOpenUrl}`;
}
@ -89,7 +85,89 @@ export async function start(
logger.error(err.stack);
});
}, 500);
const {siteConfig, plugins, localizationDir} = props;
// TODO this is historically not optimized!
// When any site file changes, we reload absolutely everything :/
// At least we should try to reload only one plugin individually?
setupFileWatchers({
props,
cliOptions,
onFileChange: () => {
reload();
},
});
const config = await getStartClientConfig({
props,
minify: cliOptions.minify ?? true,
poll: cliOptions.poll,
});
const compiler = webpack(config);
registerE2ETestHook(compiler);
const defaultDevServerConfig = await createDevServerConfig({
cliOptions,
props,
host,
port,
});
// Allow plugin authors to customize/override devServer config
const devServerConfig: WebpackDevServer.Configuration = merge(
[defaultDevServerConfig, config.devServer].filter(Boolean),
);
const devServer = new WebpackDevServer(devServerConfig, compiler);
devServer.startCallback(() => {
if (cliOptions.open) {
openBrowser(openUrl);
}
});
['SIGINT', 'SIGTERM'].forEach((sig) => {
process.on(sig, () => {
devServer.stop();
process.exit();
});
});
}
function createPollingOptions({cliOptions}: {cliOptions: StartCLIOptions}) {
return {
usePolling: !!cliOptions.poll,
interval: Number.isInteger(cliOptions.poll)
? (cliOptions.poll as number)
: undefined,
};
}
function setupFileWatchers({
props,
cliOptions,
onFileChange,
}: {
props: Props;
cliOptions: StartCLIOptions;
onFileChange: () => void;
}) {
const {siteDir} = props;
const pathsToWatch = getPathsToWatch({props});
const pollingOptions = createPollingOptions({cliOptions});
const fsWatcher = chokidar.watch(pathsToWatch, {
cwd: siteDir,
ignoreInitial: true,
...{pollingOptions},
});
['add', 'change', 'unlink', 'addDir', 'unlinkDir'].forEach((event) =>
fsWatcher.on(event, onFileChange),
);
}
function getPathsToWatch({props}: {props: Props}): string[] {
const {siteDir, siteConfigPath, plugins, localizationDir} = props;
const normalizeToSiteDir = (filepath: string) => {
if (filepath && path.isAbsolute(filepath)) {
@ -98,100 +176,49 @@ export async function start(
return posixPath(filepath);
};
const pluginPaths = plugins
const pluginsPaths = plugins
.flatMap((plugin) => plugin.getPathsToWatch?.() ?? [])
.filter(Boolean)
.map(normalizeToSiteDir);
const pathsToWatch = [...pluginPaths, props.siteConfigPath, localizationDir];
return [...pluginsPaths, siteConfigPath, localizationDir];
}
const pollingOptions = {
usePolling: !!cliOptions.poll,
interval: Number.isInteger(cliOptions.poll)
? (cliOptions.poll as number)
: undefined,
async function createUrlUtils({cliOptions}: {cliOptions: StartCLIOptions}) {
const protocol: string = process.env.HTTPS === 'true' ? 'https' : 'http';
const {host, port} = await getHostPort(cliOptions);
if (port === null) {
return process.exit();
}
const getOpenUrl = ({baseUrl}: {baseUrl: string}) => {
const urls = prepareUrls(protocol, host, port);
return normalizeUrl([urls.localUrlForBrowser, baseUrl]);
};
return {host, port, getOpenUrl};
}
async function createDevServerConfig({
cliOptions,
props,
host,
port,
}: {
cliOptions: StartCLIOptions;
props: Props;
host: string;
port: number;
}): Promise<WebpackDevServer.Configuration> {
const {baseUrl, siteDir, siteConfig} = props;
const pollingOptions = createPollingOptions({cliOptions});
const httpsConfig = await getHttpsConfig();
const fsWatcher = chokidar.watch(pathsToWatch, {
cwd: siteDir,
ignoreInitial: true,
...{pollingOptions},
});
['add', 'change', 'unlink', 'addDir', 'unlinkDir'].forEach((event) =>
fsWatcher.on(event, reload),
);
let config: webpack.Configuration = merge(
await createClientConfig(props, cliOptions.minify, false),
{
watchOptions: {
ignored: /node_modules\/(?!@docusaurus)/,
poll: cliOptions.poll,
},
infrastructureLogging: {
// Reduce log verbosity, see https://github.com/facebook/docusaurus/pull/5420#issuecomment-906613105
level: 'warn',
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
template: path.join(
__dirname,
'../webpack/templates/index.html.template.ejs',
),
// So we can define the position where the scripts are injected.
inject: false,
filename: 'index.html',
title: siteConfig.title,
headTags,
preBodyTags,
postBodyTags,
}),
],
},
);
// Plugin Lifecycle - configureWebpack and configurePostCss.
plugins.forEach((plugin) => {
const {configureWebpack, configurePostCss} = plugin;
if (configurePostCss) {
config = applyConfigurePostCss(configurePostCss.bind(plugin), config);
}
if (configureWebpack) {
config = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
config,
false,
props.siteConfig.webpack?.jsLoader,
plugin.content,
);
}
});
const compiler = webpack(config);
compiler.hooks.done.tap('done', (stats) => {
const errorsWarnings = stats.toJson('errors-warnings');
const statsErrorMessage = formatStatsErrorMessage(errorsWarnings);
if (statsErrorMessage) {
console.error(statsErrorMessage);
}
printStatsWarnings(errorsWarnings);
if (process.env.E2E_TEST) {
if (stats.hasErrors()) {
logger.error('E2E_TEST: Project has compiler errors.');
process.exit(1);
}
logger.success('E2E_TEST: Project can compile.');
process.exit(0);
}
});
// https://webpack.js.org/configuration/dev-server
const defaultDevServerConfig: WebpackDevServer.Configuration = {
return {
hot: cliOptions.hotOnly ? 'only' : true,
liveReload: false,
client: {
@ -245,23 +272,50 @@ export async function start(
return middlewares;
},
};
}
// Allow plugin authors to customize/override devServer config
const devServerConfig: WebpackDevServer.Configuration = merge(
[defaultDevServerConfig, config.devServer].filter(Boolean),
);
const devServer = new WebpackDevServer(devServerConfig, compiler);
devServer.startCallback(() => {
if (cliOptions.open) {
openBrowser(openUrl);
// E2E_TEST=true docusaurus start
// Makes "docusaurus start" exit immediately on success/error, for E2E test
function registerE2ETestHook(compiler: Compiler) {
compiler.hooks.done.tap('done', (stats) => {
const errorsWarnings = stats.toJson('errors-warnings');
const statsErrorMessage = formatStatsErrorMessage(errorsWarnings);
if (statsErrorMessage) {
console.error(statsErrorMessage);
}
printStatsWarnings(errorsWarnings);
if (process.env.E2E_TEST) {
if (stats.hasErrors()) {
logger.error('E2E_TEST: Project has compiler errors.');
process.exit(1);
}
logger.success('E2E_TEST: Project can compile.');
process.exit(0);
}
});
['SIGINT', 'SIGTERM'].forEach((sig) => {
process.on(sig, () => {
devServer.stop();
process.exit();
});
});
}
async function getStartClientConfig({
props,
minify,
poll,
}: {
props: Props;
minify: boolean;
poll: number | boolean | undefined;
}) {
const {plugins, siteConfig} = props;
let {clientConfig: config} = await createStartClientConfig({
props,
minify,
poll,
});
config = executePluginsConfigurePostCss({plugins, config});
config = executePluginsConfigureWebpack({
plugins,
config,
isServer: false,
jsLoader: siteConfig.webpack?.jsLoader,
});
return config;
}

31
packages/docusaurus/src/common.d.ts vendored Normal file
View file

@ -0,0 +1,31 @@
/**
* 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.
*/
// This file is for types that are common between client/server
// In particular the interface between SSG and serverEntry code
import type {HelmetServerState} from 'react-helmet-async';
export type AppRenderResult = {
html: string;
collectedData: PageCollectedData;
};
export type AppRenderer = (params: {
pathname: string;
}) => Promise<AppRenderResult>;
export type PageCollectedData = {
helmet: HelmetServerState;
links: string[];
anchors: string[];
modules: string[];
};
export type SiteCollectedData = {
[pathname: string]: PageCollectedData;
};

View file

@ -32,46 +32,6 @@ declare module 'react-loadable-ssr-addon-v5-slorber' {
}
}
declare module '@slorber/static-site-generator-webpack-plugin' {
import type {WebpackPluginInstance, Compiler} from 'webpack';
import type {HelmetServerState} from 'react-helmet-async';
export type Locals = {
routesLocation: {[filePath: string]: string};
generatedFilesDir: string;
headTags: string;
preBodyTags: string;
postBodyTags: string;
onLinksCollected: (params: {
staticPagePath: string;
links: string[];
anchors: string[];
}) => void;
onHeadTagsCollected: (
staticPagePath: string,
tags: HelmetServerState,
) => void;
baseUrl: string;
ssrTemplate: string;
noIndex: boolean;
DOCUSAURUS_VERSION: string;
};
export default class StaticSiteGeneratorPlugin
implements WebpackPluginInstance
{
constructor(props: {
entry: string;
locals: Locals;
paths: string[];
preferFoldersOutput?: boolean;
globals: {[key: string]: unknown};
concurrency?: number;
});
apply(compiler: Compiler): void;
}
}
declare module 'webpack/lib/HotModuleReplacementPlugin' {
import type {HotModuleReplacementPlugin} from 'webpack';

View file

@ -0,0 +1,270 @@
/**
* 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 {createRequire} from 'module';
import path from 'path';
import _ from 'lodash';
import evaluate from 'eval';
import pMap from 'p-map';
import {minify} from 'html-minifier-terser';
import logger from '@docusaurus/logger';
import {PerfLogger} from './utils';
import {renderSSRTemplate} from './templates/templates';
import type {AppRenderer, AppRenderResult, SiteCollectedData} from './common';
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;
};
// 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;
export async function loadAppRenderer({
serverBundlePath,
}: {
serverBundlePath: string;
}): Promise<AppRenderer> {
console.log(`SSG - Load server bundle`);
PerfLogger.start(`SSG - Load server bundle`);
const source = await fs.readFile(serverBundlePath);
PerfLogger.end(`SSG - Load server bundle`);
PerfLogger.log(
`SSG - Server bundle size = ${(source.length / 1024000).toFixed(3)} MB`,
);
const filename = path.basename(serverBundlePath);
const globals = {
// When using "new URL('file.js', import.meta.url)", Webpack will emit
// __filename, and this plugin will throw. not sure the __filename value
// has any importance for this plugin, just using an empty string to
// avoid the error. See https://github.com/facebook/docusaurus/issues/4922
__filename: '',
// This uses module.createRequire() instead of very old "require-like" lib
// See also: https://github.com/pierrec/node-eval/issues/33
require: createRequire(serverBundlePath),
};
PerfLogger.start(`SSG - Evaluate server bundle`);
const serverEntry = evaluate(
source,
/* filename: */ filename,
/* scope: */ globals,
/* includeGlobals: */ true,
) as {default?: AppRenderer};
PerfLogger.end(`SSG - Evaluate server bundle`);
if (!serverEntry?.default || typeof serverEntry.default !== 'function') {
throw new Error(
`Server bundle export from "${filename}" must be a function that renders the Docusaurus React app.`,
);
}
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 async function generateStaticFiles({
pathnames,
renderer,
params,
}: {
pathnames: string[];
renderer: AppRenderer;
params: SSGParams;
}): Promise<{collectedData: SiteCollectedData}> {
type SSGSuccess = {pathname: string; error: null; result: AppRenderResult};
type SSGError = {pathname: string; error: Error; result: null};
type SSGResult = SSGSuccess | SSGError;
// Note that we catch all async errors on purpose
// Docusaurus presents all the SSG errors to the user, not just the first one
const results: SSGResult[] = await pMap(
pathnames,
async (pathname) =>
generateStaticFile({
pathname,
renderer,
params,
}).then(
(result) => ({pathname, result, error: null}),
(error) => ({pathname, result: null, error: error as Error}),
),
{concurrency: Concurrency},
);
const [allSSGErrors, allSSGSuccesses] = _.partition(
results,
(r): r is SSGError => !!r.error,
);
if (allSSGErrors.length > 0) {
const message = `Docusaurus static site generation failed for ${
allSSGErrors.length
} path${allSSGErrors.length ? 's' : ''}:\n- ${allSSGErrors
.map((ssgError) => logger.path(ssgError.pathname))
.join('\n- ')}`;
// Note logging this error properly require using inspect(error,{depth})
// See https://github.com/nodejs/node/issues/51637
throw new Error(message, {
cause: new AggregateError(allSSGErrors.map((ssgError) => ssgError.error)),
});
}
const collectedData: SiteCollectedData = _.chain(allSSGSuccesses)
.keyBy((success) => success.pathname)
.mapValues((ssgSuccess) => ssgSuccess.result.collectedData)
.value();
return {collectedData};
}
async function generateStaticFile({
pathname,
renderer,
params,
}: {
pathname: string;
renderer: AppRenderer;
params: SSGParams;
}) {
try {
// This only renders the app HTML
const result = await renderer({
pathname,
});
// This renders the full page HTML, including head tags...
const fullPageHtml = renderSSRTemplate({
params,
result,
});
const content = await minifyHtml(fullPageHtml);
await writeStaticFile({
pathname,
content,
params,
});
return result;
} catch (errorUnknown) {
const error = errorUnknown as Error;
const tips = getSSGErrorTips(error);
const message = logger.interpolate`Can't render static file for pathname path=${pathname}${
tips ? `\n\n${tips}` : ''
}`;
throw new Error(message, {
cause: error,
});
}
}
function getSSGErrorTips(error: Error): string {
const parts = [];
const isNotDefinedErrorRegex =
/(?:window|document|localStorage|navigator|alert|location|buffer|self) is not defined/i;
if (isNotDefinedErrorRegex.test(error.message)) {
parts.push(`It looks like you are using code that should run on the client-side only.
To get around it, try using one of:
- ${logger.code('<BrowserOnly>')} (${logger.url(
'https://docusaurus.io/docs/docusaurus-core/#browseronly',
)})
- ${logger.code('ExecutionEnvironment')} (${logger.url(
'https://docusaurus.io/docs/docusaurus-core/#executionenvironment',
)}).
It might also require to wrap your client code in ${logger.code(
'useEffect',
)} hook and/or import a third-party library dynamically (if any).`);
}
return parts.join('\n');
}
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);
}
async function minifyHtml(html: string): Promise<string> {
try {
if (process.env.SKIP_HTML_MINIFICATION === 'true') {
return html;
}
// Minify html with https://github.com/DanielRuf/html-minifier-terser
return await minify(html, {
removeComments: false,
removeRedundantAttributes: true,
removeEmptyAttributes: true,
removeScriptTypeAttributes: true,
removeStyleLinkTypeAttributes: true,
useShortDoctype: true,
minifyJS: true,
});
} catch (err) {
throw new Error('HTML minification failed', {cause: err as Error});
}
}

View file

@ -0,0 +1,115 @@
/**
* 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 * as eta from 'eta';
import {getBundles} from 'react-loadable-ssr-addon-v5-slorber';
import type {SSGParams} from '../ssg';
import type {AppRenderResult} from '../common';
import type {Manifest} from 'react-loadable-ssr-addon-v5-slorber';
// TODO 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 = {
appHtml: string;
baseUrl: string;
htmlAttributes: string;
bodyAttributes: string;
headTags: string;
preBodyTags: string;
postBodyTags: string;
metaAttributes: string[];
scripts: string[];
stylesheets: string[];
noIndex: boolean;
version: string;
};
export type SSRTemplateCompiled = (data: SSRTemplateData) => string;
export async function compileSSRTemplate(
template: string,
): Promise<SSRTemplateCompiled> {
const compiledTemplate = eta.compile(template.trim(), {
rmWhitespace: true,
});
return (data: SSRTemplateData) => compiledTemplate(data, eta.defaultConfig);
}
/**
* Given a list of modules that were SSR an d
* @param modules
* @param manifest
*/
function getScriptsAndStylesheets({
modules,
manifest,
}: {
modules: string[];
manifest: Manifest;
}) {
// Get all required assets for this particular page
// based on client manifest information.
const modulesToBeLoaded = [...manifest.entrypoints, ...Array.from(modules)];
const bundles = getBundles(manifest, modulesToBeLoaded);
const stylesheets = (bundles.css ?? []).map((b) => b.file);
const scripts = (bundles.js ?? []).map((b) => b.file);
return {scripts, stylesheets};
}
export function renderSSRTemplate({
params,
result,
}: {
params: SSGParams;
result: AppRenderResult;
}): string {
const {
baseUrl,
headTags,
preBodyTags,
postBodyTags,
manifest,
noIndex,
DOCUSAURUS_VERSION,
ssrTemplate,
} = params;
const {
html: appHtml,
collectedData: {modules, helmet},
} = result;
const {scripts, stylesheets} = getScriptsAndStylesheets({manifest, modules});
const htmlAttributes = helmet.htmlAttributes.toString();
const bodyAttributes = helmet.bodyAttributes.toString();
const metaStrings = [
helmet.title.toString(),
helmet.meta.toString(),
helmet.link.toString(),
helmet.script.toString(),
];
const metaAttributes = metaStrings.filter(Boolean);
const data: SSRTemplateData = {
appHtml,
baseUrl,
htmlAttributes,
bodyAttributes,
headTags,
preBodyTags,
postBodyTags,
metaAttributes,
scripts,
stylesheets,
noIndex,
version: DOCUSAURUS_VERSION,
};
return ssrTemplate(data);
}

View file

@ -0,0 +1,38 @@
/**
* 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 logger from '@docusaurus/logger';
// For now this is a private env variable we use internally
// But we'll want to expose this feature officially some day
export const PerfDebuggingEnabled: boolean =
!!process.env.DOCUSAURUS_PERF_LOGGER;
type PerfLoggerAPI = {
start: (label: string) => void;
end: (label: string) => void;
log: (message: string) => void;
};
function createPerfLogger(): PerfLoggerAPI {
if (!PerfDebuggingEnabled) {
const noop = () => {};
return {
start: noop,
end: noop,
log: noop,
};
}
const prefix = logger.yellow(`[PERF] `);
return {
start: (label) => console.time(prefix + label),
end: (label) => console.timeEnd(prefix + label),
log: (label) => console.log(prefix + label),
};
}
export const PerfLogger: PerfLoggerAPI = createPerfLogger();

View file

@ -105,8 +105,9 @@ describe('base webpack config', () => {
});
it('creates webpack aliases', async () => {
const aliases = ((await createBaseConfig(props, true)).resolve?.alias ??
{}) as {[alias: string]: string};
const aliases = ((
await createBaseConfig({props, isServer: true, minify: true})
).resolve?.alias ?? {}) as {[alias: string]: string};
// Make aliases relative so that test work on all computers
const relativeAliases = _.mapValues(aliases, (a) =>
posixPath(path.relative(props.siteDir, a)),
@ -121,7 +122,7 @@ describe('base webpack config', () => {
.spyOn(utils, 'getFileLoaderUtils')
.mockImplementation(() => fileLoaderUtils);
await createBaseConfig(props, false, false);
await createBaseConfig({props, isServer: false, minify: false});
expect(mockSvg).toHaveBeenCalled();
});
});

View file

@ -7,21 +7,31 @@
import webpack from 'webpack';
import createClientConfig from '../client';
import {createBuildClientConfig, createStartClientConfig} from '../client';
import {loadSetup} from '../../server/__tests__/testUtils';
describe('webpack dev config', () => {
it('simple', async () => {
it('simple start', async () => {
const props = await loadSetup('simple-site');
const config = await createClientConfig(props);
const errors = webpack.validate(config);
expect(errors).toBeUndefined();
const {clientConfig} = await createStartClientConfig({props});
webpack.validate(clientConfig);
});
it('custom', async () => {
it('simple build', async () => {
const props = await loadSetup('simple-site');
const {config} = await createBuildClientConfig({props});
webpack.validate(config);
});
it('custom start', async () => {
const props = await loadSetup('custom-site');
const config = await createClientConfig(props);
const errors = webpack.validate(config);
expect(errors).toBeUndefined();
const {clientConfig} = await createStartClientConfig({props});
webpack.validate(clientConfig);
});
it('custom build', async () => {
const props = await loadSetup('custom-site');
const {config} = await createBuildClientConfig({props});
webpack.validate(config);
});
});

View file

@ -15,24 +15,18 @@ describe('webpack production config', () => {
it('simple', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
const props = await loadSetup('simple-site');
const config = await createServerConfig({
const {config} = await createServerConfig({
props,
onHeadTagsCollected: () => {},
onLinksCollected: () => {},
});
const errors = webpack.validate(config);
expect(errors).toBeUndefined();
webpack.validate(config);
});
it('custom', async () => {
jest.spyOn(console, 'log').mockImplementation(() => {});
const props = await loadSetup('custom-site');
const config = await createServerConfig({
const {config} = await createServerConfig({
props,
onHeadTagsCollected: () => {},
onLinksCollected: () => {},
});
const errors = webpack.validate(config);
expect(errors).toBeUndefined();
webpack.validate(config);
});
});

View file

@ -13,8 +13,8 @@ import {
getCustomizableJSLoader,
getStyleLoaders,
getCustomBabelConfigFilePath,
getMinimizer,
} from './utils';
import {getMinimizer} from './minification';
import {loadThemeAliases, loadDocusaurusAliases} from './aliases';
import type {Configuration} from 'webpack';
import type {Props} from '@docusaurus/types';
@ -44,11 +44,15 @@ export function excludeJS(modulePath: string): boolean {
);
}
export async function createBaseConfig(
props: Props,
isServer: boolean,
minify: boolean = true,
): Promise<Configuration> {
export async function createBaseConfig({
props,
isServer,
minify,
}: {
props: Props;
isServer: boolean;
minify: boolean;
}): Promise<Configuration> {
const {
outDir,
siteDir,
@ -62,8 +66,7 @@ export async function createBaseConfig(
} = props;
const totalPages = routesPaths.length;
const isProd = process.env.NODE_ENV === 'production';
const minimizeEnabled = minify && isProd && !isServer;
const useSimpleCssMinifier = process.env.USE_SIMPLE_CSS_MINIFIER === 'true';
const minimizeEnabled = minify && isProd;
const fileLoaderUtils = getFileLoaderUtils();
@ -156,9 +159,7 @@ export async function createBaseConfig(
// Only minimize client bundle in production because server bundle is only
// used for static site generation
minimize: minimizeEnabled,
minimizer: minimizeEnabled
? getMinimizer(useSimpleCssMinifier)
: undefined,
minimizer: minimizeEnabled ? getMinimizer() : undefined,
splitChunks: isServer
? false
: {

View file

@ -9,22 +9,47 @@ import path from 'path';
import logger from '@docusaurus/logger';
import merge from 'webpack-merge';
import WebpackBar from 'webpackbar';
import {DefinePlugin} from 'webpack';
import webpack from 'webpack';
import {BundleAnalyzerPlugin} from 'webpack-bundle-analyzer';
import ReactLoadableSSRAddon from 'react-loadable-ssr-addon-v5-slorber';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import {createBaseConfig} from './base';
import ChunkAssetPlugin from './plugins/ChunkAssetPlugin';
import {formatStatsErrorMessage} from './utils';
import CleanWebpackPlugin from './plugins/CleanWebpackPlugin';
import type {Props} from '@docusaurus/types';
import type {Configuration} from 'webpack';
export default async function createClientConfig(
props: Props,
minify: boolean = true,
hydrate: boolean = true,
): Promise<Configuration> {
const isBuilding = process.argv[2] === 'build';
const config = await createBaseConfig(props, false, minify);
// When building, include the plugin to force terminate building if errors
// happened in the client bundle.
class ForceTerminatePlugin implements webpack.WebpackPluginInstance {
apply(compiler: webpack.Compiler) {
compiler.hooks.done.tap('client:done', (stats) => {
if (stats.hasErrors()) {
const errorsWarnings = stats.toJson('errors-warnings');
logger.error(
`Client bundle compiled with errors therefore further build is impossible.\n${formatStatsErrorMessage(
errorsWarnings,
)}`,
);
process.exit(1);
}
});
}
}
const clientConfig = merge(config, {
async function createBaseClientConfig({
props,
hydrate,
minify,
}: {
props: Props;
hydrate: boolean;
minify: boolean;
}): Promise<Configuration> {
const baseConfig = await createBaseConfig({props, isServer: false, minify});
return merge(baseConfig, {
// Useless, disabled on purpose (errors on existing sites with no
// browserslist config)
// target: 'browserslist',
@ -35,7 +60,7 @@ export default async function createClientConfig(
runtimeChunk: true,
},
plugins: [
new DefinePlugin({
new webpack.DefinePlugin({
'process.env.HYDRATE_CLIENT_ENTRY': JSON.stringify(hydrate),
}),
new ChunkAssetPlugin(),
@ -45,26 +70,89 @@ export default async function createClientConfig(
}),
],
});
// When building, include the plugin to force terminate building if errors
// happened in the client bundle.
if (isBuilding) {
clientConfig.plugins?.push({
apply: (compiler) => {
compiler.hooks.done.tap('client:done', (stats) => {
if (stats.hasErrors()) {
const errorsWarnings = stats.toJson('errors-warnings');
logger.error(
`Client bundle compiled with errors therefore further build is impossible.\n${formatStatsErrorMessage(
errorsWarnings,
)}`,
);
process.exit(1);
}
});
},
});
}
return clientConfig;
}
// client config when running "docusaurus start"
export async function createStartClientConfig({
props,
minify,
poll,
}: {
props: Props;
minify: boolean;
poll: number | boolean | undefined;
}): Promise<{clientConfig: Configuration}> {
const {siteConfig, headTags, preBodyTags, postBodyTags} = props;
const clientConfig: webpack.Configuration = merge(
await createBaseClientConfig({
props,
minify,
hydrate: false,
}),
{
watchOptions: {
ignored: /node_modules\/(?!@docusaurus)/,
poll,
},
infrastructureLogging: {
// Reduce log verbosity, see https://github.com/facebook/docusaurus/pull/5420#issuecomment-906613105
level: 'warn',
},
plugins: [
// Generates an `index.html` file with the <script> injected.
new HtmlWebpackPlugin({
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',
title: siteConfig.title,
headTags,
preBodyTags,
postBodyTags,
}),
],
},
);
return {clientConfig};
}
// client config when running "docusaurus build"
export async function createBuildClientConfig({
props,
minify,
bundleAnalyzer,
}: {
props: Props;
minify: boolean;
bundleAnalyzer: boolean;
}): Promise<{config: Configuration; clientManifestPath: string}> {
// Apply user webpack config.
const {generatedFilesDir} = props;
const clientManifestPath = path.join(
generatedFilesDir,
'client-manifest.json',
);
const config: Configuration = merge(
await createBaseClientConfig({props, minify, hydrate: true}),
{
plugins: [
new ForceTerminatePlugin(),
// Remove/clean build folders before building bundles.
new CleanWebpackPlugin({verbose: false}),
// Visualize size of webpack output files with an interactive zoomable
// tree map.
bundleAnalyzer && new BundleAnalyzerPlugin(),
// Generate client manifests file that will be used for server bundle.
new ReactLoadableSSRAddon({
filename: clientManifestPath,
}),
].filter(<T>(x: T | undefined | false): x is T => Boolean(x)),
},
);
return {config, clientManifestPath};
}

View file

@ -0,0 +1,102 @@
/**
* 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 TerserPlugin from 'terser-webpack-plugin';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import type {CustomOptions, CssNanoOptions} from 'css-minimizer-webpack-plugin';
import type {WebpackPluginInstance} from 'webpack';
// See https://github.com/webpack-contrib/terser-webpack-plugin#parallel
function getTerserParallel() {
let terserParallel: boolean | number = true;
if (process.env.TERSER_PARALLEL === 'false') {
terserParallel = false;
} else if (
process.env.TERSER_PARALLEL &&
parseInt(process.env.TERSER_PARALLEL, 10) > 0
) {
terserParallel = parseInt(process.env.TERSER_PARALLEL, 10);
}
return terserParallel;
}
function getJsMinifierPlugin() {
return new TerserPlugin({
parallel: getTerserParallel(),
terserOptions: {
parse: {
// We want uglify-js to parse ecma 8 code. However, we don't want it
// to apply any minification steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 2020,
},
compress: {
ecma: 5,
},
mangle: {
safari10: true,
},
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using
// default. See https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
},
},
});
}
function getAdvancedCssMinifier() {
// Using the array syntax to add 2 minimizers
// see https://github.com/webpack-contrib/css-minimizer-webpack-plugin#array
return new CssMinimizerPlugin<[CssNanoOptions, CustomOptions]>({
minimizerOptions: [
// CssNano options
{
preset: require.resolve('@docusaurus/cssnano-preset'),
},
// CleanCss options
{
inline: false,
level: {
1: {
all: false,
removeWhitespace: true,
},
2: {
all: true,
restructureRules: true,
removeUnusedAtRules: false,
},
},
},
],
minify: [
CssMinimizerPlugin.cssnanoMinify,
CssMinimizerPlugin.cleanCssMinify,
],
});
}
export function getMinimizer(): WebpackPluginInstance[] {
// This is an historical env variable to opt-out of the advanced minifier
// Sometimes there's a bug in it and people are happy to disable it
const useSimpleCssMinifier = process.env.USE_SIMPLE_CSS_MINIFIER === 'true';
const minimizer: WebpackPluginInstance[] = [getJsMinifierPlugin()];
if (useSimpleCssMinifier) {
minimizer.push(new CssMinimizerPlugin());
} else {
minimizer.push(getAdvancedCssMinifier());
}
return minimizer;
}

View file

@ -6,104 +6,90 @@
*/
import path from 'path';
import fs from 'fs-extra';
import merge from 'webpack-merge';
import {
NODE_MAJOR_VERSION,
NODE_MINOR_VERSION,
DOCUSAURUS_VERSION,
} from '@docusaurus/utils';
// Forked for Docusaurus: https://github.com/slorber/static-site-generator-webpack-plugin
import StaticSiteGeneratorPlugin, {
type Locals,
} from '@slorber/static-site-generator-webpack-plugin';
import {NODE_MAJOR_VERSION, NODE_MINOR_VERSION} from '@docusaurus/utils';
import WebpackBar from 'webpackbar';
import CopyWebpackPlugin from 'copy-webpack-plugin';
import {createBaseConfig} from './base';
import WaitPlugin from './plugins/WaitPlugin';
import ssrDefaultTemplate from './templates/ssr.html.template';
import type {Props} from '@docusaurus/types';
import type {Configuration} from 'webpack';
export default async function createServerConfig({
props,
onLinksCollected,
onHeadTagsCollected,
}: Pick<Locals, 'onLinksCollected' | 'onHeadTagsCollected'> & {
export default async function createServerConfig(params: {
props: Props;
}): Promise<Configuration> {
const {
baseUrl,
routesPaths,
generatedFilesDir,
headTags,
preBodyTags,
postBodyTags,
siteConfig: {noIndex, trailingSlash, ssrTemplate},
} = props;
const config = await createBaseConfig(props, true);
}): Promise<{config: Configuration; serverBundlePath: string}> {
const {props} = params;
const routesLocation: {[filePath: string]: string} = {};
// Array of paths to be rendered. Relative to output directory
const ssgPaths = routesPaths.map((str) => {
const ssgPath =
baseUrl === '/' ? str : str.replace(new RegExp(`^${baseUrl}`), '/');
routesLocation[ssgPath] = str;
return ssgPath;
const baseConfig = await createBaseConfig({
props,
isServer: true,
// Minification of server bundle reduces size but doubles bundle time :/
minify: false,
});
const serverConfig = merge(config, {
const outputFilename = 'server.bundle.js';
const serverBundlePath = path.join(props.outDir, outputFilename);
const config = merge(baseConfig, {
target: `node${NODE_MAJOR_VERSION}.${NODE_MINOR_VERSION}`,
entry: {
main: path.resolve(__dirname, '../client/serverEntry.js'),
},
output: {
filename: 'server.bundle.js',
filename: outputFilename,
libraryTarget: 'commonjs2',
// Workaround for Webpack 4 Bug (https://github.com/webpack/webpack/issues/6522)
globalObject: 'this',
},
plugins: [
// Wait until manifest from client bundle is generated
new WaitPlugin({
filepath: path.join(generatedFilesDir, 'client-manifest.json'),
}),
// Static site generator webpack plugin.
new StaticSiteGeneratorPlugin({
entry: 'main',
locals: {
baseUrl,
generatedFilesDir,
routesLocation,
headTags,
preBodyTags,
postBodyTags,
onLinksCollected,
onHeadTagsCollected,
ssrTemplate: ssrTemplate ?? ssrDefaultTemplate,
noIndex,
DOCUSAURUS_VERSION,
},
paths: ssgPaths,
preferFoldersOutput: trailingSlash,
// When using "new URL('file.js', import.meta.url)", Webpack will emit
// __filename, and this plugin will throw. not sure the __filename value
// has any importance for this plugin, just using an empty string to
// avoid the error. See https://github.com/facebook/docusaurus/issues/4922
globals: {__filename: ''},
// Secret way to set SSR plugin concurrency option
// Waiting for feedback before documenting this officially?
concurrency: process.env.DOCUSAURUS_SSR_CONCURRENCY
? parseInt(process.env.DOCUSAURUS_SSR_CONCURRENCY, 10)
: undefined,
}),
// Show compilation progress bar.
new WebpackBar({
name: 'Server',
color: 'yellow',
}),
],
await createStaticDirectoriesCopyPlugin(params),
].filter(Boolean),
});
return {config, serverBundlePath};
}
async function createStaticDirectoriesCopyPlugin({props}: {props: Props}) {
const {
outDir,
siteDir,
siteConfig: {staticDirectories: staticDirectoriesOption},
} = props;
// The staticDirectories option can contain empty directories, or non-existent
// directories (e.g. user deleted `static`). Instead of issuing an error, we
// just silently filter them out, because user could have never configured it
// in the first place (the default option should always "work").
const staticDirectories: string[] = (
await Promise.all(
staticDirectoriesOption.map(async (dir) => {
const staticDir = path.resolve(siteDir, dir);
if (
(await fs.pathExists(staticDir)) &&
(await fs.readdir(staticDir)).length > 0
) {
return staticDir;
}
return '';
}),
)
).filter(Boolean);
if (staticDirectories.length === 0) {
return undefined;
}
return new CopyWebpackPlugin({
patterns: staticDirectories.map((dir) => ({
from: dir,
to: outDir,
toType: 'dir',
})),
});
return serverConfig;
}

View file

@ -16,20 +16,14 @@ import {
customizeArray,
customizeObject,
} from 'webpack-merge';
import webpack, {
type Configuration,
type RuleSetRule,
type WebpackPluginInstance,
} from 'webpack';
import TerserPlugin from 'terser-webpack-plugin';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import webpack, {type Configuration, type RuleSetRule} from 'webpack';
import formatWebpackMessages from 'react-dev-utils/formatWebpackMessages';
import type {CustomOptions, CssNanoOptions} from 'css-minimizer-webpack-plugin';
import type {TransformOptions} from '@babel/core';
import type {
Plugin,
PostCssOptions,
ConfigureWebpackUtils,
LoadedPlugin,
} from '@docusaurus/types';
export function formatStatsErrorMessage(
@ -259,6 +253,57 @@ export function applyConfigurePostCss(
return config;
}
// Plugin Lifecycle - configurePostCss()
export function executePluginsConfigurePostCss({
plugins,
config,
}: {
plugins: LoadedPlugin[];
config: Configuration;
}): Configuration {
let resultConfig = config;
plugins.forEach((plugin) => {
const {configurePostCss} = plugin;
if (configurePostCss) {
resultConfig = applyConfigurePostCss(
configurePostCss.bind(plugin),
resultConfig,
);
}
});
return resultConfig;
}
// Plugin Lifecycle - configureWebpack()
export function executePluginsConfigureWebpack({
plugins,
config,
isServer,
jsLoader,
}: {
plugins: LoadedPlugin[];
config: Configuration;
isServer: boolean;
jsLoader: 'babel' | ((isServer: boolean) => RuleSetRule) | undefined;
}): Configuration {
let resultConfig = config;
plugins.forEach((plugin) => {
const {configureWebpack} = plugin;
if (configureWebpack) {
resultConfig = applyConfigureWebpack(
configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`.
resultConfig,
isServer,
jsLoader,
plugin.content,
);
}
});
return resultConfig;
}
declare global {
interface Error {
/** @see https://webpack.js.org/api/node/#error-handling */
@ -266,7 +311,7 @@ declare global {
}
}
export function compile(config: Configuration[]): Promise<void> {
export function compile(config: Configuration[]): Promise<webpack.MultiStats> {
return new Promise((resolve, reject) => {
const compiler = webpack(config);
compiler.run((err, stats) => {
@ -296,7 +341,7 @@ export function compile(config: Configuration[]): Promise<void> {
logger.error(`Error while closing Webpack compiler: ${errClose}`);
reject(errClose);
} else {
resolve();
resolve(stats!);
}
});
});
@ -366,87 +411,3 @@ export async function getHttpsConfig(): Promise<
}
return isHttps;
}
// See https://github.com/webpack-contrib/terser-webpack-plugin#parallel
function getTerserParallel() {
let terserParallel: boolean | number = true;
if (process.env.TERSER_PARALLEL === 'false') {
terserParallel = false;
} else if (
process.env.TERSER_PARALLEL &&
parseInt(process.env.TERSER_PARALLEL, 10) > 0
) {
terserParallel = parseInt(process.env.TERSER_PARALLEL, 10);
}
return terserParallel;
}
export function getMinimizer(
useSimpleCssMinifier = false,
): WebpackPluginInstance[] {
const minimizer: WebpackPluginInstance[] = [
new TerserPlugin({
parallel: getTerserParallel(),
terserOptions: {
parse: {
// We want uglify-js to parse ecma 8 code. However, we don't want it
// to apply any minification steps that turns valid ecma 5 code
// into invalid ecma 5 code. This is why the 'compress' and 'output'
// sections only apply transformations that are ecma 5 safe
// https://github.com/facebook/create-react-app/pull/4234
ecma: 2020,
},
compress: {
ecma: 5,
},
mangle: {
safari10: true,
},
output: {
ecma: 5,
comments: false,
// Turned on because emoji and regex is not minified properly using
// default. See https://github.com/facebook/create-react-app/issues/2488
ascii_only: true,
},
},
}),
];
if (useSimpleCssMinifier) {
minimizer.push(new CssMinimizerPlugin());
} else {
minimizer.push(
// Using the array syntax to add 2 minimizers
// see https://github.com/webpack-contrib/css-minimizer-webpack-plugin#array
new CssMinimizerPlugin<[CssNanoOptions, CustomOptions]>({
minimizerOptions: [
// CssNano options
{
preset: require.resolve('@docusaurus/cssnano-preset'),
},
// CleanCss options
{
inline: false,
level: {
1: {
all: false,
removeWhitespace: true,
},
2: {
all: true,
restructureRules: true,
removeUnusedAtRules: false,
},
},
},
],
minify: [
CssMinimizerPlugin.cssnanoMinify,
CssMinimizerPlugin.cleanCssMinify,
],
}),
);
}
return minimizer;
}

View file

@ -363,6 +363,7 @@ triaging
TSES
twoslash
typecheck
typesafe
Typesense
typesense
Unavatar

View file

@ -2746,15 +2746,6 @@
micromark-util-character "^1.1.0"
micromark-util-symbol "^1.0.1"
"@slorber/static-site-generator-webpack-plugin@^4.0.7":
version "4.0.7"
resolved "https://registry.yarnpkg.com/@slorber/static-site-generator-webpack-plugin/-/static-site-generator-webpack-plugin-4.0.7.tgz#fc1678bddefab014e2145cbe25b3ce4e1cfc36f3"
integrity sha512-Ug7x6z5lwrz0WqdnNFOMYrDQNTPAprvHLSh6+/fmml3qUiz6l5eq+2MzLKWtn/q5K5NpSiFsZTP/fck/3vjSxA==
dependencies:
eval "^0.1.8"
p-map "^4.0.0"
webpack-sources "^3.2.2"
"@surma/rollup-plugin-off-main-thread@^2.2.3":
version "2.2.3"
resolved "https://registry.yarnpkg.com/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz#ee34985952ca21558ab0d952f00298ad2190c053"
@ -16602,7 +16593,7 @@ webpack-merge@^5.9.0:
clone-deep "^4.0.1"
wildcard "^2.0.0"
webpack-sources@^3.2.2, webpack-sources@^3.2.3:
webpack-sources@^3.2.3:
version "3.2.3"
resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde"
integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==