@cocreate/server-side-render 1.12.8 → 1.14.1

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.
@@ -1,8 +1,10 @@
1
1
  name: Automated Workflow
2
+
2
3
  on:
3
4
  push:
4
5
  branches:
5
6
  - master
7
+
6
8
  jobs:
7
9
  about:
8
10
  runs-on: ubuntu-latest
@@ -18,52 +20,37 @@ jobs:
18
20
  with:
19
21
  direction: overwrite-github
20
22
  githubToken: "${{ secrets.GITHUB }}"
23
+
21
24
  release:
22
25
  runs-on: ubuntu-latest
23
26
  steps:
24
27
  - name: Checkout
25
28
  uses: actions/checkout@v4
29
+ with:
30
+ fetch-depth: 0 # Required so semantic-release can trace git tags/history
31
+
26
32
  - name: Setup Node.js
27
33
  uses: actions/setup-node@v4
28
34
  with:
29
- node-version: 22 # Required for the latest semantic-release plugins
30
- - name: Semantic Release
31
- uses: cycjimmy/semantic-release-action@v4 # Update to v4 for better Node 20+ support
32
- id: semantic
33
- with:
34
- extra_plugins: |
35
- @semantic-release/changelog
35
+ node-version: 22
36
+
37
+ - name: Install Semantic Release & Plugins
38
+ # Installs semantic-release and its plugins on the runner
39
+ run: |
40
+ npm install -g semantic-release \
41
+ @semantic-release/changelog \
42
+ @semantic-release/npm \
43
+ @semantic-release/github \
36
44
  @semantic-release/git
37
- @semantic-release/github
45
+
46
+ - name: Run Semantic Release (Native)
47
+ id: semantic
48
+ # This will automatically pick up your export default config file in the repository root
49
+ run: npx semantic-release
38
50
  env:
39
- GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" # Use the built-in token if possible
51
+ GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
40
52
  NPM_TOKEN: "${{ secrets.NPM_TOKEN }}"
53
+
41
54
  outputs:
42
55
  new_release_published: "${{ steps.semantic.outputs.new_release_published }}"
