From 34297bc56d6c1559cdd534a3db44110220bbd61a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Lorber?= Date: Thu, 8 Feb 2024 18:44:45 +0100 Subject: [PATCH] refactor(core): internalize, simplify and optimize the SSG logic (#9798) --- packages/docusaurus/bin/docusaurus.mjs | 11 +- packages/docusaurus/package.json | 3 +- .../{serverRenderer.tsx => renderToHtml.tsx} | 2 +- .../docusaurus/src/client/serverEntry.tsx | 159 +------ packages/docusaurus/src/commands/build.ts | 389 ++++++++++-------- packages/docusaurus/src/commands/deploy.ts | 2 +- packages/docusaurus/src/commands/serve.ts | 8 +- packages/docusaurus/src/commands/start.ts | 294 +++++++------ packages/docusaurus/src/common.d.ts | 31 ++ packages/docusaurus/src/deps.d.ts | 40 -- packages/docusaurus/src/ssg.ts | 270 ++++++++++++ .../dev.html.template.ejs} | 0 .../templates/ssr.html.template.ts | 0 .../docusaurus/src/templates/templates.ts | 115 ++++++ packages/docusaurus/src/utils.ts | 38 ++ .../src/webpack/__tests__/base.test.ts | 7 +- .../src/webpack/__tests__/client.test.ts | 28 +- .../src/webpack/__tests__/server.test.ts | 14 +- packages/docusaurus/src/webpack/base.ts | 23 +- packages/docusaurus/src/webpack/client.ts | 152 +++++-- .../docusaurus/src/webpack/minification.ts | 102 +++++ packages/docusaurus/src/webpack/server.ts | 136 +++--- packages/docusaurus/src/webpack/utils.ts | 149 +++---- project-words.txt | 1 + yarn.lock | 11 +- 25 files changed, 1263 insertions(+), 722 deletions(-) rename packages/docusaurus/src/client/{serverRenderer.tsx => renderToHtml.tsx} (96%) create mode 100644 packages/docusaurus/src/common.d.ts create mode 100644 packages/docusaurus/src/ssg.ts rename packages/docusaurus/src/{webpack/templates/index.html.template.ejs => templates/dev.html.template.ejs} (100%) rename packages/docusaurus/src/{webpack => }/templates/ssr.html.template.ts (100%) create mode 100644 packages/docusaurus/src/templates/templates.ts create mode 100644 packages/docusaurus/src/utils.ts create mode 100644 packages/docusaurus/src/webpack/minification.ts diff --git a/packages/docusaurus/bin/docusaurus.mjs b/packages/docusaurus/bin/docusaurus.mjs index b65915bcd6..ca853ad2e4 100755 --- a/packages/docusaurus/bin/docusaurus.mjs +++ b/packages/docusaurus/bin/docusaurus.mjs @@ -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 is not assignable to Promise... 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}`; diff --git a/packages/docusaurus/package.json b/packages/docusaurus/package.json index 0c251b419e..99ae61d4d3 100644 --- a/packages/docusaurus/package.json +++ b/packages/docusaurus/package.json @@ -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", diff --git a/packages/docusaurus/src/client/serverRenderer.tsx b/packages/docusaurus/src/client/renderToHtml.tsx similarity index 96% rename from packages/docusaurus/src/client/serverRenderer.tsx rename to packages/docusaurus/src/client/renderToHtml.tsx index 435418024f..0f79eb5bbe 100644 --- a/packages/docusaurus/src/client/serverRenderer.tsx +++ b/packages/docusaurus/src/client/renderToHtml.tsx @@ -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 { +export async function renderToHtml(app: ReactNode): Promise { // 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 diff --git a/packages/docusaurus/src/client/serverEntry.tsx b/packages/docusaurus/src/client/serverEntry.tsx index c01c4779e9..84cea7e9bf 100644 --- a/packages/docusaurus/src/client/serverEntry.tsx +++ b/packages/docusaurus/src/client/serverEntry.tsx @@ -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 \`\` (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 { - 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(); const routerContext = {}; const helmetContext = {}; - const statefulBrokenLinks = createStatefulBrokenLinks(); const app = ( // @ts-expect-error: we are migrating away from react-loadable anyways modules.add(moduleName)}> - + @@ -111,75 +39,16 @@ async function doRender(locals: Locals & {path: string}) { ); - 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; diff --git a/packages/docusaurus/src/commands/build.ts b/packages/docusaurus/src/commands/build.ts index c0a3816409..fc086a0098 100644 --- a/packages/docusaurus/src/commands/build.ts +++ b/packages/docusaurus/src/commands/build.ts @@ -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 { +): Promise { 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((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); + } +} diff --git a/packages/docusaurus/src/commands/deploy.ts b/packages/docusaurus/src/commands/deploy.ts index 818cdb13e7..9904b8585e 100644 --- a/packages/docusaurus/src/commands/deploy.ts +++ b/packages/docusaurus/src/commands/deploy.ts @@ -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; diff --git a/packages/docusaurus/src/commands/serve.ts b/packages/docusaurus/src/commands/serve.ts index 19f4f322a8..f41acd1245 100644 --- a/packages/docusaurus/src/commands/serve.ts +++ b/packages/docusaurus/src/commands/serve.ts @@ -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, }); diff --git a/packages/docusaurus/src/commands/start.ts b/packages/docusaurus/src/commands/start.ts index 1e0e7bc106..7527df41dd 100644 --- a/packages/docusaurus/src/commands/start.ts +++ b/packages/docusaurus/src/commands/start.ts @@ -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 & { @@ -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 { + 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