|
1 | 1 | # File-System Routing |
2 | 2 |
|
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. |
4 | 4 |
|
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. |
6 | 6 |
|
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. |
8 | 8 |
|
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 |
24 | 10 |
|
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 |
34 | 15 | ``` |
35 | 16 |
|
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 | +``` |
37 | 40 |
|
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). |
43 | 44 |
|
44 | | -## Why import.meta.glob? |
| 45 | +`fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. |
45 | 46 |
|
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). |
47 | 48 |
|
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 |
50 | 50 |
|
51 | | -## Static Generation |
| 51 | +The built-in adapter follows Next.js App-Router conventions: |
52 | 52 |
|
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. |
54 | 66 |
|
55 | 67 | ```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>; |
69 | 71 | } |
| 72 | +``` |
70 | 73 |
|
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" }]; |
74 | 96 | } |
75 | 97 |
|
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>; |
82 | 100 | } |
83 | 101 | ``` |
84 | 102 |
|
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 | +``` |
86 | 149 |
|
87 | 150 | ## Full Example |
88 | 151 |
|
89 | 152 | 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. |
90 | 153 |
|
| 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 | + |
91 | 158 | ## See Also |
92 | 159 |
|
| 160 | +- [funstackStatic()](/api/funstack-static) - The `fsRoutes` plugin option |
93 | 161 | - [Multiple Entrypoints](/advanced/multiple-entrypoints) - Generating multiple HTML pages from a single project |
94 | 162 | - [EntryDefinition](/api/entry-definition) - API reference for entry definitions |
95 | 163 | - [How It Works](/learn/how-it-works) - Overall FUNSTACK Static architecture |
0 commit comments