From d091df640797b6d3cee9b20f7dea1b304943daa9 Mon Sep 17 00:00:00 2001 From: sebastien Date: Thu, 12 Nov 2026 12:06:45 +0100 Subject: [PATCH] feat(core): `docusaurus serve` serves the nearest 404.html, like localized 404 pages Co-Authored-By: Claude Opus 5.5 --- .../__tests__/__fixtures__/build/fr/404.html | 1 + .../serve/__tests__/requestHandler.test.ts | 32 ++++- .../src/commands/serve/requestHandler.ts | 117 ++++++++++++------ website/docs/cli.mdx | 4 + website/docs/i18n/i18n-tutorial.mdx | 2 +- 5 files changed, 114 insertions(+), 42 deletions(-) create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/fr/404.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/fr/404.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/fr/404.html new file mode 100644 index 000000000000..56d33a6eaa18 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/build/fr/404.html @@ -0,0 +1 @@ +fr/404.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index fba669d755d6..7bfa57b100f3 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -62,6 +62,10 @@ const RequestPaths = [ '/missing', '/missing/', '/missing.html', + // A locale build, with its own 404 page + '/fr/missing', + '/fr/missing/', + '/fr/folder/missing', // Client redirect files, for redirects from a .html path // plugin-client-redirects writes a folder, or a file with trailingSlash false '/redirect-folder.html', @@ -155,6 +159,9 @@ describe('createServeRequestHandler', () => { /missing 404 404.html /missing/ 404 404.html /missing.html 301 /missing → 404 404.html + /fr/missing 404 fr/404.html + /fr/missing/ 404 fr/404.html + /fr/folder/missing 404 fr/404.html /redirect-folder.html 200 redirect-folder.html/index.html /redirect-folder.html/ 200 redirect-folder.html/index.html /redirect-file.html 200 redirect-file.html.html @@ -219,6 +226,9 @@ describe('createServeRequestHandler', () => { /missing 301 /missing/ → 404 404.html /missing/ 404 404.html /missing.html 301 /missing → 301 /missing/ → 404 404.html + /fr/missing 301 /fr/missing/ → 404 fr/404.html + /fr/missing/ 404 fr/404.html + /fr/folder/missing 301 /fr/folder/missing/ → 404 fr/404.html /redirect-folder.html 301 /redirect-folder.html/ → 200 redirect-folder.html/index.html /redirect-folder.html/ 200 redirect-folder.html/index.html /redirect-file.html 301 /redirect-file.html/ → 200 redirect-file.html.html @@ -283,6 +293,9 @@ describe('createServeRequestHandler', () => { /missing 404 404.html /missing/ 301 /missing → 404 404.html /missing.html 301 /missing → 404 404.html + /fr/missing 404 fr/404.html + /fr/missing/ 301 /fr/missing → 404 fr/404.html + /fr/folder/missing 404 fr/404.html /redirect-folder.html 200 redirect-folder.html/index.html /redirect-folder.html/ 301 /redirect-folder.html → 200 redirect-folder.html/index.html /redirect-file.html 200 redirect-file.html.html @@ -349,6 +362,9 @@ describe('createServeRequestHandler', () => { /base/missing 404 404.html /base/missing/ 404 404.html /base/missing.html 301 /base/missing → 404 404.html + /base/fr/missing 404 fr/404.html + /base/fr/missing/ 404 fr/404.html + /base/fr/folder/missing 404 fr/404.html /base/redirect-folder.html 200 redirect-folder.html/index.html /base/redirect-folder.html/ 200 redirect-folder.html/index.html /base/redirect-file.html 200 redirect-file.html.html @@ -413,6 +429,9 @@ describe('createServeRequestHandler', () => { /base/missing 301 /base/missing/ → 404 404.html /base/missing/ 404 404.html /base/missing.html 301 /base/missing → 301 /base/missing/ → 404 404.html + /base/fr/missing 301 /base/fr/missing/ → 404 fr/404.html + /base/fr/missing/ 404 fr/404.html + /base/fr/folder/missing 301 /base/fr/folder/missing/ → 404 fr/404.html /base/redirect-folder.html 301 /base/redirect-folder.html/ → 200 redirect-folder.html/index.html /base/redirect-folder.html/ 200 redirect-folder.html/index.html /base/redirect-file.html 301 /base/redirect-file.html/ → 200 redirect-file.html.html @@ -477,6 +496,9 @@ describe('createServeRequestHandler', () => { /base/missing 404 404.html /base/missing/ 301 /base/missing → 404 404.html /base/missing.html 301 /base/missing → 404 404.html + /base/fr/missing 404 fr/404.html + /base/fr/missing/ 301 /base/fr/missing → 404 fr/404.html + /base/fr/folder/missing 404 fr/404.html /base/redirect-folder.html 200 redirect-folder.html/index.html /base/redirect-folder.html/ 301 /base/redirect-folder.html → 200 redirect-folder.html/index.html /base/redirect-file.html 200 redirect-file.html.html @@ -961,9 +983,9 @@ describe('createServeRequestHandler', () => { /site.webmanifest 200 no-cache + ETag /assets/css/styles.css 200 public, max-age=31536000, immutable + ETag /assets/images/photo.jpeg 200 public, max-age=31536000, immutable + ETag - /assets/ 404 no-cache + ETag - /assets/missing.js 404 no-cache + ETag - /missing/ 404 no-cache + ETag + /assets/ 404 no-cache + /assets/missing.js 404 no-cache + /missing/ 404 no-cache /%E0%A4%A/ 400 no-cache" `); }); @@ -989,8 +1011,8 @@ describe('createServeRequestHandler', () => { /base/file 301 no-cache /base/file.html 200 no-cache + ETag /base/assets/css/styles.css 200 public, max-age=31536000, immutable + ETag - /base/assets/missing.js 404 no-cache + ETag - /base/missing/ 404 no-cache + ETag" + /base/assets/missing.js 404 no-cache + /base/missing/ 404 no-cache" `); }); diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index 24f238478802..a25786fe18a5 100644 --- a/packages/docusaurus/src/commands/serve/requestHandler.ts +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -77,42 +77,51 @@ export function createServeRequestHandler({ // serve-handler can't serve these pages: cleanUrls would redirect them req.url = (await getHtmlPathPageFileUrl(outDir, req.url)) ?? req.url; - await serveHandler(req, res, { - // cleanUrls serves /file with file.html, but also redirects /file.html - // to /file, even when the file exists, and drops the baseUrl - // We already did our clean URL redirects: only existing .html files and - // paths with an encoded ".html" or "/index" get there - // See https://github.com/meta/docusaurus/pull/12916 - cleanUrls: !hasCleanUrlSuffix(req.url), - public: outDir, - // We do the trailing slash redirects: serve-handler would decode - // encoded slashes, merge them and drop the baseUrl - trailingSlash: undefined, - directoryListing: false, - // serve-handler only answers 304 to an ETag revalidation, and the ETag - // stays the same when a rebuild doesn't change the file content - etag: true, - headers: [ - // The default value has the file name, which crashes Node.js when it - // has Latin-1 characters like "café.html" - // See https://github.com/vercel/serve-handler/issues/205 - { - source: '**', - headers: [{key: 'Content-Disposition', value: 'inline'}], - }, - // The bundler adds a content hash to the names of these files - // Overrides the Cache-Control set above, only for existing files - { - source: 'assets/**', - headers: [ - { - key: 'Cache-Control', - value: 'public, max-age=31536000, immutable', - }, - ], - }, - ], - }); + await serveHandler( + req, + res, + { + // cleanUrls serves /file with file.html, but also redirects /file.html + // to /file, even when the file exists, and drops the baseUrl + // We already did our clean URL redirects: only existing .html files and + // paths with an encoded ".html" or "/index" get there + // See https://github.com/meta/docusaurus/pull/12916 + cleanUrls: !hasCleanUrlSuffix(req.url), + public: outDir, + // We do the trailing slash redirects: serve-handler would decode + // encoded slashes, merge them and drop the baseUrl + trailingSlash: undefined, + directoryListing: false, + // serve-handler only answers 304 to an ETag revalidation, and the ETag + // stays the same when a rebuild doesn't change the file content + etag: true, + headers: [ + // The default value has the file name, which crashes Node.js when it + // has Latin-1 characters like "café.html" + // See https://github.com/vercel/serve-handler/issues/205 + { + source: '**', + headers: [{key: 'Content-Disposition', value: 'inline'}], + }, + // The bundler adds a content hash to the names of these files + // Overrides the Cache-Control set above, only for existing files + { + source: 'assets/**', + headers: [ + { + key: 'Cache-Control', + value: 'public, max-age=31536000, immutable', + }, + ], + }, + ], + }, + { + // serve-handler only calls it for 404 errors + sendError: (absolutePath) => + sendNotFoundPage(res, outDir, absolutePath!), + }, + ); }; } @@ -284,6 +293,42 @@ function getCleanPathname(pathname: string, baseUrl: string): string { return cleanPathname === baseUrl.slice(0, -1) ? baseUrl : cleanPathname; } +// Serves the nearest 404.html, like Cloudflare Pages: "/fr/missing" gets the +// 404 page of the "fr" locale build, "fr/404.html" +// See https://developers.cloudflare.com/pages/configuration/serving-pages/#not-found-behavior +async function sendNotFoundPage( + res: http.ServerResponse, + outDir: string, + absolutePath: string, +) { + const page = await readNearestNotFoundPage(outDir, absolutePath); + if (page === undefined) { + res.writeHead(404); + res.end('Not Found'); + return; + } + res.writeHead(404, {'Content-Type': 'text/html; charset=utf-8'}); + res.end(page); +} + +async function readNearestNotFoundPage( + outDir: string, + absolutePath: string, +): Promise { + const folders = path + .relative(outDir, absolutePath) + .split(path.sep) + .slice(0, -1); + for (let depth = folders.length; depth >= 0; depth -= 1) { + const pagePath = path.join(outDir, ...folders.slice(0, depth), '404.html'); + const page = await fs.readFile(pagePath).catch(() => undefined); + if (page !== undefined) { + return page; + } + } + return undefined; +} + function redirect( res: http.ServerResponse, statusCode: 301 | 302, diff --git a/website/docs/cli.mdx b/website/docs/cli.mdx index a02b6fd41e82..0d5eab324107 100644 --- a/website/docs/cli.mdx +++ b/website/docs/cli.mdx @@ -173,6 +173,10 @@ Serve your built website locally. | `--host` | `localhost` | Specify a host to use. For example, if you want your server to be accessible externally, you can use `--host 0.0.0.0`. | | `--no-open` | `false` locally, `true` in CI | Do not open a browser window to the server location. | +#### 404 pages {/* #serve-404-pages */} + +`docusaurus serve` answers unknown paths with the nearest `404.html`, looking up the folders of the path. Each locale gets its own [localized 404 page](./i18n/i18n-tutorial.mdx#single-domain-deployment): `/fr/unknown` serves `/fr/404.html`. + #### HTTP caching {/* #serve-http-caching */} `docusaurus serve` tells browsers how to cache each response with the [`Cache-Control`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control) header: diff --git a/website/docs/i18n/i18n-tutorial.mdx b/website/docs/i18n/i18n-tutorial.mdx index 15be9fab94da..ea99390041b4 100644 --- a/website/docs/i18n/i18n-tutorial.mdx +++ b/website/docs/i18n/i18n-tutorial.mdx @@ -494,7 +494,7 @@ Static hosting providers generally redirect `/unknown/url` to `/404.html` by con **Localize your 404 pages** by configuring your host to redirect `/fr/*` to `/fr/404.html`. -This is not always possible, and depends on your host: GitHub Pages can't do this, [Netlify](https://docs.netlify.com/routing/redirects/redirect-options/#custom-404-page-handling) can. +This is not always possible, and depends on your host: GitHub Pages can't do this, [Netlify](https://docs.netlify.com/routing/redirects/redirect-options/#custom-404-page-handling) can. [`docusaurus serve`](../cli.mdx#serve-404-pages) does it automatically. :::