Skip to content

Commit b6dbea5

Browse files
authored
feat: built-in file-system routing (experimental) (#123)
1 parent b8732b3 commit b6dbea5

39 files changed

Lines changed: 1542 additions & 174 deletions

packages/docs/src/pages/GettingStarted.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,8 @@ export default function App() {
128128
}
129129
```
130130

131+
Prefer convention over configuration? FUNSTACK Static also has built-in [file-system routing](/learn/file-system-routing) that maps a `pages/` directory to routes automatically.
132+
131133
### 5. Start Development Server
132134

133135
```bash

packages/docs/src/pages/advanced/MultipleEntrypoints.mdx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ Use the `entries` option when you want to build a **multi-page static site** whe
99
- **Single-entry mode** (`root` + `app`): One HTML file, client-side routing between pages. Best for app-like experiences where dynamic data loading and client-side interactivity are heavily used, and SEO is less of a concern (e.g., dashboards, web apps).
1010
- **Multiple entries mode** (`entries`): Multiple HTML files, each independently pre-rendered. Best for content sites (blogs, docs, marketing pages) where SEO and fast initial load are priorities. Client-side routing is still possible by using a router library with SSR support.
1111

12+
> If you want a `pages/` directory mapped to routes automatically instead of writing entries by hand, use built-in [File-System Routing](/learn/file-system-routing), which generates entries for you.
13+
1214
## Basic Setup
1315

1416
### 1. Configure Vite

packages/docs/src/pages/api/FunstackStatic.mdx

Lines changed: 60 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import funstackStatic from "@funstack/static";
1010

1111
## Usage
1212

13-
There are two configuration modes: **single-entry** (one HTML page) and **multiple entries** (multiple HTML pages).
13+
There are three configuration modes: **single-entry** (one HTML page), **multiple entries** (multiple HTML pages), and **file-system routing** (pages mapped from the file system).
1414

1515
### Single-Entry Mode
1616

@@ -53,9 +53,37 @@ export default defineConfig({
5353

5454
See [Multiple Entrypoints](/advanced/multiple-entrypoints) for a full guide.
5555

56+
### File-System Routing Mode
57+
58+
> **Experimental.** Not yet covered by semantic versioning.
59+
60+
Use `fsRoutes` to map pages from a directory to routes, rendered with FUNSTACK Router:
61+
62+
```typescript
63+
// vite.config.ts
64+
import funstackStatic from "@funstack/static";
65+
import react from "@vitejs/plugin-react";
66+
import { defineConfig } from "vite";
67+
68+
export default defineConfig({
69+
plugins: [
70+
funstackStatic({
71+
ssr: true,
72+
fsRoutes: {
73+
dir: "./src/pages",
74+
root: "./src/root.tsx",
75+
},
76+
}),
77+
react(),
78+
],
79+
});
80+
```
81+
82+
`ssr: true` is required for the dev server to render pages. See [File-System Routing](/learn/file-system-routing) for a full guide.
83+
5684
## Options
5785

58-
The plugin accepts either `root` + `app` (single-entry) or `entries` (multiple entries). These two modes are mutually exclusive.
86+
The plugin accepts exactly one of `root` + `app` (single-entry), `entries` (multiple entries), or `fsRoutes` (file-system routing). These modes are mutually exclusive.
5987

6088
### root
6189

@@ -160,6 +188,36 @@ export default function getEntries(): EntryDefinition[] {
160188

161189
See [Multiple Entrypoints](/advanced/multiple-entrypoints) for details on the `EntryDefinition` type and advanced usage patterns like async generators.
162190

191+
### fsRoutes
192+
193+
**Type:** `FsRoutesConfig`
194+
**Required in:** file-system routing mode
195+
**Experimental** — not yet covered by semantic versioning.
196+
197+
Enables built-in file-system routing. Pages discovered under `fsRoutes.dir` are mapped to routes via an adapter and rendered with FUNSTACK Router. Requires `@funstack/router` to be installed.
198+
199+
Cannot be used together with `root`, `app`, or `entries`.
200+
201+
```typescript
202+
funstackStatic({
203+
fsRoutes: {
204+
dir: "./src/pages",
205+
root: "./src/root.tsx",
206+
adapter: "./src/my-adapter.ts", // optional
207+
},
208+
});
209+
```
210+
211+
`FsRoutesConfig` fields:
212+
213+
- **`dir`** (optional, default `"./src/pages"`) — directory scanned for route files, relative to the Vite root.
214+
- **`root`** (required) — path to the root (HTML shell) component module.
215+
- **`adapter`** (optional) — path to a module that `export default`s an `FsRoutesAdapter`. Defaults to the built-in Next.js-like adapter (`nextRoutes()` from `@funstack/static/fs-routes`).
216+
217+
Enable [`ssr`](#ssr-optional) alongside `fsRoutes` — it is required for the dev server to render pages and recommended in general.
218+
219+
See [File-System Routing](/learn/file-system-routing) for the conventions, dynamic routes, and writing custom adapters.
220+
163221
### publicOutDir (optional)
164222

165223
**Type:** `string`
Lines changed: 130 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -1,95 +1,163 @@
11
# File-System Routing
22

3-
FUNSTACK Static does not include a built-in file-system router, but you can implement one in userland using Vite's `import.meta.glob` and a router library like [FUNSTACK Router](https://github.com/uhyo/funstack-router).
3+
> **Experimental.** File-system routing is experimental. Its API may change in a minor release and is **not** yet covered by semantic versioning.
44
5-
## How It Works
5+
FUNSTACK Static includes **built-in file-system routing**. Pages discovered in a directory are automatically mapped to routes and rendered with [FUNSTACK Router](https://github.com/uhyo/funstack-router), with one static HTML file generated per route.
66

7-
The idea is to use `import.meta.glob` to discover page components from a `pages/` directory at compile time, then convert the file paths into route definitions.
7+
The directory / file-name convention is pluggable through an **adapter**, and a Next.js-like adapter is provided out of the box.
88

9-
```tsx
10-
import { route, type RouteDefinition } from "@funstack/router/server";
11-
12-
const pageModules = import.meta.glob<{ default: React.ComponentType }>(
13-
"./pages/**/*.tsx",
14-
{ eager: true },
15-
);
16-
17-
function filePathToUrlPath(filePath: string): string {
18-
let urlPath = filePath.replace(/^\.\/pages/, "").replace(/\.tsx$/, "");
19-
if (urlPath.endsWith("/index")) {
20-
urlPath = urlPath.slice(0, -"/index".length);
21-
}
22-
return urlPath || "/";
23-
}
9+
## Requirements
2410

25-
export const routes: RouteDefinition[] = Object.entries(pageModules).map(
26-
([filePath, module]) => {
27-
const Page = module.default;
28-
return route({
29-
path: filePathToUrlPath(filePath),
30-
component: <Page />,
31-
});
32-
},
33-
);
11+
File-system routing renders pages with FUNSTACK Router, so `@funstack/router` must be installed:
12+
13+
```sh
14+
npm install @funstack/router
3415
```
3516

36-
With this setup, files in the `pages/` directory are automatically mapped to routes:
17+
## Setup
18+
19+
Enable file-system routing with the `fsRoutes` option:
20+
21+
```typescript
22+
// vite.config.ts
23+
import funstackStatic from "@funstack/static";
24+
import react from "@vitejs/plugin-react";
25+
import { defineConfig } from "vite";
26+
27+
export default defineConfig({
28+
plugins: [
29+
funstackStatic({
30+
ssr: true,
31+
fsRoutes: {
32+
dir: "./src/pages",
33+
root: "./src/root.tsx",
34+
},
35+
}),
36+
react(),
37+
],
38+
});
39+
```
3740

38-
| File | Route |
39-
| ---------------------- | -------- |
40-
| `pages/index.tsx` | `/` |
41-
| `pages/about.tsx` | `/about` |
42-
| `pages/blog/index.tsx` | `/blog` |
41+
- `dir` — the directory scanned for route files (default `./src/pages`).
42+
- `root` — the HTML shell component (`<html>…<body>{children}</body></html>`).
43+
- `adapter` — optional path to a custom adapter module (defaults to the built-in Next.js-like adapter).
4344

44-
## Why import.meta.glob?
45+
`fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes.
4546

46-
Using `import.meta.glob` has two key advantages:
47+
> **Enable [`ssr`](/api/funstack-static).** Pages are server components rendered through FUNSTACK Router. `ssr: true` is **required for the dev server** (`vite dev`) to render your pages — without it, the dev server can only render the app shell — and it is recommended in general for SEO and faster initial load. Production builds work with `ssr` either way. Lifting this dev-server requirement is tracked in [#124](https://github.com/uhyo/funstack-static/issues/124).
4748
48-
- **Automatic discovery** — you don't need to manually register each page. Just add a new `.tsx` file and it becomes a route.
49-
- **Hot module replacement** — Vite tracks the glob pattern, so adding or removing page files in development triggers an automatic update without a server restart.
49+
## The Next.js-like Convention
5050

51-
## Static Generation
51+
The built-in adapter follows Next.js App-Router conventions:
5252

53-
To generate static HTML for each route, derive [entry definitions](/api/entry-definition) from the route list:
53+
| File | Route | Notes |
54+
| ------------------------------------ | -------------- | ----------------------------- |
55+
| `pages/page.tsx` | `/` | A page for its directory |
56+
| `pages/about/page.tsx` | `/about` | |
57+
| `pages/blog/page.tsx` | `/blog` | |
58+
| `pages/blog/[slug]/page.tsx` | `/blog/:slug` | Dynamic segment |
59+
| `pages/docs/[...slug]/page.tsx` | `/docs/:slug*` | Catch-all segment |
60+
| `pages/(marketing)/contact/page.tsx` | `/contact` | `(group)` does not affect URL |
61+
62+
- **`page.tsx`**`export default` a React component for the route.
63+
- **`layout.tsx`**`export default` a layout that wraps its directory and descendants. A layout must render `<Outlet />` (from `@funstack/router`) where child routes should appear.
64+
65+
Files that are not named `page` or `layout` are ignored, so helpers and components can be co-located with routes.
5466

5567
```tsx
56-
import type { EntryDefinition } from "@funstack/static/entries";
57-
import type { RouteDefinition } from "@funstack/router/server";
58-
59-
function collectPaths(routes: RouteDefinition[]): string[] {
60-
const paths: string[] = [];
61-
for (const route of routes) {
62-
if (route.children) {
63-
paths.push(...collectPaths(route.children));
64-
} else if (route.path !== undefined && route.path !== "*") {
65-
paths.push(route.path);
66-
}
67-
}
68-
return paths;
68+
// src/pages/page.tsx
69+
export default function Home() {
70+
return <h1>Home</h1>;
6971
}
72+
```
7073

71-
function pathToEntryPath(path: string): string {
72-
if (path === "/") return "index.html";
73-
return `${path.slice(1)}.html`;
74+
```tsx
75+
// src/pages/dashboard/layout.tsx
76+
import { Outlet } from "@funstack/router";
77+
78+
export default function DashboardLayout() {
79+
return (
80+
<section>
81+
<nav>{/* persistent dashboard navigation */}</nav>
82+
<Outlet />
83+
</section>
84+
);
85+
}
86+
```
87+
88+
## Dynamic Routes and Static Generation
89+
90+
Because FUNSTACK Static generates a static site, every page must be enumerated at build time. Dynamic routes are pre-rendered by exporting `generateStaticParams` from the page module, similar to Next.js:
91+
92+
```tsx
93+
// src/pages/blog/[slug]/page.tsx
94+
export function generateStaticParams() {
95+
return [{ slug: "hello" }, { slug: "world" }];
7496
}
7597

76-
export default function getEntries(): EntryDefinition[] {
77-
return collectPaths(routes).map((pathname) => ({
78-
path: pathToEntryPath(pathname),
79-
root: () => import("./root"),
80-
app: <App ssrPath={pathname} />,
81-
}));
98+
export default function BlogPost({ params }: { params: { slug: string } }) {
99+
return <article>Post: {params.slug}</article>;
82100
}
83101
```
84102

85-
This produces one HTML file per route at build time.
103+
This generates `blog/hello.html` and `blog/world.html`. Each page component receives the resolved `params` as a prop.
104+
105+
A dynamic route without `generateStaticParams` is **not** pre-rendered (a warning is logged); it still resolves on the client via the SPA fallback.
106+
107+
> **Note:** Because static hosting serves one pre-rendered RSC payload per page, soft client-side navigation between different values of the _same_ dynamic route reflects the params of the initially-loaded page. Loading a dynamic URL directly (or via the SPA fallback) always renders the correct params. Static routes and layouts navigate fully on the client.
108+
109+
## Custom Conventions (Adapters)
110+
111+
The convention is defined by an **adapter** implementing `FsRoutesAdapter`. Point `adapter` at a module that `export default`s an adapter to use a different convention:
112+
113+
```typescript
114+
// vite.config.ts
115+
funstackStatic({
116+
fsRoutes: {
117+
dir: "./src/pages",
118+
root: "./src/root.tsx",
119+
adapter: "./src/my-adapter.ts",
120+
},
121+
});
122+
```
123+
124+
```tsx
125+
// src/my-adapter.ts
126+
import type { FsRoutesAdapter } from "@funstack/static/fs-routes";
127+
128+
const adapter: FsRoutesAdapter = {
129+
name: "my-convention",
130+
buildRoutes(files) {
131+
// Map discovered files to a route tree.
132+
// See the FsRouteTreeNode type for the expected shape.
133+
return [];
134+
},
135+
};
136+
137+
export default adapter;
138+
```
139+
140+
The built-in Next.js-like adapter is also exported, so you can wrap or configure it:
141+
142+
```tsx
143+
// src/my-adapter.ts
144+
import { nextRoutes } from "@funstack/static/fs-routes";
145+
146+
// e.g. use `index.tsx` instead of `page.tsx`
147+
export default nextRoutes({ pageFileName: "index", layoutFileName: "_layout" });
148+
```
86149

87150
## Full Example
88151

89152
For a complete working example, see the [`example-fs-routing`](https://github.com/uhyo/funstack-static/tree/master/packages/example-fs-routing) package in the FUNSTACK Static repository.
90153

154+
## Fully Custom Routing
155+
156+
`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.
157+
91158
## See Also
92159

160+
- [funstackStatic()](/api/funstack-static) - The `fsRoutes` plugin option
93161
- [Multiple Entrypoints](/advanced/multiple-entrypoints) - Generating multiple HTML pages from a single project
94162
- [EntryDefinition](/api/entry-definition) - API reference for entry definitions
95163
- [How It Works](/learn/how-it-works) - Overall FUNSTACK Static architecture

packages/example-fs-routing/src/App.tsx

Lines changed: 0 additions & 6 deletions
This file was deleted.

packages/example-fs-routing/src/entries.tsx

Lines changed: 0 additions & 29 deletions
This file was deleted.

packages/example-fs-routing/src/pages/about.tsx renamed to packages/example-fs-routing/src/pages/about/page.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,13 @@ export default function About() {
33
<div>
44
<h1>About</h1>
55
<p>
6-
This example demonstrates file-system routing with{" "}
6+
This example demonstrates the built-in file-system routing of{" "}
77
<a href="https://github.com/uhyo/funstack-static">FUNSTACK Static</a>.
88
</p>
99
<p>
1010
Routes are derived from the file structure under <code>src/pages/</code>{" "}
11-
using Vite&apos;s <code>import.meta.glob</code>, which also enables hot
12-
module replacement during development.
11+
using the Next.js-like adapter, and rendered with FUNSTACK Router. The
12+
convention is configurable via custom adapters.
1313
</p>
1414
</div>
1515
);

packages/example-fs-routing/src/pages/blog/index.tsx

Lines changed: 0 additions & 15 deletions
This file was deleted.

0 commit comments

Comments
 (0)