From 44a8981dc6d61856a8cb2e03cf01951a6ed476f5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 13:04:27 +0000 Subject: [PATCH 1/5] feat: add built-in file-system routing Add a built-in file-system router so users no longer have to wire up routing in userland. Pages discovered under a configurable directory are mapped to routes and rendered with FUNSTACK Router, generating one static HTML file per route. - New `fsRoutes` plugin option (mutually exclusive with `root`+`app` and `entries`), taking `dir`, `root`, and an optional `adapter`. - Convention is pluggable via an `FsRoutesAdapter` (adapter pattern). - Built-in Next.js-like adapter (`nextRoutes`) supporting `page`/`layout` files, dynamic `[param]` and catch-all `[...param]` segments, and `(group)` route groups. Dynamic routes are statically generated via a `generateStaticParams` export. - Public entry `@funstack/static/fs-routes` exporting `nextRoutes` and the adapter types; `@funstack/router` is an optional peer dependency. - Unit tests for the adapter and static-path collection; e2e fixture and specs; example-fs-routing converted to the built-in feature; docs updated. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012L8PCU7xhNXfuepS7eKzro --- packages/docs/src/pages/GettingStarted.mdx | 2 + .../pages/advanced/MultipleEntrypoints.mdx | 2 + .../docs/src/pages/api/FunstackStatic.mdx | 56 +++++- .../src/pages/learn/FileSystemRouting.mdx | 187 ++++++++++++------ packages/example-fs-routing/src/App.tsx | 6 - packages/example-fs-routing/src/entries.tsx | 29 --- .../src/pages/{about.tsx => about/page.tsx} | 6 +- .../src/pages/blog/index.tsx | 15 -- .../src/pages/blog/page.tsx | 16 ++ .../example-fs-routing/src/pages/index.tsx | 28 --- .../example-fs-routing/src/pages/page.tsx | 28 +++ packages/example-fs-routing/src/routes.tsx | 24 --- packages/example-fs-routing/vite.config.ts | 7 +- .../e2e/fixture-fs-routing/package.json | 15 ++ .../src/pages/about/page.tsx | 8 + .../src/pages/blog/[slug]/page.tsx | 13 ++ .../src/pages/blog/page.tsx | 8 + .../src/pages/dashboard/layout.tsx | 10 + .../src/pages/dashboard/page.tsx | 8 + .../src/pages/dashboard/settings/page.tsx | 8 + .../e2e/fixture-fs-routing/src/pages/page.tsx | 8 + .../e2e/fixture-fs-routing/src/root.tsx | 20 ++ .../e2e/fixture-fs-routing/vite.config.ts | 15 ++ packages/static/e2e/playwright-dev.config.ts | 15 ++ packages/static/e2e/playwright.config.ts | 15 ++ .../static/e2e/tests-dev/fs-routing.spec.ts | 32 +++ packages/static/e2e/tests/fs-routing.spec.ts | 90 +++++++++ packages/static/package.json | 11 ++ packages/static/src/fs-routes/index.ts | 11 ++ .../static/src/fs-routes/nextAdapter.test.ts | 133 +++++++++++++ packages/static/src/fs-routes/nextAdapter.ts | 168 ++++++++++++++++ packages/static/src/fs-routes/runtime.tsx | 117 +++++++++++ packages/static/src/fs-routes/tree.test.ts | 126 ++++++++++++ packages/static/src/fs-routes/tree.ts | 135 +++++++++++++ packages/static/src/fs-routes/types.ts | 87 ++++++++ packages/static/src/plugin/index.ts | 93 ++++++++- packages/static/tsdown.config.ts | 1 + pnpm-lock.yaml | 30 +++ pnpm-workspace.yaml | 1 + 39 files changed, 1410 insertions(+), 174 deletions(-) delete mode 100644 packages/example-fs-routing/src/App.tsx delete mode 100644 packages/example-fs-routing/src/entries.tsx rename packages/example-fs-routing/src/pages/{about.tsx => about/page.tsx} (58%) delete mode 100644 packages/example-fs-routing/src/pages/blog/index.tsx create mode 100644 packages/example-fs-routing/src/pages/blog/page.tsx delete mode 100644 packages/example-fs-routing/src/pages/index.tsx create mode 100644 packages/example-fs-routing/src/pages/page.tsx delete mode 100644 packages/example-fs-routing/src/routes.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/package.json create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/about/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/blog/[slug]/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/blog/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/dashboard/layout.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/dashboard/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/dashboard/settings/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/pages/page.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/root.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/vite.config.ts create mode 100644 packages/static/e2e/tests-dev/fs-routing.spec.ts create mode 100644 packages/static/e2e/tests/fs-routing.spec.ts create mode 100644 packages/static/src/fs-routes/index.ts create mode 100644 packages/static/src/fs-routes/nextAdapter.test.ts create mode 100644 packages/static/src/fs-routes/nextAdapter.ts create mode 100644 packages/static/src/fs-routes/runtime.tsx create mode 100644 packages/static/src/fs-routes/tree.test.ts create mode 100644 packages/static/src/fs-routes/tree.ts create mode 100644 packages/static/src/fs-routes/types.ts diff --git a/packages/docs/src/pages/GettingStarted.mdx b/packages/docs/src/pages/GettingStarted.mdx index 8904c65..927cd01 100644 --- a/packages/docs/src/pages/GettingStarted.mdx +++ b/packages/docs/src/pages/GettingStarted.mdx @@ -128,6 +128,8 @@ export default function App() { } ``` +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. + ### 5. Start Development Server ```bash diff --git a/packages/docs/src/pages/advanced/MultipleEntrypoints.mdx b/packages/docs/src/pages/advanced/MultipleEntrypoints.mdx index 3f050e6..9d80ded 100644 --- a/packages/docs/src/pages/advanced/MultipleEntrypoints.mdx +++ b/packages/docs/src/pages/advanced/MultipleEntrypoints.mdx @@ -9,6 +9,8 @@ Use the `entries` option when you want to build a **multi-page static site** whe - **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). - **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. +> 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. + ## Basic Setup ### 1. Configure Vite diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index 1c695ab..d8834dc 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -10,7 +10,7 @@ import funstackStatic from "@funstack/static"; ## Usage -There are two configuration modes: **single-entry** (one HTML page) and **multiple entries** (multiple HTML pages). +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). ### Single-Entry Mode @@ -53,9 +53,34 @@ export default defineConfig({ See [Multiple Entrypoints](/advanced/multiple-entrypoints) for a full guide. +### File-System Routing Mode + +Use `fsRoutes` to map pages from the file system to routes, rendered with FUNSTACK Router: + +```typescript +// vite.config.ts +import funstackStatic from "@funstack/static"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, + }), + react(), + ], +}); +``` + +See [File-System Routing](/learn/file-system-routing) for a full guide. + ## Options -The plugin accepts either `root` + `app` (single-entry) or `entries` (multiple entries). These two modes are mutually exclusive. +The plugin accepts exactly one of `root` + `app` (single-entry), `entries` (multiple entries), or `fsRoutes` (file-system routing). These modes are mutually exclusive. ### root @@ -160,6 +185,33 @@ export default function getEntries(): EntryDefinition[] { See [Multiple Entrypoints](/advanced/multiple-entrypoints) for details on the `EntryDefinition` type and advanced usage patterns like async generators. +### fsRoutes + +**Type:** `FsRoutesConfig` +**Required in:** file-system routing mode + +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. + +Cannot be used together with `root`, `app`, or `entries`. + +```typescript +funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + adapter: "./src/my-adapter.ts", // optional + }, +}); +``` + +`FsRoutesConfig` fields: + +- **`dir`** (optional, default `"./src/pages"`) — directory scanned for route files, relative to the Vite root. +- **`root`** (required) — path to the root (HTML shell) component module. +- **`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`). + +See [File-System Routing](/learn/file-system-routing) for the conventions, dynamic routes, and writing custom adapters. + ### publicOutDir (optional) **Type:** `string` diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index fad9e33..7311b9b 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -1,95 +1,158 @@ # File-System Routing -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). +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. -## How It Works +The directory / file-name convention is pluggable through an **adapter**, and a Next.js-like adapter is provided out of the box. -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. +## Requirements -```tsx -import { route, type RouteDefinition } from "@funstack/router/server"; - -const pageModules = import.meta.glob<{ default: React.ComponentType }>( - "./pages/**/*.tsx", - { eager: true }, -); - -function filePathToUrlPath(filePath: string): string { - let urlPath = filePath.replace(/^\.\/pages/, "").replace(/\.tsx$/, ""); - if (urlPath.endsWith("/index")) { - urlPath = urlPath.slice(0, -"/index".length); - } - return urlPath || "/"; -} +File-system routing renders pages with FUNSTACK Router, so `@funstack/router` must be installed as a peer dependency: -export const routes: RouteDefinition[] = Object.entries(pageModules).map( - ([filePath, module]) => { - const Page = module.default; - return route({ - path: filePathToUrlPath(filePath), - component: , - }); - }, -); +```sh +npm install @funstack/router +``` + +## Setup + +Enable file-system routing with the `fsRoutes` option: + +```typescript +// vite.config.ts +import funstackStatic from "@funstack/static"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, + }), + react(), + ], +}); ``` -With this setup, files in the `pages/` directory are automatically mapped to routes: +- `dir` — the directory scanned for route files (default `./src/pages`). +- `root` — the HTML shell component (`…{children}`). +- `adapter` — optional path to a custom adapter module (defaults to the built-in Next.js-like adapter). + +`fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. -| File | Route | -| ---------------------- | -------- | -| `pages/index.tsx` | `/` | -| `pages/about.tsx` | `/about` | -| `pages/blog/index.tsx` | `/blog` | +## The Next.js-like Convention -## Why import.meta.glob? +The built-in adapter follows Next.js App-Router conventions: -Using `import.meta.glob` has two key advantages: +| File | Route | Notes | +| ------------------------------------ | -------------- | ----------------------------- | +| `pages/page.tsx` | `/` | A page for its directory | +| `pages/about/page.tsx` | `/about` | | +| `pages/blog/page.tsx` | `/blog` | | +| `pages/blog/[slug]/page.tsx` | `/blog/:slug` | Dynamic segment | +| `pages/docs/[...slug]/page.tsx` | `/docs/:slug*` | Catch-all segment | +| `pages/(marketing)/contact/page.tsx` | `/contact` | `(group)` does not affect URL | -- **Automatic discovery** — you don't need to manually register each page. Just add a new `.tsx` file and it becomes a route. -- **Hot module replacement** — Vite tracks the glob pattern, so adding or removing page files in development triggers an automatic update without a server restart. +- **`page.tsx`** — `export default` a React component for the route. +- **`layout.tsx`** — `export default` a layout that wraps its directory and descendants. A layout must render `` (from `@funstack/router`) where child routes should appear. -## Static Generation +Files that are not named `page` or `layout` are ignored, so helpers and components can be co-located with routes. -To generate static HTML for each route, derive [entry definitions](/api/entry-definition) from the route list: +```tsx +// src/pages/page.tsx +export default function Home() { + return

Home

