From f401472fb9c4d878750270e2da5910782420968a Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 30 Oct 2026 12:31:01 +0100 Subject: [PATCH 01/13] test(website): dogfood a blog post with a non-ASCII html slug (AI-assisted) Regression test for exact .html routes with encoded characters: the browser URL is /caf%C3%A9.html while the router decodes it. Co-Authored-By: Claude Opus 5.5 --- .../_blog tests/2022-10-02-html-slug-non-ascii.mdx | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 website/_dogfooding/_blog tests/2022-10-02-html-slug-non-ascii.mdx diff --git a/website/_dogfooding/_blog tests/2022-10-02-html-slug-non-ascii.mdx b/website/_dogfooding/_blog tests/2022-10-02-html-slug-non-ascii.mdx new file mode 100644 index 000000000000..7659e95ad115 --- /dev/null +++ b/website/_dogfooding/_blog tests/2022-10-02-html-slug-non-ascii.mdx @@ -0,0 +1,11 @@ +--- +title: A post with a non-ASCII html slug +unlisted: true +slug: /x/y/café.html +--- + +# Hmmm! + +This is a blog post with a non-ASCII html slug! + +Opened at `/tests/blog/x/y/café.html`, it should render only once, without hydration error. From d1d9c8491a66e36f81c9c7b78707ec289e10e6a6 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 30 Oct 2026 12:37:18 +0100 Subject: [PATCH 02/13] test(argos): screenshot pages served at .html URLs by static hosts (AI-assisted) docusaurus serve redirects /page.html URLs, so serve the HTML file the way Netlify does to reproduce the duplicate render of the app. Co-Authored-By: Claude Opus 5.5 --- argos/tests/screenshot.spec.ts | 44 ++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/argos/tests/screenshot.spec.ts b/argos/tests/screenshot.spec.ts index 95e88503a4ae..d1841be08441 100644 --- a/argos/tests/screenshot.spec.ts +++ b/argos/tests/screenshot.spec.ts @@ -11,6 +11,7 @@ import * as cheerio from 'cheerio'; import type {Page} from '@playwright/test'; const siteUrl = 'http://localhost:3000'; +const buildPath = '../website/build'; const sitemapPath = '../website/build/sitemap.xml'; const stylesheetPath = './tests/screenshot.css'; @@ -171,6 +172,45 @@ function createPathnameTest(pathname: string) { }); } +type StaticHostResponse = { + pathname: string; + file: string; + status?: number; +}; + +// docusaurus serve redirects these URLs, but static hosts like Netlify serve +// them, and React can render the page twice +// See https://github.com/facebook/docusaurus/pull/10059 +const StaticHostResponses: StaticHostResponse[] = [ + {pathname: '/tests.html', file: 'tests.html'}, + {pathname: '/tests/index.html', file: '404.html', status: 404}, + { + pathname: '/tests/blog/x/y/caf%C3%A9.html', + file: 'tests/blog/x/y/café.html', + }, +]; + +function createStaticHostResponseTest({ + pathname, + file, + status, +}: StaticHostResponse) { + test(`pathname ${pathname} served with ${file}`, async ({page}) => { + // The 404 page HTML doesn't match the page: React logs a hydration error + if (status !== 404) { + throwOnConsole(page); + } + await page.route( + (url) => url.pathname === pathname, + (route) => route.fulfill({path: `${buildPath}/${file}`, status}), + ); + await page.goto(siteUrl + pathname); + await page.waitForFunction(waitForDocusaurusHydration); + await page.addStyleTag({content: stylesheet}); + await argosScreenshot(page, `static-host/${pathnameToArgosName(pathname)}`); + }); +} + // Allow parallel execution within a single test file // See https://playwright.dev/docs/test-parallel test.describe.configure({mode: 'parallel'}); @@ -179,3 +219,7 @@ test.describe('Docusaurus site screenshots', () => { const pathnames = getPathnames(); pathnames.forEach(createPathnameTest); }); + +test.describe('Docusaurus site screenshots served by static hosts', () => { + StaticHostResponses.forEach(createStaticHostResponseTest); +}); From 8a5a0512aebef5e40aedb9cd94ac6ae5809923f3 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 30 Oct 2026 13:30:59 +0100 Subject: [PATCH 03/13] =?UTF-8?q?test(website):=20use=20a=20dogfood=20doc?= =?UTF-8?q?=20instead=20of=20a=20blog=20post=20for=20the=20caf=C3=A9=20rep?= =?UTF-8?q?ro=20(AI-assisted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blog post appeared in the sidebar of every dogfood blog page in the Argos dev build. Like dummy.html, the doc is in no sidebar. Co-Authored-By: Claude Opus 5.5 --- argos/tests/screenshot.spec.ts | 1 + .../_blog tests/2022-10-02-html-slug-non-ascii.mdx | 11 ----------- .../_dogfooding/_docs tests/html-slug-non-ascii.mdx | 7 +++++++ 3 files changed, 8 insertions(+), 11 deletions(-) delete mode 100644 website/_dogfooding/_blog tests/2022-10-02-html-slug-non-ascii.mdx create mode 100644 website/_dogfooding/_docs tests/html-slug-non-ascii.mdx diff --git a/argos/tests/screenshot.spec.ts b/argos/tests/screenshot.spec.ts index 95e88503a4ae..fcc7910a544c 100644 --- a/argos/tests/screenshot.spec.ts +++ b/argos/tests/screenshot.spec.ts @@ -60,6 +60,7 @@ function isBlacklisted(pathname: string) { // Flaky because of hydration error with docusaurus serve + .html '/tests/blog/x/y/z.html', '/tests/docs/dummy.html', + '/tests/docs/caf%C3%A9.html', // Cause weird docusaurus serve errors: '/tests/docs/tests/ascii/%C3%A6%C3%B8%C3%A5', '/tests/docs/tests/ascii/folder/%C3%A6%C3%B8%C3%A5', diff --git a/website/_dogfooding/_blog tests/2022-10-02-html-slug-non-ascii.mdx b/website/_dogfooding/_blog tests/2022-10-02-html-slug-non-ascii.mdx deleted file mode 100644 index 7659e95ad115..000000000000 --- a/website/_dogfooding/_blog tests/2022-10-02-html-slug-non-ascii.mdx +++ /dev/null @@ -1,11 +0,0 @@ ---- -title: A post with a non-ASCII html slug -unlisted: true -slug: /x/y/café.html ---- - -# Hmmm! - -This is a blog post with a non-ASCII html slug! - -Opened at `/tests/blog/x/y/café.html`, it should render only once, without hydration error. diff --git a/website/_dogfooding/_docs tests/html-slug-non-ascii.mdx b/website/_dogfooding/_docs tests/html-slug-non-ascii.mdx new file mode 100644 index 000000000000..d2fec12d7860 --- /dev/null +++ b/website/_dogfooding/_docs tests/html-slug-non-ascii.mdx @@ -0,0 +1,7 @@ +--- +slug: café.html +--- + +# Doc with a non-ASCII html slug + +Opened at `/tests/docs/café.html`, it should render without hydration error. From b1242fa39399539bb723f56bdb7e9106427ba493 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 30 Oct 2026 13:31:19 +0100 Subject: [PATCH 04/13] =?UTF-8?q?test(argos):=20screenshot=20the=20caf?= =?UTF-8?q?=C3=A9=20dogfood=20doc=20instead=20of=20the=20blog=20post=20(AI?= =?UTF-8?q?-assisted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- argos/tests/screenshot.spec.ts | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/argos/tests/screenshot.spec.ts b/argos/tests/screenshot.spec.ts index b3a8a424a092..d98d50032fb5 100644 --- a/argos/tests/screenshot.spec.ts +++ b/argos/tests/screenshot.spec.ts @@ -185,10 +185,7 @@ type StaticHostResponse = { const StaticHostResponses: StaticHostResponse[] = [ {pathname: '/tests.html', file: 'tests.html'}, {pathname: '/tests/index.html', file: '404.html', status: 404}, - { - pathname: '/tests/blog/x/y/caf%C3%A9.html', - file: 'tests/blog/x/y/café.html', - }, + {pathname: '/tests/docs/caf%C3%A9.html', file: 'tests/docs/café.html'}, ]; function createStaticHostResponseTest({ From d397459cc4afcfe5889551ead450d2379ddb51b5 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 30 Oct 2026 13:45:39 +0100 Subject: [PATCH 05/13] =?UTF-8?q?test(argos):=20move=20the=20caf=C3=A9=20r?= =?UTF-8?q?egression=20test=20out=20of=20the=20repro=20(AI-assisted)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The café doc works on main: it guards the fix PR against a regression. Co-Authored-By: Claude Opus 5.5 --- argos/tests/screenshot.spec.ts | 2 -- website/_dogfooding/_docs tests/html-slug-non-ascii.mdx | 7 ------- 2 files changed, 9 deletions(-) delete mode 100644 website/_dogfooding/_docs tests/html-slug-non-ascii.mdx diff --git a/argos/tests/screenshot.spec.ts b/argos/tests/screenshot.spec.ts index d98d50032fb5..4419f94730cc 100644 --- a/argos/tests/screenshot.spec.ts +++ b/argos/tests/screenshot.spec.ts @@ -61,7 +61,6 @@ function isBlacklisted(pathname: string) { // Flaky because of hydration error with docusaurus serve + .html '/tests/blog/x/y/z.html', '/tests/docs/dummy.html', - '/tests/docs/caf%C3%A9.html', // Cause weird docusaurus serve errors: '/tests/docs/tests/ascii/%C3%A6%C3%B8%C3%A5', '/tests/docs/tests/ascii/folder/%C3%A6%C3%B8%C3%A5', @@ -185,7 +184,6 @@ type StaticHostResponse = { const StaticHostResponses: StaticHostResponse[] = [ {pathname: '/tests.html', file: 'tests.html'}, {pathname: '/tests/index.html', file: '404.html', status: 404}, - {pathname: '/tests/docs/caf%C3%A9.html', file: 'tests/docs/café.html'}, ]; function createStaticHostResponseTest({ diff --git a/website/_dogfooding/_docs tests/html-slug-non-ascii.mdx b/website/_dogfooding/_docs tests/html-slug-non-ascii.mdx deleted file mode 100644 index d2fec12d7860..000000000000 --- a/website/_dogfooding/_docs tests/html-slug-non-ascii.mdx +++ /dev/null @@ -1,7 +0,0 @@ ---- -slug: café.html ---- - -# Doc with a non-ASCII html slug - -Opened at `/tests/docs/café.html`, it should render without hydration error. From 053c351fa445da840729c4d20ab42d79c676bea7 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 30 Oct 2026 13:46:09 +0100 Subject: [PATCH 06/13] test(argos): capture the duplicate render in aria snapshots too (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- argos/tests/screenshot.spec.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/argos/tests/screenshot.spec.ts b/argos/tests/screenshot.spec.ts index 4419f94730cc..7e0318ff1ed6 100644 --- a/argos/tests/screenshot.spec.ts +++ b/argos/tests/screenshot.spec.ts @@ -203,7 +203,13 @@ function createStaticHostResponseTest({ await page.goto(siteUrl + pathname); await page.waitForFunction(waitForDocusaurusHydration); await page.addStyleTag({content: stylesheet}); - await argosScreenshot(page, `static-host/${pathnameToArgosName(pathname)}`); + await argosScreenshot( + page, + `static-host/${pathnameToArgosName(pathname)}`, + { + ariaSnapshot: true, + }, + ); }); } From bece1ce5c94c748cad8075ee74f284150832fc06 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 17:05:25 +0100 Subject: [PATCH 07/13] fix(core): docusaurus serve adds a trailing slash to pages with a dot in their name (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- .../serve/__tests__/requestHandler.test.ts | 11 +++--- .../src/commands/serve/requestHandler.ts | 38 ++++++++++++++++--- 2 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 2bc27fd6138f..dfa7dcd33ce9 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -325,11 +325,11 @@ describe('createServeRequestHandler', () => { /base/both/ 200 both/index.html /base/both.html 302 /base/both → 302 /base/both/ → 200 both/index.html /base/both/index.html 302 /base/both/index → 302 /base/both → 302 /base/both/ → 200 both/index.html - /base/docs/2.x 200 docs/2.x/index.html + /base/docs/2.x 302 /base/docs/2.x/ → 200 docs/2.x/index.html /base/docs/2.x/ 200 docs/2.x/index.html - /base/docs/lodash.debounce 200 docs/lodash.debounce/index.html + /base/docs/lodash.debounce 302 /base/docs/lodash.debounce/ → 200 docs/lodash.debounce/index.html /base/docs/lodash.debounce/ 200 docs/lodash.debounce/index.html - /base/docs/1.x 200 docs/1.x.html + /base/docs/1.x 302 /base/docs/1.x/ → 200 docs/1.x.html /base/docs/1.x/ 200 docs/1.x.html /base/assets/css/styles.css 200 assets/css/styles.css /base/assets/css/styles.css?v=1 200 assets/css/styles.css @@ -660,14 +660,13 @@ describe('createServeRequestHandler', () => { ).resolves.toBe('200 docs/2.x/index.html'); }); - // TODO fix: add it, "302 /base/docs/2.x/ → 200 docs/2.x/index.html" - it('adds no trailing slash to pages with a dot in their name', async () => { + it('adds a trailing slash to pages with a dot in their name', async () => { await expect( serveRequest( {baseUrl: '/base/', trailingSlash: true}, '/base/docs/2.x', ), - ).resolves.toBe('200 docs/2.x/index.html'); + ).resolves.toBe('302 /base/docs/2.x/ → 200 docs/2.x/index.html'); }); // TODO fix: add it, "301 /docs/2.x/ → 200 docs/2.x/index.html" diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index 09456650858a..21fdaa299bd1 100644 --- a/packages/docusaurus/src/commands/serve/requestHandler.ts +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -7,7 +7,7 @@ import path from 'node:path'; import serveHandler from 'serve-handler'; -import {fs} from '@docusaurus/fs'; +import {fs, pathExists} from '@docusaurus/fs'; import {applyTrailingSlash} from '@docusaurus/utils-common'; import {parseURLPath, serializeURLPath} from '@docusaurus/utils'; import type http from 'node:http'; @@ -29,7 +29,8 @@ export function createServeRequestHandler({ 'baseUrl' | 'trailingSlash' >): ServeRequestHandler { return async (req, res) => { - const redirectLocation = getRedirectLocation(req.url, { + const redirectLocation = await getRedirectLocation(req.url, { + outDir, baseUrl, trailingSlash, }); @@ -85,10 +86,14 @@ async function lstatWithoutDottedFolders(filePath: string) { return stats; } -function getRedirectLocation( +async function getRedirectLocation( url: string | undefined, - {baseUrl, trailingSlash}: Pick, -): string | undefined { + { + outDir, + baseUrl, + trailingSlash, + }: {outDir: string} & Pick, +): Promise { // Automatically redirect requests to /baseUrl/ if (!url?.startsWith(baseUrl)) { return baseUrl; @@ -119,7 +124,10 @@ function getRedirectLocation( // Not super robust, but should be good enough for our use case // See https://github.com/meta/docusaurus/pull/10090 const looksLikeAsset = !!pathname.match(/\.[a-z\d]+$/i); - if (!looksLikeAsset) { + const isAsset = + looksLikeAsset && + !(await isPage(outDir, pathname.slice(baseUrl.length - 1))); + if (!isAsset) { const normalizedPathname = applyTrailingSlash(pathname, { trailingSlash, baseUrl, @@ -133,6 +141,24 @@ function getRedirectLocation( return undefined; } +// Pages with a dot in their name look like assets, like "docs/2.x" +async function isPage(outDir: string, urlPath: string): Promise { + let filePath: string; + try { + filePath = path.join(outDir, decodeURIComponent(urlPath)); + } catch { + return false; // Malformed percent-encoding + } + // Encoded slashes can decode to ".." segments: never look outside outDir + if (path.relative(outDir, filePath).startsWith('..')) { + return false; + } + return ( + (await pathExists(path.join(filePath, 'index.html'))) || + pathExists(`${filePath}.html`) + ); +} + // The serve-handler cleanUrls redirect, keeping the baseUrl function getCleanPathname(pathname: string, baseUrl: string): string { const cleanPathname = pathname.replace(/(?:\.html|\/index)$/, ''); From 018b6115bc123a1bb421b3a393a79699ce340298 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 17:07:51 +0100 Subject: [PATCH 08/13] fix(core): docusaurus serve does all the redirects, keeping the query string (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- .../serve/__tests__/requestHandler.test.ts | 147 +++++++++--------- .../src/commands/serve/requestHandler.ts | 75 +++++---- 2 files changed, 115 insertions(+), 107 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index dfa7dcd33ce9..fbc439ace77e 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -98,19 +98,19 @@ describe('createServeRequestHandler', () => { ); expect(result).toMatchInlineSnapshot(` "/ 200 index.html - /index 301 / → 200 index.html - /index.html 301 /index → 301 / → 200 index.html + /index 302 / → 200 index.html + /index.html 302 /index → 302 / → 200 index.html /file 200 file.html /file/ 200 file.html - /file.html 301 /file → 200 file.html + /file.html 302 /file → 200 file.html /folder 200 folder/index.html /folder/ 200 folder/index.html - /folder/index 301 /folder → 200 folder/index.html - /folder/index.html 301 /folder/index → 301 /folder → 200 folder/index.html + /folder/index 302 /folder → 200 folder/index.html + /folder/index.html 302 /folder/index → 302 /folder → 200 folder/index.html /both 200 both/index.html /both/ 200 both/index.html - /both.html 301 /both → 200 both/index.html - /both/index.html 301 /both/index → 301 /both → 200 both/index.html + /both.html 302 /both → 200 both/index.html + /both/index.html 302 /both/index → 302 /both → 200 both/index.html /docs/2.x 200 docs/2.x/index.html /docs/2.x/ 200 docs/2.x/index.html /docs/lodash.debounce 200 docs/lodash.debounce/index.html @@ -127,18 +127,18 @@ describe('createServeRequestHandler', () => { /site.webmanifest 200 site.webmanifest /missing 404 404.html /missing/ 404 404.html - /missing.html 301 /missing → 404 404.html + /missing.html 302 /missing → 404 404.html /file?query=1 200 file.html /file/?query=1 200 file.html - /file.html?query=1 301 /file → 200 file.html + /file.html?query=1 302 /file?query=1 → 200 file.html /folder?query=1 200 folder/index.html /folder?v=1.0 200 folder/index.html /with%20space 200 with space.html - /with%20space.html 301 /with%20space → 200 with space.html + /with%20space.html 302 /with%20space → 200 with space.html /файл 200 файл.html - /файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html + /файл.html 302 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html /café 200 café.html - //file 200 file.html + //file 302 /file → 200 file.html /%E0%A4%A 400 Bad Request /..%2Fdocusaurus.config.js 400 Bad Request" `); @@ -151,24 +151,24 @@ describe('createServeRequestHandler', () => { ); expect(result).toMatchInlineSnapshot(` "/ 200 index.html - /index 301 / → 200 index.html - /index.html 301 /index → 301 / → 200 index.html - /file 301 /file/ → 200 file.html + /index 302 / → 200 index.html + /index.html 302 /index → 302 / → 200 index.html + /file 302 /file/ → 200 file.html /file/ 200 file.html - /file.html 301 /file → 301 /file/ → 200 file.html - /folder 301 /folder/ → 200 folder/index.html + /file.html 302 /file → 302 /file/ → 200 file.html + /folder 302 /folder/ → 200 folder/index.html /folder/ 200 folder/index.html - /folder/index 301 /folder → 301 /folder/ → 200 folder/index.html - /folder/index.html 301 /folder/index → 301 /folder → 301 /folder/ → 200 folder/index.html - /both 301 /both/ → 200 both/index.html + /folder/index 302 /folder → 302 /folder/ → 200 folder/index.html + /folder/index.html 302 /folder/index → 302 /folder → 302 /folder/ → 200 folder/index.html + /both 302 /both/ → 200 both/index.html /both/ 200 both/index.html - /both.html 301 /both → 301 /both/ → 200 both/index.html - /both/index.html 301 /both/index → 301 /both → 301 /both/ → 200 both/index.html - /docs/2.x 200 docs/2.x/index.html + /both.html 302 /both → 302 /both/ → 200 both/index.html + /both/index.html 302 /both/index → 302 /both → 302 /both/ → 200 both/index.html + /docs/2.x 302 /docs/2.x/ → 200 docs/2.x/index.html /docs/2.x/ 200 docs/2.x/index.html - /docs/lodash.debounce 200 docs/lodash.debounce/index.html + /docs/lodash.debounce 302 /docs/lodash.debounce/ → 200 docs/lodash.debounce/index.html /docs/lodash.debounce/ 200 docs/lodash.debounce/index.html - /docs/1.x 200 docs/1.x.html + /docs/1.x 302 /docs/1.x/ → 200 docs/1.x.html /docs/1.x/ 200 docs/1.x.html /assets/css/styles.css 200 assets/css/styles.css /assets/css/styles.css?v=1 200 assets/css/styles.css @@ -178,21 +178,21 @@ describe('createServeRequestHandler', () => { /assets/images/photo.jpeg 200 assets/images/photo.jpeg /assets/fonts/font.woff2 200 assets/fonts/font.woff2 /site.webmanifest 200 site.webmanifest - /missing 301 /missing/ → 404 404.html + /missing 302 /missing/ → 404 404.html /missing/ 404 404.html - /missing.html 301 /missing → 301 /missing/ → 404 404.html - /file?query=1 301 /file/ → 200 file.html + /missing.html 302 /missing → 302 /missing/ → 404 404.html + /file?query=1 302 /file/?query=1 → 200 file.html /file/?query=1 200 file.html - /file.html?query=1 301 /file → 301 /file/ → 200 file.html - /folder?query=1 301 /folder/ → 200 folder/index.html - /folder?v=1.0 301 /folder/ → 200 folder/index.html - /with%20space 301 /with%20space/ → 200 with space.html - /with%20space.html 301 /with%20space → 301 /with%20space/ → 200 with space.html - /файл 301 /%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html - /файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 301 /%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html - /café 301 /caf%C3%A9/ → 200 café.html - //file 301 /file → 301 /file/ → 200 file.html - /%E0%A4%A 400 Bad Request + /file.html?query=1 302 /file?query=1 → 302 /file/?query=1 → 200 file.html + /folder?query=1 302 /folder/?query=1 → 200 folder/index.html + /folder?v=1.0 302 /folder/?v=1.0 → 200 folder/index.html + /with%20space 302 /with%20space/ → 200 with space.html + /with%20space.html 302 /with%20space → 302 /with%20space/ → 200 with space.html + /файл 302 /%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html + /файл.html 302 /%D1%84%D0%B0%D0%B9%D0%BB → 302 /%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html + /café 302 /caf%C3%A9/ → 200 café.html + //file 302 /file → 302 /file/ → 200 file.html + /%E0%A4%A 302 /%E0%A4%A/ → 400 Bad Request /..%2Fdocusaurus.config.js 400 Bad Request" `); }); @@ -204,47 +204,47 @@ describe('createServeRequestHandler', () => { ); expect(result).toMatchInlineSnapshot(` "/ 200 index.html - /index 301 / → 200 index.html - /index.html 301 /index → 301 / → 200 index.html + /index 302 / → 200 index.html + /index.html 302 /index → 302 / → 200 index.html /file 200 file.html - /file/ 301 /file → 200 file.html - /file.html 301 /file → 200 file.html + /file/ 302 /file → 200 file.html + /file.html 302 /file → 200 file.html /folder 200 folder/index.html - /folder/ 301 /folder → 200 folder/index.html - /folder/index 301 /folder → 200 folder/index.html - /folder/index.html 301 /folder/index → 301 /folder → 200 folder/index.html + /folder/ 302 /folder → 200 folder/index.html + /folder/index 302 /folder → 200 folder/index.html + /folder/index.html 302 /folder/index → 302 /folder → 200 folder/index.html /both 200 both/index.html - /both/ 301 /both → 200 both/index.html - /both.html 301 /both → 200 both/index.html - /both/index.html 301 /both/index → 301 /both → 200 both/index.html + /both/ 302 /both → 200 both/index.html + /both.html 302 /both → 200 both/index.html + /both/index.html 302 /both/index → 302 /both → 200 both/index.html /docs/2.x 200 docs/2.x/index.html - /docs/2.x/ 301 /docs/2.x → 200 docs/2.x/index.html + /docs/2.x/ 302 /docs/2.x → 200 docs/2.x/index.html /docs/lodash.debounce 200 docs/lodash.debounce/index.html - /docs/lodash.debounce/ 301 /docs/lodash.debounce → 200 docs/lodash.debounce/index.html + /docs/lodash.debounce/ 302 /docs/lodash.debounce → 200 docs/lodash.debounce/index.html /docs/1.x 200 docs/1.x.html - /docs/1.x/ 301 /docs/1.x → 200 docs/1.x.html + /docs/1.x/ 302 /docs/1.x → 200 docs/1.x.html /assets/css/styles.css 200 assets/css/styles.css /assets/css/styles.css?v=1 200 assets/css/styles.css - /assets/ 301 /assets → 404 404.html + /assets/ 302 /assets → 404 404.html /assets/files/main.c 200 assets/files/main.c /assets/files/document.pdf 200 assets/files/document.pdf /assets/images/photo.jpeg 200 assets/images/photo.jpeg /assets/fonts/font.woff2 200 assets/fonts/font.woff2 /site.webmanifest 200 site.webmanifest /missing 404 404.html - /missing/ 301 /missing → 404 404.html - /missing.html 301 /missing → 404 404.html + /missing/ 302 /missing → 404 404.html + /missing.html 302 /missing → 404 404.html /file?query=1 200 file.html - /file/?query=1 301 /file → 200 file.html - /file.html?query=1 301 /file → 200 file.html + /file/?query=1 302 /file?query=1 → 200 file.html + /file.html?query=1 302 /file?query=1 → 200 file.html /folder?query=1 200 folder/index.html /folder?v=1.0 200 folder/index.html /with%20space 200 with space.html - /with%20space.html 301 /with%20space → 200 with space.html + /with%20space.html 302 /with%20space → 200 with space.html /файл 200 файл.html - /файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html + /файл.html 302 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html /café 200 café.html - //file 301 /file → 200 file.html + //file 302 /file → 200 file.html /%E0%A4%A 400 Bad Request /..%2Fdocusaurus.config.js 400 Bad Request" `); @@ -577,7 +577,6 @@ describe('createServeRequestHandler', () => { }); }); - // Without a baseUrl, serve-handler handles these requests describe('without a baseUrl', () => { it('trailingSlash undefined', async () => { const result = await describeServing( @@ -588,9 +587,9 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/..//example.com/index 400 Bad Request /..//example.com/file.html 400 Bad Request - //example.com/index 301 /example.com → 404 404.html - //example.com/file 404 404.html - //example.com/file.html 301 /example.com/file → 404 404.html" + //example.com/index 302 /example.com/index → 302 /example.com → 404 404.html + //example.com/file 302 /example.com/file → 404 404.html + //example.com/file.html 302 /example.com/file.html → 302 /example.com/file → 404 404.html" `); }); @@ -603,9 +602,9 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/..//example.com/index 400 Bad Request /..//example.com/file.html 400 Bad Request - //example.com/index 301 /example.com → 404 404.html - //example.com/file 301 /example.com/file → 301 /example.com/file/ → 404 404.html - //example.com/file.html 301 /example.com/file → 301 /example.com/file/ → 404 404.html" + //example.com/index 302 /example.com/index → 302 /example.com → 404 404.html + //example.com/file 302 /example.com/file → 302 /example.com/file/ → 404 404.html + //example.com/file.html 302 /example.com/file.html → 302 /example.com/file → 302 /example.com/file/ → 404 404.html" `); }); @@ -618,9 +617,9 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/..//example.com/index 400 Bad Request /..//example.com/file.html 400 Bad Request - //example.com/index 301 /example.com → 404 404.html - //example.com/file 301 /example.com/file → 404 404.html - //example.com/file.html 301 /example.com/file → 404 404.html" + //example.com/index 302 /example.com/index → 302 /example.com → 404 404.html + //example.com/file 302 /example.com/file → 404 404.html + //example.com/file.html 302 /example.com/file.html → 302 /example.com/file → 404 404.html" `); }); }); @@ -643,14 +642,13 @@ describe('createServeRequestHandler', () => { ).resolves.toBe('301 /file → 302 /base/ → 200 index.html'); }); - // TODO fix: keep the query string, "301 /file?query=1 → 200 file.html" - it('drops the query string in serve-handler redirects', async () => { + it('keeps the query string in redirects without a baseUrl', async () => { await expect( serveRequest( {baseUrl: '/', trailingSlash: undefined}, '/file.html?query=1', ), - ).resolves.toBe('301 /file → 200 file.html'); + ).resolves.toBe('302 /file?query=1 → 200 file.html'); }); // See https://github.com/vercel/serve-handler/pull/230 @@ -669,11 +667,10 @@ describe('createServeRequestHandler', () => { ).resolves.toBe('302 /base/docs/2.x/ → 200 docs/2.x/index.html'); }); - // TODO fix: add it, "301 /docs/2.x/ → 200 docs/2.x/index.html" - it('adds no trailing slash to pages with a dot in their name, without a baseUrl', async () => { + it('adds a trailing slash to pages with a dot in their name, without a baseUrl', async () => { await expect( serveRequest({baseUrl: '/', trailingSlash: true}, '/docs/2.x'), - ).resolves.toBe('200 docs/2.x/index.html'); + ).resolves.toBe('302 /docs/2.x/ → 200 docs/2.x/index.html'); }); }); }); diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index 21fdaa299bd1..3339a2028fe3 100644 --- a/packages/docusaurus/src/commands/serve/requestHandler.ts +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -10,6 +10,7 @@ import serveHandler from 'serve-handler'; import {fs, pathExists} from '@docusaurus/fs'; import {applyTrailingSlash} from '@docusaurus/utils-common'; import {parseURLPath, serializeURLPath} from '@docusaurus/utils'; +import type {URLPath} from '@docusaurus/utils'; import type http from 'node:http'; import type {DocusaurusConfig} from '@docusaurus/types'; @@ -99,48 +100,58 @@ async function getRedirectLocation( return baseUrl; } - // We do the redirect ourselves for a good reason - // server-handler is annoying and won't include /baseUrl/ in redirects + // We do all the redirects ourselves for a good reason + // serve-handler redirects drop the /baseUrl/ and the query string // See https://github.com/meta/docusaurus/issues/10078#issuecomment-2084932934 - if (baseUrl !== '/') { - const {pathname, search} = parseURLPath(url); - // ".." can resolve outside of the baseUrl, even to another host: "//host" - // Like without a baseUrl, serve-handler answers 400 to these paths - if (!pathname.startsWith(baseUrl)) { - return undefined; - } + const urlPath = parseRootURLPath(url); + // ".." can resolve outside of the baseUrl + // Like serve-handler, answer 400 to these paths + if (!urlPath?.pathname.startsWith(baseUrl)) { + return undefined; + } + const {pathname, search} = urlPath; - // Merge repeated slashes, like the serve-handler redirects - const mergedPathname = pathname.replace(/\/{2,}/g, '/'); - if (pathname !== mergedPathname) { - return serializeURLPath({pathname: mergedPathname, search}); - } + // Merge repeated slashes, like the serve-handler redirects + const mergedPathname = pathname.replace(/\/{2,}/g, '/'); + if (pathname !== mergedPathname) { + return serializeURLPath({pathname: mergedPathname, search}); + } - const cleanPathname = getCleanPathname(pathname, baseUrl); - if (pathname !== cleanPathname) { - return serializeURLPath({pathname: cleanPathname, search}); - } + const cleanPathname = getCleanPathname(pathname, baseUrl); + if (pathname !== cleanPathname) { + return serializeURLPath({pathname: cleanPathname, search}); + } - // Not super robust, but should be good enough for our use case - // See https://github.com/meta/docusaurus/pull/10090 - const looksLikeAsset = !!pathname.match(/\.[a-z\d]+$/i); - const isAsset = - looksLikeAsset && - !(await isPage(outDir, pathname.slice(baseUrl.length - 1))); - if (!isAsset) { - const normalizedPathname = applyTrailingSlash(pathname, { - trailingSlash, - baseUrl, - }); - if (pathname !== normalizedPathname) { - return serializeURLPath({pathname: normalizedPathname, search}); - } + // Not super robust, but should be good enough for our use case + // See https://github.com/meta/docusaurus/pull/10090 + const looksLikeAsset = !!pathname.match(/\.[a-z\d]+$/i); + const isAsset = + looksLikeAsset && + !(await isPage(outDir, pathname.slice(baseUrl.length - 1))); + if (!isAsset) { + const normalizedPathname = applyTrailingSlash(pathname, { + trailingSlash, + baseUrl, + }); + if (pathname !== normalizedPathname) { + return serializeURLPath({pathname: normalizedPathname, search}); } } return undefined; } +// Like parseURLPath(), but undefined when ".." segments go above the root +// It also keeps "//host/path" as a path instead of parsing another host +function parseRootURLPath(url: string): URLPath | undefined { + const fakeRoot = '/__docusaurus_root__'; + const {pathname, search} = parseURLPath(`.${url}`, `${fakeRoot}/`); + if (!pathname.startsWith(`${fakeRoot}/`)) { + return undefined; + } + return {pathname: pathname.slice(fakeRoot.length), search}; +} + // Pages with a dot in their name look like assets, like "docs/2.x" async function isPage(outDir: string, urlPath: string): Promise { let filePath: string; From 57f93ed480e2cbffb9f50349b3af7f70753e1fd2 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 17:09:03 +0100 Subject: [PATCH 09/13] fix(core): docusaurus serve keeps the baseUrl with encoded slashes (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- .../serve/__tests__/requestHandler.test.ts | 16 +++++++++++++--- .../src/commands/serve/requestHandler.ts | 3 ++- 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index fbc439ace77e..b279f63b162e 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -631,14 +631,24 @@ describe('createServeRequestHandler', () => { ).resolves.toBe('302 /base/file → 200 file.html'); }); - // TODO fix: keep the baseUrl, "302 /base/file → 200 file.html" - // cspell:ignore Ffile - it('drops the baseUrl when serve-handler merges encoded slashes', async () => { + // cspell:ignore Ffile Ehtml + it('keeps the baseUrl with encoded slashes', async () => { await expect( serveRequest( {baseUrl: '/base/', trailingSlash: false}, '/base/%2F%2Ffile', ), + ).resolves.toBe('200 file.html'); + }); + + // TODO fix: keep the baseUrl, "302 /base/file → 200 file.html" + // serve-handler decodes the path before its clean URL redirect + it('drops the baseUrl in clean URL redirects of encoded paths', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: undefined}, + '/base/file%2Ehtml', + ), ).resolves.toBe('301 /file → 302 /base/ → 200 index.html'); }); diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index 3339a2028fe3..ed25fa2cbe3e 100644 --- a/packages/docusaurus/src/commands/serve/requestHandler.ts +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -55,7 +55,8 @@ export function createServeRequestHandler({ { cleanUrls: true, public: outDir, - trailingSlash, + // We do the trailing slash redirects: serve-handler would decode + // encoded slashes, merge them and drop the baseUrl directoryListing: false, // The default value has the file name, which crashes Node.js when it // has Latin-1 characters like "café.html" From e84dd30796e619b620220c81fb80b1f047a252b8 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 17:18:26 +0100 Subject: [PATCH 10/13] test(core): cover the isPage() guards of docusaurus serve (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- .../__tests__/__fixtures__/outside.x.html | 1 + .../serve/__tests__/requestHandler.test.ts | 20 +++++++++++++++++++ 2 files changed, 21 insertions(+) create mode 100644 packages/docusaurus/src/commands/serve/__tests__/__fixtures__/outside.x.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/outside.x.html b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/outside.x.html new file mode 100644 index 000000000000..76328ccd2234 --- /dev/null +++ b/packages/docusaurus/src/commands/serve/__tests__/__fixtures__/outside.x.html @@ -0,0 +1 @@ +outside.x.html diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index dfa7dcd33ce9..cba57e65b469 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -669,6 +669,26 @@ describe('createServeRequestHandler', () => { ).resolves.toBe('302 /base/docs/2.x/ → 200 docs/2.x/index.html'); }); + // __fixtures__/outside.x.html is a page outside of the build output + // cspell:ignore Foutside + it('never looks for pages outside of the build output', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: true}, + '/base/..%2Foutside.x', + ), + ).resolves.toBe('400 Bad Request'); + }); + + it('looks for no page with a malformed percent-encoding', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: true}, + '/base/%E0%A4%A.x', + ), + ).resolves.toBe('400 Bad Request'); + }); + // TODO fix: add it, "301 /docs/2.x/ → 200 docs/2.x/index.html" it('adds no trailing slash to pages with a dot in their name, without a baseUrl', async () => { await expect( From caa565e4222349a1a5baa242176d45be6886ed47 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 17:36:41 +0100 Subject: [PATCH 11/13] test(argos): screenshot /tests.html instead of faking a static host (AI-assisted) docusaurus serve now serves existing .html files, like static hosts do. Co-Authored-By: Claude Opus 5.5 --- argos/tests/screenshot.spec.ts | 55 +++++----------------------------- 1 file changed, 8 insertions(+), 47 deletions(-) diff --git a/argos/tests/screenshot.spec.ts b/argos/tests/screenshot.spec.ts index 4eee6ce19ea5..412c1df9b39a 100644 --- a/argos/tests/screenshot.spec.ts +++ b/argos/tests/screenshot.spec.ts @@ -11,7 +11,6 @@ import * as cheerio from 'cheerio'; import type {Page} from '@playwright/test'; const siteUrl = 'http://localhost:3000'; -const buildPath = '../website/build'; const sitemapPath = '../website/build/sitemap.xml'; const stylesheetPath = './tests/screenshot.css'; @@ -83,9 +82,16 @@ function isBlacklisted(pathname: string) { ); } +// Static hosts like Netlify also serve pages at their .html URL +// See https://github.com/meta/docusaurus/pull/10059 +const ExtraPathnames = ['/tests.html']; + function getPathnames(): string[] { const urls = extractSitemapUrls(); - const pathnamesUnfiltered = urls.map((url) => new URL(url).pathname); + const pathnamesUnfiltered = [ + ...urls.map((url) => new URL(url).pathname), + ...ExtraPathnames, + ]; const pathnames = pathnamesUnfiltered.filter( (pathname) => !isBlacklisted(pathname), ); @@ -172,47 +178,6 @@ function createPathnameTest(pathname: string) { }); } -type StaticHostResponse = { - pathname: string; - file: string; - status?: number; -}; - -// docusaurus serve redirects these URLs, but static hosts like Netlify serve -// them, and React can render the page twice -// See https://github.com/facebook/docusaurus/pull/10059 -const StaticHostResponses: StaticHostResponse[] = [ - {pathname: '/tests.html', file: 'tests.html'}, - {pathname: '/tests/index.html', file: '404.html', status: 404}, -]; - -function createStaticHostResponseTest({ - pathname, - file, - status, -}: StaticHostResponse) { - test(`pathname ${pathname} served with ${file}`, async ({page}) => { - // The 404 page HTML doesn't match the page: React logs a hydration error - if (status !== 404) { - throwOnConsole(page); - } - await page.route( - (url) => url.pathname === pathname, - (route) => route.fulfill({path: `${buildPath}/${file}`, status}), - ); - await page.goto(siteUrl + pathname); - await page.waitForFunction(waitForDocusaurusHydration); - await page.addStyleTag({content: stylesheet}); - await argosScreenshot( - page, - `static-host/${pathnameToArgosName(pathname)}`, - { - ariaSnapshot: true, - }, - ); - }); -} - // Allow parallel execution within a single test file // See https://playwright.dev/docs/test-parallel test.describe.configure({mode: 'parallel'}); @@ -221,7 +186,3 @@ test.describe('Docusaurus site screenshots', () => { const pathnames = getPathnames(); pathnames.forEach(createPathnameTest); }); - -test.describe('Docusaurus site screenshots served by static hosts', () => { - StaticHostResponses.forEach(createStaticHostResponseTest); -}); From 720485995a2bdcd46f4dab13b6a26909aca9bfd0 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 17:41:16 +0100 Subject: [PATCH 12/13] fix(core): `docusaurus serve` serves existing .html files as-is (AI-assisted) Like static hosts such as Netlify and GitHub Pages, `docusaurus serve` no longer redirects /file.html to /file when file.html exists. Routes that end with .html now work, so 2 dogfood pages leave the Argos blacklist. Co-Authored-By: Claude Opus 5.5 --- argos/tests/screenshot.spec.ts | 3 - .../serve/__tests__/requestHandler.test.ts | 143 ++++++++++-------- .../src/commands/serve/requestHandler.ts | 49 ++++-- 3 files changed, 114 insertions(+), 81 deletions(-) diff --git a/argos/tests/screenshot.spec.ts b/argos/tests/screenshot.spec.ts index 97c31dca84a3..1940ee7e5da8 100644 --- a/argos/tests/screenshot.spec.ts +++ b/argos/tests/screenshot.spec.ts @@ -57,9 +57,6 @@ function isBlacklisted(pathname: string) { '/tests/blog/archive', '/tests/pages/code-block-tests', '/tests/pages/embeds', - // Flaky because of hydration error with docusaurus serve + .html - '/tests/blog/x/y/z.html', - '/tests/docs/dummy.html', // Cause weird docusaurus serve errors: '/tests/docs/tests/ascii/%C3%A6%C3%B8%C3%A5', '/tests/docs/tests/ascii/folder/%C3%A6%C3%B8%C3%A5', diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index c44cb19ded0f..d827205b2393 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -99,18 +99,18 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/ 200 index.html /index 302 / → 200 index.html - /index.html 302 /index → 302 / → 200 index.html + /index.html 200 index.html /file 200 file.html /file/ 200 file.html - /file.html 302 /file → 200 file.html + /file.html 200 file.html /folder 200 folder/index.html /folder/ 200 folder/index.html /folder/index 302 /folder → 200 folder/index.html - /folder/index.html 302 /folder/index → 302 /folder → 200 folder/index.html + /folder/index.html 200 folder/index.html /both 200 both/index.html /both/ 200 both/index.html - /both.html 302 /both → 200 both/index.html - /both/index.html 302 /both/index → 302 /both → 200 both/index.html + /both.html 200 both.html + /both/index.html 200 both/index.html /docs/2.x 200 docs/2.x/index.html /docs/2.x/ 200 docs/2.x/index.html /docs/lodash.debounce 200 docs/lodash.debounce/index.html @@ -130,13 +130,13 @@ describe('createServeRequestHandler', () => { /missing.html 302 /missing → 404 404.html /file?query=1 200 file.html /file/?query=1 200 file.html - /file.html?query=1 302 /file?query=1 → 200 file.html + /file.html?query=1 200 file.html /folder?query=1 200 folder/index.html /folder?v=1.0 200 folder/index.html /with%20space 200 with space.html - /with%20space.html 302 /with%20space → 200 with space.html + /with%20space.html 200 with space.html /файл 200 файл.html - /файл.html 302 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html + /файл.html 200 файл.html /café 200 café.html //file 302 /file → 200 file.html /%E0%A4%A 400 Bad Request @@ -152,18 +152,18 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/ 200 index.html /index 302 / → 200 index.html - /index.html 302 /index → 302 / → 200 index.html + /index.html 200 index.html /file 302 /file/ → 200 file.html /file/ 200 file.html - /file.html 302 /file → 302 /file/ → 200 file.html + /file.html 200 file.html /folder 302 /folder/ → 200 folder/index.html /folder/ 200 folder/index.html /folder/index 302 /folder → 302 /folder/ → 200 folder/index.html - /folder/index.html 302 /folder/index → 302 /folder → 302 /folder/ → 200 folder/index.html + /folder/index.html 200 folder/index.html /both 302 /both/ → 200 both/index.html /both/ 200 both/index.html - /both.html 302 /both → 302 /both/ → 200 both/index.html - /both/index.html 302 /both/index → 302 /both → 302 /both/ → 200 both/index.html + /both.html 200 both.html + /both/index.html 200 both/index.html /docs/2.x 302 /docs/2.x/ → 200 docs/2.x/index.html /docs/2.x/ 200 docs/2.x/index.html /docs/lodash.debounce 302 /docs/lodash.debounce/ → 200 docs/lodash.debounce/index.html @@ -183,13 +183,13 @@ describe('createServeRequestHandler', () => { /missing.html 302 /missing → 302 /missing/ → 404 404.html /file?query=1 302 /file/?query=1 → 200 file.html /file/?query=1 200 file.html - /file.html?query=1 302 /file?query=1 → 302 /file/?query=1 → 200 file.html + /file.html?query=1 200 file.html /folder?query=1 302 /folder/?query=1 → 200 folder/index.html /folder?v=1.0 302 /folder/?v=1.0 → 200 folder/index.html /with%20space 302 /with%20space/ → 200 with space.html - /with%20space.html 302 /with%20space → 302 /with%20space/ → 200 with space.html + /with%20space.html 200 with space.html /файл 302 /%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html - /файл.html 302 /%D1%84%D0%B0%D0%B9%D0%BB → 302 /%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html + /файл.html 200 файл.html /café 302 /caf%C3%A9/ → 200 café.html //file 302 /file → 302 /file/ → 200 file.html /%E0%A4%A 302 /%E0%A4%A/ → 400 Bad Request @@ -205,18 +205,18 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/ 200 index.html /index 302 / → 200 index.html - /index.html 302 /index → 302 / → 200 index.html + /index.html 200 index.html /file 200 file.html /file/ 302 /file → 200 file.html - /file.html 302 /file → 200 file.html + /file.html 200 file.html /folder 200 folder/index.html /folder/ 302 /folder → 200 folder/index.html /folder/index 302 /folder → 200 folder/index.html - /folder/index.html 302 /folder/index → 302 /folder → 200 folder/index.html + /folder/index.html 200 folder/index.html /both 200 both/index.html /both/ 302 /both → 200 both/index.html - /both.html 302 /both → 200 both/index.html - /both/index.html 302 /both/index → 302 /both → 200 both/index.html + /both.html 200 both.html + /both/index.html 200 both/index.html /docs/2.x 200 docs/2.x/index.html /docs/2.x/ 302 /docs/2.x → 200 docs/2.x/index.html /docs/lodash.debounce 200 docs/lodash.debounce/index.html @@ -236,13 +236,13 @@ describe('createServeRequestHandler', () => { /missing.html 302 /missing → 404 404.html /file?query=1 200 file.html /file/?query=1 302 /file?query=1 → 200 file.html - /file.html?query=1 302 /file?query=1 → 200 file.html + /file.html?query=1 200 file.html /folder?query=1 200 folder/index.html /folder?v=1.0 200 folder/index.html /with%20space 200 with space.html - /with%20space.html 302 /with%20space → 200 with space.html + /with%20space.html 200 with space.html /файл 200 файл.html - /файл.html 302 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html + /файл.html 200 файл.html /café 200 café.html //file 302 /file → 200 file.html /%E0%A4%A 400 Bad Request @@ -260,18 +260,18 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/base/ 200 index.html /base/index 302 /base/ → 200 index.html - /base/index.html 302 /base/index → 302 /base/ → 200 index.html + /base/index.html 200 index.html /base/file 200 file.html /base/file/ 200 file.html - /base/file.html 302 /base/file → 200 file.html + /base/file.html 200 file.html /base/folder 200 folder/index.html /base/folder/ 200 folder/index.html /base/folder/index 302 /base/folder → 200 folder/index.html - /base/folder/index.html 302 /base/folder/index → 302 /base/folder → 200 folder/index.html + /base/folder/index.html 200 folder/index.html /base/both 200 both/index.html /base/both/ 200 both/index.html - /base/both.html 302 /base/both → 200 both/index.html - /base/both/index.html 302 /base/both/index → 302 /base/both → 200 both/index.html + /base/both.html 200 both.html + /base/both/index.html 200 both/index.html /base/docs/2.x 200 docs/2.x/index.html /base/docs/2.x/ 200 docs/2.x/index.html /base/docs/lodash.debounce 200 docs/lodash.debounce/index.html @@ -291,13 +291,13 @@ describe('createServeRequestHandler', () => { /base/missing.html 302 /base/missing → 404 404.html /base/file?query=1 200 file.html /base/file/?query=1 200 file.html - /base/file.html?query=1 302 /base/file?query=1 → 200 file.html + /base/file.html?query=1 200 file.html /base/folder?query=1 200 folder/index.html /base/folder?v=1.0 200 folder/index.html /base/with%20space 200 with space.html - /base/with%20space.html 302 /base/with%20space → 200 with space.html + /base/with%20space.html 200 with space.html /base/файл 200 файл.html - /base/файл.html 302 /base/%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html + /base/файл.html 200 файл.html /base/café 200 café.html /base//file 302 /base/file → 200 file.html /base/%E0%A4%A 400 Bad Request @@ -313,18 +313,18 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/base/ 200 index.html /base/index 302 /base/ → 200 index.html - /base/index.html 302 /base/index → 302 /base/ → 200 index.html + /base/index.html 200 index.html /base/file 302 /base/file/ → 200 file.html /base/file/ 200 file.html - /base/file.html 302 /base/file → 302 /base/file/ → 200 file.html + /base/file.html 200 file.html /base/folder 302 /base/folder/ → 200 folder/index.html /base/folder/ 200 folder/index.html /base/folder/index 302 /base/folder → 302 /base/folder/ → 200 folder/index.html - /base/folder/index.html 302 /base/folder/index → 302 /base/folder → 302 /base/folder/ → 200 folder/index.html + /base/folder/index.html 200 folder/index.html /base/both 302 /base/both/ → 200 both/index.html /base/both/ 200 both/index.html - /base/both.html 302 /base/both → 302 /base/both/ → 200 both/index.html - /base/both/index.html 302 /base/both/index → 302 /base/both → 302 /base/both/ → 200 both/index.html + /base/both.html 200 both.html + /base/both/index.html 200 both/index.html /base/docs/2.x 302 /base/docs/2.x/ → 200 docs/2.x/index.html /base/docs/2.x/ 200 docs/2.x/index.html /base/docs/lodash.debounce 302 /base/docs/lodash.debounce/ → 200 docs/lodash.debounce/index.html @@ -344,13 +344,13 @@ describe('createServeRequestHandler', () => { /base/missing.html 302 /base/missing → 302 /base/missing/ → 404 404.html /base/file?query=1 302 /base/file/?query=1 → 200 file.html /base/file/?query=1 200 file.html - /base/file.html?query=1 302 /base/file?query=1 → 302 /base/file/?query=1 → 200 file.html + /base/file.html?query=1 200 file.html /base/folder?query=1 302 /base/folder/?query=1 → 200 folder/index.html /base/folder?v=1.0 302 /base/folder/?v=1.0 → 200 folder/index.html /base/with%20space 302 /base/with%20space/ → 200 with space.html - /base/with%20space.html 302 /base/with%20space → 302 /base/with%20space/ → 200 with space.html + /base/with%20space.html 200 with space.html /base/файл 302 /base/%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html - /base/файл.html 302 /base/%D1%84%D0%B0%D0%B9%D0%BB → 302 /base/%D1%84%D0%B0%D0%B9%D0%BB/ → 200 файл.html + /base/файл.html 200 файл.html /base/café 302 /base/caf%C3%A9/ → 200 café.html /base//file 302 /base/file → 302 /base/file/ → 200 file.html /base/%E0%A4%A 302 /base/%E0%A4%A/ → 400 Bad Request @@ -366,18 +366,18 @@ describe('createServeRequestHandler', () => { expect(result).toMatchInlineSnapshot(` "/base/ 200 index.html /base/index 302 /base/ → 200 index.html - /base/index.html 302 /base/index → 302 /base/ → 200 index.html + /base/index.html 200 index.html /base/file 200 file.html /base/file/ 302 /base/file → 200 file.html - /base/file.html 302 /base/file → 200 file.html + /base/file.html 200 file.html /base/folder 200 folder/index.html /base/folder/ 302 /base/folder → 200 folder/index.html /base/folder/index 302 /base/folder → 200 folder/index.html - /base/folder/index.html 302 /base/folder/index → 302 /base/folder → 200 folder/index.html + /base/folder/index.html 200 folder/index.html /base/both 200 both/index.html /base/both/ 302 /base/both → 200 both/index.html - /base/both.html 302 /base/both → 200 both/index.html - /base/both/index.html 302 /base/both/index → 302 /base/both → 200 both/index.html + /base/both.html 200 both.html + /base/both/index.html 200 both/index.html /base/docs/2.x 200 docs/2.x/index.html /base/docs/2.x/ 302 /base/docs/2.x → 200 docs/2.x/index.html /base/docs/lodash.debounce 200 docs/lodash.debounce/index.html @@ -397,13 +397,13 @@ describe('createServeRequestHandler', () => { /base/missing.html 302 /base/missing → 404 404.html /base/file?query=1 200 file.html /base/file/?query=1 302 /base/file?query=1 → 200 file.html - /base/file.html?query=1 302 /base/file?query=1 → 200 file.html + /base/file.html?query=1 200 file.html /base/folder?query=1 200 folder/index.html /base/folder?v=1.0 200 folder/index.html /base/with%20space 200 with space.html - /base/with%20space.html 302 /base/with%20space → 200 with space.html + /base/with%20space.html 200 with space.html /base/файл 200 файл.html - /base/файл.html 302 /base/%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html + /base/файл.html 200 файл.html /base/café 200 café.html /base//file 302 /base/file → 200 file.html /base/%E0%A4%A 400 Bad Request @@ -433,13 +433,13 @@ describe('createServeRequestHandler', () => { {asIs: true}, ); expect(result).toMatchInlineSnapshot(` - "/base/file.html#hash 302 /base/file → 200 file.html - /base/file.html?query=1#hash 302 /base/file?query=1 → 200 file.html + "/base/file.html#hash 200 file.html + /base/file.html?query=1#hash 200 file.html /base/file#hash.html 200 file.html /base/folder/index#hash 302 /base/folder → 200 folder/index.html /base/assets/css/styles.css#hash 200 assets/css/styles.css - /base/./file.html 302 /base/file → 200 file.html - /base/folder/../file.html 302 /base/file → 200 file.html + /base/./file.html 200 file.html + /base/folder/../file.html 200 file.html /base/../file.html 400 Bad Request /base/%2E%2E/file.html 400 Bad Request /base/..//example.com/index 400 Bad Request @@ -454,13 +454,13 @@ describe('createServeRequestHandler', () => { {asIs: true}, ); expect(result).toMatchInlineSnapshot(` - "/base/file.html#hash 302 /base/file → 302 /base/file/ → 200 file.html - /base/file.html?query=1#hash 302 /base/file?query=1 → 302 /base/file/?query=1 → 200 file.html + "/base/file.html#hash 200 file.html + /base/file.html?query=1#hash 200 file.html /base/file#hash.html 302 /base/file/ → 200 file.html /base/folder/index#hash 302 /base/folder → 302 /base/folder/ → 200 folder/index.html /base/assets/css/styles.css#hash 200 assets/css/styles.css - /base/./file.html 302 /base/file → 302 /base/file/ → 200 file.html - /base/folder/../file.html 302 /base/file → 302 /base/file/ → 200 file.html + /base/./file.html 200 file.html + /base/folder/../file.html 200 file.html /base/../file.html 400 Bad Request /base/%2E%2E/file.html 400 Bad Request /base/..//example.com/index 400 Bad Request @@ -475,13 +475,13 @@ describe('createServeRequestHandler', () => { {asIs: true}, ); expect(result).toMatchInlineSnapshot(` - "/base/file.html#hash 302 /base/file → 200 file.html - /base/file.html?query=1#hash 302 /base/file?query=1 → 200 file.html + "/base/file.html#hash 200 file.html + /base/file.html?query=1#hash 200 file.html /base/file#hash.html 200 file.html /base/folder/index#hash 302 /base/folder → 200 folder/index.html /base/assets/css/styles.css#hash 200 assets/css/styles.css - /base/./file.html 302 /base/file → 200 file.html - /base/folder/../file.html 302 /base/file → 200 file.html + /base/./file.html 200 file.html + /base/folder/../file.html 200 file.html /base/../file.html 400 Bad Request /base/%2E%2E/file.html 400 Bad Request /base/..//example.com/index 400 Bad Request @@ -505,9 +505,9 @@ describe('createServeRequestHandler', () => { await expect( serveRequest( {baseUrl: '/base/', trailingSlash: undefined}, - '/base/file.html', + '/base/folder/index', ), - ).resolves.toBe('302 /base/file → 200 file.html'); + ).resolves.toBe('302 /base/folder → 200 folder/index.html'); }); it('redirects clean URLs containing $ characters', async () => { @@ -641,24 +641,33 @@ describe('createServeRequestHandler', () => { ).resolves.toBe('200 file.html'); }); - // TODO fix: keep the baseUrl, "302 /base/file → 200 file.html" + it('serves existing .html files with encoded paths', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: undefined}, + '/base/file%2Ehtml', + ), + ).resolves.toBe('200 file.html'); + }); + + // TODO fix: keep the baseUrl, "302 /base/missing → 404 404.html" // serve-handler decodes the path before its clean URL redirect it('drops the baseUrl in clean URL redirects of encoded paths', async () => { await expect( serveRequest( {baseUrl: '/base/', trailingSlash: undefined}, - '/base/file%2Ehtml', + '/base/missing%2Ehtml', ), - ).resolves.toBe('301 /file → 302 /base/ → 200 index.html'); + ).resolves.toBe('301 /missing → 302 /base/ → 200 index.html'); }); it('keeps the query string in redirects without a baseUrl', async () => { await expect( serveRequest( {baseUrl: '/', trailingSlash: undefined}, - '/file.html?query=1', + '/folder/index?query=1', ), - ).resolves.toBe('302 /file?query=1 → 200 file.html'); + ).resolves.toBe('302 /folder?query=1 → 200 folder/index.html'); }); // See https://github.com/vercel/serve-handler/pull/230 diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index ed25fa2cbe3e..df0085774bb8 100644 --- a/packages/docusaurus/src/commands/serve/requestHandler.ts +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -53,7 +53,9 @@ export function createServeRequestHandler({ req, res, { - cleanUrls: true, + // cleanUrls serves /file with file.html, but also redirects /file.html + // to /file, even when the file exists + cleanUrls: !(await isHtmlFileRequest(outDir, req.url)), public: outDir, // We do the trailing slash redirects: serve-handler would decode // encoded slashes, merge them and drop the baseUrl @@ -118,17 +120,20 @@ async function getRedirectLocation( return serializeURLPath({pathname: mergedPathname, search}); } + const outDirPathname = pathname.slice(baseUrl.length - 1); + const cleanPathname = getCleanPathname(pathname, baseUrl); - if (pathname !== cleanPathname) { + if ( + pathname !== cleanPathname && + !(await isHtmlFile(outDir, outDirPathname)) + ) { return serializeURLPath({pathname: cleanPathname, search}); } // Not super robust, but should be good enough for our use case // See https://github.com/meta/docusaurus/pull/10090 const looksLikeAsset = !!pathname.match(/\.[a-z\d]+$/i); - const isAsset = - looksLikeAsset && - !(await isPage(outDir, pathname.slice(baseUrl.length - 1))); + const isAsset = looksLikeAsset && !(await isPage(outDir, outDirPathname)); if (!isAsset) { const normalizedPathname = applyTrailingSlash(pathname, { trailingSlash, @@ -155,20 +160,42 @@ function parseRootURLPath(url: string): URLPath | undefined { // Pages with a dot in their name look like assets, like "docs/2.x" async function isPage(outDir: string, urlPath: string): Promise { + const filePath = getFilePath(outDir, urlPath); + if (filePath === undefined) { + return false; + } + return ( + (await pathExists(path.join(filePath, 'index.html'))) || + pathExists(`${filePath}.html`) + ); +} + +// Static hosts serve the existing .html files, without clean URL redirects +async function isHtmlFile(outDir: string, urlPath: string): Promise { + const filePath = getFilePath(outDir, urlPath); + return !!filePath?.endsWith('.html') && pathExists(filePath); +} + +async function isHtmlFileRequest( + outDir: string, + url: string, +): Promise { + const urlPath = parseRootURLPath(url); + return urlPath !== undefined && isHtmlFile(outDir, urlPath.pathname); +} + +function getFilePath(outDir: string, urlPath: string): string | undefined { let filePath: string; try { filePath = path.join(outDir, decodeURIComponent(urlPath)); } catch { - return false; // Malformed percent-encoding + return undefined; // Malformed percent-encoding } // Encoded slashes can decode to ".." segments: never look outside outDir if (path.relative(outDir, filePath).startsWith('..')) { - return false; + return undefined; } - return ( - (await pathExists(path.join(filePath, 'index.html'))) || - pathExists(`${filePath}.html`) - ); + return filePath; } // The serve-handler cleanUrls redirect, keeping the baseUrl From 09dd3a0fcb08bb30f5e0adea8c2a8c4a447c057e Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 17:53:06 +0100 Subject: [PATCH 13/13] test(core): capture that missing .html files keep the query string in redirects (AI-assisted) Co-Authored-By: Claude Opus 5.5 --- .../src/commands/serve/__tests__/requestHandler.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index d827205b2393..49840f0a62a6 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -55,6 +55,7 @@ const RequestPaths = [ '/file?query=1', '/file/?query=1', '/file.html?query=1', + '/missing.html?query=1', '/folder?query=1', '/folder?v=1.0', // Looks like an extension @@ -131,6 +132,7 @@ describe('createServeRequestHandler', () => { /file?query=1 200 file.html /file/?query=1 200 file.html /file.html?query=1 200 file.html + /missing.html?query=1 302 /missing?query=1 → 404 404.html /folder?query=1 200 folder/index.html /folder?v=1.0 200 folder/index.html /with%20space 200 with space.html @@ -184,6 +186,7 @@ describe('createServeRequestHandler', () => { /file?query=1 302 /file/?query=1 → 200 file.html /file/?query=1 200 file.html /file.html?query=1 200 file.html + /missing.html?query=1 302 /missing?query=1 → 302 /missing/?query=1 → 404 404.html /folder?query=1 302 /folder/?query=1 → 200 folder/index.html /folder?v=1.0 302 /folder/?v=1.0 → 200 folder/index.html /with%20space 302 /with%20space/ → 200 with space.html @@ -237,6 +240,7 @@ describe('createServeRequestHandler', () => { /file?query=1 200 file.html /file/?query=1 302 /file?query=1 → 200 file.html /file.html?query=1 200 file.html + /missing.html?query=1 302 /missing?query=1 → 404 404.html /folder?query=1 200 folder/index.html /folder?v=1.0 200 folder/index.html /with%20space 200 with space.html @@ -292,6 +296,7 @@ describe('createServeRequestHandler', () => { /base/file?query=1 200 file.html /base/file/?query=1 200 file.html /base/file.html?query=1 200 file.html + /base/missing.html?query=1 302 /base/missing?query=1 → 404 404.html /base/folder?query=1 200 folder/index.html /base/folder?v=1.0 200 folder/index.html /base/with%20space 200 with space.html @@ -345,6 +350,7 @@ describe('createServeRequestHandler', () => { /base/file?query=1 302 /base/file/?query=1 → 200 file.html /base/file/?query=1 200 file.html /base/file.html?query=1 200 file.html + /base/missing.html?query=1 302 /base/missing?query=1 → 302 /base/missing/?query=1 → 404 404.html /base/folder?query=1 302 /base/folder/?query=1 → 200 folder/index.html /base/folder?v=1.0 302 /base/folder/?v=1.0 → 200 folder/index.html /base/with%20space 302 /base/with%20space/ → 200 with space.html @@ -398,6 +404,7 @@ describe('createServeRequestHandler', () => { /base/file?query=1 200 file.html /base/file/?query=1 302 /base/file?query=1 → 200 file.html /base/file.html?query=1 200 file.html + /base/missing.html?query=1 302 /base/missing?query=1 → 404 404.html /base/folder?query=1 200 folder/index.html /base/folder?v=1.0 200 folder/index.html /base/with%20space 200 with space.html