@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/head.js ADDED
@@ -0,0 +1,393 @@
1
+ /**
2
+ * Clarity.js — Head / Meta Management
3
+ *
4
+ * Reactive <title>, <meta>, <link>, <script> injection — works in both
5
+ * client-side and SSR contexts.
6
+ *
7
+ * ── Client API ──────────────────────────────────────────────────────────────
8
+ *
9
+ * import { useHead } from '@ozsarman/clarityjs/head';
10
+ *
11
+ * component ProductPage(product: Object) {
12
+ * useHead({
13
+ * title: () => `${product.name} — MyShop`,
14
+ * description: () => product.description,
15
+ * og: {
16
+ * title: () => product.name,
17
+ * image: () => product.imageUrl,
18
+ * type: 'website',
19
+ * },
20
+ * link: [
21
+ * { rel: 'canonical', href: () => `https://myshop.com/products/${product.slug}` },
22
+ * ],
23
+ * });
24
+ * }
25
+ *
26
+ * ── SSR API ─────────────────────────────────────────────────────────────────
27
+ *
28
+ * import { createHead, renderHead } from '@ozsarman/clarityjs/head';
29
+ *
30
+ * const head = createHead();
31
+ * // ... render component tree (components call useHead internally) ...
32
+ * const { title, metas, links, scripts } = renderHead(head);
33
+ *
34
+ * Author: Claude (Anthropic) + Özdemir Sarman
35
+ */
36
+
37
+ import { signal, effect, batch } from './runtime.js';
38
+
39
+ // ─── Global head instance ────────────────────────────────────────────────────
40
+
41
+ let _globalHead = null;
42
+
43
+ /**
44
+ * Create and activate a new head manager.
45
+ * Call this once per app (or once per SSR request).
46
+ *
47
+ * @param {object} [opts]
48
+ * @param {string} [opts.titleTemplate] – e.g. '%s | MyApp' (%s = page title)
49
+ * @param {string} [opts.defaultTitle] – fallback when no component sets a title
50
+ * @returns {HeadManager}
51
+ */
52
+ export function createHead(opts = {}) {
53
+ const head = new HeadManager(opts);
54
+ _globalHead = head;
55
+ return head;
56
+ }
57
+
58
+ /**
59
+ * Get the active global head manager (created by createHead).
60
+ * @returns {HeadManager|null}
61
+ */
62
+ export function getHead() {
63
+ return _globalHead;
64
+ }
65
+
66
+ // ─── HeadManager ─────────────────────────────────────────────────────────────
67
+
68
+ class HeadManager {
69
+ constructor({ titleTemplate = null, defaultTitle = '' } = {}) {
70
+ this._titleTemplate = titleTemplate; // '%s | MyApp'
71
+ this._defaultTitle = defaultTitle;
72
+
73
+ // Each entry is an object from useHead() — stored in render order.
74
+ // Later entries win (deeper component wins over parent).
75
+ this._entries = [];
76
+ this._disposes = []; // cleanup functions from effect()
77
+
78
+ // Reactive resolved values (signals) — DOM patches subscribe to these
79
+ this._resolvedTitle = signal(defaultTitle);
80
+ this._resolvedMetas = signal([]);
81
+ this._resolvedLinks = signal([]);
82
+ this._resolvedScripts = signal([]);
83
+
84
+ // DOM patching (client only)
85
+ if (typeof document !== 'undefined') {
86
+ this._initDOMPatcher();
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Register a head entry (called by useHead()).
92
+ * Returns a dispose function that removes the entry.
93
+ */
94
+ _register(entry) {
95
+ this._entries.push(entry);
96
+ this._recompute();
97
+
98
+ return () => {
99
+ const idx = this._entries.indexOf(entry);
100
+ if (idx !== -1) this._entries.splice(idx, 1);
101
+ this._recompute();
102
+ };
103
+ }
104
+
105
+ /** Merge all entries and update resolved signals */
106
+ _recompute() {
107
+ // Title: last entry with a title wins
108
+ let rawTitle = this._defaultTitle;
109
+ let metaMap = new Map(); // key → { attrs }
110
+ let linkMap = new Map(); // key → { attrs }
111
+ let scripts = [];
112
+
113
+ for (const entry of this._entries) {
114
+ // ── title ──────────────────────────────────────────────────────────────
115
+ const t = _resolve(entry.title);
116
+ if (t != null && t !== '') rawTitle = String(t);
117
+
118
+ // ── description meta ───────────────────────────────────────────────────
119
+ const desc = _resolve(entry.description);
120
+ if (desc != null) {
121
+ metaMap.set('name:description', { name: 'description', content: desc });
122
+ }
123
+
124
+ // ── keywords ───────────────────────────────────────────────────────────
125
+ const kw = _resolve(entry.keywords);
126
+ if (kw != null) {
127
+ const kwStr = Array.isArray(kw) ? kw.join(', ') : String(kw);
128
+ metaMap.set('name:keywords', { name: 'keywords', content: kwStr });
129
+ }
130
+
131
+ // ── robots ─────────────────────────────────────────────────────────────
132
+ const robots = _resolve(entry.robots);
133
+ if (robots != null) {
134
+ metaMap.set('name:robots', { name: 'robots', content: robots });
135
+ }
136
+
137
+ // ── og: ────────────────────────────────────────────────────────────────
138
+ const og = entry.og ?? {};
139
+ for (const [k, v] of Object.entries(og)) {
140
+ const val = _resolve(v);
141
+ if (val != null) {
142
+ metaMap.set(`property:og:${k}`, { property: `og:${k}`, content: val });
143
+ }
144
+ }
145
+
146
+ // ── twitter: ───────────────────────────────────────────────────────────
147
+ const twitter = entry.twitter ?? {};
148
+ for (const [k, v] of Object.entries(twitter)) {
149
+ const val = _resolve(v);
150
+ if (val != null) {
151
+ metaMap.set(`name:twitter:${k}`, { name: `twitter:${k}`, content: val });
152
+ }
153
+ }
154
+
155
+ // ── arbitrary meta array ───────────────────────────────────────────────
156
+ for (const m of (entry.meta ?? [])) {
157
+ const key = m.name
158
+ ? `name:${m.name}`
159
+ : m.property
160
+ ? `property:${m.property}`
161
+ : m['http-equiv']
162
+ ? `http-equiv:${m['http-equiv']}`
163
+ : JSON.stringify(m);
164
+ const resolved = {};
165
+ for (const [mk, mv] of Object.entries(m)) resolved[mk] = _resolve(mv);
166
+ metaMap.set(key, resolved);
167
+ }
168
+
169
+ // ── link array ─────────────────────────────────────────────────────────
170
+ for (const l of (entry.link ?? [])) {
171
+ const resolved = {};
172
+ for (const [lk, lv] of Object.entries(l)) resolved[lk] = _resolve(lv);
173
+ const key = `${resolved.rel ?? ''}:${resolved.href ?? ''}`;
174
+ linkMap.set(key, resolved);
175
+ }
176
+
177
+ // ── script array ───────────────────────────────────────────────────────
178
+ for (const s of (entry.script ?? [])) {
179
+ const resolved = {};
180
+ for (const [sk, sv] of Object.entries(s)) resolved[sk] = _resolve(sv);
181
+ scripts.push(resolved);
182
+ }
183
+ }
184
+
185
+ // Apply title template
186
+ let finalTitle = rawTitle;
187
+ if (this._titleTemplate && rawTitle !== this._defaultTitle) {
188
+ finalTitle = this._titleTemplate.replace('%s', rawTitle);
189
+ }
190
+
191
+ batch(() => {
192
+ this._resolvedTitle.set(finalTitle);
193
+ this._resolvedMetas.set([...metaMap.values()]);
194
+ this._resolvedLinks.set([...linkMap.values()]);
195
+ this._resolvedScripts.set(scripts);
196
+ });
197
+ }
198
+
199
+ // ─── DOM patcher (client only) ──────────────────────────────────────────────
200
+
201
+ _initDOMPatcher() {
202
+ // Title
203
+ const disposeTitle = effect(() => {
204
+ const t = this._resolvedTitle.get();
205
+ if (document.title !== t) document.title = t;
206
+ });
207
+
208
+ // Meta
209
+ const disposeMeta = effect(() => {
210
+ const metas = this._resolvedMetas.get();
211
+ _patchMeta(metas);
212
+ });
213
+
214
+ // Link (canonical etc.)
215
+ const disposeLink = effect(() => {
216
+ const links = this._resolvedLinks.get();
217
+ _patchLinks(links);
218
+ });
219
+
220
+ this._disposes.push(disposeTitle, disposeMeta, disposeLink);
221
+ }
222
+
223
+ dispose() {
224
+ this._disposes.forEach(d => d());
225
+ this._disposes = [];
226
+ this._entries = [];
227
+ if (_globalHead === this) _globalHead = null;
228
+ }
229
+ }
230
+
231
+ // ─── useHead ─────────────────────────────────────────────────────────────────
232
+
233
+ /**
234
+ * Declare head tags from a component.
235
+ * Merges with any parent/sibling useHead() calls; later/deeper entries win.
236
+ *
237
+ * @param {object} entry
238
+ * @param {string|Function} [entry.title]
239
+ * @param {string|Function} [entry.description]
240
+ * @param {string|Function} [entry.keywords] – string or string[]
241
+ * @param {string|Function} [entry.robots]
242
+ * @param {object} [entry.og] – Open Graph { title, description, image, type, url }
243
+ * @param {object} [entry.twitter] – Twitter Card { card, site, creator, ... }
244
+ * @param {Array} [entry.meta] – arbitrary <meta> objects
245
+ * @param {Array} [entry.link] – arbitrary <link> objects
246
+ * @param {Array} [entry.script] – arbitrary <script> objects
247
+ */
248
+ export function useHead(entry = {}) {
249
+ const head = _globalHead;
250
+ if (!head) {
251
+ if (typeof console !== 'undefined') {
252
+ console.warn('[clarity/head] useHead() called before createHead(). Call createHead() in your app entry.');
253
+ }
254
+ return;
255
+ }
256
+
257
+ // Register the entry — recompute immediately so effects run synchronously
258
+ const dispose = head._register(entry);
259
+
260
+ // Hook into component lifecycle if available
261
+ if (typeof _onCleanup === 'function') {
262
+ _onCleanup(dispose);
263
+ }
264
+
265
+ return dispose;
266
+ }
267
+
268
+ // ─── SSR helpers ─────────────────────────────────────────────────────────────
269
+
270
+ /**
271
+ * Serialize the current head state to HTML strings suitable for injection
272
+ * into the <head> of a server-rendered document.
273
+ *
274
+ * Returns:
275
+ * {
276
+ * title: '<title>My Page | MyApp</title>',
277
+ * metas: '<meta name="description" content="…">\n...',
278
+ * links: '<link rel="canonical" href="…">\n...',
279
+ * scripts: '<script type="application/ld+json">…</script>\n...',
280
+ * }
281
+ *
282
+ * @param {HeadManager} [head] – defaults to the global head
283
+ */
284
+ export function renderHead(head = _globalHead) {
285
+ if (!head) return { title: '', metas: '', links: '', scripts: '' };
286
+
287
+ const title = head._resolvedTitle.get();
288
+ const metas = head._resolvedMetas.get();
289
+ const links = head._resolvedLinks.get();
290
+ const scripts = head._resolvedScripts.get();
291
+
292
+ return {
293
+ title: title ? `<title>${_esc(title)}</title>` : '',
294
+ metas: metas .map(_renderMeta) .filter(Boolean).join('\n'),
295
+ links: links .map(_renderLink) .filter(Boolean).join('\n'),
296
+ scripts: scripts .map(_renderScript).filter(Boolean).join('\n'),
297
+ };
298
+ }
299
+
300
+ // ─── DOM helpers (client) ─────────────────────────────────────────────────────
301
+
302
+ const _CLARITY_META_KEY = 'data-clarity-head';
303
+
304
+ function _patchMeta(metas) {
305
+ // Remove old managed metas
306
+ document.querySelectorAll(`meta[${_CLARITY_META_KEY}]`).forEach(el => el.remove());
307
+
308
+ for (const attrs of metas) {
309
+ const el = document.createElement('meta');
310
+ el.setAttribute(_CLARITY_META_KEY, '');
311
+ for (const [k, v] of Object.entries(attrs)) {
312
+ if (v != null) el.setAttribute(k, String(v));
313
+ }
314
+ document.head.appendChild(el);
315
+ }
316
+ }
317
+
318
+ function _patchLinks(links) {
319
+ // Only manage links we created; don't touch stylesheet links etc.
320
+ document.querySelectorAll(`link[${_CLARITY_META_KEY}]`).forEach(el => el.remove());
321
+
322
+ for (const attrs of links) {
323
+ const el = document.createElement('link');
324
+ el.setAttribute(_CLARITY_META_KEY, '');
325
+ for (const [k, v] of Object.entries(attrs)) {
326
+ if (v != null) el.setAttribute(k, String(v));
327
+ }
328
+ document.head.appendChild(el);
329
+ }
330
+ }
331
+
332
+ // ─── SSR serializers ─────────────────────────────────────────────────────────
333
+
334
+ function _renderMeta(attrs) {
335
+ const parts = Object.entries(attrs)
336
+ .filter(([, v]) => v != null)
337
+ .map(([k, v]) => `${k}="${_esc(String(v))}"`)
338
+ .join(' ');
339
+ return parts ? `<meta ${parts}>` : '';
340
+ }
341
+
342
+ function _renderLink(attrs) {
343
+ const parts = Object.entries(attrs)
344
+ .filter(([, v]) => v != null)
345
+ .map(([k, v]) => `${k}="${_esc(String(v))}"`)
346
+ .join(' ');
347
+ return parts ? `<link ${parts}>` : '';
348
+ }
349
+
350
+ function _renderScript(attrs) {
351
+ const { children, ...rest } = attrs;
352
+ const parts = Object.entries(rest)
353
+ .filter(([, v]) => v != null)
354
+ .map(([k, v]) => `${k}="${_esc(String(v))}"`)
355
+ .join(' ');
356
+ const inner = children != null ? String(children) : '';
357
+ return `<script ${parts}>${inner}</script>`;
358
+ }
359
+
360
+ // ─── Utilities ────────────────────────────────────────────────────────────────
361
+
362
+ /** Resolve a value or a getter function */
363
+ function _resolve(v) {
364
+ return typeof v === 'function' ? v() : v;
365
+ }
366
+
367
+ /** HTML-escape for attribute values and title */
368
+ function _esc(str) {
369
+ return String(str)
370
+ .replace(/&/g, '&amp;')
371
+ .replace(/"/g, '&quot;')
372
+ .replace(/</g, '&lt;')
373
+ .replace(/>/g, '&gt;');
374
+ }
375
+
376
+ // ─── Lifecycle hook ───────────────────────────────────────────────────────────
377
+ // Injected by runtime.js to avoid circular dependencies.
378
+ // runtime.js calls head._setLifecycleHook(onCleanup) during init.
379
+
380
+ let _onCleanup = null;
381
+
382
+ /**
383
+ * Called by the runtime to inject the onCleanup hook.
384
+ * This avoids a circular import between head.js and runtime.js.
385
+ * @internal
386
+ */
387
+ export function _setHeadLifecycleHook(fn) {
388
+ _onCleanup = fn;
389
+ }
390
+
391
+ // ─── Named export alias ───────────────────────────────────────────────────────
392
+
393
+ export { HeadManager };
package/src/hydrate.js ADDED
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Clarity.js — Client-Side Hydration
3
+ *
4
+ * hydrateRoot() attaches reactivity to server-rendered HTML without a
5
+ * visible content shift. The server serialises signal state into
6
+ * window.__CLARITY_DATA__; the client reads it back and re-mounts the
7
+ * component with the same initial values — so the rendered DOM is
8
+ * byte-for-byte identical to what the server produced.
9
+ *
10
+ * Usage (client entry point):
11
+ *
12
+ * import { hydrateRoot } from '@ozsarman/clarityjs/hydrate';
13
+ * import { MyComponent } from './MyComponent.js';
14
+ *
15
+ * hydrateRoot(
16
+ * document.getElementById('app'),
17
+ * MyComponent,
18
+ * { /* optional props *\/ }
19
+ * );
20
+ *
21
+ * Server side (Node.js / Express):
22
+ *
23
+ * import { renderToDocument } from '@ozsarman/clarityjs/ssr';
24
+ * const html = renderToDocument(MyComponent, { props: { ... }, clientScript: '/app.js' });
25
+ * res.send(html);
26
+ *
27
+ * Author: Claude (Anthropic) + Özdemir Sarman
28
+ */
29
+
30
+ // ─── Hydration walker ─────────────────────────────────────────────────────────
31
+ //
32
+ // The walker traverses the server-rendered DOM in document order, matching
33
+ // each createElement() call to the corresponding existing node. When there
34
+ // is a structural mismatch (e.g. server rendered a <div> but the component
35
+ // now wants a <span>), the walker falls back to creating a fresh node and
36
+ // logs a warning.
37
+ //
38
+ // Why this works with Clarity's h() function:
39
+ // h(tag, props, children) calls:
40
+ // 1. document.createElement(tag) ← we intercept → return existing node
41
+ // 2. el.setAttribute / addEventListener ← applied to the EXISTING node ✓
42
+ // 3. appendChild(el, child) ← child is already in el; DOM spec
43
+ // moves it only if it isn't ✓
44
+ // The existing node's children are cleared once claimed so that h() can
45
+ // re-append them in the correct reactive order without duplication.
46
+
47
+ class _HydrationWalker {
48
+ /**
49
+ * @param {Element} root - The container element (e.g. #app)
50
+ */
51
+ constructor(root) {
52
+ // Each frame: { el: Element, kids: Element[], i: number }
53
+ this._stack = [this._frame(root)];
54
+ this._mismatches = 0;
55
+ }
56
+
57
+ _frame(el) {
58
+ // Element children only — skip text, comment, processing-instruction nodes
59
+ const kids = Array.from(el.childNodes).filter(n => n.nodeType === 1);
60
+ return { el, kids, i: 0 };
61
+ }
62
+
63
+ /**
64
+ * Attempt to claim the next element child with the given tag.
65
+ * Returns the existing DOM node on success, null on mismatch.
66
+ */
67
+ claimElement(tag) {
68
+ const frame = this._stack[this._stack.length - 1];
69
+ if (!frame) return null;
70
+
71
+ const existing = frame.kids[frame.i];
72
+ if (!existing) return null;
73
+
74
+ if (existing.tagName.toLowerCase() !== tag.toLowerCase()) {
75
+ this._mismatches++;
76
+ if (this._mismatches <= 5) {
77
+ console.warn(
78
+ `[Clarity hydrateRoot] DOM mismatch: expected <${tag}> ` +
79
+ `but found <${existing.tagName.toLowerCase()}>. ` +
80
+ `Falling back to fresh element.`
81
+ );
82
+ }
83
+ return null;
84
+ }
85
+
86
+ frame.i++;
87
+ // Save existing children, then clear the node so h() can re-append them
88
+ // via the reactive effects without duplication.
89
+ const savedChildren = Array.from(existing.childNodes);
90
+ existing.__clarity_saved_children__ = savedChildren;
91
+ existing.innerHTML = '';
92
+
93
+ // Push frame so nested createElement() calls target this element's children
94
+ this._stack.push(this._frame({ childNodes: savedChildren }));
95
+ return existing;
96
+ }
97
+
98
+ /**
99
+ * Pop the current frame — called after h() finishes populating an element.
100
+ * We hook this via a patched appendChild: when the element's last child is
101
+ * appended, we know h() is done with it.
102
+ */
103
+ popFrame() {
104
+ if (this._stack.length > 1) this._stack.pop();
105
+ }
106
+
107
+ get depth() { return this._stack.length; }
108
+ get mismatches() { return this._mismatches; }
109
+ }
110
+
111
+ // ─── hydrateRoot ─────────────────────────────────────────────────────────────
112
+
113
+ /**
114
+ * Attach client-side reactivity to server-rendered HTML.
115
+ *
116
+ * This is the primary public API for SSR + hydration workflows.
117
+ *
118
+ * @param {Element} container - DOM element that wraps the server HTML (e.g. #app)
119
+ * @param {Function} ComponentFn - Compiled Clarity component function
120
+ * @param {object} [props={}] - Props to pass to the component
121
+ * @returns {{ unmount: () => void }} - Call unmount() to remove the component
122
+ */
123
+ export function hydrateRoot(container, ComponentFn, props = {}) {
124
+ if (!container) {
125
+ throw new Error('[Clarity] hydrateRoot: container element not found');
126
+ }
127
+
128
+ // ── 1. Read server state ─────────────────────────────────────────────────────
129
+ const ssrData =
130
+ (typeof window !== 'undefined' && window.__CLARITY_DATA__)
131
+ ? window.__CLARITY_DATA__
132
+ : {};
133
+
134
+ // ── 2. Attempt DOM adoption ──────────────────────────────────────────────────
135
+ // We try to walk the existing server-rendered DOM and reuse nodes, only
136
+ // creating fresh ones on mismatch. If the server HTML is absent (e.g.
137
+ // in a test environment), this gracefully degrades to a normal mount.
138
+
139
+ let unmount;
140
+
141
+ const hasServerHTML =
142
+ container.childNodes.length > 0 &&
143
+ container.innerHTML.trim() !== '';
144
+
145
+ if (hasServerHTML) {
146
+ unmount = _hydrateWithAdoption(container, ComponentFn, { ...props, __ssr: ssrData });
147
+ } else {
148
+ // No server HTML — fall through to a plain mount (dev server, test env)
149
+ unmount = _plainMount(container, ComponentFn, { ...props, __ssr: ssrData });
150
+ }
151
+
152
+ // ── 3. Mark as hydrated ──────────────────────────────────────────────────────
153
+ container.setAttribute('data-clarity-hydrated', 'true');
154
+
155
+ return { unmount };
156
+ }
157
+
158
+ // ─── DOM Adoption ─────────────────────────────────────────────────────────────
159
+
160
+ function _hydrateWithAdoption(container, ComponentFn, props) {
161
+ const walker = new _HydrationWalker(container);
162
+
163
+ // Patch document.createElement to return existing nodes when possible
164
+ const _origCreateElement = document.createElement.bind(document);
165
+ const _origCreateTextNode = document.createTextNode.bind(document);
166
+ const _origCreateComment = document.createComment.bind(document);
167
+
168
+ // Track depth changes via element creation to know when to pop frames
169
+ let _depth = 0;
170
+
171
+ document.createElement = function _hydratingCreateElement(tag) {
172
+ const existing = walker.claimElement(tag);
173
+ if (existing) {
174
+ existing.__clarity_adopted__ = true;
175
+ return existing;
176
+ }
177
+ // Mismatch or exhausted — create fresh
178
+ return _origCreateElement(tag);
179
+ };
180
+
181
+ // Text nodes and comments are recreated fresh (they carry reactive content)
182
+ // but we avoid console noise for them.
183
+ document.createTextNode = _origCreateTextNode;
184
+ document.createComment = _origCreateComment;
185
+
186
+ let rootEl;
187
+ try {
188
+ // Run the component — h() calls will use the patched createElement
189
+ rootEl = ComponentFn(props);
190
+ } catch (err) {
191
+ _restoreDocumentAPIs(document, _origCreateElement, _origCreateTextNode, _origCreateComment);
192
+ console.error('[Clarity] hydrateRoot: component threw during hydration', err);
193
+ // Fall back to plain mount
194
+ return _plainMount(container, ComponentFn, props);
195
+ }
196
+
197
+ _restoreDocumentAPIs(document, _origCreateElement, _origCreateTextNode, _origCreateComment);
198
+
199
+ if (walker.mismatches > 0) {
200
+ console.warn(
201
+ `[Clarity] hydrateRoot: ${walker.mismatches} DOM mismatch(es) detected. ` +
202
+ `Check that server and client render the same component with the same props.`
203
+ );
204
+ }
205
+
206
+ // Wire up the root node
207
+ if (_isNodeLike(rootEl)) {
208
+ // If adoption succeeded, the root node may already be in the container.
209
+ // If not (fresh element on root mismatch), append it.
210
+ if (!container.contains?.(rootEl)) {
211
+ container.innerHTML = '';
212
+ container.appendChild(rootEl);
213
+ }
214
+
215
+ if (typeof rootEl.__clarity_mount__ === 'function') {
216
+ rootEl.__clarity_mount__();
217
+ }
218
+
219
+ // Register in AI discovery registry (same pattern as runtime mount())
220
+ if (rootEl.__clarity_ai__) {
221
+ try {
222
+ // Dynamic import avoids circular dependency; aiRegistry lives in runtime
223
+ import('./runtime.js').then(rt => {
224
+ if (typeof rt._aiRegistryAdd === 'function') rt._aiRegistryAdd(rootEl);
225
+ }).catch(() => {});
226
+ } catch {}
227
+ }
228
+ }
229
+
230
+ return function unmount() {
231
+ if (rootEl && typeof rootEl.__clarity_cleanup__ === 'function') {
232
+ rootEl.__clarity_cleanup__();
233
+ }
234
+ if (rootEl && rootEl.parentNode) {
235
+ rootEl.parentNode.removeChild(rootEl);
236
+ }
237
+ container.removeAttribute('data-clarity-hydrated');
238
+ };
239
+ }
240
+
241
+ function _restoreDocumentAPIs(doc, createElement, createTextNode, createComment) {
242
+ doc.createElement = createElement;
243
+ doc.createTextNode = createTextNode;
244
+ doc.createComment = createComment;
245
+ }
246
+
247
+ // ─── Plain mount (fallback) ───────────────────────────────────────────────────
248
+
249
+ function _plainMount(container, ComponentFn, props) {
250
+ container.innerHTML = '';
251
+ const el = ComponentFn(props);
252
+
253
+ if (_isNodeLike(el)) {
254
+ container.appendChild(el);
255
+ if (typeof el.__clarity_mount__ === 'function') el.__clarity_mount__();
256
+ }
257
+
258
+ return function unmount() {
259
+ if (el && typeof el.__clarity_cleanup__ === 'function') el.__clarity_cleanup__();
260
+ if (el && el.parentNode) el.parentNode.removeChild(el);
261
+ container.removeAttribute('data-clarity-hydrated');
262
+ };
263
+ }
264
+
265
+ // ─── Duck-typing helper (avoids `instanceof Node` which is browser-only) ─────
266
+ function _isNodeLike(val) {
267
+ return val !== null && typeof val === 'object' && typeof val.nodeType === 'number';
268
+ }
269
+
270
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
271
+
272
+ /**
273
+ * isHydrated — check if a container has been hydrated by Clarity.
274
+ *
275
+ * @param {Element} container
276
+ * @returns {boolean}
277
+ */
278
+ export function isHydrated(container) {
279
+ return container?.getAttribute('data-clarity-hydrated') === 'true';
280
+ }
281
+
282
+ /**
283
+ * getSSRData — read the server-serialised state object.
284
+ * Returns an empty object if not available (e.g. no SSR).
285
+ *
286
+ * @returns {Record<string, unknown>}
287
+ */
288
+ export function getSSRData() {
289
+ return (typeof window !== 'undefined' && window.__CLARITY_DATA__)
290
+ ? window.__CLARITY_DATA__
291
+ : {};
292
+ }