@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 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";
@@ -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;;;iBCjBc,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,YACC,MAAA;;;KCRS,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"}
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}.$`)];\n } else {\n result[key] = `%%SEAM:${path}%%`;\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,IACgB;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,KAAK;WAEvE,MAAM,QAAQ,MAAM,IACpB,MAAM,SAAS,KACf,OAAO,MAAM,OAAO,YACpB,MAAM,OAAO,KAGb,QAAO,OAAO,CAAC,kBAAkB,MAAM,IAA+B,GAAG,KAAK,IAAI,CAAC;MAEnF,QAAO,OAAO,UAAU,KAAK;;AAGjC,QAAO;;;;;AClBT,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"}
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@canmi/seam-react",
3
- "version": "0.2.3",
3
+ "version": "0.2.14",
4
4
  "files": [
5
5
  "dist",
6
6
  "scripts"
@@ -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 { readFileSync, unlinkSync } from "node:fs";
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 { generateMockFromSchema, flattenLoaderMock, deepMerge } from "./mock-generator.mjs";
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
- buildWarnings.push(`[seam] warning: ${routePath}\n ${v.reason}`);
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
- const mockHtml = stripResourceHints(guardedRender(route.path, route.component, mock));
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 data = Object.keys(mock).length > 0 ? buildSentinelData(mock) : {};
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
- return guardedRender(`layout:${id}`, LayoutWithOutlet, data);
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
- manifest = JSON.parse(readFileSync(resolve(manifestFile), "utf-8"));
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: flat.map((r) => renderRoute(r, manifest)),
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
+ }