diff --git a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts index 87cf24d19386..895a4a46f608 100644 --- a/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts +++ b/packages/docusaurus/src/commands/serve/__tests__/requestHandler.test.ts @@ -136,7 +136,7 @@ describe('createServeRequestHandler', () => { /missing.html 301 /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 301 /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 @@ -144,7 +144,7 @@ describe('createServeRequestHandler', () => { /файл 200 файл.html /файл.html 301 /%D1%84%D0%B0%D0%B9%D0%BB → 200 файл.html /café 200 café.html - //file 200 file.html + //file 301 /file → 200 file.html /%E0%A4%A 400 Bad Request /..%2Fdocusaurus.config.js 400 Bad Request" `); @@ -170,11 +170,11 @@ describe('createServeRequestHandler', () => { /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 + /docs/2.x 301 /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 301 /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 301 /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 @@ -187,18 +187,18 @@ describe('createServeRequestHandler', () => { /missing 301 /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 + /file?query=1 301 /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 + /file.html?query=1 301 /file?query=1 → 301 /file/?query=1 → 200 file.html + /folder?query=1 301 /folder/?query=1 → 200 folder/index.html + /folder?v=1.0 301 /folder/?v=1.0 → 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 + /%E0%A4%A 301 /%E0%A4%A/ → 400 Bad Request /..%2Fdocusaurus.config.js 400 Bad Request" `); }); @@ -241,8 +241,8 @@ describe('createServeRequestHandler', () => { /missing/ 301 /missing → 404 404.html /missing.html 301 /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 301 /file?query=1 → 200 file.html + /file.html?query=1 301 /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 @@ -583,7 +583,6 @@ describe('createServeRequestHandler', () => { }); }); - // Without a baseUrl, serve-handler handles these requests describe('without a baseUrl', () => { it('trailingSlash undefined', async () => { const result = await describeServing( @@ -594,9 +593,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 301 /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.html → 301 /example.com/file → 404 404.html" `); }); @@ -609,9 +608,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/index 301 /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/file.html 301 /example.com/file.html → 301 /example.com/file → 301 /example.com/file/ → 404 404.html" `); }); @@ -624,9 +623,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/index 301 /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/file.html 301 /example.com/file.html → 301 /example.com/file → 404 404.html" `); }); }); @@ -649,14 +648,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('301 /file?query=1 → 200 file.html'); }); // See https://github.com/vercel/serve-handler/pull/230 @@ -695,11 +693,10 @@ describe('createServeRequestHandler', () => { ).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 () => { + 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('301 /docs/2.x/ → 200 docs/2.x/index.html'); }); }); @@ -718,7 +715,7 @@ describe('createServeRequestHandler', () => { '/assets/', '/assets/missing.js', '/missing/', - '/%E0%A4%A', + '/%E0%A4%A/', ], ); expect(result).toMatchInlineSnapshot(` @@ -732,7 +729,7 @@ describe('createServeRequestHandler', () => { /assets/ 404 no-cache + ETag /assets/missing.js 404 no-cache + ETag /missing/ 404 no-cache + ETag - /%E0%A4%A 400 no-cache" + /%E0%A4%A/ 400 no-cache" `); }); diff --git a/packages/docusaurus/src/commands/serve/requestHandler.ts b/packages/docusaurus/src/commands/serve/requestHandler.ts index 95564e993ae1..f755f08bad6c 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 {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'; @@ -101,48 +102,58 @@ async function getRedirectLocation( trailingSlash, }: {outDir: string} & Pick, ): Promise { - // 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;