; +} +``` ```tsx -import type { EntryDefinition } from "@funstack/static/entries"; -import type { RouteDefinition } from "@funstack/router/server"; - -function collectPaths(routes: RouteDefinition[]): string[] { - const paths: string[] = []; - for (const route of routes) { - if (route.children) { - paths.push(...collectPaths(route.children)); - } else if (route.path !== undefined && route.path !== "*") { - paths.push(route.path); - } - } - return paths; +// src/pages/dashboard/layout.tsx +import { Outlet } from "@funstack/router"; + +export default function DashboardLayout() { + return ( +
+ + +
+ ); } +``` + +## Dynamic Routes and Static Generation + +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: -function pathToEntryPath(path: string): string { - if (path === "/") return "index.html"; - return `${path.slice(1)}.html`; +```tsx +// src/pages/blog/[slug]/page.tsx +export function generateStaticParams() { + return [{ slug: "hello" }, { slug: "world" }]; } -export default function getEntries(): EntryDefinition[] { - return collectPaths(routes).map((pathname) => ({ - path: pathToEntryPath(pathname), - root: () => import("./root"), - app: , - })); +export default function BlogPost({ params }: { params: { slug: string } }) { + return
Post: {params.slug}
; } ``` -This produces one HTML file per route at build time. +This generates `blog/hello.html` and `blog/world.html`. Each page component receives the resolved `params` as a prop. + +A dynamic route without `generateStaticParams` is **not** pre-rendered (a warning is logged); it still resolves on the client via the SPA fallback. + +> **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. + +## Custom Conventions (Adapters) + +The convention is defined by an **adapter** implementing `FsRoutesAdapter`. Provide a module that `export default`s an adapter to use a different convention: + +```typescript +// vite.config.ts +funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + adapter: "./src/my-adapter.ts", + }, +}); +``` + +```tsx +// src/my-adapter.ts +import type { FsRoutesAdapter } from "@funstack/static/fs-routes"; + +const adapter: FsRoutesAdapter = { + name: "my-convention", + buildRoutes(files) { + // Map discovered files to a route tree. + // See the FsRouteTreeNode type for the expected shape. + return []; + }, +}; + +export default adapter; +``` + +The built-in Next.js-like adapter is also exported, so you can wrap or configure it: + +```tsx +// src/my-adapter.ts +import { nextRoutes } from "@funstack/static/fs-routes"; + +// e.g. use `index.tsx` instead of `page.tsx` +export default nextRoutes({ pageFileName: "index", layoutFileName: "_layout" }); +``` ## Full Example 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. +## Userland File-System Routing + +If you prefer full control, you can still implement routing in userland with Vite's `import.meta.glob` and the [`entries`](/api/funstack-static) option — deriving [entry definitions](/api/entry-definition) from a glob of page files. The built-in feature is a maintained implementation of this same idea. + ## See Also +- [funstackStatic()](/api/funstack-static) - The `fsRoutes` plugin option - [Multiple Entrypoints](/advanced/multiple-entrypoints) - Generating multiple HTML pages from a single project - [EntryDefinition](/api/entry-definition) - API reference for entry definitions - [How It Works](/learn/how-it-works) - Overall FUNSTACK Static architecture diff --git a/packages/example-fs-routing/src/App.tsx b/packages/example-fs-routing/src/App.tsx deleted file mode 100644 index e904c34..0000000 --- a/packages/example-fs-routing/src/App.tsx +++ /dev/null @@ -1,6 +0,0 @@ -import { Router } from "@funstack/router"; -import { routes } from "./routes"; - -export default function App({ ssrPath }: { ssrPath: string }) { - return ; -} diff --git a/packages/example-fs-routing/src/entries.tsx b/packages/example-fs-routing/src/entries.tsx deleted file mode 100644 index 0a54ad3..0000000 --- a/packages/example-fs-routing/src/entries.tsx +++ /dev/null @@ -1,29 +0,0 @@ -import type { EntryDefinition } from "@funstack/static/entries"; -import type { RouteDefinition } from "@funstack/router/server"; -import App from "./App"; -import { routes } from "./routes"; - -function collectPaths(routes: RouteDefinition[]): string[] { - const paths: string[] = []; - for (const route of routes) { - if (route.children) { - paths.push(...collectPaths(route.children)); - } else if (route.path !== undefined && route.path !== "*") { - paths.push(route.path); - } - } - return paths; -} - -function pathToEntryPath(path: string): string { - if (path === "/") return "index.html"; - return `${path.slice(1)}.html`; -} - -export default function getEntries(): EntryDefinition[] { - return collectPaths(routes).map((pathname) => ({ - path: pathToEntryPath(pathname), - root: () => import("./root"), - app: , - })); -} diff --git a/packages/example-fs-routing/src/pages/about.tsx b/packages/example-fs-routing/src/pages/about/page.tsx similarity index 58% rename from packages/example-fs-routing/src/pages/about.tsx rename to packages/example-fs-routing/src/pages/about/page.tsx index 055b02b..2fbcb6d 100644 --- a/packages/example-fs-routing/src/pages/about.tsx +++ b/packages/example-fs-routing/src/pages/about/page.tsx @@ -3,13 +3,13 @@ export default function About() {

About

- This example demonstrates file-system routing with{" "} + This example demonstrates the built-in file-system routing of{" "} FUNSTACK Static.

Routes are derived from the file structure under src/pages/{" "} - using Vite's import.meta.glob, which also enables hot - module replacement during development. + using the Next.js-like adapter, and rendered with FUNSTACK Router. The + convention is configurable via custom adapters.

); diff --git a/packages/example-fs-routing/src/pages/blog/index.tsx b/packages/example-fs-routing/src/pages/blog/index.tsx deleted file mode 100644 index 03f048a..0000000 --- a/packages/example-fs-routing/src/pages/blog/index.tsx +++ /dev/null @@ -1,15 +0,0 @@ -export default function Blog() { - return ( -
-

Blog

-

- This page is at pages/blog/index.tsx, which maps to the{" "} - /blog route. -

-

- Nested directories create nested URL paths. An index.tsx{" "} - file in a directory maps to the directory's path. -

-
- ); -} diff --git a/packages/example-fs-routing/src/pages/blog/page.tsx b/packages/example-fs-routing/src/pages/blog/page.tsx new file mode 100644 index 0000000..9294f08 --- /dev/null +++ b/packages/example-fs-routing/src/pages/blog/page.tsx @@ -0,0 +1,16 @@ +export default function Blog() { + return ( +
+

Blog

+

+ This page is at pages/blog/page.tsx, which maps to the{" "} + /blog route. +

+

+ Nested directories create nested URL paths. A layout.tsx{" "} + file in a directory wraps its pages (render{" "} + <Outlet /> where children should appear). +

+
+ ); +} diff --git a/packages/example-fs-routing/src/pages/index.tsx b/packages/example-fs-routing/src/pages/index.tsx deleted file mode 100644 index 3558f66..0000000 --- a/packages/example-fs-routing/src/pages/index.tsx +++ /dev/null @@ -1,28 +0,0 @@ -export default function Home() { - return ( -
-

Home

-

- Welcome to the file-system routing example! Pages in{" "} - src/pages/ are automatically mapped to routes using{" "} - import.meta.glob. -

-

How it works

-
    -
  • - pages/index.tsx/ -
  • -
  • - pages/about.tsx/about -
  • -
  • - pages/blog/index.tsx/blog -
  • -
-

- Add a new .tsx file in the pages/ directory - and it will be automatically discovered as a new route. -

-
- ); -} diff --git a/packages/example-fs-routing/src/pages/page.tsx b/packages/example-fs-routing/src/pages/page.tsx new file mode 100644 index 0000000..7c80628 --- /dev/null +++ b/packages/example-fs-routing/src/pages/page.tsx @@ -0,0 +1,28 @@ +export default function Home() { + return ( +
+

Home

+

+ Welcome to the file-system routing example! Pages in{" "} + src/pages/ are automatically mapped to routes by{" "} + @funstack/static's built-in file-system routing. +

+

How it works

+
    +
  • + pages/page.tsx/ +
  • +
  • + pages/about/page.tsx/about +
  • +
  • + pages/blog/page.tsx/blog +
  • +
+

+ Add a new page.tsx file in a directory under{" "} + pages/ and it is automatically discovered as a new route. +

+
+ ); +} diff --git a/packages/example-fs-routing/src/routes.tsx b/packages/example-fs-routing/src/routes.tsx deleted file mode 100644 index ff25660..0000000 --- a/packages/example-fs-routing/src/routes.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { route, type RouteDefinition } from "@funstack/router/server"; - -const pageModules = import.meta.glob<{ default: React.ComponentType }>( - "./pages/**/*.tsx", - { eager: true }, -); - -function filePathToUrlPath(filePath: string): string { - let urlPath = filePath.replace(/^\.\/pages/, "").replace(/\.tsx$/, ""); - if (urlPath.endsWith("/index")) { - urlPath = urlPath.slice(0, -"/index".length); - } - return urlPath || "/"; -} - -export const routes: RouteDefinition[] = Object.entries(pageModules).map( - ([filePath, module]) => { - const Page = module.default; - return route({ - path: filePathToUrlPath(filePath), - component: , - }); - }, -); diff --git a/packages/example-fs-routing/vite.config.ts b/packages/example-fs-routing/vite.config.ts index e192f96..23dc06f 100644 --- a/packages/example-fs-routing/vite.config.ts +++ b/packages/example-fs-routing/vite.config.ts @@ -5,7 +5,12 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ - entries: "./src/entries.tsx", + // Built-in file-system routing. Pages under `src/pages` are mapped to + // routes via the Next.js-like adapter and rendered with FUNSTACK Router. + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, }), react(), ], diff --git a/packages/static/e2e/fixture-fs-routing/package.json b/packages/static/e2e/fixture-fs-routing/package.json new file mode 100644 index 0000000..0b3856b --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/package.json @@ -0,0 +1,15 @@ +{ + "name": "e2e-fixture-fs-routing", + "private": true, + "type": "module", + "devDependencies": { + "@funstack/router": "^1.1.1", + "@funstack/static": "workspace:*", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "catalog:", + "react": "catalog:", + "react-dom": "catalog:", + "vite": "catalog:" + } +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/about/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/about/page.tsx new file mode 100644 index 0000000..9c3177c --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/about/page.tsx @@ -0,0 +1,8 @@ +export default function About() { + return ( +
+

About Page

+

