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..8c5aeac 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,37 @@ export default defineConfig({ See [Multiple Entrypoints](/advanced/multiple-entrypoints) for a full guide. +### 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({ + ssr: true, + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, + }), + react(), + ], +}); +``` + +`ssr: true` is required for the dev server to render pages. 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 +188,36 @@ 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`). + +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) **Type:** `string` diff --git a/packages/docs/src/pages/learn/FileSystemRouting.mdx b/packages/docs/src/pages/learn/FileSystemRouting.mdx index fad9e33..4736151 100644 --- a/packages/docs/src/pages/learn/FileSystemRouting.mdx +++ b/packages/docs/src/pages/learn/FileSystemRouting.mdx @@ -1,95 +1,163 @@ # 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). +> **Experimental.** File-system routing is experimental. Its API may change in a minor release and is **not** yet covered by semantic versioning. -## How It Works +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. -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. +The directory / file-name convention is pluggable through an **adapter**, and a Next.js-like adapter is provided out of the box. -```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 || "/"; -} +## Requirements -export const routes: RouteDefinition[] = Object.entries(pageModules).map( - ([filePath, module]) => { - const Page = module.default; - return route({ - path: filePathToUrlPath(filePath), - component: , - }); - }, -); +File-system routing renders pages with FUNSTACK Router, so `@funstack/router` must be installed: + +```sh +npm install @funstack/router ``` -With this setup, files in the `pages/` directory are automatically mapped to routes: +## 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({ + ssr: true, + fsRoutes: { + dir: "./src/pages", + root: "./src/root.tsx", + }, + }), + react(), + ], +}); +``` -| File | Route | -| ---------------------- | -------- | -| `pages/index.tsx` | `/` | -| `pages/about.tsx` | `/about` | -| `pages/blog/index.tsx` | `/blog` | +- `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). -## Why import.meta.glob? +`fsRoutes` is mutually exclusive with the `root` + `app` (single-entry) and `entries` (multiple entries) modes. -Using `import.meta.glob` has two key advantages: +> **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). -- **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. +## The Next.js-like Convention -## Static Generation +The built-in adapter follows Next.js App-Router conventions: -To generate static HTML for each route, derive [entry definitions](/api/entry-definition) from the route list: +| 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 | + +- **`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. + +Files that are not named `page` or `layout` are ignored, so helpers and components can be co-located with routes. ```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/page.tsx +export default function Home() { + return

Home

; } +``` -function pathToEntryPath(path: string): string { - if (path === "/") return "index.html"; - return `${path.slice(1)}.html`; +```tsx +// 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: + +```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`. 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/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. +## Fully Custom Routing + +`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 `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..998bd97 100644 --- a/packages/example-fs-routing/vite.config.ts +++ b/packages/example-fs-routing/vite.config.ts @@ -5,7 +5,16 @@ import { defineConfig } from "vite"; export default defineConfig({ plugins: [ funstackStatic({ - entries: "./src/entries.tsx", + // 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: { + 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..9c027db --- /dev/null +++ b/packages/static/e2e/fixture-fs-routing/vite.config.ts @@ -0,0 +1,17 @@ +import funstackStatic from "@funstack/static"; +import react from "@vitejs/plugin-react"; +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", + }, + }), + 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..35bb34b --- /dev/null +++ b/packages/static/src/fs-routes/index.ts @@ -0,0 +1,19 @@ +/** + * 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, + 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..19bd4c1 --- /dev/null +++ b/packages/static/src/fs-routes/nextAdapter.ts @@ -0,0 +1,171 @@ +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. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. + */ +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..a3ab55c --- /dev/null +++ b/packages/static/src/fs-routes/runtime.tsx @@ -0,0 +1,127 @@ +import { createElement } from "react"; +import { Router } from "@funstack/router"; +import type { RouteDefinition } from "@funstack/router/server"; +import type { + FsRootComponent, + FsRouteModule, + FsRoutesAdapter, + FsRouteTreeNode, +} from "./types"; +import type { EntryDefinition, GetEntriesResult } from "../entryDefinition"; +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. Glob your pages directory from your entries module, e.g. + * `import.meta.glob("./pages/**\/*.{tsx,jsx}", { eager: true })`. + */ + modules: Record; + /** + * The root (HTML shell) component. Renders the whole page + * (`…{children}`). + */ + root: FsRootComponent; + /** + * The convention adapter mapping files to a route tree. + * + * @default nextRoutes() + */ + adapter?: FsRoutesAdapter; +} + +/** + * 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, root: Root, adapter = nextRoutes() } = options; + + 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({ + tree, + path, + params, + }: { + tree: FsRouteTreeNode[]; + 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 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, { tree, 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..3060c33 --- /dev/null +++ b/packages/static/src/fs-routes/tree.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from "vitest"; +import { + collectStaticPaths, + modulesToRouteFiles, + 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("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"], + ["", "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..95dff7c --- /dev/null +++ b/packages/static/src/fs-routes/tree.ts @@ -0,0 +1,173 @@ +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. + */ +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..6f5278a --- /dev/null +++ b/packages/static/src/fs-routes/types.ts @@ -0,0 +1,90 @@ +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()`. + * + * @experimental File-system routing is experimental and not yet subject to + * semantic versioning. + */ +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..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}";`; 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