diff --git a/packages/docusaurus/bin/docusaurus.mjs b/packages/docusaurus/bin/docusaurus.mjs index b65915bcd6bd..ca853ad2e497 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 0c251b419e6c..99ae61d4d3ee 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 435418024f3a..0f79eb5bbe7e 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 c01c4779e904..84cea7e9bfb7 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(), - }); - - 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); - - 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, - }); + modules: Array.from(modules), + }; - try { - if (process.env.SKIP_HTML_MINIFICATION === 'true') { - return renderedHtml; - } + return {html, collectedData}; +}; - // 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 c0a38164092a..fc086a0098de 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,137 +168,133 @@ 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)), - }, - ); + // We can build the 2 configs in parallel + PerfLogger.start('Creating webpack configs'); + const [{clientConfig, clientManifestPath}, {serverConfig, serverBundlePath}] = + await Promise.all([ + getBuildClientConfig({ + props, + cliOptions, + }), + getBuildServerConfig({ + props, + }), + ]); + PerfLogger.end('Creating webpack configs'); - const collectedLinks: { - [pathname: string]: {links: string[]; anchors: string[]}; - } = {}; - const headTags: {[location: string]: HelmetServerState} = {}; + // Make sure generated client-manifest is cleaned first, so we don't reuse + // the one from previous builds. + // 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'); - let serverConfig: Configuration = await createServerConfig({ + // 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, - onLinksCollected: ({staticPagePath, links, anchors}) => { - collectedLinks[staticPagePath] = {links, anchors}; - }, - onHeadTagsCollected: (staticPagePath, tags) => { - headTags[staticPagePath] = tags; - }, + serverBundlePath, + clientManifestPath, }); + PerfLogger.end('Executing static site generation'); - // 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 ''; - }), - ) - ).filter(Boolean); - - if (staticDirectories.length > 0) { - serverConfig = merge(serverConfig, { - plugins: [ - new CopyWebpackPlugin({ - patterns: staticDirectories.map((dir) => ({ - from: dir, - to: outDir, - toType: 'dir', - })), - }), - ], - }); - } + // Remove server.bundle.js because it is not needed. + PerfLogger.start('Deleting server bundle'); + await ensureUnlink(serverBundlePath); + PerfLogger.end('Deleting server bundle'); - // Plugin Lifecycle - configureWebpack and configurePostCss. - plugins.forEach((plugin) => { - const {configureWebpack, configurePostCss} = plugin; + // Plugin Lifecycle - postBuild. + PerfLogger.start('Executing postBuild()'); + await executePluginsPostBuild({plugins, props, collectedData}); + PerfLogger.end('Executing postBuild()'); - if (configurePostCss) { - clientConfig = applyConfigurePostCss( - configurePostCss.bind(plugin), - clientConfig, - ); - } + // TODO execute this in parallel to postBuild? + PerfLogger.start('Executing broken links checker'); + await executeBrokenLinksCheck({props, collectedData}); + PerfLogger.end('Executing broken links checker'); - if (configureWebpack) { - clientConfig = applyConfigureWebpack( - configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. - clientConfig, - false, - props.siteConfig.webpack?.jsLoader, - plugin.content, - ); + logger.success`Generated static files in path=${path.relative( + process.cwd(), + outDir, + )}.`; - serverConfig = applyConfigureWebpack( - configureWebpack.bind(plugin), // The plugin lifecycle may reference `this`. - serverConfig, - true, - props.siteConfig.webpack?.jsLoader, - plugin.content, - ); - } - }); + if (isLastLocale) { + logger.info`Use code=${'npm run serve'} command to test your build locally.`; + } - // 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); + if (forceTerminate && isLastLocale && !cliOptions.bundleAnalyzer) { + process.exit(0); } - // Run webpack to build JS bundle (client) and static html files (server). - await compile([clientConfig, serverConfig]); + return outDir; +} - // 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); - } - } +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'); - // Plugin Lifecycle - postBuild. + 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) { @@ -283,31 +302,79 @@ async function buildLocale({ } await plugin.postBuild({ ...props, - head: headTags, + 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, }); +} - logger.success`Generated static files in path=${path.relative( - process.cwd(), - outDir, - )}.`; +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}; +} - if (isLastLocale) { - logger.info`Use code=${'npm run serve'} command to test your build locally.`; - } +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}; +} - if (forceTerminate && isLastLocale && !cliOptions.bundleAnalyzer) { - process.exit(0); +async function ensureUnlink(filepath: string) { + if (await fs.pathExists(filepath)) { + await fs.unlink(filepath); } - - return outDir; } diff --git a/packages/docusaurus/src/commands/deploy.ts b/packages/docusaurus/src/commands/deploy.ts index 818cdb13e7ef..9904b8585ea9 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 19f4f322a8bd..f41acd1245bd 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 1e0e7bc106b0..7527df41ddb9 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,29 +85,76 @@ export async function start( logger.error(err.stack); }); }, 500); - const {siteConfig, plugins, localizationDir} = props; - const normalizeToSiteDir = (filepath: string) => { - if (filepath && path.isAbsolute(filepath)) { - return posixPath(path.relative(siteDir, filepath)); - } - return posixPath(filepath); - }; + // 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 pluginPaths = plugins - .flatMap((plugin) => plugin.getPathsToWatch?.() ?? []) - .filter(Boolean) - .map(normalizeToSiteDir); + 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); + } + }); - const pathsToWatch = [...pluginPaths, props.siteConfigPath, localizationDir]; + ['SIGINT', 'SIGTERM'].forEach((sig) => { + process.on(sig, () => { + devServer.stop(); + process.exit(); + }); + }); +} - const pollingOptions = { +function createPollingOptions({cliOptions}: {cliOptions: StartCLIOptions}) { + return { usePolling: !!cliOptions.poll, interval: Number.isInteger(cliOptions.poll) ? (cliOptions.poll as number) : undefined, }; - const httpsConfig = await getHttpsConfig(); +} + +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, @@ -119,79 +162,63 @@ export async function start( }); ['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