@reactra/vite-plugin 0.1.0-alpha.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/walker.js ADDED
@@ -0,0 +1,1070 @@
1
+ // @reactra/vite-plugin — file-based route walker
2
+ //
3
+ // Owner: reactra-router-spec.md §2 (file-based convention) + §5.1 (manifest
4
+ // format) + reactra-compiler-spec.md §7.1 (vite plugin watcher hook)
5
+ //
6
+ // Phase 1 #5b scope:
7
+ // - deriveRoutePath: file-relative-path → URL pattern (pure fn)
8
+ // - scanPages: recursively walk <root>/src/pages/ → DiscoveredRoute[]
9
+ // - emitRouteManifest: format the typed-route-manifest TS module text
10
+ //
11
+ // Phase-1-supported page-file conventions:
12
+ // - `index.tsx` → segment is implicit (parent dir)
13
+ // - `foo.tsx` → segment is `foo`
14
+ // - `[id].tsx` → segment is `:id` (single dynamic segment)
15
+ // - `.ts` / `.jsx` / `.js` are accepted too (pages without JSX possible)
16
+ //
17
+ // Phase-1-skipped (logged as "unsupported in v0.1" → null):
18
+ // - `_*` → underscore-prefixed = special (middleware/layout/
19
+ // loading/error) → tracked as #5c/#5d
20
+ // - `(group)/` → route group → #5c
21
+ // - `[...slug]` → catch-all → #5c
22
+ // - `@parallel/` → parallel route → Phase 4 (per Router §2 table)
23
+ //
24
+ // Walker output is sorted by `path` for deterministic manifests — important
25
+ // so HMR doesn't churn the manifest on every dev-server boot.
26
+ import { readFileSync, readdirSync, statSync } from "node:fs";
27
+ import { dirname, join, relative } from "node:path";
28
+ import { lex, skipTrivia } from "@reactra/babel-plugin";
29
+ import { createGraphCache } from "./graph-cache.js";
30
+ import { componentNameFromContainers, containersToInfo, queriesFromContainers, storeNamesFromContainers, serviceNamesFromContainers, prefetchFromContainers, } from "./extract.js";
31
+ const SUPPORTED_EXT_RE = /\.(?:tsx|jsx|ts|js)$/;
32
+ const stripExt = (s) => s.replace(SUPPORTED_EXT_RE, "");
33
+ /**
34
+ * A path segment that the walker should refuse to derive a route from.
35
+ * Returning true at the per-segment level lets us skip whole subtrees
36
+ * (the directory traversal also checks isSpecialSegment).
37
+ *
38
+ * NOTE — Wave-3 §2b: route groups (`(group)`) and catch-all rest segments
39
+ * (`[...slug]`) were removed from this predicate. They're now handled
40
+ * inside {@link deriveRoutePath} as transparent / catch-all respectively.
41
+ */
42
+ const isSpecialSegment = (segment) => segment.startsWith("_") ||
43
+ segment.startsWith("@");
44
+ /**
45
+ * Wave-3 §2b — `[...name]` catch-all segment. Must appear ONLY as the LAST
46
+ * segment of the file path; a catch-all in the middle is rejected by
47
+ * {@link deriveRoutePath}. The URL representation is `*name`.
48
+ */
49
+ const isCatchAllSegment = (segment) => segment.startsWith("[...") && segment.endsWith("]") && segment.length > 5;
50
+ /**
51
+ * Wave-3 §2b — a `(name)` directory at any depth under `src/pages/`.
52
+ * Route groups organise files in the source tree without affecting the
53
+ * URL: `pages/(marketing)/about.tsx` → URL `/about`, not `/marketing/about`.
54
+ * Middleware inside a group applies based on FILE-TREE location, not URL
55
+ * prefix — see {@link chainsForRoutes}.
56
+ */
57
+ const isRouteGroupSegment = (segment) => segment.length >= 2 && segment.startsWith("(") && segment.endsWith(")");
58
+ /**
59
+ * Convert a path relative to `src/pages/` into the URL pattern the router
60
+ * registers. Returns `null` for Phase-1-unsupported features, special
61
+ * filenames, and unsupported extensions.
62
+ *
63
+ * Examples:
64
+ * "index.tsx" → "/"
65
+ * "about.tsx" → "/about"
66
+ * "customers/index.tsx" → "/customers"
67
+ * "customers/[id].tsx" → "/customers/:id"
68
+ * "customers/[id]/edit.tsx" → "/customers/:id/edit"
69
+ * "(auth)/login.tsx" → "/login" (route group — Wave 3 §2b)
70
+ * "(marketing)/index.tsx" → "/" (route group at root)
71
+ * "(auth)/dashboard/index.tsx" → "/dashboard" (route group + nested)
72
+ * "blog/[...slug].tsx" → "/blog/*slug" (catch-all — Wave 3 §2b)
73
+ * "[...rest].tsx" → "/*rest" (root catch-all)
74
+ * "_middleware.ts" → null (special — Wave 3 §2b)
75
+ * "README.md" → null (unsupported extension)
76
+ *
77
+ * Catch-all constraints:
78
+ * - `[...name]` must be the LAST segment (a mid-path catch-all is
79
+ * rejected with `null`).
80
+ * - `index` filename + catch-all not allowed
81
+ * (`[...slug]/index.tsx` rejected).
82
+ */
83
+ export const deriveRoutePath = (relativePath) => {
84
+ const normalized = relativePath.replace(/\\/g, "/");
85
+ if (!SUPPORTED_EXT_RE.test(normalized))
86
+ return null;
87
+ const segments = stripExt(normalized).split("/");
88
+ // Reject anywhere along the path — a special parent dir disqualifies all
89
+ // its files in the walker. Route groups (`(group)`) and catch-alls
90
+ // (`[...name]`) are NOT special here — handled below.
91
+ for (const seg of segments)
92
+ if (isSpecialSegment(seg))
93
+ return null;
94
+ // Drop route-group segments (Wave 3 §2b) before deriving the URL.
95
+ const urlSegments = segments.filter((s) => !isRouteGroupSegment(s));
96
+ if (urlSegments.length === 0)
97
+ return "/"; // e.g. `(marketing)/index.tsx`
98
+ // Catch-all check: `[...name]` must be the LAST segment if present.
99
+ // A mid-path catch-all is rejected.
100
+ for (let i = 0; i < urlSegments.length - 1; i++) {
101
+ if (isCatchAllSegment(urlSegments[i]))
102
+ return null;
103
+ }
104
+ const last = urlSegments[urlSegments.length - 1];
105
+ // `[...slug]/index.tsx` is not a valid shape (catch-all has no children).
106
+ if (last === "index" && urlSegments.length > 1 && isCatchAllSegment(urlSegments[urlSegments.length - 2])) {
107
+ return null;
108
+ }
109
+ const trimmed = last === "index" ? urlSegments.slice(0, -1) : urlSegments;
110
+ const routeSegments = trimmed.map((s) => {
111
+ // Catch-all `[...name]` → `*name` in the URL pattern.
112
+ if (isCatchAllSegment(s))
113
+ return "*" + s.slice(4, -1);
114
+ // `[param]` → `:param`. Anything else is a literal segment.
115
+ if (s.startsWith("[") && s.endsWith("]"))
116
+ return ":" + s.slice(1, -1);
117
+ return s;
118
+ });
119
+ return routeSegments.length === 0 ? "/" : "/" + routeSegments.join("/");
120
+ };
121
+ /**
122
+ * Slugify a URL pattern into a stable id used as the `RouteConfig.id` field
123
+ * and as part of generated chunk names downstream.
124
+ * "/" → "index"
125
+ * "/about" → "about"
126
+ * "/customers/:id" → "customers-id"
127
+ */
128
+ const pathToId = (routePath) => {
129
+ if (routePath === "/")
130
+ return "index";
131
+ return routePath
132
+ .slice(1)
133
+ .replace(/[:*]/g, "")
134
+ .replace(/\//g, "-");
135
+ };
136
+ /**
137
+ * Build a PascalCase identifier from a file's relative path. Brackets are
138
+ * stripped; segments are uppercased and joined.
139
+ * "index.tsx" → "PageIndex"
140
+ * "customers/[id].tsx" → "PageCustomersId"
141
+ * "customers/[id]/edit.tsx" → "PageCustomersIdEdit"
142
+ *
143
+ * The `Page` prefix avoids collisions with React's reserved component
144
+ * names (`Component`, `Fragment`, etc.) when the manifest is auto-generated.
145
+ */
146
+ /**
147
+ * Pull the source name of the page's `export component <Name>` declaration
148
+ * from the raw `.tsx` text. Delegates to `extract.extractComponentName` which
149
+ * calls `preprocess()` — fixes the R2 regression where the old regex missed
150
+ * the header-`uses` form (`export component Foo uses traceable {`).
151
+ *
152
+ * Returns null when no exported component is found (plain React TSX page, or a
153
+ * page whose container is not yet declared). Reads first match only — pages
154
+ * with multiple components aren't a Phase-1 case.
155
+ */
156
+ const extractPageComponentName = (cache, filePath) => componentNameFromContainers(cache.containersFor(filePath));
157
+ /**
158
+ * Source-scan a page file for its `query` declarations via `preprocess()`.
159
+ * Phase 3 replacement of `QUERY_DECL_RE` / `extractPageQueries` — fixes the
160
+ * v2 semicolon leak where the old regex captured the trailing `;` as part of
161
+ * `tsType`. Cold-start safe (no sidecar dependency).
162
+ */
163
+ const extractPageQueries = (cache, filePath) => queriesFromContainers(cache.containersFor(filePath));
164
+ /**
165
+ * Walk UP from `fileDir` to `pagesRoot`, returning the nearest ancestor
166
+ * `<basename>` (Router §2.3 — special files `_loading.tsx`, `_error.tsx`).
167
+ * Returns its import specifier (relative to the manifest location, like
168
+ * `./pages/dashboard/_loading`) plus a path-unique PascalCase identifier
169
+ * derived from the directory (so multiple routes sharing the same ancestor
170
+ * dedupe to one import). Stage 3 reuses this for `_layout.tsx` (one chain
171
+ * collector built on the same walk).
172
+ */
173
+ const extractNearestSpecial = (fileDir, pagesRoot, basename, identifierPrefix) => {
174
+ let cur = fileDir;
175
+ while (true) {
176
+ const candidate = join(cur, basename);
177
+ try {
178
+ if (statSync(candidate).isFile()) {
179
+ const relDir = relative(pagesRoot, cur).replace(/\\/g, "/");
180
+ const stem = basename.replace(/\.(tsx|ts|jsx|js)$/, "");
181
+ const specifier = relDir ? `./pages/${relDir}/${stem}` : `./pages/${stem}`;
182
+ const identifier = relDir
183
+ ? `${identifierPrefix}_${relDir.replace(/[\\/[\]]/g, "_")}`
184
+ : `${identifierPrefix}_root`;
185
+ return { specifier, identifier };
186
+ }
187
+ }
188
+ catch {
189
+ // File missing → continue walking up.
190
+ }
191
+ if (cur === pagesRoot)
192
+ break;
193
+ const parent = dirname(cur);
194
+ if (parent === cur)
195
+ break;
196
+ cur = parent;
197
+ }
198
+ return undefined;
199
+ };
200
+ const extractNearestLoading = (fileDir, pagesRoot) => extractNearestSpecial(fileDir, pagesRoot, "_loading.tsx", "Loading");
201
+ const extractNearestError = (fileDir, pagesRoot) => extractNearestSpecial(fileDir, pagesRoot, "_error.tsx", "Error");
202
+ /**
203
+ * Walk UP from `fileDir` to `pagesRoot`, collecting **every** ancestor
204
+ * `_layout.tsx` (Router §2.3 — layout chain wrapping the page). Returned
205
+ * outermost-first (root → innermost), so the runtime can wrap layouts in
206
+ * order. Multiple routes sharing an ancestor layout dedupe at emission time
207
+ * (the same specifier resolves to the same identifier).
208
+ */
209
+ const extractLayoutChain = (fileDir, pagesRoot) => {
210
+ const innermostFirst = [];
211
+ let cur = fileDir;
212
+ while (true) {
213
+ const candidate = join(cur, "_layout.tsx");
214
+ try {
215
+ if (statSync(candidate).isFile()) {
216
+ const relDir = relative(pagesRoot, cur).replace(/\\/g, "/");
217
+ const specifier = relDir ? `./pages/${relDir}/_layout` : `./pages/_layout`;
218
+ const identifier = relDir
219
+ ? `Layout_${relDir.replace(/[\\/[\]]/g, "_")}`
220
+ : "Layout_root";
221
+ innermostFirst.push({ specifier, identifier });
222
+ }
223
+ }
224
+ catch {
225
+ // File missing → continue walking up.
226
+ }
227
+ if (cur === pagesRoot)
228
+ break;
229
+ const parent = dirname(cur);
230
+ if (parent === cur)
231
+ break;
232
+ cur = parent;
233
+ }
234
+ return innermostFirst.reverse(); // outermost-first
235
+ };
236
+ /**
237
+ * Source-scan a page for its `prefetch on <trigger>` policy via the compiled
238
+ * graph (the first component wins — it's a single per-page policy). Returns the
239
+ * trigger or `undefined`.
240
+ */
241
+ const extractPagePrefetch = (cache, filePath) => prefetchFromContainers(cache.containersFor(filePath));
242
+ /**
243
+ * Pull the `:name` param names out of a route pattern, in order.
244
+ * "/" → []
245
+ * "/customers" → []
246
+ * "/customers/:id" → ["id"]
247
+ * "/c/:a/o/:b" → ["a", "b"]
248
+ *
249
+ * Day 28+ / `#5c-typed`: feeds the generated `RouteParams` map so
250
+ * `navigate("/customers/:id", { params: { id } })` is compile-checked.
251
+ */
252
+ const extractParamNames = (routePath) => routePath
253
+ .split("/")
254
+ // Both `:name` and `*name` (catch-all) contribute a param.
255
+ .filter((seg) => seg.startsWith(":") || seg.startsWith("*"))
256
+ .map((seg) => seg.slice(1));
257
+ const pathToComponentIdentifier = (relativePath) => {
258
+ const noExt = stripExt(relativePath.replace(/\\/g, "/"));
259
+ const tokens = ["Page"];
260
+ for (const segment of noExt.split("/")) {
261
+ if (!segment)
262
+ continue;
263
+ // Route groups (Wave 3 §2b) — `(marketing)` etc. — are invisible to
264
+ // the URL and so to the component identifier. They were ALREADY
265
+ // invalid here (parens aren't valid JS identifier chars), so skipping
266
+ // them is both correct and necessary.
267
+ if (isRouteGroupSegment(segment))
268
+ continue;
269
+ // Catch-all `[...name]` — extract `name` (the dots aren't valid
270
+ // identifier chars, and the trailing brackets would survive the
271
+ // single-bracket replacement below without help).
272
+ const catchAllMatch = /^\[\.\.\.(\w+)\]$/.exec(segment);
273
+ if (catchAllMatch) {
274
+ const name = catchAllMatch[1];
275
+ tokens.push(name.charAt(0).toUpperCase() + name.slice(1));
276
+ continue;
277
+ }
278
+ const cleaned = segment.replace(/\[(\w+)\]/g, "$1");
279
+ for (const sub of cleaned.split(/[-.]/)) {
280
+ if (!sub)
281
+ continue;
282
+ tokens.push(sub.charAt(0).toUpperCase() + sub.slice(1));
283
+ }
284
+ }
285
+ return tokens.join("");
286
+ };
287
+ /**
288
+ * Recursively scan `<projectRoot>/src/pages/` for page files. Files that
289
+ * `deriveRoutePath` returns `null` for are skipped. Returns the routes
290
+ * sorted by `path` so the generated manifest is byte-stable across runs.
291
+ *
292
+ * Tolerates a missing `src/pages/` directory (returns `[]`) — useful during
293
+ * a fresh `dev` boot before pages exist.
294
+ */
295
+ export const scanPages = (projectRoot, cache = createGraphCache()) => {
296
+ const pagesRoot = join(projectRoot, "src", "pages");
297
+ const out = [];
298
+ const seenComponentIdentifiers = new Set();
299
+ const seenIds = new Set();
300
+ const walk = (dir) => {
301
+ let entries;
302
+ try {
303
+ entries = readdirSync(dir);
304
+ }
305
+ catch {
306
+ return;
307
+ }
308
+ for (const entry of entries) {
309
+ const full = join(dir, entry);
310
+ // A file listed by readdir can vanish before we stat it — atomic-save
311
+ // temp files (an editor/tool writes `foo.tsx.tmp.NNNN` then renames it
312
+ // away) race the watcher. Skip the vanished entry; it's a transient, not
313
+ // a page-tree error worth failing the regen over (scanPages rethrows).
314
+ let stat;
315
+ try {
316
+ stat = statSync(full);
317
+ }
318
+ catch (err) {
319
+ if (err.code === "ENOENT")
320
+ continue;
321
+ throw err;
322
+ }
323
+ if (stat.isDirectory()) {
324
+ // Skip whole subtrees rooted at a special-name dir (Phase 1).
325
+ if (isSpecialSegment(entry))
326
+ continue;
327
+ walk(full);
328
+ continue;
329
+ }
330
+ if (!stat.isFile())
331
+ continue;
332
+ const rel = relative(pagesRoot, full).replace(/\\/g, "/");
333
+ const routePath = deriveRoutePath(rel);
334
+ if (routePath === null)
335
+ continue;
336
+ const id = pathToId(routePath);
337
+ const componentIdentifier = pathToComponentIdentifier(rel);
338
+ // Duplicate-route detection. `pages/foo.tsx` + `pages/foo.jsx`
339
+ // would both derive to "/foo"; refuse rather than silently overwrite.
340
+ if (seenIds.has(id)) {
341
+ throw new Error(`[@reactra/vite-plugin] duplicate route path "${routePath}" — two ` +
342
+ `files under src/pages/ map to the same URL. Disambiguate or ` +
343
+ `delete one. (file: ${rel})`);
344
+ }
345
+ seenIds.add(id);
346
+ // PascalCase collision (theoretically impossible given the algorithm
347
+ // is path-injective, but cheap to assert).
348
+ if (seenComponentIdentifiers.has(componentIdentifier)) {
349
+ throw new Error(`[@reactra/vite-plugin] internal: duplicate component identifier ` +
350
+ `${componentIdentifier} for ${rel}`);
351
+ }
352
+ seenComponentIdentifiers.add(componentIdentifier);
353
+ const bindingsName = extractPageComponentName(cache, full) ?? undefined;
354
+ const dirRel = dirname(rel).replace(/\\/g, "/");
355
+ const fileDirRelativeToPages = dirRel === "." ? "" : dirRel;
356
+ out.push({
357
+ id,
358
+ path: routePath,
359
+ filePath: ["src", "pages", rel].join("/"),
360
+ importSpecifier: `./pages/${stripExt(rel)}`,
361
+ componentIdentifier,
362
+ fileDirRelativeToPages,
363
+ bindingsName,
364
+ queries: extractPageQueries(cache, full),
365
+ prefetch: extractPagePrefetch(cache, full),
366
+ loading: extractNearestLoading(dirname(full), pagesRoot),
367
+ errorBoundary: extractNearestError(dirname(full), pagesRoot),
368
+ layouts: extractLayoutChain(dirname(full), pagesRoot),
369
+ });
370
+ }
371
+ };
372
+ walk(pagesRoot);
373
+ // Sort with catch-all routes LAST so the runtime's first-match iteration
374
+ // in matchRoute picks the more specific concrete/dynamic route over a
375
+ // catch-all that would also accept the URL (Wave 3 §2b). Within each
376
+ // tier, alphabetical by path keeps the generated manifest byte-stable.
377
+ out.sort((a, b) => {
378
+ const aCatch = a.path.includes("/*");
379
+ const bCatch = b.path.includes("/*");
380
+ if (aCatch !== bCatch)
381
+ return aCatch ? 1 : -1;
382
+ return a.path.localeCompare(b.path);
383
+ });
384
+ return out;
385
+ };
386
+ /**
387
+ * Discover every store binding declared anywhere under `<projectRoot>/src/`
388
+ * by source-regex via `preprocess()` (cold-start safe). Returns a deterministic
389
+ * name-sorted list so the manifest renderer's output is byte-stable.
390
+ *
391
+ * **Collect ALL declared stores regardless of the source `export` keyword.**
392
+ * The compiler force-exports every container it processes (Pass 9 emits
393
+ * `export const name = …` unconditionally), so `session store X {}` and
394
+ * `route store X {}` are BOTH importable as `import { X }` — the walker must
395
+ * collect them. When the compiler stops force-exporting (a future coordinated
396
+ * change), re-introduce an `exported=true` filter and a "non-exported store
397
+ * consumed cross-file" diagnostic. For now, collect all.
398
+ *
399
+ * Phase 3: replaced the hand-rolled `STORE_DECL_RE` regex with `preprocess()`
400
+ * for v1+v2 dual-accept grammar. The `exported` field from `extractStoreNames`
401
+ * is available for diagnostics but is NOT used to filter here.
402
+ *
403
+ * Tolerates a missing `src/` directory (returns []).
404
+ */
405
+ export const scanStores = (projectRoot, cache = createGraphCache()) => {
406
+ const srcRoot = join(projectRoot, "src");
407
+ const out = [];
408
+ const seen = new Set();
409
+ const walk = (dir) => {
410
+ let entries;
411
+ try {
412
+ entries = readdirSync(dir);
413
+ }
414
+ catch {
415
+ return;
416
+ }
417
+ for (const entry of entries) {
418
+ const full = join(dir, entry);
419
+ const stat = statSync(full);
420
+ if (stat.isDirectory()) {
421
+ walk(full);
422
+ continue;
423
+ }
424
+ if (!stat.isFile())
425
+ continue;
426
+ if (!SUPPORTED_EXT_RE.test(entry))
427
+ continue;
428
+ // A3: one cached compile per file (was readFileSync + per-scanner compile).
429
+ // The `exported` field is present but NOT used to filter: the compiler
430
+ // force-exports all containers so every declared store is importable
431
+ // (see JSDoc above). Cache returns [] on read/compile failure (was the
432
+ // old `continue` on read error).
433
+ for (const { name } of storeNamesFromContainers(cache.containersFor(full))) {
434
+ // First declaration wins; later collisions surface via
435
+ // `loadSidecarIndex` at transform time (where the conflict
436
+ // error knows both source paths).
437
+ if (seen.has(name))
438
+ continue;
439
+ seen.add(name);
440
+ out.push({
441
+ name,
442
+ sourcePath: full,
443
+ importSpecifier: deriveStoreImportSpecifier(srcRoot, full),
444
+ });
445
+ }
446
+ }
447
+ };
448
+ walk(srcRoot);
449
+ out.sort((a, b) => a.name.localeCompare(b.name));
450
+ return out;
451
+ };
452
+ /**
453
+ * Discover every service binding declared anywhere under
454
+ * `<projectRoot>/src/` by source-regex via `preprocess()` (cold-start safe).
455
+ * Returns a deterministic name-sorted list so the manifest renderer's output
456
+ * is byte-stable.
457
+ *
458
+ * **Collect ALL declared services regardless of the source `export` keyword.**
459
+ * Same force-export rationale as {@link scanStores} — see its JSDoc for the
460
+ * full explanation and the re-introduction trigger.
461
+ *
462
+ * Tolerates a missing `src/` directory (returns []).
463
+ */
464
+ export const scanServices = (projectRoot, cache = createGraphCache()) => {
465
+ const srcRoot = join(projectRoot, "src");
466
+ const out = [];
467
+ const seen = new Set();
468
+ const walk = (dir) => {
469
+ let entries;
470
+ try {
471
+ entries = readdirSync(dir);
472
+ }
473
+ catch {
474
+ return;
475
+ }
476
+ for (const entry of entries) {
477
+ const full = join(dir, entry);
478
+ const stat = statSync(full);
479
+ if (stat.isDirectory()) {
480
+ walk(full);
481
+ continue;
482
+ }
483
+ if (!stat.isFile())
484
+ continue;
485
+ if (!SUPPORTED_EXT_RE.test(entry))
486
+ continue;
487
+ // A3: one cached compile per file. The `exported` field is present but
488
+ // NOT used to filter — same force-export rationale as scanStores.
489
+ for (const { name, serviceModifier } of serviceNamesFromContainers(cache.containersFor(full))) {
490
+ // First declaration wins; duplicate names across files are unusual for
491
+ // services (unlike stores) but we apply the same dedup policy.
492
+ if (seen.has(name))
493
+ continue;
494
+ seen.add(name);
495
+ out.push({
496
+ name,
497
+ sourcePath: full,
498
+ importSpecifier: deriveStoreImportSpecifier(srcRoot, full),
499
+ serviceModifier,
500
+ });
501
+ }
502
+ }
503
+ };
504
+ walk(srcRoot);
505
+ out.sort((a, b) => a.name.localeCompare(b.name));
506
+ return out;
507
+ };
508
+ /**
509
+ * Walk `<projectRoot>/src/` and collect every `inject service X` site where
510
+ * `X` is NOT declared anywhere under `src/` (i.e. no `service X {}` or
511
+ * `export service X {}` block exists). Returns one diagnostic per injection
512
+ * site that references an undeclared name.
513
+ *
514
+ * Decision (brief §Step 5): warn-only — no minted error code. An `inject
515
+ * service X` where `X` is declared but non-exported is NOT a diagnostic
516
+ * (it's declared = configurable via hand-listed `configureServices`).
517
+ * `inject baseUrl from config("X")` carries `source:"config"`, not
518
+ * `kind:"service"`, so it is never counted.
519
+ *
520
+ * Tolerates a missing `src/` directory (returns []).
521
+ */
522
+ export const collectServiceInjectionDiagnostics = (projectRoot, cache = createGraphCache()) => {
523
+ const srcRoot = join(projectRoot, "src");
524
+ // First pass: collect ALL declared service names (exported or not).
525
+ const allDeclaredServices = new Set();
526
+ // Second pass source material: injection sites per file.
527
+ const injectionSites = [];
528
+ const walk = (dir) => {
529
+ let entries;
530
+ try {
531
+ entries = readdirSync(dir);
532
+ }
533
+ catch {
534
+ return;
535
+ }
536
+ for (const entry of entries) {
537
+ const full = join(dir, entry);
538
+ const stat = statSync(full);
539
+ if (stat.isDirectory()) {
540
+ walk(full);
541
+ continue;
542
+ }
543
+ if (!stat.isFile())
544
+ continue;
545
+ if (!SUPPORTED_EXT_RE.test(entry))
546
+ continue;
547
+ // A3: one cached compile per file (shared with scanStores/scanServices).
548
+ const containers = containersToInfo(cache.containersFor(full));
549
+ for (const c of containers) {
550
+ if (c.kind === "service") {
551
+ allDeclaredServices.add(c.name);
552
+ }
553
+ for (const svcName of c.injectedServiceNames) {
554
+ injectionSites.push({ service: svcName, file: full });
555
+ }
556
+ }
557
+ }
558
+ };
559
+ walk(srcRoot);
560
+ // Return only sites whose service was never declared anywhere in src/.
561
+ return injectionSites.filter((site) => !allDeclaredServices.has(site.service));
562
+ };
563
+ /**
564
+ * Token-scan `source` for a default export. Detects two forms:
565
+ * - `export default …` (function / class / expression)
566
+ * - the re-export `export { x as default }` (the `as default` pairing)
567
+ *
568
+ * Uses `lex()` so comments and string literals are their own token kinds —
569
+ * a commented-out `// export default x` or a string `"export default"` cannot
570
+ * false-match. Known limitation (acceptable per B5): `lex()` does not recognise
571
+ * regex literals; this is irrelevant to `export default` detection.
572
+ */
573
+ const hasDefaultExport = (source) => {
574
+ const tokens = lex(source);
575
+ const nextCode = (from) => skipTrivia(tokens, from);
576
+ const isIdent = (t, text) => t !== undefined && t.kind === "ident" && t.text === text;
577
+ const isPunct = (t, text) => t !== undefined && t.kind === "punct" && t.text === text;
578
+ for (let i = 0; i < tokens.length; i++) {
579
+ if (!isIdent(tokens[i], "export"))
580
+ continue;
581
+ const j = nextCode(i + 1);
582
+ // `export default …`
583
+ if (isIdent(tokens[j], "default"))
584
+ return true;
585
+ // `export { … as default }` — scan the brace group for an `as default` pair.
586
+ if (isPunct(tokens[j], "{")) {
587
+ for (let k = j + 1; k < tokens.length; k++) {
588
+ const t = tokens[k];
589
+ if (isPunct(t, "}"))
590
+ break;
591
+ if (isIdent(t, "as") && isIdent(tokens[nextCode(k + 1)], "default"))
592
+ return true;
593
+ }
594
+ }
595
+ }
596
+ return false;
597
+ };
598
+ /**
599
+ * RO014 — `_middleware.ts` files must have a default export so the walker-
600
+ * emitted `router-middleware.generated.ts` can `import middlewareN from "..."`
601
+ * the user's MiddlewareDef. Lexical check via {@link hasDefaultExport}: a
602
+ * token-scan for one of the common default-export patterns (function decl,
603
+ * expression, class, named re-export-as-default).
604
+ *
605
+ * On miss, throws an Error with the spec reference + the offending path so
606
+ * the build fails loudly. The vite-plugin's try/catch around scanMiddleware
607
+ * logs the error and emits an empty `router-middleware.generated.ts`,
608
+ * keeping the dev server up while still surfacing the problem.
609
+ */
610
+ const validateMiddlewareDefaultExport = (absolutePath, relativeToPages) => {
611
+ let source;
612
+ try {
613
+ source = readFileSync(absolutePath, "utf8");
614
+ }
615
+ catch (err) {
616
+ throw new Error(`[@reactra/vite-plugin] RO014: cannot read _middleware file ` +
617
+ `at ${relativeToPages} (${err.message}). ` +
618
+ `(reactra-router-spec.md §5.8)`);
619
+ }
620
+ // Token-scan for a default export instead of comment-stripping by regex.
621
+ // `lex()` emits comments and strings as their own token kinds, so a
622
+ // commented-out `// export default …` or a string literal `"export default"`
623
+ // can never false-match. Middleware is plain TS (NOT DSL), so we lex it
624
+ // directly — no compileToGraph.
625
+ if (!hasDefaultExport(source)) {
626
+ throw new Error(`[@reactra/vite-plugin] RO014: _middleware file ${relativeToPages} ` +
627
+ `has no default export. Add ` +
628
+ `\`export default defineMiddleware({ … })\` (Router §6.3). ` +
629
+ `(reactra-router-spec.md §5.8)`);
630
+ }
631
+ };
632
+ /**
633
+ * Walk `<projectRoot>/src/pages/` for `_middleware.ts` files. Skips
634
+ * Phase-1-unsupported directory shapes (route groups `(group)/`,
635
+ * catch-alls `[...slug]/`, parallel routes `@x/`) the same way
636
+ * {@link scanPages} does — a middleware inside a Phase-1-excluded
637
+ * subtree wouldn't be exercisable until those features land.
638
+ *
639
+ * Multiple `_middleware.ts` files in the same directory aren't a
640
+ * supported case (per spec — one per dir); but if both `_middleware.ts`
641
+ * and `_middleware.tsx` exist, only the `.ts` is recognised (the
642
+ * EXT_PRIORITY array drives the lookup).
643
+ *
644
+ * Tolerates a missing `src/pages/` directory (returns []).
645
+ */
646
+ export const scanMiddleware = (projectRoot) => {
647
+ const pagesRoot = join(projectRoot, "src", "pages");
648
+ const out = [];
649
+ let counter = 0;
650
+ const walk = (dir) => {
651
+ let entries;
652
+ try {
653
+ entries = readdirSync(dir);
654
+ }
655
+ catch {
656
+ return;
657
+ }
658
+ for (const entry of entries) {
659
+ const full = join(dir, entry);
660
+ const stat = statSync(full);
661
+ if (stat.isDirectory()) {
662
+ // Same exclusions as scanPages — `(group)/`, `[...slug]/`, `@parallel/`,
663
+ // any `_*` directory (we don't recurse into those for middleware
664
+ // either; e.g. `_layout/_middleware.ts` would never apply to a
665
+ // route since the parent dir itself is special).
666
+ if (isSpecialSegment(entry))
667
+ continue;
668
+ walk(full);
669
+ continue;
670
+ }
671
+ if (!stat.isFile())
672
+ continue;
673
+ if (entry !== "_middleware.ts" && entry !== "_middleware.tsx")
674
+ continue;
675
+ const rel = relative(pagesRoot, full).replace(/\\/g, "/");
676
+ // RO014 (Stage 4) — `_middleware.ts` must have a default export.
677
+ // We read the file as text and run a permissive regex covering the
678
+ // common `export default …` forms (function decl, expression, class).
679
+ // The check is intentionally lexical, not AST-based — a malformed
680
+ // file would still bail here, which is the right behaviour: the
681
+ // walker can't safely treat a no-default file as middleware.
682
+ validateMiddlewareDefaultExport(full, rel);
683
+ const dirRel = dirname(rel).replace(/\\/g, "/");
684
+ const dirRelativeToPages = dirRel === "." ? "" : dirRel;
685
+ const importSpecifier = "./pages/" + stripExt(rel);
686
+ out.push({
687
+ dirRelativeToPages,
688
+ sourcePath: full,
689
+ importSpecifier,
690
+ identifier: `middleware${counter++}`,
691
+ });
692
+ }
693
+ };
694
+ walk(pagesRoot);
695
+ // Sort by depth then path so chain resolution returns root-first
696
+ // deterministically (alphabetical within a depth tier).
697
+ out.sort((a, b) => {
698
+ const da = a.dirRelativeToPages === "" ? 0 : a.dirRelativeToPages.split("/").length;
699
+ const db = b.dirRelativeToPages === "" ? 0 : b.dirRelativeToPages.split("/").length;
700
+ if (da !== db)
701
+ return da - db;
702
+ return a.dirRelativeToPages.localeCompare(b.dirRelativeToPages);
703
+ });
704
+ return out;
705
+ };
706
+ /**
707
+ * For every route in `routes`, compute the ordered list of `_middleware.ts`
708
+ * files that apply, root-first → leaf-last (spec §5.7 example). Pure —
709
+ * given the same inputs, produces the same chains, so the generated
710
+ * `router-middleware.generated.ts` is byte-stable.
711
+ *
712
+ * A middleware at directory `D` applies to a route at file directory `R`
713
+ * iff `D` is a prefix of `R` (`D === ""` always applies; `D === "dashboard"`
714
+ * applies to `dashboard/**`). The match treats path components atomically
715
+ * — `dash` does NOT prefix `dashboard`.
716
+ *
717
+ * Wave-3 §2b — route groups: the match is on FILE-TREE location, not URL
718
+ * prefix. `pages/(auth)/_middleware.ts` (dir `(auth)`) applies to
719
+ * `pages/(auth)/login.tsx` (URL `/login`) because the file lives inside
720
+ * `(auth)/`, even though the URL doesn't carry the group segment. Hand-
721
+ * built `DiscoveredRoute` fixtures without `fileDirRelativeToPages` fall
722
+ * back to root (`""`), preserving prior test behaviour.
723
+ */
724
+ export const chainsForRoutes = (routes, middleware) => {
725
+ const out = new Map();
726
+ for (const route of routes) {
727
+ const routeDir = route.fileDirRelativeToPages ?? "";
728
+ const chain = [];
729
+ for (const mw of middleware) {
730
+ const mwDir = mw.dirRelativeToPages;
731
+ if (mwDir === "") {
732
+ chain.push(mw); // root applies to all
733
+ continue;
734
+ }
735
+ // Path-component prefix: mwDir === routeDir OR mwDir + "/" prefixes routeDir.
736
+ if (routeDir === mwDir ||
737
+ routeDir.startsWith(mwDir + "/")) {
738
+ chain.push(mw);
739
+ }
740
+ }
741
+ if (chain.length > 0)
742
+ out.set(route.path, chain);
743
+ }
744
+ return out;
745
+ };
746
+ /**
747
+ * Format the `router-middleware.generated.ts` module text per spec §6.4.
748
+ * Empty input (no `_middleware.ts` files OR no routes have a chain) emits
749
+ * a stub module with an empty map — importers don't break on a fresh
750
+ * clone before any middleware exists.
751
+ *
752
+ * Deviation from the spec's illustrative `MiddlewareFn[]` shape: we emit
753
+ * `MiddlewareDef[]` so both `beforeEnter` AND `afterLeave` are reachable
754
+ * from a single map. The runtime (Stage 3) iterates the def's `.beforeEnter`
755
+ * and `.afterLeave` properties directly.
756
+ */
757
+ export const emitRouterMiddleware = (middleware, chains) => {
758
+ const header = [
759
+ "// router-middleware.generated.ts — DO NOT EDIT",
760
+ "// Regenerated by @reactra/vite-plugin on every `_middleware.ts` change.",
761
+ "// Spec: reactra-router-spec.md §6 + phase-2.md Wave 3 / Stage 2.",
762
+ "",
763
+ 'import type { MiddlewareDef } from "@reactra/router"',
764
+ ];
765
+ if (middleware.length === 0) {
766
+ return [
767
+ ...header,
768
+ "",
769
+ "export const middlewareChains: Record<string, MiddlewareDef[]> = {}",
770
+ "",
771
+ ].join("\n");
772
+ }
773
+ // Default-import each unique `_middleware.ts` file. The user's
774
+ // `export default defineMiddleware(...)` is the imported value.
775
+ const imports = middleware.map((m) => `import ${m.identifier} from ${JSON.stringify(m.importSpecifier)}`);
776
+ // Emit chains in route-path-sorted order for byte-stability.
777
+ const sortedKeys = [...chains.keys()].sort();
778
+ const rows = [];
779
+ for (const routePath of sortedKeys) {
780
+ const chain = chains.get(routePath);
781
+ if (chain.length === 0)
782
+ continue;
783
+ rows.push(` ${JSON.stringify(routePath)}: [${chain
784
+ .map((m) => m.identifier)
785
+ .join(", ")}],`);
786
+ }
787
+ return [
788
+ ...header,
789
+ ...imports,
790
+ "",
791
+ "export const middlewareChains: Record<string, MiddlewareDef[]> = {",
792
+ ...rows,
793
+ "}",
794
+ "",
795
+ ].join("\n");
796
+ };
797
+ // ─── ─────────────────────────────────────────────────────────────────────
798
+ /**
799
+ * Turn an absolute source path under `<projectRoot>/src/` into a
800
+ * `./`-prefixed, extension-stripped import specifier. The generated
801
+ * manifest sits at `<projectRoot>/src/routeManifest.generated.ts`, so
802
+ * specifiers are relative to that file's directory (`src/`).
803
+ *
804
+ * Falls back to the raw source path if it's not under `src/` — keeps
805
+ * the walker from crashing on unusual layouts, even if Vite/TS may not
806
+ * resolve the resulting import correctly. The bigger concern of
807
+ * "stores outside src/" is rare enough to defer.
808
+ */
809
+ const deriveStoreImportSpecifier = (srcRoot, sourcePath) => {
810
+ const normalisedSrc = srcRoot.replace(/\\/g, "/");
811
+ const normalisedSource = sourcePath.replace(/\\/g, "/");
812
+ const prefix = normalisedSrc.endsWith("/") ? normalisedSrc : normalisedSrc + "/";
813
+ if (!normalisedSource.startsWith(prefix))
814
+ return normalisedSource;
815
+ const rel = normalisedSource.slice(prefix.length);
816
+ return "./" + stripExt(rel);
817
+ };
818
+ /**
819
+ * Format the routeManifest.generated.ts module text. The output is what the
820
+ * vite plugin writes to `<projectRoot>/src/routeManifest.generated.ts` on
821
+ * `buildStart` and on subsequent `src/pages/` watcher events.
822
+ *
823
+ * Phase-1 shape: an untyped `ROUTES: RouteConfig[]` array. Typed `RouteId`
824
+ * / `RouteParams` / `RouteQuery` / `TypedNavigate` exports per Router §5.1
825
+ * land with #5c (typed query coercion needs the compiler to surface the
826
+ * `query` declarations into the manifest).
827
+ *
828
+ * Import strategy: Day 21 / `#18` — the compiler now also emits
829
+ * `export default Foo` for `export component Foo` (Pass-9 auto-default-
830
+ * export), so the manifest uses clean default imports
831
+ * (`import PageX from "..."`) and references them directly in the
832
+ * ROUTES array. The Day-13 `pickPageComponent` runtime helper is gone.
833
+ */
834
+ export const emitRouteManifest = (routes, stores = [], services = []) => {
835
+ // Day 27 / `#5c-followup`: the tail HMR block needs the live
836
+ // `RouterRegistry` + `StoreRegistry` runtime references, not just
837
+ // their types. We import the values when there's anything to apply
838
+ // a delta against (any routes OR any stores).
839
+ const hasAnyRuntime = routes.length > 0 || stores.length > 0;
840
+ const hasRoutes = routes.length > 0;
841
+ // Build the single `@reactra/router` value-import line. RouterRegistry
842
+ // is needed by the HMR tail (any routes OR stores); buildPath +
843
+ // baseNavigate back the typed `navigate` (#5c-typed — routes only).
844
+ const routerValueImports = [];
845
+ if (hasAnyRuntime)
846
+ routerValueImports.push("RouterRegistry");
847
+ if (hasRoutes)
848
+ routerValueImports.push("buildPath", "navigate as baseNavigate", "RouteLink as baseRouteLink");
849
+ const header = [
850
+ "// routeManifest.generated.ts — DO NOT EDIT",
851
+ "// Regenerated by @reactra/vite-plugin on every src/ Reactra-source change.",
852
+ "// Phase 1 shape: typed RouteId / RouteParams / RouteQuery + a typed",
853
+ "// `navigate` (#5c-typed), plus untyped ROUTES + STORES arrays and an",
854
+ "// HMR-accept tail (Day 27 / #5c-followup) so manifest changes apply via",
855
+ "// delta-reconcile instead of forcing a page reload. RouteQuery is a",
856
+ "// permissive placeholder until the compiler surfaces `query foo: T`",
857
+ "// declarations into the manifest.",
858
+ "",
859
+ ...(hasRoutes
860
+ ? ['import type { RouteConfig, RouteLinkProps as BaseRouteLinkProps } from "@reactra/router"']
861
+ : ['import type { RouteConfig } from "@reactra/router"']),
862
+ // §5.2 automatic code-splitting: each route's component is `lazy()`-loaded
863
+ // so its chunk is fetched on demand (the router wraps it in <Suspense>).
864
+ ...(hasRoutes ? ['import { lazy } from "react"'] : []),
865
+ // The `StoreBinding` type import is unconditional because STORES is —
866
+ // see the STORES emission below. Type-only, so it's erased at runtime.
867
+ 'import type { StoreBinding } from "@reactra/store"',
868
+ // Phase 3: `ServiceBinding` type import is also unconditional (SERVICES
869
+ // is always emitted). Mirrors the StoreBinding import above.
870
+ 'import type { ServiceBinding } from "@reactra/service"',
871
+ ...(routerValueImports.length > 0
872
+ ? [
873
+ // Value imports. The HMR-tail use of RouterRegistry/StoreRegistry
874
+ // is tree-shaken in prod (the `if (import.meta.hot)` guard is
875
+ // false at build time so Rollup drops the block + these imports);
876
+ // buildPath + baseNavigate stay (the typed `navigate` is live app
877
+ // code).
878
+ `import { ${routerValueImports.join(", ")} } from "@reactra/router"`,
879
+ ]
880
+ : []),
881
+ ...(hasAnyRuntime ? ['import { StoreRegistry } from "@reactra/store"'] : []),
882
+ ];
883
+ // §5.2 automatic code-splitting: routes are NO LONGER eagerly imported.
884
+ // Each ROUTES row inlines `component: lazy(() => import("./pages/..."))`, so
885
+ // Rollup splits every page into its own on-demand chunk. (`componentIdentifier`
886
+ // is retained on DiscoveredRoute for tooling but no longer emitted as an import.)
887
+ // Day 20 / `#5c`: store imports stay named + EAGER (the compiler emits
888
+ // `export const <name>`; stores back the registry and aren't route-gated). One
889
+ // named import per store discovered by `scanStores`.
890
+ const storeImports = stores.map((s) => `import { ${s.name} } from ${JSON.stringify(s.importSpecifier)}`);
891
+ // Phase 3: service imports — named + EAGER, mirroring store imports. One named
892
+ // import per exported service discovered by `scanServices`.
893
+ const serviceImports = services.map((s) => `import { ${s.name} } from ${JSON.stringify(s.importSpecifier)}`);
894
+ // §2.3 `_loading.tsx`: collect unique nearest-ancestor loading files and emit
895
+ // EAGER imports (the fallback must render synchronously when the route's lazy
896
+ // chunk suspends — a lazy fallback would itself need a fallback). Multiple
897
+ // routes that share an ancestor `_loading.tsx` dedupe to one import.
898
+ const loadingMap = new Map(); // specifier → identifier
899
+ for (const r of routes)
900
+ if (r.loading)
901
+ loadingMap.set(r.loading.specifier, r.loading.identifier);
902
+ const loadingImports = [...loadingMap.entries()].map(([specifier, identifier]) => `import ${identifier} from ${JSON.stringify(specifier)}`);
903
+ // §2.3 `_error.tsx`: same dedup + eager-import treatment as `_loading.tsx`
904
+ // (a lazy error UI that itself suspends would render as a loading state).
905
+ const errorMap = new Map(); // specifier → identifier
906
+ for (const r of routes)
907
+ if (r.errorBoundary)
908
+ errorMap.set(r.errorBoundary.specifier, r.errorBoundary.identifier);
909
+ const errorImports = [...errorMap.entries()].map(([specifier, identifier]) => `import ${identifier} from ${JSON.stringify(specifier)}`);
910
+ // §2.3 `_layout.tsx`: dedup the union of every route's layout chain. Eager
911
+ // imports so layouts persist around a (suspending) page below — only the page
912
+ // area shows the route's loading fallback. Multiple routes sharing an
913
+ // ancestor layout resolve to the same identifier (single import line).
914
+ const layoutMap = new Map(); // specifier → identifier
915
+ for (const r of routes)
916
+ if (r.layouts)
917
+ for (const l of r.layouts)
918
+ layoutMap.set(l.specifier, l.identifier);
919
+ const layoutImports = [...layoutMap.entries()].map(([specifier, identifier]) => `import ${identifier} from ${JSON.stringify(specifier)}`);
920
+ const imports = [...layoutImports, ...loadingImports, ...errorImports, ...storeImports, ...serviceImports];
921
+ const body = [
922
+ "",
923
+ "export const ROUTES: RouteConfig[] = [",
924
+ ...routes.map((r) => {
925
+ const spec = JSON.stringify(r.importSpecifier);
926
+ // §5.5 prefetch: `preload` is the same dynamic import as `component`'s
927
+ // lazy() — a bare thunk PrefetchRuntime calls to warm the chunk ahead of
928
+ // navigation (Rollup dedups them into one chunk).
929
+ const head = ` { id: ${JSON.stringify(r.id)}, path: ${JSON.stringify(r.path)}, ` +
930
+ `component: lazy(() => import(${spec})), preload: () => import(${spec})`;
931
+ // §5.5: the page's declared prefetch policy, so a RouteLink with no
932
+ // `prefetch` prop inherits the destination's trigger.
933
+ const prefetchPart = r.prefetch != null ? `, prefetch: { trigger: ${JSON.stringify(r.prefetch)} }` : "";
934
+ // §2.3 `_loading.tsx`: the route's nearest-ancestor loading component;
935
+ // RouteRenderer uses it as the Suspense fallback in place of the global one.
936
+ const loadingPart = r.loading != null ? `, loading: ${r.loading.identifier}` : "";
937
+ // §2.3 `_error.tsx`: nearest-ancestor error boundary; RouteRenderer wraps
938
+ // the route in `<RouteErrorBoundary>` rendering it on a render-time throw.
939
+ const errorPart = r.errorBoundary != null ? `, errorBoundary: ${r.errorBoundary.identifier}` : "";
940
+ // §2.3 `_layout.tsx`: full ancestor chain (outermost-first); RouteRenderer
941
+ // nests them around the page so layouts persist while the page area
942
+ // suspends. Emitted only when non-empty so an outerless route stays clean.
943
+ const layoutsPart = r.layouts && r.layouts.length > 0
944
+ ? `, layouts: [${r.layouts.map((l) => l.identifier).join(", ")}]`
945
+ : "";
946
+ const tail = r.bindingsName != null ? `, bindingsName: ${JSON.stringify(r.bindingsName)} }` : " }";
947
+ return head + prefetchPart + loadingPart + errorPart + layoutsPart + tail + ",";
948
+ }),
949
+ "]",
950
+ // STORES is emitted even when empty: `main.tsx`'s documented bootstrap
951
+ // (`configureStores({ stores: STORES })`) and the HMR tail both import it
952
+ // unconditionally, so a store-less app (e.g. examples/checkout-replay)
953
+ // must still find the export.
954
+ "",
955
+ "// Day 20 / #5c: stores discovered via source-regex scan of",
956
+ "// src/. `main.tsx` does `configureStores({ stores: STORES })`",
957
+ "// instead of hand-importing each binding — new store files",
958
+ "// under src/ are picked up automatically on the next watcher",
959
+ "// event.",
960
+ "export const STORES: StoreBinding[] = [",
961
+ ...stores.map((s) => ` ${s.name},`),
962
+ "]",
963
+ // SERVICES is emitted even when empty — mirrors STORES; `main.tsx` bootstrap
964
+ // uses `configureServices({ services: SERVICES })`. A service-less app must
965
+ // still find the export so the import doesn't break on a fresh clone.
966
+ "",
967
+ "// Phase 3: services discovered via preprocess()-based scan of src/.",
968
+ "// `main.tsx` does `configureServices({ services: SERVICES })`",
969
+ "// instead of hand-listing each binding.",
970
+ "export const SERVICES: ServiceBinding[] = [",
971
+ ...services.map((s) => ` ${s.name},`),
972
+ "]",
973
+ ...(hasRoutes
974
+ ? [
975
+ "",
976
+ "// #5c-typed: compiler-derived navigation types. RouteId is the",
977
+ "// union of route patterns; RouteParams maps each to its `:name`",
978
+ "// params (or never); RouteQuery maps each to its `query foo: T`",
979
+ "// schema (every field optional — query params are omittable in a",
980
+ "// URL) or `Record<string, never>` when the page declares no query.",
981
+ "export type RouteId =",
982
+ ...routes.map((r) => ` | ${JSON.stringify(r.path)}`),
983
+ "",
984
+ "export type RouteParams = {",
985
+ ...routes.map((r) => {
986
+ const params = extractParamNames(r.path);
987
+ const value = params.length > 0
988
+ ? `{ ${params.map((n) => `${n}: string`).join("; ")} }`
989
+ : "never";
990
+ return ` ${JSON.stringify(r.path)}: ${value}`;
991
+ }),
992
+ "}",
993
+ "",
994
+ "export type RouteQuery = {",
995
+ ...routes.map((r) => {
996
+ const qs = r.queries ?? [];
997
+ const value = qs.length > 0
998
+ ? `{ ${qs.map((q) => `${q.name}?: ${q.tsType}`).join("; ")} }`
999
+ : "Record<string, never>";
1000
+ return ` ${JSON.stringify(r.path)}: ${value}`;
1001
+ }),
1002
+ "}",
1003
+ "",
1004
+ "// Two deviations from Router §5.1's illustrative type, both to",
1005
+ "// correctly implement §5.3's stated intent (RO001/RO002/RO003 are",
1006
+ "// build errors):",
1007
+ "// 1. `[RouteParams[R]] extends [never]` (tuple-wrapped) — the",
1008
+ "// naked `RouteParams[R] extends never` distributes over the",
1009
+ "// empty union and collapses the no-param branch to `never`.",
1010
+ "// 2. a variadic REST tuple instead of `options?` — an optional",
1011
+ "// `options` lets `navigate(\"/customers/:id\")` compile (RO002",
1012
+ "// not caught). The rest tuple makes options REQUIRED for",
1013
+ "// param routes and optional for no-param routes.",
1014
+ "export type TypedNavigate = {",
1015
+ " <R extends RouteId>(",
1016
+ " route: R,",
1017
+ " ...args: [RouteParams[R]] extends [never]",
1018
+ " ? [options?: { query?: RouteQuery[R]; replace?: boolean }]",
1019
+ " : [options: { params: RouteParams[R]; query?: RouteQuery[R]; replace?: boolean }]",
1020
+ " ): void",
1021
+ "}",
1022
+ "",
1023
+ "// Typed navigation entry point (Router §5.3). Delegates to the",
1024
+ "// runtime buildPath + base navigate; the types above make a wrong",
1025
+ "// or missing param a build error. The spec's `(delta: number)`",
1026
+ "// history.go overload is deferred to a later session.",
1027
+ "export const navigate: TypedNavigate = ((",
1028
+ " route: RouteId,",
1029
+ " options?: { params?: Record<string, string>; query?: Record<string, string>; replace?: boolean },",
1030
+ ") => {",
1031
+ " baseNavigate(buildPath(route, options?.params, options?.query), options?.replace ?? false)",
1032
+ "}) as TypedNavigate",
1033
+ "",
1034
+ "// Typed <RouteLink>: `to` is checked against RouteId, and `params` /",
1035
+ "// `query` against that route's RouteParams / RouteQuery (params is",
1036
+ "// required for routes with `:name` segments, forbidden otherwise). All",
1037
+ "// non-routing anchor props are inherited from the runtime RouteLinkProps.",
1038
+ "export type RouteLinkProps<R extends RouteId> =",
1039
+ ' Omit<BaseRouteLinkProps, "to" | "params" | "query"> & { to: R } & ([RouteParams[R]] extends [never]',
1040
+ " ? { params?: undefined; query?: RouteQuery[R] }",
1041
+ " : { params: RouteParams[R]; query?: RouteQuery[R] })",
1042
+ "",
1043
+ "export const RouteLink = <R extends RouteId>(props: RouteLinkProps<R>) =>",
1044
+ " baseRouteLink(props as unknown as BaseRouteLinkProps)",
1045
+ ]
1046
+ : []),
1047
+ ...(hasAnyRuntime
1048
+ ? [
1049
+ "",
1050
+ "// Day 27 / #5c-followup: HMR-accept the manifest module so",
1051
+ "// add/remove of route + store files reconciles into the live",
1052
+ "// registries instead of forcing a page reload. Per-file edits",
1053
+ "// to a store's body still flow through the Day-16 #11 per-store",
1054
+ "// accept block; THIS block handles the manifest-shape changes",
1055
+ "// (new file appears under src/, file removed) that the per-file",
1056
+ "// blocks can't see.",
1057
+ "if (import.meta.hot) {",
1058
+ " import.meta.hot.accept((newMod) => {",
1059
+ " if (!newMod) return",
1060
+ " StoreRegistry.applyDelta(newMod.STORES ?? [])",
1061
+ " RouterRegistry.replaceRoutes(newMod.ROUTES ?? [])",
1062
+ " })",
1063
+ "}",
1064
+ ]
1065
+ : []),
1066
+ "",
1067
+ ];
1068
+ return header.concat(imports.length > 0 ? [""] : [], imports, body).join("\n");
1069
+ };
1070
+ //# sourceMappingURL=walker.js.map