@canmi/seam-react 0.2.14 → 0.4.3

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.
@@ -1,530 +1,56 @@
1
1
  /* packages/client/react/scripts/build-skeletons.mjs */
2
2
 
3
3
  import { build } from "esbuild";
4
- import { createElement } from "react";
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
- import { SeamDataProvider, buildSentinelData } from "@canmi/seam-react";
11
- import {
12
- collectStructuralAxes,
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
- generateMockFromSchema,
18
- flattenLoaderMock,
19
- deepMerge,
20
- collectHtmlPaths,
21
- createAccessTracker,
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
- // -- Rendering --
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
- html = renderWithData(component, data);
22
+ const content = readFileSync(resolve(manifestFile), "utf-8");
23
+ return { manifest: JSON.parse(content), manifestContent: content };
133
24
  } catch (e) {
134
- if (e instanceof SeamBuildError) {
135
- throw new SeamBuildError(
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
- * Resolve mock data for a route: auto-generate from schema when available,
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(readFileSync(join(cacheDir, `${slug}.json`), "utf-8"));
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
- // Load manifest if provided
519
- let manifest = null;
520
- let manifestContent = "";
521
- if (manifestFile && manifestFile !== "none") {
522
- try {
523
- manifestContent = readFileSync(resolve(manifestFile), "utf-8");
524
- manifest = JSON.parse(manifestContent);
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,26 +89,45 @@ 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
 
584
- const layouts = processLayoutsWithCache(layoutMap, ctx);
585
- const renderedRoutes = processRoutesWithCache(flat, ctx);
129
+ const layouts = await processLayoutsWithCache(layoutMap, ctx);
130
+ const renderedRoutes = await processRoutesWithCache(flat, ctx);
586
131
 
587
132
  const output = {
588
133
  layouts,