@ilha/router 0.1.1 → 0.2.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/README.md +386 -37
- package/dist/index.d.ts +128 -17
- package/dist/index.js +350 -55
- package/dist/vite.js +125 -13
- package/package.json +2 -2
package/dist/vite.js
CHANGED
|
@@ -4,14 +4,35 @@ import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
|
4
4
|
//#region src/vite.ts
|
|
5
5
|
/** Files that should never be treated as pages even if they match the ts/tsx extension. */
|
|
6
6
|
const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
|
|
7
|
+
/**
|
|
8
|
+
* Match a top-of-statement `export const load`, `export let load`,
|
|
9
|
+
* `export function load`, or `export async function load`. Intentionally
|
|
10
|
+
* conservative: `export { load } from "./x"` re-exports are NOT detected
|
|
11
|
+
* in v1. Declare `load` directly in the file to be picked up.
|
|
12
|
+
*/
|
|
13
|
+
const LOADER_EXPORT_RE = /^\s*export\s+(?:const|let|var|async\s+function|function)\s+load\b/m;
|
|
14
|
+
async function hasLoaderExport(file) {
|
|
15
|
+
try {
|
|
16
|
+
const stripped = (await readFile(file, "utf8")).replace(/^\s*\/\/.*$/gm, "");
|
|
17
|
+
return LOADER_EXPORT_RE.test(stripped);
|
|
18
|
+
} catch {
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
7
22
|
function fileToSegment(name) {
|
|
8
23
|
if (name.startsWith("[...") && name.endsWith("]")) return `**:${name.slice(4, -1)}`;
|
|
9
24
|
if (name.startsWith("[") && name.endsWith("]")) return `:${name.slice(1, -1)}`;
|
|
10
25
|
return name;
|
|
11
26
|
}
|
|
27
|
+
/** Route-group directories like "(auth)" are transparent to the URL. */
|
|
28
|
+
function dirToSegment(name) {
|
|
29
|
+
if (name.startsWith("(") && name.endsWith(")")) return "";
|
|
30
|
+
return fileToSegment(name);
|
|
31
|
+
}
|
|
12
32
|
function fileToPattern(pagesDir, file) {
|
|
13
33
|
const rel = relative(pagesDir, file);
|
|
14
|
-
const
|
|
34
|
+
const parts = rel.slice(0, -extname(rel).length).split("/");
|
|
35
|
+
const segments = [...parts.slice(0, -1).map(dirToSegment), fileToSegment(parts.at(-1))];
|
|
15
36
|
if (segments.at(-1) === "index") segments.pop();
|
|
16
37
|
return "/" + segments.filter(Boolean).join("/") || "/";
|
|
17
38
|
}
|
|
@@ -57,16 +78,32 @@ async function collectFiles(dir, depth = 0) {
|
|
|
57
78
|
async function scanPages(pagesDir) {
|
|
58
79
|
const all = await collectFiles(pagesDir);
|
|
59
80
|
const allSet = new Set(all);
|
|
60
|
-
|
|
81
|
+
const pages = all.filter((f) => !basename(f).startsWith("+"));
|
|
82
|
+
const layoutLoaderCache = /* @__PURE__ */ new Map();
|
|
83
|
+
const getLayoutHasLoader = (file) => {
|
|
84
|
+
let cached = layoutLoaderCache.get(file);
|
|
85
|
+
if (!cached) {
|
|
86
|
+
cached = hasLoaderExport(file);
|
|
87
|
+
layoutLoaderCache.set(file, cached);
|
|
88
|
+
}
|
|
89
|
+
return cached;
|
|
90
|
+
};
|
|
91
|
+
return Promise.all(pages.map(async (file) => {
|
|
61
92
|
const pattern = fileToPattern(pagesDir, file);
|
|
93
|
+
const layouts = chainForFile(pagesDir, file, allSet, "+layout.ts");
|
|
94
|
+
const errors = chainForFile(pagesDir, file, allSet, "+error.ts");
|
|
95
|
+
const [pageHasLoader, ...layoutFlags] = await Promise.all([hasLoaderExport(file), ...layouts.map(getLayoutHasLoader)]);
|
|
96
|
+
const loaderLayouts = layouts.filter((_, i) => layoutFlags[i]);
|
|
62
97
|
return {
|
|
63
98
|
file,
|
|
64
99
|
pattern,
|
|
65
100
|
name: patternToName(pattern),
|
|
66
|
-
layouts
|
|
67
|
-
errors
|
|
101
|
+
layouts,
|
|
102
|
+
errors,
|
|
103
|
+
hasLoader: pageHasLoader,
|
|
104
|
+
loaderLayouts
|
|
68
105
|
};
|
|
69
|
-
});
|
|
106
|
+
}));
|
|
70
107
|
}
|
|
71
108
|
function validateEntries(entries, pagesDir) {
|
|
72
109
|
if (entries.length === 0) {
|
|
@@ -91,15 +128,16 @@ async function generate(pagesDir, outFile) {
|
|
|
91
128
|
const r = relative(dirname(outFile), abs);
|
|
92
129
|
return r.startsWith(".") ? r : `./${r}`;
|
|
93
130
|
};
|
|
94
|
-
const imports = [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island }
|
|
131
|
+
const imports = [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
|
|
95
132
|
const wrappedIslandLines = [];
|
|
96
133
|
const registryLines = [];
|
|
97
134
|
const routeLines = [];
|
|
135
|
+
const clientImport = (abs) => `${rel(abs)}?client`;
|
|
98
136
|
for (const [i, entry] of entries.entries()) {
|
|
99
137
|
const pageId = `_page${i}`;
|
|
100
|
-
imports.push(`import { default as ${pageId} } from ${JSON.stringify(
|
|
101
|
-
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(
|
|
102
|
-
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(
|
|
138
|
+
imports.push(`import { default as ${pageId} } from ${JSON.stringify(clientImport(entry.file))};`);
|
|
139
|
+
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
|
|
140
|
+
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
|
|
103
141
|
let expr = pageId;
|
|
104
142
|
for (let j = entry.errors.length - 1; j >= 0; j--) expr = `wrapError(_error${i}_${j}, ${expr})`;
|
|
105
143
|
for (let j = entry.layouts.length - 1; j >= 0; j--) expr = `wrapLayout(_layout${i}_${j}, ${expr})`;
|
|
@@ -124,7 +162,56 @@ async function generate(pagesDir, outFile) {
|
|
|
124
162
|
` ;`
|
|
125
163
|
].join("\n");
|
|
126
164
|
await mkdir(dirname(outFile), { recursive: true });
|
|
127
|
-
|
|
165
|
+
const routesChanged = await writeIfChanged(outFile, code);
|
|
166
|
+
const loadersFile = join(dirname(outFile), "loaders.ts");
|
|
167
|
+
const loadersChanged = await writeIfChanged(loadersFile, buildLoadersFile(entries, loadersFile, outFile));
|
|
168
|
+
if (routesChanged || loadersChanged) await generateTypes(outFile);
|
|
169
|
+
}
|
|
170
|
+
function buildLoadersFile(entries, loadersFile, routesFile) {
|
|
171
|
+
const relFromLoaders = (abs) => {
|
|
172
|
+
const r = relative(dirname(loadersFile), abs);
|
|
173
|
+
return r.startsWith(".") ? r : `./${r}`;
|
|
174
|
+
};
|
|
175
|
+
const withLoaders = entries.filter((e) => e.hasLoader || e.loaderLayouts.length > 0);
|
|
176
|
+
if (withLoaders.length === 0) return [
|
|
177
|
+
`// @generated by @ilha/router — do not edit`,
|
|
178
|
+
`// This project has no loader exports; this file is intentionally empty.`,
|
|
179
|
+
``,
|
|
180
|
+
`export {};`,
|
|
181
|
+
``
|
|
182
|
+
].join("\n");
|
|
183
|
+
const routesRel = relFromLoaders(routesFile).replace(/\.ts$/, "");
|
|
184
|
+
const imports = [`import { pageRouter } from ${JSON.stringify(routesRel)};`];
|
|
185
|
+
let needsComposeLoaders = false;
|
|
186
|
+
const attachLines = [];
|
|
187
|
+
for (const [i, entry] of withLoaders.entries()) {
|
|
188
|
+
const loaderIds = [];
|
|
189
|
+
for (const [j, layout] of entry.loaderLayouts.entries()) {
|
|
190
|
+
const id = `_p${i}_l${j}`;
|
|
191
|
+
imports.push(`import { load as ${id} } from ${JSON.stringify(relFromLoaders(layout))};`);
|
|
192
|
+
loaderIds.push(id);
|
|
193
|
+
}
|
|
194
|
+
if (entry.hasLoader) {
|
|
195
|
+
const id = `_p${i}`;
|
|
196
|
+
imports.push(`import { load as ${id} } from ${JSON.stringify(relFromLoaders(entry.file))};`);
|
|
197
|
+
loaderIds.push(id);
|
|
198
|
+
}
|
|
199
|
+
const loadersExpr = loaderIds.length === 1 ? loaderIds[0] : `composeLoaders([${loaderIds.join(", ")}])`;
|
|
200
|
+
if (loaderIds.length > 1) needsComposeLoaders = true;
|
|
201
|
+
attachLines.push(`pageRouter.attachLoader(${JSON.stringify(entry.pattern)}, ${loadersExpr});`);
|
|
202
|
+
}
|
|
203
|
+
if (needsComposeLoaders) imports.unshift(`import { composeLoaders } from "@ilha/router";`);
|
|
204
|
+
return [
|
|
205
|
+
`// @generated by @ilha/router — do not edit`,
|
|
206
|
+
`// Server-only. Import this module from your SSR entry to wire loaders`,
|
|
207
|
+
`// onto pageRouter. Importing it from the client is a no-op but wastes`,
|
|
208
|
+
`// bundle size — rely on the default build pipeline to keep it out.`,
|
|
209
|
+
``,
|
|
210
|
+
...imports,
|
|
211
|
+
``,
|
|
212
|
+
...attachLines,
|
|
213
|
+
``
|
|
214
|
+
].join("\n");
|
|
128
215
|
}
|
|
129
216
|
async function writeIfChanged(file, content) {
|
|
130
217
|
try {
|
|
@@ -146,16 +233,25 @@ async function generateTypes(outFile) {
|
|
|
146
233
|
` import type { Island } from "ilha";`,
|
|
147
234
|
` export const registry: Record<string, Island<any, any>>;`,
|
|
148
235
|
`}`,
|
|
236
|
+
``,
|
|
237
|
+
`declare module "ilha:loaders" {`,
|
|
238
|
+
` // Side-effect-only module. Importing it attaches loaders to pageRouter.`,
|
|
239
|
+
`}`,
|
|
149
240
|
``
|
|
150
241
|
].join("\n"));
|
|
151
242
|
}
|
|
152
243
|
const VIRTUAL_PAGES = "ilha:pages";
|
|
153
244
|
const VIRTUAL_REGISTRY = "ilha:registry";
|
|
245
|
+
const VIRTUAL_LOADERS = "ilha:loaders";
|
|
154
246
|
const RESOLVED_PAGES = "\0ilha:pages";
|
|
155
247
|
const RESOLVED_REGISTRY = "\0ilha:registry";
|
|
248
|
+
const RESOLVED_LOADERS = "\0ilha:loaders";
|
|
249
|
+
/** Query suffix used on page/layout imports in the client-safe routes file. */
|
|
250
|
+
const CLIENT_QUERY = "?client";
|
|
156
251
|
function pages(options = {}) {
|
|
157
252
|
let pagesDir;
|
|
158
253
|
let outFile;
|
|
254
|
+
let loadersFile;
|
|
159
255
|
async function regen() {
|
|
160
256
|
try {
|
|
161
257
|
await generate(pagesDir, outFile);
|
|
@@ -168,6 +264,7 @@ function pages(options = {}) {
|
|
|
168
264
|
configResolved(config) {
|
|
169
265
|
pagesDir = resolve(config.root, options.dir ?? "src/pages");
|
|
170
266
|
outFile = resolve(config.root, options.generated ?? ".ilha/routes.ts");
|
|
267
|
+
loadersFile = join(dirname(outFile), "loaders.ts");
|
|
171
268
|
},
|
|
172
269
|
async buildStart() {
|
|
173
270
|
await regen();
|
|
@@ -177,7 +274,11 @@ function pages(options = {}) {
|
|
|
177
274
|
const structuralInvalidate = async (file) => {
|
|
178
275
|
if (!file.startsWith(pagesDir)) return;
|
|
179
276
|
await regen();
|
|
180
|
-
for (const id of [
|
|
277
|
+
for (const id of [
|
|
278
|
+
RESOLVED_PAGES,
|
|
279
|
+
RESOLVED_REGISTRY,
|
|
280
|
+
RESOLVED_LOADERS
|
|
281
|
+
]) {
|
|
181
282
|
const mod = server.moduleGraph.getModuleById(id);
|
|
182
283
|
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
183
284
|
}
|
|
@@ -188,16 +289,27 @@ function pages(options = {}) {
|
|
|
188
289
|
server.watcher.on("unlink", structuralInvalidate);
|
|
189
290
|
server.watcher.on("change", async (file) => {
|
|
190
291
|
if (!file.startsWith(pagesDir)) return;
|
|
191
|
-
|
|
292
|
+
const base = basename(file);
|
|
293
|
+
if (base.startsWith("+") || /\.(ts|tsx)$/.test(base)) await structuralInvalidate(file);
|
|
192
294
|
});
|
|
193
295
|
},
|
|
194
|
-
resolveId(id) {
|
|
296
|
+
resolveId(id, importer) {
|
|
195
297
|
if (id === VIRTUAL_PAGES) return RESOLVED_PAGES;
|
|
196
298
|
if (id === VIRTUAL_REGISTRY) return RESOLVED_REGISTRY;
|
|
299
|
+
if (id === VIRTUAL_LOADERS) return RESOLVED_LOADERS;
|
|
300
|
+
if (id.endsWith(CLIENT_QUERY)) {
|
|
301
|
+
const bare = id.slice(0, -7);
|
|
302
|
+
return (importer ? resolve(dirname(importer.replace(/\?.*$/, "")), bare) : resolve(bare)) + CLIENT_QUERY;
|
|
303
|
+
}
|
|
197
304
|
},
|
|
198
305
|
load(id) {
|
|
199
306
|
if (id === RESOLVED_PAGES) return `export { pageRouter } from ${JSON.stringify(outFile)};`;
|
|
200
307
|
if (id === RESOLVED_REGISTRY) return `export { registry } from ${JSON.stringify(outFile)};`;
|
|
308
|
+
if (id === RESOLVED_LOADERS) return `import ${JSON.stringify(loadersFile)};`;
|
|
309
|
+
if (id.endsWith(CLIENT_QUERY)) {
|
|
310
|
+
const bare = id.slice(0, -7);
|
|
311
|
+
return `export { default } from ${JSON.stringify(bare)};`;
|
|
312
|
+
}
|
|
201
313
|
}
|
|
202
314
|
};
|
|
203
315
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ilha/router",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A tiny SPA router for Ilha",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Ryuz <ryuzer@proton.me>",
|
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
"test": "bun test"
|
|
28
28
|
},
|
|
29
29
|
"dependencies": {
|
|
30
|
-
"ilha": "0.
|
|
30
|
+
"ilha": "0.2.0",
|
|
31
31
|
"rou3": "0.8.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|