diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index dddf86f..4c3f752 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, 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 - [funstackStatic()](/api/funstack-static) - The `fsRoutes` plugin option diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index cf8319f..a8ecff2 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -28,6 +28,16 @@ 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"`. + * + * 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; /** * The root (HTML shell) component. Renders the whole page * (`…{children}`). @@ -61,13 +71,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 +123,7 @@ export function createFsRoutesEntries( const warn = (message: string) => { console.warn(`[funstack] ${message}`); }; - const files = modulesToRouteFiles(modules, warn); + 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 30a847d..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,27 +134,72 @@ describe("modulesToRouteFiles", () => { ]); }); - it("strips an absolute root-relative prefix", () => { - const files = modulesToRouteFiles({ - "/src/pages/page.tsx": m, - "/src/pages/about/page.tsx": m, - }); + it("keeps a subdirectory shared by every page", () => { + const files = modulesToRouteFiles( + { + "./pages/blog/page.tsx": m, + "./pages/blog/post/page.tsx": m, + }, + "./pages", + ); expect(files.map((f) => f.filePath)).toEqual([ - "page.tsx", - "about/page.tsx", + "blog/page.tsx", + "blog/post/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("keeps the directory of a single nested page", () => { + const files = modulesToRouteFiles( + { "./pages/docs/page.tsx": m }, + "./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, + }, + "/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 }, "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 }, + "./pages/", + ); + expect(files.map((f) => f.filePath)).toEqual(["docs/page.tsx"]); }); it("warns when no modules are provided", () => { const warn = vi.fn(); - expect(modulesToRouteFiles({}, warn)).toEqual([]); + expect(modulesToRouteFiles({}, "./pages", warn)).toEqual([]); expect(warn).toHaveBeenCalledTimes(1); }); + + 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/); + }); }); describe("urlPathToFilePath", () => { diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 20a17d8..78b88a3 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -1,14 +1,28 @@ import type { FsRouteFile, FsRouteModule, FsRouteTreeNode } from "./types"; +/** + * 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.). + * 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, + base: string, onWarn?: (message: string) => void, ): FsRouteFile[] { const keys = Object.keys(modules); @@ -19,21 +33,18 @@ export function modulesToRouteFiles( return []; } - // 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]!, })); } 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,`, `});`,