@canmi/seam-react 0.2.14 → 0.3.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/scripts/build-skeletons.mjs +49 -504
- package/scripts/skeleton/cache.mjs +135 -0
- package/scripts/skeleton/layout.mjs +109 -0
- package/scripts/skeleton/process.mjs +184 -0
- package/scripts/skeleton/render.mjs +160 -0
- package/scripts/skeleton/schema.mjs +120 -0
|
@@ -1,530 +1,56 @@
|
|
|
1
1
|
/* packages/client/react/scripts/build-skeletons.mjs */
|
|
2
2
|
|
|
3
3
|
import { build } from "esbuild";
|
|
4
|
-
import {
|
|
5
|
-
import { renderToString } from "react-dom/server";
|
|
6
|
-
import { createHash } from "node:crypto";
|
|
7
|
-
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { readFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
8
5
|
import { join, dirname, resolve } from "node:path";
|
|
9
6
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
|
|
11
|
-
import {
|
|
12
|
-
|
|
13
|
-
cartesianProduct,
|
|
14
|
-
buildVariantSentinel,
|
|
15
|
-
} from "./variant-generator.mjs";
|
|
7
|
+
|
|
8
|
+
import { SeamBuildError } from "./skeleton/render.mjs";
|
|
9
|
+
import { extractLayouts, flattenRoutes } from "./skeleton/layout.mjs";
|
|
16
10
|
import {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
checkFieldAccess,
|
|
23
|
-
} from "./mock-generator.mjs";
|
|
11
|
+
parseComponentImports,
|
|
12
|
+
computeComponentHashes,
|
|
13
|
+
computeScriptHash,
|
|
14
|
+
} from "./skeleton/cache.mjs";
|
|
15
|
+
import { processLayoutsWithCache, processRoutesWithCache } from "./skeleton/process.mjs";
|
|
24
16
|
|
|
25
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
function renderWithData(component, data) {
|
|
30
|
-
return renderToString(createElement(SeamDataProvider, { value: data }, createElement(component)));
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
// -- Render guards --
|
|
34
|
-
|
|
35
|
-
class SeamBuildError extends Error {
|
|
36
|
-
constructor(message) {
|
|
37
|
-
super(message);
|
|
38
|
-
this.name = "SeamBuildError";
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
const buildWarnings = [];
|
|
43
|
-
const seenWarnings = new Set();
|
|
44
|
-
|
|
45
|
-
// Matches React-injected resource hint <link> tags.
|
|
46
|
-
// Only rel values used by React's resource APIs are targeted (preload, dns-prefetch, preconnect,
|
|
47
|
-
// data-precedence); user-authored <link> tags (canonical, alternate, stylesheet) are unaffected.
|
|
48
|
-
const RESOURCE_HINT_RE =
|
|
49
|
-
/<link[^>]+rel\s*=\s*"(?:preload|dns-prefetch|preconnect)"[^>]*>|<link[^>]+data-precedence[^>]*>/gi;
|
|
50
|
-
|
|
51
|
-
function installRenderTraps(violations, teardowns) {
|
|
52
|
-
function trapCall(obj, prop, label) {
|
|
53
|
-
const orig = obj[prop];
|
|
54
|
-
obj[prop] = function () {
|
|
55
|
-
violations.push({ severity: "error", reason: `${label} called during skeleton render` });
|
|
56
|
-
throw new SeamBuildError(`${label} is not allowed in skeleton components`);
|
|
57
|
-
};
|
|
58
|
-
teardowns.push(() => {
|
|
59
|
-
obj[prop] = orig;
|
|
60
|
-
});
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
trapCall(globalThis, "fetch", "fetch()");
|
|
64
|
-
trapCall(Math, "random", "Math.random()");
|
|
65
|
-
trapCall(Date, "now", "Date.now()");
|
|
66
|
-
if (globalThis.crypto?.randomUUID) {
|
|
67
|
-
trapCall(globalThis.crypto, "randomUUID", "crypto.randomUUID()");
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
// Timer APIs — these don't affect renderToString output, but pending handles
|
|
71
|
-
// prevent the build process from exiting (Node keeps the event loop alive).
|
|
72
|
-
trapCall(globalThis, "setTimeout", "setTimeout()");
|
|
73
|
-
trapCall(globalThis, "setInterval", "setInterval()");
|
|
74
|
-
if (globalThis.setImmediate) {
|
|
75
|
-
trapCall(globalThis, "setImmediate", "setImmediate()");
|
|
76
|
-
}
|
|
77
|
-
trapCall(globalThis, "queueMicrotask", "queueMicrotask()");
|
|
78
|
-
|
|
79
|
-
// Trap browser globals (only if not already defined — these are undefined in Node;
|
|
80
|
-
// typeof checks bypass getters, so `typeof window !== 'undefined'` remains safe)
|
|
81
|
-
for (const name of ["window", "document", "localStorage"]) {
|
|
82
|
-
if (!(name in globalThis)) {
|
|
83
|
-
Object.defineProperty(globalThis, name, {
|
|
84
|
-
get() {
|
|
85
|
-
violations.push({ severity: "error", reason: `${name} accessed during skeleton render` });
|
|
86
|
-
throw new SeamBuildError(`${name} is not available in skeleton components`);
|
|
87
|
-
},
|
|
88
|
-
configurable: true,
|
|
89
|
-
});
|
|
90
|
-
teardowns.push(() => {
|
|
91
|
-
delete globalThis[name];
|
|
92
|
-
});
|
|
93
|
-
}
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
function validateOutput(html, violations) {
|
|
98
|
-
if (html.includes("<!--$!-->")) {
|
|
99
|
-
violations.push({
|
|
100
|
-
severity: "error",
|
|
101
|
-
reason:
|
|
102
|
-
"Suspense abort detected \u2014 a component used an unresolved async resource\n" +
|
|
103
|
-
" (e.g. use(promise)) inside a <Suspense> boundary, producing an incomplete\n" +
|
|
104
|
-
" template with fallback content baked in.\n" +
|
|
105
|
-
" Fix: remove use() from skeleton components. Async data belongs in loaders.",
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
const hints = Array.from(html.matchAll(RESOURCE_HINT_RE));
|
|
110
|
-
if (hints.length > 0) {
|
|
111
|
-
violations.push({
|
|
112
|
-
severity: "warning",
|
|
113
|
-
reason:
|
|
114
|
-
`stripped ${hints.length} resource hint <link> tag(s) injected by React's preload()/preinit().\n` +
|
|
115
|
-
" These are not data-driven and would cause hydration mismatch.",
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function stripResourceHints(html) {
|
|
121
|
-
return html.replace(RESOURCE_HINT_RE, "");
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
function guardedRender(routePath, component, data) {
|
|
125
|
-
const violations = [];
|
|
126
|
-
const teardowns = [];
|
|
127
|
-
|
|
128
|
-
installRenderTraps(violations, teardowns);
|
|
129
|
-
|
|
130
|
-
let html;
|
|
19
|
+
function loadManifest(manifestFile) {
|
|
20
|
+
if (!manifestFile || manifestFile === "none") return { manifest: null, manifestContent: "" };
|
|
131
21
|
try {
|
|
132
|
-
|
|
22
|
+
const content = readFileSync(resolve(manifestFile), "utf-8");
|
|
23
|
+
return { manifest: JSON.parse(content), manifestContent: content };
|
|
133
24
|
} catch (e) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
`[seam] error: Skeleton rendering failed for route "${routePath}":\n` +
|
|
137
|
-
` ${e.message}\n\n` +
|
|
138
|
-
" Move browser API calls into useEffect() or event handlers.",
|
|
139
|
-
);
|
|
140
|
-
}
|
|
141
|
-
throw e;
|
|
142
|
-
} finally {
|
|
143
|
-
for (const teardown of teardowns) teardown();
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
validateOutput(html, violations);
|
|
147
|
-
|
|
148
|
-
const fatal = violations.filter((v) => v.severity === "error");
|
|
149
|
-
if (fatal.length > 0) {
|
|
150
|
-
const msg = fatal.map((v) => `[seam] error: ${routePath}\n ${v.reason}`).join("\n\n");
|
|
151
|
-
throw new SeamBuildError(msg);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
// After fatal check, only warnings remain — dedup per message
|
|
155
|
-
for (const v of violations) {
|
|
156
|
-
const msg = `[seam] warning: ${routePath}\n ${v.reason}`;
|
|
157
|
-
if (!seenWarnings.has(msg)) {
|
|
158
|
-
seenWarnings.add(msg);
|
|
159
|
-
buildWarnings.push(msg);
|
|
160
|
-
}
|
|
161
|
-
}
|
|
162
|
-
if (violations.length > 0) {
|
|
163
|
-
html = stripResourceHints(html);
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
return html;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Merge loader procedure schemas from manifest into a combined page schema.
|
|
171
|
-
* Each loader contributes its output schema fields to the top-level properties.
|
|
172
|
-
*/
|
|
173
|
-
function buildPageSchema(route, manifest) {
|
|
174
|
-
if (!manifest) return null;
|
|
175
|
-
|
|
176
|
-
const properties = {};
|
|
177
|
-
|
|
178
|
-
for (const [loaderKey, loaderDef] of Object.entries(route.loaders || {})) {
|
|
179
|
-
const procName = loaderDef.procedure;
|
|
180
|
-
const proc = manifest.procedures?.[procName];
|
|
181
|
-
if (!proc?.output) continue;
|
|
182
|
-
|
|
183
|
-
// Always nest under the loader key so axis paths (e.g. "user.bio")
|
|
184
|
-
// align with sentinel data paths built from mock (e.g. sentinel.user.bio).
|
|
185
|
-
properties[loaderKey] = proc.output;
|
|
25
|
+
console.error(`warning: could not read manifest: ${e.message}`);
|
|
26
|
+
return { manifest: null, manifestContent: "" };
|
|
186
27
|
}
|
|
187
|
-
|
|
188
|
-
const result = {};
|
|
189
|
-
if (Object.keys(properties).length > 0) result.properties = properties;
|
|
190
|
-
return Object.keys(result).length > 0 ? result : null;
|
|
191
28
|
}
|
|
192
29
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
* then deep-merge any user-provided partial mock on top.
|
|
196
|
-
*/
|
|
197
|
-
function resolveRouteMock(route, manifest) {
|
|
198
|
-
const pageSchema = buildPageSchema(route, manifest);
|
|
199
|
-
|
|
200
|
-
if (pageSchema) {
|
|
201
|
-
const keyedMock = generateMockFromSchema(pageSchema);
|
|
202
|
-
const autoMock = flattenLoaderMock(keyedMock);
|
|
203
|
-
return route.mock ? deepMerge(autoMock, route.mock) : autoMock;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
// No manifest (frontend-only mode) — mock is required
|
|
207
|
-
if (route.mock) return route.mock;
|
|
208
|
-
|
|
209
|
-
throw new SeamBuildError(
|
|
210
|
-
`[seam] error: Mock data required for route "${route.path}"\n\n` +
|
|
211
|
-
" No procedure manifest found \u2014 cannot auto-generate mock data.\n" +
|
|
212
|
-
" Provide mock data in your route definition:\n\n" +
|
|
213
|
-
" defineRoutes([{\n" +
|
|
214
|
-
` path: "${route.path}",\n` +
|
|
215
|
-
" component: YourComponent,\n" +
|
|
216
|
-
' mock: { user: { name: "..." }, repos: [...] }\n' +
|
|
217
|
-
" }])\n\n" +
|
|
218
|
-
" Or switch to fullstack mode with typed Procedures\n" +
|
|
219
|
-
" to enable automatic mock generation from schema.",
|
|
220
|
-
);
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function renderRoute(route, manifest) {
|
|
224
|
-
const mock = resolveRouteMock(route, manifest);
|
|
225
|
-
const pageSchema = buildPageSchema(route, manifest);
|
|
226
|
-
const htmlPaths = pageSchema ? collectHtmlPaths(pageSchema) : new Set();
|
|
227
|
-
const baseSentinel = buildSentinelData(mock, "", htmlPaths);
|
|
228
|
-
const axes = pageSchema ? collectStructuralAxes(pageSchema, mock) : [];
|
|
229
|
-
const combos = cartesianProduct(axes);
|
|
230
|
-
|
|
231
|
-
const variants = combos.map((variant) => {
|
|
232
|
-
const sentinel = buildVariantSentinel(baseSentinel, mock, variant);
|
|
233
|
-
const html = guardedRender(route.path, route.component, sentinel);
|
|
234
|
-
return { variant, html };
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
// Render with real mock data for CTR equivalence check.
|
|
238
|
-
// Wrap mock with Proxy to track field accesses and detect schema mismatches.
|
|
239
|
-
const accessed = new Set();
|
|
240
|
-
const trackedMock = createAccessTracker(mock, accessed);
|
|
241
|
-
const mockHtml = stripResourceHints(guardedRender(route.path, route.component, trackedMock));
|
|
242
|
-
|
|
243
|
-
const fieldWarnings = checkFieldAccess(accessed, pageSchema, route.path);
|
|
244
|
-
for (const w of fieldWarnings) {
|
|
245
|
-
const msg = `[seam] warning: ${w}`;
|
|
246
|
-
if (!seenWarnings.has(msg)) {
|
|
247
|
-
seenWarnings.add(msg);
|
|
248
|
-
buildWarnings.push(msg);
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
return {
|
|
253
|
-
path: route.path,
|
|
254
|
-
loaders: route.loaders,
|
|
255
|
-
layout: route._layoutId || undefined,
|
|
256
|
-
axes,
|
|
257
|
-
variants,
|
|
258
|
-
mockHtml,
|
|
259
|
-
mock,
|
|
260
|
-
pageSchema,
|
|
261
|
-
};
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// -- Layout helpers --
|
|
265
|
-
|
|
266
|
-
function toLayoutId(path) {
|
|
267
|
-
return path === "/"
|
|
268
|
-
? "_layout_root"
|
|
269
|
-
: `_layout_${path.replace(/^\/|\/$/g, "").replace(/\//g, "-")}`;
|
|
270
|
-
}
|
|
271
|
-
|
|
272
|
-
/** Extract layout components and metadata from route tree */
|
|
273
|
-
function extractLayouts(routes) {
|
|
274
|
-
const seen = new Map();
|
|
275
|
-
(function walk(defs, parentId) {
|
|
276
|
-
for (const def of defs) {
|
|
277
|
-
if (def.layout && def.children) {
|
|
278
|
-
const id = toLayoutId(def.path);
|
|
279
|
-
if (!seen.has(id)) {
|
|
280
|
-
seen.set(id, {
|
|
281
|
-
component: def.layout,
|
|
282
|
-
loaders: def.loaders || {},
|
|
283
|
-
mock: def.mock || null,
|
|
284
|
-
parentId: parentId || null,
|
|
285
|
-
});
|
|
286
|
-
}
|
|
287
|
-
walk(def.children, id);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
})(routes, null);
|
|
291
|
-
return seen;
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
/**
|
|
295
|
-
* Resolve mock data for a layout: auto-generate from schema when loaders exist,
|
|
296
|
-
* then deep-merge any user-provided partial mock on top.
|
|
297
|
-
* Unlike resolveRouteMock, a layout with no loaders and no mock is valid (empty shell).
|
|
298
|
-
*/
|
|
299
|
-
function resolveLayoutMock(entry, manifest) {
|
|
300
|
-
if (Object.keys(entry.loaders).length > 0) {
|
|
301
|
-
const schema = buildPageSchema(entry, manifest);
|
|
302
|
-
if (schema) {
|
|
303
|
-
const keyedMock = generateMockFromSchema(schema);
|
|
304
|
-
const autoMock = flattenLoaderMock(keyedMock);
|
|
305
|
-
return entry.mock ? deepMerge(autoMock, entry.mock) : autoMock;
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
return entry.mock || {};
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
/** Render layout with seam-outlet placeholder, optionally with sentinel data */
|
|
312
|
-
function renderLayout(LayoutComponent, id, entry, manifest) {
|
|
313
|
-
const mock = resolveLayoutMock(entry, manifest);
|
|
314
|
-
const schema =
|
|
315
|
-
Object.keys(entry.loaders || {}).length > 0 ? buildPageSchema(entry, manifest) : null;
|
|
316
|
-
const htmlPaths = schema ? collectHtmlPaths(schema) : new Set();
|
|
317
|
-
const data = Object.keys(mock).length > 0 ? buildSentinelData(mock, "", htmlPaths) : {};
|
|
318
|
-
|
|
319
|
-
// Wrap data with Proxy to detect schema/component field mismatches
|
|
320
|
-
const accessed = new Set();
|
|
321
|
-
const trackedData = Object.keys(data).length > 0 ? createAccessTracker(data, accessed) : data;
|
|
322
|
-
|
|
323
|
-
function LayoutWithOutlet() {
|
|
324
|
-
return createElement(LayoutComponent, null, createElement("seam-outlet", null));
|
|
325
|
-
}
|
|
326
|
-
const html = guardedRender(`layout:${id}`, LayoutWithOutlet, trackedData);
|
|
327
|
-
|
|
328
|
-
const fieldWarnings = checkFieldAccess(accessed, schema, `layout:${id}`);
|
|
329
|
-
for (const w of fieldWarnings) {
|
|
330
|
-
const msg = `[seam] warning: ${w}`;
|
|
331
|
-
if (!seenWarnings.has(msg)) {
|
|
332
|
-
seenWarnings.add(msg);
|
|
333
|
-
buildWarnings.push(msg);
|
|
334
|
-
}
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
return html;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** Flatten routes, annotating each leaf with its parent layout id */
|
|
341
|
-
function flattenRoutes(routes, currentLayout) {
|
|
342
|
-
const leaves = [];
|
|
343
|
-
for (const route of routes) {
|
|
344
|
-
if (route.layout && route.children) {
|
|
345
|
-
leaves.push(...flattenRoutes(route.children, toLayoutId(route.path)));
|
|
346
|
-
} else if (route.children) {
|
|
347
|
-
leaves.push(...flattenRoutes(route.children, currentLayout));
|
|
348
|
-
} else {
|
|
349
|
-
if (currentLayout) route._layoutId = currentLayout;
|
|
350
|
-
leaves.push(route);
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
return leaves;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
// -- Cache helpers --
|
|
357
|
-
|
|
358
|
-
/** Parse import statements to map local names to specifiers */
|
|
359
|
-
function parseComponentImports(source) {
|
|
360
|
-
const map = new Map();
|
|
361
|
-
const re = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\}\s*)?from\s+['"]([^'"]+)['"]/g;
|
|
362
|
-
let m;
|
|
363
|
-
while ((m = re.exec(source)) !== null) {
|
|
364
|
-
const [, defaultName, namedPart, specifier] = m;
|
|
365
|
-
if (defaultName) map.set(defaultName, specifier);
|
|
366
|
-
if (namedPart) {
|
|
367
|
-
for (const part of namedPart.split(",")) {
|
|
368
|
-
const t = part.trim();
|
|
369
|
-
if (!t) continue;
|
|
370
|
-
const asMatch = t.match(/^(\w+)\s+as\s+(\w+)$/);
|
|
371
|
-
if (asMatch) {
|
|
372
|
-
map.set(asMatch[2], specifier);
|
|
373
|
-
map.set(asMatch[1], specifier);
|
|
374
|
-
} else {
|
|
375
|
-
map.set(t, specifier);
|
|
376
|
-
}
|
|
377
|
-
}
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
return map;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/** Bundle each component via esbuild (write: false) and SHA-256 hash the output */
|
|
384
|
-
async function computeComponentHashes(names, importMap, routesDir) {
|
|
385
|
-
const hashes = new Map();
|
|
386
|
-
const seen = new Set();
|
|
387
|
-
const tasks = [];
|
|
388
|
-
for (const name of names) {
|
|
389
|
-
const specifier = importMap.get(name);
|
|
390
|
-
if (!specifier || seen.has(specifier)) continue;
|
|
391
|
-
seen.add(specifier);
|
|
392
|
-
tasks.push(
|
|
393
|
-
build({
|
|
394
|
-
stdin: { contents: `import '${specifier}'`, resolveDir: routesDir, loader: "js" },
|
|
395
|
-
bundle: true,
|
|
396
|
-
write: false,
|
|
397
|
-
format: "esm",
|
|
398
|
-
platform: "node",
|
|
399
|
-
treeShaking: false,
|
|
400
|
-
external: ["react", "react-dom", "@canmi/seam-react"],
|
|
401
|
-
logLevel: "silent",
|
|
402
|
-
})
|
|
403
|
-
.then((result) => {
|
|
404
|
-
const content = result.outputFiles[0]?.text || "";
|
|
405
|
-
const hash = createHash("sha256").update(content).digest("hex");
|
|
406
|
-
for (const [n, s] of importMap) {
|
|
407
|
-
if (s === specifier) hashes.set(n, hash);
|
|
408
|
-
}
|
|
409
|
-
})
|
|
410
|
-
.catch(() => {}),
|
|
411
|
-
);
|
|
412
|
-
}
|
|
413
|
-
await Promise.all(tasks);
|
|
414
|
-
return hashes;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
function computeScriptHash() {
|
|
418
|
-
const files = ["build-skeletons.mjs", "variant-generator.mjs", "mock-generator.mjs"];
|
|
419
|
-
const h = createHash("sha256");
|
|
420
|
-
for (const f of files) h.update(readFileSync(join(__dirname, f), "utf-8"));
|
|
421
|
-
return h.digest("hex");
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function pathToSlug(path) {
|
|
425
|
-
const t = path
|
|
426
|
-
.replace(/^\/|\/$/g, "")
|
|
427
|
-
.replace(/\//g, "-")
|
|
428
|
-
.replace(/:/g, "");
|
|
429
|
-
return t || "index";
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function readCache(cacheDir, slug) {
|
|
30
|
+
function loadI18nConfig(i18nArg) {
|
|
31
|
+
if (!i18nArg || i18nArg === "none") return null;
|
|
433
32
|
try {
|
|
434
|
-
return JSON.parse(
|
|
435
|
-
} catch {
|
|
33
|
+
return JSON.parse(i18nArg);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error(`warning: could not parse i18n config: ${e.message}`);
|
|
436
36
|
return null;
|
|
437
37
|
}
|
|
438
38
|
}
|
|
439
39
|
|
|
440
|
-
function writeCache(cacheDir, slug, key, data) {
|
|
441
|
-
writeFileSync(join(cacheDir, `${slug}.json`), JSON.stringify({ key, data }));
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
function computeCacheKey(componentHash, manifestContent, config, scriptHash) {
|
|
445
|
-
const h = createHash("sha256");
|
|
446
|
-
h.update(componentHash);
|
|
447
|
-
h.update(manifestContent);
|
|
448
|
-
h.update(JSON.stringify(config));
|
|
449
|
-
h.update(scriptHash);
|
|
450
|
-
return h.digest("hex").slice(0, 16);
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
function processLayoutsWithCache(layoutMap, ctx) {
|
|
454
|
-
return [...layoutMap.entries()].map(([id, entry]) => {
|
|
455
|
-
const compHash = ctx.componentHashes.get(entry.component?.name);
|
|
456
|
-
if (compHash) {
|
|
457
|
-
const config = { id, loaders: entry.loaders, mock: entry.mock };
|
|
458
|
-
const key = computeCacheKey(compHash, ctx.manifestContent, config, ctx.scriptHash);
|
|
459
|
-
const slug = `layout_${id}`;
|
|
460
|
-
const cached = readCache(ctx.cacheDir, slug);
|
|
461
|
-
if (cached && cached.key === key) {
|
|
462
|
-
ctx.stats.hits++;
|
|
463
|
-
return cached.data;
|
|
464
|
-
}
|
|
465
|
-
const data = {
|
|
466
|
-
id,
|
|
467
|
-
html: renderLayout(entry.component, id, entry, ctx.manifest),
|
|
468
|
-
loaders: entry.loaders,
|
|
469
|
-
parent: entry.parentId,
|
|
470
|
-
};
|
|
471
|
-
writeCache(ctx.cacheDir, slug, key, data);
|
|
472
|
-
ctx.stats.misses++;
|
|
473
|
-
return data;
|
|
474
|
-
}
|
|
475
|
-
ctx.stats.misses++;
|
|
476
|
-
return {
|
|
477
|
-
id,
|
|
478
|
-
html: renderLayout(entry.component, id, entry, ctx.manifest),
|
|
479
|
-
loaders: entry.loaders,
|
|
480
|
-
parent: entry.parentId,
|
|
481
|
-
};
|
|
482
|
-
});
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
function processRoutesWithCache(flat, ctx) {
|
|
486
|
-
return flat.map((r) => {
|
|
487
|
-
const compHash = ctx.componentHashes.get(r.component?.name);
|
|
488
|
-
if (compHash) {
|
|
489
|
-
const config = { path: r.path, loaders: r.loaders, mock: r.mock, nullable: r.nullable };
|
|
490
|
-
const key = computeCacheKey(compHash, ctx.manifestContent, config, ctx.scriptHash);
|
|
491
|
-
const slug = `route_${pathToSlug(r.path)}`;
|
|
492
|
-
const cached = readCache(ctx.cacheDir, slug);
|
|
493
|
-
if (cached && cached.key === key) {
|
|
494
|
-
ctx.stats.hits++;
|
|
495
|
-
return cached.data;
|
|
496
|
-
}
|
|
497
|
-
const data = renderRoute(r, ctx.manifest);
|
|
498
|
-
writeCache(ctx.cacheDir, slug, key, data);
|
|
499
|
-
ctx.stats.misses++;
|
|
500
|
-
return data;
|
|
501
|
-
}
|
|
502
|
-
ctx.stats.misses++;
|
|
503
|
-
return renderRoute(r, ctx.manifest);
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
// -- Main --
|
|
508
|
-
|
|
509
40
|
async function main() {
|
|
510
41
|
const routesFile = process.argv[2];
|
|
511
|
-
const manifestFile = process.argv[3];
|
|
512
|
-
|
|
513
42
|
if (!routesFile) {
|
|
514
|
-
console.error("Usage: node build-skeletons.mjs <routes-file> [manifest-file]");
|
|
43
|
+
console.error("Usage: node build-skeletons.mjs <routes-file> [manifest-file] [i18n-json]");
|
|
515
44
|
process.exit(1);
|
|
516
45
|
}
|
|
517
46
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
if (
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
} catch (e) {
|
|
526
|
-
console.error(`warning: could not read manifest: ${e.message}`);
|
|
527
|
-
}
|
|
47
|
+
const { manifest, manifestContent } = loadManifest(process.argv[3]);
|
|
48
|
+
const i18n = loadI18nConfig(process.argv[4]);
|
|
49
|
+
|
|
50
|
+
if (i18n) {
|
|
51
|
+
const { setI18nProvider } = await import("./skeleton/render.mjs");
|
|
52
|
+
const { I18nProvider } = await import("@canmi/seam-i18n/react");
|
|
53
|
+
setI18nProvider(I18nProvider);
|
|
528
54
|
}
|
|
529
55
|
|
|
530
56
|
const absRoutes = resolve(routesFile);
|
|
@@ -541,7 +67,7 @@ async function main() {
|
|
|
541
67
|
format: "esm",
|
|
542
68
|
platform: "node",
|
|
543
69
|
outfile,
|
|
544
|
-
external: ["react", "react-dom", "@canmi/seam-react"],
|
|
70
|
+
external: ["react", "react-dom", "@canmi/seam-react", "@canmi/seam-i18n"],
|
|
545
71
|
});
|
|
546
72
|
|
|
547
73
|
try {
|
|
@@ -563,21 +89,40 @@ async function main() {
|
|
|
563
89
|
if (route.component?.name) componentNames.add(route.component.name);
|
|
564
90
|
}
|
|
565
91
|
|
|
92
|
+
// Files to hash for script-level cache invalidation
|
|
93
|
+
const skeletonDir = join(__dirname, "skeleton");
|
|
94
|
+
const scriptFiles = [
|
|
95
|
+
join(skeletonDir, "render.mjs"),
|
|
96
|
+
join(skeletonDir, "schema.mjs"),
|
|
97
|
+
join(skeletonDir, "layout.mjs"),
|
|
98
|
+
join(skeletonDir, "cache.mjs"),
|
|
99
|
+
join(skeletonDir, "process.mjs"),
|
|
100
|
+
join(__dirname, "variant-generator.mjs"),
|
|
101
|
+
join(__dirname, "mock-generator.mjs"),
|
|
102
|
+
];
|
|
103
|
+
|
|
566
104
|
const [componentHashes, scriptHash] = await Promise.all([
|
|
567
105
|
computeComponentHashes([...componentNames], importMap, routesDir),
|
|
568
|
-
Promise.resolve(computeScriptHash()),
|
|
106
|
+
Promise.resolve(computeScriptHash(scriptFiles)),
|
|
569
107
|
]);
|
|
570
108
|
|
|
571
109
|
// Set up cache directory
|
|
572
110
|
const cacheDir = join(process.cwd(), ".seam", "cache", "skeletons");
|
|
573
111
|
mkdirSync(cacheDir, { recursive: true });
|
|
574
112
|
|
|
113
|
+
// Shared warning state passed through to all render functions
|
|
114
|
+
const buildWarnings = [];
|
|
115
|
+
const seenWarnings = new Set();
|
|
116
|
+
const warnCtx = { buildWarnings, seenWarnings };
|
|
117
|
+
|
|
575
118
|
const ctx = {
|
|
576
119
|
componentHashes,
|
|
577
120
|
scriptHash,
|
|
578
121
|
manifestContent,
|
|
579
122
|
manifest,
|
|
580
123
|
cacheDir,
|
|
124
|
+
i18n,
|
|
125
|
+
warnCtx,
|
|
581
126
|
stats: { hits: 0, misses: 0 },
|
|
582
127
|
};
|
|
583
128
|
|