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 +21 -0
- package/README.md +317 -0
- package/dist/analyze.d.ts +16 -0
- package/dist/analyze.js +29 -0
- package/dist/convention.d.ts +45 -0
- package/dist/convention.js +118 -0
- package/dist/flat.d.ts +16 -0
- package/dist/flat.js +102 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +5 -0
- package/dist/manifest.d.ts +46 -0
- package/dist/manifest.js +12 -0
- package/dist/router.d.ts +54 -0
- package/dist/router.js +128 -0
- package/dist/tree.d.ts +26 -0
- package/dist/tree.js +29 -0
- package/dist/vite/constants.d.ts +8 -0
- package/dist/vite/constants.js +8 -0
- package/dist/vite/debounce.d.ts +4 -0
- package/dist/vite/debounce.js +10 -0
- package/dist/vite/fs-watcher.d.ts +5 -0
- package/dist/vite/fs-watcher.js +69 -0
- package/dist/vite/index.d.ts +69 -0
- package/dist/vite/index.js +215 -0
- package/dist/vite/tree-shake.d.ts +2 -0
- package/dist/vite/tree-shake.js +448 -0
- package/dist/vite/types.d.ts +10 -0
- package/dist/vite/types.js +123 -0
- package/package.json +84 -0
- package/types.d.ts +58 -0
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;
|
package/dist/analyze.js
ADDED
|
@@ -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
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -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";
|