filesystem-routing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Ryan Carniato
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,317 @@
1
+ # filesystem-routing
2
+
3
+ Router-neutral file-system routing.
4
+
5
+ This package owns the *machinery* of file routing — scanning a directory,
6
+ applying a filename convention and producing a neutral **route manifest** —
7
+ while routers own the *shapes*: each router ships a small emission adapter
8
+ that turns the manifest into its own route definitions, and each bundler ships
9
+ a delivery adapter that materializes the manifest into code.
10
+
11
+ | Piece | Owner |
12
+ | --- | --- |
13
+ | Scanning, filename convention, neutral route manifest | `filesystem-routing` |
14
+ | Nesting + `(group)` stripping | `filesystem-routing/tree` |
15
+ | Vite delivery (virtual module, HMR, code splitting, build inputs) | `filesystem-routing/vite` |
16
+ | `RouteDefinition` emission | `@solidjs/router/fs` |
17
+ | Request handling for `GET`/`POST` routes, middleware | `@solidjs/start` |
18
+
19
+ Nothing here is Solid-specific: the core is bundler-agnostic — it never imports
20
+ Vite — the Vite adapter is router-agnostic, and the conventions are the ones
21
+ proven by SolidStart. The manifest is served from `virtual:file-routes`, or
22
+ from whatever id you pass as `fileRoutes({ moduleId })`.
23
+
24
+ ## Usage with Solid Router and Vite
25
+
26
+ ```ts
27
+ // vite.config.ts
28
+ import { fileRoutes } from "filesystem-routing/vite";
29
+ import { defineConfig } from "vite";
30
+ import solid from "vite-plugin-solid";
31
+
32
+ export default defineConfig({
33
+ // `extensions` makes vite-plugin-solid also compile the `?pick=` route
34
+ // modules this plugin emits (their ids end in a query string)
35
+ plugins: [solid({ extensions: [".jsx", ".tsx"] }), fileRoutes()]
36
+ });
37
+ ```
38
+
39
+ ```tsx
40
+ // src/app.tsx
41
+ import { pageRoutes } from "virtual:file-routes";
42
+ import { createRouter } from "@solidjs/router";
43
+ import { fileRoutes } from "@solidjs/router/fs";
44
+
45
+ const Router = createRouter({ routes: fileRoutes(pageRoutes) });
46
+
47
+ export const App = () => <Router>{props => <>{props.children}</>}</Router>;
48
+ ```
49
+
50
+ The emission adapter never imports the virtual module itself — the app does,
51
+ so the adapter resolves without the plugin, custom `moduleId`s work, and
52
+ nothing needs excluding from dependency prebundling.
53
+
54
+ Route modules live in `src/routes` (configurable via `fileRoutes({ dir })`).
55
+ A module is a page when it has a default export, and may export a `route`
56
+ config object:
57
+
58
+ ```tsx
59
+ // src/routes/blog/[id].tsx
60
+ import type { RouteDefinition } from "@solidjs/router";
61
+
62
+ export const route = {
63
+ preload: ({ params }) => loadPost(params.id)
64
+ } satisfies RouteDefinition;
65
+
66
+ export default function Post() {
67
+ return <h1>Post</h1>;
68
+ }
69
+ ```
70
+
71
+ ## Filename conventions
72
+
73
+ Two conventions ship in the box. Both produce the same neutral manifest and
74
+ share the same module convention (default export is a page, `route` config
75
+ export, `httpMethods` handlers), so they are equally capable — only the
76
+ filenames differ.
77
+
78
+ ### Nested (`PageFileSystemRouter`, the default)
79
+
80
+ The convention proven by SolidStart:
81
+
82
+ | File | Path |
83
+ | --- | --- |
84
+ | `index.tsx` | `/` |
85
+ | `about.tsx` | `/about` |
86
+ | `blog/[id].tsx` | `/blog/:id` |
87
+ | `blog/[[page]].tsx` | `/blog/:page?` |
88
+ | `docs/[...path].tsx` | `/docs/*path` |
89
+ | `(marketing)/about.tsx` | `/about`, nested in the `(marketing)` group |
90
+
91
+ Nested layouts come from pairing a file with a directory: `blog.tsx` is the
92
+ layout for everything in `blog/`.
93
+
94
+ ### Flat (`FlatFileSystemRouter`)
95
+
96
+ The convention proven by Remix v2 and carried forward by React Router's
97
+ `@react-router/fs-routes` — `.` delimiters instead of directories:
98
+
99
+ | File | Path |
100
+ | --- | --- |
101
+ | `_index.tsx` | `/` |
102
+ | `about.tsx` | `/about` |
103
+ | `concerts.trending.tsx` | `/concerts/trending` |
104
+ | `concerts.$city.tsx` | `/concerts/:city` |
105
+ | `concerts.($page).tsx` | `/concerts/:page?` |
106
+ | `files.$.tsx` | `/files/*splat` |
107
+ | `_auth.login.tsx` | `/login`, nested in the `_auth.tsx` pathless layout |
108
+ | `concerts_.mine.tsx` | `/concerts/mine`, escaping the `concerts.tsx` layout |
109
+ | `[sitemap.xml].tsx` | `/sitemap.xml` — brackets escape special characters |
110
+
111
+ `concerts.tsx` is the layout for every `concerts.*` file; a top-level folder
112
+ routes through its `route.tsx` module and co-locates everything else in the
113
+ folder without routing it. Pass the router to the plugin:
114
+
115
+ ```ts
116
+ import { FlatFileSystemRouter } from "filesystem-routing";
117
+ import { fileRoutes } from "filesystem-routing/vite";
118
+ import { resolve } from "node:path";
119
+
120
+ fileRoutes({
121
+ router: new FlatFileSystemRouter({
122
+ dir: resolve("src/routes"),
123
+ extensions: ["js", "jsx", "ts", "tsx"]
124
+ })
125
+ });
126
+ ```
127
+
128
+ Two departures from Remix: the catch-all param is named (`params.splat`
129
+ rather than `params["*"]`), and optional *static* segments (`(en).about.tsx`)
130
+ are rejected — the neutral pattern language has no representation for them.
131
+
132
+ ## API routes
133
+
134
+ Route modules can answer requests as well as render. Turn on `httpMethods`
135
+ (off by default — a client-only manifest has no use for handlers) and
136
+ uppercase exports become request handlers:
137
+
138
+ ```ts
139
+ // src/routes/api/posts/[id].ts
140
+ export function GET(event) {
141
+ return Response.json(loadPost(event.params.id));
142
+ }
143
+
144
+ export function DELETE(event) {
145
+ deletePost(event.params.id);
146
+ return new Response(null, { status: 204 });
147
+ }
148
+ ```
149
+
150
+ The scanner turns each handler export into a lazy ref on the entry —
151
+ `$GET: { src, pick: ["GET"] }`, `$DELETE: …` — with the same code splitting
152
+ pages get:
153
+
154
+ - A module with handlers but no default export routes without being a page:
155
+ `{ path: "/api/posts/:id", page: false, $GET, $DELETE }`.
156
+ - A module can be both. A page with a `GET` export gets `$component` *and*
157
+ `$GET`, and handler exports are excluded from the component ref's picks, so
158
+ handler code never reaches the client bundle.
159
+ - A lone `GET` also answers `HEAD`: a `$HEAD` ref aliasing the `GET` export
160
+ is added unless the module exports its own.
161
+ - The recognized set is `HEAD`/`GET`/`POST`/`PUT`/`DELETE`/`PATCH`/`OPTIONS`;
162
+ pass an array instead of `true` to change it.
163
+
164
+ This package stops at the manifest: it discovers handlers, it does not serve
165
+ them. Dispatch is a few lines in whatever server consumes the manifest —
166
+ match the URL, import the ref, call the export:
167
+
168
+ ```ts
169
+ // any SSR handler or middleware
170
+ import routes from "virtual:file-routes";
171
+
172
+ async function handleApi(request: Request, entry, params) {
173
+ const ref = entry[`$${request.method}`];
174
+ if (!ref) return new Response(null, { status: 405 });
175
+ const handlers = await ref.import();
176
+ // a HEAD alias imports the GET handler
177
+ return (handlers[request.method] ?? handlers.GET)({ request, params });
178
+ }
179
+ ```
180
+
181
+ The handler's argument is whatever your server passes — the convention only
182
+ dictates the export names. Frameworks typically enable `httpMethods` on the
183
+ server environment's router only (SolidStart pairs it with `components:
184
+ false` in SPA mode, so the server manifest routes requests without shipping
185
+ page modules) — see the per-environment `routers` option below.
186
+
187
+ ## Custom conventions
188
+
189
+ Conventions plug in at two independent seams, so a custom scheme picks the
190
+ level it needs:
191
+
192
+ - **`toPath`** — the filename convention: maps a route file (relative to
193
+ `dir`, extension stripped, e.g. `/blog/[id]`) to a route path in the
194
+ neutral pattern language (`:param`, `:param?`, `*rest`, `(group)`), or
195
+ `undefined` to skip the file. Layout nesting is encoded in the paths
196
+ themselves: `buildRouteTree` nests entries by path prefix (a layout at
197
+ `/blog` wraps a page at `/blog/`), and `(group)` segments nest without
198
+ contributing URL — which is also how pathless layouts and layout escapes
199
+ are expressed.
200
+ - **`toRoute`** — the module convention: maps a source file to a manifest
201
+ entry, deciding what makes a file a route and which refs it carries. The
202
+ built-in one analyzes exports (default export, `route` config, HTTP
203
+ handlers); a custom one can key off anything.
204
+
205
+ Pass either as config for one-off tweaks:
206
+
207
+ ```ts
208
+ import { PageFileSystemRouter } from "filesystem-routing";
209
+ import { fileRoutes } from "filesystem-routing/vite";
210
+
211
+ fileRoutes({
212
+ toPath: routeFile => (routeFile.endsWith(".page") ? routeFile.slice(0, -5) : undefined)
213
+ });
214
+ ```
215
+
216
+ Or subclass for a full scheme — `FlatFileSystemRouter` is the model: it
217
+ extends `PageFileSystemRouter`, overrides only `toPath`, and everything else
218
+ (scanning, watching, module analysis, the tree, type generation) is
219
+ inherited. A convention that also changes what a route module *is* overrides
220
+ `toRoute`; a convention that changes what gets scanned overrides `glob()` on
221
+ `BaseFileSystemRouter`.
222
+
223
+ ## The manifest seam
224
+
225
+ The scanner produces flat `RouteManifestEntry` objects:
226
+
227
+ ```ts
228
+ {
229
+ path: "/blog/:id", // neutral pattern language
230
+ page: true,
231
+ $component: { src, pick }, // lazy module ref → code-split dynamic import
232
+ $$route: { src, pick } // eager module ref → static import
233
+ }
234
+ ```
235
+
236
+ The Vite adapter serializes the manifest into the virtual module, turning
237
+ `$`-prefixed refs into dynamic imports and `$$`-prefixed refs into static
238
+ imports, each tree-shaken down to the picked exports. It serves two views of
239
+ the same entries:
240
+
241
+ ```ts
242
+ import routes, { pageRoutes } from "virtual:file-routes";
243
+ ```
244
+
245
+ `routes` is the flat manifest; `pageRoutes` is the page entries nested by path
246
+ with `(group)` segments stripped, so emission adapters don't each reimplement
247
+ the tree (`buildRouteTree` from `filesystem-routing/tree` is the same
248
+ function, for consumers holding only a flat manifest). An emission adapter is
249
+ a function taking manifest entries and returning the router's shape — see
250
+ `fileRoutes` in `@solidjs/router/fs` for Solid Router's, a ~30 line adapter
251
+ other routers can mirror. Adapters take the manifest as an argument rather
252
+ than importing the virtual module, so they work standalone and with custom
253
+ module ids.
254
+
255
+ Add `/// <reference types="filesystem-routing/types" />` to type the import.
256
+
257
+ ### Keeping path-derived types alive
258
+
259
+ A manifest is a runtime value, so a router that derives types from its route
260
+ table gets nothing from it — `@solidjs/router`'s `RoutePaths` degrades to `any`
261
+ the moment the table is a `RouteDefinition[]` rather than a tuple. Turn on
262
+ `types` and the plugin writes a declaration in which the manifest *is* a
263
+ literal tuple, regenerating it as routes come and go:
264
+
265
+ ```ts
266
+ fileRoutes({ types: "file-routes.d.ts" })
267
+ ```
268
+
269
+ Reference the generated file instead of `filesystem-routing/types` — it is
270
+ self-contained, and two declarations of the same module conflict. Emission
271
+ adapters then have to preserve the tuple on the way through, which means a
272
+ mapping typed over its input rather than a plain `.map`:
273
+
274
+ ```ts
275
+ declare function toRoutes<const T extends readonly Entry[]>(
276
+ entries: T
277
+ ): { [K in keyof T]: RouteFrom<T[K]> };
278
+ ```
279
+
280
+ Frameworks with several Vite environments can serve a different router (and
281
+ convention) per environment, and have the plugin take every route module as a
282
+ build entry for the browser one:
283
+
284
+ ```ts
285
+ fileRoutes({
286
+ routers: {
287
+ // the browser routes and renders
288
+ client: new PageFileSystemRouter({ dir, extensions }),
289
+ // the server also answers `GET`/`POST` exports, and in SPA mode routes
290
+ // without shipping components
291
+ ssr: new PageFileSystemRouter({ dir, extensions, httpMethods: true, components: ssr })
292
+ },
293
+ buildInputs: "client"
294
+ });
295
+ ```
296
+
297
+ | Option | What it does |
298
+ | --- | --- |
299
+ | `httpMethods` | emit `$GET`, `$POST`, … refs for uppercase handler exports; a module with handlers but no default export routes without being a page |
300
+ | `components` | set `false` to route without emitting `$component` refs, keeping page modules out of that environment's bundle |
301
+ | `buildInputs` | environments whose build takes every code-split route module as an entry |
302
+ | `moduleId` | the id the manifest is served from |
303
+ | `optimizeDepsExclude` | escape hatch for packages that import the virtual module, kept out of dep prebundling (defaults to `[]`) |
304
+ | `types` | write a declaration typing the manifest as a literal tuple, kept in step with the route directory |
305
+
306
+ ## Credits
307
+
308
+ The design comes from [Vinxi](https://github.com/nksaraf/vinxi)'s file-system
309
+ router by Nikhil Saraf, which powered SolidStart through 1.x: the
310
+ `BaseFileSystemRouter` with pluggable `toPath`/`toRoute`, and above all the
311
+ insight that the manifest should carry inert module refs — `$`-prefixed keys
312
+ becoming dynamic imports, `$$`-prefixed keys becoming static ones — so any
313
+ router and any bundler can meet at the same seam. This package extracts that
314
+ approach into a standalone library, adding the nested tree view, the flat
315
+ convention, and literal-tuple type generation. The filename conventions are
316
+ the ones proven by [SolidStart](https://github.com/solidjs/solid-start) and
317
+ [Remix](https://remix.run) v2.
@@ -0,0 +1,16 @@
1
+ import { type StaticExportEntry } from "oxc-parser";
2
+ export type { StaticExportEntry };
3
+ /**
4
+ * Analyze a route module's static exports.
5
+ *
6
+ * This is the compiler-shaped slice of file routing: it reports what a module
7
+ * exports without knowing what a route is. It is kept behind its own seam so
8
+ * a compiler that already performs export analysis can provide it instead.
9
+ */
10
+ export declare function analyzeModule(src: string): StaticExportEntry[];
11
+ export declare function getExportName(entry: StaticExportEntry): string;
12
+ /**
13
+ * Returns the export name only when it is backed by a same-named local
14
+ * binding, i.e. it can be re-picked from the module without renaming.
15
+ */
16
+ export declare function getLocalExportName(entry: StaticExportEntry): string | undefined;
@@ -0,0 +1,29 @@
1
+ import fs from "node:fs";
2
+ import { parseSync } from "oxc-parser";
3
+ /**
4
+ * Analyze a route module's static exports.
5
+ *
6
+ * This is the compiler-shaped slice of file routing: it reports what a module
7
+ * exports without knowing what a route is. It is kept behind its own seam so
8
+ * a compiler that already performs export analysis can provide it instead.
9
+ */
10
+ export function analyzeModule(src) {
11
+ const result = parseSync(src, fs.readFileSync(src, "utf-8"), { lang: "tsx" });
12
+ const error = result.errors[0];
13
+ if (error)
14
+ throw new SyntaxError(`Failed to parse ${src}:\n${error.codeframe || error.message}`);
15
+ return result.module.staticExports.flatMap(({ entries }) => entries.filter(entry => !entry.isType && entry.exportName.kind !== "None"));
16
+ }
17
+ export function getExportName(entry) {
18
+ return entry.exportName.name ?? "default";
19
+ }
20
+ /**
21
+ * Returns the export name only when it is backed by a same-named local
22
+ * binding, i.e. it can be re-picked from the module without renaming.
23
+ */
24
+ export function getLocalExportName(entry) {
25
+ const name = getExportName(entry);
26
+ if (name === "default")
27
+ return;
28
+ return name === (entry.localName.name ?? entry.importName.name ?? name) ? name : undefined;
29
+ }
@@ -0,0 +1,45 @@
1
+ import type { RouteManifestEntry } from "./manifest.ts";
2
+ import { BaseFileSystemRouter, type FileSystemRouterConfig } from "./router.ts";
3
+ /**
4
+ * The filename convention proven by SolidStart:
5
+ * - `index` files map to their directory's path
6
+ * - `[param]` maps to `:param`
7
+ * - `[[param]]` maps to an optional `:param?`
8
+ * - `[...rest]` maps to a catch-all `*rest`
9
+ * - `(group)` segments are retained for emission adapters to nest and strip
10
+ */
11
+ export declare function routePathFromFile(routeFile: string): string;
12
+ /**
13
+ * The HTTP methods a route module may export as a request handler. Enabled
14
+ * per-router via `httpMethods`; frameworks that do not serve requests from
15
+ * route modules (a client manifest, a static site) leave it off.
16
+ */
17
+ export declare const HTTP_METHODS: readonly ["HEAD", "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
18
+ export interface PageFileSystemRouterConfig extends FileSystemRouterConfig {
19
+ /**
20
+ * Emit `$component` refs for pages. Set to `false` for manifests that
21
+ * route without rendering — a server manifest in SPA mode, where the
22
+ * client owns every component — so page modules stay out of that bundle.
23
+ * Defaults to `true`.
24
+ */
25
+ components?: boolean;
26
+ /**
27
+ * Treat these uppercase exports as request handlers, emitting a `$GET`,
28
+ * `$POST`, … ref for each (and a `$HEAD` alias for a lone `GET`). A module
29
+ * with handlers but no default export is a route without being a page.
30
+ * Pass `true` for the standard set. Defaults to `false`.
31
+ */
32
+ httpMethods?: boolean | readonly string[];
33
+ }
34
+ /**
35
+ * The module convention proven by SolidStart: a route module is a page when
36
+ * it has a default export, may export a `route` config object, and — when
37
+ * `httpMethods` is on — may export `GET`, `POST`, … request handlers.
38
+ * `.md`/`.mdx` files are always pages.
39
+ */
40
+ export declare class PageFileSystemRouter extends BaseFileSystemRouter {
41
+ config: PageFileSystemRouterConfig;
42
+ constructor(config: PageFileSystemRouterConfig);
43
+ toPath(src: string): string | undefined;
44
+ toRoute(src: string): RouteManifestEntry | undefined;
45
+ }
@@ -0,0 +1,118 @@
1
+ import { analyzeModule, getExportName, getLocalExportName } from "./analyze.js";
2
+ import { BaseFileSystemRouter, cleanPath } from "./router.js";
3
+ /**
4
+ * The filename convention proven by SolidStart:
5
+ * - `index` files map to their directory's path
6
+ * - `[param]` maps to `:param`
7
+ * - `[[param]]` maps to an optional `:param?`
8
+ * - `[...rest]` maps to a catch-all `*rest`
9
+ * - `(group)` segments are retained for emission adapters to nest and strip
10
+ */
11
+ export function routePathFromFile(routeFile) {
12
+ const routePath = routeFile
13
+ // remove the initial slash
14
+ .slice(1)
15
+ .replace(/index$/, "")
16
+ .replace(/\[([^/]+)\]/g, (_, m) => {
17
+ if (m.length > 3 && m.startsWith("...")) {
18
+ return `*${m.slice(3)}`;
19
+ }
20
+ if (m.length > 2 && m.startsWith("[") && m.endsWith("]")) {
21
+ return `:${m.slice(1, -1)}?`;
22
+ }
23
+ return `:${m}`;
24
+ });
25
+ return routePath?.length > 0 ? `/${routePath}` : "/";
26
+ }
27
+ /**
28
+ * The HTTP methods a route module may export as a request handler. Enabled
29
+ * per-router via `httpMethods`; frameworks that do not serve requests from
30
+ * route modules (a client manifest, a static site) leave it off.
31
+ */
32
+ export const HTTP_METHODS = ["HEAD", "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"];
33
+ function httpMethodsOf(config) {
34
+ if (!config.httpMethods)
35
+ return [];
36
+ return config.httpMethods === true ? HTTP_METHODS : config.httpMethods;
37
+ }
38
+ function createHandlerRefs(src, exports, methods) {
39
+ const handlers = {};
40
+ for (const method of methods) {
41
+ if (!exports.includes(method))
42
+ continue;
43
+ handlers[`$${method}`] = { src, pick: [method] };
44
+ }
45
+ // A `GET` handler answers `HEAD` unless the module handles it itself.
46
+ if (handlers.$GET && !handlers.$HEAD && methods.includes("HEAD")) {
47
+ handlers.$HEAD = { src, pick: ["GET"] };
48
+ }
49
+ return handlers;
50
+ }
51
+ /**
52
+ * The module convention proven by SolidStart: a route module is a page when
53
+ * it has a default export, may export a `route` config object, and — when
54
+ * `httpMethods` is on — may export `GET`, `POST`, … request handlers.
55
+ * `.md`/`.mdx` files are always pages.
56
+ */
57
+ export class PageFileSystemRouter extends BaseFileSystemRouter {
58
+ constructor(config) {
59
+ super(config);
60
+ }
61
+ toPath(src) {
62
+ if (this.config.toPath)
63
+ return super.toPath(src);
64
+ return routePathFromFile(cleanPath(src, this.config));
65
+ }
66
+ toRoute(src) {
67
+ if (this.config.toRoute)
68
+ return super.toRoute(src);
69
+ const path = this.toPath(src);
70
+ if (path === undefined)
71
+ return;
72
+ const components = this.config.components ?? true;
73
+ if (src.endsWith(".md") || src.endsWith(".mdx")) {
74
+ return {
75
+ page: true,
76
+ $component: components
77
+ ? {
78
+ src: src,
79
+ pick: ["$css"]
80
+ }
81
+ : undefined,
82
+ $$route: undefined,
83
+ path
84
+ };
85
+ }
86
+ const methods = httpMethodsOf(this.config);
87
+ const exports = analyzeModule(src);
88
+ const exportNames = exports.map(getExportName);
89
+ const localExportNames = exports.map(getLocalExportName).filter(name => name !== undefined);
90
+ const hasDefault = exportNames.includes("default");
91
+ const hasRouteConfig = exportNames.includes("route");
92
+ const handlers = createHandlerRefs(src, exportNames, methods);
93
+ const hasHandlers = Object.keys(handlers).length > 0;
94
+ if (hasDefault || hasHandlers) {
95
+ return {
96
+ page: hasDefault,
97
+ $component: components && hasDefault
98
+ ? {
99
+ src: src,
100
+ pick: [
101
+ ...localExportNames.filter(name => name !== "route" && !methods.includes(name)),
102
+ "default",
103
+ "$css"
104
+ ]
105
+ }
106
+ : undefined,
107
+ $$route: hasRouteConfig
108
+ ? {
109
+ src: src,
110
+ pick: ["route"]
111
+ }
112
+ : undefined,
113
+ ...handlers,
114
+ path
115
+ };
116
+ }
117
+ }
118
+ }
package/dist/flat.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { PageFileSystemRouter } from "./convention.ts";
2
+ /**
3
+ * Maps a flat route file (relative to the route dir, extension stripped,
4
+ * e.g. `/concerts.$city`) to a route path in the neutral pattern language,
5
+ * or `undefined` for co-located files that are not routes.
6
+ */
7
+ export declare function flatRoutePathFromFile(routeFile: string): string | undefined;
8
+ /**
9
+ * `PageFileSystemRouter` with the flat filename convention. The module
10
+ * convention is inherited unchanged — default export is a page, `route`
11
+ * config export, `httpMethods` handlers, `components: false` — so the two
12
+ * conventions are equally capable; only the filenames differ.
13
+ */
14
+ export declare class FlatFileSystemRouter extends PageFileSystemRouter {
15
+ toPath(src: string): string | undefined;
16
+ }
package/dist/flat.js ADDED
@@ -0,0 +1,102 @@
1
+ import { PageFileSystemRouter } from "./convention.js";
2
+ import { cleanPath } from "./router.js";
3
+ /** Splits a flat route name on unescaped `.`, resolving `[...]` escapes. */
4
+ function parseSegments(name) {
5
+ const segments = [[]];
6
+ let inEscape = false;
7
+ for (const char of name) {
8
+ if (char === "[" && !inEscape) {
9
+ inEscape = true;
10
+ }
11
+ else if (char === "]" && inEscape) {
12
+ inEscape = false;
13
+ }
14
+ else if (char === "." && !inEscape) {
15
+ segments.push([]);
16
+ }
17
+ else {
18
+ segments[segments.length - 1].push({ char, escaped: inEscape });
19
+ }
20
+ }
21
+ return segments;
22
+ }
23
+ const text = (chars) => chars.map(c => c.char).join("");
24
+ const unescapedAt = (chars, index, char) => chars.at(index)?.char === char && !chars.at(index).escaped;
25
+ function interpretSegment(segment, isFinal) {
26
+ // `_index` routes to its parent's path (the leading `_` must be literal)
27
+ if (isFinal && unescapedAt(segment, 0, "_") && text(segment) === "_index") {
28
+ return { escapesLayout: false };
29
+ }
30
+ let chars = segment;
31
+ let escapesLayout = false;
32
+ if (chars.length > 1 && unescapedAt(chars, -1, "_")) {
33
+ chars = chars.slice(0, -1);
34
+ escapesLayout = true;
35
+ }
36
+ // a pathless layout becomes a group segment the tree nests by and strips
37
+ if (unescapedAt(chars, 0, "_")) {
38
+ return { text: `(${text(chars)})`, escapesLayout };
39
+ }
40
+ let optional = false;
41
+ if (chars.length > 2 && unescapedAt(chars, 0, "(") && unescapedAt(chars, -1, ")")) {
42
+ chars = chars.slice(1, -1);
43
+ optional = true;
44
+ }
45
+ if (unescapedAt(chars, 0, "$")) {
46
+ const param = text(chars.slice(1));
47
+ if (param === "")
48
+ return { text: "*splat", escapesLayout };
49
+ return { text: `:${param}${optional ? "?" : ""}`, escapesLayout };
50
+ }
51
+ if (optional) {
52
+ throw new Error(`Optional static segments have no neutral representation: (${text(chars)})`);
53
+ }
54
+ return { text: text(chars), escapesLayout };
55
+ }
56
+ /**
57
+ * Maps a flat route file (relative to the route dir, extension stripped,
58
+ * e.g. `/concerts.$city`) to a route path in the neutral pattern language,
59
+ * or `undefined` for co-located files that are not routes.
60
+ */
61
+ export function flatRoutePathFromFile(routeFile) {
62
+ const parts = routeFile.slice(1).split("/");
63
+ // a top-level folder routes through its `route` module only
64
+ if (parts.length === 2 && parts[1] !== "route")
65
+ return undefined;
66
+ if (parts.length > 2)
67
+ return undefined;
68
+ const segments = parseSegments(parts[0]);
69
+ const path = [];
70
+ let index = false;
71
+ for (const [i, segment] of segments.entries()) {
72
+ const flat = interpretSegment(segment, i === segments.length - 1);
73
+ if (flat.text === undefined) {
74
+ index = true;
75
+ break;
76
+ }
77
+ // a synthetic group breaks `buildRouteTree`'s prefix match with the
78
+ // parent layout while stripping back out of the URL
79
+ if (flat.escapesLayout)
80
+ path.push(`(${flat.text}_)`);
81
+ path.push(flat.text);
82
+ }
83
+ // an index route takes its parent's path plus a trailing slash, the same
84
+ // shape the nested convention gives `index` files, so the tree nests it
85
+ // under the layout that shares its path
86
+ if (index)
87
+ return `/${path.map(segment => segment + "/").join("")}`;
88
+ return path.length > 0 ? `/${path.join("/")}` : "/";
89
+ }
90
+ /**
91
+ * `PageFileSystemRouter` with the flat filename convention. The module
92
+ * convention is inherited unchanged — default export is a page, `route`
93
+ * config export, `httpMethods` handlers, `components: false` — so the two
94
+ * conventions are equally capable; only the filenames differ.
95
+ */
96
+ export class FlatFileSystemRouter extends PageFileSystemRouter {
97
+ toPath(src) {
98
+ if (this.config.toPath)
99
+ return super.toPath(src);
100
+ return flatRoutePathFromFile(cleanPath(src, this.config));
101
+ }
102
+ }
@@ -0,0 +1,7 @@
1
+ export { analyzeModule, getExportName, getLocalExportName } from "./analyze.ts";
2
+ export type { StaticExportEntry } from "./analyze.ts";
3
+ export type { ModuleRef, RouteManifestEntry } from "./manifest.ts";
4
+ export { BaseFileSystemRouter, cleanPath, glob, normalizePath, type FileSystemRouterConfig } from "./router.ts";
5
+ export { HTTP_METHODS, PageFileSystemRouter, routePathFromFile, type PageFileSystemRouterConfig } from "./convention.ts";
6
+ export { FlatFileSystemRouter, flatRoutePathFromFile } from "./flat.ts";
7
+ export { buildRouteTree, stripRouteGroups, type RouteTreeEntry } from "./tree.ts";