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
4 changes: 4 additions & 0 deletions packages/docs/src/pages/learn/FileSystemRouting.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ The adapter fails the build with a clear error instead of producing broken route
- **Parallel route slots** (`@slot`) and **intercepting routes** (`(.)segment`) — these Next.js features are not supported.
- **Conflicting pages** — two pages resolving to the same route, such as `(a)/foo/page.tsx` + `(b)/foo/page.tsx`, or sibling dynamic pages with different param names (`[a]` + `[b]`).
- **Duplicate files** — two page (or layout) files in the same directory, such as `page.tsx` next to `page.jsx`.
- **Segment names routes cannot match** — param names may only contain letters, digits, `_`, and `$` (so `[foo-bar]` is rejected); the same param name may appear only once on a route path; and static directory names must not contain URL-pattern characters (`:`, `*`, `?`, `+`, parentheses, braces, or backslash).
- **Route modules without a default export** — a `page.tsx` or `layout.tsx` that does not `export default` a component would silently render an empty page.

Multiple root layouts via route groups (e.g. `(marketing)/layout.tsx` and `(shop)/layout.tsx`) are supported.

Expand Down Expand Up @@ -122,6 +124,8 @@ This generates `blog/hello.html` and `blog/world.html`. Each page component rece

A dynamic route **must** export `generateStaticParams`; the build fails otherwise. A static site can only serve pages that were enumerated at build time, so a dynamic route without it would produce no output.

Param values must be non-empty strings that stay within their URL segment: a regular param value must not contain `/`, and no value may contain `.` or `..` segments, `?`, or `#`. A catch-all param value may contain `/` to span multiple segments, but not leading, trailing, or repeated slashes. The build fails on any other value, since it would generate a page that its own route can never match (or a file outside the output directory).

Because `generateStaticParams` runs on the server at build time, a page module that exports it cannot be marked `"use client"`. If the page body needs to be a Client Component, move it into a separate `"use client"` module and re-export it from the page:

