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/dist/index.js ADDED
@@ -0,0 +1,5 @@
1
+ export { analyzeModule, getExportName, getLocalExportName } from "./analyze.js";
2
+ export { BaseFileSystemRouter, cleanPath, glob, normalizePath } from "./router.js";
3
+ export { HTTP_METHODS, PageFileSystemRouter, routePathFromFile } from "./convention.js";
4
+ export { FlatFileSystemRouter, flatRoutePathFromFile } from "./flat.js";
5
+ export { buildRouteTree, stripRouteGroups } from "./tree.js";
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The neutral route manifest.
3
+ *
4
+ * A file-system router produces a flat list of `RouteManifestEntry` objects.
5
+ * The manifest is deliberately router-agnostic: it records paths, params,
6
+ * module refs and export info, but says nothing about how a router turns
7
+ * those into its own route definitions. Each router ships a small emission
8
+ * adapter for that (e.g. `@solidjs/router/fs` emits `RouteDefinition`s),
9
+ * and each bundler ships a delivery adapter (e.g. `filesystem-routing/vite`
10
+ * serializes the manifest into a virtual module).
11
+ */
12
+ /**
13
+ * A reference to a subset of a route module's exports.
14
+ *
15
+ * Delivery adapters materialize refs into code. The key the ref is stored
16
+ * under decides how:
17
+ * - keys prefixed `$` become lazy refs: `{ src, import: () => import(...) }`
18
+ * - keys prefixed `$$` become eager refs: `{ require: () => ({ ...exports }) }`
19
+ */
20
+ export interface ModuleRef {
21
+ /** Absolute path of the source module. */
22
+ src: string;
23
+ /** The named exports (or `"default"`) this ref selects from the module. */
24
+ pick: string[];
25
+ }
26
+ export interface RouteManifestEntry {
27
+ /**
28
+ * The route path in the neutral pattern language proven by SolidStart:
29
+ * `:param`, `:param?` (optional) and `*rest` (catch-all) segments.
30
+ * Group segments (`(name)`) are retained; emission adapters decide how
31
+ * to nest and strip them.
32
+ */
33
+ path: string;
34
+ /** `true` when the module renders a page (has a default export). */
35
+ page?: boolean;
36
+ /** Lazy ref to the page component module. */
37
+ $component?: ModuleRef;
38
+ /** Eager ref to the route config (`route` export), when present. */
39
+ $$route?: ModuleRef;
40
+ /**
41
+ * Additional refs and metadata added by convention extensions, e.g. a
42
+ * server convention may add `$GET`/`$POST` handler refs. Keys prefixed
43
+ * `$`/`$$` are treated as lazy/eager module refs by delivery adapters.
44
+ */
45
+ [key: string]: unknown;
46
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * The neutral route manifest.
3
+ *
4
+ * A file-system router produces a flat list of `RouteManifestEntry` objects.
5
+ * The manifest is deliberately router-agnostic: it records paths, params,
6
+ * module refs and export info, but says nothing about how a router turns
7
+ * those into its own route definitions. Each router ships a small emission
8
+ * adapter for that (e.g. `@solidjs/router/fs` emits `RouteDefinition`s),
9
+ * and each bundler ships a delivery adapter (e.g. `filesystem-routing/vite`
10
+ * serializes the manifest into a virtual module).
11
+ */
12
+ export {};
@@ -0,0 +1,54 @@
1
+ import type { RouteManifestEntry } from "./manifest.ts";
2
+ export declare const glob: (path: string) => string[];
3
+ /** Normalize a file path to posix separators (bundler-agnostic). */
4
+ export declare function normalizePath(path: string): string;
5
+ export interface FileSystemRouterConfig {
6
+ /** Absolute path of the route directory to scan. */
7
+ dir: string;
8
+ /** File extensions (without the dot) that participate in routing. */
9
+ extensions: string[];
10
+ /**
11
+ * Pluggable filename convention: maps a route file (relative to `dir`,
12
+ * extension stripped, e.g. `/blog/[id]`) to a route path in the neutral
13
+ * pattern language, or `undefined` to skip the file. When omitted, the
14
+ * router's own `toPath` implementation is used.
15
+ */
16
+ toPath?: (routeFile: string, config: FileSystemRouterConfig) => string | undefined;
17
+ /**
18
+ * Pluggable module convention: maps a source file to a manifest entry,
19
+ * or `undefined` to skip the file. When omitted, the router's own
20
+ * `toRoute` implementation is used.
21
+ */
22
+ toRoute?: (src: string, router: BaseFileSystemRouter) => RouteManifestEntry | undefined;
23
+ }
24
+ type RouterEvent = CustomEvent<{
25
+ route: string;
26
+ type: "update" | "remove" | "add";
27
+ }>;
28
+ /** Strips the route dir prefix and the file extension from a source path. */
29
+ export declare function cleanPath(src: string, config: FileSystemRouterConfig): string;
30
+ /**
31
+ * Bundler-agnostic file-system router: scans a directory into a flat, neutral
32
+ * route manifest and keeps it up to date as files are added, changed and
33
+ * removed. Delivery adapters (e.g. the Vite plugin) subscribe to `reload`
34
+ * events; emission adapters consume the manifest.
35
+ */
36
+ export declare class BaseFileSystemRouter extends EventTarget {
37
+ routes: RouteManifestEntry[];
38
+ config: FileSystemRouterConfig;
39
+ constructor(config: FileSystemRouterConfig);
40
+ glob(): string;
41
+ buildRoutes(): Promise<RouteManifestEntry[]>;
42
+ isRoute(src: string): boolean;
43
+ toPath(src: string): string | undefined;
44
+ toRoute(src: string): RouteManifestEntry | undefined;
45
+ _addRoute(route: RouteManifestEntry): boolean;
46
+ addRoute(src: string): Promise<void>;
47
+ reload(route: string, type: "update" | "remove" | "add"): void;
48
+ updateRoute(src_: string): Promise<void>;
49
+ removeRoute(src: string): void;
50
+ on(type: string, cb: (evt: RouterEvent) => void): () => void;
51
+ buildRoutesPromise?: Promise<RouteManifestEntry[]>;
52
+ getRoutes(): Promise<RouteManifestEntry[]>;
53
+ }
54
+ export {};
package/dist/router.js ADDED
@@ -0,0 +1,128 @@
1
+ import fg from "fast-glob";
2
+ import micromatch from "micromatch";
3
+ import { posix, sep } from "node:path";
4
+ export const glob = (path) => fg.sync(path, { absolute: true });
5
+ /** Normalize a file path to posix separators (bundler-agnostic). */
6
+ export function normalizePath(path) {
7
+ return sep === "\\" ? path.replace(/\\/g, "/") : path;
8
+ }
9
+ /** Strips the route dir prefix and the file extension from a source path. */
10
+ export function cleanPath(src, config) {
11
+ return src
12
+ .slice(config.dir.length)
13
+ .replace(new RegExp(`\.(${(config.extensions ?? []).join("|")})$`), "");
14
+ }
15
+ /**
16
+ * Bundler-agnostic file-system router: scans a directory into a flat, neutral
17
+ * route manifest and keeps it up to date as files are added, changed and
18
+ * removed. Delivery adapters (e.g. the Vite plugin) subscribe to `reload`
19
+ * events; emission adapters consume the manifest.
20
+ */
21
+ export class BaseFileSystemRouter extends EventTarget {
22
+ routes;
23
+ config;
24
+ constructor(config) {
25
+ super();
26
+ this.routes = [];
27
+ this.config = config;
28
+ }
29
+ glob() {
30
+ const extensions = this.config.extensions;
31
+ // a single-entry brace pattern like `.{tsx}` is treated as a literal
32
+ const suffix = extensions.length === 1 ? `.${extensions[0]}` : `.{${extensions.join(",")}}`;
33
+ return posix.join(fg.convertPathToPattern(this.config.dir), "**/*") + suffix;
34
+ }
35
+ async buildRoutes() {
36
+ for (const src of glob(this.glob())) {
37
+ await this.addRoute(src);
38
+ }
39
+ return this.routes;
40
+ }
41
+ isRoute(src) {
42
+ return Boolean(micromatch(src, this.glob())?.length);
43
+ }
44
+ toPath(src) {
45
+ if (this.config.toPath)
46
+ return this.config.toPath(cleanPath(src, this.config), this.config);
47
+ throw new Error("Not implemented");
48
+ }
49
+ toRoute(src) {
50
+ if (this.config.toRoute)
51
+ return this.config.toRoute(src, this);
52
+ throw new Error("Not implemented");
53
+ }
54
+ _addRoute(route) {
55
+ const idx = this.routes.findIndex(r => r.path === route.path);
56
+ if (idx >= 0)
57
+ this.routes.splice(idx, 1);
58
+ this.routes.push(route);
59
+ return idx >= 0;
60
+ }
61
+ async addRoute(src) {
62
+ src = normalizePath(src);
63
+ if (this.isRoute(src)) {
64
+ try {
65
+ const route = this.toRoute(src);
66
+ if (route) {
67
+ this._addRoute(route);
68
+ this.reload(route.path, "add");
69
+ }
70
+ }
71
+ catch (e) {
72
+ console.error(e);
73
+ }
74
+ }
75
+ }
76
+ reload(route, type) {
77
+ this.dispatchEvent(new CustomEvent("reload", {
78
+ detail: {
79
+ route,
80
+ type
81
+ }
82
+ }));
83
+ }
84
+ async updateRoute(src_) {
85
+ const src = normalizePath(src_);
86
+ if (this.isRoute(src)) {
87
+ try {
88
+ const route = this.toRoute(src);
89
+ if (route) {
90
+ const updated = this._addRoute(route);
91
+ this.reload(route.path, updated ? "update" : "add");
92
+ }
93
+ else {
94
+ this.removeRoute(src_);
95
+ }
96
+ }
97
+ catch (e) {
98
+ console.error(e);
99
+ }
100
+ }
101
+ }
102
+ removeRoute(src) {
103
+ src = normalizePath(src);
104
+ if (this.isRoute(src)) {
105
+ const path = this.toPath(src);
106
+ if (path === undefined) {
107
+ return;
108
+ }
109
+ const idx = this.routes.findIndex(r => r.path === path);
110
+ if (idx === -1)
111
+ return;
112
+ this.routes.splice(idx, 1);
113
+ this.reload(path, "remove");
114
+ }
115
+ }
116
+ on(type, cb) {
117
+ this.addEventListener(type, cb);
118
+ return () => this.removeEventListener(type, cb);
119
+ }
120
+ buildRoutesPromise;
121
+ async getRoutes() {
122
+ if (!this.buildRoutesPromise) {
123
+ this.buildRoutesPromise = this.buildRoutes();
124
+ }
125
+ await this.buildRoutesPromise;
126
+ return this.routes;
127
+ }
128
+ }
package/dist/tree.d.ts ADDED
@@ -0,0 +1,26 @@
1
+ import type { RouteManifestEntry } from "./manifest.ts";
2
+ /**
3
+ * Nesting and group-stripping for the neutral route manifest.
4
+ *
5
+ * This module is deliberately free of Node and bundler imports: delivery
6
+ * adapters run it at build time (the Vite adapter serializes the resulting
7
+ * tree into the virtual module), and runtime consumers that receive only a
8
+ * flat manifest can run it themselves.
9
+ */
10
+ export interface RouteTreeEntry extends RouteManifestEntry {
11
+ /** The manifest path this node was nested under, group segments included. */
12
+ id: string;
13
+ children?: RouteTreeEntry[];
14
+ }
15
+ /** Removes `(group)` segments from a route path: `/(app)/home` → `/home`. */
16
+ export declare function stripRouteGroups(path: string): string;
17
+ /**
18
+ * Nests a flat manifest by path prefix and strips `(group)` segments, so
19
+ * `/(app)/dashboard` renders at `/dashboard` inside the `/(app)` layout.
20
+ *
21
+ * Nested entries keep the path *relative to their parent*, which is what
22
+ * nested routers expect. Pass only the entries that participate in the tree
23
+ * (typically `entries.filter(entry => entry.page)`); the input is not
24
+ * mutated.
25
+ */
26
+ export declare function buildRouteTree(entries: readonly RouteManifestEntry[]): RouteTreeEntry[];
package/dist/tree.js ADDED
@@ -0,0 +1,29 @@
1
+ /** Removes `(group)` segments from a route path: `/(app)/home` → `/home`. */
2
+ export function stripRouteGroups(path) {
3
+ return path.replace(/\([^)/]+\)/g, "").replace(/\/+/g, "/");
4
+ }
5
+ /**
6
+ * Nests a flat manifest by path prefix and strips `(group)` segments, so
7
+ * `/(app)/dashboard` renders at `/dashboard` inside the `/(app)` layout.
8
+ *
9
+ * Nested entries keep the path *relative to their parent*, which is what
10
+ * nested routers expect. Pass only the entries that participate in the tree
11
+ * (typically `entries.filter(entry => entry.page)`); the input is not
12
+ * mutated.
13
+ */
14
+ export function buildRouteTree(entries) {
15
+ function processRoute(routes, route, id) {
16
+ const parentRoute = routes.find(o => id.startsWith(o.id + "/"));
17
+ if (!parentRoute) {
18
+ routes.push({ ...route, id, path: stripRouteGroups(id) });
19
+ return;
20
+ }
21
+ processRoute(parentRoute.children || (parentRoute.children = []), route, id.slice(parentRoute.id.length));
22
+ }
23
+ return [...entries]
24
+ .sort((a, b) => a.path.length - b.path.length)
25
+ .reduce((routes, route) => {
26
+ processRoute(routes, route, route.path);
27
+ return routes;
28
+ }, []);
29
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The virtual module that serves the serialized route manifest.
3
+ *
4
+ * `virtual:` is Vite's convention for plugin-served modules, and the name is
5
+ * not tied to any router. Override it with the plugin's `moduleId` option.
6
+ */
7
+ export declare const moduleId = "virtual:file-routes";
8
+ export declare const DEFAULT_EXTENSIONS: string[];
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The virtual module that serves the serialized route manifest.
3
+ *
4
+ * `virtual:` is Vite's convention for plugin-served modules, and the name is
5
+ * not tied to any router. Override it with the plugin's `moduleId` option.
6
+ */
7
+ export const moduleId = "virtual:file-routes";
8
+ export const DEFAULT_EXTENSIONS = ["js", "jsx", "ts", "tsx"];
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Creates a debounced function that delays the invocation of a function
3
+ */
4
+ export declare const debounce: <T extends (...args: any[]) => void>(cb: T, debounceMs: number) => (...args: Parameters<T>) => void;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Creates a debounced function that delays the invocation of a function
3
+ */
4
+ export const debounce = (cb, debounceMs) => {
5
+ let timeout;
6
+ return (...args) => {
7
+ clearTimeout(timeout);
8
+ timeout = setTimeout(() => cb(...args), debounceMs);
9
+ };
10
+ };
@@ -0,0 +1,5 @@
1
+ import type { PluginOption } from "vite";
2
+ import type { BaseFileSystemRouter } from "../router.ts";
3
+ export declare const fileSystemWatcher: (getRouter: (environment: string) => BaseFileSystemRouter | undefined, moduleId: string,
4
+ /** Runs on every route change, before the module is invalidated. */
5
+ onReload?: () => void | Promise<void>) => PluginOption;
@@ -0,0 +1,69 @@
1
+ import { debounce } from "./debounce.js";
2
+ function setupWatcher(watcher, routes) {
3
+ watcher.on("unlink", path => routes.removeRoute(path));
4
+ watcher.on("add", path => routes.addRoute(path));
5
+ watcher.on("change", path => routes.updateRoute(path));
6
+ }
7
+ function createRoutesReloader(server, routes, environment, moduleId, onReload) {
8
+ const devEnv = server.environments[environment];
9
+ if (!devEnv?.moduleGraph)
10
+ return;
11
+ /**
12
+ * Debounce catches multiple route changes in a row
13
+ * Short timeout for inexpensive invalidations
14
+ */
15
+ const invalidateModule = debounce((mod) => {
16
+ devEnv.moduleGraph.invalidateModule(mod);
17
+ }, 0);
18
+ /**
19
+ * Long debounce timeout for expensive reloads
20
+ */
21
+ const reloadModule = debounce((mod) => {
22
+ devEnv.reloadModule(mod);
23
+ }, 200);
24
+ return routes.on("reload", function handleRoutesReload(evt) {
25
+ void onReload?.();
26
+ const mod = devEnv.moduleGraph.getModuleById(moduleId);
27
+ if (!mod) {
28
+ devEnv.hot.send({ type: "full-reload" });
29
+ return;
30
+ }
31
+ if (evt.detail.type !== "update") {
32
+ // Adding or removing a route changes the manifest itself, and marking
33
+ // it invalid is not enough to replace it: whoever evaluated the module
34
+ // holds their own copy — the browser, or a server-side module runner
35
+ // like the one nitro serves SSR from — and only a reload displaces it.
36
+ // An `update` needs no reload: the entries are unchanged, and the route
37
+ // module's own HMR covers its contents.
38
+ reloadModule(mod);
39
+ }
40
+ else {
41
+ invalidateModule(mod);
42
+ }
43
+ });
44
+ }
45
+ export const fileSystemWatcher = (getRouter, moduleId,
46
+ /** Runs on every route change, before the module is invalidated. */
47
+ onReload) => {
48
+ const plugin = {
49
+ name: "filesystem-routing:watcher",
50
+ async configureServer(server) {
51
+ const watched = new Set();
52
+ for (const environment of Object.keys(server.environments)) {
53
+ const router = getRouter(environment);
54
+ if (!router)
55
+ continue;
56
+ if (!watched.has(router)) {
57
+ watched.add(router);
58
+ setupWatcher(server.watcher, router);
59
+ }
60
+ // Build the manifest before listening for reloads, so the initial
61
+ // scan's `add` events don't invalidate the module mid-page-load.
62
+ router
63
+ .getRoutes()
64
+ .then(() => createRoutesReloader(server, router, environment, moduleId, onReload));
65
+ }
66
+ }
67
+ };
68
+ return plugin;
69
+ };
@@ -0,0 +1,69 @@
1
+ import type { PluginOption } from "vite";
2
+ import { type PageFileSystemRouterConfig } from "../convention.ts";
3
+ import { BaseFileSystemRouter } from "../router.ts";
4
+ import { DEFAULT_EXTENSIONS, moduleId } from "./constants.ts";
5
+ export { DEFAULT_EXTENSIONS, moduleId };
6
+ export { treeShake } from "./tree-shake.ts";
7
+ export { fileSystemWatcher } from "./fs-watcher.ts";
8
+ export interface FileRoutesOptions extends Pick<PageFileSystemRouterConfig, "components" | "httpMethods" | "toPath" | "toRoute"> {
9
+ /** Route directory, relative to the Vite root. Defaults to `src/routes`. */
10
+ dir?: string;
11
+ /** File extensions that participate in routing. Defaults to js/jsx/ts/tsx. */
12
+ extensions?: string[];
13
+ /**
14
+ * A custom file-system router (scanning + convention) used for every Vite
15
+ * environment. Defaults to a `PageFileSystemRouter` over `dir`, configured
16
+ * with the convention options above.
17
+ */
18
+ router?: BaseFileSystemRouter;
19
+ /**
20
+ * Per-environment file-system routers, keyed by Vite environment name.
21
+ * Frameworks (e.g. SolidStart) use this to serve different conventions to
22
+ * client and server environments. Falls back to `router` for environments
23
+ * not listed.
24
+ */
25
+ routers?: Record<string, BaseFileSystemRouter>;
26
+ /** The id the route manifest is served from. Defaults to `virtual:file-routes`. */
27
+ moduleId?: string;
28
+ /**
29
+ * Vite environments whose build should take every route module as an
30
+ * entry, so routes are code-split into their own chunks. Frameworks pass
31
+ * their browser environment's name; the inputs are added to whatever the
32
+ * framework already configures.
33
+ */
34
+ buildInputs?: string | string[];
35
+ /**
36
+ * Packages that import the virtual module and must therefore stay out of
37
+ * esbuild's dependency prebundling, which cannot resolve it. Emission
38
+ * adapters are expected to take the manifest as an argument instead of
39
+ * importing the virtual module (so app source holds the only import, which
40
+ * prebundling never touches), making this an escape hatch for packages
41
+ * that import it anyway. Defaults to `[]`.
42
+ */
43
+ optimizeDepsExclude?: string[];
44
+ /**
45
+ * Write a declaration for the virtual module typing the manifest as a
46
+ * literal tuple, and keep it in step with the route directory. Routers
47
+ * that derive types from their route table need this: a runtime array
48
+ * tells them nothing, so path-derived types silently degrade without it.
49
+ *
50
+ * `true` writes `file-routes.d.ts` next to the Vite root; pass a path to
51
+ * put it elsewhere. The generated file is self-contained — reference it
52
+ * *instead of* `filesystem-routing/types`, never both.
53
+ */
54
+ types?: boolean | string;
55
+ }
56
+ /**
57
+ * The Vite delivery adapter for `filesystem-routing`.
58
+ *
59
+ * Serializes the neutral route manifest into the virtual module — module refs
60
+ * become code-split dynamic imports (`$`-prefixed keys) or eagerly required
61
+ * static imports (`$$`-prefixed keys) — and keeps it hot as route files are
62
+ * added, changed and removed.
63
+ *
64
+ * The module serves two views of the same entries: the default export is the
65
+ * flat manifest, and `pageRoutes` is the page entries nested by path with
66
+ * `(group)` segments stripped, so emission adapters don't each reimplement
67
+ * the tree.
68
+ */
69
+ export declare function fileRoutes(options?: FileRoutesOptions): PluginOption[];