@ilha/router 0.1.1 → 0.2.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 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 segments = rel.slice(0, -extname(rel).length).split("/").map(fileToSegment);
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
- return all.filter((f) => !basename(f).startsWith("+")).map((file) => {
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: chainForFile(pagesDir, file, allSet, "+layout.ts"),
67
- errors: chainForFile(pagesDir, file, allSet, "+error.ts")
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 } from "ilha";`];
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(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))};`);
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
- if (await writeIfChanged(outFile, code)) await generateTypes(outFile);
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 [RESOLVED_PAGES, RESOLVED_REGISTRY]) {
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
- if (basename(file).startsWith("+")) await structuralInvalidate(file);
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.1.1",
3
+ "version": "0.2.1",
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.1.0",
30
+ "ilha": "0.2.1",
31
31
  "rou3": "0.8.1"
32
32
  },
33
33
  "devDependencies": {