@canmi/seam-react 0.2.14 → 0.4.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/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/package.json +3 -2
- package/scripts/build-skeletons.mjs +51 -506
- package/scripts/skeleton/cache.mjs +139 -0
- package/scripts/skeleton/layout.mjs +109 -0
- package/scripts/skeleton/process.mjs +188 -0
- package/scripts/skeleton/render.mjs +160 -0
- package/scripts/skeleton/schema.mjs +120 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/* packages/client/react/scripts/skeleton/cache.mjs */
|
|
2
|
+
|
|
3
|
+
import { build } from "esbuild";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
|
|
8
|
+
/** Parse import statements to map local names to specifiers */
|
|
9
|
+
function parseComponentImports(source) {
|
|
10
|
+
const map = new Map();
|
|
11
|
+
const re = /import\s+(?:(\w+)\s*,?\s*)?(?:\{([^}]*)\}\s*)?from\s+['"]([^'"]+)['"]/g;
|
|
12
|
+
let m;
|
|
13
|
+
while ((m = re.exec(source)) !== null) {
|
|
14
|
+
const [, defaultName, namedPart, specifier] = m;
|
|
15
|
+
if (defaultName) map.set(defaultName, specifier);
|
|
16
|
+
if (namedPart) {
|
|
17
|
+
for (const part of namedPart.split(",")) {
|
|
18
|
+
const t = part.trim();
|
|
19
|
+
if (!t) continue;
|
|
20
|
+
const asMatch = t.match(/^(\w+)\s+as\s+(\w+)$/);
|
|
21
|
+
if (asMatch) {
|
|
22
|
+
map.set(asMatch[2], specifier);
|
|
23
|
+
map.set(asMatch[1], specifier);
|
|
24
|
+
} else {
|
|
25
|
+
map.set(t, specifier);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return map;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Bundle each component via esbuild (write: false) and SHA-256 hash the output */
|
|
34
|
+
async function computeComponentHashes(names, importMap, routesDir) {
|
|
35
|
+
const hashes = new Map();
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
const tasks = [];
|
|
38
|
+
for (const name of names) {
|
|
39
|
+
const specifier = importMap.get(name);
|
|
40
|
+
if (!specifier || seen.has(specifier)) continue;
|
|
41
|
+
seen.add(specifier);
|
|
42
|
+
tasks.push(
|
|
43
|
+
build({
|
|
44
|
+
stdin: { contents: `import '${specifier}'`, resolveDir: routesDir, loader: "js" },
|
|
45
|
+
bundle: true,
|
|
46
|
+
write: false,
|
|
47
|
+
format: "esm",
|
|
48
|
+
platform: "node",
|
|
49
|
+
treeShaking: false,
|
|
50
|
+
external: ["react", "react-dom", "@canmi/seam-react", "@canmi/seam-i18n"],
|
|
51
|
+
logLevel: "silent",
|
|
52
|
+
})
|
|
53
|
+
.then((result) => {
|
|
54
|
+
const content = result.outputFiles[0]?.text || "";
|
|
55
|
+
const hash = createHash("sha256").update(content).digest("hex");
|
|
56
|
+
for (const [n, s] of importMap) {
|
|
57
|
+
if (s === specifier) hashes.set(n, hash);
|
|
58
|
+
}
|
|
59
|
+
})
|
|
60
|
+
.catch(() => {}),
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
await Promise.all(tasks);
|
|
64
|
+
return hashes;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Hash the build scripts themselves to invalidate cache when tooling changes.
|
|
69
|
+
* @param {string[]} scriptFiles - absolute paths of script files to hash
|
|
70
|
+
*/
|
|
71
|
+
function computeScriptHash(scriptFiles) {
|
|
72
|
+
const h = createHash("sha256");
|
|
73
|
+
for (const f of scriptFiles) h.update(readFileSync(f, "utf-8"));
|
|
74
|
+
return h.digest("hex");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function pathToSlug(path) {
|
|
78
|
+
const t = path
|
|
79
|
+
.replace(/^\/|\/$/g, "")
|
|
80
|
+
.replace(/\//g, "-")
|
|
81
|
+
.replace(/:/g, "");
|
|
82
|
+
return t || "index";
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readCache(cacheDir, slug) {
|
|
86
|
+
try {
|
|
87
|
+
return JSON.parse(readFileSync(join(cacheDir, `${slug}.json`), "utf-8"));
|
|
88
|
+
} catch {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function writeCache(cacheDir, slug, key, data) {
|
|
94
|
+
writeFileSync(join(cacheDir, `${slug}.json`), JSON.stringify({ key, data }));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function computeCacheKey(componentHash, manifestContent, config, scriptHash, locale, messagesJson) {
|
|
98
|
+
const h = createHash("sha256");
|
|
99
|
+
h.update(componentHash);
|
|
100
|
+
h.update(manifestContent);
|
|
101
|
+
h.update(JSON.stringify(config));
|
|
102
|
+
h.update(scriptHash);
|
|
103
|
+
if (locale) h.update(locale);
|
|
104
|
+
if (messagesJson) h.update(messagesJson);
|
|
105
|
+
return h.digest("hex").slice(0, 16);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let _createI18n = null;
|
|
109
|
+
async function buildI18nValue(locale, messages, defaultLocale) {
|
|
110
|
+
if (!_createI18n) {
|
|
111
|
+
const mod = await import("@canmi/seam-i18n");
|
|
112
|
+
_createI18n = mod.createI18n;
|
|
113
|
+
}
|
|
114
|
+
const localeMessages = messages?.[locale] || {};
|
|
115
|
+
const fallback =
|
|
116
|
+
defaultLocale && locale !== defaultLocale ? messages?.[defaultLocale] || {} : undefined;
|
|
117
|
+
const instance = _createI18n(locale, localeMessages, fallback);
|
|
118
|
+
const usedKeys = new Set();
|
|
119
|
+
const origT = instance.t;
|
|
120
|
+
return {
|
|
121
|
+
locale: instance.locale,
|
|
122
|
+
t(key, params) {
|
|
123
|
+
usedKeys.add(key);
|
|
124
|
+
return origT(key, params);
|
|
125
|
+
},
|
|
126
|
+
_usedKeys: usedKeys,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export {
|
|
131
|
+
parseComponentImports,
|
|
132
|
+
computeComponentHashes,
|
|
133
|
+
computeScriptHash,
|
|
134
|
+
pathToSlug,
|
|
135
|
+
readCache,
|
|
136
|
+
writeCache,
|
|
137
|
+
computeCacheKey,
|
|
138
|
+
buildI18nValue,
|
|
139
|
+
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/* packages/client/react/scripts/skeleton/layout.mjs */
|
|
2
|
+
|
|
3
|
+
import { createElement } from "react";
|
|
4
|
+
import { buildSentinelData } from "@canmi/seam-react";
|
|
5
|
+
import {
|
|
6
|
+
generateMockFromSchema,
|
|
7
|
+
flattenLoaderMock,
|
|
8
|
+
deepMerge,
|
|
9
|
+
collectHtmlPaths,
|
|
10
|
+
createAccessTracker,
|
|
11
|
+
checkFieldAccess,
|
|
12
|
+
} from "../mock-generator.mjs";
|
|
13
|
+
import { guardedRender } from "./render.mjs";
|
|
14
|
+
import { buildPageSchema } from "./schema.mjs";
|
|
15
|
+
|
|
16
|
+
function toLayoutId(path) {
|
|
17
|
+
return path === "/"
|
|
18
|
+
? "_layout_root"
|
|
19
|
+
: `_layout_${path.replace(/^\/|\/$/g, "").replace(/\//g, "-")}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Extract layout components and metadata from route tree */
|
|
23
|
+
function extractLayouts(routes) {
|
|
24
|
+
const seen = new Map();
|
|
25
|
+
(function walk(defs, parentId) {
|
|
26
|
+
for (const def of defs) {
|
|
27
|
+
if (def.layout && def.children) {
|
|
28
|
+
const id = toLayoutId(def.path);
|
|
29
|
+
if (!seen.has(id)) {
|
|
30
|
+
seen.set(id, {
|
|
31
|
+
component: def.layout,
|
|
32
|
+
loaders: def.loaders || {},
|
|
33
|
+
mock: def.mock || null,
|
|
34
|
+
parentId: parentId || null,
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
walk(def.children, id);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
})(routes, null);
|
|
41
|
+
return seen;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Resolve mock data for a layout: auto-generate from schema when loaders exist,
|
|
46
|
+
* then deep-merge any user-provided partial mock on top.
|
|
47
|
+
* Unlike resolveRouteMock, a layout with no loaders and no mock is valid (empty shell).
|
|
48
|
+
*/
|
|
49
|
+
function resolveLayoutMock(entry, manifest) {
|
|
50
|
+
if (Object.keys(entry.loaders).length > 0) {
|
|
51
|
+
const schema = buildPageSchema(entry, manifest);
|
|
52
|
+
if (schema) {
|
|
53
|
+
const keyedMock = generateMockFromSchema(schema);
|
|
54
|
+
const autoMock = flattenLoaderMock(keyedMock);
|
|
55
|
+
return entry.mock ? deepMerge(autoMock, entry.mock) : autoMock;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return entry.mock || {};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render layout with seam-outlet placeholder, optionally with sentinel data.
|
|
63
|
+
* @param {{ buildWarnings: string[], seenWarnings: Set<string> }} ctx - shared warning state
|
|
64
|
+
*/
|
|
65
|
+
function renderLayout(LayoutComponent, id, entry, manifest, i18nValue, ctx) {
|
|
66
|
+
const mock = resolveLayoutMock(entry, manifest);
|
|
67
|
+
const schema =
|
|
68
|
+
Object.keys(entry.loaders || {}).length > 0 ? buildPageSchema(entry, manifest) : null;
|
|
69
|
+
const htmlPaths = schema ? collectHtmlPaths(schema) : new Set();
|
|
70
|
+
const data = Object.keys(mock).length > 0 ? buildSentinelData(mock, "", htmlPaths) : {};
|
|
71
|
+
|
|
72
|
+
// Wrap data with Proxy to detect schema/component field mismatches
|
|
73
|
+
const accessed = new Set();
|
|
74
|
+
const trackedData = Object.keys(data).length > 0 ? createAccessTracker(data, accessed) : data;
|
|
75
|
+
|
|
76
|
+
function LayoutWithOutlet() {
|
|
77
|
+
return createElement(LayoutComponent, null, createElement("seam-outlet", null));
|
|
78
|
+
}
|
|
79
|
+
const html = guardedRender(`layout:${id}`, LayoutWithOutlet, trackedData, i18nValue, ctx);
|
|
80
|
+
|
|
81
|
+
const fieldWarnings = checkFieldAccess(accessed, schema, `layout:${id}`);
|
|
82
|
+
for (const w of fieldWarnings) {
|
|
83
|
+
const msg = `[seam] warning: ${w}`;
|
|
84
|
+
if (!ctx.seenWarnings.has(msg)) {
|
|
85
|
+
ctx.seenWarnings.add(msg);
|
|
86
|
+
ctx.buildWarnings.push(msg);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return html;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Flatten routes, annotating each leaf with its parent layout id */
|
|
94
|
+
function flattenRoutes(routes, currentLayout) {
|
|
95
|
+
const leaves = [];
|
|
96
|
+
for (const route of routes) {
|
|
97
|
+
if (route.layout && route.children) {
|
|
98
|
+
leaves.push(...flattenRoutes(route.children, toLayoutId(route.path)));
|
|
99
|
+
} else if (route.children) {
|
|
100
|
+
leaves.push(...flattenRoutes(route.children, currentLayout));
|
|
101
|
+
} else {
|
|
102
|
+
if (currentLayout) route._layoutId = currentLayout;
|
|
103
|
+
leaves.push(route);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return leaves;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export { toLayoutId, extractLayouts, resolveLayoutMock, renderLayout, flattenRoutes };
|
|
@@ -0,0 +1,188 @@
|
|
|
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
|
+
async function processLayoutsWithCache(layoutMap, ctx) {
|
|
8
|
+
return Promise.all(
|
|
9
|
+
[...layoutMap.entries()].map(async ([id, entry]) => {
|
|
10
|
+
// i18n: render once per locale, return localeHtml map
|
|
11
|
+
if (ctx.i18n) {
|
|
12
|
+
const localeHtml = {};
|
|
13
|
+
let collectedKeys = null;
|
|
14
|
+
for (const locale of ctx.i18n.locales) {
|
|
15
|
+
const i18nValue = await buildI18nValue(locale, ctx.i18n.messages, ctx.i18n.default);
|
|
16
|
+
const messagesJson = JSON.stringify(ctx.i18n.messages?.[locale] || {});
|
|
17
|
+
const compHash = ctx.componentHashes.get(entry.component?.name);
|
|
18
|
+
if (compHash) {
|
|
19
|
+
const config = { id, loaders: entry.loaders, mock: entry.mock };
|
|
20
|
+
const key = computeCacheKey(
|
|
21
|
+
compHash,
|
|
22
|
+
ctx.manifestContent,
|
|
23
|
+
config,
|
|
24
|
+
ctx.scriptHash,
|
|
25
|
+
locale,
|
|
26
|
+
messagesJson,
|
|
27
|
+
);
|
|
28
|
+
const slug = `layout_${id}_${locale}`;
|
|
29
|
+
const cached = readCache(ctx.cacheDir, slug);
|
|
30
|
+
if (cached && cached.key === key) {
|
|
31
|
+
ctx.stats.hits++;
|
|
32
|
+
localeHtml[locale] = cached.data.html;
|
|
33
|
+
if (!collectedKeys) collectedKeys = cached.data.i18nKeys;
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
const html = renderLayout(
|
|
37
|
+
entry.component,
|
|
38
|
+
id,
|
|
39
|
+
entry,
|
|
40
|
+
ctx.manifest,
|
|
41
|
+
i18nValue,
|
|
42
|
+
ctx.warnCtx,
|
|
43
|
+
);
|
|
44
|
+
const i18nKeys = [...i18nValue._usedKeys].sort();
|
|
45
|
+
writeCache(ctx.cacheDir, slug, key, { html, i18nKeys });
|
|
46
|
+
ctx.stats.misses++;
|
|
47
|
+
localeHtml[locale] = html;
|
|
48
|
+
if (!collectedKeys) collectedKeys = i18nKeys;
|
|
49
|
+
} else {
|
|
50
|
+
ctx.stats.misses++;
|
|
51
|
+
const html = renderLayout(
|
|
52
|
+
entry.component,
|
|
53
|
+
id,
|
|
54
|
+
entry,
|
|
55
|
+
ctx.manifest,
|
|
56
|
+
i18nValue,
|
|
57
|
+
ctx.warnCtx,
|
|
58
|
+
);
|
|
59
|
+
const i18nKeys = [...i18nValue._usedKeys].sort();
|
|
60
|
+
localeHtml[locale] = html;
|
|
61
|
+
if (!collectedKeys) collectedKeys = i18nKeys;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
id,
|
|
66
|
+
localeHtml,
|
|
67
|
+
loaders: entry.loaders,
|
|
68
|
+
parent: entry.parentId,
|
|
69
|
+
i18nKeys: collectedKeys || [],
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// No i18n: original behavior
|
|
74
|
+
const compHash = ctx.componentHashes.get(entry.component?.name);
|
|
75
|
+
if (compHash) {
|
|
76
|
+
const config = { id, loaders: entry.loaders, mock: entry.mock };
|
|
77
|
+
const key = computeCacheKey(compHash, ctx.manifestContent, config, ctx.scriptHash);
|
|
78
|
+
const slug = `layout_${id}`;
|
|
79
|
+
const cached = readCache(ctx.cacheDir, slug);
|
|
80
|
+
if (cached && cached.key === key) {
|
|
81
|
+
ctx.stats.hits++;
|
|
82
|
+
return cached.data;
|
|
83
|
+
}
|
|
84
|
+
const data = {
|
|
85
|
+
id,
|
|
86
|
+
html: renderLayout(entry.component, id, entry, ctx.manifest, undefined, ctx.warnCtx),
|
|
87
|
+
loaders: entry.loaders,
|
|
88
|
+
parent: entry.parentId,
|
|
89
|
+
};
|
|
90
|
+
writeCache(ctx.cacheDir, slug, key, data);
|
|
91
|
+
ctx.stats.misses++;
|
|
92
|
+
return data;
|
|
93
|
+
}
|
|
94
|
+
ctx.stats.misses++;
|
|
95
|
+
return {
|
|
96
|
+
id,
|
|
97
|
+
html: renderLayout(entry.component, id, entry, ctx.manifest, undefined, ctx.warnCtx),
|
|
98
|
+
loaders: entry.loaders,
|
|
99
|
+
parent: entry.parentId,
|
|
100
|
+
};
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function processRoutesWithCache(flat, ctx) {
|
|
106
|
+
return Promise.all(
|
|
107
|
+
flat.map(async (r) => {
|
|
108
|
+
// i18n: render once per locale, return localeVariants map
|
|
109
|
+
if (ctx.i18n) {
|
|
110
|
+
const localeVariants = {};
|
|
111
|
+
let collectedKeys = null;
|
|
112
|
+
for (const locale of ctx.i18n.locales) {
|
|
113
|
+
const i18nValue = await buildI18nValue(locale, ctx.i18n.messages, ctx.i18n.default);
|
|
114
|
+
const messagesJson = JSON.stringify(ctx.i18n.messages?.[locale] || {});
|
|
115
|
+
const compHash = ctx.componentHashes.get(r.component?.name);
|
|
116
|
+
if (compHash) {
|
|
117
|
+
const config = { path: r.path, loaders: r.loaders, mock: r.mock, nullable: r.nullable };
|
|
118
|
+
const key = computeCacheKey(
|
|
119
|
+
compHash,
|
|
120
|
+
ctx.manifestContent,
|
|
121
|
+
config,
|
|
122
|
+
ctx.scriptHash,
|
|
123
|
+
locale,
|
|
124
|
+
messagesJson,
|
|
125
|
+
);
|
|
126
|
+
const slug = `route_${pathToSlug(r.path)}_${locale}`;
|
|
127
|
+
const cached = readCache(ctx.cacheDir, slug);
|
|
128
|
+
if (cached && cached.key === key) {
|
|
129
|
+
ctx.stats.hits++;
|
|
130
|
+
localeVariants[locale] = cached.data;
|
|
131
|
+
if (!collectedKeys) collectedKeys = cached.data.i18nKeys;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
const data = renderRoute(r, ctx.manifest, i18nValue, ctx.warnCtx);
|
|
135
|
+
data.i18nKeys = [...i18nValue._usedKeys].sort();
|
|
136
|
+
writeCache(ctx.cacheDir, slug, key, data);
|
|
137
|
+
ctx.stats.misses++;
|
|
138
|
+
localeVariants[locale] = data;
|
|
139
|
+
if (!collectedKeys) collectedKeys = data.i18nKeys;
|
|
140
|
+
} else {
|
|
141
|
+
ctx.stats.misses++;
|
|
142
|
+
const data = renderRoute(r, ctx.manifest, i18nValue, ctx.warnCtx);
|
|
143
|
+
data.i18nKeys = [...i18nValue._usedKeys].sort();
|
|
144
|
+
localeVariants[locale] = data;
|
|
145
|
+
if (!collectedKeys) collectedKeys = data.i18nKeys;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// Combine per-locale data into the expected output format
|
|
149
|
+
const first = localeVariants[ctx.i18n.locales[0]];
|
|
150
|
+
return {
|
|
151
|
+
path: r.path,
|
|
152
|
+
loaders: first.loaders,
|
|
153
|
+
layout: first.layout,
|
|
154
|
+
mock: first.mock,
|
|
155
|
+
pageSchema: first.pageSchema,
|
|
156
|
+
i18nKeys: collectedKeys || [],
|
|
157
|
+
localeVariants: Object.fromEntries(
|
|
158
|
+
Object.entries(localeVariants).map(([loc, data]) => [
|
|
159
|
+
loc,
|
|
160
|
+
{ axes: data.axes, variants: data.variants, mockHtml: data.mockHtml },
|
|
161
|
+
]),
|
|
162
|
+
),
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// No i18n: original behavior
|
|
167
|
+
const compHash = ctx.componentHashes.get(r.component?.name);
|
|
168
|
+
if (compHash) {
|
|
169
|
+
const config = { path: r.path, loaders: r.loaders, mock: r.mock, nullable: r.nullable };
|
|
170
|
+
const key = computeCacheKey(compHash, ctx.manifestContent, config, ctx.scriptHash);
|
|
171
|
+
const slug = `route_${pathToSlug(r.path)}`;
|
|
172
|
+
const cached = readCache(ctx.cacheDir, slug);
|
|
173
|
+
if (cached && cached.key === key) {
|
|
174
|
+
ctx.stats.hits++;
|
|
175
|
+
return cached.data;
|
|
176
|
+
}
|
|
177
|
+
const data = renderRoute(r, ctx.manifest, undefined, ctx.warnCtx);
|
|
178
|
+
writeCache(ctx.cacheDir, slug, key, data);
|
|
179
|
+
ctx.stats.misses++;
|
|
180
|
+
return data;
|
|
181
|
+
}
|
|
182
|
+
ctx.stats.misses++;
|
|
183
|
+
return renderRoute(r, ctx.manifest, undefined, ctx.warnCtx);
|
|
184
|
+
}),
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
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 };
|