@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.
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -7
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/scripts/build-skeletons.mjs +89 -306
- package/scripts/mock-generator.mjs +309 -0
- package/scripts/skeleton/cache.mjs +135 -0
- package/scripts/skeleton/layout.mjs +109 -0
- package/scripts/skeleton/process.mjs +184 -0
- package/scripts/skeleton/render.mjs +160 -0
- package/scripts/skeleton/schema.mjs +120 -0
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
|
|
@@ -29,7 +30,7 @@ declare function defineRoutes(routes: RouteDef[]): RouteDef[];
|
|
|
29
30
|
//#region src/use-seam-data.d.ts
|
|
30
31
|
declare const SeamDataProvider: react.Provider<unknown>;
|
|
31
32
|
declare function useSeamData<T extends object = Record<string, unknown>>(): T;
|
|
32
|
-
declare function parseSeamData(): Record<string, unknown>;
|
|
33
|
+
declare function parseSeamData(dataId?: string): Record<string, unknown>;
|
|
33
34
|
//#endregion
|
|
34
35
|
//#region src/sentinel.d.ts
|
|
35
36
|
/**
|
|
@@ -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,CAAc,MAAA,YAA2B,MAAA;;;;;;;;AFXzD;;;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
|
@@ -15,9 +15,9 @@ function useSeamData() {
|
|
|
15
15
|
if (value === null || value === void 0) throw new Error("useSeamData must be used inside <SeamDataProvider>");
|
|
16
16
|
return value;
|
|
17
17
|
}
|
|
18
|
-
function parseSeamData() {
|
|
19
|
-
const el = document.getElementById(
|
|
20
|
-
if (!el?.textContent) throw new Error(
|
|
18
|
+
function parseSeamData(dataId = "__SEAM_DATA__") {
|
|
19
|
+
const el = document.getElementById(dataId);
|
|
20
|
+
if (!el?.textContent) throw new Error(`${dataId} not found`);
|
|
21
21
|
return JSON.parse(el.textContent);
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -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(
|
|
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(dataId = \"__SEAM_DATA__\"): Record<string, unknown> {\n const el = document.getElementById(dataId);\n if (!el?.textContent) throw new Error(`${dataId} 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,cAAc,SAAS,iBAA0C;CAC/E,MAAM,KAAK,SAAS,eAAe,OAAO;AAC1C,KAAI,CAAC,IAAI,YAAa,OAAM,IAAI,MAAM,GAAG,OAAO,YAAY;AAC5D,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.
|
|
3
|
+
"version": "0.3.7",
|
|
4
4
|
"files": [
|
|
5
5
|
"dist",
|
|
6
6
|
"scripts"
|
|
@@ -18,10 +18,11 @@
|
|
|
18
18
|
},
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"@canmi/seam-client": "workspace:*",
|
|
21
|
+
"@canmi/seam-i18n": "workspace:*",
|
|
21
22
|
"esbuild": "^0.25.0"
|
|
22
23
|
},
|
|
23
24
|
"devDependencies": {
|
|
24
|
-
"@canmi/seam-
|
|
25
|
+
"@canmi/seam-engine": "workspace:*",
|
|
25
26
|
"@types/react": "^19.0.0",
|
|
26
27
|
"@types/react-dom": "^19.0.0",
|
|
27
28
|
"jsdom": "^28.1.0",
|
|
@@ -1,335 +1,73 @@
|
|
|
1
1
|
/* packages/client/react/scripts/build-skeletons.mjs */
|
|
2
2
|
|
|
3
3
|
import { build } from "esbuild";
|
|
4
|
-
import {
|
|
5
|
-
import { renderToString } from "react-dom/server";
|
|
6
|
-
import { readFileSync, unlinkSync } from "node:fs";
|
|
4
|
+
import { readFileSync, mkdirSync, unlinkSync } from "node:fs";
|
|
7
5
|
import { join, dirname, resolve } from "node:path";
|
|
8
6
|
import { fileURLToPath } from "node:url";
|
|
9
|
-
|
|
7
|
+
|
|
8
|
+
import { SeamBuildError } from "./skeleton/render.mjs";
|
|
9
|
+
import { extractLayouts, flattenRoutes } from "./skeleton/layout.mjs";
|
|
10
10
|
import {
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
} from "./
|
|
15
|
-
import {
|
|
11
|
+
parseComponentImports,
|
|
12
|
+
computeComponentHashes,
|
|
13
|
+
computeScriptHash,
|
|
14
|
+
} from "./skeleton/cache.mjs";
|
|
15
|
+
import { processLayoutsWithCache, processRoutesWithCache } from "./skeleton/process.mjs";
|
|
16
16
|
|
|
17
17
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
function renderWithData(component, data) {
|
|
22
|
-
return renderToString(createElement(SeamDataProvider, { value: data }, createElement(component)));
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
// -- Render guards --
|
|
26
|
-
|
|
27
|
-
class SeamBuildError extends Error {
|
|
28
|
-
constructor(message) {
|
|
29
|
-
super(message);
|
|
30
|
-
this.name = "SeamBuildError";
|
|
31
|
-
}
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
const buildWarnings = [];
|
|
35
|
-
|
|
36
|
-
// Matches React-injected resource hint <link> tags.
|
|
37
|
-
// Only rel values used by React's resource APIs are targeted (preload, dns-prefetch, preconnect,
|
|
38
|
-
// data-precedence); user-authored <link> tags (canonical, alternate, stylesheet) are unaffected.
|
|
39
|
-
const RESOURCE_HINT_RE =
|
|
40
|
-
/<link[^>]+rel\s*=\s*"(?:preload|dns-prefetch|preconnect)"[^>]*>|<link[^>]+data-precedence[^>]*>/gi;
|
|
41
|
-
|
|
42
|
-
function installRenderTraps(violations, teardowns) {
|
|
43
|
-
function trapCall(obj, prop, label) {
|
|
44
|
-
const orig = obj[prop];
|
|
45
|
-
obj[prop] = function () {
|
|
46
|
-
violations.push({ severity: "error", reason: `${label} called during skeleton render` });
|
|
47
|
-
throw new SeamBuildError(`${label} is not allowed in skeleton components`);
|
|
48
|
-
};
|
|
49
|
-
teardowns.push(() => {
|
|
50
|
-
obj[prop] = orig;
|
|
51
|
-
});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
trapCall(globalThis, "fetch", "fetch()");
|
|
55
|
-
trapCall(Math, "random", "Math.random()");
|
|
56
|
-
trapCall(Date, "now", "Date.now()");
|
|
57
|
-
if (globalThis.crypto?.randomUUID) {
|
|
58
|
-
trapCall(globalThis.crypto, "randomUUID", "crypto.randomUUID()");
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
// Timer APIs — these don't affect renderToString output, but pending handles
|
|
62
|
-
// prevent the build process from exiting (Node keeps the event loop alive).
|
|
63
|
-
trapCall(globalThis, "setTimeout", "setTimeout()");
|
|
64
|
-
trapCall(globalThis, "setInterval", "setInterval()");
|
|
65
|
-
if (globalThis.setImmediate) {
|
|
66
|
-
trapCall(globalThis, "setImmediate", "setImmediate()");
|
|
67
|
-
}
|
|
68
|
-
trapCall(globalThis, "queueMicrotask", "queueMicrotask()");
|
|
69
|
-
|
|
70
|
-
// Trap browser globals (only if not already defined — these are undefined in Node;
|
|
71
|
-
// typeof checks bypass getters, so `typeof window !== 'undefined'` remains safe)
|
|
72
|
-
for (const name of ["window", "document", "localStorage"]) {
|
|
73
|
-
if (!(name in globalThis)) {
|
|
74
|
-
Object.defineProperty(globalThis, name, {
|
|
75
|
-
get() {
|
|
76
|
-
violations.push({ severity: "error", reason: `${name} accessed during skeleton render` });
|
|
77
|
-
throw new SeamBuildError(`${name} is not available in skeleton components`);
|
|
78
|
-
},
|
|
79
|
-
configurable: true,
|
|
80
|
-
});
|
|
81
|
-
teardowns.push(() => {
|
|
82
|
-
delete globalThis[name];
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function validateOutput(html, violations) {
|
|
89
|
-
if (html.includes("<!--$!-->")) {
|
|
90
|
-
violations.push({
|
|
91
|
-
severity: "error",
|
|
92
|
-
reason:
|
|
93
|
-
"Suspense abort detected \u2014 a component used an unresolved async resource\n" +
|
|
94
|
-
" (e.g. use(promise)) inside a <Suspense> boundary, producing an incomplete\n" +
|
|
95
|
-
" template with fallback content baked in.\n" +
|
|
96
|
-
" Fix: remove use() from skeleton components. Async data belongs in loaders.",
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const hints = Array.from(html.matchAll(RESOURCE_HINT_RE));
|
|
101
|
-
if (hints.length > 0) {
|
|
102
|
-
violations.push({
|
|
103
|
-
severity: "warning",
|
|
104
|
-
reason:
|
|
105
|
-
`stripped ${hints.length} resource hint <link> tag(s) injected by React's preload()/preinit().\n` +
|
|
106
|
-
" These are not data-driven and would cause hydration mismatch.",
|
|
107
|
-
});
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
function stripResourceHints(html) {
|
|
112
|
-
return html.replace(RESOURCE_HINT_RE, "");
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function guardedRender(routePath, component, data) {
|
|
116
|
-
const violations = [];
|
|
117
|
-
const teardowns = [];
|
|
118
|
-
|
|
119
|
-
installRenderTraps(violations, teardowns);
|
|
120
|
-
|
|
121
|
-
let html;
|
|
19
|
+
function loadManifest(manifestFile) {
|
|
20
|
+
if (!manifestFile || manifestFile === "none") return { manifest: null, manifestContent: "" };
|
|
122
21
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
22
|
+
const content = readFileSync(resolve(manifestFile), "utf-8");
|
|
23
|
+
return { manifest: JSON.parse(content), manifestContent: content };
|
|
24
|
+
} catch (e) {
|
|
25
|
+
console.error(`warning: could not read manifest: ${e.message}`);
|
|
26
|
+
return { manifest: null, manifestContent: "" };
|
|
126
27
|
}
|
|
127
|
-
|
|
128
|
-
validateOutput(html, violations);
|
|
129
|
-
|
|
130
|
-
const fatal = violations.filter((v) => v.severity === "error");
|
|
131
|
-
if (fatal.length > 0) {
|
|
132
|
-
const msg = fatal.map((v) => `[seam] error: ${routePath}\n ${v.reason}`).join("\n\n");
|
|
133
|
-
throw new SeamBuildError(msg);
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
// After fatal check, only warnings remain
|
|
137
|
-
for (const v of violations) {
|
|
138
|
-
buildWarnings.push(`[seam] warning: ${routePath}\n ${v.reason}`);
|
|
139
|
-
}
|
|
140
|
-
if (violations.length > 0) {
|
|
141
|
-
html = stripResourceHints(html);
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
return html;
|
|
145
28
|
}
|
|
146
29
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
const properties = {};
|
|
155
|
-
|
|
156
|
-
for (const [loaderKey, loaderDef] of Object.entries(route.loaders || {})) {
|
|
157
|
-
const procName = loaderDef.procedure;
|
|
158
|
-
const proc = manifest.procedures?.[procName];
|
|
159
|
-
if (!proc?.output) continue;
|
|
160
|
-
|
|
161
|
-
// Always nest under the loader key so axis paths (e.g. "user.bio")
|
|
162
|
-
// align with sentinel data paths built from mock (e.g. sentinel.user.bio).
|
|
163
|
-
properties[loaderKey] = proc.output;
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
const result = {};
|
|
167
|
-
if (Object.keys(properties).length > 0) result.properties = properties;
|
|
168
|
-
return Object.keys(result).length > 0 ? result : null;
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
/**
|
|
172
|
-
* Resolve mock data for a route: auto-generate from schema when available,
|
|
173
|
-
* then deep-merge any user-provided partial mock on top.
|
|
174
|
-
*/
|
|
175
|
-
function resolveRouteMock(route, manifest) {
|
|
176
|
-
const pageSchema = buildPageSchema(route, manifest);
|
|
177
|
-
|
|
178
|
-
if (pageSchema) {
|
|
179
|
-
const keyedMock = generateMockFromSchema(pageSchema);
|
|
180
|
-
const autoMock = flattenLoaderMock(keyedMock);
|
|
181
|
-
return route.mock ? deepMerge(autoMock, route.mock) : autoMock;
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
// No manifest (frontend-only mode) — mock is required
|
|
185
|
-
if (route.mock) return route.mock;
|
|
186
|
-
|
|
187
|
-
throw new SeamBuildError(
|
|
188
|
-
`[seam] error: Mock data required for route "${route.path}"\n\n` +
|
|
189
|
-
" No procedure manifest found \u2014 cannot auto-generate mock data.\n" +
|
|
190
|
-
" Provide mock data in your route definition:\n\n" +
|
|
191
|
-
" defineRoutes([{\n" +
|
|
192
|
-
` path: "${route.path}",\n` +
|
|
193
|
-
" component: YourComponent,\n" +
|
|
194
|
-
' mock: { user: { name: "..." }, repos: [...] }\n' +
|
|
195
|
-
" }])\n\n" +
|
|
196
|
-
" Or switch to fullstack mode with typed Procedures\n" +
|
|
197
|
-
" to enable automatic mock generation from schema.",
|
|
198
|
-
);
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
function renderRoute(route, manifest) {
|
|
202
|
-
const mock = resolveRouteMock(route, manifest);
|
|
203
|
-
const baseSentinel = buildSentinelData(mock);
|
|
204
|
-
const pageSchema = buildPageSchema(route, manifest);
|
|
205
|
-
const axes = pageSchema ? collectStructuralAxes(pageSchema, mock) : [];
|
|
206
|
-
const combos = cartesianProduct(axes);
|
|
207
|
-
|
|
208
|
-
const variants = combos.map((variant) => {
|
|
209
|
-
const sentinel = buildVariantSentinel(baseSentinel, mock, variant);
|
|
210
|
-
const html = guardedRender(route.path, route.component, sentinel);
|
|
211
|
-
return { variant, html };
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
// Render with real mock data for CTR equivalence check
|
|
215
|
-
const mockHtml = stripResourceHints(guardedRender(route.path, route.component, mock));
|
|
216
|
-
|
|
217
|
-
return {
|
|
218
|
-
path: route.path,
|
|
219
|
-
loaders: route.loaders,
|
|
220
|
-
layout: route._layoutId || undefined,
|
|
221
|
-
axes,
|
|
222
|
-
variants,
|
|
223
|
-
mockHtml,
|
|
224
|
-
mock,
|
|
225
|
-
pageSchema,
|
|
226
|
-
};
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
// -- Layout helpers --
|
|
230
|
-
|
|
231
|
-
function toLayoutId(path) {
|
|
232
|
-
return path === "/"
|
|
233
|
-
? "_layout_root"
|
|
234
|
-
: `_layout_${path.replace(/^\/|\/$/g, "").replace(/\//g, "-")}`;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/** Extract layout components and metadata from route tree */
|
|
238
|
-
function extractLayouts(routes) {
|
|
239
|
-
const seen = new Map();
|
|
240
|
-
(function walk(defs, parentId) {
|
|
241
|
-
for (const def of defs) {
|
|
242
|
-
if (def.layout && def.children) {
|
|
243
|
-
const id = toLayoutId(def.path);
|
|
244
|
-
if (!seen.has(id)) {
|
|
245
|
-
seen.set(id, {
|
|
246
|
-
component: def.layout,
|
|
247
|
-
loaders: def.loaders || {},
|
|
248
|
-
mock: def.mock || null,
|
|
249
|
-
parentId: parentId || null,
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
walk(def.children, id);
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
})(routes, null);
|
|
256
|
-
return seen;
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/**
|
|
260
|
-
* Resolve mock data for a layout: auto-generate from schema when loaders exist,
|
|
261
|
-
* then deep-merge any user-provided partial mock on top.
|
|
262
|
-
* Unlike resolveRouteMock, a layout with no loaders and no mock is valid (empty shell).
|
|
263
|
-
*/
|
|
264
|
-
function resolveLayoutMock(entry, manifest) {
|
|
265
|
-
if (Object.keys(entry.loaders).length > 0) {
|
|
266
|
-
const schema = buildPageSchema(entry, manifest);
|
|
267
|
-
if (schema) {
|
|
268
|
-
const keyedMock = generateMockFromSchema(schema);
|
|
269
|
-
const autoMock = flattenLoaderMock(keyedMock);
|
|
270
|
-
return entry.mock ? deepMerge(autoMock, entry.mock) : autoMock;
|
|
271
|
-
}
|
|
272
|
-
}
|
|
273
|
-
return entry.mock || {};
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
/** Render layout with seam-outlet placeholder, optionally with sentinel data */
|
|
277
|
-
function renderLayout(LayoutComponent, id, entry, manifest) {
|
|
278
|
-
const mock = resolveLayoutMock(entry, manifest);
|
|
279
|
-
const data = Object.keys(mock).length > 0 ? buildSentinelData(mock) : {};
|
|
280
|
-
function LayoutWithOutlet() {
|
|
281
|
-
return createElement(LayoutComponent, null, createElement("seam-outlet", null));
|
|
282
|
-
}
|
|
283
|
-
return guardedRender(`layout:${id}`, LayoutWithOutlet, data);
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
/** Flatten routes, annotating each leaf with its parent layout id */
|
|
287
|
-
function flattenRoutes(routes, currentLayout) {
|
|
288
|
-
const leaves = [];
|
|
289
|
-
for (const route of routes) {
|
|
290
|
-
if (route.layout && route.children) {
|
|
291
|
-
leaves.push(...flattenRoutes(route.children, toLayoutId(route.path)));
|
|
292
|
-
} else if (route.children) {
|
|
293
|
-
leaves.push(...flattenRoutes(route.children, currentLayout));
|
|
294
|
-
} else {
|
|
295
|
-
if (currentLayout) route._layoutId = currentLayout;
|
|
296
|
-
leaves.push(route);
|
|
297
|
-
}
|
|
30
|
+
function loadI18nConfig(i18nArg) {
|
|
31
|
+
if (!i18nArg || i18nArg === "none") return null;
|
|
32
|
+
try {
|
|
33
|
+
return JSON.parse(i18nArg);
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error(`warning: could not parse i18n config: ${e.message}`);
|
|
36
|
+
return null;
|
|
298
37
|
}
|
|
299
|
-
return leaves;
|
|
300
38
|
}
|
|
301
39
|
|
|
302
|
-
// -- Main --
|
|
303
|
-
|
|
304
40
|
async function main() {
|
|
305
41
|
const routesFile = process.argv[2];
|
|
306
|
-
const manifestFile = process.argv[3];
|
|
307
|
-
|
|
308
42
|
if (!routesFile) {
|
|
309
|
-
console.error("Usage: node build-skeletons.mjs <routes-file> [manifest-file]");
|
|
43
|
+
console.error("Usage: node build-skeletons.mjs <routes-file> [manifest-file] [i18n-json]");
|
|
310
44
|
process.exit(1);
|
|
311
45
|
}
|
|
312
46
|
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
}
|
|
47
|
+
const { manifest, manifestContent } = loadManifest(process.argv[3]);
|
|
48
|
+
const i18n = loadI18nConfig(process.argv[4]);
|
|
49
|
+
|
|
50
|
+
if (i18n) {
|
|
51
|
+
const { setI18nProvider } = await import("./skeleton/render.mjs");
|
|
52
|
+
const { I18nProvider } = await import("@canmi/seam-i18n/react");
|
|
53
|
+
setI18nProvider(I18nProvider);
|
|
321
54
|
}
|
|
322
55
|
|
|
323
56
|
const absRoutes = resolve(routesFile);
|
|
57
|
+
const routesDir = dirname(absRoutes);
|
|
324
58
|
const outfile = join(__dirname, ".tmp-routes-bundle.mjs");
|
|
325
59
|
|
|
60
|
+
// Parse imports from source (before bundle) for component hash resolution
|
|
61
|
+
const routesSource = readFileSync(absRoutes, "utf-8");
|
|
62
|
+
const importMap = parseComponentImports(routesSource);
|
|
63
|
+
|
|
326
64
|
await build({
|
|
327
65
|
entryPoints: [absRoutes],
|
|
328
66
|
bundle: true,
|
|
329
67
|
format: "esm",
|
|
330
68
|
platform: "node",
|
|
331
69
|
outfile,
|
|
332
|
-
external: ["react", "react-dom", "@canmi/seam-react"],
|
|
70
|
+
external: ["react", "react-dom", "@canmi/seam-react", "@canmi/seam-i18n"],
|
|
333
71
|
});
|
|
334
72
|
|
|
335
73
|
try {
|
|
@@ -340,17 +78,62 @@ async function main() {
|
|
|
340
78
|
}
|
|
341
79
|
|
|
342
80
|
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
81
|
const flat = flattenRoutes(routes);
|
|
82
|
+
|
|
83
|
+
// Collect all unique component names for hashing
|
|
84
|
+
const componentNames = new Set();
|
|
85
|
+
for (const [, entry] of layoutMap) {
|
|
86
|
+
if (entry.component?.name) componentNames.add(entry.component.name);
|
|
87
|
+
}
|
|
88
|
+
for (const route of flat) {
|
|
89
|
+
if (route.component?.name) componentNames.add(route.component.name);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Files to hash for script-level cache invalidation
|
|
93
|
+
const skeletonDir = join(__dirname, "skeleton");
|
|
94
|
+
const scriptFiles = [
|
|
95
|
+
join(skeletonDir, "render.mjs"),
|
|
96
|
+
join(skeletonDir, "schema.mjs"),
|
|
97
|
+
join(skeletonDir, "layout.mjs"),
|
|
98
|
+
join(skeletonDir, "cache.mjs"),
|
|
99
|
+
join(skeletonDir, "process.mjs"),
|
|
100
|
+
join(__dirname, "variant-generator.mjs"),
|
|
101
|
+
join(__dirname, "mock-generator.mjs"),
|
|
102
|
+
];
|
|
103
|
+
|
|
104
|
+
const [componentHashes, scriptHash] = await Promise.all([
|
|
105
|
+
computeComponentHashes([...componentNames], importMap, routesDir),
|
|
106
|
+
Promise.resolve(computeScriptHash(scriptFiles)),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
// Set up cache directory
|
|
110
|
+
const cacheDir = join(process.cwd(), ".seam", "cache", "skeletons");
|
|
111
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
112
|
+
|
|
113
|
+
// Shared warning state passed through to all render functions
|
|
114
|
+
const buildWarnings = [];
|
|
115
|
+
const seenWarnings = new Set();
|
|
116
|
+
const warnCtx = { buildWarnings, seenWarnings };
|
|
117
|
+
|
|
118
|
+
const ctx = {
|
|
119
|
+
componentHashes,
|
|
120
|
+
scriptHash,
|
|
121
|
+
manifestContent,
|
|
122
|
+
manifest,
|
|
123
|
+
cacheDir,
|
|
124
|
+
i18n,
|
|
125
|
+
warnCtx,
|
|
126
|
+
stats: { hits: 0, misses: 0 },
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
const layouts = processLayoutsWithCache(layoutMap, ctx);
|
|
130
|
+
const renderedRoutes = processRoutesWithCache(flat, ctx);
|
|
131
|
+
|
|
350
132
|
const output = {
|
|
351
133
|
layouts,
|
|
352
|
-
routes:
|
|
134
|
+
routes: renderedRoutes,
|
|
353
135
|
warnings: buildWarnings,
|
|
136
|
+
cacheStats: ctx.stats,
|
|
354
137
|
};
|
|
355
138
|
process.stdout.write(JSON.stringify(output));
|
|
356
139
|
} finally {
|