about

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/blog/[slug]/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/blog/[slug]/page.tsx new file mode 100644 index 0000000..55e06bd --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/blog/[slug]/page.tsx @@ -0,0 +1,13 @@ +export function generateStaticParams() { + return [{ slug: "hello" }, { slug: "world" }]; +} + +export default function BlogPost({ params }: { params: { slug: string } }) { + return ( +
+

Blog Post

+

blog-post

+

{params.slug}

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/blog/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/blog/page.tsx new file mode 100644 index 0000000..7dacf83 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/blog/page.tsx @@ -0,0 +1,8 @@ +export default function Blog() { + return ( +
+

Blog Index

+

blog-index

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/layout.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/layout.tsx new file mode 100644 index 0000000..12097fd --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/layout.tsx @@ -0,0 +1,10 @@ +import { Outlet } from "@funstack/router"; + +export default function DashboardLayout() { + return ( +
+

dashboard-layout

+ +
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/page.tsx new file mode 100644 index 0000000..73f4529 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/page.tsx @@ -0,0 +1,8 @@ +export default function Dashboard() { + return ( +
+

Dashboard

+

dashboard

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/settings/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/settings/page.tsx new file mode 100644 index 0000000..e4ebe68 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/dashboard/settings/page.tsx @@ -0,0 +1,8 @@ +export default function DashboardSettings() { + return ( +
+

Dashboard Settings

+

dashboard-settings

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/pages/page.tsx b/packages/static/e2e/fixture-fs-routing/src/pages/page.tsx new file mode 100644 index 0000000..aa8b635 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/pages/page.tsx @@ -0,0 +1,8 @@ +export default function Home() { + return ( +
+

Home Page

+

home

+
+ ); +} diff --git a/packages/static/e2e/fixture-fs-routing/src/root.tsx b/packages/static/e2e/fixture-fs-routing/src/root.tsx new file mode 100644 index 0000000..31ddcba --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/root.tsx @@ -0,0 +1,20 @@ +export default function Root({ children }: { children: React.ReactNode }) { + return ( + + + + + FS Routing E2E Fixture + + + +
{children}
+ + + ); +} diff --git a/packages/static/e2e/fixture-fs-routing/vite.config.ts b/packages/static/e2e/fixture-fs-routing/vite.config.ts new file mode 100644 index 0000000..da07ba9 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/vite.config.ts @@ -0,0 +1,15 @@ +import funstackStatic from "@funstack/static"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, + }), + react(), + ], +}); diff --git a/packages/static/e2e/playwright-dev.config.ts b/packages/static/e2e/playwright-dev.config.ts index 0e6db32..cc0c6ac 100644 --- a/packages/static/e2e/playwright-dev.config.ts +++ b/packages/static/e2e/playwright-dev.config.ts @@ -37,6 +37,14 @@ export default defineConfig({ }, testMatch: /\/ssr-defer\.spec\.ts$/, }, + { + name: "fs-routing-dev", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:4180", + }, + testMatch: /\/fs-routing\.spec\.ts$/, + }, ], webServer: [ @@ -59,5 +67,12 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 120000, }, + { + command: + "cd fixture-fs-routing && pnpm vite dev --port 4180 --strictPort", + url: "http://localhost:4180/@vite/client", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, ], }); diff --git a/packages/static/e2e/playwright.config.ts b/packages/static/e2e/playwright.config.ts index 16dbc9e..ee58daa 100644 --- a/packages/static/e2e/playwright.config.ts +++ b/packages/static/e2e/playwright.config.ts @@ -37,6 +37,14 @@ export default defineConfig({ }, testMatch: /\/ssr-defer\.spec\.ts$/, }, + { + name: "fs-routing", + use: { + ...devices["Desktop Chrome"], + baseURL: "http://localhost:4179", + }, + testMatch: /\/fs-routing\.spec\.ts$/, + }, ], webServer: [ @@ -61,5 +69,12 @@ export default defineConfig({ reuseExistingServer: !process.env.CI, timeout: 120000, }, + { + command: + "cd fixture-fs-routing && pnpm vite build && pnpm dlx serve -p 4179 dist/public", + url: "http://localhost:4179", + reuseExistingServer: !process.env.CI, + timeout: 120000, + }, ], }); diff --git a/packages/static/e2e/tests-dev/fs-routing.spec.ts b/packages/static/e2e/tests-dev/fs-routing.spec.ts new file mode 100644 index 0000000..2a6aa85 --- /dev/null +++ b/packages/static/e2e/tests-dev/fs-routing.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "@playwright/test"; + +test.describe("File-system routing (dev server)", () => { + test("renders the index page", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("page-id")).toHaveText("home"); + }); + + test("renders a static nested page", async ({ page }) => { + await page.goto("/about"); + await expect(page.getByTestId("page-id")).toHaveText("about"); + }); + + test("renders a dynamic route with params", async ({ page }) => { + await page.goto("/blog/hello"); + await expect(page.getByTestId("slug")).toHaveText("hello"); + }); + + test("wraps nested pages in their layout", async ({ page }) => { + await page.goto("/dashboard/settings"); + await expect(page.getByTestId("dashboard-layout")).toHaveText( + "dashboard-layout", + ); + await expect(page.getByTestId("page-id")).toHaveText("dashboard-settings"); + }); + + test("navigates between routes on the client", async ({ page }) => { + await page.goto("/"); + await page.getByRole("link", { name: "About" }).click(); + await expect(page.getByTestId("page-id")).toHaveText("about"); + }); +}); diff --git a/packages/static/e2e/tests/fs-routing.spec.ts b/packages/static/e2e/tests/fs-routing.spec.ts new file mode 100644 index 0000000..ded2f3c --- /dev/null +++ b/packages/static/e2e/tests/fs-routing.spec.ts @@ -0,0 +1,90 @@ +import { expect, test } from "@playwright/test"; + +test.describe("File-system routing build output", () => { + test("generates an HTML file per route", async ({ request }) => { + for (const path of [ + "/", + "/about", + "/blog", + "/blog/hello", + "/blog/world", + "/dashboard", + "/dashboard/settings", + ]) { + const response = await request.get(path); + expect(response.ok(), `expected ${path} to be served`).toBe(true); + const html = await response.text(); + expect(html).toContain(""); + expect(html).toContain("funstack__/fun__rsc-payload/"); + } + }); +}); + +test.describe("File-system routing rendering", () => { + test("renders the index page", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("page-id")).toHaveText("home"); + }); + + test("renders a static nested page", async ({ page }) => { + await page.goto("/about"); + await expect(page.getByTestId("page-id")).toHaveText("about"); + }); + + test("renders the directory index page", async ({ page }) => { + await page.goto("/blog"); + await expect(page.getByTestId("page-id")).toHaveText("blog-index"); + }); + + test("statically generates dynamic routes with params", async ({ page }) => { + await page.goto("/blog/hello"); + await expect(page.getByTestId("page-id")).toHaveText("blog-post"); + await expect(page.getByTestId("slug")).toHaveText("hello"); + + await page.goto("/blog/world"); + await expect(page.getByTestId("slug")).toHaveText("world"); + }); + + test("wraps nested pages in their layout", async ({ page }) => { + await page.goto("/dashboard"); + await expect(page.getByTestId("dashboard-layout")).toHaveText( + "dashboard-layout", + ); + await expect(page.getByTestId("page-id")).toHaveText("dashboard"); + + await page.goto("/dashboard/settings"); + await expect(page.getByTestId("dashboard-layout")).toHaveText( + "dashboard-layout", + ); + await expect(page.getByTestId("page-id")).toHaveText("dashboard-settings"); + }); + + test("navigates between routes on the client", async ({ page }) => { + await page.goto("/"); + await expect(page.getByTestId("page-id")).toHaveText("home"); + + await page.getByRole("link", { name: "About" }).click(); + await expect(page.getByTestId("page-id")).toHaveText("about"); + + await page.getByRole("link", { name: "Blog", exact: true }).click(); + await expect(page.getByTestId("page-id")).toHaveText("blog-index"); + + await page.getByRole("link", { name: "Dashboard" }).click(); + await expect(page.getByTestId("dashboard-layout")).toHaveText( + "dashboard-layout", + ); + await expect(page.getByTestId("page-id")).toHaveText("dashboard"); + }); + + test("no JavaScript errors while navigating", async ({ page }) => { + const errors: string[] = []; + page.on("pageerror", (error) => { + errors.push(error.message); + }); + await page.goto("/"); + await page.waitForLoadState("networkidle"); + await page.getByRole("link", { name: "Dashboard" }).click(); + await expect(page.getByTestId("page-id")).toHaveText("dashboard"); + expect(errors).toEqual([]); + }); +}); diff --git a/packages/static/package.json b/packages/static/package.json index cb61d27..374f323 100644 --- a/packages/static/package.json +++ b/packages/static/package.json @@ -31,6 +31,10 @@ "types": "./dist/entries/server.d.mts", "import": "./dist/entries/server.mjs" }, + "./fs-routes": { + "types": "./dist/fs-routes/index.d.mts", + "import": "./dist/fs-routes/index.mjs" + }, "./entries/*": "./dist/entries/*.mjs" }, "imports": { @@ -56,6 +60,7 @@ "author": "uhyo ", "license": "MIT", "devDependencies": { + "@funstack/router": "^1.1.1", "@playwright/test": "^1.61.0", "@types/node": "catalog:", "@types/react": "^19.2.17", @@ -75,8 +80,14 @@ "srvx": "^0.11.17" }, "peerDependencies": { + "@funstack/router": "^1.1.1", "react": "^19.2.3", "react-dom": "^19.2.3", "vite": "^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@funstack/router": { + "optional": true + } } } diff --git a/packages/static/src/fs-routes/index.ts b/packages/static/src/fs-routes/index.ts new file mode 100644 index 0000000..68daad1 --- /dev/null +++ b/packages/static/src/fs-routes/index.ts @@ -0,0 +1,11 @@ +export { nextRoutes, type NextRoutesOptions } from "./nextAdapter"; +export type { + FsRoutesAdapter, + FsRouteFile, + FsRouteModule, + FsRouteTreeNode, + FsRootComponent, + MaybePromise, +} from "./types"; +export { collectStaticPaths, urlPathToFilePath, type StaticPage } from "./tree"; +export { createFsRoutesEntries, type CreateFsRoutesOptions } from "./runtime"; diff --git a/packages/static/src/fs-routes/nextAdapter.test.ts b/packages/static/src/fs-routes/nextAdapter.test.ts new file mode 100644 index 0000000..eaf7220 --- /dev/null +++ b/packages/static/src/fs-routes/nextAdapter.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it } from "vitest"; +import { nextRoutes } from "./nextAdapter"; +import type { FsRouteFile, FsRouteModule, FsRouteTreeNode } from "./types"; + +interface MarkedModule extends FsRouteModule { + __id: string; +} + +function makeFiles(paths: string[]): FsRouteFile[] { + return paths.map((filePath) => ({ + filePath, + module: { default: () => null, __id: filePath } as MarkedModule, + })); +} + +interface SimpleNode { + path: string | undefined; + page: boolean; + id: string; + children?: SimpleNode[]; +} + +function simplify(nodes: FsRouteTreeNode[]): SimpleNode[] { + return nodes.map((node) => { + const simple: SimpleNode = { + path: node.path, + page: node.page, + id: (node.module as MarkedModule).__id, + }; + if (node.children) { + simple.children = simplify(node.children); + } + return simple; + }); +} + +describe("nextRoutes adapter", () => { + it("maps flat pages without a layout to sibling routes", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles(["page.tsx", "about/page.tsx", "blog/page.tsx"]), + ); + expect(simplify(tree)).toEqual([ + { path: "/", page: true, id: "page.tsx" }, + { path: "/about", page: true, id: "about/page.tsx" }, + { path: "/blog", page: true, id: "blog/page.tsx" }, + ]); + }); + + it("wraps pages in a pathless root layout", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles([ + "layout.tsx", + "page.tsx", + "about/page.tsx", + "blog/page.tsx", + "blog/[slug]/page.tsx", + ]), + ); + expect(simplify(tree)).toEqual([ + { + path: undefined, + page: false, + id: "layout.tsx", + children: [ + { path: "/", page: true, id: "page.tsx" }, + { path: "/about", page: true, id: "about/page.tsx" }, + { path: "/blog", page: true, id: "blog/page.tsx" }, + { path: "/blog/:slug", page: true, id: "blog/[slug]/page.tsx" }, + ], + }, + ]); + }); + + it("nests a directory layout and treats its page as an index route", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles([ + "dashboard/layout.tsx", + "dashboard/page.tsx", + "dashboard/settings/page.tsx", + ]), + ); + expect(simplify(tree)).toEqual([ + { + path: "/dashboard", + page: false, + id: "dashboard/layout.tsx", + children: [ + { path: "/", page: true, id: "dashboard/page.tsx" }, + { path: "/settings", page: true, id: "dashboard/settings/page.tsx" }, + ], + }, + ]); + }); + + it("ignores route groups, supports catch-all, and ignores non-route files", () => { + const adapter = nextRoutes(); + const tree = adapter.buildRoutes( + makeFiles([ + "(marketing)/contact/page.tsx", + "docs/[...slug]/page.tsx", + "shared/Button.tsx", + ]), + ); + expect(simplify(tree)).toEqual([ + { path: "/contact", page: true, id: "(marketing)/contact/page.tsx" }, + { path: "/docs/:slug*", page: true, id: "docs/[...slug]/page.tsx" }, + ]); + }); + + it("honours custom page/layout file names", () => { + const adapter = nextRoutes({ + pageFileName: "index", + layoutFileName: "_layout", + }); + const tree = adapter.buildRoutes( + makeFiles(["_layout.tsx", "index.tsx", "about/index.tsx"]), + ); + expect(simplify(tree)).toEqual([ + { + path: undefined, + page: false, + id: "_layout.tsx", + children: [ + { path: "/", page: true, id: "index.tsx" }, + { path: "/about", page: true, id: "about/index.tsx" }, + ], + }, + ]); + }); +}); diff --git a/packages/static/src/fs-routes/nextAdapter.ts b/packages/static/src/fs-routes/nextAdapter.ts new file mode 100644 index 0000000..d173630 --- /dev/null +++ b/packages/static/src/fs-routes/nextAdapter.ts @@ -0,0 +1,168 @@ +import type { + FsRouteFile, + FsRouteModule, + FsRouteTreeNode, + FsRoutesAdapter, +} from "./types"; + +/** + * Options for the built-in Next.js-like adapter. + */ +export interface NextRoutesOptions { + /** + * Base file name (without extension) that marks a page. + * @default "page" + */ + pageFileName?: string; + /** + * Base file name (without extension) that marks a layout. + * @default "layout" + */ + layoutFileName?: string; +} + +interface TrieNode { + /** Raw directory segment name (`""` for the routes-directory root). */ + segment: string; + page?: FsRouteModule; + layout?: FsRouteModule; + children: Map; +} + +type FileKind = "page" | "layout"; + +/** + * Splits a relative file path into its directory segments and base file name. + */ +function splitFilePath(filePath: string): { dirs: string[]; base: string } { + const parts = filePath.split("/").filter(Boolean); + const base = parts.pop() ?? ""; + return { dirs: parts, base }; +} + +/** + * Classifies a base file name as a page, a layout, or `undefined` (ignored). + */ +function classify( + base: string, + pageFileName: string, + layoutFileName: string, +): FileKind | undefined { + const dot = base.lastIndexOf("."); + const name = dot === -1 ? base : base.slice(0, dot); + if (name === pageFileName) return "page"; + if (name === layoutFileName) return "layout"; + return undefined; +} + +/** + * Converts a directory segment to its URL contribution in FUNSTACK Router + * syntax, or `null` when the segment does not affect the URL. + * + * - `""` (root) → `null` + * - `(group)` route group → `null` + * - `[...slug]` catch-all → `":slug*"` + * - `[slug]` dynamic → `":slug"` + * - `about` → `"about"` + */ +function urlSegment(segment: string): string | null { + if (segment === "") return null; + if (segment.startsWith("(") && segment.endsWith(")")) return null; + const catchAll = /^\[\.\.\.(.+)\]$/.exec(segment); + if (catchAll) return `:${catchAll[1]}*`; + const dynamic = /^\[(.+)\]$/.exec(segment); + if (dynamic) return `:${dynamic[1]}`; + return segment; +} + +function ensureDir(root: TrieNode, dirs: string[]): TrieNode { + let current = root; + for (const segment of dirs) { + let next = current.children.get(segment); + if (!next) { + next = { segment, children: new Map() }; + current.children.set(segment, next); + } + current = next; + } + return current; +} + +/** + * Converts a trie node into route tree nodes. + * + * `prefix` holds URL segments contributed by ancestor directories that did not + * introduce a layout (and therefore did not open a nesting boundary). A + * directory with a `layout` opens a nesting boundary: it becomes a parent route + * consuming `prefix + ownSegment`, and its descendants are emitted relative to + * it (with an empty prefix). + */ +function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] { + const segment = urlSegment(node.segment); + const here = segment === null ? prefix : [...prefix, segment]; + + // Deterministic ordering by raw segment name. + const childNodes = [...node.children.values()].sort((a, b) => + a.segment < b.segment ? -1 : a.segment > b.segment ? 1 : 0, + ); + + if (node.layout) { + const children: FsRouteTreeNode[] = []; + if (node.page) { + children.push({ path: "/", module: node.page, page: true }); + } + for (const child of childNodes) { + children.push(...emit(child, [])); + } + const path = here.length === 0 ? undefined : `/${here.join("/")}`; + return [{ path, module: node.layout, page: false, children }]; + } + + const result: FsRouteTreeNode[] = []; + if (node.page) { + const path = here.length === 0 ? "/" : `/${here.join("/")}`; + result.push({ path, module: node.page, page: true }); + } + for (const child of childNodes) { + result.push(...emit(child, here)); + } + return result; +} + +/** + * Creates a Next.js-like file-system routing adapter (App-Router conventions). + * + * Conventions: + * - `page.{tsx,jsx}` — a page for its directory. + * - `layout.{tsx,jsx}` — a layout wrapping its directory and descendants. The + * layout component must render `` where children should appear. + * - `[param]` directory — a dynamic segment (`:param`). + * - `[...param]` directory — a catch-all segment. + * - `(group)` directory — a route group that does not affect the URL. + * + * Other files in the routes directory are ignored, so helpers and components + * may be co-located with routes. + */ +export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter { + const pageFileName = options.pageFileName ?? "page"; + const layoutFileName = options.layoutFileName ?? "layout"; + + return { + name: "next", + buildRoutes(files: FsRouteFile[]): FsRouteTreeNode[] { + const root: TrieNode = { segment: "", children: new Map() }; + for (const file of files) { + const { dirs, base } = splitFilePath(file.filePath); + const kind = classify(base, pageFileName, layoutFileName); + if (!kind) continue; + const node = ensureDir(root, dirs); + if (kind === "page") { + node.page = file.module; + } else { + node.layout = file.module; + } + } + return emit(root, []); + }, + }; +} diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx new file mode 100644 index 0000000..bbd2944 --- /dev/null +++ b/packages/static/src/fs-routes/runtime.tsx @@ -0,0 +1,117 @@ +import { createElement } from "react"; +import { Router } from "@funstack/router"; +import type { RouteDefinition } from "@funstack/router/server"; +import type { + FsRootComponent, + FsRouteFile, + FsRouteModule, + FsRoutesAdapter, + FsRouteTreeNode, +} from "./types"; +import type { EntryDefinition, GetEntriesResult } from "../entryDefinition"; +import { collectStaticPaths, urlPathToFilePath } from "./tree"; + +/** + * Options for {@link createFsRoutesEntries}. + */ +export interface CreateFsRoutesOptions { + /** + * The result of `import.meta.glob` (eager) over the routes directory, + * keyed by file path. + */ + modules: Record; + /** + * The glob base (root-relative directory, e.g. `"/src/pages"`) to strip from + * the module keys when computing each file's path relative to the routes + * directory. + */ + base: string; + /** The convention adapter mapping files to a route tree. */ + adapter: FsRoutesAdapter; + /** The root (HTML shell) component. */ + Root: FsRootComponent; +} + +/** + * Converts the eager-glob result into the list of files relative to the routes + * directory. + */ +function modulesToFiles( + modules: Record, + base: string, +): FsRouteFile[] { + const prefix = base.endsWith("/") ? base : `${base}/`; + const files: FsRouteFile[] = []; + for (const [key, module] of Object.entries(modules)) { + const filePath = key.startsWith(prefix) ? key.slice(prefix.length) : key; + files.push({ filePath, module }); + } + return files; +} + +/** + * Builds the FUNSTACK Router state for file-system routing and returns a + * `getEntries` function that yields one entry per statically-generated page. + * + * The route tree is built once via the adapter; the router route definitions + * are rebuilt per page so that concrete dynamic `params` can be passed to the + * route components. + */ +export function createFsRoutesEntries( + options: CreateFsRoutesOptions, +): () => GetEntriesResult { + const { modules, base, adapter, Root } = options; + const files = modulesToFiles(modules, base); + const tree = adapter.buildRoutes(files); + + function buildRouteDefinitions( + nodes: FsRouteTreeNode[], + params: Record, + ): RouteDefinition[] { + return nodes.map((node): RouteDefinition => { + const Component = node.module.default; + const definition: { + path?: string; + component?: React.ReactNode; + children?: RouteDefinition[]; + } = {}; + if (node.path !== undefined) { + definition.path = node.path; + } + if (Component) { + definition.component = createElement( + Component as React.ComponentType<{ params: Record }>, + { params }, + ); + } + if (node.children) { + definition.children = buildRouteDefinitions(node.children, params); + } + return definition; + }); + } + + function FsRoutesApp({ + path, + params, + }: { + path: string; + params: Record; + }): React.ReactNode { + const routes = buildRouteDefinitions(tree, params); + return createElement(Router, { routes, fallback: "static", ssr: { path } }); + } + + return async function* getEntries(): AsyncGenerator { + const pages = await collectStaticPaths(tree, (message) => { + console.warn(`[funstack] ${message}`); + }); + for (const { urlPath, params } of pages) { + yield { + path: urlPathToFilePath(urlPath), + root: { default: Root }, + app: createElement(FsRoutesApp, { path: urlPath, params }), + }; + } + }; +} diff --git a/packages/static/src/fs-routes/tree.test.ts b/packages/static/src/fs-routes/tree.test.ts new file mode 100644 index 0000000..6502898 --- /dev/null +++ b/packages/static/src/fs-routes/tree.test.ts @@ -0,0 +1,126 @@ +import { describe, expect, it, vi } from "vitest"; +import { collectStaticPaths, urlPathToFilePath } from "./tree"; +import type { FsRouteModule, FsRouteTreeNode } from "./types"; + +const component: FsRouteModule = { default: () => null }; + +function pageModule( + generateStaticParams?: FsRouteModule["generateStaticParams"], +): FsRouteModule { + return { default: () => null, generateStaticParams }; +} + +describe("collectStaticPaths", () => { + it("collects static pages, including index pages under a layout", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: undefined, + page: false, + module: component, + children: [ + { path: "/", page: true, module: component }, + { path: "/about", page: true, module: component }, + ], + }, + ]; + const pages = await collectStaticPaths(tree); + expect(pages).toEqual([ + { urlPath: "/", params: {} }, + { urlPath: "/about", params: {} }, + ]); + }); + + it("accumulates the path of a nested layout for its children", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/dashboard", + page: false, + module: component, + children: [ + { path: "/", page: true, module: component }, + { path: "/settings", page: true, module: component }, + ], + }, + ]; + const pages = await collectStaticPaths(tree); + expect(pages.map((p) => p.urlPath)).toEqual([ + "/dashboard", + "/dashboard/settings", + ]); + }); + + it("expands a dynamic route via generateStaticParams", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/blog/:slug", + page: true, + module: pageModule(() => [{ slug: "hello" }, { slug: "world" }]), + }, + ]; + const pages = await collectStaticPaths(tree); + expect(pages).toEqual([ + { urlPath: "/blog/hello", params: { slug: "hello" } }, + { urlPath: "/blog/world", params: { slug: "world" } }, + ]); + }); + + it("supports async generateStaticParams", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/u/:id", + page: true, + module: pageModule(async () => [{ id: "1" }]), + }, + ]; + const pages = await collectStaticPaths(tree); + expect(pages).toEqual([{ urlPath: "/u/1", params: { id: "1" } }]); + }); + + it("substitutes catch-all values that contain slashes", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/docs/:slug*", + page: true, + module: pageModule(() => [{ slug: "guide/intro" }]), + }, + ]; + const pages = await collectStaticPaths(tree); + expect(pages).toEqual([ + { urlPath: "/docs/guide/intro", params: { slug: "guide/intro" } }, + ]); + }); + + it("warns and skips a dynamic route without generateStaticParams", async () => { + const tree: FsRouteTreeNode[] = [ + { path: "/blog/:slug", page: true, module: component }, + ]; + const warn = vi.fn(); + const pages = await collectStaticPaths(tree, warn); + expect(pages).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0]?.[0]).toContain("/blog/:slug"); + }); + + it("throws when generateStaticParams is missing a param value", async () => { + const tree: FsRouteTreeNode[] = [ + { + path: "/blog/:slug", + page: true, + module: pageModule(() => [{ other: "x" }]), + }, + ]; + await expect(collectStaticPaths(tree)).rejects.toThrow(/slug/); + }); +}); + +describe("urlPathToFilePath", () => { + it.each([ + ["/", "index.html"], + ["", "index.html"], + ["/about", "about.html"], + ["/blog/hello", "blog/hello.html"], + ["/docs/guide/intro", "docs/guide/intro.html"], + ])("maps %s to %s", (urlPath, expected) => { + expect(urlPathToFilePath(urlPath)).toBe(expected); + }); +}); diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts new file mode 100644 index 0000000..77d2b62 --- /dev/null +++ b/packages/static/src/fs-routes/tree.ts @@ -0,0 +1,135 @@ +import type { FsRouteModule, FsRouteTreeNode } from "./types"; + +/** + * A single page to statically generate. + */ +export interface StaticPage { + /** Concrete URL path, e.g. `"/"`, `"/about"`, `"/blog/hello"`. */ + urlPath: string; + /** Resolved dynamic params for this page (empty for static routes). */ + params: Record; +} + +/** + * Splits a FUNSTACK Router path (e.g. `"/blog/:slug"`) into its non-empty + * segments. A pathless or `"/"` path yields no segments. + */ +function splitRoutePath(path: string): string[] { + return path.split("/").filter(Boolean); +} + +/** + * Joins URL segments into a normalized absolute URL path. + */ +function segmentsToUrl(segments: string[]): string { + const joined = segments + .join("/") + .replace(/\/+/g, "/") + .replace(/^\//, "") + .replace(/\/$/, ""); + return joined === "" ? "/" : `/${joined}`; +} + +/** + * Extracts the param name from a dynamic segment. + * `":slug"` → `"slug"`, `":slug*"` (catch-all) → `"slug"`. + */ +function paramName(segment: string): string { + return segment.slice(1).replace(/\*$/, ""); +} + +/** + * Whether a router segment is dynamic (`:param` or catch-all `:param*`). + */ +function isDynamicSegment(segment: string): boolean { + return segment.startsWith(":"); +} + +async function addPagesForLeaf( + segments: string[], + module: FsRouteModule, + pages: StaticPage[], + onWarn?: (message: string) => void, +): Promise { + const dynamicSegments = segments.filter(isDynamicSegment); + + if (dynamicSegments.length === 0) { + pages.push({ urlPath: segmentsToUrl(segments), params: {} }); + return; + } + + const generate = module.generateStaticParams; + if (typeof generate !== "function") { + onWarn?.( + `Dynamic route "${segmentsToUrl(segments)}" has no generateStaticParams() export; skipping static generation. ` + + `It will still work on the client via the SPA fallback.`, + ); + return; + } + + const paramSets = await generate(); + for (const params of paramSets) { + const concreteSegments = segments.map((segment) => { + if (!isDynamicSegment(segment)) return segment; + const name = paramName(segment); + const value = params[name]; + if (value === undefined) { + throw new Error( + `generateStaticParams() for "${segmentsToUrl(segments)}" is missing a value for param "${name}".`, + ); + } + return value; + }); + pages.push({ urlPath: segmentsToUrl(concreteSegments), params }); + } +} + +async function walk( + nodes: FsRouteTreeNode[], + prefixSegments: string[], + pages: StaticPage[], + onWarn?: (message: string) => void, +): Promise { + for (const node of nodes) { + const ownSegments = + node.path !== undefined ? splitRoutePath(node.path) : []; + const segments = [...prefixSegments, ...ownSegments]; + if (node.page) { + await addPagesForLeaf(segments, node.module, pages, onWarn); + } + if (node.children) { + await walk(node.children, segments, pages, onWarn); + } + } +} + +/** + * Walks a route tree and enumerates every page to statically generate. + * + * Static routes are emitted directly. Dynamic routes (with `:param` or + * catch-all segments) are expanded using each page module's + * `generateStaticParams()`; if absent, the route is skipped and `onWarn` is + * invoked. + */ +export async function collectStaticPaths( + tree: FsRouteTreeNode[], + onWarn?: (message: string) => void, +): Promise { + const pages: StaticPage[] = []; + await walk(tree, [], pages, onWarn); + return pages; +} + +/** + * Maps a URL path to the output HTML file path relative to the build output. + * + * `"/"` → `"index.html"`, `"/about"` → `"about.html"`, + * `"/blog/hello"` → `"blog/hello.html"`. + */ +export function urlPathToFilePath(urlPath: string): string { + if (urlPath === "/" || urlPath === "") { + return "index.html"; + } + const stripped = urlPath.replace(/^\//, "").replace(/\/$/, ""); + return `${stripped}.html`; +} diff --git a/packages/static/src/fs-routes/types.ts b/packages/static/src/fs-routes/types.ts new file mode 100644 index 0000000..a513b6c --- /dev/null +++ b/packages/static/src/fs-routes/types.ts @@ -0,0 +1,87 @@ +import type { ComponentType, ReactNode } from "react"; + +export type MaybePromise = T | Promise; + +/** + * Module shape for a discovered route file (a page or a layout). + * + * Route files `export default` a React component. Page modules may also + * `export` a `generateStaticParams` function to enumerate concrete params + * for dynamic routes (modeled after Next.js). + */ +export interface FsRouteModule { + /** The component for this page or layout. */ + default?: ComponentType<{ params: Record }> | ComponentType; + /** + * Optional function used to statically generate a dynamic route. + * + * Returns the list of concrete params to pre-render. Each entry maps every + * dynamic param name in the route's path to a concrete string value. For a + * catch-all segment, the value may contain slashes. + * + * Without this export, a dynamic route is not pre-rendered to HTML (it still + * works on the client via the SPA fallback). + */ + generateStaticParams?: () => MaybePromise>>; + [key: string]: unknown; +} + +/** + * A route file discovered in the routes directory. + */ +export interface FsRouteFile { + /** + * Path relative to the routes directory, using POSIX separators and + * including the file extension. + * + * Examples: `"page.tsx"`, `"about/page.tsx"`, `"blog/[slug]/page.tsx"`. + */ + filePath: string; + /** The eagerly-imported module for this file. */ + module: FsRouteModule; +} + +/** + * A node in the route tree produced by an adapter. + * + * The framework converts this tree both into FUNSTACK Router route definitions + * and into the list of pages to statically generate. + */ +export interface FsRouteTreeNode { + /** + * Path segment(s) for this node relative to its parent, in FUNSTACK Router + * syntax (leading slash). Examples: `"/"`, `"/blog"`, `"/:slug"`, + * `"/docs/:slug*"`. + * + * `undefined` makes this a pathless layout route that always matches and + * consumes no pathname. + */ + path?: string; + /** The module providing this node's component (page or layout). */ + module: FsRouteModule; + /** + * Whether this node is a concrete page that should be statically generated. + * Layout nodes set this to `false`. + */ + page: boolean; + /** Child route nodes. */ + children?: FsRouteTreeNode[]; +} + +/** + * An adapter that maps file-system naming conventions to a route tree. + * + * Implement this interface to support a custom directory / file-name + * convention. A Next.js-like adapter is provided built-in via `nextRoutes()`. + */ +export interface FsRoutesAdapter { + /** Adapter name, used in diagnostics. */ + name: string; + /** Build a route tree from the discovered route files. */ + buildRoutes(files: FsRouteFile[]): FsRouteTreeNode[]; +} + +/** + * The root (HTML shell) component type used by file-system routing. + */ +export type FsRootComponent = ComponentType<{ children: ReactNode }>; diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index acd1f1d..3e266d0 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -68,6 +68,7 @@ interface SingleEntryOptions { */ app: string; entries?: never; + fsRoutes?: never; } interface MultipleEntriesOptions { @@ -78,10 +79,52 @@ interface MultipleEntriesOptions { * Mutually exclusive with `root`+`app`. */ entries: string; + fsRoutes?: never; +} + +/** + * Options for built-in file-system based routing. + * + * Pages discovered in `dir` are mapped to routes via an adapter (which defines + * the directory / file-name convention) and rendered through FUNSTACK Router. + * Using this feature requires `@funstack/router` to be installed. + */ +export interface FsRoutesConfig { + /** + * Directory containing route files, relative to the Vite root. + * + * @default "./src/pages" + */ + dir?: string; + /** + * Path to the root (HTML shell) component module. + * The file should `export default` a React component that renders the whole + * page (`...`) and places `children` inside ``. + */ + root: string; + /** + * Path to a module that `export default`s an `FsRoutesAdapter`, which defines + * the directory / file-name convention. + * + * Defaults to the built-in Next.js-like adapter (`nextRoutes()` from + * `@funstack/static/fs-routes`). + */ + adapter?: string; +} + +interface FsRoutesOptions { + root?: never; + app?: never; + entries?: never; + /** + * Enable built-in file-system based routing. + * Mutually exclusive with `root`+`app` and `entries`. + */ + fsRoutes: FsRoutesConfig; } export type FunstackStaticOptions = FunstackStaticBaseOptions & - (SingleEntryOptions | MultipleEntriesOptions); + (SingleEntryOptions | MultipleEntriesOptions | FsRoutesOptions); export default function funstackStatic( options: FunstackStaticOptions, @@ -109,9 +152,15 @@ export default function funstackStatic( let resolvedEntriesModule: string = "__uninitialized__"; let resolvedClientInitEntry: string | undefined; let resolvedBuildEntry: string | undefined; + // Resolved configuration for file-system routing (fsRoutes mode). + let resolvedFsRoutes: + | { root: string; adapter: string | undefined; globBase: string } + | undefined; - // Determine whether user specified entries or root+app - const isMultiEntry = "entries" in options && options.entries !== undefined; + // Determine which entry mode the user selected. + const isFsRoutes = "fsRoutes" in options && options.fsRoutes !== undefined; + const isMultiEntry = + !isFsRoutes && "entries" in options && options.entries !== undefined; return [ { @@ -142,7 +191,21 @@ export default function funstackStatic( { name: "@funstack/static:config", configResolved(config) { - if (isMultiEntry) { + if (isFsRoutes) { + const fsRoutes = options.fsRoutes; + const dir = fsRoutes.dir ?? "./src/pages"; + const resolvedRoot = normalizePath( + path.resolve(config.root, fsRoutes.root), + ); + const resolvedDir = normalizePath(path.resolve(config.root, dir)); + // Glob patterns in generated virtual modules must be root-relative. + let globBase = normalizePath(path.relative(config.root, resolvedDir)); + globBase = `/${globBase.replace(/^\.?\/?/, "").replace(/\/$/, "")}`; + const adapter = fsRoutes.adapter + ? normalizePath(path.resolve(config.root, fsRoutes.adapter)) + : undefined; + resolvedFsRoutes = { root: resolvedRoot, adapter, globBase }; + } else if (isMultiEntry) { resolvedEntriesModule = normalizePath( path.resolve(config.root, options.entries), ); @@ -214,6 +277,28 @@ export default function funstackStatic( }, load(id) { if (id === "\0virtual:funstack/entries") { + if (isFsRoutes && resolvedFsRoutes) { + // Synthesize a getEntries module from file-system routing config. + const { root, adapter, globBase } = resolvedFsRoutes; + const globPattern = `${globBase}/**/*.{tsx,jsx}`; + const lines = [ + `import Root from "${root}";`, + `import { createFsRoutesEntries, nextRoutes } from "@funstack/static/fs-routes";`, + ]; + if (adapter) { + lines.push(`import adapter from "${adapter}";`); + } + lines.push( + `const modules = import.meta.glob(${JSON.stringify(globPattern)}, { eager: true });`, + `export default createFsRoutesEntries({`, + ` modules,`, + ` base: ${JSON.stringify(globBase)},`, + ` adapter: ${adapter ? "adapter" : "nextRoutes()"},`, + ` Root,`, + `});`, + ); + return lines.join("\n"); + } if (isMultiEntry) { // Re-export the user's entries module return `export { default } from "${resolvedEntriesModule}";`; diff --git a/packages/static/tsdown.config.ts b/packages/static/tsdown.config.ts index 8eb5a9d..7a6a140 100644 --- a/packages/static/tsdown.config.ts +++ b/packages/static/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ "src/entryDefinition.ts", "src/entries/*.ts", "src/bin/*.ts", + "src/fs-routes/index.ts", ], // Vite virtual modules & subpath imports external: [/^virtual:/, /^#/], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57b3c55..a5accc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -171,6 +171,9 @@ importers: specifier: ^0.11.17 version: 0.11.17 devDependencies: + '@funstack/router': + specifier: ^1.1.1 + version: 1.1.1(react@19.2.7) '@playwright/test': specifier: ^1.61.0 version: 1.61.0 @@ -226,6 +229,33 @@ importers: specifier: 'catalog:' version: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(terser@5.47.1)(tsx@4.21.0) + packages/static/e2e/fixture-fs-routing: + devDependencies: + '@funstack/router': + specifier: ^1.1.1 + version: 1.1.1(react@19.2.7) + '@funstack/static': + specifier: workspace:* + version: link:../.. + '@types/react': + specifier: ^19.2.17 + version: 19.2.17 + '@types/react-dom': + specifier: ^19.2.3 + version: 19.2.3(@types/react@19.2.17) + '@vitejs/plugin-react': + specifier: 'catalog:' + version: 6.0.2(vite@8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(terser@5.47.1)(tsx@4.21.0)) + react: + specifier: 'catalog:' + version: 19.2.7 + react-dom: + specifier: 'catalog:' + version: 19.2.7(react@19.2.7) + vite: + specifier: 'catalog:' + version: 8.0.16(@types/node@26.0.0)(esbuild@0.28.1)(terser@5.47.1)(tsx@4.21.0) + packages/static/e2e/fixture-multi-entry: devDependencies: '@funstack/static': diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index f1efec3..1783282 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,7 @@ packages: - packages/static/e2e/fixture - packages/static/e2e/fixture-multi-entry - packages/static/e2e/fixture-ssr-defer + - packages/static/e2e/fixture-fs-routing catalog: "@types/node": ^26.0.0 From f506e55835b682007547ac1b04725ad0005aa9dd Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 13:53:36 +0000 Subject: [PATCH 2/5] refactor: make file-system routing an entries-based helper Address review feedback: - Drop the dedicated `fsRoutes` plugin option. File-system routing is now set up entirely through the existing `entries` option: the user writes an entries module that globs pages with `import.meta.glob` and passes them to `createFsRoutesEntries` from `@funstack/static/fs-routes`, along with the `root` component and an optional `adapter`. This keeps the plugin surface to two modes and lets the glob/Root/adapter be plain values. - `createFsRoutesEntries` now auto-strips the common glob prefix (`modulesToRouteFiles`), so no directory needs to be configured twice. - Mark file-system routing as experimental (not yet covered by semantic versioning) in the public API JSDoc and docs. - Update the example, e2e fixture, and docs to the entries-based usage. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012L8PCU7xhNXfuepS7eKzro --- .../docs/src/pages/api/FunstackStatic.mdx | 56 +---------- .../src/pages/learn/FileSystemRouting.mdx | 97 ++++++++++++------- packages/example-fs-routing/src/entries.tsx | 13 +++ packages/example-fs-routing/vite.config.ts | 9 +- .../e2e/fixture-fs-routing/src/entries.tsx | 9 ++ .../e2e/fixture-fs-routing/vite.config.ts | 5 +- packages/static/src/fs-routes/index.ts | 8 ++ packages/static/src/fs-routes/nextAdapter.ts | 3 + packages/static/src/fs-routes/runtime.tsx | 84 +++++++++------- packages/static/src/fs-routes/tree.test.ts | 45 ++++++++- packages/static/src/fs-routes/tree.ts | 40 +++++++- packages/static/src/fs-routes/types.ts | 3 + packages/static/src/plugin/index.ts | 93 +----------------- 13 files changed, 237 insertions(+), 228 deletions(-) create mode 100644 packages/example-fs-routing/src/entries.tsx create mode 100644 packages/static/e2e/fixture-fs-routing/src/entries.tsx diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index d8834dc..896c3e0 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -10,7 +10,7 @@ import funstackStatic from "@funstack/static"; ## Usage -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). +There are two configuration modes: **single-entry** (one HTML page) and **multiple entries** (multiple HTML pages). ### Single-Entry Mode @@ -53,34 +53,11 @@ export default defineConfig({ See [Multiple Entrypoints](/advanced/multiple-entrypoints) for a full guide. -### File-System Routing Mode - -Use `fsRoutes` to map pages from the file system to routes, rendered with FUNSTACK Router: - -```typescript -// vite.config.ts -import funstackStatic from "@funstack/static"; -import react from "@vitejs/plugin-react"; -import { defineConfig } from "vite"; - -export default defineConfig({ - plugins: [ - funstackStatic({ - fsRoutes: { - dir: "./src/pages", - root: "./src/root.tsx", - }, - }), - react(), - ], -}); -``` - -See [File-System Routing](/learn/file-system-routing) for a full guide. +> **Tip:** For convention-based routing, the `entries` module can be generated from a `pages/` directory using built-in [File-System Routing](/learn/file-system-routing). ## Options -The plugin accepts exactly one of `root` + `app` (single-entry), `entries` (multiple entries), or `fsRoutes` (file-system routing). These modes are mutually exclusive. +The plugin accepts either `root` + `app` (single-entry) or `entries` (multiple entries). These two modes are mutually exclusive. ### root @@ -185,33 +162,6 @@ export default function getEntries(): EntryDefinition[] { See [Multiple Entrypoints](/advanced/multiple-entrypoints) for details on the `EntryDefinition` type and advanced usage patterns like async generators. -### fsRoutes - -**Type:** `FsRoutesConfig` -**Required in:** file-system routing mode - -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. - -Cannot be used together with `root`, `app`, or `entries`. - -```typescript -funstackStatic({ - fsRoutes: { - dir: "./src/pages", - root: "./src/root.tsx", - adapter: "./src/my-adapter.ts", // optional - }, -}); -``` - -`FsRoutesConfig` fields: - -- **`dir`** (optional, default `"./src/pages"`) — directory scanned for route files, relative to the Vite root. -- **`root`** (required) — path to the root (HTML shell) component module. -- **`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`). - -See [File-System Routing](/learn/file-system-routing) for the conventions, dynamic routes, and writing custom adapters. - ### publicOutDir (optional) **Type:** `string` diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 7311b9b..fa23022 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -1,12 +1,14 @@ # File-System Routing -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. +> **Experimental.** File-system routing is experimental. Its API may change in a minor release and is **not** yet covered by semantic versioning. -The directory / file-name convention is pluggable through an **adapter**, and a Next.js-like adapter is provided out of the box. +FUNSTACK Static ships a built-in file-system router. Pages discovered in a directory are mapped to routes and rendered with [FUNSTACK Router](https://github.com/uhyo/funstack-router), generating one static HTML file per route. + +It is built on top of the [`entries`](/api/funstack-static) option: you write a small entries module that globs your pages and hands them to `@funstack/static/fs-routes`. The directory / file-name convention is pluggable through an **adapter**, and a Next.js-like adapter is provided out of the box. ## Requirements -File-system routing renders pages with FUNSTACK Router, so `@funstack/router` must be installed as a peer dependency: +File-system routing renders pages with FUNSTACK Router, so `@funstack/router` must be installed: ```sh npm install @funstack/router @@ -14,7 +16,33 @@ npm install @funstack/router ## Setup -Enable file-system routing with the `fsRoutes` option: +### 1. Create an entries module + +The entries module globs your pages with `import.meta.glob` and passes them to `createFsRoutesEntries` along with your root (HTML shell) component: + +```tsx +// src/entries.tsx +import { createFsRoutesEntries } from "@funstack/static/fs-routes"; +import Root from "./root"; + +// Discover all page/layout files under `pages/` at build time. +const modules = import.meta.glob<{ default: React.ComponentType }>( + "./pages/**/*.{tsx,jsx}", + { eager: true }, +); + +export default createFsRoutesEntries({ modules, root: Root }); +``` + +`createFsRoutesEntries` accepts: + +- `modules` — the eager `import.meta.glob` result for your pages directory. +- `root` — the root (HTML shell) component. +- `adapter` — optional; defaults to the built-in Next.js-like adapter (`nextRoutes()`). + +Because you own the glob, the pattern lives in your code (resolved relative to the entries module) — there is no extra plugin configuration. + +### 2. Point the plugin at the entries module ```typescript // vite.config.ts @@ -25,22 +53,13 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ - fsRoutes: { - dir: "./src/pages", - root: "./src/root.tsx", - }, + entries: "./src/entries.tsx", }), react(), ], }); ``` -- `dir` — the directory scanned for route files (default `./src/pages`). -- `root` — the HTML shell component (`…{children}`). -- `adapter` — optional path to a custom adapter module (defaults to the built-in Next.js-like adapter). - -`fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. - ## The Next.js-like Convention The built-in adapter follows Next.js App-Router conventions: @@ -103,24 +122,17 @@ A dynamic route without `generateStaticParams` is **not** pre-rendered (a warnin ## Custom Conventions (Adapters) -The convention is defined by an **adapter** implementing `FsRoutesAdapter`. Provide a module that `export default`s an adapter to use a different convention: - -```typescript -// vite.config.ts -funstackStatic({ - fsRoutes: { - dir: "./src/pages", - root: "./src/root.tsx", - adapter: "./src/my-adapter.ts", - }, -}); -``` +The convention is defined by an **adapter** implementing `FsRoutesAdapter`. Pass an `adapter` to use a different convention: ```tsx -// src/my-adapter.ts -import type { FsRoutesAdapter } from "@funstack/static/fs-routes"; - -const adapter: FsRoutesAdapter = { +// src/entries.tsx +import { + createFsRoutesEntries, + type FsRoutesAdapter, +} from "@funstack/static/fs-routes"; +import Root from "./root"; + +const myAdapter: FsRoutesAdapter = { name: "my-convention", buildRoutes(files) { // Map discovered files to a route tree. @@ -129,30 +141,41 @@ const adapter: FsRoutesAdapter = { }, }; -export default adapter; +const modules = import.meta.glob<{ default: React.ComponentType }>( + "./pages/**/*.{tsx,jsx}", + { eager: true }, +); + +export default createFsRoutesEntries({ + modules, + root: Root, + adapter: myAdapter, +}); ``` -The built-in Next.js-like adapter is also exported, so you can wrap or configure it: +The built-in Next.js-like adapter can also be configured: ```tsx -// src/my-adapter.ts import { nextRoutes } from "@funstack/static/fs-routes"; // e.g. use `index.tsx` instead of `page.tsx` -export default nextRoutes({ pageFileName: "index", layoutFileName: "_layout" }); +const adapter = nextRoutes({ + pageFileName: "index", + layoutFileName: "_layout", +}); ``` ## Full Example 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. -## Userland File-System Routing +## Fully Custom Routing -If you prefer full control, you can still implement routing in userland with Vite's `import.meta.glob` and the [`entries`](/api/funstack-static) option — deriving [entry definitions](/api/entry-definition) from a glob of page files. The built-in feature is a maintained implementation of this same idea. +`createFsRoutesEntries` 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 and deriving [entry definitions](/api/entry-definition) yourself. ## See Also -- [funstackStatic()](/api/funstack-static) - The `fsRoutes` plugin option +- [funstackStatic()](/api/funstack-static) - The `entries` plugin option - [Multiple Entrypoints](/advanced/multiple-entrypoints) - Generating multiple HTML pages from a single project - [EntryDefinition](/api/entry-definition) - API reference for entry definitions - [How It Works](/learn/how-it-works) - Overall FUNSTACK Static architecture diff --git a/packages/example-fs-routing/src/entries.tsx b/packages/example-fs-routing/src/entries.tsx new file mode 100644 index 0000000..97d9387 --- /dev/null +++ b/packages/example-fs-routing/src/entries.tsx @@ -0,0 +1,13 @@ +import { createFsRoutesEntries } from "@funstack/static/fs-routes"; +import Root from "./root"; + +// Discover all page/layout files under `pages/` at build time. +const modules = import.meta.glob<{ default: React.ComponentType }>( + "./pages/**/*.{tsx,jsx}", + { eager: true }, +); + +// Map the files to routes using the built-in Next.js-like adapter and render +// them with FUNSTACK Router. Pass a custom `adapter` to use a different +// convention. +export default createFsRoutesEntries({ modules, root: Root }); diff --git a/packages/example-fs-routing/vite.config.ts b/packages/example-fs-routing/vite.config.ts index 23dc06f..ffd543f 100644 --- a/packages/example-fs-routing/vite.config.ts +++ b/packages/example-fs-routing/vite.config.ts @@ -5,12 +5,9 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ - // Built-in file-system routing. Pages under `src/pages` are mapped to - // routes via the Next.js-like adapter and rendered with FUNSTACK Router. - fsRoutes: { - dir: "./src/pages", - root: "./src/root.tsx", - }, + // File-system routing is set up in the entries module using + // `@funstack/static/fs-routes`. + entries: "./src/entries.tsx", }), react(), ], diff --git a/packages/static/e2e/fixture-fs-routing/src/entries.tsx b/packages/static/e2e/fixture-fs-routing/src/entries.tsx new file mode 100644 index 0000000..d837325 --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/src/entries.tsx @@ -0,0 +1,9 @@ +import { createFsRoutesEntries } from "@funstack/static/fs-routes"; +import Root from "./root"; + +const modules = import.meta.glob<{ default: React.ComponentType }>( + "./pages/**/*.{tsx,jsx}", + { eager: true }, +); + +export default createFsRoutesEntries({ modules, root: Root }); diff --git a/packages/static/e2e/fixture-fs-routing/vite.config.ts b/packages/static/e2e/fixture-fs-routing/vite.config.ts index da07ba9..e192f96 100644 --- a/packages/static/e2e/fixture-fs-routing/vite.config.ts +++ b/packages/static/e2e/fixture-fs-routing/vite.config.ts @@ -5,10 +5,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ - fsRoutes: { - dir: "./src/pages", - root: "./src/root.tsx", - }, + entries: "./src/entries.tsx", }), react(), ], diff --git a/packages/static/src/fs-routes/index.ts b/packages/static/src/fs-routes/index.ts index 68daad1..35bb34b 100644 --- a/packages/static/src/fs-routes/index.ts +++ b/packages/static/src/fs-routes/index.ts @@ -1,3 +1,11 @@ +/** + * Built-in file-system routing for `@funstack/static`. + * + * @experimental This module is experimental and not yet subject to semantic + * versioning. Its API may change in a minor release. + * + * @packageDocumentation + */ export { nextRoutes, type NextRoutesOptions } from "./nextAdapter"; export type { FsRoutesAdapter, diff --git a/packages/static/src/fs-routes/nextAdapter.ts b/packages/static/src/fs-routes/nextAdapter.ts index d173630..19bd4c1 100644 --- a/packages/static/src/fs-routes/nextAdapter.ts +++ b/packages/static/src/fs-routes/nextAdapter.ts @@ -142,6 +142,9 @@ function emit(node: TrieNode, prefix: string[]): FsRouteTreeNode[] { * * Other files in the routes directory are ignored, so helpers and components * may be co-located with routes. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. */ export function nextRoutes(options: NextRoutesOptions = {}): FsRoutesAdapter { const pageFileName = options.pageFileName ?? "page"; diff --git a/packages/static/src/fs-routes/runtime.tsx b/packages/static/src/fs-routes/runtime.tsx index bbd2944..a3ab55c 100644 --- a/packages/static/src/fs-routes/runtime.tsx +++ b/packages/static/src/fs-routes/runtime.tsx @@ -3,66 +3,71 @@ import { Router } from "@funstack/router"; import type { RouteDefinition } from "@funstack/router/server"; import type { FsRootComponent, - FsRouteFile, FsRouteModule, FsRoutesAdapter, FsRouteTreeNode, } from "./types"; import type { EntryDefinition, GetEntriesResult } from "../entryDefinition"; -import { collectStaticPaths, urlPathToFilePath } from "./tree"; +import { nextRoutes } from "./nextAdapter"; +import { + collectStaticPaths, + modulesToRouteFiles, + urlPathToFilePath, +} from "./tree"; /** * Options for {@link createFsRoutesEntries}. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. */ export interface CreateFsRoutesOptions { /** - * The result of `import.meta.glob` (eager) over the routes directory, - * keyed by file path. + * The result of `import.meta.glob` (eager) over the routes directory, keyed + * by file path. Glob your pages directory from your entries module, e.g. + * `import.meta.glob("./pages/**\/*.{tsx,jsx}", { eager: true })`. */ modules: Record; /** - * The glob base (root-relative directory, e.g. `"/src/pages"`) to strip from - * the module keys when computing each file's path relative to the routes - * directory. + * The root (HTML shell) component. Renders the whole page + * (`…{children}`). */ - base: string; - /** The convention adapter mapping files to a route tree. */ - adapter: FsRoutesAdapter; - /** The root (HTML shell) component. */ - Root: FsRootComponent; -} - -/** - * Converts the eager-glob result into the list of files relative to the routes - * directory. - */ -function modulesToFiles( - modules: Record, - base: string, -): FsRouteFile[] { - const prefix = base.endsWith("/") ? base : `${base}/`; - const files: FsRouteFile[] = []; - for (const [key, module] of Object.entries(modules)) { - const filePath = key.startsWith(prefix) ? key.slice(prefix.length) : key; - files.push({ filePath, module }); - } - return files; + root: FsRootComponent; + /** + * The convention adapter mapping files to a route tree. + * + * @default nextRoutes() + */ + adapter?: FsRoutesAdapter; } /** - * Builds the FUNSTACK Router state for file-system routing and returns a - * `getEntries` function that yields one entry per statically-generated page. + * Builds FUNSTACK Router state for file-system routing and returns a + * `getEntries` function (the default export expected by the `entries` plugin + * option). One entry is produced per statically-generated page. * * The route tree is built once via the adapter; the router route definitions * are rebuilt per page so that concrete dynamic `params` can be passed to the * route components. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. Its API may change in a minor release. + * + * @example + * ```tsx + * // src/entries.tsx + * import { createFsRoutesEntries } from "@funstack/static/fs-routes"; + * import Root from "./root"; + * + * const modules = import.meta.glob("./pages/**\/*.{tsx,jsx}", { eager: true }); + * + * export default createFsRoutesEntries({ modules, root: Root }); + * ``` */ export function createFsRoutesEntries( options: CreateFsRoutesOptions, ): () => GetEntriesResult { - const { modules, base, adapter, Root } = options; - const files = modulesToFiles(modules, base); - const tree = adapter.buildRoutes(files); + const { modules, root: Root, adapter = nextRoutes() } = options; function buildRouteDefinitions( nodes: FsRouteTreeNode[], @@ -92,9 +97,11 @@ export function createFsRoutesEntries( } function FsRoutesApp({ + tree, path, params, }: { + tree: FsRouteTreeNode[]; path: string; params: Record; }): React.ReactNode { @@ -103,14 +110,17 @@ export function createFsRoutesEntries( } return async function* getEntries(): AsyncGenerator { - const pages = await collectStaticPaths(tree, (message) => { + const warn = (message: string) => { console.warn(`[funstack] ${message}`); - }); + }; + const files = modulesToRouteFiles(modules, warn); + const tree = adapter.buildRoutes(files); + const pages = await collectStaticPaths(tree, warn); for (const { urlPath, params } of pages) { yield { path: urlPathToFilePath(urlPath), root: { default: Root }, - app: createElement(FsRoutesApp, { path: urlPath, params }), + app: createElement(FsRoutesApp, { tree, path: urlPath, params }), }; } }; diff --git a/packages/static/src/fs-routes/tree.test.ts b/packages/static/src/fs-routes/tree.test.ts index 6502898..3060c33 100644 --- a/packages/static/src/fs-routes/tree.test.ts +++ b/packages/static/src/fs-routes/tree.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it, vi } from "vitest"; -import { collectStaticPaths, urlPathToFilePath } from "./tree"; +import { + collectStaticPaths, + modulesToRouteFiles, + urlPathToFilePath, +} from "./tree"; import type { FsRouteModule, FsRouteTreeNode } from "./types"; const component: FsRouteModule = { default: () => null }; @@ -113,6 +117,45 @@ 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, + }); + 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, + }); + expect(files.map((f) => f.filePath)).toEqual([ + "page.tsx", + "about/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("warns when no modules are provided", () => { + const warn = vi.fn(); + expect(modulesToRouteFiles({}, warn)).toEqual([]); + expect(warn).toHaveBeenCalledTimes(1); + }); +}); + describe("urlPathToFilePath", () => { it.each([ ["/", "index.html"], diff --git a/packages/static/src/fs-routes/tree.ts b/packages/static/src/fs-routes/tree.ts index 77d2b62..95dff7c 100644 --- a/packages/static/src/fs-routes/tree.ts +++ b/packages/static/src/fs-routes/tree.ts @@ -1,4 +1,42 @@ -import type { FsRouteModule, FsRouteTreeNode } from "./types"; +import type { FsRouteFile, FsRouteModule, FsRouteTreeNode } from "./types"; + +/** + * 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.). + */ +export function modulesToRouteFiles( + modules: Record, + onWarn?: (message: string) => void, +): FsRouteFile[] { + const keys = Object.keys(modules); + if (keys.length === 0) { + onWarn?.( + "createFsRoutesEntries received no modules. Did your import.meta.glob pattern match any files?", + ); + 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), + ); + for (let i = 0; i < commonLength; i++) { + const segment = dirSegments[0]![i]; + if (!dirSegments.every((segments) => segments[i] === segment)) { + commonLength = i; + break; + } + } + + return keys.map((key) => ({ + filePath: key.split("/").slice(commonLength).join("/"), + module: modules[key]!, + })); +} /** * A single page to statically generate. diff --git a/packages/static/src/fs-routes/types.ts b/packages/static/src/fs-routes/types.ts index a513b6c..6f5278a 100644 --- a/packages/static/src/fs-routes/types.ts +++ b/packages/static/src/fs-routes/types.ts @@ -73,6 +73,9 @@ export interface FsRouteTreeNode { * * Implement this interface to support a custom directory / file-name * convention. A Next.js-like adapter is provided built-in via `nextRoutes()`. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. */ export interface FsRoutesAdapter { /** Adapter name, used in diagnostics. */ diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index 3e266d0..acd1f1d 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -68,7 +68,6 @@ interface SingleEntryOptions { */ app: string; entries?: never; - fsRoutes?: never; } interface MultipleEntriesOptions { @@ -79,52 +78,10 @@ interface MultipleEntriesOptions { * Mutually exclusive with `root`+`app`. */ entries: string; - fsRoutes?: never; -} - -/** - * Options for built-in file-system based routing. - * - * Pages discovered in `dir` are mapped to routes via an adapter (which defines - * the directory / file-name convention) and rendered through FUNSTACK Router. - * Using this feature requires `@funstack/router` to be installed. - */ -export interface FsRoutesConfig { - /** - * Directory containing route files, relative to the Vite root. - * - * @default "./src/pages" - */ - dir?: string; - /** - * Path to the root (HTML shell) component module. - * The file should `export default` a React component that renders the whole - * page (`...`) and places `children` inside ``. - */ - root: string; - /** - * Path to a module that `export default`s an `FsRoutesAdapter`, which defines - * the directory / file-name convention. - * - * Defaults to the built-in Next.js-like adapter (`nextRoutes()` from - * `@funstack/static/fs-routes`). - */ - adapter?: string; -} - -interface FsRoutesOptions { - root?: never; - app?: never; - entries?: never; - /** - * Enable built-in file-system based routing. - * Mutually exclusive with `root`+`app` and `entries`. - */ - fsRoutes: FsRoutesConfig; } export type FunstackStaticOptions = FunstackStaticBaseOptions & - (SingleEntryOptions | MultipleEntriesOptions | FsRoutesOptions); + (SingleEntryOptions | MultipleEntriesOptions); export default function funstackStatic( options: FunstackStaticOptions, @@ -152,15 +109,9 @@ export default function funstackStatic( let resolvedEntriesModule: string = "__uninitialized__"; let resolvedClientInitEntry: string | undefined; let resolvedBuildEntry: string | undefined; - // Resolved configuration for file-system routing (fsRoutes mode). - let resolvedFsRoutes: - | { root: string; adapter: string | undefined; globBase: string } - | undefined; - // Determine which entry mode the user selected. - const isFsRoutes = "fsRoutes" in options && options.fsRoutes !== undefined; - const isMultiEntry = - !isFsRoutes && "entries" in options && options.entries !== undefined; + // Determine whether user specified entries or root+app + const isMultiEntry = "entries" in options && options.entries !== undefined; return [ { @@ -191,21 +142,7 @@ export default function funstackStatic( { name: "@funstack/static:config", configResolved(config) { - if (isFsRoutes) { - const fsRoutes = options.fsRoutes; - const dir = fsRoutes.dir ?? "./src/pages"; - const resolvedRoot = normalizePath( - path.resolve(config.root, fsRoutes.root), - ); - const resolvedDir = normalizePath(path.resolve(config.root, dir)); - // Glob patterns in generated virtual modules must be root-relative. - let globBase = normalizePath(path.relative(config.root, resolvedDir)); - globBase = `/${globBase.replace(/^\.?\/?/, "").replace(/\/$/, "")}`; - const adapter = fsRoutes.adapter - ? normalizePath(path.resolve(config.root, fsRoutes.adapter)) - : undefined; - resolvedFsRoutes = { root: resolvedRoot, adapter, globBase }; - } else if (isMultiEntry) { + if (isMultiEntry) { resolvedEntriesModule = normalizePath( path.resolve(config.root, options.entries), ); @@ -277,28 +214,6 @@ export default function funstackStatic( }, load(id) { if (id === "\0virtual:funstack/entries") { - if (isFsRoutes && resolvedFsRoutes) { - // Synthesize a getEntries module from file-system routing config. - const { root, adapter, globBase } = resolvedFsRoutes; - const globPattern = `${globBase}/**/*.{tsx,jsx}`; - const lines = [ - `import Root from "${root}";`, - `import { createFsRoutesEntries, nextRoutes } from "@funstack/static/fs-routes";`, - ]; - if (adapter) { - lines.push(`import adapter from "${adapter}";`); - } - lines.push( - `const modules = import.meta.glob(${JSON.stringify(globPattern)}, { eager: true });`, - `export default createFsRoutesEntries({`, - ` modules,`, - ` base: ${JSON.stringify(globBase)},`, - ` adapter: ${adapter ? "adapter" : "nextRoutes()"},`, - ` Root,`, - `});`, - ); - return lines.join("\n"); - } if (isMultiEntry) { // Re-export the user's entries module return `export { default } from "${resolvedEntriesModule}";`; From a7ee5d0a38c095469dbcee6af17fc1501040de7d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 14:16:44 +0000 Subject: [PATCH 3/5] feat: restore fsRoutes plugin option for file-system routing Bring back the dedicated `fsRoutes` plugin option as the user-facing way to enable file-system routing, so users don't have to write `import.meta.glob` themselves. The plugin synthesizes the entries module (emitting the glob in a virtual module that Vite transforms) and delegates to `createFsRoutesEntries` from `@funstack/static/fs-routes`, which remains exported as the underlying primitive and escape hatch. - `fsRoutes: { dir?, root, adapter? }` config; mutually exclusive with `root`+`app` and `entries`. - Generated module relies on the runtime's automatic common-prefix stripping, so no separate base needs to be threaded through. - Marked experimental (not yet covered by semantic versioning). - Example, e2e fixture, and docs switched back to the `fsRoutes` option. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012L8PCU7xhNXfuepS7eKzro --- .../docs/src/pages/api/FunstackStatic.mdx | 59 +++++++++- .../src/pages/learn/FileSystemRouting.mdx | 91 ++++++---------- packages/example-fs-routing/src/entries.tsx | 13 --- packages/example-fs-routing/vite.config.ts | 9 +- .../e2e/fixture-fs-routing/src/entries.tsx | 9 -- .../e2e/fixture-fs-routing/vite.config.ts | 5 +- packages/static/src/plugin/index.ts | 103 +++++++++++++++++- 7 files changed, 200 insertions(+), 89 deletions(-) delete mode 100644 packages/example-fs-routing/src/entries.tsx delete mode 100644 packages/static/e2e/fixture-fs-routing/src/entries.tsx diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index 896c3e0..4ae8523 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -10,7 +10,7 @@ import funstackStatic from "@funstack/static"; ## Usage -There are two configuration modes: **single-entry** (one HTML page) and **multiple entries** (multiple HTML pages). +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). ### Single-Entry Mode @@ -53,11 +53,36 @@ export default defineConfig({ See [Multiple Entrypoints](/advanced/multiple-entrypoints) for a full guide. -> **Tip:** For convention-based routing, the `entries` module can be generated from a `pages/` directory using built-in [File-System Routing](/learn/file-system-routing). +### File-System Routing Mode + +> **Experimental.** Not yet covered by semantic versioning. + +Use `fsRoutes` to map pages from a directory to routes, rendered with FUNSTACK Router: + +```typescript +// vite.config.ts +import funstackStatic from "@funstack/static"; +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; + +export default defineConfig({ + plugins: [ + funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, + }), + react(), + ], +}); +``` + +See [File-System Routing](/learn/file-system-routing) for a full guide. ## Options -The plugin accepts either `root` + `app` (single-entry) or `entries` (multiple entries). These two modes are mutually exclusive. +The plugin accepts exactly one of `root` + `app` (single-entry), `entries` (multiple entries), or `fsRoutes` (file-system routing). These modes are mutually exclusive. ### root @@ -162,6 +187,34 @@ export default function getEntries(): EntryDefinition[] { See [Multiple Entrypoints](/advanced/multiple-entrypoints) for details on the `EntryDefinition` type and advanced usage patterns like async generators. +### fsRoutes + +**Type:** `FsRoutesConfig` +**Required in:** file-system routing mode +**Experimental** — not yet covered by semantic versioning. + +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. + +Cannot be used together with `root`, `app`, or `entries`. + +```typescript +funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + adapter: "./src/my-adapter.ts", // optional + }, +}); +``` + +`FsRoutesConfig` fields: + +- **`dir`** (optional, default `"./src/pages"`) — directory scanned for route files, relative to the Vite root. +- **`root`** (required) — path to the root (HTML shell) component module. +- **`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`). + +See [File-System Routing](/learn/file-system-routing) for the conventions, dynamic routes, and writing custom adapters. + ### publicOutDir (optional) **Type:** `string` diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index fa23022..8315de4 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -2,9 +2,9 @@ > **Experimental.** File-system routing is experimental. Its API may change in a minor release and is **not** yet covered by semantic versioning. -FUNSTACK Static ships a built-in file-system router. Pages discovered in a directory are mapped to routes and rendered with [FUNSTACK Router](https://github.com/uhyo/funstack-router), generating one static HTML file per route. +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. -It is built on top of the [`entries`](/api/funstack-static) option: you write a small entries module that globs your pages and hands them to `@funstack/static/fs-routes`. The directory / file-name convention is pluggable through an **adapter**, and a Next.js-like adapter is provided out of the box. +The directory / file-name convention is pluggable through an **adapter**, and a Next.js-like adapter is provided out of the box. ## Requirements @@ -16,33 +16,7 @@ npm install @funstack/router ## Setup -### 1. Create an entries module - -The entries module globs your pages with `import.meta.glob` and passes them to `createFsRoutesEntries` along with your root (HTML shell) component: - -```tsx -// src/entries.tsx -import { createFsRoutesEntries } from "@funstack/static/fs-routes"; -import Root from "./root"; - -// Discover all page/layout files under `pages/` at build time. -const modules = import.meta.glob<{ default: React.ComponentType }>( - "./pages/**/*.{tsx,jsx}", - { eager: true }, -); - -export default createFsRoutesEntries({ modules, root: Root }); -``` - -`createFsRoutesEntries` accepts: - -- `modules` — the eager `import.meta.glob` result for your pages directory. -- `root` — the root (HTML shell) component. -- `adapter` — optional; defaults to the built-in Next.js-like adapter (`nextRoutes()`). - -Because you own the glob, the pattern lives in your code (resolved relative to the entries module) — there is no extra plugin configuration. - -### 2. Point the plugin at the entries module +Enable file-system routing with the `fsRoutes` option: ```typescript // vite.config.ts @@ -53,13 +27,22 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ - entries: "./src/entries.tsx", + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, }), react(), ], }); ``` +- `dir` — the directory scanned for route files (default `./src/pages`). +- `root` — the HTML shell component (`…{children}`). +- `adapter` — optional path to a custom adapter module (defaults to the built-in Next.js-like adapter). + +`fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. + ## The Next.js-like Convention The built-in adapter follows Next.js App-Router conventions: @@ -122,17 +105,24 @@ A dynamic route without `generateStaticParams` is **not** pre-rendered (a warnin ## Custom Conventions (Adapters) -The convention is defined by an **adapter** implementing `FsRoutesAdapter`. Pass an `adapter` to use a different convention: +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: + +```typescript +// vite.config.ts +funstackStatic({ + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + adapter: "./src/my-adapter.ts", + }, +}); +``` ```tsx -// src/entries.tsx -import { - createFsRoutesEntries, - type FsRoutesAdapter, -} from "@funstack/static/fs-routes"; -import Root from "./root"; - -const myAdapter: FsRoutesAdapter = { +// src/my-adapter.ts +import type { FsRoutesAdapter } from "@funstack/static/fs-routes"; + +const adapter: FsRoutesAdapter = { name: "my-convention", buildRoutes(files) { // Map discovered files to a route tree. @@ -141,28 +131,17 @@ const myAdapter: FsRoutesAdapter = { }, }; -const modules = import.meta.glob<{ default: React.ComponentType }>( - "./pages/**/*.{tsx,jsx}", - { eager: true }, -); - -export default createFsRoutesEntries({ - modules, - root: Root, - adapter: myAdapter, -}); +export default adapter; ``` -The built-in Next.js-like adapter can also be configured: +The built-in Next.js-like adapter is also exported, so you can wrap or configure it: ```tsx +// src/my-adapter.ts import { nextRoutes } from "@funstack/static/fs-routes"; // e.g. use `index.tsx` instead of `page.tsx` -const adapter = nextRoutes({ - pageFileName: "index", - layoutFileName: "_layout", -}); +export default nextRoutes({ pageFileName: "index", layoutFileName: "_layout" }); ``` ## Full Example @@ -171,11 +150,11 @@ For a complete working example, see the [`example-fs-routing`](https://github.co ## Fully Custom Routing -`createFsRoutesEntries` 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 and deriving [entry definitions](/api/entry-definition) yourself. +`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. ## See Also -- [funstackStatic()](/api/funstack-static) - The `entries` plugin option +- [funstackStatic()](/api/funstack-static) - The `fsRoutes` plugin option - [Multiple Entrypoints](/advanced/multiple-entrypoints) - Generating multiple HTML pages from a single project - [EntryDefinition](/api/entry-definition) - API reference for entry definitions - [How It Works](/learn/how-it-works) - Overall FUNSTACK Static architecture diff --git a/packages/example-fs-routing/src/entries.tsx b/packages/example-fs-routing/src/entries.tsx deleted file mode 100644 index 97d9387..0000000 --- a/packages/example-fs-routing/src/entries.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { createFsRoutesEntries } from "@funstack/static/fs-routes"; -import Root from "./root"; - -// Discover all page/layout files under `pages/` at build time. -const modules = import.meta.glob<{ default: React.ComponentType }>( - "./pages/**/*.{tsx,jsx}", - { eager: true }, -); - -// Map the files to routes using the built-in Next.js-like adapter and render -// them with FUNSTACK Router. Pass a custom `adapter` to use a different -// convention. -export default createFsRoutesEntries({ modules, root: Root }); diff --git a/packages/example-fs-routing/vite.config.ts b/packages/example-fs-routing/vite.config.ts index ffd543f..23dc06f 100644 --- a/packages/example-fs-routing/vite.config.ts +++ b/packages/example-fs-routing/vite.config.ts @@ -5,9 +5,12 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ - // File-system routing is set up in the entries module using - // `@funstack/static/fs-routes`. - entries: "./src/entries.tsx", + // Built-in file-system routing. Pages under `src/pages` are mapped to + // routes via the Next.js-like adapter and rendered with FUNSTACK Router. + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, }), react(), ], diff --git a/packages/static/e2e/fixture-fs-routing/src/entries.tsx b/packages/static/e2e/fixture-fs-routing/src/entries.tsx deleted file mode 100644 index d837325..0000000 --- a/packages/static/e2e/fixture-fs-routing/src/entries.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { createFsRoutesEntries } from "@funstack/static/fs-routes"; -import Root from "./root"; - -const modules = import.meta.glob<{ default: React.ComponentType }>( - "./pages/**/*.{tsx,jsx}", - { eager: true }, -); - -export default createFsRoutesEntries({ modules, root: Root }); diff --git a/packages/static/e2e/fixture-fs-routing/vite.config.ts b/packages/static/e2e/fixture-fs-routing/vite.config.ts index e192f96..da07ba9 100644 --- a/packages/static/e2e/fixture-fs-routing/vite.config.ts +++ b/packages/static/e2e/fixture-fs-routing/vite.config.ts @@ -5,7 +5,10 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ - entries: "./src/entries.tsx", + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, }), react(), ], diff --git a/packages/static/src/plugin/index.ts b/packages/static/src/plugin/index.ts index acd1f1d..9e3fc7d 100644 --- a/packages/static/src/plugin/index.ts +++ b/packages/static/src/plugin/index.ts @@ -68,6 +68,7 @@ interface SingleEntryOptions { */ app: string; entries?: never; + fsRoutes?: never; } interface MultipleEntriesOptions { @@ -78,10 +79,57 @@ interface MultipleEntriesOptions { * Mutually exclusive with `root`+`app`. */ entries: string; + fsRoutes?: never; +} + +/** + * Options for built-in file-system based routing. + * + * Pages discovered in `dir` are mapped to routes via an adapter (which defines + * the directory / file-name convention) and rendered through FUNSTACK Router. + * Using this feature requires `@funstack/router` to be installed. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. + */ +export interface FsRoutesConfig { + /** + * Directory containing route files, relative to the Vite root. + * + * @default "./src/pages" + */ + dir?: string; + /** + * Path to the root (HTML shell) component module. + * The file should `export default` a React component that renders the whole + * page (`...`) and places `children` inside ``. + */ + root: string; + /** + * Path to a module that `export default`s an `FsRoutesAdapter`, which defines + * the directory / file-name convention. + * + * Defaults to the built-in Next.js-like adapter (`nextRoutes()` from + * `@funstack/static/fs-routes`). + */ + adapter?: string; +} + +interface FsRoutesOptions { + root?: never; + app?: never; + entries?: never; + /** + * Enable built-in file-system based routing. + * Mutually exclusive with `root`+`app` and `entries`. + * + * @experimental + */ + fsRoutes: FsRoutesConfig; } export type FunstackStaticOptions = FunstackStaticBaseOptions & - (SingleEntryOptions | MultipleEntriesOptions); + (SingleEntryOptions | MultipleEntriesOptions | FsRoutesOptions); export default function funstackStatic( options: FunstackStaticOptions, @@ -109,9 +157,15 @@ export default function funstackStatic( let resolvedEntriesModule: string = "__uninitialized__"; let resolvedClientInitEntry: string | undefined; let resolvedBuildEntry: string | undefined; + // Resolved configuration for file-system routing (fsRoutes mode). + let resolvedFsRoutes: + | { root: string; adapter: string | undefined; globBase: string } + | undefined; - // Determine whether user specified entries or root+app - const isMultiEntry = "entries" in options && options.entries !== undefined; + // Determine which entry mode the user selected. + const isFsRoutes = "fsRoutes" in options && options.fsRoutes !== undefined; + const isMultiEntry = + !isFsRoutes && "entries" in options && options.entries !== undefined; return [ { @@ -142,7 +196,24 @@ export default function funstackStatic( { name: "@funstack/static:config", configResolved(config) { - if (isMultiEntry) { + if (isFsRoutes) { + const fsRoutes = options.fsRoutes; + const dir = fsRoutes.dir ?? "./src/pages"; + const resolvedRoot = normalizePath( + path.resolve(config.root, fsRoutes.root), + ); + const resolvedDir = normalizePath(path.resolve(config.root, dir)); + // Glob patterns in generated virtual modules must be root-relative + // (a virtual module has no real path to resolve "./" against). + const relativeDir = normalizePath( + path.relative(config.root, resolvedDir), + ); + const globBase = `/${relativeDir.replace(/^\.?\/?/, "").replace(/\/$/, "")}`; + const adapter = fsRoutes.adapter + ? normalizePath(path.resolve(config.root, fsRoutes.adapter)) + : undefined; + resolvedFsRoutes = { root: resolvedRoot, adapter, globBase }; + } else if (isMultiEntry) { resolvedEntriesModule = normalizePath( path.resolve(config.root, options.entries), ); @@ -214,6 +285,30 @@ export default function funstackStatic( }, load(id) { if (id === "\0virtual:funstack/entries") { + if (isFsRoutes && resolvedFsRoutes) { + // Synthesize a getEntries module from file-system routing config. + // The import.meta.glob call is emitted here so users don't have to + // wire it up themselves; Vite transforms the glob in this virtual + // module just like in user code. + const { root, adapter, globBase } = resolvedFsRoutes; + const globPattern = `${globBase}/**/*.{tsx,jsx}`; + const lines = [ + `import Root from "${root}";`, + `import { createFsRoutesEntries } from "@funstack/static/fs-routes";`, + ]; + if (adapter) { + lines.push(`import adapter from "${adapter}";`); + } + lines.push( + `const modules = import.meta.glob(${JSON.stringify(globPattern)}, { eager: true });`, + `export default createFsRoutesEntries({`, + ` modules,`, + ` root: Root,`, + ...(adapter ? [` adapter,`] : []), + `});`, + ); + return lines.join("\n"); + } if (isMultiEntry) { // Re-export the user's entries module return `export { default } from "${resolvedEntriesModule}";`; From dd588a9cb2d9cfe5ba36fe4b69282811bbd3e12a Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 14:53:07 +0000 Subject: [PATCH 4/5] fix(static): require ssr for file-system routing dev server The dev server (vite dev) could not render file-system routes with the default `ssr: false`: pages are server components rendered through FUNSTACK Router (a client component), and @vitejs/plugin-rsc serializes that server-component route data as eval'd dev-JSX that fails to load in the browser. Production builds are unaffected (the RSC payload is pre-rendered), and enabling `ssr: true` renders pages on the server in dev as well. Set `ssr: true` in the example and e2e fixture, and document that SSR is required for the dev server (and recommended for SEO / initial load). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012L8PCU7xhNXfuepS7eKzro --- packages/docs/src/pages/api/FunstackStatic.mdx | 5 ++++- packages/docs/src/pages/learn/FileSystemRouting.mdx | 3 +++ packages/example-fs-routing/vite.config.ts | 4 ++++ packages/static/e2e/fixture-fs-routing/vite.config.ts | 2 ++ 4 files changed, 13 insertions(+), 1 deletion(-) diff --git a/packages/docs/src/pages/api/FunstackStatic.mdx b/packages/docs/src/pages/api/FunstackStatic.mdx index 4ae8523..8c5aeac 100644 --- a/packages/docs/src/pages/api/FunstackStatic.mdx +++ b/packages/docs/src/pages/api/FunstackStatic.mdx @@ -68,6 +68,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ + ssr: true, fsRoutes: { dir: "./src/pages", root: "./src/root.tsx", @@ -78,7 +79,7 @@ export default defineConfig({ }); ``` -See [File-System Routing](/learn/file-system-routing) for a full guide. +`ssr: true` is required for the dev server to render pages. See [File-System Routing](/learn/file-system-routing) for a full guide. ## Options @@ -213,6 +214,8 @@ funstackStatic({ - **`root`** (required) — path to the root (HTML shell) component module. - **`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`). +Enable [`ssr`](#ssr-optional) alongside `fsRoutes` — it is required for the dev server to render pages and recommended in general. + See [File-System Routing](/learn/file-system-routing) for the conventions, dynamic routes, and writing custom adapters. ### publicOutDir (optional) diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 8315de4..3672e7e 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -27,6 +27,7 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ + ssr: true, fsRoutes: { dir: "./src/pages", root: "./src/root.tsx", @@ -43,6 +44,8 @@ export default defineConfig({ `fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. +> **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. + ## The Next.js-like Convention The built-in adapter follows Next.js App-Router conventions: diff --git a/packages/example-fs-routing/vite.config.ts b/packages/example-fs-routing/vite.config.ts index 23dc06f..998bd97 100644 --- a/packages/example-fs-routing/vite.config.ts +++ b/packages/example-fs-routing/vite.config.ts @@ -5,6 +5,10 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ + // SSR is required for file-system routing to render in the dev server + // (pages are server components rendered through FUNSTACK Router), and is + // recommended in general for SEO and faster initial load. + ssr: true, // Built-in file-system routing. Pages under `src/pages` are mapped to // routes via the Next.js-like adapter and rendered with FUNSTACK Router. fsRoutes: { diff --git a/packages/static/e2e/fixture-fs-routing/vite.config.ts b/packages/static/e2e/fixture-fs-routing/vite.config.ts index da07ba9..9c027db 100644 --- a/packages/static/e2e/fixture-fs-routing/vite.config.ts +++ b/packages/static/e2e/fixture-fs-routing/vite.config.ts @@ -5,6 +5,8 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ + // Required for file-system routing to render in the dev server. + ssr: true, fsRoutes: { dir: "./src/pages", root: "./src/root.tsx", From 0d98bfe13c31431c00016510aa38f0e6b9150dbc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 16:29:41 +0000 Subject: [PATCH 5/5] docs: link ssr dev-server limitation to tracking issue #124 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_012L8PCU7xhNXfuepS7eKzro --- packages/docs/src/pages/learn/FileSystemRouting.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index 3672e7e..4736151 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -44,7 +44,7 @@ export default defineConfig({ `fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. -> **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. +> **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). ## The Next.js-like Convention