@ilha/router 0.1.1

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/vite.js ADDED
@@ -0,0 +1,205 @@
1
+ import { wrapError, wrapLayout } from "./index.js";
2
+ import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
3
+ import { basename, dirname, extname, join, relative, resolve } from "node:path";
4
+ //#region src/vite.ts
5
+ /** Files that should never be treated as pages even if they match the ts/tsx extension. */
6
+ const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
7
+ function fileToSegment(name) {
8
+ if (name.startsWith("[...") && name.endsWith("]")) return `**:${name.slice(4, -1)}`;
9
+ if (name.startsWith("[") && name.endsWith("]")) return `:${name.slice(1, -1)}`;
10
+ return name;
11
+ }
12
+ function fileToPattern(pagesDir, file) {
13
+ const rel = relative(pagesDir, file);
14
+ const segments = rel.slice(0, -extname(rel).length).split("/").map(fileToSegment);
15
+ if (segments.at(-1) === "index") segments.pop();
16
+ return "/" + segments.filter(Boolean).join("/") || "/";
17
+ }
18
+ function patternToName(pattern) {
19
+ if (pattern === "/") return "index";
20
+ return pattern.replace(/^\//, "").replace(/\*\*:[^/]*/g, (m) => m.length > 3 ? m.slice(3) : "wildcard").replace(/:/g, "").replace(/\*\*/g, "wildcard").replace(/\//g, "-").replace(/[^a-zA-Z0-9-]/g, "") || "page";
21
+ }
22
+ function specificityScore(pattern) {
23
+ if (pattern === "/") return 3;
24
+ if (pattern.includes("**")) return 0;
25
+ if (pattern.includes(":")) return 1;
26
+ return 2;
27
+ }
28
+ /** Deterministic sort: by specificity desc, then by segment count desc, then alphabetical. */
29
+ function sortEntries(entries) {
30
+ return [...entries].sort((a, b) => {
31
+ const specDiff = specificityScore(b.pattern) - specificityScore(a.pattern);
32
+ if (specDiff !== 0) return specDiff;
33
+ const segDiff = b.pattern.split("/").length - a.pattern.split("/").length;
34
+ if (segDiff !== 0) return segDiff;
35
+ return a.pattern.localeCompare(b.pattern);
36
+ });
37
+ }
38
+ function chainForFile(pagesDir, file, all, sentinel) {
39
+ const relDir = relative(pagesDir, dirname(file));
40
+ const parts = relDir === "" ? [] : relDir.split("/");
41
+ return [pagesDir, ...parts.map((_, i) => join(pagesDir, ...parts.slice(0, i + 1)))].map((dir) => join(dir, sentinel)).filter((candidate) => all.has(candidate));
42
+ }
43
+ const MAX_SCAN_DEPTH = 20;
44
+ async function collectFiles(dir, depth = 0) {
45
+ if (depth > MAX_SCAN_DEPTH) {
46
+ console.warn(`[ilha:pages] Max scan depth (${MAX_SCAN_DEPTH}) reached at ${dir} — skipping`);
47
+ return [];
48
+ }
49
+ const results = [];
50
+ for (const entry of await readdir(dir, { withFileTypes: true })) {
51
+ const full = join(dir, entry.name);
52
+ if (entry.isDirectory()) results.push(...await collectFiles(full, depth + 1));
53
+ else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !EXCLUDED_RE.test(entry.name)) results.push(full);
54
+ }
55
+ return results;
56
+ }
57
+ async function scanPages(pagesDir) {
58
+ const all = await collectFiles(pagesDir);
59
+ const allSet = new Set(all);
60
+ return all.filter((f) => !basename(f).startsWith("+")).map((file) => {
61
+ const pattern = fileToPattern(pagesDir, file);
62
+ return {
63
+ file,
64
+ pattern,
65
+ name: patternToName(pattern),
66
+ layouts: chainForFile(pagesDir, file, allSet, "+layout.ts"),
67
+ errors: chainForFile(pagesDir, file, allSet, "+error.ts")
68
+ };
69
+ });
70
+ }
71
+ function validateEntries(entries, pagesDir) {
72
+ if (entries.length === 0) {
73
+ console.warn(`[ilha:pages] No pages found in ${pagesDir}`);
74
+ return;
75
+ }
76
+ const seenPatterns = /* @__PURE__ */ new Map();
77
+ const seenNames = /* @__PURE__ */ new Map();
78
+ for (const entry of entries) {
79
+ const existingPattern = seenPatterns.get(entry.pattern);
80
+ if (existingPattern) console.warn(`[ilha:pages] Duplicate route pattern "${entry.pattern}"\n first: ${existingPattern}\n second: ${entry.file}\n The first match wins — the second page will never be reached.`);
81
+ else seenPatterns.set(entry.pattern, entry.file);
82
+ const existingName = seenNames.get(entry.name);
83
+ if (existingName) console.warn(`[ilha:pages] Registry name collision: "${entry.name}" is used by both\n ${existingName}\n ${entry.file}\n Hydration may not work correctly for one of these routes.`);
84
+ else seenNames.set(entry.name, entry.file);
85
+ }
86
+ }
87
+ async function generate(pagesDir, outFile) {
88
+ const entries = sortEntries(await scanPages(pagesDir));
89
+ validateEntries(entries, pagesDir);
90
+ const rel = (abs) => {
91
+ const r = relative(dirname(outFile), abs);
92
+ return r.startsWith(".") ? r : `./${r}`;
93
+ };
94
+ const imports = [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
95
+ const wrappedIslandLines = [];
96
+ const registryLines = [];
97
+ const routeLines = [];
98
+ for (const [i, entry] of entries.entries()) {
99
+ const pageId = `_page${i}`;
100
+ imports.push(`import { default as ${pageId} } from ${JSON.stringify(rel(entry.file))};`);
101
+ for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(rel(l))};`);
102
+ for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(rel(e))};`);
103
+ let expr = pageId;
104
+ for (let j = entry.errors.length - 1; j >= 0; j--) expr = `wrapError(_error${i}_${j}, ${expr})`;
105
+ for (let j = entry.layouts.length - 1; j >= 0; j--) expr = `wrapLayout(_layout${i}_${j}, ${expr})`;
106
+ const wrappedId = `_wrapped${i}`;
107
+ wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
108
+ registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}` + (i < entries.length - 1 ? "," : ""));
109
+ routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})`);
110
+ }
111
+ const code = [
112
+ `// @generated by @ilha/router — do not edit`,
113
+ ``,
114
+ ...imports,
115
+ ``,
116
+ ...wrappedIslandLines,
117
+ ``,
118
+ `export const registry: Record<string, Island<any, any>> = {`,
119
+ ...registryLines,
120
+ `};`,
121
+ ``,
122
+ `export const pageRouter = router()`,
123
+ ...routeLines,
124
+ ` ;`
125
+ ].join("\n");
126
+ await mkdir(dirname(outFile), { recursive: true });
127
+ if (await writeIfChanged(outFile, code)) await generateTypes(outFile);
128
+ }
129
+ async function writeIfChanged(file, content) {
130
+ try {
131
+ if (await readFile(file, "utf8") === content) return false;
132
+ } catch {}
133
+ await writeFile(file, content, "utf8");
134
+ return true;
135
+ }
136
+ async function generateTypes(outFile) {
137
+ await writeIfChanged(outFile.replace(/\.ts$/, ".d.ts"), [
138
+ `// @generated by @ilha/router — do not edit`,
139
+ ``,
140
+ `declare module "ilha:pages" {`,
141
+ ` import type { RouterBuilder } from "@ilha/router";`,
142
+ ` export const pageRouter: RouterBuilder;`,
143
+ `}`,
144
+ ``,
145
+ `declare module "ilha:registry" {`,
146
+ ` import type { Island } from "ilha";`,
147
+ ` export const registry: Record<string, Island<any, any>>;`,
148
+ `}`,
149
+ ``
150
+ ].join("\n"));
151
+ }
152
+ const VIRTUAL_PAGES = "ilha:pages";
153
+ const VIRTUAL_REGISTRY = "ilha:registry";
154
+ const RESOLVED_PAGES = "\0ilha:pages";
155
+ const RESOLVED_REGISTRY = "\0ilha:registry";
156
+ function pages(options = {}) {
157
+ let pagesDir;
158
+ let outFile;
159
+ async function regen() {
160
+ try {
161
+ await generate(pagesDir, outFile);
162
+ } catch (e) {
163
+ console.error("[ilha:pages] codegen failed:", e);
164
+ }
165
+ }
166
+ return {
167
+ name: "ilha:pages",
168
+ configResolved(config) {
169
+ pagesDir = resolve(config.root, options.dir ?? "src/pages");
170
+ outFile = resolve(config.root, options.generated ?? ".ilha/routes.ts");
171
+ },
172
+ async buildStart() {
173
+ await regen();
174
+ },
175
+ configureServer(server) {
176
+ server.watcher.add(pagesDir);
177
+ const structuralInvalidate = async (file) => {
178
+ if (!file.startsWith(pagesDir)) return;
179
+ await regen();
180
+ for (const id of [RESOLVED_PAGES, RESOLVED_REGISTRY]) {
181
+ const mod = server.moduleGraph.getModuleById(id);
182
+ if (mod) server.moduleGraph.invalidateModule(mod);
183
+ }
184
+ server.hot.send({ type: "full-reload" });
185
+ };
186
+ server.watcher.on("add", structuralInvalidate);
187
+ server.watcher.on("addDir", structuralInvalidate);
188
+ server.watcher.on("unlink", structuralInvalidate);
189
+ server.watcher.on("change", async (file) => {
190
+ if (!file.startsWith(pagesDir)) return;
191
+ if (basename(file).startsWith("+")) await structuralInvalidate(file);
192
+ });
193
+ },
194
+ resolveId(id) {
195
+ if (id === VIRTUAL_PAGES) return RESOLVED_PAGES;
196
+ if (id === VIRTUAL_REGISTRY) return RESOLVED_REGISTRY;
197
+ },
198
+ load(id) {
199
+ if (id === RESOLVED_PAGES) return `export { pageRouter } from ${JSON.stringify(outFile)};`;
200
+ if (id === RESOLVED_REGISTRY) return `export { registry } from ${JSON.stringify(outFile)};`;
201
+ }
202
+ };
203
+ }
204
+ //#endregion
205
+ export { pages, wrapError, wrapLayout };
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@ilha/router",
3
+ "version": "0.1.1",
4
+ "description": "A tiny SPA router for Ilha",
5
+ "license": "MIT",
6
+ "author": "Ryuz <ryuzer@proton.me>",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "git+https://github.com/ilhajs/ilha.git"
10
+ },
11
+ "files": [
12
+ "dist"
13
+ ],
14
+ "type": "module",
15
+ "exports": {
16
+ ".": {
17
+ "types": "./dist/index.d.ts",
18
+ "import": "./dist/index.js"
19
+ },
20
+ "./vite": {
21
+ "types": "./dist/vite.d.ts",
22
+ "import": "./dist/vite.js"
23
+ }
24
+ },
25
+ "scripts": {
26
+ "build": "tsc && tsdown",
27
+ "test": "bun test"
28
+ },
29
+ "dependencies": {
30
+ "ilha": "0.1.0",
31
+ "rou3": "0.8.1"
32
+ },
33
+ "devDependencies": {
34
+ "vite": "^8.0.7"
35
+ }
36
+ }