@ilha/router 0.8.0 → 0.8.2
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 +3 -1
- package/dist/plugin-DdquB3Nf.js +642 -0
- package/dist/rolldown.d.ts +1 -1
- package/dist/rolldown.js +10 -1
- package/dist/rspack.d.ts +1 -1
- package/dist/rspack.js +10 -1
- package/dist/src-C_n-faIh.js +1926 -0
- package/dist/ssr.js +139 -10
- package/dist/vite.d.ts +1 -1
- package/dist/vite.js +10 -1
- package/package.json +3 -3
- package/dist/plugin-BW2tnuyF.js +0 -8
- package/dist/src-8LBo_Ieg.js +0 -4
package/dist/index.js
CHANGED
|
@@ -1 +1,3 @@
|
|
|
1
|
-
import{A as
|
|
1
|
+
import { A as wrapLayout, C as routePath, D as src_default, E as serializeHead, M as setHistoryMode, O as useRoute, S as routeParams, T as router, _ as navigating, a as RouterView, b as redirect, c as composeLoaders, d as error, f as head, g as navigate, h as loader, i as RouterLink, j as getHistoryMode, k as wrapError, l as defineLayout, m as isActive, n as LoaderError, o as afterNavigate, p as invalidate, r as Redirect, s as beforeNavigate, t as LOADER_ENDPOINT, u as enableLinkInterception, v as prefetch, w as routeSearch, x as routeHash, y as prime } from "./src-C_n-faIh.js";
|
|
2
|
+
|
|
3
|
+
export { LOADER_ENDPOINT, LoaderError, Redirect, RouterLink, RouterView, afterNavigate, beforeNavigate, composeLoaders, src_default as default, defineLayout, enableLinkInterception, error, getHistoryMode, head, invalidate, isActive, loader, navigate, navigating, prefetch, prime, redirect, routeHash, routeParams, routePath, routeSearch, router, serializeHead, setHistoryMode, useRoute, wrapError, wrapLayout };
|
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import { existsSync, readFileSync, watch } from "node:fs";
|
|
2
|
+
import { basename, dirname, extname, join, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { createUnplugin } from "unplugin";
|
|
4
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
5
|
+
|
|
6
|
+
//#region src/codegen.ts
|
|
7
|
+
function toPosix(p) {
|
|
8
|
+
return p.replace(/\\/g, "/");
|
|
9
|
+
}
|
|
10
|
+
/** Files that should never be treated as pages even if they match the ts/tsx extension. */
|
|
11
|
+
const EXCLUDED_RE = /\.(test|spec|d)\.(ts|tsx)$/;
|
|
12
|
+
/**
|
|
13
|
+
* Match a top-of-statement `export const load`, `export let load`,
|
|
14
|
+
* `export function load`, or `export async function load`. Intentionally
|
|
15
|
+
* conservative: `export { load } from "./x"` re-exports are NOT detected
|
|
16
|
+
* in v1. Declare `load` directly in the file to be picked up.
|
|
17
|
+
*/
|
|
18
|
+
const LOADER_EXPORT_RE = /^\s*export\s+(?:const|let|var|async\s+function|function)\s+load\b/m;
|
|
19
|
+
/** Same shape for `clientLoad` — a loader executed in the browser on client navigations. */
|
|
20
|
+
const CLIENT_LOADER_EXPORT_RE = /^\s*export\s+(?:const|let|var|async\s+function|function)\s+clientLoad\b/m;
|
|
21
|
+
async function detectLoaderExports(file) {
|
|
22
|
+
try {
|
|
23
|
+
const stripped = (await readFile(file, "utf8")).replace(/^\s*\/\/.*$/gm, "");
|
|
24
|
+
return {
|
|
25
|
+
load: LOADER_EXPORT_RE.test(stripped),
|
|
26
|
+
clientLoad: CLIENT_LOADER_EXPORT_RE.test(stripped)
|
|
27
|
+
};
|
|
28
|
+
} catch (err) {
|
|
29
|
+
if (err?.code !== "ENOENT") console.warn(`[ilha-router] failed to read ${file} while detecting loader exports:`, err);
|
|
30
|
+
return {
|
|
31
|
+
load: false,
|
|
32
|
+
clientLoad: false
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function fileToSegment(name) {
|
|
37
|
+
if (name.startsWith("[...") && name.endsWith("]")) return `**:${name.slice(4, -1)}`;
|
|
38
|
+
if (name.startsWith("[") && name.endsWith("]")) return `:${name.slice(1, -1)}`;
|
|
39
|
+
return name;
|
|
40
|
+
}
|
|
41
|
+
/** Route-group directories like "(auth)" are transparent to the URL. */
|
|
42
|
+
function dirToSegment(name) {
|
|
43
|
+
if (name.startsWith("(") && name.endsWith(")")) return "";
|
|
44
|
+
return fileToSegment(name);
|
|
45
|
+
}
|
|
46
|
+
function fileToPattern(pagesDir, file) {
|
|
47
|
+
const rel = toPosix(relative(pagesDir, file));
|
|
48
|
+
const parts = rel.slice(0, -extname(rel).length).split("/");
|
|
49
|
+
const segments = [...parts.slice(0, -1).map(dirToSegment), fileToSegment(parts.at(-1))];
|
|
50
|
+
if (segments.at(-1) === "index") segments.pop();
|
|
51
|
+
return "/" + segments.filter(Boolean).join("/") || "/";
|
|
52
|
+
}
|
|
53
|
+
function patternToName(pattern) {
|
|
54
|
+
if (pattern === "/") return "index";
|
|
55
|
+
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";
|
|
56
|
+
}
|
|
57
|
+
function specificityScore(pattern) {
|
|
58
|
+
if (pattern === "/") return 3;
|
|
59
|
+
if (pattern.includes("**")) return 0;
|
|
60
|
+
if (pattern.includes(":")) return 1;
|
|
61
|
+
return 2;
|
|
62
|
+
}
|
|
63
|
+
/** Deterministic sort: by specificity desc, then by segment count desc, then alphabetical. */
|
|
64
|
+
function sortEntries(entries) {
|
|
65
|
+
return [...entries].sort((a, b) => {
|
|
66
|
+
const specDiff = specificityScore(b.pattern) - specificityScore(a.pattern);
|
|
67
|
+
if (specDiff !== 0) return specDiff;
|
|
68
|
+
const segDiff = b.pattern.split("/").length - a.pattern.split("/").length;
|
|
69
|
+
if (segDiff !== 0) return segDiff;
|
|
70
|
+
return a.pattern.localeCompare(b.pattern);
|
|
71
|
+
});
|
|
72
|
+
}
|
|
73
|
+
function chainForFile(pagesDir, file, all, sentinel) {
|
|
74
|
+
const relDir = toPosix(relative(pagesDir, dirname(file)));
|
|
75
|
+
const parts = relDir === "" ? [] : relDir.split("/");
|
|
76
|
+
const dirs = [pagesDir, ...parts.map((_, i) => join(pagesDir, ...parts.slice(0, i + 1)))];
|
|
77
|
+
const candidatesFor = (dir) => {
|
|
78
|
+
const tsx = `${join(dir, sentinel)}.tsx`;
|
|
79
|
+
if (all.has(tsx)) return [tsx];
|
|
80
|
+
const ts = `${join(dir, sentinel)}.ts`;
|
|
81
|
+
if (all.has(ts)) return [ts];
|
|
82
|
+
return [];
|
|
83
|
+
};
|
|
84
|
+
return dirs.flatMap(candidatesFor);
|
|
85
|
+
}
|
|
86
|
+
const MAX_SCAN_DEPTH = 20;
|
|
87
|
+
async function collectFiles(dir, depth = 0) {
|
|
88
|
+
if (depth > MAX_SCAN_DEPTH) {
|
|
89
|
+
console.warn(`[ilha:pages] Max scan depth (${MAX_SCAN_DEPTH}) reached at ${dir} — skipping`);
|
|
90
|
+
return [];
|
|
91
|
+
}
|
|
92
|
+
const results = [];
|
|
93
|
+
try {
|
|
94
|
+
for (const entry of await readdir(dir, { withFileTypes: true })) {
|
|
95
|
+
const full = join(dir, entry.name);
|
|
96
|
+
if (entry.isDirectory()) results.push(...await collectFiles(full, depth + 1));
|
|
97
|
+
else if (entry.isFile() && /\.(ts|tsx)$/.test(entry.name) && !EXCLUDED_RE.test(entry.name)) results.push(full);
|
|
98
|
+
}
|
|
99
|
+
} catch (e) {
|
|
100
|
+
if (e.code === "ENOENT") return [];
|
|
101
|
+
throw e;
|
|
102
|
+
}
|
|
103
|
+
return results;
|
|
104
|
+
}
|
|
105
|
+
async function scanPages(pagesDir) {
|
|
106
|
+
const all = await collectFiles(pagesDir);
|
|
107
|
+
const allSet = new Set(all);
|
|
108
|
+
const pages = all.filter((f) => !basename(f).startsWith("+"));
|
|
109
|
+
const layoutLoaderCache = /* @__PURE__ */ new Map();
|
|
110
|
+
const getLayoutExports = (file) => {
|
|
111
|
+
let cached = layoutLoaderCache.get(file);
|
|
112
|
+
if (!cached) {
|
|
113
|
+
cached = detectLoaderExports(file);
|
|
114
|
+
layoutLoaderCache.set(file, cached);
|
|
115
|
+
}
|
|
116
|
+
return cached;
|
|
117
|
+
};
|
|
118
|
+
return Promise.all(pages.map(async (file) => {
|
|
119
|
+
const pattern = fileToPattern(pagesDir, file);
|
|
120
|
+
const layouts = chainForFile(pagesDir, file, allSet, "+layout");
|
|
121
|
+
const errors = chainForFile(pagesDir, file, allSet, "+error");
|
|
122
|
+
const [pageExports, ...layoutExports] = await Promise.all([detectLoaderExports(file), ...layouts.map(getLayoutExports)]);
|
|
123
|
+
const loaderLayouts = layouts.filter((_, i) => layoutExports[i].load);
|
|
124
|
+
const clientLoaderLayouts = layouts.filter((_, i) => layoutExports[i].clientLoad);
|
|
125
|
+
return {
|
|
126
|
+
file,
|
|
127
|
+
pattern,
|
|
128
|
+
name: patternToName(pattern),
|
|
129
|
+
layouts,
|
|
130
|
+
errors,
|
|
131
|
+
hasLoader: pageExports.load,
|
|
132
|
+
loaderLayouts,
|
|
133
|
+
hasClientLoader: pageExports.clientLoad,
|
|
134
|
+
clientLoaderLayouts
|
|
135
|
+
};
|
|
136
|
+
}));
|
|
137
|
+
}
|
|
138
|
+
function validateEntries(entries, pagesDir, strict) {
|
|
139
|
+
if (entries.length === 0) {
|
|
140
|
+
console.warn(`[ilha:pages] No pages found in ${pagesDir}`);
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
const seenPatterns = /* @__PURE__ */ new Map();
|
|
144
|
+
const seenNames = /* @__PURE__ */ new Map();
|
|
145
|
+
const problems = [];
|
|
146
|
+
for (const entry of entries) {
|
|
147
|
+
const existingPattern = seenPatterns.get(entry.pattern);
|
|
148
|
+
if (existingPattern) problems.push(`Duplicate route pattern "${entry.pattern}"\n first: ${existingPattern}\n second: ${entry.file}\n The first match wins — the second page will never be reached.`);
|
|
149
|
+
else seenPatterns.set(entry.pattern, entry.file);
|
|
150
|
+
const existingName = seenNames.get(entry.name);
|
|
151
|
+
if (existingName) problems.push(`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.`);
|
|
152
|
+
else seenNames.set(entry.name, entry.file);
|
|
153
|
+
}
|
|
154
|
+
if (problems.length === 0) return;
|
|
155
|
+
if (strict) throw new Error(`[ilha:pages] Route validation failed:\n\n${problems.join("\n\n")}`);
|
|
156
|
+
for (const p of problems) console.warn(`[ilha:pages] ${p}`);
|
|
157
|
+
}
|
|
158
|
+
function resolveGeneratedPaths(outDir) {
|
|
159
|
+
return {
|
|
160
|
+
serverFile: join(outDir, "pages.server.ts"),
|
|
161
|
+
clientFile: join(outDir, "pages.client.ts"),
|
|
162
|
+
loadersFile: join(outDir, "loaders.ts")
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
async function generate(pagesDir, outDir, options = {}) {
|
|
166
|
+
const mode = options.mode ?? "spa";
|
|
167
|
+
const interceptLinks = options.interceptLinks;
|
|
168
|
+
const isStatic = mode === "static";
|
|
169
|
+
const entries = sortEntries(await scanPages(pagesDir));
|
|
170
|
+
validateEntries(entries, pagesDir, options.strict === true);
|
|
171
|
+
await mkdir(outDir, { recursive: true });
|
|
172
|
+
const { serverFile, clientFile, loadersFile } = resolveGeneratedPaths(outDir);
|
|
173
|
+
const serverChanged = await writeIfChanged(serverFile, buildServerFile(entries, serverFile));
|
|
174
|
+
const clientChanged = await writeIfChanged(clientFile, buildClientFile(entries, clientFile, {
|
|
175
|
+
isStatic,
|
|
176
|
+
interceptLinks
|
|
177
|
+
}));
|
|
178
|
+
if (!isStatic) await writeIfChanged(loadersFile, buildLoadersFile(entries, loadersFile, serverFile));
|
|
179
|
+
if (serverChanged || clientChanged) await generateTypes(outDir);
|
|
180
|
+
}
|
|
181
|
+
function buildServerFile(entries, serverFile) {
|
|
182
|
+
const rel = (abs) => {
|
|
183
|
+
const r = toPosix(relative(dirname(serverFile), abs));
|
|
184
|
+
return r.startsWith(".") ? r : `./${r}`;
|
|
185
|
+
};
|
|
186
|
+
const imports = [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
|
|
187
|
+
const wrappedIslandLines = [];
|
|
188
|
+
const registryLines = [];
|
|
189
|
+
const routeLines = [];
|
|
190
|
+
for (const [i, entry] of entries.entries()) {
|
|
191
|
+
imports.push(`import { default as _page${i} } from ${JSON.stringify(rel(entry.file))};`);
|
|
192
|
+
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(rel(l))};`);
|
|
193
|
+
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(rel(e))};`);
|
|
194
|
+
let expr = `_page${i}`;
|
|
195
|
+
for (let j = entry.errors.length - 1; j >= 0; j--) expr = `wrapError(_error${i}_${j}, ${expr})`;
|
|
196
|
+
for (let j = entry.layouts.length - 1; j >= 0; j--) expr = `wrapLayout(_layout${i}_${j}, ${expr})`;
|
|
197
|
+
const wrappedId = `_wrapped${i}`;
|
|
198
|
+
wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
|
|
199
|
+
registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}` + (i < entries.length - 1 ? "," : ""));
|
|
200
|
+
routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})` + (entry.hasLoader || entry.loaderLayouts.length > 0 ? `.markLoader(${JSON.stringify(entry.pattern)})` : ""));
|
|
201
|
+
if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
|
|
202
|
+
}
|
|
203
|
+
return [
|
|
204
|
+
`// @generated by @ilha/router — do not edit`,
|
|
205
|
+
`// Server module. Use for SSR and SSG/prerender.`,
|
|
206
|
+
`// Import via: import { pageRouter, registry } from "ilha:pages/server";`,
|
|
207
|
+
``,
|
|
208
|
+
...imports,
|
|
209
|
+
``,
|
|
210
|
+
...wrappedIslandLines,
|
|
211
|
+
``,
|
|
212
|
+
`export const registry: Record<string, Island<any, any>> = {`,
|
|
213
|
+
...registryLines,
|
|
214
|
+
`};`,
|
|
215
|
+
``,
|
|
216
|
+
`export const pageRouter = router()`,
|
|
217
|
+
...routeLines,
|
|
218
|
+
` ;`
|
|
219
|
+
].join("\n");
|
|
220
|
+
}
|
|
221
|
+
function buildClientFile(entries, clientFile, opts) {
|
|
222
|
+
const { isStatic, interceptLinks } = opts;
|
|
223
|
+
const rel = (abs) => {
|
|
224
|
+
const r = toPosix(relative(dirname(clientFile), abs));
|
|
225
|
+
return r.startsWith(".") ? r : `./${r}`;
|
|
226
|
+
};
|
|
227
|
+
const clientImport = (abs) => `${rel(abs)}?client`;
|
|
228
|
+
const clientLoaderImport = (abs) => `${rel(abs)}?client-loader`;
|
|
229
|
+
let needsComposeLoaders = false;
|
|
230
|
+
const imports = isStatic ? [`import { router as _router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`] : [`import { router, wrapLayout, wrapError } from "@ilha/router";`, `import type { Island } from "ilha";`];
|
|
231
|
+
const wrappedIslandLines = [];
|
|
232
|
+
const registryLines = [];
|
|
233
|
+
const routeLines = [];
|
|
234
|
+
for (const [i, entry] of entries.entries()) {
|
|
235
|
+
imports.push(`import { default as _page${i} } from ${JSON.stringify(clientImport(entry.file))};`);
|
|
236
|
+
for (const [j, l] of entry.layouts.entries()) imports.push(`import { default as _layout${i}_${j} } from ${JSON.stringify(clientImport(l))};`);
|
|
237
|
+
for (const [j, e] of entry.errors.entries()) imports.push(`import { default as _error${i}_${j} } from ${JSON.stringify(clientImport(e))};`);
|
|
238
|
+
let expr = `_page${i}`;
|
|
239
|
+
for (let j = entry.errors.length - 1; j >= 0; j--) expr = `wrapError(_error${i}_${j}, ${expr})`;
|
|
240
|
+
for (let j = entry.layouts.length - 1; j >= 0; j--) expr = `wrapLayout(_layout${i}_${j}, ${expr})`;
|
|
241
|
+
const wrappedId = `_wrapped${i}`;
|
|
242
|
+
wrappedIslandLines.push(`const ${wrappedId} = ${expr};`);
|
|
243
|
+
registryLines.push(` ${JSON.stringify(entry.name)}: ${wrappedId}` + (i < entries.length - 1 ? "," : ""));
|
|
244
|
+
if (!isStatic) {
|
|
245
|
+
routeLines.push(` .route(${JSON.stringify(entry.pattern)}, ${wrappedId})` + (entry.hasLoader || entry.loaderLayouts.length > 0 ? `.markLoader(${JSON.stringify(entry.pattern)})` : ""));
|
|
246
|
+
const clientLoaderIds = [];
|
|
247
|
+
for (const [j, layout] of entry.clientLoaderLayouts.entries()) {
|
|
248
|
+
const id = `_cl${i}_l${j}`;
|
|
249
|
+
imports.push(`import { clientLoad as ${id} } from ${JSON.stringify(clientLoaderImport(layout))};`);
|
|
250
|
+
clientLoaderIds.push(id);
|
|
251
|
+
}
|
|
252
|
+
if (entry.hasClientLoader) {
|
|
253
|
+
const id = `_cl${i}`;
|
|
254
|
+
imports.push(`import { clientLoad as ${id} } from ${JSON.stringify(clientLoaderImport(entry.file))};`);
|
|
255
|
+
clientLoaderIds.push(id);
|
|
256
|
+
}
|
|
257
|
+
if (clientLoaderIds.length > 0) {
|
|
258
|
+
const expr = clientLoaderIds.length === 1 ? clientLoaderIds[0] : `composeLoaders([${clientLoaderIds.join(", ")}])`;
|
|
259
|
+
if (clientLoaderIds.length > 1) needsComposeLoaders = true;
|
|
260
|
+
routeLines.push(` .clientLoader(${JSON.stringify(entry.pattern)}, ${expr})`);
|
|
261
|
+
}
|
|
262
|
+
if (entry.errors.length > 0) routeLines.push(` .errorBoundary(${JSON.stringify(entry.pattern)}, _error${i}_${entry.errors.length - 1})`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
if (needsComposeLoaders) imports[0] = imports[0].replace("{ router", "{ composeLoaders, router");
|
|
266
|
+
const routerExpr = isStatic ? `_router({ mode: "static" })` : `router(${interceptLinks === false ? `{ interceptLinks: false }` : ""})`;
|
|
267
|
+
const lines = [
|
|
268
|
+
`// @generated by @ilha/router — do not edit`,
|
|
269
|
+
`// Client module. Use for browser hydration.`,
|
|
270
|
+
`// Import via: import { pageRouter, registry } from "ilha:pages/client";`,
|
|
271
|
+
``,
|
|
272
|
+
...imports,
|
|
273
|
+
``,
|
|
274
|
+
...wrappedIslandLines,
|
|
275
|
+
``,
|
|
276
|
+
`export const registry: Record<string, Island<any, any>> = {`,
|
|
277
|
+
...registryLines,
|
|
278
|
+
`};`,
|
|
279
|
+
``
|
|
280
|
+
];
|
|
281
|
+
if (isStatic) lines.push(`export const pageRouter = ${routerExpr};`);
|
|
282
|
+
else lines.push(`export const pageRouter = ${routerExpr}`, ...routeLines, ` ;`);
|
|
283
|
+
return lines.join("\n");
|
|
284
|
+
}
|
|
285
|
+
function buildLoadersFile(entries, loadersFile, serverFile) {
|
|
286
|
+
const relFromLoaders = (abs) => {
|
|
287
|
+
const r = toPosix(relative(dirname(loadersFile), abs));
|
|
288
|
+
return r.startsWith(".") ? r : `./${r}`;
|
|
289
|
+
};
|
|
290
|
+
const withLoaders = entries.filter((e) => e.hasLoader || e.loaderLayouts.length > 0);
|
|
291
|
+
if (withLoaders.length === 0) return [
|
|
292
|
+
`// @generated by @ilha/router — do not edit`,
|
|
293
|
+
`// This project has no loader exports; this file is intentionally empty.`,
|
|
294
|
+
``,
|
|
295
|
+
`export {};`,
|
|
296
|
+
``
|
|
297
|
+
].join("\n");
|
|
298
|
+
const serverRel = relFromLoaders(serverFile).replace(/\.tsx?$/, "");
|
|
299
|
+
const imports = [`import { pageRouter } from ${JSON.stringify(serverRel)};`];
|
|
300
|
+
let needsComposeLoaders = false;
|
|
301
|
+
const attachLines = [];
|
|
302
|
+
for (const [i, entry] of withLoaders.entries()) {
|
|
303
|
+
const loaderIds = [];
|
|
304
|
+
for (const [j, layout] of entry.loaderLayouts.entries()) {
|
|
305
|
+
const id = `_p${i}_l${j}`;
|
|
306
|
+
imports.push(`import { load as ${id} } from ${JSON.stringify(relFromLoaders(layout))};`);
|
|
307
|
+
loaderIds.push(id);
|
|
308
|
+
}
|
|
309
|
+
if (entry.hasLoader) {
|
|
310
|
+
const id = `_p${i}`;
|
|
311
|
+
imports.push(`import { load as ${id} } from ${JSON.stringify(relFromLoaders(entry.file))};`);
|
|
312
|
+
loaderIds.push(id);
|
|
313
|
+
}
|
|
314
|
+
const loadersExpr = loaderIds.length === 1 ? loaderIds[0] : `composeLoaders([${loaderIds.join(", ")}])`;
|
|
315
|
+
if (loaderIds.length > 1) needsComposeLoaders = true;
|
|
316
|
+
attachLines.push(`pageRouter.attachLoader(${JSON.stringify(entry.pattern)}, ${loadersExpr});`);
|
|
317
|
+
}
|
|
318
|
+
if (needsComposeLoaders) imports.unshift(`import { composeLoaders } from "@ilha/router";`);
|
|
319
|
+
return [
|
|
320
|
+
`// @generated by @ilha/router — do not edit`,
|
|
321
|
+
`// Server-only. Import this module from your SSR entry to wire loaders`,
|
|
322
|
+
`// onto pageRouter. Importing it from the client is a no-op but wastes`,
|
|
323
|
+
`// bundle size — rely on the default build pipeline to keep it out.`,
|
|
324
|
+
``,
|
|
325
|
+
...imports,
|
|
326
|
+
``,
|
|
327
|
+
...attachLines,
|
|
328
|
+
``
|
|
329
|
+
].join("\n");
|
|
330
|
+
}
|
|
331
|
+
async function writeIfChanged(file, content) {
|
|
332
|
+
try {
|
|
333
|
+
if (await readFile(file, "utf8") === content) return false;
|
|
334
|
+
} catch {}
|
|
335
|
+
await writeFile(file, content, "utf8");
|
|
336
|
+
return true;
|
|
337
|
+
}
|
|
338
|
+
async function generateTypes(outDir) {
|
|
339
|
+
await writeIfChanged(join(outDir, "pages.d.ts"), [
|
|
340
|
+
`// @generated by @ilha/router — do not edit`,
|
|
341
|
+
``,
|
|
342
|
+
`declare module "ilha:pages/server" {`,
|
|
343
|
+
` import type { RouterBuilder } from "@ilha/router";`,
|
|
344
|
+
` import type { Island } from "ilha";`,
|
|
345
|
+
` export const pageRouter: RouterBuilder;`,
|
|
346
|
+
` export const registry: Record<string, Island<any, any>>;`,
|
|
347
|
+
`}`,
|
|
348
|
+
``,
|
|
349
|
+
`declare module "ilha:pages/client" {`,
|
|
350
|
+
` import type { RouterBuilder } from "@ilha/router";`,
|
|
351
|
+
` import type { Island } from "ilha";`,
|
|
352
|
+
` export const pageRouter: RouterBuilder;`,
|
|
353
|
+
` export const registry: Record<string, Island<any, any>>;`,
|
|
354
|
+
`}`,
|
|
355
|
+
``,
|
|
356
|
+
`declare module "ilha:loaders" {`,
|
|
357
|
+
` // Side-effect-only module. Importing it attaches loaders to pageRouter.`,
|
|
358
|
+
`}`,
|
|
359
|
+
``
|
|
360
|
+
].join("\n"));
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
//#endregion
|
|
364
|
+
//#region src/plugin.ts
|
|
365
|
+
const VIRTUAL_PAGES_SERVER = "ilha:pages/server";
|
|
366
|
+
const VIRTUAL_PAGES_CLIENT = "ilha:pages/client";
|
|
367
|
+
const VIRTUAL_LOADERS = "ilha:loaders";
|
|
368
|
+
const RESOLVED_PAGES_SERVER = "\0ilha:pages/server";
|
|
369
|
+
const RESOLVED_PAGES_CLIENT = "\0ilha:pages/client";
|
|
370
|
+
const RESOLVED_LOADERS = "\0ilha:loaders";
|
|
371
|
+
const RESOLVED_VIRTUAL_IDS = [
|
|
372
|
+
RESOLVED_PAGES_SERVER,
|
|
373
|
+
RESOLVED_PAGES_CLIENT,
|
|
374
|
+
RESOLVED_LOADERS
|
|
375
|
+
];
|
|
376
|
+
/** Query suffix used on page/layout imports in the client file. */
|
|
377
|
+
const CLIENT_QUERY = "?client";
|
|
378
|
+
/** Query suffix that re-exports a page/layout's `clientLoad` for the browser bundle. */
|
|
379
|
+
const CLIENT_LOADER_QUERY = "?client-loader";
|
|
380
|
+
/** Read & parse a package.json, returning null on any error. */
|
|
381
|
+
function readJson(path) {
|
|
382
|
+
try {
|
|
383
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
384
|
+
} catch {
|
|
385
|
+
return null;
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/** Resolve a dependency's package.json by walking up node_modules from `root`. */
|
|
389
|
+
function readDepPackageJson(root, name) {
|
|
390
|
+
let dir = root;
|
|
391
|
+
for (;;) {
|
|
392
|
+
const candidate = join(dir, "node_modules", name, "package.json");
|
|
393
|
+
if (existsSync(candidate)) return readJson(candidate);
|
|
394
|
+
const parent = dirname(dir);
|
|
395
|
+
if (parent === dir) return null;
|
|
396
|
+
dir = parent;
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
/**
|
|
400
|
+
* Find app dependencies that bridge ilha primitives — i.e. declare `ilha` as a
|
|
401
|
+
* peer or dependency (e.g. a UI library like `areia`). They render islands with
|
|
402
|
+
* `bind:*`/slot directives, so they MUST share the app's single ilha instance.
|
|
403
|
+
* Returned here so the plugin can give them the same `dedupe` + `ssr.noExternal`
|
|
404
|
+
* treatment as the framework singletons; otherwise SSR externalizes them with
|
|
405
|
+
* their own ilha copy (a second renderCtxStack) and hydration silently breaks.
|
|
406
|
+
* Keeps app vite configs minimal — no manual `noExternal: ["areia"]` needed.
|
|
407
|
+
*/
|
|
408
|
+
function detectIlhaConsumers(root) {
|
|
409
|
+
const appPkg = readJson(join(root, "package.json"));
|
|
410
|
+
if (!appPkg) return [];
|
|
411
|
+
const deps = {
|
|
412
|
+
...appPkg.dependencies ?? {},
|
|
413
|
+
...appPkg.devDependencies ?? {}
|
|
414
|
+
};
|
|
415
|
+
const found = [];
|
|
416
|
+
for (const name of Object.keys(deps)) {
|
|
417
|
+
if (name === "ilha") continue;
|
|
418
|
+
const pkg = readDepPackageJson(root, name);
|
|
419
|
+
if (!pkg) continue;
|
|
420
|
+
const peers = pkg.peerDependencies ?? {};
|
|
421
|
+
const directDeps = pkg.dependencies ?? {};
|
|
422
|
+
if ("ilha" in peers || "ilha" in directDeps) found.push(name);
|
|
423
|
+
}
|
|
424
|
+
return found;
|
|
425
|
+
}
|
|
426
|
+
function resolvePluginPaths(root, options) {
|
|
427
|
+
const pagesDir = resolve(root, options.dir ?? "src/pages");
|
|
428
|
+
const outDir = resolve(root, options.outDir ?? ".ilha");
|
|
429
|
+
const { serverFile, clientFile, loadersFile } = resolveGeneratedPaths(outDir);
|
|
430
|
+
return {
|
|
431
|
+
pagesDir,
|
|
432
|
+
outDir,
|
|
433
|
+
serverFile,
|
|
434
|
+
clientFile,
|
|
435
|
+
loadersFile
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
function createPagesPluginState(options) {
|
|
439
|
+
let pagesDir;
|
|
440
|
+
let outDir;
|
|
441
|
+
let serverFile;
|
|
442
|
+
let clientFile;
|
|
443
|
+
let loadersFile;
|
|
444
|
+
const setPaths = (root) => {
|
|
445
|
+
({pagesDir, outDir, serverFile, clientFile, loadersFile} = resolvePluginPaths(root, options));
|
|
446
|
+
};
|
|
447
|
+
const regen = async () => {
|
|
448
|
+
try {
|
|
449
|
+
await generate(pagesDir, outDir, {
|
|
450
|
+
mode: options.mode,
|
|
451
|
+
interceptLinks: options.interceptLinks,
|
|
452
|
+
strict: options.strict
|
|
453
|
+
});
|
|
454
|
+
} catch (e) {
|
|
455
|
+
console.error("[ilha:pages] codegen failed:", e);
|
|
456
|
+
if (options.strict) throw e;
|
|
457
|
+
}
|
|
458
|
+
};
|
|
459
|
+
const isUnderPagesDir = (file) => file === pagesDir || file.startsWith(pagesDir + sep);
|
|
460
|
+
const shouldRegenOnChange = (file) => {
|
|
461
|
+
if (!isUnderPagesDir(file)) return false;
|
|
462
|
+
const base = basename(file);
|
|
463
|
+
return base.startsWith("+") || /\.(ts|tsx)$/.test(base);
|
|
464
|
+
};
|
|
465
|
+
return {
|
|
466
|
+
get pagesDir() {
|
|
467
|
+
return pagesDir;
|
|
468
|
+
},
|
|
469
|
+
get outDir() {
|
|
470
|
+
return outDir;
|
|
471
|
+
},
|
|
472
|
+
get serverFile() {
|
|
473
|
+
return serverFile;
|
|
474
|
+
},
|
|
475
|
+
get clientFile() {
|
|
476
|
+
return clientFile;
|
|
477
|
+
},
|
|
478
|
+
get loadersFile() {
|
|
479
|
+
return loadersFile;
|
|
480
|
+
},
|
|
481
|
+
setPaths,
|
|
482
|
+
regen,
|
|
483
|
+
shouldRegenOnChange,
|
|
484
|
+
isUnderPagesDir
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
async function regenFromPagesChange(state, file, shouldRegen) {
|
|
488
|
+
if (!shouldRegen(file)) return;
|
|
489
|
+
await state.regen();
|
|
490
|
+
}
|
|
491
|
+
function resolvePagesId(state, id, importer) {
|
|
492
|
+
if (id === "ilha:pages/server") return RESOLVED_PAGES_SERVER;
|
|
493
|
+
if (id === "ilha:pages/client") return RESOLVED_PAGES_CLIENT;
|
|
494
|
+
if (id === "ilha:loaders") return RESOLVED_LOADERS;
|
|
495
|
+
for (const query of [CLIENT_LOADER_QUERY, CLIENT_QUERY]) {
|
|
496
|
+
if (!id.endsWith(query)) continue;
|
|
497
|
+
const bare = id.slice(0, -query.length);
|
|
498
|
+
const resolved = importer ? resolve(importer.replace(/\?.*$/, ""), "..", bare) : resolve(bare);
|
|
499
|
+
if (!state.pagesDir || !state.isUnderPagesDir(resolved)) return;
|
|
500
|
+
return resolved + query;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
function loadPagesModule(state, id) {
|
|
504
|
+
if (id === "\0ilha:pages/server") {
|
|
505
|
+
const spec = state.serverFile.replace(/\.tsx?$/, "");
|
|
506
|
+
return `export { pageRouter, registry } from ${JSON.stringify(spec)};`;
|
|
507
|
+
}
|
|
508
|
+
if (id === "\0ilha:pages/client") {
|
|
509
|
+
const spec = state.clientFile.replace(/\.tsx?$/, "");
|
|
510
|
+
return `export { pageRouter, registry } from ${JSON.stringify(spec)};`;
|
|
511
|
+
}
|
|
512
|
+
if (id === "\0ilha:loaders") {
|
|
513
|
+
const spec = state.loadersFile.replace(/\.tsx?$/, "");
|
|
514
|
+
return `import ${JSON.stringify(spec)};`;
|
|
515
|
+
}
|
|
516
|
+
if (id.endsWith("?client-loader")) {
|
|
517
|
+
const bare = id.slice(0, -14);
|
|
518
|
+
return `export { clientLoad } from ${JSON.stringify(bare)};`;
|
|
519
|
+
}
|
|
520
|
+
if (id.endsWith("?client")) {
|
|
521
|
+
const bare = id.slice(0, -7);
|
|
522
|
+
return `export { default } from ${JSON.stringify(bare)};`;
|
|
523
|
+
}
|
|
524
|
+
}
|
|
525
|
+
function createStructuralInvalidate(state, invalidate) {
|
|
526
|
+
return async (file) => {
|
|
527
|
+
if (!state.isUnderPagesDir(file)) return;
|
|
528
|
+
await state.regen();
|
|
529
|
+
await invalidate();
|
|
530
|
+
};
|
|
531
|
+
}
|
|
532
|
+
function setupRspackPagesWatcher(state, structuralInvalidate) {
|
|
533
|
+
let watcher = null;
|
|
534
|
+
let poll = null;
|
|
535
|
+
let closed = false;
|
|
536
|
+
const attach = () => {
|
|
537
|
+
watcher = watch(state.pagesDir, { recursive: true }, (_event, filename) => {
|
|
538
|
+
if (!filename) return;
|
|
539
|
+
structuralInvalidate(join(state.pagesDir, filename));
|
|
540
|
+
});
|
|
541
|
+
};
|
|
542
|
+
if (existsSync(state.pagesDir)) attach();
|
|
543
|
+
else {
|
|
544
|
+
poll = setInterval(() => {
|
|
545
|
+
if (closed || !existsSync(state.pagesDir)) return;
|
|
546
|
+
clearInterval(poll);
|
|
547
|
+
poll = null;
|
|
548
|
+
attach();
|
|
549
|
+
structuralInvalidate(join(state.pagesDir, "."));
|
|
550
|
+
}, 1e3);
|
|
551
|
+
poll.unref?.();
|
|
552
|
+
}
|
|
553
|
+
return () => {
|
|
554
|
+
closed = true;
|
|
555
|
+
if (poll) clearInterval(poll);
|
|
556
|
+
watcher?.close();
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const pagesFactory = (options = {}) => {
|
|
560
|
+
const state = createPagesPluginState(options);
|
|
561
|
+
return {
|
|
562
|
+
name: "ilha:pages",
|
|
563
|
+
async buildStart() {
|
|
564
|
+
if (!state.pagesDir) state.setPaths(process.cwd());
|
|
565
|
+
this.addWatchFile?.(state.pagesDir);
|
|
566
|
+
await state.regen();
|
|
567
|
+
},
|
|
568
|
+
async watchChange(file) {
|
|
569
|
+
await regenFromPagesChange(state, file, (f) => state.shouldRegenOnChange(f));
|
|
570
|
+
},
|
|
571
|
+
resolveId(id, importer) {
|
|
572
|
+
return resolvePagesId(state, id, importer);
|
|
573
|
+
},
|
|
574
|
+
load(id) {
|
|
575
|
+
return loadPagesModule(state, id);
|
|
576
|
+
},
|
|
577
|
+
vite: {
|
|
578
|
+
config(userConfig) {
|
|
579
|
+
const singletonPeers = [
|
|
580
|
+
"ilha",
|
|
581
|
+
"@ilha/store",
|
|
582
|
+
"@ilha/router",
|
|
583
|
+
"alien-signals",
|
|
584
|
+
...detectIlhaConsumers(userConfig.root ? resolve(userConfig.root) : process.cwd())
|
|
585
|
+
];
|
|
586
|
+
const existingNoExternal = userConfig.ssr?.noExternal;
|
|
587
|
+
const noExternal = existingNoExternal === true ? true : [.../* @__PURE__ */ new Set([...Array.isArray(existingNoExternal) ? existingNoExternal : existingNoExternal != null ? [existingNoExternal] : [], ...singletonPeers])];
|
|
588
|
+
return {
|
|
589
|
+
resolve: { dedupe: [.../* @__PURE__ */ new Set([...userConfig.resolve?.dedupe ?? [], ...singletonPeers])] },
|
|
590
|
+
ssr: { noExternal },
|
|
591
|
+
optimizeDeps: {
|
|
592
|
+
...userConfig.optimizeDeps,
|
|
593
|
+
include: [.../* @__PURE__ */ new Set([
|
|
594
|
+
...userConfig.optimizeDeps?.include ?? [],
|
|
595
|
+
"ilha",
|
|
596
|
+
"ilha/jsx-runtime",
|
|
597
|
+
"ilha/jsx-dev-runtime",
|
|
598
|
+
"@ilha/store",
|
|
599
|
+
"alien-signals"
|
|
600
|
+
])]
|
|
601
|
+
}
|
|
602
|
+
};
|
|
603
|
+
},
|
|
604
|
+
configResolved(config) {
|
|
605
|
+
state.setPaths(config.root);
|
|
606
|
+
},
|
|
607
|
+
configureServer(server) {
|
|
608
|
+
server.watcher.add(state.pagesDir);
|
|
609
|
+
const structuralInvalidate = createStructuralInvalidate(state, async () => {
|
|
610
|
+
for (const id of RESOLVED_VIRTUAL_IDS) {
|
|
611
|
+
const mod = server.moduleGraph.getModuleById(id);
|
|
612
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
613
|
+
}
|
|
614
|
+
server.hot.send({ type: "full-reload" });
|
|
615
|
+
});
|
|
616
|
+
server.watcher.on("add", structuralInvalidate);
|
|
617
|
+
server.watcher.on("addDir", structuralInvalidate);
|
|
618
|
+
server.watcher.on("unlink", structuralInvalidate);
|
|
619
|
+
server.watcher.on("change", async (file) => {
|
|
620
|
+
if (state.shouldRegenOnChange(file)) await structuralInvalidate(file);
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
},
|
|
624
|
+
rspack(compiler) {
|
|
625
|
+
state.setPaths(compiler.options.context ?? process.cwd());
|
|
626
|
+
const structuralInvalidate = createStructuralInvalidate(state, () => {
|
|
627
|
+
if (!compiler.watching) return;
|
|
628
|
+
compiler.invalidate();
|
|
629
|
+
});
|
|
630
|
+
let closeWatcher;
|
|
631
|
+
compiler.hooks.watchRun.tap("ilha:pages", () => {
|
|
632
|
+
closeWatcher?.();
|
|
633
|
+
closeWatcher = setupRspackPagesWatcher(state, structuralInvalidate);
|
|
634
|
+
});
|
|
635
|
+
compiler.hooks.shutdown.tap("ilha:pages", () => closeWatcher?.());
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
};
|
|
639
|
+
const ilhaPages = /* #__PURE__ */ createUnplugin(pagesFactory);
|
|
640
|
+
|
|
641
|
+
//#endregion
|
|
642
|
+
export { ilhaPages as t };
|
package/dist/rolldown.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export type { LayoutHandler, ErrorHandler, RouteSnapshot, AppError } from "./index";
|
|
2
2
|
export { ilhaPages, type IlhaPagesOptions } from "./plugin";
|
|
3
3
|
import { type IlhaPagesOptions } from "./plugin";
|
|
4
4
|
/** Rolldown plugin — use via `@ilha/router/rolldown`. */
|
package/dist/rolldown.js
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import { t as ilhaPages } from "./plugin-DdquB3Nf.js";
|
|
2
|
+
|
|
3
|
+
//#region src/rolldown.ts
|
|
4
|
+
/** Rolldown plugin — use via `@ilha/router/rolldown`. */
|
|
5
|
+
function pages(options = {}) {
|
|
6
|
+
return ilhaPages.rolldown(options);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
export { ilhaPages, pages };
|
package/dist/rspack.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export type { LayoutHandler, ErrorHandler, RouteSnapshot, AppError } from "./index";
|
|
2
2
|
export { ilhaPages, type IlhaPagesOptions } from "./plugin";
|
|
3
3
|
import { type IlhaPagesOptions } from "./plugin";
|
|
4
4
|
/** Rspack plugin — use via `@ilha/router/rspack`. */
|
package/dist/rspack.js
CHANGED
|
@@ -1 +1,10 @@
|
|
|
1
|
-
import{
|
|
1
|
+
import { t as ilhaPages } from "./plugin-DdquB3Nf.js";
|
|
2
|
+
|
|
3
|
+
//#region src/rspack.ts
|
|
4
|
+
/** Rspack plugin — use via `@ilha/router/rspack`. */
|
|
5
|
+
function pages(options = {}) {
|
|
6
|
+
return ilhaPages.rspack(options);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
export { ilhaPages, pages };
|