Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 13 additions & 3 deletions packages/static/src/fs-routes/runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ export interface CreateFsRoutesOptions {
* `import.meta.glob("./pages/**\/*.{tsx,jsx}", { eager: true })`.
*/
modules: Record<string, FsRouteModule>;
/**
* 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
* (`<html>…<body>{children}</body></html>`).
Expand Down Expand Up @@ -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[],
Expand Down Expand Up @@ -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) {
Expand Down
82 changes: 65 additions & 17 deletions packages/static/src/fs-routes/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,40 +118,88 @@ 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",
"blog/[slug]/page.tsx",
]);
});

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", () => {
Expand Down
39 changes: 25 additions & 14 deletions packages/static/src/fs-routes/tree.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import type { FsRouteFile, FsRouteModule, FsRouteTreeNode } from "./types";

/**
* Returns the candidate `"<dir>/"` 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<string, FsRouteModule>,
base: string,
onWarn?: (message: string) => void,
): FsRouteFile[] {
const keys = Object.keys(modules);
Expand All @@ -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]!,
}));
}
Expand Down
1 change: 1 addition & 0 deletions packages/static/src/plugin/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,`,
`});`,
Expand Down