diff --git a/packages/client/src/router/resolveRoutePath.ts b/packages/client/src/router/resolveRoutePath.ts index 430f8f123f..36f2cf81a6 100644 --- a/packages/client/src/router/resolveRoutePath.ts +++ b/packages/client/src/router/resolveRoutePath.ts @@ -32,6 +32,22 @@ export const resolveRoutePath = ( return redirectedRoutePath } + // fallback to the directory route + // 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/` + // 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, + )}/` + // 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..90ce626bc2 --- /dev/null +++ b/packages/client/tests/router/resolveRoutePath.spec.ts @@ -0,0 +1,73 @@ +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() }, + '/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() }, + }, + }, + redirects: { + value: {}, + }, +})) + +const TEST_CASES: [string, string][] = [ + // root + ['/', '/'], + ['/index.html', '/'], + + // 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'], + ['/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 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.html') + }) +})