@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
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/* packages/client/react/scripts/skeleton/process.mjs */
|
|
2
|
+
|
|
3
|
+
import { buildI18nValue, computeCacheKey, pathToSlug, readCache, writeCache } from "./cache.mjs";
|
|
4
|
+
import { renderLayout } from "./layout.mjs";
|
|
5
|
+
import { renderRoute } from "./schema.mjs";
|
|
6
|
+
|
|
7
|
+
function processLayoutsWithCache(layoutMap, ctx) {
|
|
8
|
+
return [...layoutMap.entries()].map(([id, entry]) => {
|
|
9
|
+
// i18n: render once per locale, return localeHtml map
|
|
10
|
+
if (ctx.i18n) {
|
|
11
|
+
const localeHtml = {};
|
|
12
|
+
let collectedKeys = null;
|
|
13
|
+
for (const locale of ctx.i18n.locales) {
|
|
14
|
+
const i18nValue = buildI18nValue(locale, ctx.i18n.messages, ctx.i18n.default);
|
|
15
|
+
const messagesJson = JSON.stringify(ctx.i18n.messages?.[locale] || {});
|
|
16
|
+
const compHash = ctx.componentHashes.get(entry.component?.name);
|
|
17
|
+
if (compHash) {
|
|
18
|
+
const config = { id, loaders: entry.loaders, mock: entry.mock };
|
|
19
|
+
const key = computeCacheKey(
|
|
20
|
+
compHash,
|
|
21
|
+
ctx.manifestContent,
|
|
22
|
+
config,
|
|
23
|
+
ctx.scriptHash,
|
|
24
|
+
locale,
|
|
25
|
+
messagesJson,
|
|
26
|
+
);
|
|
27
|
+
const slug = `layout_${id}_${locale}`;
|
|
28
|
+
const cached = readCache(ctx.cacheDir, slug);
|
|
29
|
+
if (cached && cached.key === key) {
|
|
30
|
+
ctx.stats.hits++;
|
|
31
|
+
localeHtml[locale] = cached.data.html;
|
|
32
|
+
if (!collectedKeys) collectedKeys = cached.data.i18nKeys;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const html = renderLayout(
|
|
36
|
+
entry.component,
|
|
37
|
+
id,
|
|
38
|
+
entry,
|
|
39
|
+
ctx.manifest,
|
|
40
|
+
i18nValue,
|
|
41
|
+
ctx.warnCtx,
|
|
42
|
+
);
|
|
43
|
+
const i18nKeys = [...i18nValue._usedKeys].sort();
|
|
44
|
+
writeCache(ctx.cacheDir, slug, key, { html, i18nKeys });
|
|
45
|
+
ctx.stats.misses++;
|
|
46
|
+
localeHtml[locale] = html;
|
|
47
|
+
if (!collectedKeys) collectedKeys = i18nKeys;
|
|
48
|
+
} else {
|
|
49
|
+
ctx.stats.misses++;
|
|
50
|
+
const html = renderLayout(
|
|
51
|
+
entry.component,
|
|
52
|
+
id,
|
|
53
|
+
entry,
|
|
54
|
+
ctx.manifest,
|
|
55
|
+
i18nValue,
|
|
56
|
+
ctx.warnCtx,
|
|
57
|
+
);
|
|
58
|
+
const i18nKeys = [...i18nValue._usedKeys].sort();
|
|
59
|
+
localeHtml[locale] = html;
|
|
60
|
+
if (!collectedKeys) collectedKeys = i18nKeys;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
id,
|
|
65
|
+
localeHtml,
|
|
66
|
+
loaders: entry.loaders,
|
|
67
|
+
parent: entry.parentId,
|
|
68
|
+
i18nKeys: collectedKeys || [],
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// No i18n: original behavior
|
|
73
|
+
const compHash = ctx.componentHashes.get(entry.component?.name);
|
|
74
|
+
if (compHash) {
|
|
75
|
+
const config = { id, loaders: entry.loaders, mock: entry.mock };
|
|
76
|
+
const key = computeCacheKey(compHash, ctx.manifestContent, config, ctx.scriptHash);
|
|
77
|
+
const slug = `layout_${id}`;
|
|
78
|
+
const cached = readCache(ctx.cacheDir, slug);
|
|
79
|
+
if (cached && cached.key === key) {
|
|
80
|
+
ctx.stats.hits++;
|
|
81
|
+
return cached.data;
|
|
82
|
+
}
|
|
83
|
+
const data = {
|
|
84
|
+
id,
|
|
85
|
+
html: renderLayout(entry.component, id, entry, ctx.manifest, undefined, ctx.warnCtx),
|
|
86
|
+
loaders: entry.loaders,
|
|
87
|
+
parent: entry.parentId,
|
|
88
|
+
};
|
|
89
|
+
writeCache(ctx.cacheDir, slug, key, data);
|
|
90
|
+
ctx.stats.misses++;
|
|
91
|
+
return data;
|
|
92
|
+
}
|
|
93
|
+
ctx.stats.misses++;
|
|
94
|
+
return {
|
|
95
|
+
id,
|
|
96
|
+
html: renderLayout(entry.component, id, entry, ctx.manifest, undefined, ctx.warnCtx),
|
|
97
|
+
loaders: entry.loaders,
|
|
98
|
+
parent: entry.parentId,
|
|
99
|
+
};
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function processRoutesWithCache(flat, ctx) {
|
|
104
|
+
return flat.map((r) => {
|
|
105
|
+
// i18n: render once per locale, return localeVariants map
|
|
106
|
+
if (ctx.i18n) {
|
|
107
|
+
const localeVariants = {};
|
|
108
|
+
let collectedKeys = null;
|
|
109
|
+
for (const locale of ctx.i18n.locales) {
|
|
110
|
+
const i18nValue = buildI18nValue(locale, ctx.i18n.messages, ctx.i18n.default);
|
|
111
|
+
const messagesJson = JSON.stringify(ctx.i18n.messages?.[locale] || {});
|
|
112
|
+
const compHash = ctx.componentHashes.get(r.component?.name);
|
|
113
|
+
if (compHash) {
|
|
114
|
+
const config = { path: r.path, loaders: r.loaders, mock: r.mock, nullable: r.nullable };
|
|
115
|
+
const key = computeCacheKey(
|
|
116
|
+
compHash,
|
|
117
|
+
ctx.manifestContent,
|
|
118
|
+
config,
|
|
119
|
+
ctx.scriptHash,
|
|
120
|
+
locale,
|
|
121
|
+
messagesJson,
|
|
122
|
+
);
|
|
123
|
+
const slug = `route_${pathToSlug(r.path)}_${locale}`;
|
|
124
|
+
const cached = readCache(ctx.cacheDir, slug);
|
|
125
|
+
if (cached && cached.key === key) {
|
|
126
|
+
ctx.stats.hits++;
|
|
127
|
+
localeVariants[locale] = cached.data;
|
|
128
|
+
if (!collectedKeys) collectedKeys = cached.data.i18nKeys;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
const data = renderRoute(r, ctx.manifest, i18nValue, ctx.warnCtx);
|
|
132
|
+
data.i18nKeys = [...i18nValue._usedKeys].sort();
|
|
133
|
+
writeCache(ctx.cacheDir, slug, key, data);
|
|
134
|
+
ctx.stats.misses++;
|
|
135
|
+
localeVariants[locale] = data;
|
|
136
|
+
if (!collectedKeys) collectedKeys = data.i18nKeys;
|
|
137
|
+
} else {
|
|
138
|
+
ctx.stats.misses++;
|
|
139
|
+
const data = renderRoute(r, ctx.manifest, i18nValue, ctx.warnCtx);
|
|
140
|
+
data.i18nKeys = [...i18nValue._usedKeys].sort();
|
|
141
|
+
localeVariants[locale] = data;
|
|
142
|
+
if (!collectedKeys) collectedKeys = data.i18nKeys;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// Combine per-locale data into the expected output format
|
|
146
|
+
const first = localeVariants[ctx.i18n.locales[0]];
|
|
147
|
+
return {
|
|
148
|
+
path: r.path,
|
|
149
|
+
loaders: first.loaders,
|
|
150
|
+
layout: first.layout,
|
|
151
|
+
mock: first.mock,
|
|
152
|
+
pageSchema: first.pageSchema,
|
|
153
|
+
i18nKeys: collectedKeys || [],
|
|
154
|
+
localeVariants: Object.fromEntries(
|
|
155
|
+
Object.entries(localeVariants).map(([loc, data]) => [
|
|
156
|
+
loc,
|
|
157
|
+
{ axes: data.axes, variants: data.variants, mockHtml: data.mockHtml },
|
|
158
|
+
]),
|
|
159
|
+
),
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// No i18n: original behavior
|
|
164
|
+
const compHash = ctx.componentHashes.get(r.component?.name);
|
|
165
|
+
if (compHash) {
|
|
166
|
+
const config = { path: r.path, loaders: r.loaders, mock: r.mock, nullable: r.nullable };
|
|
167
|
+
const key = computeCacheKey(compHash, ctx.manifestContent, config, ctx.scriptHash);
|
|
168
|
+
const slug = `route_${pathToSlug(r.path)}`;
|
|
169
|
+
const cached = readCache(ctx.cacheDir, slug);
|
|
170
|
+
if (cached && cached.key === key) {
|
|
171
|
+
ctx.stats.hits++;
|
|
172
|
+
return cached.data;
|
|
173
|
+
}
|
|
174
|
+
const data = renderRoute(r, ctx.manifest, undefined, ctx.warnCtx);
|
|
175
|
+
writeCache(ctx.cacheDir, slug, key, data);
|
|
176
|
+
ctx.stats.misses++;
|
|
177
|
+
return data;
|
|
178
|
+
}
|
|
179
|
+
ctx.stats.misses++;
|
|
180
|
+
return renderRoute(r, ctx.manifest, undefined, ctx.warnCtx);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
export { processLayoutsWithCache, processRoutesWithCache };
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
/* packages/client/react/scripts/skeleton/render.mjs */
|
|
2
|
+
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { renderToString } from "react-dom/server";
|
|
5
|
+
import { SeamDataProvider } from "@canmi/seam-react";
|
|
6
|
+
|
|
7
|
+
let _I18nProvider = null;
|
|
8
|
+
|
|
9
|
+
export function setI18nProvider(provider) {
|
|
10
|
+
_I18nProvider = provider;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
class SeamBuildError extends Error {
|
|
14
|
+
constructor(message) {
|
|
15
|
+
super(message);
|
|
16
|
+
this.name = "SeamBuildError";
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Matches React-injected resource hint <link> tags.
|
|
21
|
+
// Only rel values used by React's resource APIs are targeted (preload, dns-prefetch, preconnect,
|
|
22
|
+
// data-precedence); user-authored <link> tags (canonical, alternate, stylesheet) are unaffected.
|
|
23
|
+
const RESOURCE_HINT_RE =
|
|
24
|
+
/<link[^>]+rel\s*=\s*"(?:preload|dns-prefetch|preconnect)"[^>]*>|<link[^>]+data-precedence[^>]*>/gi;
|
|
25
|
+
|
|
26
|
+
function renderWithData(component, data, i18nValue) {
|
|
27
|
+
const inner = createElement(SeamDataProvider, { value: data }, createElement(component));
|
|
28
|
+
if (i18nValue && _I18nProvider) {
|
|
29
|
+
return renderToString(createElement(_I18nProvider, { value: i18nValue }, inner));
|
|
30
|
+
}
|
|
31
|
+
return renderToString(inner);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function installRenderTraps(violations, teardowns) {
|
|
35
|
+
function trapCall(obj, prop, label) {
|
|
36
|
+
const orig = obj[prop];
|
|
37
|
+
obj[prop] = function () {
|
|
38
|
+
violations.push({ severity: "error", reason: `${label} called during skeleton render` });
|
|
39
|
+
throw new SeamBuildError(`${label} is not allowed in skeleton components`);
|
|
40
|
+
};
|
|
41
|
+
teardowns.push(() => {
|
|
42
|
+
obj[prop] = orig;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
trapCall(globalThis, "fetch", "fetch()");
|
|
47
|
+
trapCall(Math, "random", "Math.random()");
|
|
48
|
+
trapCall(Date, "now", "Date.now()");
|
|
49
|
+
if (globalThis.crypto?.randomUUID) {
|
|
50
|
+
trapCall(globalThis.crypto, "randomUUID", "crypto.randomUUID()");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Timer APIs — these don't affect renderToString output, but pending handles
|
|
54
|
+
// prevent the build process from exiting (Node keeps the event loop alive).
|
|
55
|
+
trapCall(globalThis, "setTimeout", "setTimeout()");
|
|
56
|
+
trapCall(globalThis, "setInterval", "setInterval()");
|
|
57
|
+
if (globalThis.setImmediate) {
|
|
58
|
+
trapCall(globalThis, "setImmediate", "setImmediate()");
|
|
59
|
+
}
|
|
60
|
+
trapCall(globalThis, "queueMicrotask", "queueMicrotask()");
|
|
61
|
+
|
|
62
|
+
// Trap browser globals (only if not already defined — these are undefined in Node;
|
|
63
|
+
// typeof checks bypass getters, so `typeof window !== 'undefined'` remains safe)
|
|
64
|
+
for (const name of ["window", "document", "localStorage"]) {
|
|
65
|
+
if (!(name in globalThis)) {
|
|
66
|
+
Object.defineProperty(globalThis, name, {
|
|
67
|
+
get() {
|
|
68
|
+
violations.push({ severity: "error", reason: `${name} accessed during skeleton render` });
|
|
69
|
+
throw new SeamBuildError(`${name} is not available in skeleton components`);
|
|
70
|
+
},
|
|
71
|
+
configurable: true,
|
|
72
|
+
});
|
|
73
|
+
teardowns.push(() => {
|
|
74
|
+
delete globalThis[name];
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function validateOutput(html, violations) {
|
|
81
|
+
if (html.includes("<!--$!-->")) {
|
|
82
|
+
violations.push({
|
|
83
|
+
severity: "error",
|
|
84
|
+
reason:
|
|
85
|
+
"Suspense abort detected \u2014 a component used an unresolved async resource\n" +
|
|
86
|
+
" (e.g. use(promise)) inside a <Suspense> boundary, producing an incomplete\n" +
|
|
87
|
+
" template with fallback content baked in.\n" +
|
|
88
|
+
" Fix: remove use() from skeleton components. Async data belongs in loaders.",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const hints = Array.from(html.matchAll(RESOURCE_HINT_RE));
|
|
93
|
+
if (hints.length > 0) {
|
|
94
|
+
violations.push({
|
|
95
|
+
severity: "warning",
|
|
96
|
+
reason:
|
|
97
|
+
`stripped ${hints.length} resource hint <link> tag(s) injected by React's preload()/preinit().\n` +
|
|
98
|
+
" These are not data-driven and would cause hydration mismatch.",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function stripResourceHints(html) {
|
|
104
|
+
return html.replace(RESOURCE_HINT_RE, "");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Render a component with full safety guards.
|
|
109
|
+
* @param {string} routePath - route or layout identifier for error messages
|
|
110
|
+
* @param {Function} component - React component to render
|
|
111
|
+
* @param {object} data - sentinel or tracked data
|
|
112
|
+
* @param {object|null} i18nValue - i18n context value
|
|
113
|
+
* @param {{ buildWarnings: string[], seenWarnings: Set<string> }} ctx - shared warning state
|
|
114
|
+
*/
|
|
115
|
+
function guardedRender(routePath, component, data, i18nValue, ctx) {
|
|
116
|
+
const violations = [];
|
|
117
|
+
const teardowns = [];
|
|
118
|
+
|
|
119
|
+
installRenderTraps(violations, teardowns);
|
|
120
|
+
|
|
121
|
+
let html;
|
|
122
|
+
try {
|
|
123
|
+
html = renderWithData(component, data, i18nValue);
|
|
124
|
+
} catch (e) {
|
|
125
|
+
if (e instanceof SeamBuildError) {
|
|
126
|
+
throw new SeamBuildError(
|
|
127
|
+
`[seam] error: Skeleton rendering failed for route "${routePath}":\n` +
|
|
128
|
+
` ${e.message}\n\n` +
|
|
129
|
+
" Move browser API calls into useEffect() or event handlers.",
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
throw e;
|
|
133
|
+
} finally {
|
|
134
|
+
for (const teardown of teardowns) teardown();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
validateOutput(html, violations);
|
|
138
|
+
|
|
139
|
+
const fatal = violations.filter((v) => v.severity === "error");
|
|
140
|
+
if (fatal.length > 0) {
|
|
141
|
+
const msg = fatal.map((v) => `[seam] error: ${routePath}\n ${v.reason}`).join("\n\n");
|
|
142
|
+
throw new SeamBuildError(msg);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// After fatal check, only warnings remain — dedup per message
|
|
146
|
+
for (const v of violations) {
|
|
147
|
+
const msg = `[seam] warning: ${routePath}\n ${v.reason}`;
|
|
148
|
+
if (!ctx.seenWarnings.has(msg)) {
|
|
149
|
+
ctx.seenWarnings.add(msg);
|
|
150
|
+
ctx.buildWarnings.push(msg);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (violations.length > 0) {
|
|
154
|
+
html = stripResourceHints(html);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return html;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export { SeamBuildError, guardedRender, stripResourceHints, RESOURCE_HINT_RE };
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/* packages/client/react/scripts/skeleton/schema.mjs */
|
|
2
|
+
|
|
3
|
+
import { buildSentinelData } from "@canmi/seam-react";
|
|
4
|
+
import {
|
|
5
|
+
collectStructuralAxes,
|
|
6
|
+
cartesianProduct,
|
|
7
|
+
buildVariantSentinel,
|
|
8
|
+
} from "../variant-generator.mjs";
|
|
9
|
+
import {
|
|
10
|
+
generateMockFromSchema,
|
|
11
|
+
flattenLoaderMock,
|
|
12
|
+
deepMerge,
|
|
13
|
+
collectHtmlPaths,
|
|
14
|
+
createAccessTracker,
|
|
15
|
+
checkFieldAccess,
|
|
16
|
+
} from "../mock-generator.mjs";
|
|
17
|
+
import { SeamBuildError, guardedRender, stripResourceHints } from "./render.mjs";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Merge loader procedure schemas from manifest into a combined page schema.
|
|
21
|
+
* Each loader contributes its output schema fields to the top-level properties.
|
|
22
|
+
*/
|
|
23
|
+
function buildPageSchema(route, manifest) {
|
|
24
|
+
if (!manifest) return null;
|
|
25
|
+
|
|
26
|
+
const properties = {};
|
|
27
|
+
|
|
28
|
+
for (const [loaderKey, loaderDef] of Object.entries(route.loaders || {})) {
|
|
29
|
+
const procName = loaderDef.procedure;
|
|
30
|
+
const proc = manifest.procedures?.[procName];
|
|
31
|
+
if (!proc?.output) continue;
|
|
32
|
+
|
|
33
|
+
// Always nest under the loader key so axis paths (e.g. "user.bio")
|
|
34
|
+
// align with sentinel data paths built from mock (e.g. sentinel.user.bio).
|
|
35
|
+
properties[loaderKey] = proc.output;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const result = {};
|
|
39
|
+
if (Object.keys(properties).length > 0) result.properties = properties;
|
|
40
|
+
return Object.keys(result).length > 0 ? result : null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Resolve mock data for a route: auto-generate from schema when available,
|
|
45
|
+
* then deep-merge any user-provided partial mock on top.
|
|
46
|
+
*/
|
|
47
|
+
function resolveRouteMock(route, manifest) {
|
|
48
|
+
const pageSchema = buildPageSchema(route, manifest);
|
|
49
|
+
|
|
50
|
+
if (pageSchema) {
|
|
51
|
+
const keyedMock = generateMockFromSchema(pageSchema);
|
|
52
|
+
const autoMock = flattenLoaderMock(keyedMock);
|
|
53
|
+
return route.mock ? deepMerge(autoMock, route.mock) : autoMock;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// No manifest (frontend-only mode) — mock is required
|
|
57
|
+
if (route.mock) return route.mock;
|
|
58
|
+
|
|
59
|
+
throw new SeamBuildError(
|
|
60
|
+
`[seam] error: Mock data required for route "${route.path}"\n\n` +
|
|
61
|
+
" No procedure manifest found \u2014 cannot auto-generate mock data.\n" +
|
|
62
|
+
" Provide mock data in your route definition:\n\n" +
|
|
63
|
+
" defineRoutes([{\n" +
|
|
64
|
+
` path: "${route.path}",\n` +
|
|
65
|
+
" component: YourComponent,\n" +
|
|
66
|
+
' mock: { user: { name: "..." }, repos: [...] }\n' +
|
|
67
|
+
" }])\n\n" +
|
|
68
|
+
" Or switch to fullstack mode with typed Procedures\n" +
|
|
69
|
+
" to enable automatic mock generation from schema.",
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Render a route: generate variants from structural axes, plus a mock-data render for CTR check.
|
|
75
|
+
* @param {{ buildWarnings: string[], seenWarnings: Set<string> }} ctx - shared warning state
|
|
76
|
+
*/
|
|
77
|
+
function renderRoute(route, manifest, i18nValue, ctx) {
|
|
78
|
+
const mock = resolveRouteMock(route, manifest);
|
|
79
|
+
const pageSchema = buildPageSchema(route, manifest);
|
|
80
|
+
const htmlPaths = pageSchema ? collectHtmlPaths(pageSchema) : new Set();
|
|
81
|
+
const baseSentinel = buildSentinelData(mock, "", htmlPaths);
|
|
82
|
+
const axes = pageSchema ? collectStructuralAxes(pageSchema, mock) : [];
|
|
83
|
+
const combos = cartesianProduct(axes);
|
|
84
|
+
|
|
85
|
+
const variants = combos.map((variant) => {
|
|
86
|
+
const sentinel = buildVariantSentinel(baseSentinel, mock, variant);
|
|
87
|
+
const html = guardedRender(route.path, route.component, sentinel, i18nValue, ctx);
|
|
88
|
+
return { variant, html };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// Render with real mock data for CTR equivalence check.
|
|
92
|
+
// Wrap mock with Proxy to track field accesses and detect schema mismatches.
|
|
93
|
+
const accessed = new Set();
|
|
94
|
+
const trackedMock = createAccessTracker(mock, accessed);
|
|
95
|
+
const mockHtml = stripResourceHints(
|
|
96
|
+
guardedRender(route.path, route.component, trackedMock, i18nValue, ctx),
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const fieldWarnings = checkFieldAccess(accessed, pageSchema, route.path);
|
|
100
|
+
for (const w of fieldWarnings) {
|
|
101
|
+
const msg = `[seam] warning: ${w}`;
|
|
102
|
+
if (!ctx.seenWarnings.has(msg)) {
|
|
103
|
+
ctx.seenWarnings.add(msg);
|
|
104
|
+
ctx.buildWarnings.push(msg);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
path: route.path,
|
|
110
|
+
loaders: route.loaders,
|
|
111
|
+
layout: route._layoutId || undefined,
|
|
112
|
+
axes,
|
|
113
|
+
variants,
|
|
114
|
+
mockHtml,
|
|
115
|
+
mock,
|
|
116
|
+
pageSchema,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export { buildPageSchema, resolveRouteMock, renderRoute };
|