@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/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, '&')
|
|
371
|
+
.replace(/"/g, '"')
|
|
372
|
+
.replace(/</g, '<')
|
|
373
|
+
.replace(/>/g, '>');
|
|
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
|
+
}
|