@canmi/seam-react 0.2.3 → 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.
@@ -41,6 +41,11 @@ export function generateMockFromSchema(schema, fieldPath = "") {
41
41
  return generateMockFromSchema(inner, fieldPath);
42
42
  }
43
43
 
44
+ // HTML format: return sample HTML content instead of plain text
45
+ if (schema.type === "string" && schema.metadata?.format === "html") {
46
+ return "<p>Sample HTML content</p>";
47
+ }
48
+
44
49
  // Primitive type forms
45
50
  if (schema.type) {
46
51
  switch (schema.type) {
@@ -163,3 +168,307 @@ export function deepMerge(base, override) {
163
168
  }
164
169
  return result;
165
170
  }
171
+
172
+ /**
173
+ * Recursively walk a JTD schema collecting dot-separated paths
174
+ * where metadata.format === "html".
175
+ * Returns a Set including both full paths and flattened paths
176
+ * (first segment stripped) to match flattenLoaderMock behavior.
177
+ * @param {object} schema - page-level JTD schema
178
+ * @returns {Set<string>}
179
+ */
180
+ /**
181
+ * Walk JTD schema collecting all valid dot-separated field paths.
182
+ * Returns a Set including both keyed paths and flattened paths
183
+ * (first segment stripped) to match flattenLoaderMock behavior.
184
+ * @param {object} schema - page-level JTD schema
185
+ * @returns {Set<string>}
186
+ */
187
+ export function collectSchemaPaths(schema) {
188
+ const paths = new Set();
189
+
190
+ function walk(node, prefix) {
191
+ if (!node || typeof node !== "object") return;
192
+
193
+ if (node.nullable) {
194
+ const inner = { ...node };
195
+ delete inner.nullable;
196
+ walk(inner, prefix);
197
+ return;
198
+ }
199
+
200
+ if (node.properties || node.optionalProperties) {
201
+ for (const [key, sub] of Object.entries({
202
+ ...node.properties,
203
+ ...node.optionalProperties,
204
+ })) {
205
+ const path = prefix ? `${prefix}.${key}` : key;
206
+ paths.add(path);
207
+ walk(sub, path);
208
+ }
209
+ return;
210
+ }
211
+
212
+ if (node.elements) {
213
+ walk(node.elements, prefix ? `${prefix}.$` : "$");
214
+ return;
215
+ }
216
+ }
217
+
218
+ walk(schema, "");
219
+
220
+ // Add flattened paths (strip first segment) to match flattenLoaderMock.
221
+ // Snapshot before iterating because we mutate paths in the loop body.
222
+ const snapshot = Array.from(paths);
223
+ for (const p of snapshot) {
224
+ const dot = p.indexOf(".");
225
+ if (dot !== -1) paths.add(p.slice(dot + 1));
226
+ }
227
+ return paths;
228
+ }
229
+
230
+ /**
231
+ * Standard Levenshtein distance between two strings.
232
+ * @param {string} a
233
+ * @param {string} b
234
+ * @returns {number}
235
+ */
236
+ export function levenshtein(a, b) {
237
+ const m = a.length;
238
+ const n = b.length;
239
+ const dp = Array.from({ length: m + 1 }, () => Array.from({ length: n + 1 }, () => 0));
240
+ for (let i = 0; i <= m; i++) dp[i][0] = i;
241
+ for (let j = 0; j <= n; j++) dp[0][j] = j;
242
+ for (let i = 1; i <= m; i++) {
243
+ for (let j = 1; j <= n; j++) {
244
+ dp[i][j] =
245
+ a[i - 1] === b[j - 1]
246
+ ? dp[i - 1][j - 1]
247
+ : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
248
+ }
249
+ }
250
+ return dp[m][n];
251
+ }
252
+
253
+ /**
254
+ * Return the closest candidate within Levenshtein distance <= 3, or null.
255
+ * @param {string} name
256
+ * @param {Iterable<string>} candidates
257
+ * @returns {string | null}
258
+ */
259
+ export function didYouMean(name, candidates) {
260
+ let best = null;
261
+ let bestDist = 4; // threshold: distance must be <= 3
262
+ for (const c of candidates) {
263
+ const d = levenshtein(name, c);
264
+ if (d < bestDist) {
265
+ bestDist = d;
266
+ best = c;
267
+ }
268
+ }
269
+ return best;
270
+ }
271
+
272
+ // Keys to ignore in Proxy tracking — React internals, framework hooks, and prototype methods
273
+ const SKIP_KEYS = new Set([
274
+ "$$typeof",
275
+ "then",
276
+ "toJSON",
277
+ "constructor",
278
+ "valueOf",
279
+ "toString",
280
+ "hasOwnProperty",
281
+ "isPrototypeOf",
282
+ "propertyIsEnumerable",
283
+ "toLocaleString",
284
+ "__proto__",
285
+ "_owner",
286
+ "_store",
287
+ "ref",
288
+ "key",
289
+ "type",
290
+ "props",
291
+ "_self",
292
+ "_source",
293
+ ]);
294
+
295
+ const ARRAY_METHODS = new Set([
296
+ "length",
297
+ "map",
298
+ "filter",
299
+ "forEach",
300
+ "find",
301
+ "findIndex",
302
+ "some",
303
+ "every",
304
+ "reduce",
305
+ "reduceRight",
306
+ "includes",
307
+ "indexOf",
308
+ "lastIndexOf",
309
+ "flat",
310
+ "flatMap",
311
+ "slice",
312
+ "concat",
313
+ "join",
314
+ "sort",
315
+ "reverse",
316
+ "entries",
317
+ "keys",
318
+ "values",
319
+ "at",
320
+ "fill",
321
+ "copyWithin",
322
+ "push",
323
+ "pop",
324
+ "shift",
325
+ "unshift",
326
+ "splice",
327
+ ]);
328
+
329
+ /**
330
+ * Wrap an object with a Proxy that records all property access paths into `accessed`.
331
+ * Nested objects/arrays are recursively wrapped.
332
+ * @param {unknown} obj - object to wrap
333
+ * @param {Set<string>} accessed - set to record accessed paths
334
+ * @param {string} [prefix=""] - current dot-separated path prefix
335
+ * @returns {unknown}
336
+ */
337
+ export function createAccessTracker(obj, accessed, prefix = "") {
338
+ if (obj === null || obj === undefined || typeof obj !== "object") return obj;
339
+
340
+ return new Proxy(obj, {
341
+ get(target, prop, receiver) {
342
+ // Skip symbols (React internals: Symbol.toPrimitive, Symbol.iterator, etc.)
343
+ if (typeof prop === "symbol") return Reflect.get(target, prop, receiver);
344
+
345
+ // Skip framework / prototype keys
346
+ if (SKIP_KEYS.has(prop)) return Reflect.get(target, prop, receiver);
347
+
348
+ const isArr = Array.isArray(target);
349
+
350
+ // Skip array methods but still wrap returned object values
351
+ if (isArr && ARRAY_METHODS.has(prop)) {
352
+ const val = Reflect.get(target, prop, receiver);
353
+ return val;
354
+ }
355
+
356
+ // Numeric index on array — record as prefix.$
357
+ if (isArr && /^\d+$/.test(prop)) {
358
+ const path = prefix ? `${prefix}.$` : "$";
359
+ accessed.add(path);
360
+ const val = target[prop];
361
+ if (val !== null && val !== undefined && typeof val === "object") {
362
+ return createAccessTracker(val, accessed, path);
363
+ }
364
+ return val;
365
+ }
366
+
367
+ const path = prefix ? `${prefix}.${prop}` : prop;
368
+ accessed.add(path);
369
+
370
+ const val = Reflect.get(target, prop, receiver);
371
+ if (val !== null && val !== undefined && typeof val === "object") {
372
+ return createAccessTracker(val, accessed, path);
373
+ }
374
+ return val;
375
+ },
376
+ });
377
+ }
378
+
379
+ /**
380
+ * Compare accessed property paths against schema-defined paths.
381
+ * Returns an array of warning strings for fields accessed but not in schema.
382
+ * @param {Set<string>} accessed - paths recorded by createAccessTracker
383
+ * @param {object | null} schema - page-level JTD schema
384
+ * @param {string} routePath - route path for warning messages
385
+ * @returns {string[]}
386
+ */
387
+ export function checkFieldAccess(accessed, schema, routePath) {
388
+ if (!schema) return [];
389
+
390
+ const known = collectSchemaPaths(schema);
391
+ if (known.size === 0) return [];
392
+
393
+ const warnings = [];
394
+ // Collect leaf field names for did-you-mean suggestions
395
+ const leafNames = new Set();
396
+ for (const p of known) {
397
+ const dot = p.lastIndexOf(".");
398
+ leafNames.add(dot === -1 ? p : p.slice(dot + 1));
399
+ }
400
+
401
+ for (const path of accessed) {
402
+ if (known.has(path)) continue;
403
+
404
+ // Skip if it's a parent prefix of a known path (e.g. "user" when "user.name" exists)
405
+ let isParent = false;
406
+ for (const k of known) {
407
+ if (k.startsWith(path + ".")) {
408
+ isParent = true;
409
+ break;
410
+ }
411
+ }
412
+ if (isParent) continue;
413
+
414
+ const fieldName = path.includes(".") ? path.slice(path.lastIndexOf(".") + 1) : path;
415
+ const suggestion = didYouMean(fieldName, leafNames);
416
+
417
+ const knownList = [...leafNames].sort().join(", ");
418
+ let msg = `Route "${routePath}" component accessed data.${path},\n but schema only defines: ${knownList}`;
419
+ if (suggestion) {
420
+ msg += `\n Did you mean: ${suggestion}?`;
421
+ }
422
+ warnings.push(msg);
423
+ }
424
+
425
+ return warnings;
426
+ }
427
+
428
+ export function collectHtmlPaths(schema) {
429
+ const paths = new Set();
430
+
431
+ function walk(node, prefix) {
432
+ if (!node || typeof node !== "object") return;
433
+
434
+ if (node.nullable) {
435
+ const inner = { ...node };
436
+ delete inner.nullable;
437
+ walk(inner, prefix);
438
+ return;
439
+ }
440
+
441
+ if (node.type === "string" && node.metadata?.format === "html") {
442
+ paths.add(prefix);
443
+ return;
444
+ }
445
+
446
+ if (node.properties || node.optionalProperties) {
447
+ for (const [key, sub] of Object.entries(node.properties || {})) {
448
+ walk(sub, prefix ? `${prefix}.${key}` : key);
449
+ }
450
+ for (const [key, sub] of Object.entries(node.optionalProperties || {})) {
451
+ walk(sub, prefix ? `${prefix}.${key}` : key);
452
+ }
453
+ return;
454
+ }
455
+
456
+ if (node.elements) {
457
+ walk(node.elements, prefix ? `${prefix}.$` : "$");
458
+ return;
459
+ }
460
+ }
461
+
462
+ walk(schema, "");
463
+
464
+ // Add flattened paths (strip first segment) to match flattenLoaderMock
465
+ const flattened = new Set(paths);
466
+ for (const p of paths) {
467
+ const dot = p.indexOf(".");
468
+ if (dot !== -1) {
469
+ flattened.add(p.slice(dot + 1));
470
+ }
471
+ }
472
+
473
+ return flattened;
474
+ }
@@ -0,0 +1,135 @@
1
+ /* packages/client/react/scripts/skeleton/cache.mjs */
2
+
3
+ import { build } from "esbuild";
4
+ import { createHash } from "node:crypto";
5
+ import { readFileSync, writeFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+ import { createI18n } from "@canmi/seam-i18n";
8
+
9
+ /** Parse import statements to map local names to specifiers */
10
+ function parseComponentImports(source) {
11
+ const map = new Map();
12
+ const re = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\}\s*)?from\s+['"]([^'"]+)['"]/g;
13
+ let m;
14
+ while ((m = re.exec(source)) !== null) {
15
+ const [, defaultName, namedPart, specifier] = m;
16
+ if (defaultName) map.set(defaultName, specifier);
17
+ if (namedPart) {
18
+ for (const part of namedPart.split(",")) {
19
+ const t = part.trim();
20
+ if (!t) continue;
21
+ const asMatch = t.match(/^(\w+)\s+as\s+(\w+)$/);
22
+ if (asMatch) {
23
+ map.set(asMatch[2], specifier);
24
+ map.set(asMatch[1], specifier);
25
+ } else {
26
+ map.set(t, specifier);
27
+ }
28
+ }
29
+ }
30
+ }
31
+ return map;
32
+ }
33
+
34
+ /** Bundle each component via esbuild (write: false) and SHA-256 hash the output */
35
+ async function computeComponentHashes(names, importMap, routesDir) {
36
+ const hashes = new Map();
37
+ const seen = new Set();
38
+ const tasks = [];
39
+ for (const name of names) {
40
+ const specifier = importMap.get(name);
41
+ if (!specifier || seen.has(specifier)) continue;
42
+ seen.add(specifier);
43
+ tasks.push(
44
+ build({
45
+ stdin: { contents: `import '${specifier}'`, resolveDir: routesDir, loader: "js" },
46
+ bundle: true,
47
+ write: false,
48
+ format: "esm",
49
+ platform: "node",
50
+ treeShaking: false,
51
+ external: ["react", "react-dom", "@canmi/seam-react", "@canmi/seam-i18n"],
52
+ logLevel: "silent",
53
+ })
54
+ .then((result) => {
55
+ const content = result.outputFiles[0]?.text || "";
56
+ const hash = createHash("sha256").update(content).digest("hex");
57
+ for (const [n, s] of importMap) {
58
+ if (s === specifier) hashes.set(n, hash);
59
+ }
60
+ })
61
+ .catch(() => {}),
62
+ );
63
+ }
64
+ await Promise.all(tasks);
65
+ return hashes;
66
+ }
67
+
68
+ /**
69
+ * Hash the build scripts themselves to invalidate cache when tooling changes.
70
+ * @param {string[]} scriptFiles - absolute paths of script files to hash
71
+ */
72
+ function computeScriptHash(scriptFiles) {
73
+ const h = createHash("sha256");
74
+ for (const f of scriptFiles) h.update(readFileSync(f, "utf-8"));
75
+ return h.digest("hex");
76
+ }
77
+
78
+ function pathToSlug(path) {
79
+ const t = path
80
+ .replace(/^\/|\/$/g, "")
81
+ .replace(/\//g, "-")
82
+ .replace(/:/g, "");
83
+ return t || "index";
84
+ }
85
+
86
+ function readCache(cacheDir, slug) {
87
+ try {
88
+ return JSON.parse(readFileSync(join(cacheDir, `${slug}.json`), "utf-8"));
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
93
+
94
+ function writeCache(cacheDir, slug, key, data) {
95
+ writeFileSync(join(cacheDir, `${slug}.json`), JSON.stringify({ key, data }));
96
+ }
97
+
98
+ function computeCacheKey(componentHash, manifestContent, config, scriptHash, locale, messagesJson) {
99
+ const h = createHash("sha256");
100
+ h.update(componentHash);
101
+ h.update(manifestContent);
102
+ h.update(JSON.stringify(config));
103
+ h.update(scriptHash);
104
+ if (locale) h.update(locale);
105
+ if (messagesJson) h.update(messagesJson);
106
+ return h.digest("hex").slice(0, 16);
107
+ }
108
+
109
+ function buildI18nValue(locale, messages, defaultLocale) {
110
+ const localeMessages = messages?.[locale] || {};
111
+ const fallback =
112
+ defaultLocale && locale !== defaultLocale ? messages?.[defaultLocale] || {} : undefined;
113
+ const instance = createI18n(locale, localeMessages, fallback);
114
+ const usedKeys = new Set();
115
+ const origT = instance.t;
116
+ return {
117
+ locale: instance.locale,
118
+ t(key, params) {
119
+ usedKeys.add(key);
120
+ return origT(key, params);
121
+ },
122
+ _usedKeys: usedKeys,
123
+ };
124
+ }
125
+
126
+ export {
127
+ parseComponentImports,
128
+ computeComponentHashes,
129
+ computeScriptHash,
130
+ pathToSlug,
131
+ readCache,
132
+ writeCache,
133
+ computeCacheKey,
134
+ buildI18nValue,
135
+ };
@@ -0,0 +1,109 @@
1
+ /* packages/client/react/scripts/skeleton/layout.mjs */
2
+
3
+ import { createElement } from "react";
4
+ import { buildSentinelData } from "@canmi/seam-react";
5
+ import {
6
+ generateMockFromSchema,
7
+ flattenLoaderMock,
8
+ deepMerge,
9
+ collectHtmlPaths,
10
+ createAccessTracker,
11
+ checkFieldAccess,
12
+ } from "../mock-generator.mjs";
13
+ import { guardedRender } from "./render.mjs";
14
+ import { buildPageSchema } from "./schema.mjs";
15
+
16
+ function toLayoutId(path) {
17
+ return path === "/"
18
+ ? "_layout_root"
19
+ : `_layout_${path.replace(/^\/|\/$/g, "").replace(/\//g, "-")}`;
20
+ }
21
+
22
+ /** Extract layout components and metadata from route tree */
23
+ function extractLayouts(routes) {
24
+ const seen = new Map();
25
+ (function walk(defs, parentId) {
26
+ for (const def of defs) {
27
+ if (def.layout && def.children) {
28
+ const id = toLayoutId(def.path);
29
+ if (!seen.has(id)) {
30
+ seen.set(id, {
31
+ component: def.layout,
32
+ loaders: def.loaders || {},
33
+ mock: def.mock || null,
34
+ parentId: parentId || null,
35
+ });
36
+ }
37
+ walk(def.children, id);
38
+ }
39
+ }
40
+ })(routes, null);
41
+ return seen;
42
+ }
43
+
44
+ /**
45
+ * Resolve mock data for a layout: auto-generate from schema when loaders exist,
46
+ * then deep-merge any user-provided partial mock on top.
47
+ * Unlike resolveRouteMock, a layout with no loaders and no mock is valid (empty shell).
48
+ */
49
+ function resolveLayoutMock(entry, manifest) {
50
+ if (Object.keys(entry.loaders).length > 0) {
51
+ const schema = buildPageSchema(entry, manifest);
52
+ if (schema) {
53
+ const keyedMock = generateMockFromSchema(schema);
54
+ const autoMock = flattenLoaderMock(keyedMock);
55
+ return entry.mock ? deepMerge(autoMock, entry.mock) : autoMock;
56
+ }
57
+ }
58
+ return entry.mock || {};
59
+ }
60
+
61
+ /**
62
+ * Render layout with seam-outlet placeholder, optionally with sentinel data.
63
+ * @param {{ buildWarnings: string[], seenWarnings: Set<string> }} ctx - shared warning state
64
+ */
65
+ function renderLayout(LayoutComponent, id, entry, manifest, i18nValue, ctx) {
66
+ const mock = resolveLayoutMock(entry, manifest);
67
+ const schema =
68
+ Object.keys(entry.loaders || {}).length > 0 ? buildPageSchema(entry, manifest) : null;
69
+ const htmlPaths = schema ? collectHtmlPaths(schema) : new Set();
70
+ const data = Object.keys(mock).length > 0 ? buildSentinelData(mock, "", htmlPaths) : {};
71
+
72
+ // Wrap data with Proxy to detect schema/component field mismatches
73
+ const accessed = new Set();
74
+ const trackedData = Object.keys(data).length > 0 ? createAccessTracker(data, accessed) : data;
75
+
76
+ function LayoutWithOutlet() {
77
+ return createElement(LayoutComponent, null, createElement("seam-outlet", null));
78
+ }
79
+ const html = guardedRender(`layout:${id}`, LayoutWithOutlet, trackedData, i18nValue, ctx);
80
+
81
+ const fieldWarnings = checkFieldAccess(accessed, schema, `layout:${id}`);
82
+ for (const w of fieldWarnings) {
83
+ const msg = `[seam] warning: ${w}`;
84
+ if (!ctx.seenWarnings.has(msg)) {
85
+ ctx.seenWarnings.add(msg);
86
+ ctx.buildWarnings.push(msg);
87
+ }
88
+ }
89
+
90
+ return html;
91
+ }
92
+
93
+ /** Flatten routes, annotating each leaf with its parent layout id */
94
+ function flattenRoutes(routes, currentLayout) {
95
+ const leaves = [];
96
+ for (const route of routes) {
97
+ if (route.layout && route.children) {
98
+ leaves.push(...flattenRoutes(route.children, toLayoutId(route.path)));
99
+ } else if (route.children) {
100
+ leaves.push(...flattenRoutes(route.children, currentLayout));
101
+ } else {
102
+ if (currentLayout) route._layoutId = currentLayout;
103
+ leaves.push(route);
104
+ }
105
+ }
106
+ return leaves;
107
+ }
108
+
109
+ export { toLayoutId, extractLayouts, resolveLayoutMock, renderLayout, flattenRoutes };