From 5913aaabe6ca206f7f53f5e9a7a89d6dc00c438e Mon Sep 17 00:00:00 2001 From: MEMZ-CHROER Date: Mon, 17 Aug 2026 13:31:23 +0800 Subject: [PATCH 1/2] fix(client): fallback to directory route when .html route does not exist MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When resolving a clean URL (no trailing slash) like /foo, the normalizeRoutePath converts it to /foo.html. If the page is a directory page (foo/README.md → /foo/), the /foo.html route does not exist in the routes table, causing a 404 or an infinite redirect loop when deployed behind static hosts that normalize URLs (e.g. Vercel). Fallback to the directory route /foo/ when the normalized .html route does not exist, so that clean URLs can resolve to directory pages correctly. Fix #3239 --- .../client/src/router/resolveRoutePath.ts | 14 +++++ .../tests/router/resolveRoutePath.spec.ts | 57 +++++++++++++++++++ 2 files changed, 71 insertions(+) create mode 100644 packages/client/tests/router/resolveRoutePath.spec.ts diff --git a/packages/client/src/router/resolveRoutePath.ts b/packages/client/src/router/resolveRoutePath.ts index 430f8f123f..19321b2c62 100644 --- a/packages/client/src/router/resolveRoutePath.ts +++ b/packages/client/src/router/resolveRoutePath.ts @@ -32,6 +32,20 @@ export const resolveRoutePath = ( return redirectedRoutePath } + // fallback to the directory route + // when the normalized path ends with `.html` but no matching `.html` route exists, + // try to match the directory route instead, e.g. `/foo` -> `/foo.html` -> `/foo/` + if (normalizedRoutePath.endsWith('.html')) { + const directoryRoutePath = `${normalizedRoutePath.slice( + 0, + -'.html'.length, + )}/` + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- unsafe indexed access + if (routes.value[directoryRoutePath]) { + return directoryRoutePath + } + } + // default to normalized route path return normalizedRoutePath } diff --git a/packages/client/tests/router/resolveRoutePath.spec.ts b/packages/client/tests/router/resolveRoutePath.spec.ts new file mode 100644 index 0000000000..ba2da04a78 --- /dev/null +++ b/packages/client/tests/router/resolveRoutePath.spec.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' + +import { routes } from '../../src/internal/routes.js' +import { resolveRoutePath } from '../../src/router/resolveRoutePath.js' + +// mock the generated `@internal/routes` module to provide test fixtures +vi.mock('../../src/internal/routes.js', () => ({ + routes: { + value: { + '/': { loader: vi.fn() }, + '/foo/': { loader: vi.fn() }, + '/foo/bar.html': { loader: vi.fn() }, + '/foo/index.html': { loader: vi.fn() }, + }, + }, + redirects: { + value: {}, + }, +})) + +const TEST_CASES: [string, string][] = [ + // root + ['/', '/'], + ['/index.html', '/'], + + // directory route + // no slash -> normalize to .html -> fallback to directory route + ['/foo', '/foo/'], + ['/foo/', '/foo/'], + ['/foo/index.html', '/foo/'], + ['/foo/README.md', '/foo/'], + + // file route + ['/foo/bar', '/foo/bar.html'], + ['/foo/bar.html', '/foo/bar.html'], + ['/foo/bar.md', '/foo/bar.html'], + + // 404 paths should be kept as-is (normalized) + ['/not-exist', '/not-exist.html'], + ['/foo/not-exist', '/foo/not-exist.html'], +] + +describe('resolveRoutePath', () => { + it('should resolve route paths correctly', () => { + TEST_CASES.forEach(([path, expected]) => { + expect(resolveRoutePath(path), `"${path}"`).toBe(expected) + }) + }) + + it('should fallback to directory route when normalized .html route does not exist', () => { + // `/foo` normalizes to `/foo.html` which does not exist in routes, + // but `/foo/` exists, so it should fallback to `/foo/` + expect(routes.value['/foo.html']).toBeUndefined() + expect(routes.value['/foo/']).toBeDefined() + expect(resolveRoutePath('/foo')).toBe('/foo/') + }) +}) From 3f21454bfb8277c0c9c7986cb30861512984fc30 Mon Sep 17 00:00:00 2001 From: MEMZ-CHROER Date: Mon, 17 Aug 2026 14:15:21 +0800 Subject: [PATCH 2/2] fix(client): prioritize same-named file route over directory fallback Refine the directory fallback logic to only trigger when the raw path has no extension. The same-named file route (e.g. /foo.html from foo.md) is prioritized over the directory route (/foo/), so /foo resolves to /foo.html when foo.md exists, and only falls back to /foo/ when it does not. This avoids conflating foo.md with foo/README.md. --- .../client/src/router/resolveRoutePath.ts | 6 ++-- .../tests/router/resolveRoutePath.spec.ts | 32 ++++++++++++++----- 2 files changed, 28 insertions(+), 10 deletions(-) diff --git a/packages/client/src/router/resolveRoutePath.ts b/packages/client/src/router/resolveRoutePath.ts index 19321b2c62..36f2cf81a6 100644 --- a/packages/client/src/router/resolveRoutePath.ts +++ b/packages/client/src/router/resolveRoutePath.ts @@ -33,9 +33,11 @@ export const resolveRoutePath = ( } // fallback to the directory route - // when the normalized path ends with `.html` but no matching `.html` route exists, + // when the raw path has no extension and the normalized `.html` route does not exist, // try to match the directory route instead, e.g. `/foo` -> `/foo.html` -> `/foo/` - if (normalizedRoutePath.endsWith('.html')) { + // the same-named file route is prioritized, so `/foo` will resolve to `/foo.html` + // when `foo.md` exists, and only fallback to `/foo/` when it does not + if (normalizedRoutePath.endsWith('.html') && !pathname.includes('.')) { const directoryRoutePath = `${normalizedRoutePath.slice( 0, -'.html'.length, diff --git a/packages/client/tests/router/resolveRoutePath.spec.ts b/packages/client/tests/router/resolveRoutePath.spec.ts index ba2da04a78..90ce626bc2 100644 --- a/packages/client/tests/router/resolveRoutePath.spec.ts +++ b/packages/client/tests/router/resolveRoutePath.spec.ts @@ -8,7 +8,9 @@ vi.mock('../../src/internal/routes.js', () => ({ routes: { value: { '/': { loader: vi.fn() }, + '/bar/': { loader: vi.fn() }, '/foo/': { loader: vi.fn() }, + '/foo.html': { loader: vi.fn() }, '/foo/bar.html': { loader: vi.fn() }, '/foo/index.html': { loader: vi.fn() }, }, @@ -23,13 +25,19 @@ const TEST_CASES: [string, string][] = [ ['/', '/'], ['/index.html', '/'], - // directory route - // no slash -> normalize to .html -> fallback to directory route - ['/foo', '/foo/'], + // directory route with a same-named file route + // the same-named file route has priority, so `/foo` resolves to `/foo.html` + // instead of falling back to `/foo/` + ['/foo', '/foo.html'], ['/foo/', '/foo/'], ['/foo/index.html', '/foo/'], ['/foo/README.md', '/foo/'], + // directory route without a same-named file route + // fallback to the directory route, `/bar` -> `/bar.html` -> `/bar/` + ['/bar', '/bar/'], + ['/bar/', '/bar/'], + // file route ['/foo/bar', '/foo/bar.html'], ['/foo/bar.html', '/foo/bar.html'], @@ -47,11 +55,19 @@ describe('resolveRoutePath', () => { }) }) - it('should fallback to directory route when normalized .html route does not exist', () => { - // `/foo` normalizes to `/foo.html` which does not exist in routes, - // but `/foo/` exists, so it should fallback to `/foo/` - expect(routes.value['/foo.html']).toBeUndefined() + it('should fallback to directory route when the same-named file route does not exist', () => { + // `/bar` normalizes to `/bar.html` which does not exist in routes, + // but `/bar/` exists, so it should fallback to `/bar/` + expect(routes.value['/bar.html']).toBeUndefined() + expect(routes.value['/bar/']).toBeDefined() + expect(resolveRoutePath('/bar')).toBe('/bar/') + }) + + it('should prioritize the same-named file route over the directory route', () => { + // `/foo` normalizes to `/foo.html` which exists in routes, + // so it should NOT fallback to `/foo/` even though `/foo/` exists + expect(routes.value['/foo.html']).toBeDefined() expect(routes.value['/foo/']).toBeDefined() - expect(resolveRoutePath('/foo')).toBe('/foo/') + expect(resolveRoutePath('/foo')).toBe('/foo.html') }) })