Skip to content

Commit c3b98fe

Browse files
authored
Merge pull request #7 from JoviDeCroock/JoviDeCroock/ssg-path-gen
Add getStaticPaths for dynamic SSG route generation
2 parents 364bdbe + e7a599e commit c3b98fe

13 files changed

Lines changed: 84 additions & 25 deletions

File tree

docs/ARCHITECTURE.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -127,9 +127,9 @@ export function ErrorBoundary({ error }: ErrorBoundaryProps) {
127127
return <p>Something went wrong: {error.message}</p>;
128128
}
129129

130-
// Build: enumerate paths for SSG/ISG prerendering (optional)
131-
export async function prerender(): Promise<string[]> {
132-
return ["/dashboard"];
130+
// Build: enumerate params for SSG/ISG prerendering (optional)
131+
export function getStaticPaths(): RouteParams[] {
132+
return [{ id: "1" }, { id: "2" }];
133133
}
134134
```
135135

docs/RENDERING_MODES.md

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,22 +28,26 @@ route — it's served as a static file.
2828

2929
### Dynamic SSG paths
3030

31-
For routes with dynamic segments, export a `prerender` function:
31+
For routes with dynamic segments, export a `getStaticPaths` function that
32+
returns the params for each page to generate:
3233

3334
```typescript
3435
// src/routes/blog-post.tsx
35-
export async function prerender(): Promise<string[]> {
36-
const posts = await getAllPosts();
37-
return posts.map((p) => `/blog/${p.slug}`);
36+
import type { LoaderArgs, RouteParams } from "viact";
37+
38+
export function getStaticPaths(): RouteParams[] {
39+
const posts = getAllPosts();
40+
return posts.map((p) => ({ slug: p.slug }));
3841
}
3942

4043
export async function loader({ params }: LoaderArgs) {
4144
return { post: await getPost(params.slug) };
4245
}
4346
```
4447

45-
The build calls `prerender()` to enumerate all paths, then runs the loader
46-
and renderer for each. Output: `dist/client/blog/hello-world/index.html`, etc.
48+
The build calls `getStaticPaths()` to enumerate params, constructs full paths
49+
from the route pattern, then runs the loader and renderer for each.
50+
Output: `dist/client/blog/hello-world/index.html`, etc.
4751

4852
Prerendering runs concurrently (default: 6 parallel renders).
4953

e2e/node-build.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,17 @@ test("viact build emits a deployable Node server entry", async () => {
6363
"Viact starts with an explicit app manifest.",
6464
);
6565

66+
// Dynamic SSG routes should be prerendered as static HTML files
67+
for (const id of ["1", "2", "3"]) {
68+
const htmlPath = resolve(exampleDir, `dist/client/products/${id}/index.html`);
69+
expect(existsSync(htmlPath)).toBe(true);
70+
71+
const productResponse = await fetch(`http://127.0.0.1:${port}/products/${id}`);
72+
expect(productResponse.status).toBe(200);
73+
const productHtml = await productResponse.text();
74+
expect(productHtml).toContain("Price:");
75+
}
76+
6677
const apiResponse = await fetch(`http://127.0.0.1:${port}/api/health`);
6778
expect(apiResponse.status).toBe(200);
6879
await expect(apiResponse.json()).resolves.toEqual({ status: "ok" });

examples/basic/src/routes.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,10 @@ export const app = defineApp({
1111
routes: [
1212
group({ shell: "public" }, [
1313
route("/", "./routes/home.tsx", { id: "home", render: "ssg" }),
14+
route("/products/:productId", "./routes/product.tsx", {
15+
id: "product",
16+
render: "ssg",
17+
}),
1418
route("/pricing", "./routes/pricing.tsx", {
1519
id: "pricing",
1620
render: "isg",
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { LoaderArgs, RouteComponentProps, RouteParams } from "viact";
2+
3+
const PRODUCTS = [
4+
{ id: "1", name: "Widget", price: "$9.99" },
5+
{ id: "2", name: "Gadget", price: "$19.99" },
6+
{ id: "3", name: "Doohickey", price: "$4.99" },
7+
];
8+
9+
export function getStaticPaths(): RouteParams[] {
10+
return PRODUCTS.map((p) => ({ productId: p.id }));
11+
}
12+
13+
export function loader({ params }: LoaderArgs) {
14+
const product = PRODUCTS.find((p) => p.id === params.productId);
15+
if (!product) throw new Error("Product not found");
16+
return product;
17+
}
18+
19+
export function Component({ data }: RouteComponentProps<typeof loader>) {
20+
return (
21+
<div>
22+
<h1>{data.name}</h1>
23+
<p>Price: {data.price}</p>
24+
</div>
25+
);
26+
}

examples/docs/src/routes/docs/recipes-i18n.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -224,11 +224,11 @@ export function LanguageSwitcher({ currentLocale }: { currentLocale: string }) {
224224

225225
## Tips
226226

227-
- For **SSG** pages, use `prerender()` to generate a page per locale:
227+
- For **SSG** pages, use `getStaticPaths()` to generate a page per locale:
228228

229229
```ts
230-
export async function prerender() {
231-
return ["/en/about", "/fr/about"];
230+
export function getStaticPaths(): RouteParams[] {
231+
return [{ locale: "en" }, { locale: "fr" }];
232232
}
233233
```
234234

examples/docs/src/routes/docs/rendering.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,12 @@ HTML is generated at build time. The loader runs once during the build, and the
3131

3232
### Dynamic SSG paths
3333

34-
For routes with dynamic segments, export a `prerender` function to enumerate all paths:
34+
For routes with dynamic segments, export a `getStaticPaths` function that returns the params for each page:
3535

3636
```ts [src/routes/blog-post.tsx]
37-
export async function prerender(): Promise<string[]> {
38-
const posts = await getAllPosts();
39-
return posts.map(p => `/blog/${p.slug}`);
37+
export function getStaticPaths(): RouteParams[] {
38+
const posts = getAllPosts();
39+
return posts.map(p => ({ slug: p.slug }));
4040
}
4141

4242
export async function loader({ params }: LoaderArgs) {
@@ -48,7 +48,7 @@ export function Component({ data }) {
4848
}
4949
```
5050

51-
The build calls `prerender()` to enumerate all paths, then runs the loader and renderer for each. Prerendering runs concurrently (default: 6 parallel renders).
51+
The build calls `getStaticPaths()` to enumerate params, constructs full paths from the route pattern, then runs the loader and renderer for each. Prerendering runs concurrently (default: 6 parallel renders).
5252

5353
---
5454

packages/framework/src/app.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,17 @@ function normalizeRoutePath(path: string): string {
262262
return collapsed.length > 1 && collapsed.endsWith("/") ? collapsed.slice(0, -1) : collapsed;
263263
}
264264

265+
export function buildPathFromSegments(segments: RouteSegment[], params: RouteParams): string {
266+
const parts = segments.map((segment) => {
267+
if (segment.type === "static") return segment.value;
268+
if (segment.type === "param") return encodeURIComponent(params[segment.name] ?? "");
269+
// catchall
270+
return params["*"] ?? "";
271+
});
272+
273+
return normalizeRoutePath("/" + parts.join("/"));
274+
}
275+
265276
// ---------------------------------------------------------------------------
266277
// API Routes — file-based auto-discovery
267278
// ---------------------------------------------------------------------------

packages/framework/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export {
2+
buildPathFromSegments,
23
defineApp,
34
group,
45
matchApiRoute,

packages/framework/src/runtime.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1193,17 +1193,19 @@ async function collectSSGPaths(
11931193
return [route.path];
11941194
}
11951195

1196-
// Dynamic route — must export prerender() to enumerate paths
1196+
// Dynamic route — must export getStaticPaths() to enumerate params
11971197
const routeModule = await resolveRegistryModule<RouteModule>(registry?.routeModules, route.file);
11981198

1199-
if (!routeModule?.prerender) {
1199+
if (!routeModule?.getStaticPaths) {
12001200
console.warn(
1201-
` Warning: SSG route "${route.path}" has dynamic segments but no prerender() export, skipping.`,
1201+
` Warning: SSG route "${route.path}" has dynamic segments but no getStaticPaths() export, skipping.`,
12021202
);
12031203
return [];
12041204
}
12051205

1206-
return routeModule.prerender();
1206+
const { buildPathFromSegments } = await import("./app.ts");
1207+
const paramSets = await routeModule.getStaticPaths();
1208+
return paramSets.map((params) => buildPathFromSegments(route.segments, params));
12071209
}
12081210

12091211
async function readResponseBody(response: Response): Promise<unknown> {

0 commit comments

Comments
 (0)