diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 57edd8940c1b..73bc3ffc5bfc 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -727,15 +727,27 @@ describe('createServeRequestHandler', () => { ).resolves.toBe('200 file.html'); }); - // TODO fix: keep the baseUrl, "301 /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/missing%2Ehtml', - ), - ).resolves.toBe('301 /missing → 302 /base/ → 200 index.html'); + // serve-handler decodes the path before its clean URL redirect, which + // would drop the baseUrl + describe('answers 404 to encoded clean URL paths', () => { + it('with an encoded .html extension', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: undefined}, + '/base/missing%2Ehtml', + ), + ).resolves.toBe('404 404.html'); + }); + + // cspell:ignore Findex + it('with an encoded /index segment', async () => { + await expect( + serveRequest( + {baseUrl: '/base/', trailingSlash: undefined}, + '/base/folder%2Findex', + ), + ).resolves.toBe('404 404.html'); + }); }); it('keeps the query string in redirects without a baseUrl', async () => { diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index b2f6729a4ea2..92c7426a900f 100644 --- a/packages/docusaurus/src/commands/serve/requestHandler.ts +++ b/packages/docusaurus/src/commands/serve/requestHandler.ts @@ -76,8 +76,11 @@ export function createServeRequestHandler({ await serveHandler(req, res, { // cleanUrls serves /file with file.html, but also redirects /file.html - // to /file, even when the file exists - cleanUrls: !(await isHtmlFileRequest(outDir, req.url)), + // 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 @@ -194,6 +197,16 @@ function hasNullByte(url: string): boolean { return pathname.includes('%00'); } +// The decoded paths that serve-handler's cleanUrls redirects +function hasCleanUrlSuffix(url: string): boolean { + const pathname = url.split(/[?#]/)[0]!; + try { + return /(?:\.html|\/index)$/.test(decodeURIComponent(pathname)); + } catch { + return false; // Malformed percent-encoding, serve-handler answers 400 + } +} + // 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);