From bece1ce5c94c748cad8075ee74f284150832fc06 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 17:05:25 +0100 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 233372c61678fed8ab7e857deafeb624acafb954 Mon Sep 17 00:00:00 2001 From: sebastien Date: Fri, 6 Nov 2026 18:54:53 +0100 Subject: [PATCH 5/5] Put the trailing slash comment on an explicit trailingSlash: undefined Co-Authored-By: Claude Opus 5.5 --- packages/docusaurus/src/commands/serve/requestHandler.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index ffc9d26999ee..ecca322cffb7 100644 --- a/packages/docusaurus/src/commands/serve/requestHandler.ts +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -67,6 +67,7 @@ export function createServeRequestHandler({ 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