@symbiotejs/symbiote 3.0.3 → 3.0.4

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.
Files changed (2) hide show
  1. package/node/SSR.js +421 -0
  2. package/package.json +2 -1
package/node/SSR.js ADDED
@@ -0,0 +1,421 @@
1
+ /**
2
+ * @module SSR
3
+ * Server-side rendering for Symbiote.js components.
4
+ * Requires `linkedom` as a peer dependency.
5
+ *
6
+ * Usage (basic):
7
+ * ```js
8
+ * import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
9
+ * await import('./my-component.js');
10
+ * let html = await SSR.processHtml('<my-page><my-component></my-component></my-page>');
11
+ * ```
12
+ *
13
+ * Usage (advanced):
14
+ * ```js
15
+ * import { SSR } from '@symbiotejs/symbiote/node/SSR.js';
16
+ * await SSR.init();
17
+ * await import('./my-component.js');
18
+ * let html = SSR.renderToString('my-component', { title: 'Hello' });
19
+ * SSR.destroy();
20
+ * ```
21
+ */
22
+
23
+ /**
24
+ * Extract CSS text from a stylesheet (works with both CSSStyleSheet and SSR polyfill).
25
+ * @param {CSSStyleSheet | {cssText: string}} sheet
26
+ * @returns {string}
27
+ */
28
+ function extractCSS(sheet) {
29
+ if ('cssText' in sheet && typeof sheet.cssText === 'string') {
30
+ return sheet.cssText;
31
+ }
32
+ let text = '';
33
+ try {
34
+ // @ts-ignore
35
+ for (let rule of sheet.cssRules) {
36
+ text += rule.cssText + '\n';
37
+ }
38
+ } catch {
39
+ // Security restrictions on some stylesheets
40
+ }
41
+ return text;
42
+ }
43
+
44
+ /**
45
+ * Resolve {{prop}} text node tokens by reading values from the closest custom element.
46
+ * @param {string} text
47
+ * @param {Node} node
48
+ * @returns {string}
49
+ */
50
+ function resolveTextTokens(text, node) {
51
+ if (!text.includes('{{')) return text;
52
+ // Find closest parent custom element:
53
+ let el = node.parentElement;
54
+ while (el && !el.localName?.includes('-')) {
55
+ el = el.parentElement;
56
+ }
57
+ if (!el || !/** @type {any} */ (el).localCtx) return text;
58
+ let ctx = /** @type {any} */ (el).localCtx;
59
+ return text.replace(/\{\{([^}]+)\}\}/g, (match, prop) => {
60
+ let val = ctx.has(prop) ? ctx.read(prop) : undefined;
61
+ return val !== undefined && val !== null ? String(val) : match;
62
+ });
63
+ }
64
+
65
+ /**
66
+ * Serialize a custom element to HTML with DSD and rootStyles support.
67
+ * @param {HTMLElement} el
68
+ * @param {Set<Function>} emittedStyles - track which constructors already emitted rootStyles
69
+ * @returns {string}
70
+ */
71
+ function serializeElement(el, emittedStyles) {
72
+ let tagName = el.localName;
73
+ let attrsStr = serializeAttrs(el);
74
+ let ctor = /** @type {any} */ (el).constructor;
75
+
76
+ let innerContent = '';
77
+
78
+ // Light DOM rootStyles — inject <style> (once per constructor):
79
+ if (ctor.rootStyleSheets && !emittedStyles.has(ctor)) {
80
+ emittedStyles.add(ctor);
81
+ for (let sheet of ctor.rootStyleSheets) {
82
+ let cssText = extractCSS(sheet);
83
+ if (cssText) {
84
+ innerContent += `<style>${cssText}</style>`;
85
+ }
86
+ }
87
+ }
88
+
89
+ // Declarative Shadow DOM:
90
+ if (el.shadowRoot) {
91
+ let shadowHTML = '';
92
+ // New scope — each shadow root is a separate styling root:
93
+ let shadowEmitted = new Set();
94
+ if (ctor.shadowStyleSheets) {
95
+ for (let sheet of ctor.shadowStyleSheets) {
96
+ let cssText = extractCSS(sheet);
97
+ if (cssText) {
98
+ shadowHTML += `<style>${cssText}</style>`;
99
+ }
100
+ }
101
+ }
102
+ for (let child of el.shadowRoot.childNodes) {
103
+ shadowHTML += serializeNode(child, shadowEmitted);
104
+ }
105
+ innerContent += `<template shadowrootmode="open">${shadowHTML}</template>`;
106
+ }
107
+
108
+ // Light DOM content:
109
+ for (let child of el.childNodes) {
110
+ innerContent += serializeNode(child, emittedStyles);
111
+ }
112
+
113
+ return `<${tagName}${attrsStr}>${innerContent}</${tagName}>`;
114
+ }
115
+
116
+ /**
117
+ * Serialize attributes of an element.
118
+ * @param {HTMLElement} el
119
+ * @returns {string}
120
+ */
121
+ function serializeAttrs(el) {
122
+ let str = '';
123
+ if (el.attributes) {
124
+ for (let attr of el.attributes) {
125
+ str += ` ${attr.name}="${attr.value}"`;
126
+ }
127
+ }
128
+ return str;
129
+ }
130
+
131
+ /**
132
+ * Serialize a DOM node to HTML string.
133
+ * @param {Node} node
134
+ * @param {Set<Function>} emittedStyles
135
+ * @returns {string}
136
+ */
137
+ function serializeNode(node, emittedStyles) {
138
+ // Custom element — recurse:
139
+ if (node.nodeType === 1 && /** @type {Element} */ (node).localName?.includes('-')) {
140
+ return serializeElement(/** @type {HTMLElement} */ (node), emittedStyles);
141
+ }
142
+ // Regular element:
143
+ if (node.nodeType === 1) {
144
+ let el = /** @type {HTMLElement} */ (node);
145
+ let attrsStr = serializeAttrs(el);
146
+ let childHTML = '';
147
+ for (let child of el.childNodes) {
148
+ childHTML += serializeNode(child, emittedStyles);
149
+ }
150
+ return `<${el.localName}${attrsStr}>${childHTML}</${el.localName}>`;
151
+ }
152
+ // Text node:
153
+ if (node.nodeType === 3) {
154
+ return resolveTextTokens(node.textContent || '', node);
155
+ }
156
+ // Comment:
157
+ if (node.nodeType === 8) {
158
+ return `<!--${node.textContent}-->`;
159
+ }
160
+ return '';
161
+ }
162
+
163
+ /**
164
+ * Stream-serialize an element, yielding chunks.
165
+ * @param {HTMLElement} el
166
+ * @param {Set<Function>} emittedStyles
167
+ * @returns {AsyncGenerator<string>}
168
+ */
169
+ async function* streamElement(el, emittedStyles) {
170
+ let tagName = el.localName;
171
+ let attrsStr = serializeAttrs(el);
172
+ let ctor = /** @type {any} */ (el).constructor;
173
+
174
+ yield `<${tagName}${attrsStr}>`;
175
+
176
+ // Light DOM rootStyles (once per constructor):
177
+ if (ctor.rootStyleSheets && !emittedStyles.has(ctor)) {
178
+ emittedStyles.add(ctor);
179
+ for (let sheet of ctor.rootStyleSheets) {
180
+ let cssText = extractCSS(sheet);
181
+ if (cssText) {
182
+ yield `<style>${cssText}</style>`;
183
+ }
184
+ }
185
+ }
186
+
187
+ // Declarative Shadow DOM:
188
+ if (el.shadowRoot) {
189
+ yield '<template shadowrootmode="open">';
190
+ // New scope — each shadow root is a separate styling root:
191
+ let shadowEmitted = new Set();
192
+ if (ctor.shadowStyleSheets) {
193
+ for (let sheet of ctor.shadowStyleSheets) {
194
+ let cssText = extractCSS(sheet);
195
+ if (cssText) {
196
+ yield `<style>${cssText}</style>`;
197
+ }
198
+ }
199
+ }
200
+ for (let child of el.shadowRoot.childNodes) {
201
+ yield* streamNode(child, shadowEmitted);
202
+ }
203
+ yield '</template>';
204
+ }
205
+
206
+ // Light DOM content:
207
+ for (let child of el.childNodes) {
208
+ yield* streamNode(child, emittedStyles);
209
+ }
210
+
211
+ yield `</${tagName}>`;
212
+ }
213
+
214
+ /**
215
+ * Stream-serialize a DOM node.
216
+ * @param {Node} node
217
+ * @param {Set<Function>} emittedStyles
218
+ * @returns {AsyncGenerator<string>}
219
+ */
220
+ async function* streamNode(node, emittedStyles) {
221
+ if (node.nodeType === 1 && /** @type {Element} */ (node).localName?.includes('-')) {
222
+ yield* streamElement(/** @type {HTMLElement} */ (node), emittedStyles);
223
+ return;
224
+ }
225
+ if (node.nodeType === 1) {
226
+ let el = /** @type {HTMLElement} */ (node);
227
+ let attrsStr = serializeAttrs(el);
228
+ if (el.childNodes.length) {
229
+ yield `<${el.localName}${attrsStr}>`;
230
+ for (let child of el.childNodes) {
231
+ yield* streamNode(child, emittedStyles);
232
+ }
233
+ yield `</${el.localName}>`;
234
+ } else {
235
+ yield `<${el.localName}${attrsStr}>${el.innerHTML}</${el.localName}>`;
236
+ }
237
+ return;
238
+ }
239
+ if (node.nodeType === 3) {
240
+ yield resolveTextTokens(node.textContent || '', node);
241
+ return;
242
+ }
243
+ if (node.nodeType === 8) {
244
+ yield `<!--${node.textContent}-->`;
245
+ }
246
+ }
247
+
248
+ export class SSR {
249
+
250
+ static #doc = null;
251
+ static #win = null;
252
+
253
+ /**
254
+ * Initialize the SSR environment using linkedom.
255
+ * Called automatically by processHtml(). Call manually only for renderToString/renderToStream.
256
+ */
257
+ static async init() {
258
+ // @ts-ignore
259
+ let { parseHTML } = /** @type {any} */ (await import('linkedom'));
260
+ let { document, window, HTMLElement, customElements, DocumentFragment, NodeFilter, MutationObserver } = parseHTML('<!DOCTYPE html><html><head></head><body></body></html>');
261
+
262
+ SSR.#doc = document;
263
+ SSR.#win = window;
264
+
265
+ // Polyfill CSSStyleSheet for linkedom:
266
+ if (!window.CSSStyleSheet || !('replaceSync' in (window.CSSStyleSheet?.prototype || {}))) {
267
+ class SSRStyleSheet {
268
+ #cssText = '';
269
+ replaceSync(text) {
270
+ this.#cssText = text;
271
+ }
272
+ replace(text) {
273
+ this.#cssText = text;
274
+ return Promise.resolve(this);
275
+ }
276
+ get cssText() {
277
+ return this.#cssText;
278
+ }
279
+ get cssRules() {
280
+ return [];
281
+ }
282
+ }
283
+ // @ts-ignore
284
+ window.CSSStyleSheet = SSRStyleSheet;
285
+ // @ts-ignore
286
+ globalThis.CSSStyleSheet = SSRStyleSheet;
287
+ }
288
+
289
+ // Polyfill NodeFilter:
290
+ let nodeFilter = NodeFilter || {
291
+ SHOW_ALL: 0xFFFFFFFF,
292
+ SHOW_ELEMENT: 0x1,
293
+ SHOW_TEXT: 0x4,
294
+ SHOW_COMMENT: 0x80,
295
+ FILTER_ACCEPT: 1,
296
+ FILTER_REJECT: 2,
297
+ FILTER_SKIP: 3,
298
+ };
299
+
300
+ // Polyfill MutationObserver:
301
+ let mutationObserver = MutationObserver || class {
302
+ observe() {}
303
+ disconnect() {}
304
+ takeRecords() { return []; }
305
+ };
306
+
307
+ // Polyfill adoptedStyleSheets (linkedom doesn't support it):
308
+ if (!document.adoptedStyleSheets) {
309
+ document.adoptedStyleSheets = [];
310
+ }
311
+
312
+ // Patch globals:
313
+ globalThis.__SYMBIOTE_SSR = true;
314
+ globalThis.document = document;
315
+ globalThis.window = window;
316
+ globalThis.HTMLElement = HTMLElement;
317
+ globalThis.customElements = customElements;
318
+ globalThis.DocumentFragment = DocumentFragment;
319
+ globalThis.NodeFilter = nodeFilter;
320
+ globalThis.MutationObserver = mutationObserver;
321
+
322
+ return { document, window };
323
+ }
324
+
325
+ /**
326
+ * Clean up the SSR environment.
327
+ * Called automatically by processHtml(). Call manually only after renderToString/renderToStream.
328
+ */
329
+ static destroy() {
330
+ if (SSR.#doc) {
331
+ SSR.#doc.body.innerHTML = '';
332
+ }
333
+ delete globalThis.__SYMBIOTE_SSR;
334
+ delete globalThis.document;
335
+ delete globalThis.window;
336
+ delete globalThis.HTMLElement;
337
+ delete globalThis.customElements;
338
+ delete globalThis.DocumentFragment;
339
+ delete globalThis.NodeFilter;
340
+ delete globalThis.MutationObserver;
341
+ delete globalThis.CSSStyleSheet;
342
+ SSR.#doc = null;
343
+ SSR.#win = null;
344
+ }
345
+
346
+ /**
347
+ * Process an arbitrary HTML string, rendering all Symbiote components found within.
348
+ * Initializes and destroys the SSR environment automatically.
349
+ *
350
+ * @param {string} html - Any HTML string containing custom element tags
351
+ * @returns {Promise<string>} Processed HTML with rendered components
352
+ *
353
+ * @example
354
+ * ```js
355
+ * await import('./my-components.js');
356
+ * let result = await SSR.processHtml('<div><my-header></my-header><main>content</main></div>');
357
+ * ```
358
+ */
359
+ static async processHtml(html) {
360
+ let autoInited = !SSR.#doc;
361
+ if (autoInited) {
362
+ await SSR.init();
363
+ }
364
+ SSR.#doc.body.innerHTML = html;
365
+ let emittedStyles = new Set();
366
+ let result = '';
367
+ for (let child of SSR.#doc.body.childNodes) {
368
+ result += serializeNode(child, emittedStyles);
369
+ }
370
+ SSR.#doc.body.innerHTML = '';
371
+ if (autoInited) {
372
+ SSR.destroy();
373
+ }
374
+ return result;
375
+ }
376
+
377
+ /**
378
+ * Render a single Symbiote component to an HTML string.
379
+ * Requires manual init()/destroy() lifecycle.
380
+ *
381
+ * @param {string} tagName - Custom element tag name
382
+ * @param {Object<string, string>} [attrs] - Attributes to set on the element
383
+ * @returns {string}
384
+ */
385
+ static renderToString(tagName, attrs = {}) {
386
+ if (!SSR.#doc) {
387
+ throw new Error('[Symbiote SSR] Call SSR.init() before renderToString()');
388
+ }
389
+ let el = SSR.#doc.createElement(tagName);
390
+ for (let [key, val] of Object.entries(attrs)) {
391
+ el.setAttribute(key, String(val));
392
+ }
393
+ SSR.#doc.body.appendChild(el);
394
+ let html = serializeElement(el, new Set());
395
+ el.remove();
396
+ return html;
397
+ }
398
+
399
+ /**
400
+ * Render a single Symbiote component as a stream of HTML chunks.
401
+ * Requires manual init()/destroy() lifecycle.
402
+ *
403
+ * @param {string} tagName - Custom element tag name
404
+ * @param {Object<string, string>} [attrs] - Attributes to set on the element
405
+ * @returns {AsyncGenerator<string>}
406
+ */
407
+ static async *renderToStream(tagName, attrs = {}) {
408
+ if (!SSR.#doc) {
409
+ throw new Error('[Symbiote SSR] Call SSR.init() before renderToStream()');
410
+ }
411
+ let el = SSR.#doc.createElement(tagName);
412
+ for (let [key, val] of Object.entries(attrs)) {
413
+ el.setAttribute(key, String(val));
414
+ }
415
+ SSR.#doc.body.appendChild(el);
416
+ yield* streamElement(el, new Set());
417
+ el.remove();
418
+ }
419
+ }
420
+
421
+ export default SSR;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@symbiotejs/symbiote",
4
- "version": "3.0.3",
4
+ "version": "3.0.4",
5
5
  "description": "Symbiote.js - zero-dependency close-to-platform frontend library to build super-powered web components",
6
6
  "author": "team@rnd-pro.com",
7
7
  "license": "MIT",
@@ -18,6 +18,7 @@
18
18
  "files": [
19
19
  "core/*",
20
20
  "utils/*",
21
+ "node/*",
21
22
  "types/*",
22
23
  "scripts/*",
23
24
  "README.md",