```tsx
Expand Down
41 changes: 41 additions & 0 deletions packages/static/src/fs-routes/nextAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,47 @@ describe("nextRoutes adapter", () => {
).toThrow(/Intercepting routes/);
});

it("rejects param names URL patterns cannot express", () => {
const adapter = nextRoutes();
expect(() =>
adapter.buildRoutes(makeFiles(["blog/[foo-bar]/page.tsx"])),
).toThrow(/Invalid param name "foo-bar"/);
expect(() =>
adapter.buildRoutes(makeFiles(["docs/[...foo.bar]/page.tsx"])),
).toThrow(/Invalid param name "foo\.bar"/);
});

it("allows param names with letters, digits, underscore, and dollar", () => {
const adapter = nextRoutes();
const tree = adapter.buildRoutes(makeFiles(["u/[$user_1]/page.tsx"]));
expect(simplify(tree)).toEqual([
{ path: "/u/:$user_1", page: true, id: "u/[$user_1]/page.tsx" },
]);
});

it("rejects a param name used twice on one route path", () => {
const adapter = nextRoutes();
expect(() =>
adapter.buildRoutes(makeFiles(["[slug]/x/[slug]/page.tsx"])),
).toThrow(/Duplicate param name "slug"/);
// Also across a layout boundary, where the inner value would shadow
// the outer one.
expect(() =>
adapter.buildRoutes(
makeFiles(["[id]/layout.tsx", "[id]/sub/[id]/page.tsx"]),
),
).toThrow(/Duplicate param name "id"/);
});

it("rejects static directory names containing URL pattern characters", () => {
const adapter = nextRoutes();
for (const dir of ["a+b", "a?b", "a*b", "a:b", "a(b)c", "a{b}"]) {
expect(() => adapter.buildRoutes(makeFiles([`${dir}/page.tsx`]))).toThrow(
/special meaning in URL patterns/,
);
}
});

it("rejects two pages resolving to the same route via route groups", () => {
const adapter = nextRoutes();
expect(() =>
Expand Down
55 changes: 52 additions & 3 deletions packages/static/src/fs-routes/nextAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,24 @@ function classify(
return undefined;
}

/**
* Param names FUNSTACK Router (via URLPattern) can express. Anything else —
* e.g. `[foo-bar]` — is parsed by URLPattern as a shorter param followed by
* literal text, silently producing a route that never matches its pages.
*/
const VALID_PARAM_NAME = /^[A-Za-z0-9_$]+$/;

/**
* Characters with special meaning in URLPattern pathname patterns. A static
* directory name containing one would either fail URLPattern construction at
* match time (`?`, `+`) or silently match the wrong URLs (`:`, `*`, `(`…).
*/
const URL_PATTERN_SPECIAL_CHARS = /[:*?+(){}\\]/;

/**
* Rejects directory segments using Next.js syntaxes that this adapter does not
* support, so they fail loudly instead of silently producing broken routes.
* support, and segments FUNSTACK Router's URL patterns cannot express, so they
* fail loudly instead of silently producing broken routes.
*/
function validateSegment(segment: string, filePath: string): void {
if (/^\[\[.*\]\]$/.test(segment)) {
Expand All @@ -78,6 +93,28 @@ function validateSegment(segment: string, filePath: string): void {
`Intercepting routes ("${segment}" in "${filePath}") are not supported.`,
);
}
// Route groups do not reach the URL, so their names are unconstrained.
if (segment.startsWith("(") && segment.endsWith(")")) {
return;
}
const dynamic = /^\[(?:\.\.\.)?(.+)\]$/.exec(segment);
if (dynamic) {
if (!VALID_PARAM_NAME.test(dynamic[1]!)) {
throw new Error(
`Invalid param name "${dynamic[1]}" ("${segment}" in "${filePath}"). ` +
`Param names may only contain letters, digits, "_", and "$".`,
);
}
return;
}
const special = URL_PATTERN_SPECIAL_CHARS.exec(segment);
if (special) {
throw new Error(
`Directory name "${segment}" (in "${filePath}") contains "${special[0]}", ` +
`which has a special meaning in URL patterns and cannot be routed. ` +
`Rename the directory.`,
);
}
}

/**
Expand Down Expand Up @@ -254,8 +291,6 @@ export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter {
name: "next",
buildRoutes(files: FsRouteFile[]): FsRouteTreeNode[] {
const root: TrieNode = { segment: "", children: new Map() };
// Route position each page/layout occupies, with dynamic segments
// normalized so that e.g. `[a]` and `[b]` at the same position conflict.
// Exact directory each page/layout file lives in, to detect duplicate
// files for the same node (e.g. `page.tsx` next to `page.jsx`).
const filesByDir = new Map<string, string>();
Expand All @@ -268,8 +303,22 @@ export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter {
const { dirs, base } = splitFilePath(file.filePath);
const kind = classify(base, pageFileName, layoutFileName);
if (!kind) continue;
const seenParamNames = new Set<string>();
for (const segment of dirs) {
validateSegment(segment, file.filePath);
const dynamic = /^\[(?:\.\.\.)?(.+)\]$/.exec(segment);
if (dynamic) {
// A param name used twice on one path either fails URLPattern
// construction (within one route) or shadows the outer value
// (across a layout boundary), so reject it up front.
if (seenParamNames.has(dynamic[1]!)) {
throw new Error(
`Duplicate param name "${dynamic[1]}" in "${file.filePath}": ` +
`a route may use each param name only once.`,
);
}
seenParamNames.add(dynamic[1]!);
}
}
const dirKey = `${kind} ${dirs.join("/")}`;
const sameDir = filesByDir.get(dirKey);
Expand Down
15 changes: 15 additions & 0 deletions packages/static/src/fs-routes/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,4 +224,19 @@ describe("createFsRoutesEntries route definitions", () => {
expect(ids).toEqual(idsPerEntry[0]);
}
});

it("throws for a page module without a default export", async () => {
await expect(entriesFor({ "./pages/about/page.tsx": {} })).rejects.toThrow(
/page module "about\/page\.tsx" has no default export/,
);
});

it("throws for a layout module without a default export", async () => {
await expect(
entriesFor({
"./pages/layout.tsx": { notDefault: () => null },
"./pages/page.tsx": { default: () => null },
}),
).rejects.toThrow(/layout module "layout\.tsx" has no default export/);
});
});
37 changes: 30 additions & 7 deletions packages/static/src/fs-routes/runtime.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ import { Router } from "@funstack/router";
import type { RouteDefinition } from "@funstack/router/server";
import type {
FsRootComponent,
FsRouteComponentProps,
FsRouteModule,
FsRouteObject,
FsRoutesAdapter,
Expand Down Expand Up @@ -99,6 +98,30 @@ interface NodeMeta {
chunks: Record<string, string>;
}

/**
* Rejects route modules without a default export. Rendering would silently
* skip the missing component (producing a blank page, or a pass-through
* layout), so a typo'd or forgotten export must fail the build instead.
*/
function validateRouteModules(nodes: FsRouteTreeNode[]): void {
for (const node of nodes) {
if (node.module.default === undefined) {
const kind = node.page ? "page" : "layout";
const which =
node.filePath === undefined
? `for route "${node.path ?? "(pathless)"}"`
: `"${node.filePath}"`;
throw new Error(
`Route ${kind} module ${which} has no default export. ` +
`Page and layout modules must \`export default\` a React component.`,
);
}
if (node.children) {
validateRouteModules(node.children);
}
}
}

