@canmi/seam-react 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -0
- package/dist/index.d.ts +59 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +112 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -0
- package/scripts/build-skeletons.mjs +370 -0
- package/scripts/mock-generator.mjs +165 -0
- package/scripts/variant-generator.mjs +142 -0
package/README.md
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# @canmi/seam-react
|
|
2
|
+
|
|
3
|
+
React bindings for SeamJS, providing hooks and components to consume server-injected data and real-time subscriptions.
|
|
4
|
+
|
|
5
|
+
## Key Exports
|
|
6
|
+
|
|
7
|
+
| Export | Purpose |
|
|
8
|
+
| --------------------- | ------------------------------------------------------------- |
|
|
9
|
+
| `defineRoutes` | Define client-side route configuration |
|
|
10
|
+
| `useSeamData` | Access server-injected data from `SeamDataProvider` context |
|
|
11
|
+
| `SeamDataProvider` | Context provider for server data |
|
|
12
|
+
| `parseSeamData` | Parse JSON from `<script id="__SEAM_DATA__">` |
|
|
13
|
+
| `buildSentinelData` | Build sentinel data for skeleton rendering |
|
|
14
|
+
| `useSeamSubscription` | Hook for SSE subscriptions, returns `{ data, error, status }` |
|
|
15
|
+
|
|
16
|
+
## Structure
|
|
17
|
+
|
|
18
|
+
- `src/index.ts` — Public API exports
|
|
19
|
+
- `src/use-seam-data.ts` — Data provider and hooks
|
|
20
|
+
- `src/use-seam-subscription.ts` — SSE subscription hook
|
|
21
|
+
- `src/define-routes.ts` — Route definition utilities
|
|
22
|
+
- `src/sentinel.ts` — Sentinel data builder for skeleton rendering
|
|
23
|
+
- `scripts/` — Build-time scripts
|
|
24
|
+
|
|
25
|
+
## Development
|
|
26
|
+
|
|
27
|
+
- Build: `bun run --filter '@canmi/seam-react' build`
|
|
28
|
+
- Test: `bun run --filter '@canmi/seam-react' test`
|
|
29
|
+
|
|
30
|
+
## Notes
|
|
31
|
+
|
|
32
|
+
- Peer dependencies: `react` ^18 || ^19, `react-dom` ^18 || ^19
|
|
33
|
+
- Depends on `@canmi/seam-client` for underlying RPC and subscription logic
|
|
34
|
+
- `parseSeamData()` reads from a `<script>` tag injected by the server during HTML rendering
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as react from "react";
|
|
2
|
+
import { ComponentType, ReactNode } from "react";
|
|
3
|
+
import { SeamClientError } from "@canmi/seam-client";
|
|
4
|
+
|
|
5
|
+
//#region src/types.d.ts
|
|
6
|
+
interface ParamMapping {
|
|
7
|
+
from: "route";
|
|
8
|
+
type?: "string" | "int";
|
|
9
|
+
}
|
|
10
|
+
interface LoaderDef {
|
|
11
|
+
procedure: string;
|
|
12
|
+
params?: Record<string, ParamMapping>;
|
|
13
|
+
}
|
|
14
|
+
interface RouteDef {
|
|
15
|
+
path: string;
|
|
16
|
+
component?: ComponentType<Record<string, unknown>>;
|
|
17
|
+
layout?: ComponentType<{
|
|
18
|
+
children: ReactNode;
|
|
19
|
+
}>;
|
|
20
|
+
children?: RouteDef[];
|
|
21
|
+
loaders?: Record<string, LoaderDef>;
|
|
22
|
+
mock?: Record<string, unknown>;
|
|
23
|
+
nullable?: string[];
|
|
24
|
+
}
|
|
25
|
+
//#endregion
|
|
26
|
+
//#region src/define-routes.d.ts
|
|
27
|
+
declare function defineRoutes(routes: RouteDef[]): RouteDef[];
|
|
28
|
+
//#endregion
|
|
29
|
+
//#region src/use-seam-data.d.ts
|
|
30
|
+
declare const SeamDataProvider: react.Provider<unknown>;
|
|
31
|
+
declare function useSeamData<T extends object = Record<string, unknown>>(): T;
|
|
32
|
+
declare function parseSeamData(): Record<string, unknown>;
|
|
33
|
+
//#endregion
|
|
34
|
+
//#region src/sentinel.d.ts
|
|
35
|
+
/**
|
|
36
|
+
* Replace every leaf value in `obj` with a sentinel string `%%SEAM:dotted.path%%`.
|
|
37
|
+
* Nested objects are recursed; primitives and null become leaf sentinels.
|
|
38
|
+
*
|
|
39
|
+
* Arrays of objects (length > 0, first element is object) produce a 1-element
|
|
40
|
+
* sentinel array where each leaf in the object template uses `$.` path prefix.
|
|
41
|
+
* Arrays of primitives, empty arrays, and null remain leaf sentinels.
|
|
42
|
+
*/
|
|
43
|
+
declare function buildSentinelData(obj: Record<string, unknown>, prefix?: string): Record<string, unknown>;
|
|
44
|
+
//#endregion
|
|
45
|
+
//#region src/use-seam-subscription.d.ts
|
|
46
|
+
type SubscriptionStatus = "connecting" | "active" | "error" | "closed";
|
|
47
|
+
interface UseSeamSubscriptionResult<T> {
|
|
48
|
+
data: T | null;
|
|
49
|
+
error: SeamClientError | null;
|
|
50
|
+
status: SubscriptionStatus;
|
|
51
|
+
}
|
|
52
|
+
declare function useSeamSubscription<T>(baseUrl: string, procedure: string, input: unknown): UseSeamSubscriptionResult<T>;
|
|
53
|
+
//#endregion
|
|
54
|
+
//#region src/use-seam-navigate.d.ts
|
|
55
|
+
declare const SeamNavigateProvider: react.Provider<(url: string) => void>;
|
|
56
|
+
declare function useSeamNavigate(): (url: string) => void;
|
|
57
|
+
//#endregion
|
|
58
|
+
export { type LoaderDef, type ParamMapping, type RouteDef, SeamDataProvider, SeamNavigateProvider, type SubscriptionStatus, type UseSeamSubscriptionResult, buildSentinelData, defineRoutes, parseSeamData, useSeamData, useSeamNavigate, useSeamSubscription };
|
|
59
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +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"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { SeamClientError } from "@canmi/seam-client";
|
|
3
|
+
|
|
4
|
+
//#region src/define-routes.ts
|
|
5
|
+
function defineRoutes(routes) {
|
|
6
|
+
return routes;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
//#endregion
|
|
10
|
+
//#region src/use-seam-data.ts
|
|
11
|
+
const SeamDataContext = createContext(null);
|
|
12
|
+
const SeamDataProvider = SeamDataContext.Provider;
|
|
13
|
+
function useSeamData() {
|
|
14
|
+
const value = useContext(SeamDataContext);
|
|
15
|
+
if (value === null || value === void 0) throw new Error("useSeamData must be used inside <SeamDataProvider>");
|
|
16
|
+
return value;
|
|
17
|
+
}
|
|
18
|
+
function parseSeamData() {
|
|
19
|
+
const el = document.getElementById("__SEAM_DATA__");
|
|
20
|
+
if (!el?.textContent) throw new Error("__SEAM_DATA__ not found");
|
|
21
|
+
return JSON.parse(el.textContent);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
//#region src/sentinel.ts
|
|
26
|
+
/**
|
|
27
|
+
* Replace every leaf value in `obj` with a sentinel string `%%SEAM:dotted.path%%`.
|
|
28
|
+
* Nested objects are recursed; primitives and null become leaf sentinels.
|
|
29
|
+
*
|
|
30
|
+
* Arrays of objects (length > 0, first element is object) produce a 1-element
|
|
31
|
+
* sentinel array where each leaf in the object template uses `$.` path prefix.
|
|
32
|
+
* Arrays of primitives, empty arrays, and null remain leaf sentinels.
|
|
33
|
+
*/
|
|
34
|
+
function buildSentinelData(obj, prefix = "") {
|
|
35
|
+
const result = {};
|
|
36
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
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}%%`;
|
|
41
|
+
}
|
|
42
|
+
return result;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/use-seam-subscription.ts
|
|
47
|
+
function useSeamSubscription(baseUrl, procedure, input) {
|
|
48
|
+
const [data, setData] = useState(null);
|
|
49
|
+
const [error, setError] = useState(null);
|
|
50
|
+
const [status, setStatus] = useState("connecting");
|
|
51
|
+
const inputKey = JSON.stringify(input);
|
|
52
|
+
const inputRef = useRef(inputKey);
|
|
53
|
+
inputRef.current = inputKey;
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
setData(null);
|
|
56
|
+
setError(null);
|
|
57
|
+
setStatus("connecting");
|
|
58
|
+
const url = `${baseUrl.replace(/\/+$/, "")}/_seam/subscribe/${procedure}?${new URLSearchParams({ input: inputKey }).toString()}`;
|
|
59
|
+
const es = new EventSource(url);
|
|
60
|
+
es.addEventListener("data", (e) => {
|
|
61
|
+
try {
|
|
62
|
+
setData(JSON.parse(e.data));
|
|
63
|
+
setStatus("active");
|
|
64
|
+
} catch {
|
|
65
|
+
setError(new SeamClientError("INTERNAL_ERROR", "Failed to parse SSE data", 0));
|
|
66
|
+
setStatus("error");
|
|
67
|
+
es.close();
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
es.addEventListener("error", (e) => {
|
|
71
|
+
if (e instanceof MessageEvent) try {
|
|
72
|
+
const payload = JSON.parse(e.data);
|
|
73
|
+
setError(new SeamClientError("INTERNAL_ERROR", typeof payload.message === "string" ? payload.message : "SSE error", 0));
|
|
74
|
+
} catch {
|
|
75
|
+
setError(new SeamClientError("INTERNAL_ERROR", "SSE error", 0));
|
|
76
|
+
}
|
|
77
|
+
else setError(new SeamClientError("INTERNAL_ERROR", "SSE connection error", 0));
|
|
78
|
+
setStatus("error");
|
|
79
|
+
es.close();
|
|
80
|
+
});
|
|
81
|
+
es.addEventListener("complete", () => {
|
|
82
|
+
setStatus("closed");
|
|
83
|
+
es.close();
|
|
84
|
+
});
|
|
85
|
+
return () => {
|
|
86
|
+
es.close();
|
|
87
|
+
};
|
|
88
|
+
}, [
|
|
89
|
+
baseUrl,
|
|
90
|
+
procedure,
|
|
91
|
+
inputKey
|
|
92
|
+
]);
|
|
93
|
+
return {
|
|
94
|
+
data,
|
|
95
|
+
error,
|
|
96
|
+
status
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
//#endregion
|
|
101
|
+
//#region src/use-seam-navigate.ts
|
|
102
|
+
const SeamNavigateContext = createContext((url) => {
|
|
103
|
+
globalThis.location.href = url;
|
|
104
|
+
});
|
|
105
|
+
const SeamNavigateProvider = SeamNavigateContext.Provider;
|
|
106
|
+
function useSeamNavigate() {
|
|
107
|
+
return useContext(SeamNavigateContext);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
//#endregion
|
|
111
|
+
export { SeamDataProvider, SeamNavigateProvider, buildSentinelData, defineRoutes, parseSeamData, useSeamData, useSeamNavigate, useSeamSubscription };
|
|
112
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +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"}
|
package/package.json
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@canmi/seam-react",
|
|
3
|
+
"version": "0.2.3",
|
|
4
|
+
"files": [
|
|
5
|
+
"dist",
|
|
6
|
+
"scripts"
|
|
7
|
+
],
|
|
8
|
+
"type": "module",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"scripts": {
|
|
16
|
+
"build": "tsdown",
|
|
17
|
+
"test": "vitest run"
|
|
18
|
+
},
|
|
19
|
+
"dependencies": {
|
|
20
|
+
"@canmi/seam-client": "workspace:*",
|
|
21
|
+
"esbuild": "^0.25.0"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@canmi/seam-injector": "workspace:*",
|
|
25
|
+
"@types/react": "^19.0.0",
|
|
26
|
+
"@types/react-dom": "^19.0.0",
|
|
27
|
+
"jsdom": "^28.1.0",
|
|
28
|
+
"react": "^19.0.0",
|
|
29
|
+
"react-dom": "^19.0.0",
|
|
30
|
+
"tsdown": "^0.20.0",
|
|
31
|
+
"typescript": "^5.7.0",
|
|
32
|
+
"vitest": "^3.0.0"
|
|
33
|
+
},
|
|
34
|
+
"peerDependencies": {
|
|
35
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
36
|
+
"react-dom": "^18.0.0 || ^19.0.0"
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/* packages/client/react/scripts/build-skeletons.mjs */
|
|
2
|
+
|
|
3
|
+
import { build } from "esbuild";
|
|
4
|
+
import { createElement } from "react";
|
|
5
|
+
import { renderToString } from "react-dom/server";
|
|
6
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
7
|
+
import { join, dirname, resolve } from "node:path";
|
|
8
|
+
import { fileURLToPath } from "node:url";
|
|
9
|
+
import { SeamDataProvider, buildSentinelData } from "@canmi/seam-react";
|
|
10
|
+
import {
|
|
11
|
+
collectStructuralAxes,
|
|
12
|
+
cartesianProduct,
|
|
13
|
+
buildVariantSentinel,
|
|
14
|
+
} from "./variant-generator.mjs";
|
|
15
|
+
import { generateMockFromSchema, flattenLoaderMock, deepMerge } from "./mock-generator.mjs";
|
|
16
|
+
|
|
17
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
18
|
+
|
|
19
|
+
// -- Rendering --
|
|
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;
|
|
122
|
+
try {
|
|
123
|
+
html = renderWithData(component, data);
|
|
124
|
+
} finally {
|
|
125
|
+
for (const teardown of teardowns) teardown();
|
|
126
|
+
}
|
|
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
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Merge loader procedure schemas from manifest into a combined page schema.
|
|
149
|
+
* Each loader contributes its output schema fields to the top-level properties.
|
|
150
|
+
*/
|
|
151
|
+
function buildPageSchema(route, manifest) {
|
|
152
|
+
if (!manifest) return null;
|
|
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
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return leaves;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// -- Main --
|
|
303
|
+
|
|
304
|
+
async function main() {
|
|
305
|
+
const routesFile = process.argv[2];
|
|
306
|
+
const manifestFile = process.argv[3];
|
|
307
|
+
|
|
308
|
+
if (!routesFile) {
|
|
309
|
+
console.error("Usage: node build-skeletons.mjs <routes-file> [manifest-file]");
|
|
310
|
+
process.exit(1);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// Load manifest if provided
|
|
314
|
+
let manifest = null;
|
|
315
|
+
if (manifestFile && manifestFile !== "none") {
|
|
316
|
+
try {
|
|
317
|
+
manifest = JSON.parse(readFileSync(resolve(manifestFile), "utf-8"));
|
|
318
|
+
} catch (e) {
|
|
319
|
+
console.error(`warning: could not read manifest: ${e.message}`);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const absRoutes = resolve(routesFile);
|
|
324
|
+
const outfile = join(__dirname, ".tmp-routes-bundle.mjs");
|
|
325
|
+
|
|
326
|
+
await build({
|
|
327
|
+
entryPoints: [absRoutes],
|
|
328
|
+
bundle: true,
|
|
329
|
+
format: "esm",
|
|
330
|
+
platform: "node",
|
|
331
|
+
outfile,
|
|
332
|
+
external: ["react", "react-dom", "@canmi/seam-react"],
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
try {
|
|
336
|
+
const mod = await import(outfile);
|
|
337
|
+
const routes = mod.default || mod.routes;
|
|
338
|
+
if (!Array.isArray(routes)) {
|
|
339
|
+
throw new Error("Routes file must export default or named 'routes' as an array");
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
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
|
+
const flat = flattenRoutes(routes);
|
|
350
|
+
const output = {
|
|
351
|
+
layouts,
|
|
352
|
+
routes: flat.map((r) => renderRoute(r, manifest)),
|
|
353
|
+
warnings: buildWarnings,
|
|
354
|
+
};
|
|
355
|
+
process.stdout.write(JSON.stringify(output));
|
|
356
|
+
} finally {
|
|
357
|
+
try {
|
|
358
|
+
unlinkSync(outfile);
|
|
359
|
+
} catch {}
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
main().catch((err) => {
|
|
364
|
+
if (err instanceof SeamBuildError) {
|
|
365
|
+
console.error(err.message);
|
|
366
|
+
} else {
|
|
367
|
+
console.error(err);
|
|
368
|
+
}
|
|
369
|
+
process.exit(1);
|
|
370
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
/* packages/client/react/scripts/mock-generator.mjs */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Auto-generate deterministic mock data from JTD schema.
|
|
5
|
+
* Lets users omit mock in route definitions when a manifest is available.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const STRING_RULES = [
|
|
9
|
+
{ test: (f) => /name/i.test(f), value: "Example Name" },
|
|
10
|
+
{ test: (f) => /url|href|src/i.test(f), value: "https://example.com" },
|
|
11
|
+
{ test: (f) => /email/i.test(f), value: "user@example.com" },
|
|
12
|
+
{ test: (f) => /color/i.test(f), value: "#888888" },
|
|
13
|
+
{ test: (f) => /description|bio|summary/i.test(f), value: "Sample description" },
|
|
14
|
+
{ test: (f) => /title/i.test(f), value: "Sample Title" },
|
|
15
|
+
{ test: (f) => /^id$/i.test(f), value: "sample-id" },
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
function inferStringValue(fieldPath) {
|
|
19
|
+
const lastSegment = fieldPath ? fieldPath.split(".").pop() : "";
|
|
20
|
+
for (const rule of STRING_RULES) {
|
|
21
|
+
if (rule.test(lastSegment)) return rule.value;
|
|
22
|
+
}
|
|
23
|
+
// Capitalize first letter for readability
|
|
24
|
+
const label = lastSegment ? lastSegment.charAt(0).toUpperCase() + lastSegment.slice(1) : "text";
|
|
25
|
+
return `Sample ${label}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Recursively traverse a JTD schema and produce deterministic mock values.
|
|
30
|
+
* @param {object} schema - JTD schema node
|
|
31
|
+
* @param {string} [fieldPath=""] - dot-separated path for semantic string inference
|
|
32
|
+
* @returns {unknown}
|
|
33
|
+
*/
|
|
34
|
+
export function generateMockFromSchema(schema, fieldPath = "") {
|
|
35
|
+
if (!schema || typeof schema !== "object") return {};
|
|
36
|
+
|
|
37
|
+
// Strip nullable wrapper — generate a populated (non-null) value
|
|
38
|
+
if (schema.nullable) {
|
|
39
|
+
const inner = { ...schema };
|
|
40
|
+
delete inner.nullable;
|
|
41
|
+
return generateMockFromSchema(inner, fieldPath);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Primitive type forms
|
|
45
|
+
if (schema.type) {
|
|
46
|
+
switch (schema.type) {
|
|
47
|
+
case "string":
|
|
48
|
+
return inferStringValue(fieldPath);
|
|
49
|
+
case "boolean":
|
|
50
|
+
return true;
|
|
51
|
+
case "int8":
|
|
52
|
+
case "int16":
|
|
53
|
+
case "int32":
|
|
54
|
+
case "uint8":
|
|
55
|
+
case "uint16":
|
|
56
|
+
case "uint32":
|
|
57
|
+
case "float32":
|
|
58
|
+
case "float64":
|
|
59
|
+
return 1;
|
|
60
|
+
case "timestamp":
|
|
61
|
+
return "2024-01-01T00:00:00Z";
|
|
62
|
+
default:
|
|
63
|
+
return `Sample ${schema.type}`;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Enum form
|
|
68
|
+
if (schema.enum) {
|
|
69
|
+
return schema.enum[0];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Object form (properties / optionalProperties)
|
|
73
|
+
if (schema.properties || schema.optionalProperties) {
|
|
74
|
+
const result = {};
|
|
75
|
+
const props = schema.properties || {};
|
|
76
|
+
const optProps = schema.optionalProperties || {};
|
|
77
|
+
for (const [key, sub] of Object.entries(props)) {
|
|
78
|
+
result[key] = generateMockFromSchema(sub, fieldPath ? `${fieldPath}.${key}` : key);
|
|
79
|
+
}
|
|
80
|
+
for (const [key, sub] of Object.entries(optProps)) {
|
|
81
|
+
result[key] = generateMockFromSchema(sub, fieldPath ? `${fieldPath}.${key}` : key);
|
|
82
|
+
}
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Array form (elements)
|
|
87
|
+
if (schema.elements) {
|
|
88
|
+
return [
|
|
89
|
+
generateMockFromSchema(schema.elements, fieldPath ? `${fieldPath}.$` : "$"),
|
|
90
|
+
generateMockFromSchema(schema.elements, fieldPath ? `${fieldPath}.$` : "$"),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Record/map form (values)
|
|
95
|
+
if (schema.values) {
|
|
96
|
+
return {
|
|
97
|
+
item1: generateMockFromSchema(schema.values, fieldPath ? `${fieldPath}.item1` : "item1"),
|
|
98
|
+
item2: generateMockFromSchema(schema.values, fieldPath ? `${fieldPath}.item2` : "item2"),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Discriminator form (tagged union)
|
|
103
|
+
if (schema.discriminator && schema.mapping) {
|
|
104
|
+
const tag = schema.discriminator;
|
|
105
|
+
const mappingKeys = Object.keys(schema.mapping);
|
|
106
|
+
if (mappingKeys.length === 0) return { [tag]: "" };
|
|
107
|
+
const firstKey = mappingKeys[0];
|
|
108
|
+
const variant = generateMockFromSchema(schema.mapping[firstKey], fieldPath);
|
|
109
|
+
return { [tag]: firstKey, ...variant };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Empty / any schema
|
|
113
|
+
return {};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Replicate page handler's merge logic: keyed loader data flattened
|
|
118
|
+
* so that top-level keys from each loader's object result are also
|
|
119
|
+
* accessible at the root level.
|
|
120
|
+
* @param {Record<string, unknown>} keyedMock
|
|
121
|
+
* @returns {Record<string, unknown>}
|
|
122
|
+
*/
|
|
123
|
+
export function flattenLoaderMock(keyedMock) {
|
|
124
|
+
const flat = { ...keyedMock };
|
|
125
|
+
for (const value of Object.values(keyedMock)) {
|
|
126
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
127
|
+
Object.assign(flat, value);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return flat;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Deep merge base with override:
|
|
135
|
+
* - object + object → recursive merge
|
|
136
|
+
* - array in override → replaces entirely
|
|
137
|
+
* - primitive/null in override → replaces
|
|
138
|
+
* - keys only in base → preserved
|
|
139
|
+
* @param {unknown} base
|
|
140
|
+
* @param {unknown} override
|
|
141
|
+
* @returns {unknown}
|
|
142
|
+
*/
|
|
143
|
+
export function deepMerge(base, override) {
|
|
144
|
+
if (override === null || override === undefined) return override;
|
|
145
|
+
if (typeof override !== "object" || Array.isArray(override)) return override;
|
|
146
|
+
if (typeof base !== "object" || base === null || Array.isArray(base)) return override;
|
|
147
|
+
|
|
148
|
+
const result = { ...base };
|
|
149
|
+
for (const [key, val] of Object.entries(override)) {
|
|
150
|
+
if (
|
|
151
|
+
key in result &&
|
|
152
|
+
typeof result[key] === "object" &&
|
|
153
|
+
result[key] !== null &&
|
|
154
|
+
!Array.isArray(result[key]) &&
|
|
155
|
+
typeof val === "object" &&
|
|
156
|
+
val !== null &&
|
|
157
|
+
!Array.isArray(val)
|
|
158
|
+
) {
|
|
159
|
+
result[key] = deepMerge(result[key], val);
|
|
160
|
+
} else {
|
|
161
|
+
result[key] = val;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
/* packages/client/react/scripts/variant-generator.mjs */
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Walk a JTD schema + mock data to discover all structural axes.
|
|
5
|
+
* Each axis represents a dimension that affects template structure:
|
|
6
|
+
* boolean fields, enum fields, nullable fields, array (elements) fields.
|
|
7
|
+
*/
|
|
8
|
+
export function collectStructuralAxes(schema, mock, prefix = "") {
|
|
9
|
+
const axes = [];
|
|
10
|
+
if (!schema || typeof schema !== "object") return axes;
|
|
11
|
+
|
|
12
|
+
const props = schema.properties || {};
|
|
13
|
+
const optProps = schema.optionalProperties || {};
|
|
14
|
+
const allProps = { ...props, ...optProps };
|
|
15
|
+
|
|
16
|
+
for (const [key, fieldSchema] of Object.entries(allProps)) {
|
|
17
|
+
const path = prefix ? `${prefix}.${key}` : key;
|
|
18
|
+
|
|
19
|
+
if (fieldSchema.type === "boolean") {
|
|
20
|
+
axes.push({ path, kind: "boolean", values: [true, false] });
|
|
21
|
+
} else if (fieldSchema.enum) {
|
|
22
|
+
axes.push({ path, kind: "enum", values: [...fieldSchema.enum] });
|
|
23
|
+
} else if (fieldSchema.nullable) {
|
|
24
|
+
axes.push({ path, kind: "nullable", values: ["present", "null"] });
|
|
25
|
+
// Recurse into the underlying schema (remove nullable flag)
|
|
26
|
+
const innerSchema = { ...fieldSchema };
|
|
27
|
+
delete innerSchema.nullable;
|
|
28
|
+
if (innerSchema.properties || innerSchema.optionalProperties) {
|
|
29
|
+
const mockValue = mock?.[key];
|
|
30
|
+
axes.push(...collectStructuralAxes(innerSchema, mockValue, path));
|
|
31
|
+
}
|
|
32
|
+
} else if (fieldSchema.elements) {
|
|
33
|
+
axes.push({ path, kind: "array", values: ["populated", "empty"] });
|
|
34
|
+
// Recurse into element schema with $.prefix
|
|
35
|
+
const elemSchema = fieldSchema.elements;
|
|
36
|
+
const mockArray = mock?.[key];
|
|
37
|
+
const elemMock = Array.isArray(mockArray) && mockArray.length > 0 ? mockArray[0] : {};
|
|
38
|
+
if (elemSchema.properties || elemSchema.optionalProperties) {
|
|
39
|
+
axes.push(...collectStructuralAxes(elemSchema, elemMock, `${path}.$`));
|
|
40
|
+
}
|
|
41
|
+
} else if (fieldSchema.properties || fieldSchema.optionalProperties) {
|
|
42
|
+
// Nested object: recurse
|
|
43
|
+
const mockValue = mock?.[key];
|
|
44
|
+
axes.push(...collectStructuralAxes(fieldSchema, mockValue, path));
|
|
45
|
+
}
|
|
46
|
+
// string/number/etc: non-structural, skip
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return axes;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Compute cartesian product of all axis values.
|
|
54
|
+
* Returns array of variant objects, e.g. [{ isAdmin: true, role: "admin" }, ...]
|
|
55
|
+
*/
|
|
56
|
+
export function cartesianProduct(axes) {
|
|
57
|
+
if (axes.length === 0) return [{}];
|
|
58
|
+
|
|
59
|
+
let combos = [{}];
|
|
60
|
+
for (const axis of axes) {
|
|
61
|
+
const next = [];
|
|
62
|
+
for (const existing of combos) {
|
|
63
|
+
for (const value of axis.values) {
|
|
64
|
+
next.push({ ...existing, [axis.path]: value });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
combos = next;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (combos.length > 10000) {
|
|
71
|
+
console.error(`warning: ${combos.length} variants detected — build may be slow`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return combos;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Build sentinel data with structural fields set according to a variant combo.
|
|
79
|
+
* Uses the base sentinel as starting point and adjusts structural fields.
|
|
80
|
+
*/
|
|
81
|
+
export function buildVariantSentinel(baseSentinel, mock, variant) {
|
|
82
|
+
const result = JSON.parse(JSON.stringify(baseSentinel));
|
|
83
|
+
|
|
84
|
+
for (const [path, value] of Object.entries(variant)) {
|
|
85
|
+
applyVariantValue(result, mock, path, value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return result;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function applyVariantValue(sentinel, mock, path, value) {
|
|
92
|
+
// Handle $.prefixed paths (inside arrays) — these modify the element template
|
|
93
|
+
if (path.includes(".$")) {
|
|
94
|
+
const parts = path.split(".$.");
|
|
95
|
+
const arrayPath = parts[0];
|
|
96
|
+
const innerPath = parts.slice(1).join(".$.");
|
|
97
|
+
const arr = getNestedValue(sentinel, arrayPath);
|
|
98
|
+
if (Array.isArray(arr) && arr.length > 0) {
|
|
99
|
+
for (const item of arr) {
|
|
100
|
+
applyVariantValue(item, getNestedValue(mock, arrayPath)?.[0] || {}, innerPath, value);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const pathParts = path.split(".");
|
|
107
|
+
|
|
108
|
+
if (value === "null") {
|
|
109
|
+
setNestedValue(sentinel, pathParts, null);
|
|
110
|
+
} else if (value === "empty") {
|
|
111
|
+
setNestedValue(sentinel, pathParts, []);
|
|
112
|
+
} else if (value === "populated") {
|
|
113
|
+
// Already populated from base sentinel, no change needed
|
|
114
|
+
} else if (value === "present") {
|
|
115
|
+
// Already present from base sentinel, no change needed
|
|
116
|
+
} else if (value === true || value === false) {
|
|
117
|
+
setNestedValue(sentinel, pathParts, value);
|
|
118
|
+
} else if (typeof value === "string") {
|
|
119
|
+
// Enum value: set the field to the actual value
|
|
120
|
+
setNestedValue(sentinel, pathParts, value);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getNestedValue(obj, dottedPath) {
|
|
125
|
+
const parts = dottedPath.split(".");
|
|
126
|
+
let cur = obj;
|
|
127
|
+
for (const part of parts) {
|
|
128
|
+
if (cur === null || cur === undefined || typeof cur !== "object") return undefined;
|
|
129
|
+
cur = cur[part];
|
|
130
|
+
}
|
|
131
|
+
return cur;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function setNestedValue(obj, parts, value) {
|
|
135
|
+
let cur = obj;
|
|
136
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
137
|
+
if (cur[parts[i]] === null || cur[parts[i]] === undefined || typeof cur[parts[i]] !== "object")
|
|
138
|
+
return;
|
|
139
|
+
cur = cur[parts[i]];
|
|
140
|
+
}
|
|
141
|
+
cur[parts[parts.length - 1]] = value;
|
|
142
|
+
}
|