@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/LICENSE +21 -0
- package/README.md +17 -0
- package/dist/esbuild-prescan.d.ts +31 -0
- package/dist/esbuild-prescan.d.ts.map +1 -0
- package/dist/esbuild-prescan.js +75 -0
- package/dist/esbuild-prescan.js.map +1 -0
- package/dist/extract.d.ts +128 -0
- package/dist/extract.d.ts.map +1 -0
- package/dist/extract.js +155 -0
- package/dist/extract.js.map +1 -0
- package/dist/fingerprint.d.ts +28 -0
- package/dist/fingerprint.d.ts.map +1 -0
- package/dist/fingerprint.js +68 -0
- package/dist/fingerprint.js.map +1 -0
- package/dist/graph-cache.d.ts +23 -0
- package/dist/graph-cache.d.ts.map +1 -0
- package/dist/graph-cache.js +52 -0
- package/dist/graph-cache.js.map +1 -0
- package/dist/index.d.ts +84 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +379 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest.d.ts +57 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +147 -0
- package/dist/manifest.js.map +1 -0
- package/dist/resource-names.d.ts +49 -0
- package/dist/resource-names.d.ts.map +1 -0
- package/dist/resource-names.js +128 -0
- package/dist/resource-names.js.map +1 -0
- package/dist/sidecar-writer.d.ts +60 -0
- package/dist/sidecar-writer.d.ts.map +1 -0
- package/dist/sidecar-writer.js +114 -0
- package/dist/sidecar-writer.js.map +1 -0
- package/dist/walker.d.ts +332 -0
- package/dist/walker.d.ts.map +1 -0
- package/dist/walker.js +1070 -0
- package/dist/walker.js.map +1 -0
- package/package.json +39 -0
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
|