@cocreate/render 1.53.0 → 2.0.0
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/.github/FUNDING.yml +1 -1
- package/.github/workflows/automated.yml +18 -7
- package/CHANGELOG.md +691 -652
- package/CONTRIBUTING.md +6 -6
- package/README.md +48 -18
- package/demo/fetch.string.html +2 -2
- package/demo/index.html +1 -1
- package/demo/render.array.html +1 -1
- package/demo/render.html +1 -1
- package/demo/render.object.html +1 -1
- package/demo/renderKey.html +1 -1
- package/docs/index.html +7 -7
- package/package.json +16 -22
- package/src/client/index.css +2 -0
- package/src/{index.js → client/index.js} +270 -149
- package/src/client/package.json +3 -0
- package/src/server/index.js +801 -0
- package/src/server/package.json +3 -0
- package/test/{render-order.test.cjs → client/render-order.test.cjs} +1 -1
- package/test/client/render-value-ast.test.cjs +58 -0
- package/test/server/deferred-file.test.js +14 -0
- package/test/server/file-access.test.js +9 -0
- package/test/server/package.json +3 -0
- package/test/server/stylesheet-links.test.js +103 -0
- package/test/server/theme.test.js +21 -0
- package/test/server/value-transformations.test.js +87 -0
- package/webpack.config.js +1 -1
- package/src/index.css +0 -1
|
@@ -0,0 +1,801 @@
|
|
|
1
|
+
import { checkValue, ObjectId, HTMLScanner, getValueAttribute, transformValue, convertValue } from "@cocreate/utils";
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
function transformChunk(element, value) {
|
|
5
|
+
value = transformValue(element, value, "set-value");
|
|
6
|
+
value = convertValue(value, getValueAttribute(element, "type", "set-value") || "");
|
|
7
|
+
return typeof value === "object" ? JSON.stringify(value, null, 2) : String(value);
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// Elements that should not have generic [src] fetch logic applied
|
|
11
|
+
const SKIP_SRC_ELEMENTS = new Set([
|
|
12
|
+
"SCRIPT", "IMG", "IFRAME", "AUDIO", "VIDEO", "SOURCE", "TRACK", "INPUT", "EMBED", "FRAME"
|
|
13
|
+
]);
|
|
14
|
+
|
|
15
|
+
// Element types ignored during data attribute rendering
|
|
16
|
+
const IGNORE_ELEMENTS = new Set([
|
|
17
|
+
"FORM", "INPUT", "TEXTAREA", "SELECT", "LINK", "IFRAME", "COCREATE-SELECT"
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
// Default maximum allowed recursion depth (0 disables the depth limit for infinite scaling)
|
|
21
|
+
const DEFAULT_MAX_DEPTH = 25;
|
|
22
|
+
|
|
23
|
+
function parseState(value) {
|
|
24
|
+
if (!value) return {};
|
|
25
|
+
if (typeof value === "object" && !Array.isArray(value)) return value;
|
|
26
|
+
if (typeof value !== "string") return {};
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const parsed = JSON.parse(value);
|
|
30
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
31
|
+
? parsed
|
|
32
|
+
: {};
|
|
33
|
+
} catch (_) {
|
|
34
|
+
return {};
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isEnabled(value) {
|
|
39
|
+
return value === "" || (Boolean(value) && value !== "false");
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function applyState(doc, state) {
|
|
43
|
+
if (!state || !Object.keys(state).length) return;
|
|
44
|
+
|
|
45
|
+
const elements = doc.querySelectorAll({ attributes: ["state-id"] });
|
|
46
|
+
for (const element of elements) {
|
|
47
|
+
const stateIds = String(element.getAttribute("state-id") || "")
|
|
48
|
+
.split(",")
|
|
49
|
+
.map(value => value.trim())
|
|
50
|
+
.filter(Boolean);
|
|
51
|
+
|
|
52
|
+
for (const stateId of stateIds) {
|
|
53
|
+
const values = state[stateId];
|
|
54
|
+
if (!values || typeof values !== "object" || Array.isArray(values)) continue;
|
|
55
|
+
|
|
56
|
+
const overwriteAttribute = element.getAttribute("state-overwrite");
|
|
57
|
+
const overwrite = isEnabled(
|
|
58
|
+
overwriteAttribute === null || overwriteAttribute === undefined
|
|
59
|
+
? values.overwrite
|
|
60
|
+
: overwriteAttribute
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
for (const [attribute, value] of Object.entries(values)) {
|
|
64
|
+
if (value === undefined || value === null || attribute.startsWith("$")) continue;
|
|
65
|
+
|
|
66
|
+
if (attribute === "class" && value) {
|
|
67
|
+
const classes = new Set(
|
|
68
|
+
String(element.getAttribute("class") || "")
|
|
69
|
+
.split(/\s+/)
|
|
70
|
+
.filter(Boolean)
|
|
71
|
+
);
|
|
72
|
+
for (const name of String(value).split(/\s+/).filter(Boolean)) {
|
|
73
|
+
classes.add(name);
|
|
74
|
+
}
|
|
75
|
+
element.setAttribute("class", [...classes].join(" "));
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const currentValue = element.getAttribute(attribute);
|
|
80
|
+
if (
|
|
81
|
+
element.hasAttribute(attribute) &&
|
|
82
|
+
(!currentValue || overwrite)
|
|
83
|
+
) {
|
|
84
|
+
element.setAttribute(attribute, String(value));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Extracts <head> content from chunk HTML and merges title, meta, link, script tags into root HTML head.
|
|
93
|
+
* Completely powered by HTMLScanner handles.
|
|
94
|
+
* @param {string} rootHtml - Main host HTML string
|
|
95
|
+
* @param {string} chunkHtml - Fetched chunk HTML string
|
|
96
|
+
* @returns {{ rootHtml: string, cleanChunkHtml: string }}
|
|
97
|
+
* @private
|
|
98
|
+
* @environment server
|
|
99
|
+
*/
|
|
100
|
+
function extractAndMergeHead(rootHtml, chunkHtml) {
|
|
101
|
+
const chunkDoc = new HTMLScanner(chunkHtml);
|
|
102
|
+
const headHandles = chunkDoc.querySelectorAll({ tags: ["HEAD"] });
|
|
103
|
+
|
|
104
|
+
if (headHandles.length === 0) {
|
|
105
|
+
return { rootHtml, cleanChunkHtml: chunkHtml };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const chunkHeadHandle = headHandles[0];
|
|
109
|
+
const chunkHeadContent = chunkHeadHandle.getInnerHTML();
|
|
110
|
+
|
|
111
|
+
// Remove <head> tag from chunk
|
|
112
|
+
chunkHeadHandle.replaceWith("");
|
|
113
|
+
const cleanChunkHtml = chunkDoc.toString();
|
|
114
|
+
|
|
115
|
+
const rootDoc = new HTMLScanner(rootHtml);
|
|
116
|
+
const rootHeadHandles = rootDoc.querySelectorAll({ tags: ["HEAD"] });
|
|
117
|
+
|
|
118
|
+
if (rootHeadHandles.length > 0) {
|
|
119
|
+
const rootHeadHandle = rootHeadHandles[0];
|
|
120
|
+
const existingContent = rootHeadHandle.getInnerHTML();
|
|
121
|
+
rootHeadHandle.setInnerHTML(`${existingContent}\n${chunkHeadContent}\n`);
|
|
122
|
+
rootHtml = rootDoc.toString();
|
|
123
|
+
} else {
|
|
124
|
+
// Inject <head> into root html tag if missing
|
|
125
|
+
const rootHtmlHandles = rootDoc.querySelectorAll({ tags: ["HTML"] });
|
|
126
|
+
if (rootHtmlHandles.length > 0) {
|
|
127
|
+
const htmlHandle = rootHtmlHandles[0];
|
|
128
|
+
const existingInner = htmlHandle.getInnerHTML();
|
|
129
|
+
htmlHandle.setInnerHTML(`<head>\n${chunkHeadContent}\n</head>\n${existingInner}`);
|
|
130
|
+
rootHtml = rootDoc.toString();
|
|
131
|
+
} else {
|
|
132
|
+
rootHtml = `<head>\n${chunkHeadContent}\n</head>\n` + rootHtml;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { rootHtml, cleanChunkHtml };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function extractStylesheetLinks(html, rootHtml = html) {
|
|
140
|
+
// Fragments keep their links in place unless the host already has a head.
|
|
141
|
+
const rootDoc = new HTMLScanner(rootHtml);
|
|
142
|
+
if (!rootDoc.querySelectorAll({ tags: ["HEAD"] }).length) {
|
|
143
|
+
return { cleanHtml: html, links: [] };
|
|
144
|
+
}
|
|
145
|
+
const doc = new HTMLScanner(html);
|
|
146
|
+
const links = [];
|
|
147
|
+
const linkHandles = doc.querySelectorAll({ tags: ["LINK"] });
|
|
148
|
+
|
|
149
|
+
for (const link of linkHandles) {
|
|
150
|
+
const rel = String(link.getAttribute("rel") || "")
|
|
151
|
+
.toLowerCase()
|
|
152
|
+
.split(/\s+/)
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
if (
|
|
155
|
+
!rel.includes("stylesheet") ||
|
|
156
|
+
!link.getAttribute("href") ||
|
|
157
|
+
link.hasAttribute("array") ||
|
|
158
|
+
link.hasAttribute("object")
|
|
159
|
+
) continue;
|
|
160
|
+
|
|
161
|
+
links.push({
|
|
162
|
+
html: link.getOuterHTML(),
|
|
163
|
+
href: link.getAttribute("href"),
|
|
164
|
+
media: link.getAttribute("media") || ""
|
|
165
|
+
});
|
|
166
|
+
link.replaceWith("");
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return { cleanHtml: doc.toString(), links };
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function mergeStylesheetLinks(html, links) {
|
|
173
|
+
if (!links.length) return html;
|
|
174
|
+
|
|
175
|
+
const unique = new Map();
|
|
176
|
+
for (const link of links) {
|
|
177
|
+
const key = `${link.href}\u0000${link.media}`;
|
|
178
|
+
if (!unique.has(key)) unique.set(key, link.html);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const content = [...unique.values()].join("\n");
|
|
182
|
+
const doc = new HTMLScanner(html);
|
|
183
|
+
const headHandles = doc.querySelectorAll({ tags: ["HEAD"] });
|
|
184
|
+
|
|
185
|
+
if (headHandles.length > 0) {
|
|
186
|
+
const head = headHandles[0];
|
|
187
|
+
head.setInnerHTML(`${head.getInnerHTML()}\n${content}\n`);
|
|
188
|
+
return doc.toString();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const htmlHandles = doc.querySelectorAll({ tags: ["HTML"] });
|
|
192
|
+
if (htmlHandles.length > 0) {
|
|
193
|
+
const root = htmlHandles[0];
|
|
194
|
+
root.setInnerHTML(`<head>\n${content}\n</head>\n${root.getInnerHTML()}`);
|
|
195
|
+
return doc.toString();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return `<head>\n${content}\n</head>\n${html}`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Applies theme attribute to <html> tag and injects color-scheme meta tag into <head> using HTMLScanner
|
|
203
|
+
* @param {string} html - Full document HTML string
|
|
204
|
+
* @param {string} theme - Selected theme name (e.g., 'dark')
|
|
205
|
+
* @returns {string} Modified HTML
|
|
206
|
+
* @private
|
|
207
|
+
* @environment server
|
|
208
|
+
*/
|
|
209
|
+
function applyTheme(html, theme) {
|
|
210
|
+
const doc = new HTMLScanner(html);
|
|
211
|
+
|
|
212
|
+
// Apply color-scheme attribute on <html> element
|
|
213
|
+
const htmlHandles = doc.querySelectorAll({ tags: ["HTML"] });
|
|
214
|
+
const selectedTheme = htmlHandles[0]?.getAttribute("color-scheme");
|
|
215
|
+
if (selectedTheme === "dark" || selectedTheme === "light") theme = selectedTheme;
|
|
216
|
+
if (theme !== "dark" && theme !== "light") return html;
|
|
217
|
+
if (htmlHandles.length > 0) {
|
|
218
|
+
htmlHandles[0].setAttribute("color-scheme", theme);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// Update or inject <meta name="color-scheme">
|
|
222
|
+
const metaHandles = doc.querySelectorAll({ tags: ["META"] });
|
|
223
|
+
let colorSchemeMeta = metaHandles.find(h => h.getAttribute("name") === "color-scheme");
|
|
224
|
+
|
|
225
|
+
if (colorSchemeMeta) {
|
|
226
|
+
colorSchemeMeta.setAttribute("content", theme);
|
|
227
|
+
} else {
|
|
228
|
+
const headHandles = doc.querySelectorAll({ tags: ["HEAD"] });
|
|
229
|
+
if (headHandles.length > 0) {
|
|
230
|
+
const existingHeadContent = headHandles[0].getInnerHTML();
|
|
231
|
+
headHandles[0].setInnerHTML(`${existingHeadContent}\n<meta name="color-scheme" content="${theme}">`);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return doc.toString();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Generates <link rel="alternate" ...> tags for organization languages.
|
|
240
|
+
* @param {Object} file - File object
|
|
241
|
+
* @param {Object} organization - Organization metadata
|
|
242
|
+
* @param {Object} urlObject - Request URL
|
|
243
|
+
* @returns {string} Link tags string
|
|
244
|
+
* @private
|
|
245
|
+
* @environment server
|
|
246
|
+
*/
|
|
247
|
+
function createLanguageLinkTags(file, organization, urlObject) {
|
|
248
|
+
if (!organization?.languages?.length) return "";
|
|
249
|
+
|
|
250
|
+
let tags = `<link rel="alternate" hreflang="x-default" href="https://${urlObject.hostname}${file.pathname}">\n`;
|
|
251
|
+
for (const language of organization.languages) {
|
|
252
|
+
const hrefUrl = `https://${urlObject.hostname}/${language}${file.pathname}`;
|
|
253
|
+
tags += `<link rel="alternate" hreflang="${language}" href="${hrefUrl}">\n`;
|
|
254
|
+
}
|
|
255
|
+
return tags;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Merges target language dictionary entries from a chunk's translation object into the unified translations accumulator.
|
|
260
|
+
* @param {Record<string, Record<string, Object>>} targetMap - Dynamic accumulator map holding merged translations.
|
|
261
|
+
* @param {Record<string, Record<string, Object>>} sourceTranslations - Translation object from a fetched file.
|
|
262
|
+
* @param {string} [langRegion] - Target locale code (e.g., "en-US").
|
|
263
|
+
* @param {string} [lang] - Fallback primary language code (e.g., "en").
|
|
264
|
+
* @private
|
|
265
|
+
* @environment server
|
|
266
|
+
*/
|
|
267
|
+
function mergeTranslationSource(targetMap, sourceTranslations, langRegion, lang) {
|
|
268
|
+
if (!sourceTranslations) return;
|
|
269
|
+
|
|
270
|
+
const targetLangKey = (langRegion && sourceTranslations[langRegion])
|
|
271
|
+
? langRegion
|
|
272
|
+
: (lang && sourceTranslations[lang])
|
|
273
|
+
? lang
|
|
274
|
+
: null;
|
|
275
|
+
|
|
276
|
+
if (!targetLangKey || !sourceTranslations[targetLangKey]) return;
|
|
277
|
+
|
|
278
|
+
if (!targetMap[targetLangKey]) {
|
|
279
|
+
targetMap[targetLangKey] = {};
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
Object.assign(targetMap[targetLangKey], sourceTranslations[targetLangKey]);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Fast Language-First Translator operating directly via HTMLScanner handles.
|
|
287
|
+
* Scans for `[i18n]` attributes and replaces element content and attributes.
|
|
288
|
+
* @private
|
|
289
|
+
* @environment server
|
|
290
|
+
*/
|
|
291
|
+
function translate(html, translations, langRegion, lang) {
|
|
292
|
+
if (!html || !translations || (!langRegion && !lang)) return html;
|
|
293
|
+
|
|
294
|
+
const langDict = translations[langRegion] || translations[lang];
|
|
295
|
+
if (!langDict) return html;
|
|
296
|
+
|
|
297
|
+
const doc = new HTMLScanner(html);
|
|
298
|
+
const elements = doc.querySelectorAll({ attributes: ["i18n"] });
|
|
299
|
+
|
|
300
|
+
for (const el of elements) {
|
|
301
|
+
const key = el.getAttribute("i18n");
|
|
302
|
+
const values = langDict[key];
|
|
303
|
+
if (!values) continue;
|
|
304
|
+
|
|
305
|
+
for (const [attr, value] of Object.entries(values)) {
|
|
306
|
+
if (attr === "text" || value === undefined || value === null) continue;
|
|
307
|
+
el.setAttribute(attr, String(value));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (values.text !== undefined && values.text !== null && !el.isSelfClosing) {
|
|
311
|
+
let contentStr = "";
|
|
312
|
+
if (el.tagName === "SCRIPT" && typeof values.text === "object") {
|
|
313
|
+
contentStr = JSON.stringify(values.text, null, 2);
|
|
314
|
+
} else {
|
|
315
|
+
contentStr = String(values.text);
|
|
316
|
+
}
|
|
317
|
+
el.setInnerHTML(contentStr);
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
let translatedHtml = doc.toString();
|
|
322
|
+
|
|
323
|
+
// Fallback for application/ld+json script if keyed as "ld+json" without explicit attribute
|
|
324
|
+
if (langDict["ld+json"]) {
|
|
325
|
+
const jsonDoc = new HTMLScanner(translatedHtml);
|
|
326
|
+
const scriptHandles = jsonDoc.querySelectorAll({ tags: ["SCRIPT"] });
|
|
327
|
+
for (const el of scriptHandles) {
|
|
328
|
+
if (el.getAttribute("type") === "application/ld+json") {
|
|
329
|
+
const values = langDict["ld+json"];
|
|
330
|
+
const content = values.text || values;
|
|
331
|
+
const scriptContent = typeof content === "object" ? JSON.stringify(content, null, 2) : String(content);
|
|
332
|
+
el.setInnerHTML(scriptContent);
|
|
333
|
+
break;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
translatedHtml = jsonDoc.toString();
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return translatedHtml;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/**
|
|
343
|
+
* Main functional entry point for Server-Side Rendering using HTMLScanner.
|
|
344
|
+
* @param {Object} options - SSR options
|
|
345
|
+
* @returns {Promise<string>} Fully rendered and translated HTML string
|
|
346
|
+
* @public
|
|
347
|
+
* @environment server
|
|
348
|
+
*/
|
|
349
|
+
export async function HTML({
|
|
350
|
+
file,
|
|
351
|
+
organization,
|
|
352
|
+
urlObject,
|
|
353
|
+
langRegion,
|
|
354
|
+
lang,
|
|
355
|
+
theme,
|
|
356
|
+
state,
|
|
357
|
+
resolveFile,
|
|
358
|
+
crud,
|
|
359
|
+
maxDepth = DEFAULT_MAX_DEPTH
|
|
360
|
+
}) {
|
|
361
|
+
let dep = [];
|
|
362
|
+
const dbCache = new Map();
|
|
363
|
+
const stylesheetLinks = [];
|
|
364
|
+
const activeState = parseState(state);
|
|
365
|
+
|
|
366
|
+
// Accumulate translations from root and recursively included files
|
|
367
|
+
let activeTranslations = {};
|
|
368
|
+
if (file?.translations) {
|
|
369
|
+
mergeTranslationSource(activeTranslations, file.translations, langRegion, lang);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const organization_id = file?.organization_id;
|
|
373
|
+
const host = urlObject?.hostname;
|
|
374
|
+
const rootFilePath = file?.path || file?.pathname || "/";
|
|
375
|
+
let rootHtml = file?.src || "";
|
|
376
|
+
|
|
377
|
+
async function renderRecursive(currentHtml, lastKey, currentFilePath) {
|
|
378
|
+
// If maxDepth is 0, depth limiting is disabled and allowed to scale infinitely
|
|
379
|
+
if (maxDepth > 0 && dep.length > maxDepth) {
|
|
380
|
+
throw new Error(`Maximum recursive depth of ${maxDepth} exceeded in SSR rendering chain.`);
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
let doc = new HTMLScanner(currentHtml);
|
|
384
|
+
applyState(doc, activeState);
|
|
385
|
+
doc = new HTMLScanner(doc.toString());
|
|
386
|
+
const targets = doc.querySelectorAll({
|
|
387
|
+
attributes: ["src", "array"],
|
|
388
|
+
excludeTags: ["SCRIPT", "IMG", "IFRAME", "AUDIO", "VIDEO", "SOURCE", "TRACK", "INPUT", "EMBED", "FRAME"]
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
for (const el of targets) {
|
|
392
|
+
let chunkStr = null;
|
|
393
|
+
try {
|
|
394
|
+
const isSrc = el.hasAttribute("src") && !SKIP_SRC_ELEMENTS.has(el.tagName);
|
|
395
|
+
const isData = el.hasAttribute("array");
|
|
396
|
+
|
|
397
|
+
// 1. Process File Source [src]
|
|
398
|
+
if (isSrc) {
|
|
399
|
+
let src = el.getAttribute("src");
|
|
400
|
+
if (src && typeof src === "string") {
|
|
401
|
+
let pathname = currentFilePath;
|
|
402
|
+
if (!pathname.endsWith("/")) pathname += "/";
|
|
403
|
+
|
|
404
|
+
pathname = new URL(src, `http://localhost${pathname}`).pathname;
|
|
405
|
+
if (pathname.endsWith("/")) pathname += "index.html";
|
|
406
|
+
|
|
407
|
+
const srcKey = `file:${pathname}`;
|
|
408
|
+
if (dep.includes(srcKey)) {
|
|
409
|
+
throw new Error(`Infinite loop detected: Parent context "${lastKey}" requested "${srcKey}", which is already in active chain: ${dep.join(" -> ")}`);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
dep.push(srcKey);
|
|
413
|
+
try {
|
|
414
|
+
let fetchedFile = null;
|
|
415
|
+
if (typeof resolveFile === "function") {
|
|
416
|
+
fetchedFile = await resolveFile(pathname);
|
|
417
|
+
} else if (crud) {
|
|
418
|
+
const data = await crud.send({
|
|
419
|
+
method: "object.read",
|
|
420
|
+
host,
|
|
421
|
+
array: "files",
|
|
422
|
+
object: "",
|
|
423
|
+
filter: { query: { pathname } },
|
|
424
|
+
state,
|
|
425
|
+
organization_id
|
|
426
|
+
});
|
|
427
|
+
fetchedFile = data?.object?.[0] || null;
|
|
428
|
+
const cacheControl = Object.entries(fetchedFile?.headers || {})
|
|
429
|
+
.find(([name]) => name.toLowerCase() === "cache-control")?.[1];
|
|
430
|
+
const publicFile = typeof cacheControl === "string" &&
|
|
431
|
+
/(?:^|,)\s*public\s*(?:,|$)/i.test(cacheControl) &&
|
|
432
|
+
!/(?:^|,)\s*(?:private|must-revalidate)\b/i.test(cacheControl);
|
|
433
|
+
if (fetchedFile && !publicFile) fetchedFile = { status: 403 };
|
|
434
|
+
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
let statusCode = fetchedFile?.status;
|
|
438
|
+
if (!statusCode) {
|
|
439
|
+
if (!fetchedFile || !fetchedFile.src) statusCode = 404;
|
|
440
|
+
else statusCode = 200;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (statusCode === 304) {
|
|
444
|
+
el.setAttribute("error", "304");
|
|
445
|
+
el.removeAttribute("rendered");
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
if (statusCode >= 400) {
|
|
450
|
+
el.setAttribute("error", String(statusCode));
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (statusCode < 400 || el.hasAttribute("render-error")) {
|
|
454
|
+
if (fetchedFile?.translations) {
|
|
455
|
+
mergeTranslationSource(activeTranslations, fetchedFile.translations, langRegion, lang);
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let val = fetchedFile?.src;
|
|
459
|
+
if (typeof val === "string" && val.startsWith("data:image/svg+xml;base64,")) {
|
|
460
|
+
val = Buffer.from(val.split(",")[1], "base64").toString("utf-8");
|
|
461
|
+
}
|
|
462
|
+
if (val !== undefined && val !== null) {
|
|
463
|
+
chunkStr = transformChunk(el, val);
|
|
464
|
+
const stylesheets = extractStylesheetLinks(chunkStr, rootHtml);
|
|
465
|
+
stylesheetLinks.push(...stylesheets.links);
|
|
466
|
+
chunkStr = stylesheets.cleanHtml;
|
|
467
|
+
const merged = extractAndMergeHead(rootHtml, chunkStr);
|
|
468
|
+
rootHtml = merged.rootHtml;
|
|
469
|
+
chunkStr = await renderRecursive(merged.cleanChunkHtml, srcKey, pathname);
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
} finally {
|
|
473
|
+
dep.pop();
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
// 2. Process Data Source [array]
|
|
478
|
+
else if (isData) {
|
|
479
|
+
let shouldProcess = true;
|
|
480
|
+
|
|
481
|
+
if (
|
|
482
|
+
IGNORE_ELEMENTS.has(el.tagName) ||
|
|
483
|
+
!el.hasAttribute("key")
|
|
484
|
+
) {
|
|
485
|
+
shouldProcess = false;
|
|
486
|
+
} else if (el.hasAttribute("render-query") || el.hasAttribute("component") || el.hasAttribute("plugin") || el.hasAttribute("actions")) {
|
|
487
|
+
shouldProcess = false;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
if (shouldProcess && crud) {
|
|
491
|
+
const array = el.getAttribute("array");
|
|
492
|
+
const key = el.getAttribute("key");
|
|
493
|
+
const _id = el.getAttribute("object") || "";
|
|
494
|
+
const filterAttr = el.getAttribute("filter");
|
|
495
|
+
let filter = {};
|
|
496
|
+
|
|
497
|
+
if (filterAttr) {
|
|
498
|
+
try { filter = JSON.parse(filterAttr); } catch (e) { }
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
if (array && checkValue(array) && (!key || checkValue(key))) {
|
|
502
|
+
const keyStr = key ? `-${key}` : "";
|
|
503
|
+
const filterKeyStr = filterAttr ? `-${filterAttr}` : "";
|
|
504
|
+
const crudKey = `data:${_id}${array}${keyStr}${filterKeyStr}`;
|
|
505
|
+
|
|
506
|
+
if (dep.includes(crudKey)) {
|
|
507
|
+
throw new Error(`Infinite loop detected: Parent context "${lastKey}" requested data key "${crudKey}", which is already in active chain: ${dep.join(" -> ")}`);
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
dep.push(crudKey);
|
|
511
|
+
try {
|
|
512
|
+
const cacheKey = `${_id}${array}${filterKeyStr}`;
|
|
513
|
+
let data;
|
|
514
|
+
|
|
515
|
+
if (dbCache.has(cacheKey)) {
|
|
516
|
+
data = dbCache.get(cacheKey);
|
|
517
|
+
} else {
|
|
518
|
+
data = await crud.send({
|
|
519
|
+
method: "object.read",
|
|
520
|
+
host,
|
|
521
|
+
array,
|
|
522
|
+
object: _id ? { _id } : "",
|
|
523
|
+
filter,
|
|
524
|
+
state,
|
|
525
|
+
organization_id
|
|
526
|
+
});
|
|
527
|
+
if (data?.object?.[0]) data = data.object[0];
|
|
528
|
+
dbCache.set(cacheKey, data);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if (data) {
|
|
532
|
+
const chunkVal = key ? data[key] : data;
|
|
533
|
+
if (chunkVal !== undefined && chunkVal !== null) {
|
|
534
|
+
const rawChunk = transformChunk(el, chunkVal);
|
|
535
|
+
const stylesheets = extractStylesheetLinks(rawChunk, rootHtml);
|
|
536
|
+
stylesheetLinks.push(...stylesheets.links);
|
|
537
|
+
const merged = extractAndMergeHead(rootHtml, stylesheets.cleanHtml);
|
|
538
|
+
rootHtml = merged.rootHtml;
|
|
539
|
+
chunkStr = await renderRecursive(merged.cleanChunkHtml, crudKey, currentFilePath);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
} finally {
|
|
543
|
+
dep.pop();
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
if (chunkStr !== null) {
|
|
550
|
+
if (getValueAttribute(el, "type", "set-value")?.toLowerCase() === "outerhtml") {
|
|
551
|
+
el.replaceWith(chunkStr);
|
|
552
|
+
} else {
|
|
553
|
+
el.setAttribute("rendered", "true");
|
|
554
|
+
el.setInnerHTML(chunkStr);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
} catch (error) {
|
|
558
|
+
const statusCode = Number(error?.statusCode || error?.status);
|
|
559
|
+
el.setAttribute(
|
|
560
|
+
"error",
|
|
561
|
+
String(statusCode >= 400 && statusCode < 600 ? statusCode : 500)
|
|
562
|
+
);
|
|
563
|
+
console.error("SSR element rendering failed:", error);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
return doc.toString();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const rootNormalizedPath = rootFilePath.endsWith("/") ? `${rootFilePath}index.html` : rootFilePath;
|
|
571
|
+
dep.push(`file:${rootNormalizedPath}`);
|
|
572
|
+
|
|
573
|
+
try {
|
|
574
|
+
rootHtml = await renderRecursive(rootHtml, "root", rootNormalizedPath);
|
|
575
|
+
|
|
576
|
+
const rootStylesheets = extractStylesheetLinks(rootHtml);
|
|
577
|
+
rootHtml = mergeStylesheetLinks(
|
|
578
|
+
rootStylesheets.cleanHtml,
|
|
579
|
+
[...rootStylesheets.links, ...stylesheetLinks]
|
|
580
|
+
);
|
|
581
|
+
|
|
582
|
+
// Apply translations to full composite string
|
|
583
|
+
if (langRegion || lang) {
|
|
584
|
+
rootHtml = translate(rootHtml, activeTranslations, langRegion, lang);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Apply visual theme
|
|
588
|
+
rootHtml = applyTheme(rootHtml, theme);
|
|
589
|
+
|
|
590
|
+
// Inject localization alternate links into <head>
|
|
591
|
+
if (organization?.languages?.length > 0) {
|
|
592
|
+
const langLinks = createLanguageLinkTags(file, organization, urlObject);
|
|
593
|
+
if (langLinks) {
|
|
594
|
+
const doc = new HTMLScanner(rootHtml);
|
|
595
|
+
const headHandles = doc.querySelectorAll({ tags: ["HEAD"] });
|
|
596
|
+
if (headHandles.length > 0) {
|
|
597
|
+
const headContent = headHandles[0].getInnerHTML();
|
|
598
|
+
headHandles[0].setInnerHTML(`${headContent}\n${langLinks}`);
|
|
599
|
+
rootHtml = doc.toString();
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
} finally {
|
|
604
|
+
dep = [];
|
|
605
|
+
dbCache.clear();
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
// Replace ObjectId() tokens across final rendered document
|
|
609
|
+
return rootHtml.replaceAll("ObjectId()", () => ObjectId().toString());
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
*
|
|
614
|
+
* @name actions
|
|
615
|
+
* @kind member
|
|
616
|
+
* @attribute
|
|
617
|
+
* @environment server
|
|
618
|
+
*/
|
|
619
|
+
|
|
620
|
+
/**
|
|
621
|
+
*
|
|
622
|
+
* @name array
|
|
623
|
+
* @kind member
|
|
624
|
+
* @attribute
|
|
625
|
+
* @environment server
|
|
626
|
+
*/
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
*
|
|
630
|
+
* @name class
|
|
631
|
+
* @kind member
|
|
632
|
+
* @attribute
|
|
633
|
+
* @environment server
|
|
634
|
+
*/
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
*
|
|
638
|
+
* @name component
|
|
639
|
+
* @kind member
|
|
640
|
+
* @attribute
|
|
641
|
+
* @environment server
|
|
642
|
+
*/
|
|
643
|
+
|
|
644
|
+
/**
|
|
645
|
+
*
|
|
646
|
+
* @name content
|
|
647
|
+
* @kind member
|
|
648
|
+
* @attribute
|
|
649
|
+
* @environment server
|
|
650
|
+
*/
|
|
651
|
+
|
|
652
|
+
/**
|
|
653
|
+
*
|
|
654
|
+
* @name color-scheme
|
|
655
|
+
* @kind member
|
|
656
|
+
* @attribute
|
|
657
|
+
* @environment server
|
|
658
|
+
*/
|
|
659
|
+
|
|
660
|
+
/**
|
|
661
|
+
*
|
|
662
|
+
* @name error
|
|
663
|
+
* @kind member
|
|
664
|
+
* @attribute
|
|
665
|
+
* @environment server
|
|
666
|
+
*/
|
|
667
|
+
|
|
668
|
+
/**
|
|
669
|
+
*
|
|
670
|
+
* @name filter
|
|
671
|
+
* @kind member
|
|
672
|
+
* @attribute
|
|
673
|
+
* @environment server
|
|
674
|
+
*/
|
|
675
|
+
|
|
676
|
+
/**
|
|
677
|
+
*
|
|
678
|
+
* @name href
|
|
679
|
+
* @kind member
|
|
680
|
+
* @attribute
|
|
681
|
+
* @environment server
|
|
682
|
+
*/
|
|
683
|
+
|
|
684
|
+
/**
|
|
685
|
+
*
|
|
686
|
+
* @name i18n
|
|
687
|
+
* @kind member
|
|
688
|
+
* @attribute
|
|
689
|
+
* @environment server
|
|
690
|
+
*/
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
*
|
|
694
|
+
* @name key
|
|
695
|
+
* @kind member
|
|
696
|
+
* @attribute
|
|
697
|
+
* @environment server
|
|
698
|
+
*/
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
*
|
|
702
|
+
* @name media
|
|
703
|
+
* @kind member
|
|
704
|
+
* @attribute
|
|
705
|
+
* @environment server
|
|
706
|
+
*/
|
|
707
|
+
|
|
708
|
+
/**
|
|
709
|
+
*
|
|
710
|
+
* @name name
|
|
711
|
+
* @kind member
|
|
712
|
+
* @attribute
|
|
713
|
+
* @environment server
|
|
714
|
+
*/
|
|
715
|
+
|
|
716
|
+
/**
|
|
717
|
+
*
|
|
718
|
+
* @name object
|
|
719
|
+
* @kind member
|
|
720
|
+
* @attribute
|
|
721
|
+
* @environment server
|
|
722
|
+
*/
|
|
723
|
+
|
|
724
|
+
/**
|
|
725
|
+
*
|
|
726
|
+
* @name plugin
|
|
727
|
+
* @kind member
|
|
728
|
+
* @attribute
|
|
729
|
+
* @environment server
|
|
730
|
+
*/
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
*
|
|
734
|
+
* @name rel
|
|
735
|
+
* @kind member
|
|
736
|
+
* @attribute
|
|
737
|
+
* @environment server
|
|
738
|
+
*/
|
|
739
|
+
|
|
740
|
+
/**
|
|
741
|
+
*
|
|
742
|
+
* @name render-error
|
|
743
|
+
* @kind member
|
|
744
|
+
* @attribute
|
|
745
|
+
* @environment server
|
|
746
|
+
*/
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
*
|
|
750
|
+
* @name render-query
|
|
751
|
+
* @kind member
|
|
752
|
+
* @attribute
|
|
753
|
+
* @environment server
|
|
754
|
+
*/
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
*
|
|
758
|
+
* @name rendered
|
|
759
|
+
* @kind member
|
|
760
|
+
* @attribute
|
|
761
|
+
* @environment server
|
|
762
|
+
*/
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
*
|
|
766
|
+
* @name src
|
|
767
|
+
* @kind member
|
|
768
|
+
* @attribute
|
|
769
|
+
* @environment server
|
|
770
|
+
*/
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
*
|
|
774
|
+
* @name state-id
|
|
775
|
+
* @kind member
|
|
776
|
+
* @attribute
|
|
777
|
+
* @environment server
|
|
778
|
+
*/
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
*
|
|
782
|
+
* @name state-overwrite
|
|
783
|
+
* @kind member
|
|
784
|
+
* @attribute
|
|
785
|
+
* @environment server
|
|
786
|
+
*/
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
*
|
|
790
|
+
* @name type
|
|
791
|
+
* @kind member
|
|
792
|
+
* @attribute
|
|
793
|
+
* @environment server
|
|
794
|
+
*/
|
|
795
|
+
|
|
796
|
+
/**
|
|
797
|
+
* High-performance, stateless server-side rendering (SSR) engine with recursive HTML template parsing, multi-source data resolution, and automated circular dependency protection.
|
|
798
|
+
*
|
|
799
|
+
* @module render
|
|
800
|
+
* @environment server
|
|
801
|
+
*/
|