43
- new_release_version: "${{ steps.semantic.outputs.new_release_version }}"
44
- upload:
45
- runs-on: ubuntu-latest
46
- needs: release
47
- if: needs.release.outputs.new_release_published == 'true'
48
- env:
49
- VERSION: "${{ needs.release.outputs.new_release_version }}"
50
- steps:
51
- - name: Checkout
52
- uses: actions/checkout@v3
53
- - name: Setup Node.js
54
- uses: actions/setup-node@v3
55
- with:
56
- node-version: 16
57
- - name: Set npm registry auth
58
- run: echo "//registry.npmjs.org/:_authToken=${{ secrets.NPM_TOKEN }}" > ~/.npmrc
59
- - name: Install dependencies
60
- run: yarn install
61
-
62
- - name: Set Environment Variables
63
- run: |
64
- echo "organization_id=${{ secrets.COCREATE_ORGANIZATION_ID }}" >> $GITHUB_ENV
65
- echo "key=${{ secrets.COCREATE_KEY }}" >> $GITHUB_ENV
66
- echo "host=${{ secrets.COCREATE_HOST }}" >> $GITHUB_ENV
67
- - name: CoCreate Upload
68
- run: coc upload
69
-
56
+ new_release_version: "${{ steps.semantic.outputs.new_release_version }}"
package/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ ## [1.14.1](https://github.com/CoCreate-app/CoCreate-server-side-render/compare/v1.14.0...v1.14.1) (2026-07-17)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * bump dependencies ([822bbc8](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/822bbc8944b8782a69bff4e10ae0cb3fb2b02245))
7
+ * update dependencies for improved compatibility and performance ([db6db94](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/db6db94096338ab6a4daf58569549cdac4623d38))
8
+
9
+ # [1.14.0](https://github.com/CoCreate-app/CoCreate-server-side-render/compare/v1.13.0...v1.14.0) (2026-07-17)
10
+
11
+
12
+ ### Features
13
+
14
+ * update automated workflow and release configuration for improved semantic release handling ([30e01d7](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/30e01d75c47cb36dc20f83a136d7eb296887d504))
15
+
16
+ # [1.13.0](https://github.com/CoCreate-app/CoCreate-server-side-render/compare/v1.12.8...v1.13.0) (2026-07-14)
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * handle error states for fetched files in server-side rendering ([8c492c3](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/8c492c39669082348bc920bb553479eb35ed35a1))
22
+ * removed post install ([2ce8447](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/2ce8447779ca84c2a887b7bfbac27a626aaaa044))
23
+ * semantic version handling. Reorganize .gitignore for improved clarity and structure ([15bf544](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/15bf5442decac9654746486d4d4cec8a1ad775b4))
24
+ * update .gitignore to exclude package-lock.json and pnpm-lock.yaml ([7605dff](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/7605dff02dd0b3071583e987d95ac723609515bc))
25
+ * update module export to ES6 syntax in release.config.js ([a29e40f](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/a29e40f1aa52b9113473ed27fbaffada015e03ff))
26
+ * update package.json to use ESM and restructure server/client entry points ([e9b4c83](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/e9b4c83720aa05dd9c45fc94fc070521357c32eb))
27
+
28
+
29
+ ### Features
30
+
31
+ * optimize rendering logic and improve error handling in server-side rendering ([208aeba](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/208aebac9efe650b7ef5c30713d5c6a4cc5b0aac))
32
+ * update package.json and refactor index.js to use ES modules ([7ab7c9d](https://github.com/CoCreate-app/CoCreate-server-side-render/commit/7ab7c9d225505ef368c3434278d695eb7ac6f8d7))
33
+
1
34
  ## [1.12.8](https://github.com/CoCreate-app/CoCreate-server-side-render/compare/v1.12.7...v1.12.8) (2026-04-04)
2
35
 
3
36
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cocreate/server-side-render",
3
- "version": "1.12.8",
3
+ "version": "1.14.1",
4
4
  "description": "A simple server-side-render component in vanilla javascript. Easily configured using HTML5 data-attributes and/or JavaScript API.",
5
5
  "keywords": [
6
6
  "server-side-render",
@@ -16,10 +16,9 @@
16
16
  "access": "public"
17
17
  },
18
18
  "scripts": {
19
- "start": "npx webpack --config webpack.config.js",
20
- "build": "npx webpack --mode=production --config webpack.config.js",
21
- "dev": "npx webpack --config webpack.config.js --watch",
22
- "postinstall": "node -e \"const { execSync } = require('child_process'); try { execSync('coc --version', { stdio: 'ignore' }); } catch (error) { try { execSync('npm install -g @cocreate/cli', { stdio: 'inherit' }); console.log('Installed \"@cocreate/cli\" globally.'); } catch (error) { console.error('Failed to install \"@cocreate/cli\" globally:', error); } }\""
19
+ "start": "webpack --config webpack.config.js",
20
+ "build": "webpack --mode=production --config webpack.config.js",
21
+ "dev": "webpack --config webpack.config.js --watch"
23
22
  },
24
23
  "repository": {
25
24
  "type": "git",
@@ -35,9 +34,10 @@
35
34
  "type": "GitHub Sponsors ❤",
36
35
  "url": "https://github.com/sponsors/CoCreate-app"
37
36
  },
37
+ "type": "module",
38
38
  "main": "./src/index.js",
39
39
  "dependencies": {
40
- "@cocreate/utils": "^1.39.0",
41
- "node-html-parser": "^6.1.12"
40
+ "@cocreate/utils": "^1.44.0",
41
+ "node-html-parser": "^9.0.0"
42
42
  }
43
43
  }
package/release.config.js CHANGED
@@ -1,4 +1,4 @@
1
- module.exports = {
1
+ export default {
2
2
  dryRun: false,
3
3
  branches: ["master"],
4
4
  plugins: [
@@ -10,12 +10,20 @@ module.exports = {
10
10
  changelogFile: "CHANGELOG.md",
11
11
  },
12
12
  ],
13
- "@semantic-release/npm",
14
- "@semantic-release/github",
13
+ "@semantic-release/npm",
15
14
  [
16
- "@semantic-release/git",
15
+ "@semantic-release/github",
17
16
  {
17
+ successComment: false,
18
+ failTitle: false,
19
+ },
20
+ ],
21
+ [
22
+ "@semantic-release/git",
23
+ {
24
+ // Only stage and commit the changelog and package.json
18
25
  assets: ["CHANGELOG.md", "package.json"],
26
+ message: "chore(release): ${nextRelease.version} [skip ci]",
19
27
  },
20
28
  ],
21
29
  ],
package/src/index.js CHANGED
@@ -1,285 +1,407 @@
1
- const { parse } = require("node-html-parser");
2
- const { checkValue, getRelativePath, ObjectId } = require("@cocreate/utils");
3
- const path = require("path");
4
-
5
- class CoCreateServerSideRender {
6
- constructor(crud) {
7
- this.crud = crud;
8
- }
9
-
10
- async HTML(file, organization, urlObject, langRegion, lang, theme) {
11
- const self = this;
12
- let ignoreElement = {
13
- INPUT: true,
14
- TEXTAREA: true,
15
- SELECT: true,
16
- LINK: true,
17
- IFRAME: true,
18
- "COCREATE-SELECT": true
19
- };
20
-
21
- let dep = [];
22
- let dbCache = new Map();
23
- let organization_id = file.organization_id;
24
- const host = urlObject.hostname;
25
-
26
- async function render(dom, lastKey) {
27
- // Handle elements with [array][key][object]
28
- for (let el of dom.querySelectorAll("[array][key][object]")) {
29
- let meta = el.attributes;
30
-
31
- if (ignoreElement[el.tagName]) continue;
32
-
33
- if (el.closest(".template, [template], template, [render]"))
34
- continue;
35
-
36
- if (el.hasAttribute("render-query")) continue;
37
-
38
- if (el.hasAttribute("component") || el.hasAttribute("plugin"))
39
- continue;
40
-
41
- if (el.hasAttribute("actions")) continue;
42
-
43
- let _id = meta["object"],
44
- array = meta["array"],
45
- key = meta["key"];
46
- let crudKey = _id + array + key;
47
-
48
- if (!_id || !key || !array) continue;
49
- if (!checkValue(_id) || !checkValue(key) || !checkValue(array))
50
- continue;
51
- if (dep.includes(crudKey))
52
- throw new Error(
53
- `infinite loop: ${lastKey} ${array} ${key} ${_id} has been already rendered`
54
- );
55
- else dep.push(crudKey);
56
-
57
- let cacheKey = _id + array;
58
- let data;
59
- if (dbCache.has(cacheKey)) {
60
- data = dbCache.get(cacheKey);
61
- } else {
62
- data = await self.crud.send({
63
- method: "object.read",
64
- host,
65
- array,
66
- object: { _id },
67
- organization_id
68
- });
69
- if (data && data.object && data.object[0])
70
- data = data.object[0];
71
-
72
- dbCache.set(cacheKey, data);
73
- }
74
-
75
- if (!data || !data[key]) {
76
- dep.pop();
77
- continue;
78
- }
79
-
80
- let chunk = data[key];
81
- if (!chunk) {
82
- dep.pop();
83
- continue;
84
- }
85
-
86
- chunk = await render(chunk);
87
-
88
- el.setAttribute("rendered", "");
89
- el.innerHTML = "";
90
- el.appendChild(chunk);
91
-
92
- dep.pop();
93
- }
94
-
95
- // ToDo: Fetch and render src, update relativePath. must have similar functionality to @cocreate/elements/fetch-src
96
- // Handle elements with [src]
97
- for (let el of dom.querySelectorAll(
98
- "[src]:not(script, img, iframe, audio, video, source, track, input, embed, frame)"
99
- )) {
100
- let src = el.getAttribute("src");
101
- if (!src) continue;
102
-
103
- let path =
104
- el.getAttribute("path") || getRelativePath(file.path);
105
-
106
- if (path) {
107
- src = src.replaceAll(/\$relativePath\/?/g, path);
108
- }
109
-
110
- let pathname = file.path
111
- if (!pathname.endsWith("/")) {
112
- pathname += "/";
113
- }
114
- // Construct actual pathname using src and the original URL
115
- pathname = new URL(src, `http://localhost${pathname}`)
116
- .pathname;
117
-
118
- if (pathname.endsWith("/")) {
119
- pathname += "index.html";
120
- }
121
- let $filter = {
122
- query: {
123
- pathname: pathname
124
- }
125
- }; // Use filter to structure query
126
-
127
- let data = await self.crud.send({
128
- method: "object.read",
129
- host,
130
- array: "files",
131
- object: "",
132
- $filter,
133
- organization_id
134
- });
135
-
136
- if (
137
- data &&
138
- data.object &&
139
- data.object[0] &&
140
- data.object[0].src
141
- ) {
142
- let chunk = data.object[0].src;
143
-
144
- if (typeof chunk === "string" && chunk.startsWith("data:image/svg+xml;base64,")) {
145
- chunk = Buffer.from(chunk.split(",")[1], "base64").toString("utf-8");
146
- }
147
-
148
- // Replace $relativePath in the fetched chunk
149
- let path =
150
- el.getAttribute("path") || getRelativePath(file.path);
151
- if (path) {
152
- chunk = chunk.replaceAll(/\$relativePath\/?/g, path);
153
- }
154
-
155
- // Replace ObjectId() with a new ObjectId
156
- chunk = chunk.replaceAll("ObjectId()", () => {
157
- // Generate a NEW ObjectId inside the function
158
- return ObjectId().toString(); // Return its string representation
159
- });
160
-
161
- // Parse chunk into DOM before recursive rendering
162
- let chunkDom = parse(chunk);
163
- chunkDom = await render(chunkDom);
164
-
165
- // If element requests outerHTML insertion, replace the element
166
- // with the fetched chunk's content. Otherwise append children.
167
- const valueType = el.getAttribute && el.getAttribute("value-type");
168
- if (valueType === "outerHTML") {
169
- // Replace the element with the parsed chunk's child nodes
170
- // spread child nodes so we don't create an extra wrapper
171
- if (chunkDom.childNodes && chunkDom.childNodes.length) {
172
- el.replaceWith(...chunkDom.childNodes);
173
- } else {
174
- // If no child nodes, just remove the element
175
- el.remove();
176
- }
177
- } else {
178
- el.setAttribute("rendered", "");
179
- el.innerHTML = "";
180
- for (const child of chunkDom.childNodes) {
181
- el.appendChild(child);
182
- }
183
- }
184
- }
185
- }
186
-
187
- return dom;
188
- }
189
-
190
- let dom = parse(file.src);
191
- dom = await render(dom, "root");
192
- if (langRegion || lang) {
193
- dom = await this.translate(dom, file, langRegion, lang);
194
- }
195
-
196
- // Inject preferred theme into the DOM so client gets server-rendered theme
197
- if (theme) {
198
- const htmlEl = dom.querySelector("html");
199
- try {
200
- if (htmlEl && htmlEl.setAttribute) htmlEl.setAttribute("data-bs-theme", theme);
201
-
202
- const head = dom.querySelector("head");
203
- if (head) {
204
- // add or update color-scheme meta tag
205
- let meta = head.querySelector('meta[name="color-scheme"]');
206
- if (meta && meta.setAttribute) {
207
- meta.setAttribute("content", theme);
208
- } else {
209
- const metaNode = parse(`<meta name="color-scheme" content="${theme}">`);
210
- head.appendChild(metaNode);
211
- }
212
- }
213
- } catch (e) {
214
- // fail-safe: don't abort rendering if theme injection fails
215
- }
216
- }
217
-
218
- if (organization.languages && organization.languages.length > 0) {
219
- let langLinkTags = this.createLanguageLinkTags(
220
- file,
221
- organization,
222
- urlObject
223
- );
224
- const head = dom.querySelector("head");
225
- if (head && langLinkTags) {
226
- const linksFragment = parse(langLinkTags);
227
- for (const link of linksFragment.querySelectorAll("link")) {
228
- head.appendChild(link);
229
- }
230
- // Remove the fragment node from the DOM if it exists
231
- if (linksFragment.parentNode) {
232
- linksFragment.remove();
233
- }
234
- }
235
- }
236
-
237
- dep = [];
238
- dbCache.clear();
239
- return dom.toString();
240
- }
241
-
242
- createLanguageLinkTags(file, organization, urlObject) {
243
- let generatedLinksString = `<link rel="alternate" hreflang="x-default" href="https://${urlObject.hostname}${file.pathname}">\n`;
244
-
245
- for (const language of organization.languages) {
246
- let langPath = `/${language}${file.pathname}`;
247
- const hrefUrl = `https://${urlObject.hostname}${langPath}`;
248
- generatedLinksString += `<link rel="alternate" hreflang="${language}" href="${hrefUrl}">\n`;
249
- }
250
- return generatedLinksString;
251
- }
252
-
253
- async translate(dom, file, langRegion, lang) {
254
- if (file.translations && (langRegion || lang)) {
255
- for (let translation of file.translations) {
256
- let elements = dom.querySelectorAll(translation.selector) || [];
257
- for (let el of elements) {
258
- if (!el) continue;
259
- if (translation.innerHTML) {
260
- let content =
261
- translation.innerHTML[langRegion] ||
262
- translation.innerHTML[lang];
263
- if (content) {
264
- el.innerHTML = content;
265
- }
266
- }
267
- if (translation.attributes) {
268
- for (let [key, languageObj] of Object.entries(
269
- translation.attributes
270
- )) {
271
- let value =
272
- languageObj[langRegion] || languageObj[lang];
273
- if (value) {
274
- el.setAttribute(key, value);
275
- }
276
- }
277
- }
278
- }
279
- }
280
- }
281
- return dom;
282
- }
1
+ import { parse } from "node-html-parser";
2
+ import { checkValue, ObjectId } from "@cocreate/utils";
3
+
4
+ // Using a Set for O(1) lookups is cleaner and faster than an object map
5
+ const IGNORE_ELEMENTS = new Set([
6
+ "FORM", "INPUT", "TEXTAREA", "SELECT", "LINK", "IFRAME", "COCREATE-SELECT"
7
+ ]);
8
+
9
+ // Prevent these tags from triggering the generic [src] chunk-fetching logic
10
+ const SKIP_SRC_ELEMENTS = new Set([
11
+ "SCRIPT", "IMG", "IFRAME", "AUDIO", "VIDEO", "SOURCE", "TRACK", "INPUT", "EMBED", "FRAME"
12
+ ]);
13
+
14
+ // CSS Selector for identifying target elements
15
+ const RENDER_SELECTOR = "[array], [src]:not(script, img, iframe, audio, video, source, track, input, embed, frame)";
16
+
17
+ /**
18
+ * Main functional entry point for Server-Side Rendering.
19
+ * Completely stateless—all required dependencies are passed directly as arguments.
20
+ * @param {Object} options
21
+ * @param {Object} options.file - The template file object containing source markup
22
+ * @param {Object} options.organization - Organization configuration data
23
+ * @param {Object} options.urlObject - Request URL parsed object
24
+ * @param {string} options.langRegion - Target language region (e.g., 'en-US')
25
+ * @param {string} options.lang - Target language (e.g., 'en')
26
+ * @param {string} options.theme - Visual theme (e.g., 'dark')
27
+ * @param {Function} options.resolveFile - Injected custom file resolver callback
28
+ * @param {Object} options.crud - Crud database service instance
29
+ * @returns {Promise<string>} The fully rendered HTML string
30
+ */
31
+ export async function HTML({
32
+ file,
33
+ organization,
34
+ urlObject,
35
+ langRegion,
36
+ lang,
37
+ theme,
38
+ resolveFile,
39
+ crud
40
+ }) {
41
+ let dep = [];
42
+ let dbCache = new Map();
43
+
44
+ const organization_id = file?.organization_id;
45
+ const host = urlObject?.hostname;
46
+
47
+ // Ensure file.path has a fallback to prevent runtime properties errors
48
+ const filePath = file?.path || file?.pathname || "/";
49
+
50
+ // Apply parsing directly on root file source without modifying any paths
51
+ let rootSrc = file?.src || "";
52
+ let dom = parse(rootSrc);
53
+
54
+ // Core Render Function (placed at the top for prominence)
55
+ async function render(currentDom, lastKey) {
56
+ // Find target elements and filter out nested targets to prevent redundant querying
57
+ const elements = currentDom.querySelectorAll(RENDER_SELECTOR);
58
+
59
+ for (let el of elements) {
60
+ let isData = el.hasAttribute("array");
61
+ let isSrc = el.hasAttribute("src") && !SKIP_SRC_ELEMENTS.has(el.tagName.toUpperCase());
62
+
63
+ let chunkDom = null;
64
+
65
+ // 1. Process File Source (Takes priority over Data Source)
66
+ if (isSrc) {
67
+ let src = el.getAttribute("src");
68
+ // Explicitly check that src is a valid non-empty string (handling null in v9.x)
69
+ if (src && typeof src === "string") {
70
+ let pathname = filePath;
71
+ if (!pathname.endsWith("/")) pathname += "/";
72
+
73
+ pathname = new URL(src, `http://localhost${pathname}`).pathname;
74
+ if (pathname.endsWith("/")) pathname += "index.html";
75
+
76
+ // CIRCULAR DEPENDENCY CHECK: Track active src resolution paths in loop prevention list
77
+ let srcKey = `file:${pathname}`;
78
+ if (dep.includes(srcKey)) {
79
+ throw new Error(`Infinite loop detected: Parent context "${lastKey}" requested "${srcKey}", which is already in the active import chain.`);
80
+ }
81
+ dep.push(srcKey);
82
+
83
+ let fetchedFile = null;
84
+
85
+ // Use injected resolver callback if provided, otherwise fallback to CRUD query
86
+ if (typeof resolveFile === "function") {
87
+ fetchedFile = await resolveFile(pathname);
88
+ } else if (crud) {
89
+ let data = await crud.send({
90
+ method: "object.read",
91
+ host,
92
+ array: "files",
93
+ object: "",
94
+ $filter: { query: { pathname } },
95
+ organization_id
96
+ });
97
+ fetchedFile = data?.object?.[0] || null;
98
+ }
99
+
100
+ // Intelligent Status Code Handling
101
+ let statusCode = fetchedFile?.status;
102
+ if (!statusCode) {
103
+ if (!fetchedFile || !fetchedFile.src) statusCode = 404;
104
+ else if (fetchedFile.public === false || String(fetchedFile.public) === "false") statusCode = 403;
105
+ else statusCode = 200;
106
+ }
107
+
108
+ if (statusCode >= 400) {
109
+ el.setAttribute("error", statusCode.toString());
110
+ }
111
+
112
+ // Render if status is successful OR if the element explicitly forces rendering errors via 'render-error'
113
+ if (statusCode < 400 || el.hasAttribute("render-error")) {
114
+ let chunkVal = fetchedFile?.src;
115
+
116
+ if (chunkVal) {
117
+ // Decode base64 SVG if necessary
118
+ if (typeof chunkVal === "string" && chunkVal.startsWith("data:image/svg+xml;base64,")) {
119
+ chunkVal = Buffer.from(chunkVal.split(",")[1], "base64").toString("utf-8");
120
+ }
121
+
122
+ // Normalize non-string values safely to strings before parsing (failsafe)
123
+ let chunkStr = typeof chunkVal === 'string' ? chunkVal : String(chunkVal);
124
+ chunkDom = parse(chunkStr);
125
+
126
+ // Extract and merge any embedded <head> parameters before rendering nested items
127
+ extractAndMergeHead(chunkDom, dom);
128
+
129
+ // Passing dynamic srcKey instead of static "src-chunk" to preserve trace context
130
+ chunkDom = await render(chunkDom, srcKey);
131
+ }
132
+ }
133
+ dep.pop(); // Complete tracking cycle for this source resolution
134
+ }
135
+ }
136
+ // 2. Process Data Source (Only runs if element does not have a valid src being resolved)
137
+ else if (isData) {
138
+ let shouldProcessData = true;
139
+
140
+ // Exclude specific tags, templates, actions, and components from rendering
141
+ if (IGNORE_ELEMENTS.has(el.tagName.toUpperCase())) {
142
+ shouldProcessData = false;
143
+ } else if (el.closest(".template, [template], template, [render]")) {
144
+ shouldProcessData = false;
145
+ } else if (el.hasAttribute("render-query") || el.hasAttribute("component") || el.hasAttribute("plugin") || el.hasAttribute("actions")) {
146
+ shouldProcessData = false;
147
+ }
148
+
149
+ if (shouldProcessData && crud) {
150
+ let array = el.getAttribute("array");
151
+ let key = el.getAttribute("key"); // Optional attribute
152
+ let _id = el.getAttribute("object") || "";
153
+
154
+ let $filter = {};
155
+ let filterAttr = el.getAttribute("filter");
156
+ if (filterAttr) {
157
+ try {
158
+ $filter = JSON.parse(filterAttr);
159
+ } catch (e) {
160
+ $filter = {};
161
+ }
162
+ }
163
+
164
+ if (array && checkValue(array) && (!key || checkValue(key))) {
165
+ let keyStr = key ? `-${key}` : '';
166
+ let filterKeyStr = filterAttr ? `-${filterAttr}` : '';
167
+ let crudKey = `${_id}${array}${keyStr}${filterKeyStr}`;
168
+
169
+ if (dep.includes(crudKey)) {
170
+ throw new Error(`Infinite loop detected: Parent context "${lastKey}" requested data key "${crudKey}", which has already been rendered.`);
171
+ }
172
+ dep.push(crudKey);
173
+
174
+ let cacheKey = `${_id}${array}${filterKeyStr}`;
175
+ let data;
176
+
177
+ if (dbCache.has(cacheKey)) {
178
+ data = dbCache.get(cacheKey);
179
+ } else {
180
+ data = await crud.send({
181
+ method: "object.read",
182
+ host,
183
+ array,
184
+ object: _id ? { _id } : "",
185
+ $filter,
186
+ organization_id
187
+ });
188
+ if (data?.object?.[0]) data = data.object[0];
189
+ dbCache.set(cacheKey, data);
190
+ }
191
+
192
+ if (data) {
193
+ // If a key is defined, fetch that key's value; otherwise, render the entire object/document
194
+ let chunkVal = key ? data[key] : data;
195
+
196
+ if (chunkVal !== undefined && chunkVal !== null) {
197
+ // Normalize objects to JSON strings, and everything else safely to standard strings
198
+ let chunkStr = typeof chunkVal === 'object' ? JSON.stringify(chunkVal) : String(chunkVal);
199
+ chunkDom = parse(chunkStr);
200
+
201
+ // Extract and merge any embedded <head> parameters before rendering nested items
202
+ extractAndMergeHead(chunkDom, dom);
203
+
204
+ // Recursively resolve all nested requirements in the chunk BEFORE inserting it
205
+ chunkDom = await render(chunkDom, crudKey);
206
+ }
207
+ }
208
+ dep.pop();
209
+ }
210
+ }
211
+ }
212
+
213
+ // Apply DOM mutations only after everything in this sub-tree branch is completely resolved
214
+ if (chunkDom) {
215
+ if (el.getAttribute("value-type") === "outerHTML") {
216
+ // Create a static array copy of childNodes to prevent mutation index shifts during replacement
217
+ const childNodes = chunkDom.childNodes ? Array.from(chunkDom.childNodes) : [];
218
+ if (childNodes.length) {
219
+ el.replaceWith(...childNodes);
220
+ } else {
221
+ el.remove();
222
+ }
223
+ } else {
224
+ el.setAttribute("rendered", "");
225
+
226
+ // Use modern set_content native helper for v9.0.0 (safer and cleaner than manually appending nodes)
227
+ const contentToInject = chunkDom.childNodes && chunkDom.childNodes.length > 0
228
+ ? Array.from(chunkDom.childNodes)
229
+ : chunkDom;
230
+
231
+ el.set_content(contentToInject);
232
+ }
233
+ }
234
+ }
235
+
236
+ return currentDom;
237
+ }
238
+
239
+ // Initialize recursion tracker with the primary file pathname to catch immediate self-reference imports
240
+ const rootNormalizedPath = filePath.endsWith("/") ? `${filePath}index.html` : filePath;
241
+ dep.push(`file:${rootNormalizedPath}`);
242
+
243
+ dom = await render(dom, "root");
244
+
245
+ if (langRegion || lang) {
246
+ dom = translate(dom, file, langRegion, lang);
247
+ }
248
+
249
+ // Apply theme data-attributes/meta-tags if a head is available, or can be lazily resolved
250
+ if (theme) {
251
+ const htmlEl = dom.querySelector("html");
252
+ try {
253
+ if (htmlEl?.setAttribute) htmlEl.setAttribute("data-bs-theme", theme);
254
+
255
+ const currentHead = dom.querySelector("head");
256
+ if (currentHead) {
257
+ let meta = currentHead.querySelector('meta[name="color-scheme"]');
258
+ if (meta?.setAttribute) {
259
+ meta.setAttribute("content", theme);
260
+ } else {
261
+ const metaNode = parse(`<meta name="color-scheme" content="${theme}">`);
262
+ currentHead.appendChild(metaNode);
263
+ }
264
+ }
265
+ } catch (e) {
266
+ // fail-safe
267
+ }
268
+ }
269
+
270
+ // Append localization link tags if head is present, or can be lazily resolved
271
+ if (organization?.languages?.length > 0) {
272
+ let langLinkTags = createLanguageLinkTags(file, organization, urlObject);
273
+ const currentHead = dom.querySelector("head");
274
+ if (currentHead && langLinkTags) {
275
+ const linksFragment = parse(langLinkTags);
276
+ for (const link of linksFragment.querySelectorAll("link")) {
277
+ currentHead.appendChild(link);
278
+ }
279
+ }
280
+ }
281
+
282
+ dep = [];
283
+ dbCache.clear();
284
+
285
+ let finalHtml = dom.toString();
286
+
287
+ // Apply ObjectId generation globally across the fully rendered document
288
+ finalHtml = finalHtml.replaceAll("ObjectId()", () => ObjectId().toString());
289
+
290
+ return finalHtml;
283
291
  }
284
292
 
285
- module.exports = CoCreateServerSideRender;
293
+ // ==========================================
294
+ // Stateless Private Helper Functions
295
+ // ==========================================
296
+
297
+ function ensureMainHead(rootDom) {
298
+ let head = rootDom.querySelector("head");
299
+ if (!head) {
300
+ const htmlEl = rootDom.querySelector("html") || rootDom;
301
+ const headDom = parse("<head></head>");
302
+ head = headDom.querySelector("head");
303
+ if (htmlEl.childNodes && htmlEl.childNodes.length > 0) {
304
+ htmlEl.insertBefore(head, htmlEl.childNodes[0]);
305
+ } else {
306
+ htmlEl.appendChild(head);
307
+ }
308
+ }
309
+ return head;
310
+ }
311
+
312
+ function getMatchingSelector(newEl) {
313
+ const tagName = newEl.tagName.toUpperCase();
314
+ if (tagName === "TITLE") {
315
+ return "title";
316
+ }
317
+ if (tagName === "META") {
318
+ const name = newEl.getAttribute("name");
319
+ if (name) return `meta[name="${name}"]`;
320
+ const property = newEl.getAttribute("property");
321
+ if (property) return `meta[property="${property}"]`;
322
+ }
323
+ if (tagName === "SCRIPT") {
324
+ const type = newEl.getAttribute("type");
325
+ if (type === "application/ld+json") {
326
+ return 'script[type="application/ld+json"]';
327
+ }
328
+ const id = newEl.getAttribute("id");
329
+ if (id) return `script[id="${id}"]`;
330
+ }
331
+ if (tagName === "LINK") {
332
+ const rel = newEl.getAttribute("rel");
333
+ if (rel === "canonical") {
334
+ return 'link[rel="canonical"]';
335
+ }
336
+ const id = newEl.getAttribute("id");
337
+ if (id) return `link[id="${id}"]`;
338
+ }
339
+ return null;
340
+ }
341
+
342
+ function extractAndMergeHead(chunkDom, rootDom) {
343
+ const chunkHead = chunkDom.querySelector("head");
344
+ if (!chunkHead) return;
345
+
346
+ // Lazily ensure a main head exists on the root 'dom' since we found nested head assets to merge
347
+ const head = ensureMainHead(rootDom);
348
+
349
+ // Clone array of nodes to safely mutate during iteration
350
+ const children = [...chunkHead.childNodes];
351
+
352
+ for (const child of children) {
353
+ if (child.nodeType === 1) { // Element Node
354
+ const selector = getMatchingSelector(child);
355
+ let matched = false;
356
+ if (selector) {
357
+ const existing = head.querySelector(selector);
358
+ if (existing) {
359
+ existing.replaceWith(child);
360
+ matched = true;
361
+ }
362
+ }
363
+ if (!matched) {
364
+ head.appendChild(child);
365
+ }
366
+ } else {
367
+ // For text, script content, or comment nodes, append straight to main head
368
+ head.appendChild(child);
369
+ }
370
+ }
371
+
372
+ // Remove empty nested head container from the chunk
373
+ chunkHead.remove();
374
+ }
375
+
376
+ function createLanguageLinkTags(file, organization, urlObject) {
377
+ let generatedLinksString = `<link rel="alternate" hreflang="x-default" href="https://${urlObject.hostname}${file.pathname}">\n`;
378
+ for (const language of organization.languages) {
379
+ const hrefUrl = `https://${urlObject.hostname}/${language}${file.pathname}`;
380
+ generatedLinksString += `<link rel="alternate" hreflang="${language}" href="${hrefUrl}">\n`;
381
+ }
382
+ return generatedLinksString;
383
+ }
384
+
385
+ function translate(dom, file, langRegion, lang) {
386
+ if (!file?.translations || (!langRegion && !lang)) return dom;
387
+
388
+ for (let translation of file.translations) {
389
+ let elements = dom.querySelectorAll(translation.selector) || [];
390
+ for (let el of elements) {
391
+ if (!el) continue;
392
+
393
+ if (translation.innerHTML) {
394
+ let content = translation.innerHTML[langRegion] || translation.innerHTML[lang];
395
+ if (content) el.innerHTML = content;
396
+ }
397
+
398
+ if (translation.attributes) {
399
+ for (let [key, languageObj] of Object.entries(translation.attributes)) {
400
+ let value = languageObj[langRegion] || languageObj[lang];
401
+ if (value) el.setAttribute(key, value);
402
+ }
403
+ }
404
+ }
405
+ }
406
+ return dom;
407
+ }