@ozsarman/clarityjs 0.6.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/README.md +178 -0
- package/package.json +168 -0
- package/src/analyze.js +534 -0
- package/src/async-state.js +555 -0
- package/src/bundle-runtime.js +35 -0
- package/src/clarity-bundle.js +332 -0
- package/src/clarity-test.js +622 -0
- package/src/cli.js +453 -0
- package/src/codegen.js +1934 -0
- package/src/dev-server.js +362 -0
- package/src/devtools.js +765 -0
- package/src/edge.js +606 -0
- package/src/error-overlay.js +535 -0
- package/src/file-conventions.js +472 -0
- package/src/font.js +513 -0
- package/src/game-loop.js +106 -0
- package/src/head.js +393 -0
- package/src/hydrate.js +292 -0
- package/src/i18n.js +403 -0
- package/src/image.js +352 -0
- package/src/index.js +193 -0
- package/src/islands.js +284 -0
- package/src/isr.js +306 -0
- package/src/layout.js +342 -0
- package/src/lexer.js +572 -0
- package/src/linter.js +547 -0
- package/src/pages-router.js +229 -0
- package/src/parser.js +1108 -0
- package/src/router.js +732 -0
- package/src/runtime.js +1465 -0
- package/src/scoped-css.js +641 -0
- package/src/server-actions.js +439 -0
- package/src/server-data.js +225 -0
- package/src/sourcemap.js +130 -0
- package/src/ssg.js +310 -0
- package/src/ssr.js +621 -0
- package/src/store.js +276 -0
- package/src/transitions.js +438 -0
- package/src/ts-plugin.js +613 -0
- package/src/typegen.js +240 -0
- package/src/vite-plugin.js +447 -0
- package/types/index.d.ts +366 -0
package/src/ssr.js
ADDED
|
@@ -0,0 +1,621 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Clarity.js — Server-Side Rendering
|
|
3
|
+
*
|
|
4
|
+
* Provides renderToString() and renderToStaticHTML() for server environments.
|
|
5
|
+
* Components run inside a lightweight fake DOM; effects flush synchronously.
|
|
6
|
+
* The result is an HTML string plus serialised signal state for client hydration.
|
|
7
|
+
*
|
|
8
|
+
* Quick start:
|
|
9
|
+
*
|
|
10
|
+
* import { compile } from '@ozsarman/clarityjs';
|
|
11
|
+
* import { renderToString } from '@ozsarman/clarityjs/ssr';
|
|
12
|
+
*
|
|
13
|
+
* // 1. Compile the .clarity source with runtimePath pointing to ssr-runtime.js
|
|
14
|
+
* const { code } = compile(src, { runtimePath: './ssr-runtime.js' });
|
|
15
|
+
*
|
|
16
|
+
* // 2. Import the compiled component (or pass the function directly)
|
|
17
|
+
* const { MyComponent } = await import('./MyComponent.server.js');
|
|
18
|
+
*
|
|
19
|
+
* // 3. Render
|
|
20
|
+
* const { html, state } = renderToString(MyComponent, { title: 'Hello' });
|
|
21
|
+
*
|
|
22
|
+
* Author: Claude (Anthropic) + Özdemir Sarman
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
// ─── HTML helpers ─────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
const _ESC = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
28
|
+
export function escapeHTML(s) { return String(s ?? '').replace(/[&<>"']/g, c => _ESC[c]); }
|
|
29
|
+
export function escapeAttr(s) { return String(s ?? '').replace(/[&<>"']/g, c => _ESC[c]); }
|
|
30
|
+
|
|
31
|
+
const VOID_ELEMENTS = new Set([
|
|
32
|
+
'area','base','br','col','embed','hr','img','input',
|
|
33
|
+
'link','meta','param','source','track','wbr',
|
|
34
|
+
]);
|
|
35
|
+
const BOOLEAN_ATTRS = new Set([
|
|
36
|
+
'checked','disabled','readonly','required','multiple',
|
|
37
|
+
'selected','autofocus','autoplay','controls','hidden',
|
|
38
|
+
'loop','muted','open','defer','async',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
// ─── SSR Style proxy ──────────────────────────────────────────────────────────
|
|
42
|
+
// Mirrors the browser CSSStyleDeclaration enough for generated code.
|
|
43
|
+
|
|
44
|
+
class SSRStyle {
|
|
45
|
+
#props = {};
|
|
46
|
+
#owner;
|
|
47
|
+
constructor(owner) { this.#owner = owner; }
|
|
48
|
+
get cssText() {
|
|
49
|
+
return Object.entries(this.#props).map(([k, v]) => `${k}:${v}`).join(';');
|
|
50
|
+
}
|
|
51
|
+
set cssText(v) {
|
|
52
|
+
this.#props = {};
|
|
53
|
+
if (!v) return;
|
|
54
|
+
for (const part of String(v).split(';')) {
|
|
55
|
+
const idx = part.indexOf(':');
|
|
56
|
+
if (idx < 0) continue;
|
|
57
|
+
const key = part.slice(0, idx).trim();
|
|
58
|
+
const val = part.slice(idx + 1).trim();
|
|
59
|
+
if (key) this.#props[key] = val;
|
|
60
|
+
}
|
|
61
|
+
this.#owner._syncStyle();
|
|
62
|
+
}
|
|
63
|
+
setProperty(k, v) { this.#props[k] = v; this.#owner._syncStyle(); }
|
|
64
|
+
removeProperty(k) { delete this.#props[k]; this.#owner._syncStyle(); }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// ─── SSR Node classes ─────────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
class SSRTextNode {
|
|
70
|
+
nodeType = 3;
|
|
71
|
+
_parent = null;
|
|
72
|
+
#text;
|
|
73
|
+
constructor(text) { this.#text = String(text ?? ''); }
|
|
74
|
+
get parentNode() { return this._parent; }
|
|
75
|
+
get textContent() { return this.#text; }
|
|
76
|
+
set textContent(v) { this.#text = String(v ?? ''); }
|
|
77
|
+
get data() { return this.#text; }
|
|
78
|
+
set data(v) { this.#text = String(v ?? ''); }
|
|
79
|
+
toHTML() { return escapeHTML(this.#text); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
class SSRComment {
|
|
83
|
+
nodeType = 8;
|
|
84
|
+
_parent = null;
|
|
85
|
+
#text;
|
|
86
|
+
constructor(text) { this.#text = String(text ?? ''); }
|
|
87
|
+
get parentNode() { return this._parent; }
|
|
88
|
+
get textContent() { return ''; }
|
|
89
|
+
toHTML() { return `<!--${this.#text}-->`; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
class SSRFragment {
|
|
93
|
+
nodeType = 11;
|
|
94
|
+
_parent = null;
|
|
95
|
+
_children = [];
|
|
96
|
+
|
|
97
|
+
appendChild(child) {
|
|
98
|
+
if (!child) return child;
|
|
99
|
+
if (child instanceof SSRFragment) {
|
|
100
|
+
for (const c of [...child._children]) this.appendChild(c);
|
|
101
|
+
return child;
|
|
102
|
+
}
|
|
103
|
+
child._parent = this;
|
|
104
|
+
this._children.push(child);
|
|
105
|
+
return child;
|
|
106
|
+
}
|
|
107
|
+
insertBefore(newNode, refNode) {
|
|
108
|
+
if (!refNode) return this.appendChild(newNode);
|
|
109
|
+
const idx = this._children.indexOf(refNode);
|
|
110
|
+
if (idx >= 0) {
|
|
111
|
+
newNode._parent = this;
|
|
112
|
+
this._children.splice(idx, 0, newNode);
|
|
113
|
+
} else {
|
|
114
|
+
this.appendChild(newNode);
|
|
115
|
+
}
|
|
116
|
+
return newNode;
|
|
117
|
+
}
|
|
118
|
+
removeChild(child) {
|
|
119
|
+
const idx = this._children.indexOf(child);
|
|
120
|
+
if (idx >= 0) { this._children.splice(idx, 1); child._parent = null; }
|
|
121
|
+
return child;
|
|
122
|
+
}
|
|
123
|
+
get textContent() { return this._children.map(c => c.textContent ?? '').join(''); }
|
|
124
|
+
toHTML() { return this._children.map(c => c.toHTML?.() ?? '').join(''); }
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export class SSRElement {
|
|
128
|
+
nodeType = 1;
|
|
129
|
+
_parent = null;
|
|
130
|
+
#tag;
|
|
131
|
+
#attrs = new Map();
|
|
132
|
+
#children = [];
|
|
133
|
+
#style;
|
|
134
|
+
// Storage for AI contract — collected during renderToString
|
|
135
|
+
__clarity_ai__ = null;
|
|
136
|
+
__clarity_cleanup__ = null;
|
|
137
|
+
__clarity_mount__ = null;
|
|
138
|
+
|
|
139
|
+
constructor(tag) {
|
|
140
|
+
this.#tag = String(tag).toLowerCase();
|
|
141
|
+
this.#style = new SSRStyle(this);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Hierarchy ──────────────────────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
get tagName() { return this.#tag.toUpperCase(); }
|
|
147
|
+
get parentNode() { return this._parent; }
|
|
148
|
+
get firstChild() { return this.#children[0] ?? null; }
|
|
149
|
+
get lastChild() { return this.#children[this.#children.length - 1] ?? null; }
|
|
150
|
+
get childNodes() { return [...this.#children]; }
|
|
151
|
+
// Getter for children (non-comment non-text)
|
|
152
|
+
get children() { return this.#children.filter(c => c.nodeType === 1); }
|
|
153
|
+
|
|
154
|
+
appendChild(child) {
|
|
155
|
+
if (!child) return child;
|
|
156
|
+
if (child instanceof SSRFragment) {
|
|
157
|
+
for (const c of [...child._children]) this.appendChild(c);
|
|
158
|
+
return child;
|
|
159
|
+
}
|
|
160
|
+
if (child._parent) child._parent.removeChild(child);
|
|
161
|
+
child._parent = this;
|
|
162
|
+
this.#children.push(child);
|
|
163
|
+
return child;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
insertBefore(newNode, refNode) {
|
|
167
|
+
if (!refNode) return this.appendChild(newNode);
|
|
168
|
+
if (newNode instanceof SSRFragment) {
|
|
169
|
+
for (const c of [...newNode._children]) this.insertBefore(c, refNode);
|
|
170
|
+
return newNode;
|
|
171
|
+
}
|
|
172
|
+
if (newNode._parent) newNode._parent.removeChild(newNode);
|
|
173
|
+
const idx = this.#children.indexOf(refNode);
|
|
174
|
+
if (idx >= 0) {
|
|
175
|
+
newNode._parent = this;
|
|
176
|
+
this.#children.splice(idx, 0, newNode);
|
|
177
|
+
} else {
|
|
178
|
+
this.appendChild(newNode);
|
|
179
|
+
}
|
|
180
|
+
return newNode;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
removeChild(child) {
|
|
184
|
+
const idx = this.#children.indexOf(child);
|
|
185
|
+
if (idx >= 0) { this.#children.splice(idx, 1); child._parent = null; }
|
|
186
|
+
return child;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
replaceChild(newChild, oldChild) {
|
|
190
|
+
const idx = this.#children.indexOf(oldChild);
|
|
191
|
+
if (idx >= 0) {
|
|
192
|
+
if (newChild._parent) newChild._parent.removeChild(newChild);
|
|
193
|
+
newChild._parent = this;
|
|
194
|
+
oldChild._parent = null;
|
|
195
|
+
this.#children[idx] = newChild;
|
|
196
|
+
}
|
|
197
|
+
return oldChild;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
contains(node) {
|
|
201
|
+
let n = node;
|
|
202
|
+
while (n) { if (n === this) return true; n = n._parent; }
|
|
203
|
+
return false;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// ── Attributes ─────────────────────────────────────────────────────────────
|
|
207
|
+
|
|
208
|
+
setAttribute(k, v) { this.#attrs.set(String(k), v); }
|
|
209
|
+
removeAttribute(k) { this.#attrs.delete(String(k)); }
|
|
210
|
+
hasAttribute(k) { return this.#attrs.has(String(k)); }
|
|
211
|
+
getAttribute(k) { return this.#attrs.get(String(k)) ?? null; }
|
|
212
|
+
|
|
213
|
+
// Common property shortcuts generated by Clarity codegen
|
|
214
|
+
get className() { return this.getAttribute('class') ?? ''; }
|
|
215
|
+
set className(v) { this.setAttribute('class', v ?? ''); }
|
|
216
|
+
get id() { return this.getAttribute('id') ?? ''; }
|
|
217
|
+
set id(v) { this.setAttribute('id', String(v)); }
|
|
218
|
+
get href() { return this.getAttribute('href') ?? ''; }
|
|
219
|
+
set href(v) { this.setAttribute('href', String(v)); }
|
|
220
|
+
get src() { return this.getAttribute('src') ?? ''; }
|
|
221
|
+
set src(v) { this.setAttribute('src', String(v)); }
|
|
222
|
+
get value() { return this.getAttribute('value') ?? ''; }
|
|
223
|
+
set value(v) { this.setAttribute('value', String(v ?? '')); }
|
|
224
|
+
get checked() { return this.hasAttribute('checked'); }
|
|
225
|
+
set checked(v) { v ? this.setAttribute('checked', '') : this.removeAttribute('checked'); }
|
|
226
|
+
get disabled() { return this.hasAttribute('disabled'); }
|
|
227
|
+
set disabled(v) { v ? this.setAttribute('disabled', '') : this.removeAttribute('disabled'); }
|
|
228
|
+
|
|
229
|
+
// ── Style ──────────────────────────────────────────────────────────────────
|
|
230
|
+
|
|
231
|
+
get style() { return this.#style; }
|
|
232
|
+
_syncStyle() {
|
|
233
|
+
const css = this.#style.cssText;
|
|
234
|
+
if (css) this.#attrs.set('style', css);
|
|
235
|
+
else this.#attrs.delete('style');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// ── Text content ───────────────────────────────────────────────────────────
|
|
239
|
+
|
|
240
|
+
get textContent() { return this.#children.map(c => c.textContent ?? '').join(''); }
|
|
241
|
+
set textContent(v) {
|
|
242
|
+
this.#children = [];
|
|
243
|
+
const s = String(v ?? '');
|
|
244
|
+
if (s !== '') {
|
|
245
|
+
const t = new SSRTextNode(s);
|
|
246
|
+
t._parent = this;
|
|
247
|
+
this.#children.push(t);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
get innerHTML() { return this.#children.map(c => c.toHTML?.() ?? '').join(''); }
|
|
252
|
+
set innerHTML(v) {
|
|
253
|
+
// Very naive — just treat as raw HTML text in an SSR text node
|
|
254
|
+
this.#children = [];
|
|
255
|
+
if (v) {
|
|
256
|
+
// Wrap in a raw node that doesn't escape
|
|
257
|
+
const raw = { nodeType: 3, textContent: v, _parent: this, toHTML: () => String(v) };
|
|
258
|
+
this.#children.push(raw);
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Events (no-ops in SSR) ─────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
addEventListener() {}
|
|
265
|
+
removeEventListener() {}
|
|
266
|
+
dispatchEvent() { return true; }
|
|
267
|
+
|
|
268
|
+
// ── querySelector (used by some runtime helpers) ───────────────────────────
|
|
269
|
+
|
|
270
|
+
querySelector() { return null; }
|
|
271
|
+
querySelectorAll() { return []; }
|
|
272
|
+
|
|
273
|
+
// ── Serialisation ──────────────────────────────────────────────────────────
|
|
274
|
+
|
|
275
|
+
toHTML() {
|
|
276
|
+
const parts = [];
|
|
277
|
+
for (const [k, v] of this.#attrs) {
|
|
278
|
+
if (v === false || v === null || v === undefined) continue;
|
|
279
|
+
if (v === true || (typeof v === 'string' && v === '' && BOOLEAN_ATTRS.has(k))) {
|
|
280
|
+
parts.push(k);
|
|
281
|
+
} else if (BOOLEAN_ATTRS.has(k) && v) {
|
|
282
|
+
parts.push(k);
|
|
283
|
+
} else {
|
|
284
|
+
parts.push(`${k}="${escapeAttr(v)}"`);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
const attrStr = parts.length ? ' ' + parts.join(' ') : '';
|
|
288
|
+
if (VOID_ELEMENTS.has(this.#tag)) return `<${this.#tag}${attrStr}>`;
|
|
289
|
+
const inner = this.#children.map(c => c.toHTML?.() ?? escapeHTML(String(c))).join('');
|
|
290
|
+
return `<${this.#tag}${attrStr}>${inner}</${this.#tag}>`;
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// ─── SSR document factory ─────────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
function createSSRDocument() {
|
|
297
|
+
const body = new SSRElement('body');
|
|
298
|
+
const head = new SSRElement('head');
|
|
299
|
+
return {
|
|
300
|
+
body,
|
|
301
|
+
head,
|
|
302
|
+
createElement: (tag) => new SSRElement(tag),
|
|
303
|
+
createElementNS: (_ns, tag) => new SSRElement(tag),
|
|
304
|
+
createTextNode: (text) => new SSRTextNode(text),
|
|
305
|
+
createComment: (text) => new SSRComment(text),
|
|
306
|
+
createDocumentFragment: () => new SSRFragment(),
|
|
307
|
+
getElementById: () => null,
|
|
308
|
+
querySelector: () => null,
|
|
309
|
+
querySelectorAll: () => [],
|
|
310
|
+
addEventListener: () => {},
|
|
311
|
+
removeEventListener: () => {},
|
|
312
|
+
// For portal support — return body so portals render inline
|
|
313
|
+
_portals: [],
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// ─── renderToString ───────────────────────────────────────────────────────────
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* Renders a Clarity component to an HTML string on the server.
|
|
321
|
+
*
|
|
322
|
+
* The component function is called inside a sandboxed environment where:
|
|
323
|
+
* • `document` is replaced with a lightweight SSR fake DOM
|
|
324
|
+
* • `queueMicrotask` is stubbed to run tasks synchronously after the
|
|
325
|
+
* component returns (ensures <when> and <list> effects flush correctly)
|
|
326
|
+
* • `window` is stubbed to avoid ReferenceErrors
|
|
327
|
+
*
|
|
328
|
+
* @param {Function} componentFn Compiled Clarity component function
|
|
329
|
+
* @param {object} [props={}] Props to pass to the component
|
|
330
|
+
* @returns {{ html: string, state: object, aiContracts: object[] }}
|
|
331
|
+
*/
|
|
332
|
+
export function renderToString(componentFn, props = {}) {
|
|
333
|
+
// ── Save globals ────────────────────────────────────────────────────────────
|
|
334
|
+
const _globals = _saveGlobals();
|
|
335
|
+
|
|
336
|
+
// ── Set up SSR environment ──────────────────────────────────────────────────
|
|
337
|
+
const ssrDoc = createSSRDocument();
|
|
338
|
+
const taskQueue = [];
|
|
339
|
+
const aiContracts = [];
|
|
340
|
+
|
|
341
|
+
globalThis.document = ssrDoc;
|
|
342
|
+
globalThis.window = globalThis.window ?? {};
|
|
343
|
+
globalThis.queueMicrotask = (fn) => taskQueue.push(fn);
|
|
344
|
+
|
|
345
|
+
// Minimal CSS.supports stub (used by some style helpers)
|
|
346
|
+
if (!globalThis.CSS) globalThis.CSS = { supports: () => false };
|
|
347
|
+
|
|
348
|
+
let rootNode;
|
|
349
|
+
try {
|
|
350
|
+
// ── Run the component ────────────────────────────────────────────────────
|
|
351
|
+
rootNode = componentFn(props);
|
|
352
|
+
|
|
353
|
+
// ── Flush deferred microtasks (when / list effects) ──────────────────────
|
|
354
|
+
// Tasks can enqueue further tasks (nested when / list), so we loop until empty.
|
|
355
|
+
let safety = 0;
|
|
356
|
+
while (taskQueue.length > 0 && safety++ < 10_000) {
|
|
357
|
+
taskQueue.shift()();
|
|
358
|
+
}
|
|
359
|
+
if (safety >= 10_000) {
|
|
360
|
+
console.warn('[Clarity SSR] Possible infinite loop in queueMicrotask tasks — bailed out.');
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// ── Collect AI contracts ─────────────────────────────────────────────────
|
|
364
|
+
_collectAIContracts(rootNode, aiContracts);
|
|
365
|
+
// Also collect from portals
|
|
366
|
+
for (const p of ssrDoc._portals) _collectAIContracts(p, aiContracts);
|
|
367
|
+
|
|
368
|
+
// ── Serialize ────────────────────────────────────────────────────────────
|
|
369
|
+
let html;
|
|
370
|
+
if (rootNode instanceof SSRElement || rootNode instanceof SSRFragment) {
|
|
371
|
+
html = rootNode.toHTML();
|
|
372
|
+
} else if (rootNode == null) {
|
|
373
|
+
html = '';
|
|
374
|
+
} else {
|
|
375
|
+
html = String(rootNode);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// Portals append after root
|
|
379
|
+
const portalHTML = ssrDoc._portals.map(p => p.toHTML?.() ?? '').join('');
|
|
380
|
+
|
|
381
|
+
// ── Snapshot state via AI contracts ──────────────────────────────────────
|
|
382
|
+
const state = {};
|
|
383
|
+
for (const contract of aiContracts) {
|
|
384
|
+
try {
|
|
385
|
+
state[contract.component] = contract.snapshot();
|
|
386
|
+
} catch {
|
|
387
|
+
// snapshot might fail if component has no readable fields
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
return {
|
|
392
|
+
html: html + portalHTML,
|
|
393
|
+
state,
|
|
394
|
+
aiContracts,
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
} finally {
|
|
398
|
+
_restoreGlobals(_globals);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Async variant of renderToString for components that have a `server { }` block.
|
|
404
|
+
*
|
|
405
|
+
* Calls `componentFn.__clarity_server__(props, ctx)` to fetch data, then passes
|
|
406
|
+
* the result as `__ssr` into the component so signals are seeded with server data.
|
|
407
|
+
*
|
|
408
|
+
* @param {Function} componentFn Compiled Clarity component function (with __clarity_server__)
|
|
409
|
+
* @param {object} [props={}] Props to pass to the component
|
|
410
|
+
* @param {object} [ctx={}] Server context (request, auth, etc.) forwarded to the loader
|
|
411
|
+
* @returns {Promise<{ html: string, state: object, aiContracts: object[] }>}
|
|
412
|
+
*/
|
|
413
|
+
export async function renderToStringAsync(componentFn, props = {}, ctx = {}) {
|
|
414
|
+
let ssrData = {};
|
|
415
|
+
if (typeof componentFn.__clarity_server__ === 'function') {
|
|
416
|
+
ssrData = await componentFn.__clarity_server__(props, ctx);
|
|
417
|
+
}
|
|
418
|
+
// Merge __ssr into props so the component's destructuring picks it up
|
|
419
|
+
return renderToString(componentFn, { ...props, __ssr: ssrData });
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Like renderToString but wraps the output in a full HTML document shell.
|
|
424
|
+
*
|
|
425
|
+
* @param {Function} componentFn
|
|
426
|
+
* @param {object} [options={}]
|
|
427
|
+
* @param {object} [options.props={}] Props for the component
|
|
428
|
+
* @param {string} [options.title=''] <title> tag content
|
|
429
|
+
* @param {string} [options.lang='en'] <html lang> attribute
|
|
430
|
+
* @param {string} [options.clientScript] Path to client-side JS bundle
|
|
431
|
+
* @param {string} [options.styles] Inline CSS to inject
|
|
432
|
+
* @returns {string} Complete HTML document
|
|
433
|
+
*/
|
|
434
|
+
export function renderToDocument(componentFn, {
|
|
435
|
+
props = {},
|
|
436
|
+
title = '',
|
|
437
|
+
lang = 'en',
|
|
438
|
+
clientScript = null,
|
|
439
|
+
styles = null,
|
|
440
|
+
} = {}) {
|
|
441
|
+
const { html, state } = renderToString(componentFn, props);
|
|
442
|
+
const stateScript = Object.keys(state).length > 0
|
|
443
|
+
? `<script>window.__CLARITY_DATA__=${JSON.stringify(state)};</script>`
|
|
444
|
+
: '';
|
|
445
|
+
const clientTag = clientScript
|
|
446
|
+
? `<script type="module" src="${escapeAttr(clientScript)}"></script>`
|
|
447
|
+
: '';
|
|
448
|
+
const styleTag = styles
|
|
449
|
+
? `<style>${styles}</style>`
|
|
450
|
+
: '';
|
|
451
|
+
return [
|
|
452
|
+
`<!DOCTYPE html>`,
|
|
453
|
+
`<html lang="${escapeAttr(lang)}">`,
|
|
454
|
+
`<head>`,
|
|
455
|
+
`<meta charset="utf-8">`,
|
|
456
|
+
`<meta name="viewport" content="width=device-width,initial-scale=1">`,
|
|
457
|
+
title ? `<title>${escapeHTML(title)}</title>` : '',
|
|
458
|
+
styleTag,
|
|
459
|
+
`</head>`,
|
|
460
|
+
`<body>`,
|
|
461
|
+
`<div id="app">${html}</div>`,
|
|
462
|
+
stateScript,
|
|
463
|
+
clientTag,
|
|
464
|
+
`</body>`,
|
|
465
|
+
`</html>`,
|
|
466
|
+
].filter(Boolean).join('\n');
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ─── renderToStream ───────────────────────────────────────────────────────────
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* Streams a full HTML document to a Node.js Writable (or Web ReadableStream).
|
|
473
|
+
*
|
|
474
|
+
* The shell (head + opening body) is flushed immediately so the browser can
|
|
475
|
+
* start downloading assets. The component HTML follows as a second chunk,
|
|
476
|
+
* then the closing tags. This gives the "streaming SSR" behaviour of
|
|
477
|
+
* Next.js App Router / React 18 without a virtual DOM.
|
|
478
|
+
*
|
|
479
|
+
* Node.js usage (Express):
|
|
480
|
+
*
|
|
481
|
+
* import { renderToStream } from '@ozsarman/clarityjs/ssr';
|
|
482
|
+
* app.get('/', async (req, res) => {
|
|
483
|
+
* res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
|
484
|
+
* res.setHeader('Transfer-Encoding', 'chunked');
|
|
485
|
+
* const stream = renderToStream(App, { props: { user: req.user }, title: 'Home' });
|
|
486
|
+
* stream.pipe(res);
|
|
487
|
+
* });
|
|
488
|
+
*
|
|
489
|
+
* Web Streams usage (Cloudflare Workers / Deno / Bun):
|
|
490
|
+
*
|
|
491
|
+
* const stream = renderToStream(App, { webStream: true });
|
|
492
|
+
* return new Response(stream, { headers: { 'Content-Type': 'text/html' } });
|
|
493
|
+
*
|
|
494
|
+
* @param {Function} componentFn
|
|
495
|
+
* @param {object} [options={}]
|
|
496
|
+
* @param {object} [options.props={}] Props for the component
|
|
497
|
+
* @param {string} [options.title=''] <title> tag
|
|
498
|
+
* @param {string} [options.lang='en'] <html lang>
|
|
499
|
+
* @param {string} [options.clientScript] Path to client bundle
|
|
500
|
+
* @param {string} [options.styles] Inline CSS
|
|
501
|
+
* @param {boolean} [options.webStream=false] Return a Web ReadableStream
|
|
502
|
+
* @returns {import('node:stream').Readable | ReadableStream}
|
|
503
|
+
*/
|
|
504
|
+
export function renderToStream(componentFn, {
|
|
505
|
+
props = {},
|
|
506
|
+
title = '',
|
|
507
|
+
lang = 'en',
|
|
508
|
+
clientScript = null,
|
|
509
|
+
styles = null,
|
|
510
|
+
webStream = false,
|
|
511
|
+
} = {}) {
|
|
512
|
+
// Build shell synchronously — send immediately so browser fetches assets
|
|
513
|
+
const styleTag = styles ? `<style>${styles}</style>` : '';
|
|
514
|
+
const clientTag = clientScript
|
|
515
|
+
? `<script type="module" src="${escapeAttr(clientScript)}"></script>`
|
|
516
|
+
: '';
|
|
517
|
+
|
|
518
|
+
const shell = [
|
|
519
|
+
`<!DOCTYPE html>`,
|
|
520
|
+
`<html lang="${escapeAttr(lang)}">`,
|
|
521
|
+
`<head>`,
|
|
522
|
+
`<meta charset="utf-8">`,
|
|
523
|
+
`<meta name="viewport" content="width=device-width,initial-scale=1">`,
|
|
524
|
+
title ? `<title>${escapeHTML(title)}</title>` : '',
|
|
525
|
+
styleTag,
|
|
526
|
+
`</head>`,
|
|
527
|
+
`<body>`,
|
|
528
|
+
`<div id="app">`,
|
|
529
|
+
].filter(Boolean).join('\n');
|
|
530
|
+
|
|
531
|
+
// Render component — synchronous (effects flushed inside renderToString)
|
|
532
|
+
const { html, state } = renderToString(componentFn, props);
|
|
533
|
+
|
|
534
|
+
const stateScript = Object.keys(state).length > 0
|
|
535
|
+
? `<script>window.__CLARITY_DATA__=${JSON.stringify(state)};</script>`
|
|
536
|
+
: '';
|
|
537
|
+
|
|
538
|
+
const tail = [
|
|
539
|
+
`</div>`,
|
|
540
|
+
stateScript,
|
|
541
|
+
clientTag,
|
|
542
|
+
`</body>`,
|
|
543
|
+
`</html>`,
|
|
544
|
+
].filter(Boolean).join('\n');
|
|
545
|
+
|
|
546
|
+
if (webStream) {
|
|
547
|
+
// Web Streams API (Workers, Deno, Bun)
|
|
548
|
+
return new ReadableStream({
|
|
549
|
+
start(controller) {
|
|
550
|
+
const enc = new TextEncoder();
|
|
551
|
+
controller.enqueue(enc.encode(shell));
|
|
552
|
+
controller.enqueue(enc.encode(html));
|
|
553
|
+
controller.enqueue(enc.encode(tail));
|
|
554
|
+
controller.close();
|
|
555
|
+
},
|
|
556
|
+
});
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// Node.js Readable stream — works with .pipe(res)
|
|
560
|
+
// Dynamic import avoids bundling 'node:stream' in browser builds
|
|
561
|
+
const { Readable } = await_import_stream();
|
|
562
|
+
|
|
563
|
+
const chunks = [shell, html, tail];
|
|
564
|
+
let _idx = 0;
|
|
565
|
+
|
|
566
|
+
return new Readable({
|
|
567
|
+
read() {
|
|
568
|
+
if (_idx < chunks.length) {
|
|
569
|
+
this.push(chunks[_idx++], 'utf8');
|
|
570
|
+
} else {
|
|
571
|
+
this.push(null); // EOF
|
|
572
|
+
}
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* @internal — lazy-loads node:stream so the module stays browser-compatible.
|
|
579
|
+
* In environments where node:stream is unavailable, use the webStream option.
|
|
580
|
+
*/
|
|
581
|
+
function await_import_stream() {
|
|
582
|
+
// This is called at runtime (not at module parse time), so bundlers that
|
|
583
|
+
// tree-shake import() won't inline it in browser builds.
|
|
584
|
+
try {
|
|
585
|
+
// Node 18+ supports native require() in ESM via createRequire, but the
|
|
586
|
+
// simplest approach is to use a dynamic import wrapped in eval() so that
|
|
587
|
+
// browser bundlers never see it.
|
|
588
|
+
return eval('import("node:stream")');
|
|
589
|
+
} catch {
|
|
590
|
+
throw new Error(
|
|
591
|
+
'[Clarity renderToStream] node:stream is not available in this environment. ' +
|
|
592
|
+
'Pass { webStream: true } to use the Web Streams API instead.'
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
598
|
+
|
|
599
|
+
function _collectAIContracts(node, out) {
|
|
600
|
+
if (!node) return;
|
|
601
|
+
if (node.__clarity_ai__) out.push(node.__clarity_ai__);
|
|
602
|
+
const children = node._children ?? node.childNodes ?? [];
|
|
603
|
+
for (const child of children) {
|
|
604
|
+
if (child && typeof child === 'object') _collectAIContracts(child, out);
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
const _GLOBAL_KEYS = ['document', 'window', 'queueMicrotask', 'CSS'];
|
|
609
|
+
|
|
610
|
+
function _saveGlobals() {
|
|
611
|
+
const saved = {};
|
|
612
|
+
for (const k of _GLOBAL_KEYS) saved[k] = globalThis[k];
|
|
613
|
+
return saved;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function _restoreGlobals(saved) {
|
|
617
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
618
|
+
if (v === undefined) delete globalThis[k];
|
|
619
|
+
else globalThis[k] = v;
|
|
620
|
+
}
|
|
621
|
+
}
|