@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/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 = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
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
+ }