From fce7e4976aa2e555544c2a0fe517d73f6f9e6002 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 09:56:56 +0000 Subject: [PATCH 1/3] fix: strip the routes root deterministically in fs-routes modulesToRouteFiles inferred the routes root as the longest common directory prefix of the import.meta.glob keys. When every page lived under one shared subdirectory (e.g. only ./pages/blog/**), that subdirectory was treated as part of the routes root and stripped, shifting all routes up one level. The plugin now passes its known routes directory (globBase) through to createFsRoutesEntries as a new `base` option, which is stripped deterministically. Direct users of createFsRoutesEntries can pass `base` themselves; the common-prefix heuristic remains as a fallback when base is omitted or does not prefix every key. Closes #139 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EV5Pn6mAHVvqBZHjFKUguA --- packages/static/src/fs-routes/runtime.tsx | 18 +++++- packages/static/src/fs-routes/tree.test.ts | 71 +++++++++++++++++++++- packages/static/src/fs-routes/tree.ts | 56 +++++++++++++++-- packages/static/src/plugin/index.ts | 1 + 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index cf8319f..695223c 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -28,6 +28,18 @@ export interface CreateFsRoutesOptions { * `import.meta.glob("./pages/**\/*.{tsx,jsx}", { eager: true })`. */ modules: Record; + /** + * The routes directory that the `modules` keys are relative to — the + * directory your `import.meta.glob` pattern starts with, e.g. `"./pages"` + * or `"/src/pages"`. + * + * When provided, this prefix is stripped from every module key + * deterministically. When omitted, the longest common directory prefix + * across all keys is stripped instead; that heuristic misdetects the routes + * root when every page happens to live under one shared subdirectory, so + * passing `base` is recommended. + */ + base?: string; /** * The root (HTML shell) component. Renders the whole page * (`…{children}`). @@ -61,13 +73,13 @@ export interface CreateFsRoutesOptions { * * const modules = import.meta.glob("./pages/**\/*.{tsx,jsx}", { eager: true }); * - * export default createFsRoutesEntries({ modules, root: Root }); + * export default createFsRoutesEntries({ modules, base: "./pages", root: Root }); * ``` */ export function createFsRoutesEntries( options: CreateFsRoutesOptions, ): () => GetEntriesResult { - const { modules, root: Root, adapter = nextRoutes() } = options; + const { modules, base, root: Root, adapter = nextRoutes() } = options; function buildRouteDefinitions( nodes: FsRouteTreeNode[], @@ -113,7 +125,7 @@ export function createFsRoutesEntries( const warn = (message: string) => { console.warn(`[funstack] ${message}`); }; - const files = modulesToRouteFiles(modules, warn); + const files = modulesToRouteFiles(modules, { onWarn: warn, base }); const tree = adapter.buildRoutes(files); const pages = await collectStaticPaths(tree); for (const { urlPath, params } of pages) { diff --git a/packages/static/src/fs-routes/tree.test.ts b/packages/static/src/fs-routes/tree.test.ts index 30a847d..b585650 100644 --- a/packages/static/src/fs-routes/tree.test.ts +++ b/packages/static/src/fs-routes/tree.test.ts @@ -149,9 +149,78 @@ describe("modulesToRouteFiles", () => { it("warns when no modules are provided", () => { const warn = vi.fn(); - expect(modulesToRouteFiles({}, warn)).toEqual([]); + expect(modulesToRouteFiles({}, { onWarn: warn })).toEqual([]); expect(warn).toHaveBeenCalledTimes(1); }); + + it("with base, keeps a subdirectory shared by every page", () => { + const files = modulesToRouteFiles( + { + "./pages/blog/page.tsx": m, + "./pages/blog/post/page.tsx": m, + }, + { base: "./pages" }, + ); + expect(files.map((f) => f.filePath)).toEqual([ + "blog/page.tsx", + "blog/post/page.tsx", + ]); + }); + + it("with base, keeps the directory of a single nested page", () => { + const files = modulesToRouteFiles( + { "./pages/docs/page.tsx": m }, + { base: "./pages" }, + ); + expect(files.map((f) => f.filePath)).toEqual(["docs/page.tsx"]); + }); + + it("strips a root-relative base as emitted by the plugin", () => { + const files = modulesToRouteFiles( + { + "/src/pages/blog/page.tsx": m, + "/src/pages/blog/post/page.tsx": m, + }, + { base: "/src/pages" }, + ); + expect(files.map((f) => f.filePath)).toEqual([ + "blog/page.tsx", + "blog/post/page.tsx", + ]); + }); + + it("matches a base written without the leading ./ of the keys", () => { + const files = modulesToRouteFiles( + { "./pages/docs/page.tsx": m }, + { base: "pages" }, + ); + expect(files.map((f) => f.filePath)).toEqual(["docs/page.tsx"]); + }); + + it("ignores a trailing slash in base", () => { + const files = modulesToRouteFiles( + { "./pages/docs/page.tsx": m }, + { base: "./pages/" }, + ); + expect(files.map((f) => f.filePath)).toEqual(["docs/page.tsx"]); + }); + + it("warns and falls back to the heuristic when base does not match", () => { + const warn = vi.fn(); + const files = modulesToRouteFiles( + { + "./pages/page.tsx": m, + "./pages/about/page.tsx": m, + }, + { base: "./routes", onWarn: warn }, + ); + expect(files.map((f) => f.filePath)).toEqual([ + "page.tsx", + "about/page.tsx", + ]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]![0]).toMatch(/\.\/routes/); + }); }); describe("urlPathToFilePath", () => { diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 20a17d8..beb0eca 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -1,16 +1,47 @@ import type { FsRouteFile, FsRouteModule, FsRouteTreeNode } from "./types"; +/** + * Options for {@link modulesToRouteFiles}. + */ +export interface ModulesToRouteFilesOptions { + /** Called with a human-readable message when something looks wrong. */ + onWarn?: (message: string) => void; + /** + * The routes directory that glob keys are relative to, e.g. `"./pages"` or + * `"/src/pages"`. When provided, it is stripped from every key + * deterministically. When omitted (or when it does not prefix every key), + * the longest common directory prefix across all keys is stripped instead — + * a heuristic that misdetects the root when every page happens to live + * under one shared subdirectory. + */ + base?: string; +} + +/** + * Returns the candidate `"/"` prefixes to strip for a user-provided + * base. A base written without a leading `"./"` also matches the `"./"`-style + * keys Vite produces for relative glob patterns. + */ +function basePrefixes(base: string): string[] { + const prefix = `${base.replace(/\/+$/, "")}/`; + if (!prefix.startsWith("/") && !prefix.startsWith("./")) { + return [prefix, `./${prefix}`]; + } + return [prefix]; +} + /** * Converts the result of an eager `import.meta.glob` into route files. * - * The longest common leading directory prefix across all keys is stripped so - * that each file's path is relative to the routes directory, regardless of how - * the glob was written (`"./pages/…"`, `"/src/pages/…"`, etc.). + * Each file's path is made relative to the routes directory: the `base` + * option is stripped when provided, otherwise the longest common leading + * directory prefix across all keys is stripped as a fallback heuristic. */ export function modulesToRouteFiles( modules: Record, - onWarn?: (message: string) => void, + options: ModulesToRouteFilesOptions = {}, ): FsRouteFile[] { + const { onWarn, base } = options; const keys = Object.keys(modules); if (keys.length === 0) { onWarn?.( @@ -19,6 +50,23 @@ export function modulesToRouteFiles( return []; } + if (base !== undefined) { + const prefix = basePrefixes(base).find((candidate) => + keys.every((key) => key.startsWith(candidate)), + ); + if (prefix !== undefined) { + return keys.map((key) => ({ + filePath: key.slice(prefix.length), + module: modules[key]!, + })); + } + onWarn?.( + `base "${base}" is not a prefix of every module key; ` + + `falling back to common-prefix detection. ` + + `Pass the same directory your import.meta.glob pattern starts with.`, + ); + } + // Directory segments of each key (excluding the file name). const dirSegments = keys.map((key) => key.split("/").slice(0, -1)); let commonLength = Math.min( diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index 66e1188..d92fae5 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -338,6 +338,7 @@ export default function funstackStatic( `const modules = import.meta.glob(${JSON.stringify(globPattern)}, { eager: true });`, `export default createFsRoutesEntries({`, ` modules,`, + ` base: ${JSON.stringify(globBase)},`, ` root: Root,`, ` adapter,`, `});`, From 87423580b13d9c2dbea2b72879e7c864ff2d28fc Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 09:56:56 +0000 Subject: [PATCH 2/3] docs: recommend the base option when calling createFsRoutesEntries directly Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EV5Pn6mAHVvqBZHjFKUguA --- packages/docs/src/pages/learn/FileSystemRouting.mdx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index dddf86f..4a01457 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -173,6 +173,8 @@ For a complete working example, see the [`example-fs-routing`](https://github.co `fsRoutes` is a convenience built on the [`entries`](/api/funstack-static) option. If you need full control, you can write the entries module by hand — globbing pages with `import.meta.glob` and deriving [entry definitions](/api/entry-definition) yourself. The `@funstack/static/fs-routes` building blocks (`createFsRoutesEntries`, `nextRoutes`) are exported for this purpose. +When calling `createFsRoutesEntries` directly, also pass the routes directory as `base` — the directory your glob pattern starts with (e.g. `base: "./pages"` for `import.meta.glob("./pages/**/*.tsx")`). Without it, the routes root is inferred as the longest common directory prefix of the globbed files, which is ambiguous when every page happens to live under one shared subdirectory. + ## See Also - [funstackStatic()](/api/funstack-static) - The `fsRoutes` plugin option From 3fbc017ff3d71a94dae808a1c6a13cd260c543f2 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 10:03:59 +0000 Subject: [PATCH 3/3] feat(static)!: require the base option in createFsRoutesEntries Since base is now always known (the plugin passes its resolved routes directory, and direct users know the directory they globbed), the common-prefix heuristic is removed entirely. A base that does not prefix every globbed key now fails the build with a clear error instead of silently guessing the routes root. The fs-routes module is experimental and not covered by semantic versioning, so this breaking change lands in a minor release. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EV5Pn6mAHVvqBZHjFKUguA --- .../src/pages/learn/FileSystemRouting.mdx | 2 +- packages/static/src/fs-routes/runtime.tsx | 12 ++- packages/static/src/fs-routes/tree.test.ts | 81 +++++++------------ packages/static/src/fs-routes/tree.ts | 63 +++------------ 4 files changed, 49 insertions(+), 109 deletions(-) diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 4a01457..4c3f752 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -173,7 +173,7 @@ For a complete working example, see the [`example-fs-routing`](https://github.co `fsRoutes` is a convenience built on the [`entries`](/api/funstack-static) option. If you need full control, you can write the entries module by hand — globbing pages with `import.meta.glob` and deriving [entry definitions](/api/entry-definition) yourself. The `@funstack/static/fs-routes` building blocks (`createFsRoutesEntries`, `nextRoutes`) are exported for this purpose. -When calling `createFsRoutesEntries` directly, also pass the routes directory as `base` — the directory your glob pattern starts with (e.g. `base: "./pages"` for `import.meta.glob("./pages/**/*.tsx")`). Without it, the routes root is inferred as the longest common directory prefix of the globbed files, which is ambiguous when every page happens to live under one shared subdirectory. +When calling `createFsRoutesEntries` directly, pass the routes directory as the required `base` option — the directory your glob pattern starts with (e.g. `base: "./pages"` for `import.meta.glob("./pages/**/*.tsx")`). File paths are made relative to `base` to derive the routes, and the build fails if it does not prefix every globbed file. ## See Also diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index 695223c..a8ecff2 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -33,13 +33,11 @@ export interface CreateFsRoutesOptions { * directory your `import.meta.glob` pattern starts with, e.g. `"./pages"` * or `"/src/pages"`. * - * When provided, this prefix is stripped from every module key - * deterministically. When omitted, the longest common directory prefix - * across all keys is stripped instead; that heuristic misdetects the routes - * root when every page happens to live under one shared subdirectory, so - * passing `base` is recommended. + * This prefix is stripped from every module key to make route paths + * relative to the routes directory. The build fails if it does not prefix + * every key. */ - base?: string; + base: string; /** * The root (HTML shell) component. Renders the whole page * (`…{children}`). @@ -125,7 +123,7 @@ export function createFsRoutesEntries( const warn = (message: string) => { console.warn(`[funstack] ${message}`); }; - const files = modulesToRouteFiles(modules, { onWarn: warn, base }); + const files = modulesToRouteFiles(modules, base, warn); const tree = adapter.buildRoutes(files); const pages = await collectStaticPaths(tree); for (const { urlPath, params } of pages) { diff --git a/packages/static/src/fs-routes/tree.test.ts b/packages/static/src/fs-routes/tree.test.ts index b585650..c8079ed 100644 --- a/packages/static/src/fs-routes/tree.test.ts +++ b/packages/static/src/fs-routes/tree.test.ts @@ -118,12 +118,15 @@ describe("collectStaticPaths", () => { describe("modulesToRouteFiles", () => { const m: FsRouteModule = { default: () => null }; - it("strips a common ./pages/ prefix", () => { - const files = modulesToRouteFiles({ - "./pages/page.tsx": m, - "./pages/about/page.tsx": m, - "./pages/blog/[slug]/page.tsx": m, - }); + it("strips the base from every key", () => { + const files = modulesToRouteFiles( + { + "./pages/page.tsx": m, + "./pages/about/page.tsx": m, + "./pages/blog/[slug]/page.tsx": m, + }, + "./pages", + ); expect(files.map((f) => f.filePath)).toEqual([ "page.tsx", "about/page.tsx", @@ -131,35 +134,13 @@ describe("modulesToRouteFiles", () => { ]); }); - it("strips an absolute root-relative prefix", () => { - const files = modulesToRouteFiles({ - "/src/pages/page.tsx": m, - "/src/pages/about/page.tsx": m, - }); - expect(files.map((f) => f.filePath)).toEqual([ - "page.tsx", - "about/page.tsx", - ]); - }); - - it("handles a single file at the routes root", () => { - const files = modulesToRouteFiles({ "./pages/page.tsx": m }); - expect(files.map((f) => f.filePath)).toEqual(["page.tsx"]); - }); - - it("warns when no modules are provided", () => { - const warn = vi.fn(); - expect(modulesToRouteFiles({}, { onWarn: warn })).toEqual([]); - expect(warn).toHaveBeenCalledTimes(1); - }); - - it("with base, keeps a subdirectory shared by every page", () => { + it("keeps a subdirectory shared by every page", () => { const files = modulesToRouteFiles( { "./pages/blog/page.tsx": m, "./pages/blog/post/page.tsx": m, }, - { base: "./pages" }, + "./pages", ); expect(files.map((f) => f.filePath)).toEqual([ "blog/page.tsx", @@ -167,10 +148,10 @@ describe("modulesToRouteFiles", () => { ]); }); - it("with base, keeps the directory of a single nested page", () => { + it("keeps the directory of a single nested page", () => { const files = modulesToRouteFiles( { "./pages/docs/page.tsx": m }, - { base: "./pages" }, + "./pages", ); expect(files.map((f) => f.filePath)).toEqual(["docs/page.tsx"]); }); @@ -181,7 +162,7 @@ describe("modulesToRouteFiles", () => { "/src/pages/blog/page.tsx": m, "/src/pages/blog/post/page.tsx": m, }, - { base: "/src/pages" }, + "/src/pages", ); expect(files.map((f) => f.filePath)).toEqual([ "blog/page.tsx", @@ -190,36 +171,34 @@ describe("modulesToRouteFiles", () => { }); it("matches a base written without the leading ./ of the keys", () => { - const files = modulesToRouteFiles( - { "./pages/docs/page.tsx": m }, - { base: "pages" }, - ); + const files = modulesToRouteFiles({ "./pages/docs/page.tsx": m }, "pages"); expect(files.map((f) => f.filePath)).toEqual(["docs/page.tsx"]); }); it("ignores a trailing slash in base", () => { const files = modulesToRouteFiles( { "./pages/docs/page.tsx": m }, - { base: "./pages/" }, + "./pages/", ); expect(files.map((f) => f.filePath)).toEqual(["docs/page.tsx"]); }); - it("warns and falls back to the heuristic when base does not match", () => { + it("warns when no modules are provided", () => { const warn = vi.fn(); - const files = modulesToRouteFiles( - { - "./pages/page.tsx": m, - "./pages/about/page.tsx": m, - }, - { base: "./routes", onWarn: warn }, - ); - expect(files.map((f) => f.filePath)).toEqual([ - "page.tsx", - "about/page.tsx", - ]); + expect(modulesToRouteFiles({}, "./pages", warn)).toEqual([]); expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0]![0]).toMatch(/\.\/routes/); + }); + + it("throws when base does not prefix every key", () => { + expect(() => + modulesToRouteFiles( + { + "./pages/page.tsx": m, + "./pages/about/page.tsx": m, + }, + "./routes", + ), + ).toThrow(/"\.\/routes".*import\.meta\.glob/); }); }); diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index beb0eca..78b88a3 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -1,22 +1,5 @@ import type { FsRouteFile, FsRouteModule, FsRouteTreeNode } from "./types"; -/** - * Options for {@link modulesToRouteFiles}. - */ -export interface ModulesToRouteFilesOptions { - /** Called with a human-readable message when something looks wrong. */ - onWarn?: (message: string) => void; - /** - * The routes directory that glob keys are relative to, e.g. `"./pages"` or - * `"/src/pages"`. When provided, it is stripped from every key - * deterministically. When omitted (or when it does not prefix every key), - * the longest common directory prefix across all keys is stripped instead — - * a heuristic that misdetects the root when every page happens to live - * under one shared subdirectory. - */ - base?: string; -} - /** * Returns the candidate `"/"` prefixes to strip for a user-provided * base. A base written without a leading `"./"` also matches the `"./"`-style @@ -33,15 +16,15 @@ function basePrefixes(base: string): string[] { /** * Converts the result of an eager `import.meta.glob` into route files. * - * Each file's path is made relative to the routes directory: the `base` - * option is stripped when provided, otherwise the longest common leading - * directory prefix across all keys is stripped as a fallback heuristic. + * The routes directory `base` is stripped from every key so that each file's + * path is relative to the routes directory. Throws if `base` does not prefix + * every key, since route paths cannot be derived from a wrong base. */ export function modulesToRouteFiles( modules: Record, - options: ModulesToRouteFilesOptions = {}, + base: string, + onWarn?: (message: string) => void, ): FsRouteFile[] { - const { onWarn, base } = options; const keys = Object.keys(modules); if (keys.length === 0) { onWarn?.( @@ -50,38 +33,18 @@ export function modulesToRouteFiles( return []; } - if (base !== undefined) { - const prefix = basePrefixes(base).find((candidate) => - keys.every((key) => key.startsWith(candidate)), - ); - if (prefix !== undefined) { - return keys.map((key) => ({ - filePath: key.slice(prefix.length), - module: modules[key]!, - })); - } - onWarn?.( - `base "${base}" is not a prefix of every module key; ` + - `falling back to common-prefix detection. ` + - `Pass the same directory your import.meta.glob pattern starts with.`, - ); - } - - // Directory segments of each key (excluding the file name). - const dirSegments = keys.map((key) => key.split("/").slice(0, -1)); - let commonLength = Math.min( - ...dirSegments.map((segments) => segments.length), + const prefix = basePrefixes(base).find((candidate) => + keys.every((key) => key.startsWith(candidate)), ); - for (let i = 0; i < commonLength; i++) { - const segment = dirSegments[0]![i]; - if (!dirSegments.every((segments) => segments[i] === segment)) { - commonLength = i; - break; - } + if (prefix === undefined) { + throw new Error( + `base "${base}" is not a prefix of every globbed module key (e.g. "${keys[0]}"). ` + + `Pass the directory your import.meta.glob pattern starts with.`, + ); } return keys.map((key) => ({ - filePath: key.split("/").slice(commonLength).join("/"), + filePath: key.slice(prefix.length), module: modules[key]!, })); }