@ruledwdl/dom 0.1.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/index.js ADDED
@@ -0,0 +1,753 @@
1
+ /**
2
+ * @ruledwdl/dom — Native DOM runtime for @ruledwdl/state
3
+ *
4
+ * Listens to every event emitted by ComponentState / ComponentManager and
5
+ * applies surgical DOM mutations with createElement / insertBefore / remove.
6
+ * No innerHTML on updates.
7
+ *
8
+ * State events handled (from @ruledwdl/state):
9
+ * layers:change actions: set | append | before | after | wrap | remove | update
10
+ * attr:change actions: set | update | remove
11
+ * data:change actions: set | remove (optional data-* / text binding)
12
+ * variant:change actions: set
13
+ * registry:change actions: set | update | addRule | removeRule
14
+ *
15
+ * Event payload shape (from state.emit):
16
+ * {
17
+ * type: string, // e.g. "layers:change"
18
+ * componentId: string,
19
+ * action: string, // e.g. "append"
20
+ * targetId?: string, // semantic id involved
21
+ * payload?: any, // layer expr | attrs | path value | rule …
22
+ * timestamp: number
23
+ * }
24
+ *
25
+ * Usage:
26
+ * import { createWdlDom } from './wdl-dom.js';
27
+ * // or: import { ComponentManager } from '@ruledwdl/state';
28
+ *
29
+ * const hero = manager.create('hero', { layers: '...', attr: {...} });
30
+ * const dom = createWdlDom({
31
+ * container: document.getElementById('app'),
32
+ * component: hero, // ComponentState instance
33
+ * // optional:
34
+ * // onDataBind: (el, path, value) => { ... },
35
+ * // styleTarget: document.head,
36
+ * });
37
+ *
38
+ * // later: hero.attr.set('title', { text: 'Hi' }); // → surgical update
39
+ * // destroy: dom.destroy();
40
+ */
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ const ATTR_SKIP = new Set(['text', 'class', 'html']);
47
+
48
+ /**
49
+ * Parse a single layer token: "button.cta" | "div" | "h1.title"
50
+ * @returns {{ tag: string, semanticId: string }}
51
+ */
52
+ function parseLayerToken(expr) {
53
+ if (typeof expr !== 'string') {
54
+ if (expr && typeof expr === 'object') {
55
+ return {
56
+ tag: String(expr.tag || 'div').toLowerCase(),
57
+ semanticId: String(expr.semanticId || expr.id || '').replace(/^\./, ''),
58
+ };
59
+ }
60
+ throw new Error('[wdl-dom] invalid layer expression');
61
+ }
62
+ const trimmed = expr.trim();
63
+ const m = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_-]*)(?:\.([a-zA-Z0-9_-]+))?$/);
64
+ if (!m) {
65
+ // fallback: treat whole string as tag
66
+ return { tag: trimmed.toLowerCase() || 'div', semanticId: '' };
67
+ }
68
+ return {
69
+ tag: m[1].toLowerCase(),
70
+ semanticId: (m[2] || '').replace(/^\./, ''),
71
+ };
72
+ }
73
+
74
+ /**
75
+ * Very small layers string → tree (supports > and + only).
76
+ * For full WDL grammar prefer tree from component.layers.tree().
77
+ */
78
+ function parseLayersSimple(str) {
79
+ if (Array.isArray(str)) return structuredClone(str);
80
+ if (typeof str !== 'string' || !str.trim()) return [];
81
+
82
+ const root = { tag: '__root__', semanticId: '', children: [] };
83
+ const stack = [root];
84
+ let i = 0;
85
+
86
+ const top = () => stack[stack.length - 1];
87
+
88
+ while (i < str.length) {
89
+ const ch = str[i];
90
+ if (/\s/.test(ch)) { i++; continue; }
91
+ if (ch === '>') {
92
+ const last = top().children[top().children.length - 1];
93
+ if (last) stack.push(last);
94
+ i++;
95
+ continue;
96
+ }
97
+ if (ch === '+') { i++; continue; }
98
+ if (ch === '<') {
99
+ i++;
100
+ if (stack.length > 1) stack.pop();
101
+ continue;
102
+ }
103
+ // element token
104
+ let tag = '';
105
+ while (i < str.length && /[a-zA-Z0-9_-]/.test(str[i])) tag += str[i++];
106
+ let semanticId = '';
107
+ if (str[i] === '.') {
108
+ i++;
109
+ while (i < str.length && /[a-zA-Z0-9_-]/.test(str[i])) semanticId += str[i++];
110
+ }
111
+ top().children.push({ tag: tag.toLowerCase() || 'div', semanticId, children: [] });
112
+ }
113
+ return root.children;
114
+ }
115
+
116
+ function normalizeId(id) {
117
+ if (id == null) return '';
118
+ return String(id).replace(/^\./, '');
119
+ }
120
+
121
+ // ---------------------------------------------------------------------------
122
+ // WdlDom
123
+ // ---------------------------------------------------------------------------
124
+
125
+ export class WdlDom {
126
+ /**
127
+ * @param {object} options
128
+ * @param {HTMLElement|string} options.container
129
+ * @param {object} options.component ComponentState instance (has .on, .layers, .attr, .getSnapshot)
130
+ * @param {(el: HTMLElement, path: string, value: any) => void} [options.onDataBind]
131
+ * @param {HTMLElement|Document} [options.styleTarget=document.head]
132
+ * @param {boolean} [options.debug=false]
133
+ */
134
+ constructor(options = {}) {
135
+ const {
136
+ container,
137
+ component,
138
+ onDataBind = null,
139
+ styleTarget = typeof document !== 'undefined' ? document.head : null,
140
+ debug = false,
141
+ } = options;
142
+
143
+ if (!container) throw new Error('[wdl-dom] container is required');
144
+ if (!component) throw new Error('[wdl-dom] component (ComponentState) is required');
145
+
146
+ this.container =
147
+ typeof container === 'string'
148
+ ? document.querySelector(container)
149
+ : container;
150
+ if (!this.container) throw new Error('[wdl-dom] container not found');
151
+
152
+ this.component = component;
153
+ this.onDataBind = onDataBind;
154
+ this.styleTarget = styleTarget;
155
+ this.debug = debug;
156
+
157
+ /** @type {Map<string, HTMLElement>} semanticId → element */
158
+ this.liveMap = new Map();
159
+
160
+ /** @type {Array<() => void>} */
161
+ this._unsubs = [];
162
+
163
+ /** style element for registry rules */
164
+ this._styleEl = null;
165
+
166
+ this._mount();
167
+ this._bindStateEvents();
168
+ }
169
+
170
+ // -----------------------------------------------------------------------
171
+ // Public API
172
+ // -----------------------------------------------------------------------
173
+
174
+ /** Force full remount from current snapshot */
175
+ remount() {
176
+ this._mount();
177
+ }
178
+
179
+ /** Current live map (read-only view) */
180
+ getLiveMap() {
181
+ return new Map(this.liveMap);
182
+ }
183
+
184
+ /** Clean up listeners and DOM */
185
+ destroy() {
186
+ this._unsubs.forEach((u) => {
187
+ try { u(); } catch (_) {}
188
+ });
189
+ this._unsubs = [];
190
+ this.container.replaceChildren();
191
+ this.liveMap.clear();
192
+ if (this._styleEl && this._styleEl.parentNode) {
193
+ this._styleEl.parentNode.removeChild(this._styleEl);
194
+ }
195
+ this._styleEl = null;
196
+ }
197
+
198
+ // -----------------------------------------------------------------------
199
+ // Initial mount
200
+ // -----------------------------------------------------------------------
201
+
202
+ _mount() {
203
+ this.container.replaceChildren();
204
+ this.liveMap.clear();
205
+
206
+ let tree;
207
+ try {
208
+ tree = typeof this.component.layers?.tree === 'function'
209
+ ? this.component.layers.tree()
210
+ : parseLayersSimple(this.component.layers?.list?.() ?? this.component.getSnapshot?.()?.layers);
211
+ } catch (e) {
212
+ this._log('warn', 'tree() failed, falling back to simple parse', e);
213
+ const layers = this.component.getSnapshot?.()?.layers ?? '';
214
+ tree = parseLayersSimple(layers);
215
+ }
216
+
217
+ if (!Array.isArray(tree)) tree = [];
218
+
219
+ for (const node of tree) {
220
+ const el = this._createElementFromNode(node);
221
+ this.container.appendChild(el);
222
+ }
223
+
224
+ // Apply all current attributes
225
+ const attrMap =
226
+ this.component.attr?.list?.() ??
227
+ this.component.getSnapshot?.()?.attr ??
228
+ {};
229
+ for (const [sel, props] of Object.entries(attrMap)) {
230
+ this._applyAttrs(normalizeId(sel), props);
231
+ }
232
+
233
+ // Apply variant if present on root
234
+ this._applyRootVariant();
235
+
236
+ // Registry styles
237
+ this._syncRegistryStyles();
238
+
239
+ this._log('mount', `mounted ${this.liveMap.size} nodes`);
240
+ }
241
+
242
+ /**
243
+ * @param {{ tag: string, semanticId?: string, id?: string, children?: any[] }} node
244
+ */
245
+ _createElementFromNode(node) {
246
+ const tag = (node.tag || 'div').toLowerCase();
247
+ const id = normalizeId(node.semanticId || node.id || '');
248
+ const el = document.createElement(tag);
249
+
250
+ if (id) {
251
+ el.classList.add(id);
252
+ el.setAttribute('wdl-comp', id);
253
+ this.liveMap.set(id, el);
254
+ }
255
+
256
+ if (Array.isArray(node.children)) {
257
+ for (const child of node.children) {
258
+ el.appendChild(this._createElementFromNode(child));
259
+ }
260
+ }
261
+ return el;
262
+ }
263
+
264
+ // -----------------------------------------------------------------------
265
+ // State event binding
266
+ // -----------------------------------------------------------------------
267
+
268
+ _bindStateEvents() {
269
+ const c = this.component;
270
+ if (typeof c.on !== 'function') {
271
+ this._log('warn', 'component has no .on() — events will not be applied');
272
+ return;
273
+ }
274
+
275
+ const handler = (event) => this._onStateEvent(event);
276
+
277
+ // All events documented by @ruledwdl/state
278
+ const types = [
279
+ 'layers:change',
280
+ 'attr:change',
281
+ 'data:change',
282
+ 'variant:change',
283
+ 'registry:change',
284
+ ];
285
+
286
+ for (const type of types) {
287
+ const off = c.on(type, handler);
288
+ // state.on may return unsubscribe fn or nothing
289
+ if (typeof off === 'function') this._unsubs.push(off);
290
+ else this._unsubs.push(() => c.off?.(type, handler));
291
+ }
292
+ }
293
+
294
+ /**
295
+ * @param {{ type: string, action: string, targetId?: string, payload?: any, componentId?: string }} event
296
+ */
297
+ _onStateEvent(event) {
298
+ if (!event || !event.type) return;
299
+ this._log('event', event.type, event.action, event.targetId, event.payload);
300
+
301
+ switch (event.type) {
302
+ case 'layers:change':
303
+ this._handleLayers(event);
304
+ break;
305
+ case 'attr:change':
306
+ this._handleAttr(event);
307
+ break;
308
+ case 'data:change':
309
+ this._handleData(event);
310
+ break;
311
+ case 'variant:change':
312
+ this._handleVariant(event);
313
+ break;
314
+ case 'registry:change':
315
+ this._handleRegistry(event);
316
+ break;
317
+ default:
318
+ this._log('warn', 'unknown event type', event.type);
319
+ }
320
+ }
321
+
322
+ // -----------------------------------------------------------------------
323
+ // layers:change
324
+ // actions: set | append | before | after | wrap | remove | update
325
+ // -----------------------------------------------------------------------
326
+
327
+ _handleLayers(event) {
328
+ const { action, targetId, payload } = event;
329
+ const id = normalizeId(targetId);
330
+
331
+ switch (action) {
332
+ case 'set': {
333
+ // Full layers replacement → remount
334
+ this._mount();
335
+ break;
336
+ }
337
+ case 'append': {
338
+ // payload = layer expression (string or node-like)
339
+ const parentEl = this.liveMap.get(id);
340
+ if (!parentEl) {
341
+ this._log('warn', `append: parent "${id}" not in liveMap — remounting`);
342
+ this._mount();
343
+ return;
344
+ }
345
+ const token = parseLayerToken(payload);
346
+ const el = this._createElementFromNode({
347
+ tag: token.tag,
348
+ semanticId: token.semanticId,
349
+ children: [],
350
+ });
351
+ parentEl.appendChild(el);
352
+ this._log('op', `append <${token.tag}.${token.semanticId}> → #${id}`);
353
+ break;
354
+ }
355
+ case 'prepend': {
356
+ const parentEl = this.liveMap.get(id);
357
+ if (!parentEl) {
358
+ this._log('warn', `prepend: parent "${id}" not in liveMap — remounting`);
359
+ this._mount();
360
+ return;
361
+ }
362
+ const token = parseLayerToken(payload);
363
+ const el = this._createElementFromNode({
364
+ tag: token.tag,
365
+ semanticId: token.semanticId,
366
+ children: [],
367
+ });
368
+ parentEl.insertBefore(el, parentEl.firstChild);
369
+ this._log('op', `prepend <${token.tag}.${token.semanticId}> → #${id}`);
370
+ break;
371
+ }
372
+ case 'before':
373
+ case 'after': {
374
+ const targetEl = this.liveMap.get(id);
375
+ if (!targetEl || !targetEl.parentNode) {
376
+ this._log('warn', `${action}: target "${id}" missing — remounting`);
377
+ this._mount();
378
+ return;
379
+ }
380
+ const token = parseLayerToken(payload);
381
+ const el = this._createElementFromNode({
382
+ tag: token.tag,
383
+ semanticId: token.semanticId,
384
+ children: [],
385
+ });
386
+ if (action === 'before') {
387
+ targetEl.parentNode.insertBefore(el, targetEl);
388
+ } else {
389
+ targetEl.parentNode.insertBefore(el, targetEl.nextSibling);
390
+ }
391
+ this._log('op', `${action} <${token.tag}.${token.semanticId}> relative to #${id}`);
392
+ break;
393
+ }
394
+ case 'wrap': {
395
+ // payload = wrapper layer expression
396
+ const targetEl = this.liveMap.get(id);
397
+ if (!targetEl || !targetEl.parentNode) {
398
+ this._log('warn', `wrap: target "${id}" missing — remounting`);
399
+ this._mount();
400
+ return;
401
+ }
402
+ const token = parseLayerToken(payload);
403
+ const wrapper = this._createElementFromNode({
404
+ tag: token.tag,
405
+ semanticId: token.semanticId,
406
+ children: [],
407
+ });
408
+ targetEl.parentNode.insertBefore(wrapper, targetEl);
409
+ wrapper.appendChild(targetEl);
410
+ this._log('op', `wrap #${id} with <${token.tag}.${token.semanticId}>`);
411
+ break;
412
+ }
413
+ case 'unwrap': {
414
+ const wrapperEl = this.liveMap.get(id);
415
+ if (!wrapperEl || !wrapperEl.parentNode) {
416
+ this._log('warn', `unwrap: target "${id}" missing — remounting`);
417
+ this._mount();
418
+ return;
419
+ }
420
+ const parent = wrapperEl.parentNode;
421
+ while (wrapperEl.firstChild) {
422
+ parent.insertBefore(wrapperEl.firstChild, wrapperEl);
423
+ }
424
+ wrapperEl.remove();
425
+ this.liveMap.delete(id);
426
+ this._log('op', `unwrap #${id}`);
427
+ break;
428
+ }
429
+ case 'move': {
430
+ const sourceEl = this.liveMap.get(id);
431
+ const { targetSemanticId, position } = payload || {};
432
+ const targetId = normalizeId(targetSemanticId);
433
+ const targetEl = this.liveMap.get(targetId);
434
+
435
+ if (!sourceEl || !targetEl) {
436
+ this._log('warn', `move: source "${id}" or target "${targetId}" missing — remounting`);
437
+ this._mount();
438
+ return;
439
+ }
440
+
441
+ if (position === 'before') {
442
+ targetEl.parentNode?.insertBefore(sourceEl, targetEl);
443
+ } else if (position === 'after') {
444
+ targetEl.parentNode?.insertBefore(sourceEl, targetEl.nextSibling);
445
+ } else {
446
+ targetEl.appendChild(sourceEl);
447
+ }
448
+ this._log('op', `move #${id} -> ${position} #${targetId}`);
449
+ break;
450
+ }
451
+ case 'remove': {
452
+ const el = this.liveMap.get(id);
453
+ if (!el) {
454
+ this._log('warn', `remove: #${id} not in liveMap`);
455
+ return;
456
+ }
457
+ // Remove descendants from liveMap first
458
+ for (const [sid, node] of [...this.liveMap]) {
459
+ if (sid !== id && el.contains(node)) this.liveMap.delete(sid);
460
+ }
461
+ el.remove();
462
+ this.liveMap.delete(id);
463
+ this._log('op', `remove #${id}`);
464
+ break;
465
+ }
466
+ case 'update': {
467
+ // payload = { tag?, semanticId? }
468
+ const el = this.liveMap.get(id);
469
+ if (!el) {
470
+ this._log('warn', `update: #${id} missing — remounting`);
471
+ this._mount();
472
+ return;
473
+ }
474
+ const patch = payload || {};
475
+ if (patch.tag && patch.tag.toLowerCase() !== el.tagName.toLowerCase()) {
476
+ // Tag change requires recreate
477
+ const next = document.createElement(String(patch.tag).toLowerCase());
478
+ // copy attributes & children
479
+ for (const attr of el.attributes) next.setAttribute(attr.name, attr.value);
480
+ while (el.firstChild) next.appendChild(el.firstChild);
481
+ el.parentNode?.replaceChild(next, el);
482
+ this.liveMap.set(id, next);
483
+ if (patch.semanticId && normalizeId(patch.semanticId) !== id) {
484
+ const newId = normalizeId(patch.semanticId);
485
+ next.classList.remove(id);
486
+ next.classList.add(newId);
487
+ next.setAttribute('wdl-comp', newId);
488
+ this.liveMap.delete(id);
489
+ this.liveMap.set(newId, next);
490
+ }
491
+ } else if (patch.semanticId && normalizeId(patch.semanticId) !== id) {
492
+ const newId = normalizeId(patch.semanticId);
493
+ el.classList.remove(id);
494
+ el.classList.add(newId);
495
+ el.setAttribute('wdl-comp', newId);
496
+ this.liveMap.delete(id);
497
+ this.liveMap.set(newId, el);
498
+ }
499
+ this._log('op', `update #${id}`, patch);
500
+ break;
501
+ }
502
+ default:
503
+ this._log('warn', `layers: unknown action "${action}" — remounting`);
504
+ this._mount();
505
+ }
506
+ }
507
+
508
+ // -----------------------------------------------------------------------
509
+ // attr:change
510
+ // actions: set | update | remove
511
+ // -----------------------------------------------------------------------
512
+
513
+ _handleAttr(event) {
514
+ const { action, targetId, payload } = event;
515
+ const id = normalizeId(targetId);
516
+
517
+ if (action === 'set' || action === 'update') {
518
+ // payload = full attrs object (set) or patch (update)
519
+ this._applyAttrs(id, payload || {});
520
+ this._log('op', `attr.${action} #${id}`, payload);
521
+ return;
522
+ }
523
+
524
+ if (action === 'remove') {
525
+ const el = this.liveMap.get(id);
526
+ if (!el) return;
527
+ const attrKey = payload; // may be undefined → remove whole entry
528
+ if (attrKey == null || attrKey === '') {
529
+ el.textContent = '';
530
+ // keep semantic class
531
+ el.className = id;
532
+ this._log('op', `attr.remove entire #${id}`);
533
+ } else if (attrKey === 'text') {
534
+ el.textContent = '';
535
+ this._log('op', `attr.remove text from #${id}`);
536
+ } else if (attrKey === 'class') {
537
+ el.className = id;
538
+ this._log('op', `attr.remove class from #${id}`);
539
+ } else if (attrKey === 'html') {
540
+ el.innerHTML = '';
541
+ this._log('op', `attr.remove html from #${id}`);
542
+ } else {
543
+ el.removeAttribute(attrKey);
544
+ this._log('op', `attr.remove ${attrKey} from #${id}`);
545
+ }
546
+ }
547
+ }
548
+
549
+ /**
550
+ * Apply attribute object onto live element.
551
+ * Supports: text, class, html, data-*, style object, arbitrary attrs.
552
+ */
553
+ _applyAttrs(id, props) {
554
+ if (!props || typeof props !== 'object') return;
555
+ const el = this.liveMap.get(id);
556
+ if (!el) {
557
+ this._log('warn', `attr: #${id} not in liveMap`);
558
+ return;
559
+ }
560
+
561
+ if (props.text !== undefined) {
562
+ el.textContent = String(props.text);
563
+ }
564
+ if (props.html !== undefined) {
565
+ el.innerHTML = String(props.html);
566
+ }
567
+ if (props.class !== undefined) {
568
+ const extra = String(props.class).trim();
569
+ el.className = extra ? `${id} ${extra}` : id;
570
+ }
571
+
572
+ for (const [key, value] of Object.entries(props)) {
573
+ if (ATTR_SKIP.has(key)) continue;
574
+ if (key === 'style' && value && typeof value === 'object') {
575
+ Object.assign(el.style, value);
576
+ continue;
577
+ }
578
+ if (value == null) {
579
+ el.removeAttribute(key);
580
+ } else {
581
+ el.setAttribute(key, String(value));
582
+ }
583
+ }
584
+ }
585
+
586
+ // -----------------------------------------------------------------------
587
+ // data:change
588
+ // actions: set | remove
589
+ // Optional: onDataBind callback for custom binding
590
+ // -----------------------------------------------------------------------
591
+
592
+ _handleData(event) {
593
+ const { action, targetId, payload } = event;
594
+ // targetId is the path (e.g. "user.name"), payload is the value
595
+ const path = targetId || '';
596
+ if (typeof this.onDataBind === 'function') {
597
+ // Let consumer decide how data maps to DOM
598
+ // We pass the root container + path + value
599
+ try {
600
+ this.onDataBind(this.container, path, action === 'remove' ? undefined : payload);
601
+ } catch (e) {
602
+ this._log('warn', 'onDataBind error', e);
603
+ }
604
+ }
605
+ this._log('op', `data.${action} ${path}`, action === 'remove' ? undefined : payload);
606
+ }
607
+
608
+ // -----------------------------------------------------------------------
609
+ // variant:change
610
+ // action: set targetId = semanticId (or component id) payload = variantName
611
+ // -----------------------------------------------------------------------
612
+
613
+ _handleVariant(event) {
614
+ const { targetId, payload } = event;
615
+ const id = normalizeId(targetId) || this._rootSemanticId();
616
+ const el = this.liveMap.get(id);
617
+ if (!el) {
618
+ // try root
619
+ const rootId = this._rootSemanticId();
620
+ const rootEl = rootId ? this.liveMap.get(rootId) : null;
621
+ if (rootEl) {
622
+ if (payload) rootEl.dataset.variant = String(payload);
623
+ else delete rootEl.dataset.variant;
624
+ this._log('op', `variant → ${payload || '(none)'} on #${rootId}`);
625
+ }
626
+ return;
627
+ }
628
+ if (payload) el.dataset.variant = String(payload);
629
+ else delete el.dataset.variant;
630
+ this._log('op', `variant → ${payload || '(none)'} on #${id}`);
631
+ }
632
+
633
+ _applyRootVariant() {
634
+ // Read current variant from attr if present
635
+ const attr =
636
+ this.component.attr?.list?.() ??
637
+ this.component.getSnapshot?.()?.attr ??
638
+ {};
639
+ const rootId = this._rootSemanticId();
640
+ if (!rootId) return;
641
+ const rootKey = '.' + rootId;
642
+ const variant =
643
+ attr[rootKey]?.['data-variant'] ??
644
+ attr['.']?.['data-variant'] ??
645
+ null;
646
+ if (variant && this.liveMap.has(rootId)) {
647
+ this.liveMap.get(rootId).dataset.variant = String(variant);
648
+ }
649
+ }
650
+
651
+ _rootSemanticId() {
652
+ // First entry in liveMap that is a direct child of container, or first key
653
+ for (const [id, el] of this.liveMap) {
654
+ if (el.parentNode === this.container) return id;
655
+ }
656
+ const first = this.liveMap.keys().next();
657
+ return first.done ? '' : first.value;
658
+ }
659
+
660
+ // -----------------------------------------------------------------------
661
+ // registry:change
662
+ // actions: set | update | addRule | removeRule
663
+ // Injects / updates a <style data-wdl-dom="componentId"> in styleTarget
664
+ // -----------------------------------------------------------------------
665
+
666
+ _handleRegistry(event) {
667
+ this._syncRegistryStyles();
668
+ this._log('op', `registry.${event.action}`);
669
+ }
670
+
671
+ _syncRegistryStyles() {
672
+ if (!this.styleTarget) return;
673
+
674
+ const registry =
675
+ this.component.registry?.get?.() ??
676
+ this.component.getSnapshot?.()?.registry ??
677
+ null;
678
+
679
+ const componentId = this.component.id || this.component.getSnapshot?.()?.id || 'comp';
680
+
681
+ if (!this._styleEl) {
682
+ this._styleEl = document.createElement('style');
683
+ this._styleEl.setAttribute('data-wdl-dom', componentId);
684
+ this.styleTarget.appendChild(this._styleEl);
685
+ }
686
+
687
+ if (!registry) {
688
+ this._styleEl.textContent = '';
689
+ return;
690
+ }
691
+
692
+ const rules = Array.isArray(registry.rules) ? registry.rules : [];
693
+ const parts = [];
694
+
695
+ // CSS variables
696
+ if (registry.vars && typeof registry.vars === 'object') {
697
+ const decls = Object.entries(registry.vars)
698
+ .map(([k, v]) => `--${k}: ${v};`)
699
+ .join(' ');
700
+ if (decls) parts.push(`:root, [wdl-comp] { ${decls} }`);
701
+ }
702
+
703
+ for (const rule of rules) {
704
+ if (!rule || !rule.selector) continue;
705
+ const css =
706
+ typeof rule.css === 'string'
707
+ ? rule.css
708
+ : rule.css && typeof rule.css === 'object'
709
+ ? Object.entries(rule.css)
710
+ .map(([k, v]) => `${camelToKebab(k)}: ${v};`)
711
+ .join(' ')
712
+ : '';
713
+ if (!css) continue;
714
+ const selector = rule.selector.startsWith('&')
715
+ ? rule.selector.replace('&', `[wdl-comp="${componentId}"]`)
716
+ : rule.selector;
717
+ const media = rule.media ? `@media ${rule.media} { ${selector} { ${css} } }` : `${selector} { ${css} }`;
718
+ parts.push(media);
719
+ }
720
+
721
+ this._styleEl.textContent = parts.join('\n');
722
+ }
723
+
724
+ // -----------------------------------------------------------------------
725
+ // Utils
726
+ // -----------------------------------------------------------------------
727
+
728
+ _log(kind, ...args) {
729
+ if (!this.debug && kind !== 'warn') return;
730
+ const prefix = `[wdl-dom:${kind}]`;
731
+ if (kind === 'warn') console.warn(prefix, ...args);
732
+ else console.log(prefix, ...args);
733
+ }
734
+ }
735
+
736
+ function camelToKebab(str) {
737
+ return String(str).replace(/[A-Z]/g, (m) => '-' + m.toLowerCase());
738
+ }
739
+
740
+ // ---------------------------------------------------------------------------
741
+ // Factory
742
+ // ---------------------------------------------------------------------------
743
+
744
+ /**
745
+ * Create and mount a WdlDom runtime.
746
+ * @param {ConstructorParameters<typeof WdlDom>[0]} options
747
+ * @returns {WdlDom}
748
+ */
749
+ export function createWdlDom(options) {
750
+ return new WdlDom(options);
751
+ }
752
+
753
+ export default createWdlDom;