@canmi/seam-react 0.2.3 → 0.2.14
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 +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -4
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/scripts/build-skeletons.mjs +255 -17
- package/scripts/mock-generator.mjs +309 -0
package/dist/index.d.ts
CHANGED
|
@@ -21,6 +21,7 @@ interface RouteDef {
|
|
|
21
21
|
loaders?: Record<string, LoaderDef>;
|
|
22
22
|
mock?: Record<string, unknown>;
|
|
23
23
|
nullable?: string[];
|
|
24
|
+
staleTime?: number;
|
|
24
25
|
}
|
|
25
26
|
//#endregion
|
|
26
27
|
//#region src/define-routes.d.ts
|
|
@@ -40,7 +41,7 @@ declare function parseSeamData(): Record<string, unknown>;
|
|
|
40
41
|
* sentinel array where each leaf in the object template uses `$.` path prefix.
|
|
41
42
|
* Arrays of primitives, empty arrays, and null remain leaf sentinels.
|
|
42
43
|
*/
|
|
43
|
-
declare function buildSentinelData(obj: Record<string, unknown>, prefix?: string): Record<string, unknown>;
|
|
44
|
+
declare function buildSentinelData(obj: Record<string, unknown>, prefix?: string, htmlPaths?: Set<string>): Record<string, unknown>;
|
|
44
45
|
//#endregion
|
|
45
46
|
//#region src/use-seam-subscription.d.ts
|
|
46
47
|
type SubscriptionStatus = "connecting" | "active" | "error" | "closed";
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/define-routes.ts","../src/use-seam-data.ts","../src/sentinel.ts","../src/use-seam-subscription.ts","../src/use-seam-navigate.ts"],"mappings":";;;;;UAIiB,YAAA;EACf,IAAA;EACA,IAAA;AAAA;AAAA,UAGe,SAAA;EACf,SAAA;EACA,MAAA,GAAS,MAAA,SAAe,YAAA;AAAA;AAAA,UAGT,QAAA;EACf,IAAA;EACA,SAAA,GAAY,aAAA,CAAc,MAAA;EAC1B,MAAA,GAAS,aAAA;IAAgB,QAAA,EAAU,SAAA;EAAA;EACnC,QAAA,GAAW,QAAA;EACX,OAAA,GAAU,MAAA,SAAe,SAAA;EACzB,IAAA,GAAO,MAAA;EACP,QAAA;AAAA;;;
|
|
1
|
+
{"version":3,"file":"index.d.ts","names":[],"sources":["../src/types.ts","../src/define-routes.ts","../src/use-seam-data.ts","../src/sentinel.ts","../src/use-seam-subscription.ts","../src/use-seam-navigate.ts"],"mappings":";;;;;UAIiB,YAAA;EACf,IAAA;EACA,IAAA;AAAA;AAAA,UAGe,SAAA;EACf,SAAA;EACA,MAAA,GAAS,MAAA,SAAe,YAAA;AAAA;AAAA,UAGT,QAAA;EACf,IAAA;EACA,SAAA,GAAY,aAAA,CAAc,MAAA;EAC1B,MAAA,GAAS,aAAA;IAAgB,QAAA,EAAU,SAAA;EAAA;EACnC,QAAA,GAAW,QAAA;EACX,OAAA,GAAU,MAAA,SAAe,SAAA;EACzB,IAAA,GAAO,MAAA;EACP,QAAA;EACA,SAAA;AAAA;;;iBClBc,YAAA,CAAa,MAAA,EAAQ,QAAA,KAAa,QAAA;;;cCErC,gBAAA,EAA2C,KAAA,CAA3B,QAAA;AAAA,iBAEb,WAAA,oBAA+B,MAAA,kBAAA,CAAA,GAA4B,CAAA;AAAA,iBAO3D,aAAA,CAAA,GAAiB,MAAA;;;;;;;;AFXjC;;;iBGMgB,iBAAA,CACd,GAAA,EAAK,MAAA,mBACL,MAAA,WACA,SAAA,GAAY,GAAA,WACX,MAAA;;;KCTS,kBAAA;AAAA,UAEK,yBAAA;EACf,IAAA,EAAM,CAAA;EACN,KAAA,EAAO,eAAA;EACP,MAAA,EAAQ,kBAAA;AAAA;AAAA,iBAGM,mBAAA,GAAA,CACd,OAAA,UACA,SAAA,UACA,KAAA,YACC,yBAAA,CAA0B,CAAA;;;cCThB,oBAAA,EAAmD,KAAA,CAA/B,QAAA,EAAA,GAAA;AAAA,iBAEjB,eAAA,CAAA,IAAoB,GAAA"}
|
package/dist/index.js
CHANGED
|
@@ -31,13 +31,13 @@ function parseSeamData() {
|
|
|
31
31
|
* sentinel array where each leaf in the object template uses `$.` path prefix.
|
|
32
32
|
* Arrays of primitives, empty arrays, and null remain leaf sentinels.
|
|
33
33
|
*/
|
|
34
|
-
function buildSentinelData(obj, prefix = "") {
|
|
34
|
+
function buildSentinelData(obj, prefix = "", htmlPaths) {
|
|
35
35
|
const result = {};
|
|
36
36
|
for (const [key, value] of Object.entries(obj)) {
|
|
37
37
|
const path = prefix ? `${prefix}.${key}` : key;
|
|
38
|
-
if (value !== null && typeof value === "object" && !Array.isArray(value)) result[key] = buildSentinelData(value, path);
|
|
39
|
-
else if (Array.isArray(value) && value.length > 0 && typeof value[0] === "object" && value[0] !== null) result[key] = [buildSentinelData(value[0], `${path}
|
|
40
|
-
else result[key] = `%%SEAM:${path}%%`;
|
|
38
|
+
if (value !== null && typeof value === "object" && !Array.isArray(value)) result[key] = buildSentinelData(value, path, htmlPaths);
|
|
39
|
+
else if (Array.isArray(value) && value.length > 0 && typeof value[0] === "object" && value[0] !== null) result[key] = [buildSentinelData(value[0], `${path}.$`, htmlPaths)];
|
|
40
|
+
else result[key] = `%%SEAM:${path}${htmlPaths?.has(path) ? ":html" : ""}%%`;
|
|
41
41
|
}
|
|
42
42
|
return result;
|
|
43
43
|
}
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../src/define-routes.ts","../src/use-seam-data.ts","../src/sentinel.ts","../src/use-seam-subscription.ts","../src/use-seam-navigate.ts"],"sourcesContent":["/* packages/client/react/src/define-routes.ts */\n\nimport type { RouteDef } from \"./types.js\";\n\nexport function defineRoutes(routes: RouteDef[]): RouteDef[] {\n return routes;\n}\n","/* packages/client/react/src/use-seam-data.ts */\n\nimport { createContext, useContext } from \"react\";\n\nconst SeamDataContext = createContext<unknown>(null);\n\nexport const SeamDataProvider = SeamDataContext.Provider;\n\nexport function useSeamData<T extends object = Record<string, unknown>>(): T {\n const value = useContext(SeamDataContext);\n if (value === null || value === undefined)\n throw new Error(\"useSeamData must be used inside <SeamDataProvider>\");\n return value as T;\n}\n\nexport function parseSeamData(): Record<string, unknown> {\n const el = document.getElementById(\"__SEAM_DATA__\");\n if (!el?.textContent) throw new Error(\"__SEAM_DATA__ not found\");\n return JSON.parse(el.textContent) as Record<string, unknown>;\n}\n","/* packages/client/react/src/sentinel.ts */\n\n/**\n * Replace every leaf value in `obj` with a sentinel string `%%SEAM:dotted.path%%`.\n * Nested objects are recursed; primitives and null become leaf sentinels.\n *\n * Arrays of objects (length > 0, first element is object) produce a 1-element\n * sentinel array where each leaf in the object template uses `$.` path prefix.\n * Arrays of primitives, empty arrays, and null remain leaf sentinels.\n */\nexport function buildSentinelData(\n obj: Record<string, unknown>,\n prefix = \"\",\n): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n const path = prefix ? `${prefix}.${key}` : key;\n if (value !== null && typeof value === \"object\" && !Array.isArray(value)) {\n result[key] = buildSentinelData(value as Record<string, unknown>, path);\n } else if (\n Array.isArray(value) &&\n value.length > 0 &&\n typeof value[0] === \"object\" &&\n value[0] !== null\n ) {\n // Array of objects: produce 1-element sentinel array with $.field paths\n result[key] = [buildSentinelData(value[0] as Record<string, unknown>, `${path}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../src/define-routes.ts","../src/use-seam-data.ts","../src/sentinel.ts","../src/use-seam-subscription.ts","../src/use-seam-navigate.ts"],"sourcesContent":["/* packages/client/react/src/define-routes.ts */\n\nimport type { RouteDef } from \"./types.js\";\n\nexport function defineRoutes(routes: RouteDef[]): RouteDef[] {\n return routes;\n}\n","/* packages/client/react/src/use-seam-data.ts */\n\nimport { createContext, useContext } from \"react\";\n\nconst SeamDataContext = createContext<unknown>(null);\n\nexport const SeamDataProvider = SeamDataContext.Provider;\n\nexport function useSeamData<T extends object = Record<string, unknown>>(): T {\n const value = useContext(SeamDataContext);\n if (value === null || value === undefined)\n throw new Error(\"useSeamData must be used inside <SeamDataProvider>\");\n return value as T;\n}\n\nexport function parseSeamData(): Record<string, unknown> {\n const el = document.getElementById(\"__SEAM_DATA__\");\n if (!el?.textContent) throw new Error(\"__SEAM_DATA__ not found\");\n return JSON.parse(el.textContent) as Record<string, unknown>;\n}\n","/* packages/client/react/src/sentinel.ts */\n\n/**\n * Replace every leaf value in `obj` with a sentinel string `%%SEAM:dotted.path%%`.\n * Nested objects are recursed; primitives and null become leaf sentinels.\n *\n * Arrays of objects (length > 0, first element is object) produce a 1-element\n * sentinel array where each leaf in the object template uses `$.` path prefix.\n * Arrays of primitives, empty arrays, and null remain leaf sentinels.\n */\nexport function buildSentinelData(\n obj: Record<string, unknown>,\n prefix = \"\",\n htmlPaths?: Set<string>,\n): Record<string, unknown> {\n const result: Record<string, unknown> = {};\n for (const [key, value] of Object.entries(obj)) {\n const path = prefix ? `${prefix}.${key}` : key;\n if (value !== null && typeof value === \"object\" && !Array.isArray(value)) {\n result[key] = buildSentinelData(value as Record<string, unknown>, path, htmlPaths);\n } else if (\n Array.isArray(value) &&\n value.length > 0 &&\n typeof value[0] === \"object\" &&\n value[0] !== null\n ) {\n // Array of objects: produce 1-element sentinel array with $.field paths\n result[key] = [\n buildSentinelData(value[0] as Record<string, unknown>, `${path}.$`, htmlPaths),\n ];\n } else {\n const suffix = htmlPaths?.has(path) ? \":html\" : \"\";\n result[key] = `%%SEAM:${path}${suffix}%%`;\n }\n }\n return result;\n}\n","/* packages/client/react/src/use-seam-subscription.ts */\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { SeamClientError } from \"@canmi/seam-client\";\n\nexport type SubscriptionStatus = \"connecting\" | \"active\" | \"error\" | \"closed\";\n\nexport interface UseSeamSubscriptionResult<T> {\n data: T | null;\n error: SeamClientError | null;\n status: SubscriptionStatus;\n}\n\nexport function useSeamSubscription<T>(\n baseUrl: string,\n procedure: string,\n input: unknown,\n): UseSeamSubscriptionResult<T> {\n const [data, setData] = useState<T | null>(null);\n const [error, setError] = useState<SeamClientError | null>(null);\n const [status, setStatus] = useState<SubscriptionStatus>(\"connecting\");\n\n // Serialize input for stable dependency\n const inputKey = JSON.stringify(input);\n const inputRef = useRef(inputKey);\n inputRef.current = inputKey;\n\n useEffect(() => {\n setData(null);\n setError(null);\n setStatus(\"connecting\");\n\n const cleanBase = baseUrl.replace(/\\/+$/, \"\");\n const params = new URLSearchParams({ input: inputKey });\n const url = `${cleanBase}/_seam/subscribe/${procedure}?${params.toString()}`;\n const es = new EventSource(url);\n\n es.addEventListener(\"data\", (e) => {\n try {\n setData(JSON.parse(e.data as string) as T);\n setStatus(\"active\");\n } catch {\n setError(new SeamClientError(\"INTERNAL_ERROR\", \"Failed to parse SSE data\", 0));\n setStatus(\"error\");\n es.close();\n }\n });\n\n es.addEventListener(\"error\", (e) => {\n if (e instanceof MessageEvent) {\n try {\n const payload = JSON.parse(e.data as string) as { code?: string; message?: string };\n setError(\n new SeamClientError(\n \"INTERNAL_ERROR\",\n typeof payload.message === \"string\" ? payload.message : \"SSE error\",\n 0,\n ),\n );\n } catch {\n setError(new SeamClientError(\"INTERNAL_ERROR\", \"SSE error\", 0));\n }\n } else {\n setError(new SeamClientError(\"INTERNAL_ERROR\", \"SSE connection error\", 0));\n }\n setStatus(\"error\");\n es.close();\n });\n\n es.addEventListener(\"complete\", () => {\n setStatus(\"closed\");\n es.close();\n });\n\n return () => {\n es.close();\n };\n }, [baseUrl, procedure, inputKey]);\n\n return { data, error, status };\n}\n","/* packages/client/react/src/use-seam-navigate.ts */\n\nimport { createContext, useContext } from \"react\";\n\nconst SeamNavigateContext = createContext<(url: string) => void>((url) => {\n globalThis.location.href = url;\n});\n\nexport const SeamNavigateProvider = SeamNavigateContext.Provider;\n\nexport function useSeamNavigate(): (url: string) => void {\n return useContext(SeamNavigateContext);\n}\n"],"mappings":";;;;AAIA,SAAgB,aAAa,QAAgC;AAC3D,QAAO;;;;;ACDT,MAAM,kBAAkB,cAAuB,KAAK;AAEpD,MAAa,mBAAmB,gBAAgB;AAEhD,SAAgB,cAA6D;CAC3E,MAAM,QAAQ,WAAW,gBAAgB;AACzC,KAAI,UAAU,QAAQ,UAAU,OAC9B,OAAM,IAAI,MAAM,qDAAqD;AACvE,QAAO;;AAGT,SAAgB,gBAAyC;CACvD,MAAM,KAAK,SAAS,eAAe,gBAAgB;AACnD,KAAI,CAAC,IAAI,YAAa,OAAM,IAAI,MAAM,0BAA0B;AAChE,QAAO,KAAK,MAAM,GAAG,YAAY;;;;;;;;;;;;;ACRnC,SAAgB,kBACd,KACA,SAAS,IACT,WACyB;CACzB,MAAM,SAAkC,EAAE;AAC1C,MAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,IAAI,EAAE;EAC9C,MAAM,OAAO,SAAS,GAAG,OAAO,GAAG,QAAQ;AAC3C,MAAI,UAAU,QAAQ,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,MAAM,CACtE,QAAO,OAAO,kBAAkB,OAAkC,MAAM,UAAU;WAElF,MAAM,QAAQ,MAAM,IACpB,MAAM,SAAS,KACf,OAAO,MAAM,OAAO,YACpB,MAAM,OAAO,KAGb,QAAO,OAAO,CACZ,kBAAkB,MAAM,IAA+B,GAAG,KAAK,KAAK,UAAU,CAC/E;MAGD,QAAO,OAAO,UAAU,OADT,WAAW,IAAI,KAAK,GAAG,UAAU,GACV;;AAG1C,QAAO;;;;;ACtBT,SAAgB,oBACd,SACA,WACA,OAC8B;CAC9B,MAAM,CAAC,MAAM,WAAW,SAAmB,KAAK;CAChD,MAAM,CAAC,OAAO,YAAY,SAAiC,KAAK;CAChE,MAAM,CAAC,QAAQ,aAAa,SAA6B,aAAa;CAGtE,MAAM,WAAW,KAAK,UAAU,MAAM;CACtC,MAAM,WAAW,OAAO,SAAS;AACjC,UAAS,UAAU;AAEnB,iBAAgB;AACd,UAAQ,KAAK;AACb,WAAS,KAAK;AACd,YAAU,aAAa;EAIvB,MAAM,MAAM,GAFM,QAAQ,QAAQ,QAAQ,GAAG,CAEpB,mBAAmB,UAAU,GADvC,IAAI,gBAAgB,EAAE,OAAO,UAAU,CAAC,CACS,UAAU;EAC1E,MAAM,KAAK,IAAI,YAAY,IAAI;AAE/B,KAAG,iBAAiB,SAAS,MAAM;AACjC,OAAI;AACF,YAAQ,KAAK,MAAM,EAAE,KAAe,CAAM;AAC1C,cAAU,SAAS;WACb;AACN,aAAS,IAAI,gBAAgB,kBAAkB,4BAA4B,EAAE,CAAC;AAC9E,cAAU,QAAQ;AAClB,OAAG,OAAO;;IAEZ;AAEF,KAAG,iBAAiB,UAAU,MAAM;AAClC,OAAI,aAAa,aACf,KAAI;IACF,MAAM,UAAU,KAAK,MAAM,EAAE,KAAe;AAC5C,aACE,IAAI,gBACF,kBACA,OAAO,QAAQ,YAAY,WAAW,QAAQ,UAAU,aACxD,EACD,CACF;WACK;AACN,aAAS,IAAI,gBAAgB,kBAAkB,aAAa,EAAE,CAAC;;OAGjE,UAAS,IAAI,gBAAgB,kBAAkB,wBAAwB,EAAE,CAAC;AAE5E,aAAU,QAAQ;AAClB,MAAG,OAAO;IACV;AAEF,KAAG,iBAAiB,kBAAkB;AACpC,aAAU,SAAS;AACnB,MAAG,OAAO;IACV;AAEF,eAAa;AACX,MAAG,OAAO;;IAEX;EAAC;EAAS;EAAW;EAAS,CAAC;AAElC,QAAO;EAAE;EAAM;EAAO;EAAQ;;;;;AC3EhC,MAAM,sBAAsB,eAAsC,QAAQ;AACxE,YAAW,SAAS,OAAO;EAC3B;AAEF,MAAa,uBAAuB,oBAAoB;AAExD,SAAgB,kBAAyC;AACvD,QAAO,WAAW,oBAAoB"}
|
package/package.json
CHANGED
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import { build } from "esbuild";
|
|
4
4
|
import { createElement } from "react";
|
|
5
5
|
import { renderToString } from "react-dom/server";
|
|
6
|
-
import {
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
7
8
|
import { join, dirname, resolve } from "node:path";
|
|
8
9
|
import { fileURLToPath } from "node:url";
|
|
9
10
|
import { SeamDataProvider, buildSentinelData } from "@canmi/seam-react";
|
|
@@ -12,7 +13,14 @@ import {
|
|
|
12
13
|
cartesianProduct,
|
|
13
14
|
buildVariantSentinel,
|
|
14
15
|
} from "./variant-generator.mjs";
|
|
15
|
-
import {
|
|
16
|
+
import {
|
|
17
|
+
generateMockFromSchema,
|
|
18
|
+
flattenLoaderMock,
|
|
19
|
+
deepMerge,
|
|
20
|
+
collectHtmlPaths,
|
|
21
|
+
createAccessTracker,
|
|
22
|
+
checkFieldAccess,
|
|
23
|
+
} from "./mock-generator.mjs";
|
|
16
24
|
|
|
17
25
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
26
|
|
|
@@ -32,6 +40,7 @@ class SeamBuildError extends Error {
|
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
const buildWarnings = [];
|
|
43
|
+
const seenWarnings = new Set();
|
|
35
44
|
|
|
36
45
|
// Matches React-injected resource hint <link> tags.
|
|
37
46
|
// Only rel values used by React's resource APIs are targeted (preload, dns-prefetch, preconnect,
|
|
@@ -121,6 +130,15 @@ function guardedRender(routePath, component, data) {
|
|
|
121
130
|
let html;
|
|
122
131
|
try {
|
|
123
132
|
html = renderWithData(component, data);
|
|
133
|
+
} 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;
|
|
124
142
|
} finally {
|
|
125
143
|
for (const teardown of teardowns) teardown();
|
|
126
144
|
}
|
|
@@ -133,9 +151,13 @@ function guardedRender(routePath, component, data) {
|
|
|
133
151
|
throw new SeamBuildError(msg);
|
|
134
152
|
}
|
|
135
153
|
|
|
136
|
-
// After fatal check, only warnings remain
|
|
154
|
+
// After fatal check, only warnings remain — dedup per message
|
|
137
155
|
for (const v of violations) {
|
|
138
|
-
|
|
156
|
+
const msg = `[seam] warning: ${routePath}\n ${v.reason}`;
|
|
157
|
+
if (!seenWarnings.has(msg)) {
|
|
158
|
+
seenWarnings.add(msg);
|
|
159
|
+
buildWarnings.push(msg);
|
|
160
|
+
}
|
|
139
161
|
}
|
|
140
162
|
if (violations.length > 0) {
|
|
141
163
|
html = stripResourceHints(html);
|
|
@@ -200,8 +222,9 @@ function resolveRouteMock(route, manifest) {
|
|
|
200
222
|
|
|
201
223
|
function renderRoute(route, manifest) {
|
|
202
224
|
const mock = resolveRouteMock(route, manifest);
|
|
203
|
-
const baseSentinel = buildSentinelData(mock);
|
|
204
225
|
const pageSchema = buildPageSchema(route, manifest);
|
|
226
|
+
const htmlPaths = pageSchema ? collectHtmlPaths(pageSchema) : new Set();
|
|
227
|
+
const baseSentinel = buildSentinelData(mock, "", htmlPaths);
|
|
205
228
|
const axes = pageSchema ? collectStructuralAxes(pageSchema, mock) : [];
|
|
206
229
|
const combos = cartesianProduct(axes);
|
|
207
230
|
|
|
@@ -211,8 +234,20 @@ function renderRoute(route, manifest) {
|
|
|
211
234
|
return { variant, html };
|
|
212
235
|
});
|
|
213
236
|
|
|
214
|
-
// Render with real mock data for CTR equivalence check
|
|
215
|
-
|
|
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
|
+
}
|
|
216
251
|
|
|
217
252
|
return {
|
|
218
253
|
path: route.path,
|
|
@@ -276,11 +311,30 @@ function resolveLayoutMock(entry, manifest) {
|
|
|
276
311
|
/** Render layout with seam-outlet placeholder, optionally with sentinel data */
|
|
277
312
|
function renderLayout(LayoutComponent, id, entry, manifest) {
|
|
278
313
|
const mock = resolveLayoutMock(entry, manifest);
|
|
279
|
-
const
|
|
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
|
+
|
|
280
323
|
function LayoutWithOutlet() {
|
|
281
324
|
return createElement(LayoutComponent, null, createElement("seam-outlet", null));
|
|
282
325
|
}
|
|
283
|
-
|
|
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;
|
|
284
338
|
}
|
|
285
339
|
|
|
286
340
|
/** Flatten routes, annotating each leaf with its parent layout id */
|
|
@@ -299,6 +353,157 @@ function flattenRoutes(routes, currentLayout) {
|
|
|
299
353
|
return leaves;
|
|
300
354
|
}
|
|
301
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) {
|
|
433
|
+
try {
|
|
434
|
+
return JSON.parse(readFileSync(join(cacheDir, `${slug}.json`), "utf-8"));
|
|
435
|
+
} catch {
|
|
436
|
+
return null;
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
|
|
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
|
+
|
|
302
507
|
// -- Main --
|
|
303
508
|
|
|
304
509
|
async function main() {
|
|
@@ -312,17 +517,24 @@ async function main() {
|
|
|
312
517
|
|
|
313
518
|
// Load manifest if provided
|
|
314
519
|
let manifest = null;
|
|
520
|
+
let manifestContent = "";
|
|
315
521
|
if (manifestFile && manifestFile !== "none") {
|
|
316
522
|
try {
|
|
317
|
-
|
|
523
|
+
manifestContent = readFileSync(resolve(manifestFile), "utf-8");
|
|
524
|
+
manifest = JSON.parse(manifestContent);
|
|
318
525
|
} catch (e) {
|
|
319
526
|
console.error(`warning: could not read manifest: ${e.message}`);
|
|
320
527
|
}
|
|
321
528
|
}
|
|
322
529
|
|
|
323
530
|
const absRoutes = resolve(routesFile);
|
|
531
|
+
const routesDir = dirname(absRoutes);
|
|
324
532
|
const outfile = join(__dirname, ".tmp-routes-bundle.mjs");
|
|
325
533
|
|
|
534
|
+
// Parse imports from source (before bundle) for component hash resolution
|
|
535
|
+
const routesSource = readFileSync(absRoutes, "utf-8");
|
|
536
|
+
const importMap = parseComponentImports(routesSource);
|
|
537
|
+
|
|
326
538
|
await build({
|
|
327
539
|
entryPoints: [absRoutes],
|
|
328
540
|
bundle: true,
|
|
@@ -340,17 +552,43 @@ async function main() {
|
|
|
340
552
|
}
|
|
341
553
|
|
|
342
554
|
const layoutMap = extractLayouts(routes);
|
|
343
|
-
const layouts = [...layoutMap.entries()].map(([id, entry]) => ({
|
|
344
|
-
id,
|
|
345
|
-
html: renderLayout(entry.component, id, entry, manifest),
|
|
346
|
-
loaders: entry.loaders,
|
|
347
|
-
parent: entry.parentId,
|
|
348
|
-
}));
|
|
349
555
|
const flat = flattenRoutes(routes);
|
|
556
|
+
|
|
557
|
+
// Collect all unique component names for hashing
|
|
558
|
+
const componentNames = new Set();
|
|
559
|
+
for (const [, entry] of layoutMap) {
|
|
560
|
+
if (entry.component?.name) componentNames.add(entry.component.name);
|
|
561
|
+
}
|
|
562
|
+
for (const route of flat) {
|
|
563
|
+
if (route.component?.name) componentNames.add(route.component.name);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const [componentHashes, scriptHash] = await Promise.all([
|
|
567
|
+
computeComponentHashes([...componentNames], importMap, routesDir),
|
|
568
|
+
Promise.resolve(computeScriptHash()),
|
|
569
|
+
]);
|
|
570
|
+
|
|
571
|
+
// Set up cache directory
|
|
572
|
+
const cacheDir = join(process.cwd(), ".seam", "cache", "skeletons");
|
|
573
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
574
|
+
|
|
575
|
+
const ctx = {
|
|
576
|
+
componentHashes,
|
|
577
|
+
scriptHash,
|
|
578
|
+
manifestContent,
|
|
579
|
+
manifest,
|
|
580
|
+
cacheDir,
|
|
581
|
+
stats: { hits: 0, misses: 0 },
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
const layouts = processLayoutsWithCache(layoutMap, ctx);
|
|
585
|
+
const renderedRoutes = processRoutesWithCache(flat, ctx);
|
|
586
|
+
|
|
350
587
|
const output = {
|
|
351
588
|
layouts,
|
|
352
|
-
routes:
|
|
589
|
+
routes: renderedRoutes,
|
|
353
590
|
warnings: buildWarnings,
|
|
591
|
+
cacheStats: ctx.stats,
|
|
354
592
|
};
|
|
355
593
|
process.stdout.write(JSON.stringify(output));
|
|
356
594
|
} finally {
|
|
@@ -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
|
+
}
|