function buildNodeMetas(
nodes: FsRouteTreeNode[],
inheritedParamNames: string[],
Expand Down Expand Up @@ -162,8 +185,7 @@ function registerChunks(
}
for (const [node, nodeCombos] of combos) {
const meta = metas.get(node)!;
const Component = node.module
.default as ComponentType<FsRouteComponentProps>;
const Component = node.module.default!;
for (const [key, params] of nodeCombos) {
const element = createElement(Component, { params, route: meta.route });
meta.chunks[key] = host.registerChunk(
Expand Down Expand Up @@ -233,10 +255,10 @@ export function createFsRoutesEntriesWithHost(
if (pageChain.has(node)) {
const params = pickParams(pageParams, meta.paramNames);
slotProps.initialKey = paramsKey(meta.paramNames, pageParams);
slotProps.initial = createElement(
Component as React.ComponentType<FsRouteComponentProps>,
{ params, route: meta.route },
);
slotProps.initial = createElement(Component, {
params,
route: meta.route,
});
}
definition.component = createElement(host.RouteSlot, slotProps);
}
Expand Down Expand Up @@ -281,6 +303,7 @@ export function createFsRoutesEntriesWithHost(
};
const files = modulesToRouteFiles(modules, base, warn);
const tree = adapter.buildRoutes(files);
validateRouteModules(tree);
const pages = await collectStaticPaths(tree);
const metas = new Map<FsRouteTreeNode, NodeMeta>();
buildNodeMetas(tree, [], "", metas);
Expand Down
89 changes: 89 additions & 0 deletions packages/static/src/fs-routes/tree.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,95 @@ describe("collectStaticPaths", () => {
);
});

it("throws when a non-catch-all value contains a slash", async () => {
const tree: FsRouteTreeNode[] = [
{
path: "/blog/:slug",
page: true,
module: pageModule(() => [{ slug: "a/b" }]),
},
];
await expect(collectStaticPaths(tree)).rejects.toThrow(/"a\/b".*catch-all/);
});

it("throws for an empty param value", async () => {
const tree: FsRouteTreeNode[] = [
{
path: "/blog/:slug",
page: true,
module: pageModule(() => [{ slug: "" }]),
},
];
await expect(collectStaticPaths(tree)).rejects.toThrow(/empty value/);
});

it("throws for an empty catch-all value, suggesting a parent page", async () => {
const tree: FsRouteTreeNode[] = [
{
path: "/docs/:slug*",
page: true,
module: pageModule(() => [{ slug: "" }]),
},
];
await expect(collectStaticPaths(tree)).rejects.toThrow(
/parent route instead/,
);
});

it("throws for a non-string param value", async () => {
const tree: FsRouteTreeNode[] = [
{
path: "/blog/:id",
page: true,
module: pageModule(() => [
{ id: 5 } as unknown as Record<string, string>,
]),
},
];
await expect(collectStaticPaths(tree)).rejects.toThrow(
/returned a number.*"id"/,
);
});

it("throws for a catch-all value with leading, trailing, or repeated slashes", async () => {
for (const slug of ["/a", "a/", "a//b"]) {
const tree: FsRouteTreeNode[] = [
{
path: "/docs/:slug*",
page: true,
module: pageModule(() => [{ slug }]),
},
];
await expect(collectStaticPaths(tree)).rejects.toThrow(/slashes/);
}
});

it('throws for param values containing "." or ".." segments', async () => {
for (const slug of ["..", ".", "a/../b"]) {
const tree: FsRouteTreeNode[] = [
{
path: "/docs/:slug*",
page: true,
module: pageModule(() => [{ slug }]),
},
];
await expect(collectStaticPaths(tree)).rejects.toThrow(/"\."/);
}
});

it('throws for param values containing "?" or "#"', async () => {
for (const slug of ["a?b", "a#b"]) {
const tree: FsRouteTreeNode[] = [
{
path: "/blog/:slug",
page: true,
module: pageModule(() => [{ slug }]),
},
];
await expect(collectStaticPaths(tree)).rejects.toThrow(/URL path/);
}
});

it("throws when generateStaticParams is missing a param value", async () => {
const tree: FsRouteTreeNode[] = [
{
Expand Down
Loading