@nisli/core 0.43.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.
@@ -0,0 +1,753 @@
1
+ /**
2
+ * template.ts — Tagged template engine with targeted DOM binding
3
+ *
4
+ * The html tagged template literal parses HTML with expression slots,
5
+ * creates bindings for signals, and produces a TemplateResult that
6
+ * can be mounted into the DOM with surgical updates.
7
+ *
8
+ * Two phases:
9
+ * 1. First render: parse template, clone, create bindings, mount
10
+ * 2. Updates: signal changes trigger individual binding updates (O(1))
11
+ */
12
+ import { signal, isSignal, effect, computed, } from './signal.js';
13
+ import { isRef } from './ref.js';
14
+ // ── Template cache ──────────────────────────────────────────────────
15
+ /**
16
+ * Cache parsed templates by their static string parts.
17
+ * Since tagged templates always produce the same static strings array
18
+ * reference per call site, we use WeakMap for efficient caching.
19
+ */
20
+ const templateCache = new WeakMap();
21
+ // ── Marker for expression slots ─────────────────────────────────────
22
+ const MARKER_PREFIX = '<!--bk-';
23
+ const MARKER_SUFFIX = '-->';
24
+ const ATTR_MARKER = 'bk-';
25
+ /**
26
+ * Generate a unique marker for each expression slot.
27
+ * We use HTML comments as markers in text positions and
28
+ * special attribute prefixes for attribute positions.
29
+ */
30
+ function createMarker(index) {
31
+ return `${MARKER_PREFIX}${index}${MARKER_SUFFIX}`;
32
+ }
33
+ // ── html tagged template ────────────────────────────────────────────
34
+ /**
35
+ * Tagged template literal for creating reactive DOM templates.
36
+ *
37
+ * ```ts
38
+ * const name = signal('World');
39
+ * const greeting = html`<h1>Hello, ${name}!</h1>`;
40
+ * ```
41
+ *
42
+ * Signals in expression slots are detected automatically and
43
+ * create fine-grained bindings. When the signal changes, only
44
+ * the specific text node or attribute updates — no diffing needed.
45
+ */
46
+ export function html(strings, ...values) {
47
+ const bindings = [];
48
+ const disposers = [];
49
+ return {
50
+ __templateResult: true,
51
+ mount(host) {
52
+ // Build the HTML string with markers.
53
+ // Track HTML parsing state to auto-quote markers in unquoted
54
+ // attribute positions where the > in --> would close the tag.
55
+ // See ADR 0069.
56
+ let htmlStr = '';
57
+ let inTag = false;
58
+ let quoteChar = null;
59
+ for (let i = 0; i < strings.length; i++) {
60
+ const s = strings[i] ?? '';
61
+ for (let c = 0; c < s.length; c++) {
62
+ const ch = s.charAt(c);
63
+ if (quoteChar) {
64
+ if (ch === quoteChar)
65
+ quoteChar = null;
66
+ }
67
+ else if (inTag) {
68
+ if (ch === '>')
69
+ inTag = false;
70
+ else if (ch === '"' || ch === "'")
71
+ quoteChar = ch;
72
+ }
73
+ else {
74
+ if (ch === '<')
75
+ inTag = true;
76
+ }
77
+ }
78
+ htmlStr += s;
79
+ if (i < values.length) {
80
+ const needsQuotes = inTag && !quoteChar && /=\s*$/.test(s);
81
+ htmlStr += needsQuotes ? `"${createMarker(i)}"` : createMarker(i);
82
+ }
83
+ }
84
+ // Parse the HTML
85
+ const template = document.createElement('template');
86
+ template.innerHTML = htmlStr;
87
+ const fragment = template.content.cloneNode(true);
88
+ // Walk the DOM tree and replace markers with bindings
89
+ processNode(fragment, values, bindings, disposers);
90
+ // Mount into host
91
+ host.appendChild(fragment);
92
+ },
93
+ dispose() {
94
+ for (const d of disposers) {
95
+ try {
96
+ d();
97
+ }
98
+ catch (_) { }
99
+ }
100
+ disposers.length = 0;
101
+ for (const b of bindings) {
102
+ if ('dispose' in b && b.dispose) {
103
+ try {
104
+ b.dispose();
105
+ }
106
+ catch (_) { }
107
+ }
108
+ }
109
+ bindings.length = 0;
110
+ },
111
+ };
112
+ }
113
+ // ── DOM walking and binding creation ────────────────────────────────
114
+ function processNode(node, values, bindings, disposers) {
115
+ // Process element attributes
116
+ if (node.nodeType === Node.ELEMENT_NODE) {
117
+ const el = node;
118
+ processAttributes(el, values, bindings, disposers);
119
+ }
120
+ // Process text nodes and comments (markers)
121
+ if (node.nodeType === Node.COMMENT_NODE) {
122
+ const comment = node;
123
+ const text = comment.textContent || '';
124
+ // Check if this is one of our markers
125
+ if (text.startsWith('bk-') && !isNaN(Number(text.slice(3)))) {
126
+ const index = Number(text.slice(3));
127
+ const value = values[index];
128
+ replaceMarkerWithBinding(comment, value, bindings, disposers);
129
+ return; // Don't recurse into replaced content
130
+ }
131
+ }
132
+ // Process child nodes (make a copy since we may mutate)
133
+ const children = [...node.childNodes];
134
+ for (const child of children) {
135
+ processNode(child, values, bindings, disposers);
136
+ }
137
+ }
138
+ function processAttributes(el, values, bindings, disposers) {
139
+ // We need to find attributes that contain markers
140
+ const attrsToRemove = [];
141
+ for (const attr of [...el.attributes]) {
142
+ const name = attr.name;
143
+ const attrValue = attr.value;
144
+ // Check for @event bindings
145
+ if (name.startsWith('@')) {
146
+ const eventParts = name.slice(1).split('.');
147
+ const eventName = eventParts[0] ?? '';
148
+ const modifiers = eventParts.slice(1);
149
+ // The value should be a marker containing the handler
150
+ const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
151
+ if (markerMatch) {
152
+ const index = Number(markerMatch[1]);
153
+ const handler = values[index];
154
+ if (typeof handler === 'function') {
155
+ bindEvent(el, eventName, handler, modifiers, bindings);
156
+ disposers.push(() => el.removeEventListener(eventName, handler));
157
+ }
158
+ }
159
+ attrsToRemove.push(name);
160
+ continue;
161
+ }
162
+ // Check for class:name bindings
163
+ if (name.startsWith('class:')) {
164
+ const className = name.slice(6);
165
+ const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
166
+ if (markerMatch) {
167
+ const index = Number(markerMatch[1]);
168
+ const value = values[index];
169
+ bindClass(el, className, value, bindings, disposers);
170
+ }
171
+ attrsToRemove.push(name);
172
+ continue;
173
+ }
174
+ // Check for html:inner binding (trusted HTML rendering)
175
+ if (name === 'html:inner') {
176
+ const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
177
+ if (markerMatch) {
178
+ const index = Number(markerMatch[1]);
179
+ const value = values[index];
180
+ bindInnerHtml(el, value, bindings, disposers);
181
+ }
182
+ attrsToRemove.push(name);
183
+ continue;
184
+ }
185
+ // Check for ref binding: ref="${myRef}"
186
+ if (name === 'ref' && attrValue.includes(MARKER_PREFIX)) {
187
+ const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
188
+ if (markerMatch) {
189
+ const index = Number(markerMatch[1]);
190
+ const value = values[index];
191
+ if (isRef(value)) {
192
+ value.current = el;
193
+ disposers.push(() => { value.current = null; });
194
+ }
195
+ }
196
+ attrsToRemove.push(name);
197
+ continue;
198
+ }
199
+ // Check for regular attribute bindings with markers
200
+ if (attrValue.includes(MARKER_PREFIX)) {
201
+ const markers = [...attrValue.matchAll(/<!--bk-(\d+)-->/g)];
202
+ if (markers.length) {
203
+ const first = markers[0];
204
+ // Single expression = entire value: preserve raw type and signal
205
+ if (markers.length === 1 && first && attrValue === first[0]) {
206
+ bindAttribute(el, name, values[Number(first[1])], bindings, disposers);
207
+ }
208
+ else {
209
+ // Mixed static + dynamic: resolve markers into string
210
+ const resolve = () => attrValue.replace(/<!--bk-(\d+)-->/g, (_, i) => {
211
+ const v = values[Number(i)];
212
+ const raw = isSignal(v) ? v.value : v;
213
+ return raw == null || raw === false ? '' : String(raw);
214
+ });
215
+ const hasSignals = markers.some(m => isSignal(values[Number(m[1])]));
216
+ bindAttribute(el, name, hasSignals ? computed(resolve) : resolve(), bindings, disposers);
217
+ }
218
+ }
219
+ continue;
220
+ }
221
+ }
222
+ for (const name of attrsToRemove) {
223
+ el.removeAttribute(name);
224
+ }
225
+ }
226
+ /**
227
+ * Mount a factory result (from component() factory functions) into a DOM element.
228
+ * Handles prop forwarding (with signal subscriptions) and host-level class attrs.
229
+ */
230
+ function mountFactoryResult(factory, disposers) {
231
+ const el = document.createElement(factory.tagName);
232
+ for (const [key, raw] of Object.entries(factory.props)) {
233
+ if (isSignal(raw)) {
234
+ const sig = raw;
235
+ el._setProp?.(key, sig.value);
236
+ const unsub = sig.subscribe((newVal) => {
237
+ el._setProp?.(key, newVal);
238
+ });
239
+ disposers.push(unsub);
240
+ }
241
+ else {
242
+ el._setProp?.(key, raw);
243
+ }
244
+ }
245
+ if (factory.hostAttrs?.class != null) {
246
+ const classVal = factory.hostAttrs.class;
247
+ if (isSignal(classVal)) {
248
+ const sig = classVal;
249
+ let prevClasses = [];
250
+ const applyClasses = (raw) => {
251
+ const next = raw ? raw.split(/\s+/).filter(Boolean) : [];
252
+ for (const cls of prevClasses) {
253
+ if (!next.includes(cls))
254
+ el.classList.remove(cls);
255
+ }
256
+ for (const cls of next) {
257
+ if (!prevClasses.includes(cls))
258
+ el.classList.add(cls);
259
+ }
260
+ prevClasses = next;
261
+ };
262
+ applyClasses(sig.value);
263
+ const unsub = sig.subscribe(applyClasses);
264
+ disposers.push(unsub);
265
+ }
266
+ else if (typeof classVal === 'string' && classVal) {
267
+ for (const cls of classVal.split(/\s+/).filter(Boolean)) {
268
+ el.classList.add(cls);
269
+ }
270
+ }
271
+ }
272
+ return el;
273
+ }
274
+ function replaceMarkerWithBinding(comment, value, bindings, disposers) {
275
+ const parent = comment.parentNode;
276
+ if (!parent)
277
+ return;
278
+ if (isSignal(value)) {
279
+ const signalValue = value.value;
280
+ // Check if the signal holds a TemplateResult, factory, null, undefined, or array
281
+ // (e.g., from reactive when(), computed(() => items.map(renderFn)), or factory results)
282
+ const isReactiveSlot = signalValue === null || signalValue === undefined
283
+ || (signalValue && typeof signalValue === 'object' && '__templateResult' in signalValue)
284
+ || (signalValue && typeof signalValue === 'object' && '__type' in signalValue)
285
+ || Array.isArray(signalValue);
286
+ if (isReactiveSlot) {
287
+ // Reactive template slot — mount/unmount templates as signal changes
288
+ const startMarker = document.createComment('slot-start');
289
+ const endMarker = document.createComment('slot-end');
290
+ parent.replaceChild(endMarker, comment);
291
+ parent.insertBefore(startMarker, endMarker);
292
+ let currentResults = [];
293
+ let currentNodes = [];
294
+ const dispose = effect(() => {
295
+ const newValue = value.value;
296
+ // Use endMarker.parentNode — the captured `parent` may be a
297
+ // DocumentFragment that was already appended to the real DOM,
298
+ // leaving the markers reparented under the actual DOM element.
299
+ const liveParent = endMarker.parentNode;
300
+ if (!liveParent)
301
+ return;
302
+ // Remove previous content
303
+ for (const r of currentResults) {
304
+ try {
305
+ r.dispose();
306
+ }
307
+ catch (_) { }
308
+ }
309
+ for (const node of currentNodes) {
310
+ node.parentNode?.removeChild(node);
311
+ }
312
+ currentNodes = [];
313
+ currentResults = [];
314
+ if (newValue == null) {
315
+ // null/undefined — nothing to mount
316
+ }
317
+ else if (Array.isArray(newValue)) {
318
+ // Array of TemplateResults (or mixed content)
319
+ for (const item of newValue) {
320
+ if (item && typeof item === 'object' && '__templateResult' in item) {
321
+ const tpl = item;
322
+ const wrapper = document.createDocumentFragment();
323
+ tpl.mount(wrapper);
324
+ const nodes = [...wrapper.childNodes];
325
+ currentNodes.push(...nodes);
326
+ currentResults.push(tpl);
327
+ liveParent.insertBefore(wrapper, endMarker);
328
+ }
329
+ else if (item && typeof item === 'object' && '__type' in item && item.__type === 'factory') {
330
+ const el = mountFactoryResult(item, disposers);
331
+ currentNodes.push(el);
332
+ liveParent.insertBefore(el, endMarker);
333
+ }
334
+ else if (item != null && item !== false) {
335
+ const textNode = document.createTextNode(String(item));
336
+ currentNodes.push(textNode);
337
+ liveParent.insertBefore(textNode, endMarker);
338
+ }
339
+ }
340
+ }
341
+ else if (typeof newValue === 'object' && '__templateResult' in newValue) {
342
+ // Single TemplateResult
343
+ const tpl = newValue;
344
+ const wrapper = document.createDocumentFragment();
345
+ tpl.mount(wrapper);
346
+ currentNodes = [...wrapper.childNodes];
347
+ currentResults.push(tpl);
348
+ liveParent.insertBefore(wrapper, endMarker);
349
+ }
350
+ else if (typeof newValue === 'object' && '__type' in newValue && newValue.__type === 'factory') {
351
+ // Factory result — create child element
352
+ const el = mountFactoryResult(newValue, disposers);
353
+ currentNodes.push(el);
354
+ liveParent.insertBefore(el, endMarker);
355
+ }
356
+ });
357
+ disposers.push(dispose);
358
+ }
359
+ else {
360
+ // Primitive signal — create a reactive text node
361
+ const textNode = document.createTextNode(String(signalValue));
362
+ parent.replaceChild(textNode, comment);
363
+ const binding = { type: 'text', node: textNode };
364
+ bindings.push(binding);
365
+ const dispose = effect(() => {
366
+ textNode.data = String(value.value);
367
+ });
368
+ binding.dispose = dispose;
369
+ disposers.push(dispose);
370
+ }
371
+ }
372
+ else if (value && typeof value === 'object' && '__templateResult' in value) {
373
+ // Nested template result — mount it
374
+ const result = value;
375
+ const startMarker = document.createComment('tpl-start');
376
+ const endMarker = document.createComment('tpl-end');
377
+ parent.replaceChild(endMarker, comment);
378
+ parent.insertBefore(startMarker, endMarker);
379
+ // Create a wrapper element to mount into
380
+ const wrapper = document.createDocumentFragment();
381
+ result.mount(wrapper);
382
+ parent.insertBefore(wrapper, endMarker);
383
+ disposers.push(() => result.dispose());
384
+ }
385
+ else if (value && typeof value === 'object' && '__type' in value && value.__type === 'factory') {
386
+ // Factory result — create child element
387
+ const el = mountFactoryResult(value, disposers);
388
+ parent.replaceChild(el, comment);
389
+ }
390
+ else if (Array.isArray(value)) {
391
+ // Array of template results
392
+ const startMarker = document.createComment('list-start');
393
+ const endMarker = document.createComment('list-end');
394
+ parent.replaceChild(endMarker, comment);
395
+ parent.insertBefore(startMarker, endMarker);
396
+ for (const item of value) {
397
+ if (item && typeof item === 'object' && '__templateResult' in item) {
398
+ const wrapper = document.createDocumentFragment();
399
+ item.mount(wrapper);
400
+ parent.insertBefore(wrapper, endMarker);
401
+ disposers.push(() => item.dispose());
402
+ }
403
+ else {
404
+ const textNode = document.createTextNode(String(item));
405
+ parent.insertBefore(textNode, endMarker);
406
+ }
407
+ }
408
+ }
409
+ else if (value == null || value === false) {
410
+ // null, undefined, false — render nothing
411
+ parent.removeChild(comment);
412
+ }
413
+ else {
414
+ // Primitive value — render as text
415
+ const textNode = document.createTextNode(String(value));
416
+ parent.replaceChild(textNode, comment);
417
+ }
418
+ }
419
+ function bindAttribute(el, name, value, bindings, disposers) {
420
+ // class attribute gets special handling to avoid conflicts with
421
+ // class:name directives (see bindClassAttribute for details).
422
+ if (name === 'class') {
423
+ bindClassAttribute(el, value, bindings, disposers);
424
+ return;
425
+ }
426
+ // Auto-resolution: framework components get _setProp for custom props
427
+ // (preserves types). Standard HTML attributes (id, style, data-*,
428
+ // aria-*) always use setAttribute even on framework components.
429
+ const isHtmlAttr = name === 'id' || name === 'style'
430
+ || name === 'slot' || name.startsWith('data-') || name.startsWith('aria-');
431
+ const hasPropSetter = !isHtmlAttr && typeof el._setProp === 'function';
432
+ if (hasPropSetter) {
433
+ if (isSignal(value)) {
434
+ const binding = { type: 'attribute', element: el, name };
435
+ bindings.push(binding);
436
+ const dispose = effect(() => {
437
+ el._setProp(name, value.value);
438
+ });
439
+ binding.dispose = dispose;
440
+ disposers.push(dispose);
441
+ }
442
+ else {
443
+ el._setProp(name, value);
444
+ }
445
+ return;
446
+ }
447
+ if (isSignal(value)) {
448
+ const binding = { type: 'attribute', element: el, name };
449
+ bindings.push(binding);
450
+ const dispose = effect(() => {
451
+ const v = value.value;
452
+ if (v == null || v === false) {
453
+ el.removeAttribute(name);
454
+ }
455
+ else if (v === true) {
456
+ el.setAttribute(name, '');
457
+ }
458
+ else {
459
+ el.setAttribute(name, String(v));
460
+ }
461
+ });
462
+ binding.dispose = dispose;
463
+ disposers.push(dispose);
464
+ }
465
+ else {
466
+ if (value == null || value === false) {
467
+ el.removeAttribute(name);
468
+ }
469
+ else if (value === true) {
470
+ el.setAttribute(name, '');
471
+ }
472
+ else {
473
+ el.setAttribute(name, String(value));
474
+ }
475
+ }
476
+ }
477
+ /**
478
+ * Bind a reactive class attribute using classList.add/remove instead of
479
+ * setAttribute('class', ...). This prevents the class attribute binding
480
+ * from overwriting classes toggled by class:name directives.
481
+ *
482
+ * The problem: setAttribute('class', 'foo bar') replaces ALL classes,
483
+ * wiping out any classes added by classList.toggle() from class:name
484
+ * bindings. By tracking which classes "belong" to the class attribute
485
+ * and using classList operations, we only manage our own classes.
486
+ */
487
+ function bindClassAttribute(el, value, bindings, disposers) {
488
+ // On first call, we must clear the parser-set class attribute which
489
+ // contains raw marker text (e.g. "badge status-<!--bk-0-->").
490
+ // We snapshot any non-marker classes set by the parser before clearing.
491
+ let initialized = false;
492
+ let prevClasses = [];
493
+ const applyClasses = (raw) => {
494
+ if (!initialized) {
495
+ // Clear the parser's class attribute (contains marker text)
496
+ el.setAttribute('class', '');
497
+ initialized = true;
498
+ }
499
+ const str = raw == null || raw === false ? '' : String(raw);
500
+ const next = str.split(/\s+/).filter(Boolean);
501
+ // Remove classes no longer in the attribute value
502
+ for (const cls of prevClasses) {
503
+ if (!next.includes(cls)) {
504
+ el.classList.remove(cls);
505
+ }
506
+ }
507
+ // Add new classes
508
+ for (const cls of next) {
509
+ if (!prevClasses.includes(cls)) {
510
+ el.classList.add(cls);
511
+ }
512
+ }
513
+ prevClasses = next;
514
+ };
515
+ if (isSignal(value)) {
516
+ const binding = { type: 'attribute', element: el, name: 'class' };
517
+ bindings.push(binding);
518
+ const dispose = effect(() => {
519
+ applyClasses(value.value);
520
+ });
521
+ binding.dispose = dispose;
522
+ disposers.push(dispose);
523
+ }
524
+ else {
525
+ applyClasses(value);
526
+ }
527
+ }
528
+ function bindClass(el, className, value, bindings, disposers) {
529
+ if (isSignal(value)) {
530
+ const binding = { type: 'class', element: el, className };
531
+ bindings.push(binding);
532
+ const dispose = effect(() => {
533
+ const v = value.value;
534
+ el.classList.toggle(className, !!v);
535
+ });
536
+ binding.dispose = dispose;
537
+ disposers.push(dispose);
538
+ }
539
+ else {
540
+ el.classList.toggle(className, !!value);
541
+ }
542
+ }
543
+ /**
544
+ * Bind trusted HTML content to an element's innerHTML.
545
+ *
546
+ * WARNING: This is for trusted content only (e.g., highlighted search results
547
+ * from @orama/highlight, diff2html output). NEVER use with user-generated input.
548
+ *
549
+ * Supports both static strings and reactive Signal<string> values.
550
+ * When the signal changes, innerHTML is updated reactively.
551
+ *
552
+ * Usage in templates:
553
+ * html`<span html:inner="${highlightedHtml}"></span>`
554
+ */
555
+ function bindInnerHtml(el, value, bindings, disposers) {
556
+ if (isSignal(value)) {
557
+ const binding = { type: 'innerHtml', element: el };
558
+ bindings.push(binding);
559
+ const dispose = effect(() => {
560
+ const v = value.value;
561
+ el.innerHTML = v == null ? '' : String(v);
562
+ });
563
+ binding.dispose = dispose;
564
+ disposers.push(dispose);
565
+ }
566
+ else {
567
+ el.innerHTML = value == null ? '' : String(value);
568
+ }
569
+ }
570
+ function bindEvent(el, eventName, handler, modifiers, bindings) {
571
+ let wrappedHandler = handler;
572
+ // Apply modifiers
573
+ if (modifiers.includes('stop')) {
574
+ const original = wrappedHandler;
575
+ wrappedHandler = (e) => {
576
+ e.stopPropagation();
577
+ original(e);
578
+ };
579
+ }
580
+ if (modifiers.includes('prevent')) {
581
+ const original = wrappedHandler;
582
+ wrappedHandler = (e) => {
583
+ e.preventDefault();
584
+ original(e);
585
+ };
586
+ }
587
+ if (modifiers.includes('once')) {
588
+ const original = wrappedHandler;
589
+ wrappedHandler = (e) => {
590
+ el.removeEventListener(eventName, wrappedHandler);
591
+ original(e);
592
+ };
593
+ }
594
+ // Keyboard modifiers
595
+ for (const mod of modifiers) {
596
+ if (['enter', 'escape', 'space', 'tab'].includes(mod)) {
597
+ const keyMap = {
598
+ enter: 'Enter',
599
+ escape: 'Escape',
600
+ space: ' ',
601
+ tab: 'Tab',
602
+ };
603
+ const original = wrappedHandler;
604
+ wrappedHandler = (e) => {
605
+ if (e.key === keyMap[mod]) {
606
+ original(e);
607
+ }
608
+ };
609
+ }
610
+ }
611
+ // Wrap in try/catch for error containment
612
+ const safeHandler = (e) => {
613
+ try {
614
+ wrappedHandler(e);
615
+ }
616
+ catch (err) {
617
+ console.error(`Event handler error for '${eventName}':`, err);
618
+ }
619
+ };
620
+ el.addEventListener(eventName, safeHandler);
621
+ bindings.push({
622
+ type: 'event',
623
+ element: el,
624
+ eventName,
625
+ handler: safeHandler,
626
+ modifiers,
627
+ });
628
+ }
629
+ /**
630
+ * Conditional rendering helper.
631
+ * Shows the template when condition is truthy.
632
+ *
633
+ * Supports both static and reactive (signal) conditions.
634
+ * For signal conditions, returns a computed that reactively switches
635
+ * between the template and null.
636
+ *
637
+ * The template argument can be a TemplateResult or a lazy callback
638
+ * `() => TemplateResult` to avoid evaluating expensive branches.
639
+ */
640
+ // ── each() reactive list rendering ──────────────────────────────────
641
+ /** Brand for each result detection */
642
+ const EACH_BRAND = Symbol.for('backlog.each');
643
+ /**
644
+ * Reactive list rendering with keyed reconciliation.
645
+ *
646
+ * Renders a list of items from a signal, tracking each item by key.
647
+ * When the array changes, only affected DOM nodes are added, removed,
648
+ * or reordered — existing items update in-place via their signals.
649
+ *
650
+ * ```ts
651
+ * const tasks = signal([{ id: '1', title: 'A' }, { id: '2', title: 'B' }]);
652
+ * html`<ul>${each(tasks, t => t.id, (task, index) =>
653
+ * html`<li>${computed(() => task.value.title)}</li>`
654
+ * )}</ul>`
655
+ * ```
656
+ */
657
+ export function each(items, keyFn, templateFn) {
658
+ let startMarker;
659
+ let endMarker;
660
+ let entries = [];
661
+ let effectDispose = null;
662
+ return {
663
+ __templateResult: true,
664
+ mount(host) {
665
+ startMarker = document.createComment('each-start');
666
+ endMarker = document.createComment('each-end');
667
+ host.appendChild(startMarker);
668
+ host.appendChild(endMarker);
669
+ effectDispose = effect(() => {
670
+ const newItems = items.value;
671
+ reconcile(newItems);
672
+ });
673
+ },
674
+ dispose() {
675
+ if (effectDispose) {
676
+ effectDispose();
677
+ effectDispose = null;
678
+ }
679
+ for (const entry of entries) {
680
+ entry.templateResult.dispose();
681
+ }
682
+ entries = [];
683
+ },
684
+ };
685
+ function reconcile(newItems) {
686
+ const parent = endMarker.parentNode;
687
+ if (!parent)
688
+ return;
689
+ // Build old key → entry map
690
+ const oldMap = new Map();
691
+ for (const entry of entries) {
692
+ oldMap.set(entry.key, entry);
693
+ }
694
+ // Build new entries list
695
+ const newEntries = [];
696
+ const newKeys = new Set();
697
+ for (let i = 0; i < newItems.length; i++) {
698
+ const item = newItems[i];
699
+ if (item === undefined)
700
+ continue;
701
+ const key = keyFn(item, i);
702
+ newKeys.add(key);
703
+ const existing = oldMap.get(key);
704
+ if (existing) {
705
+ // Reuse — update signals in place
706
+ existing.itemSignal.value = item;
707
+ existing.indexSignal.value = i;
708
+ newEntries.push(existing);
709
+ }
710
+ else {
711
+ // Create new entry with a stable wrapper element.
712
+ // Using display:contents so the wrapper is invisible to layout —
713
+ // inner reactive content can freely swap DOM nodes without
714
+ // invalidating the each() reconciler's node tracking.
715
+ const itemSignal = signal(item);
716
+ const indexSignal = signal(i);
717
+ const wrapper = document.createElement('each-item');
718
+ wrapper.style.display = 'contents';
719
+ const templateResult = templateFn(itemSignal, indexSignal);
720
+ templateResult.mount(wrapper);
721
+ newEntries.push({ key, itemSignal, indexSignal, templateResult, wrapper });
722
+ }
723
+ }
724
+ // Remove entries whose key is gone
725
+ for (const entry of entries) {
726
+ if (!newKeys.has(entry.key)) {
727
+ entry.templateResult.dispose();
728
+ entry.wrapper.parentNode?.removeChild(entry.wrapper);
729
+ }
730
+ }
731
+ // Reorder DOM nodes to match new order.
732
+ // Each entry has exactly one stable wrapper element, so positioning
733
+ // is simple and immune to inner reactive content swaps.
734
+ let cursor = startMarker;
735
+ for (const entry of newEntries) {
736
+ const nextSibling = cursor.nextSibling;
737
+ if (nextSibling !== entry.wrapper) {
738
+ parent.insertBefore(entry.wrapper, nextSibling);
739
+ }
740
+ cursor = entry.wrapper;
741
+ }
742
+ entries = newEntries;
743
+ }
744
+ }
745
+ export function when(condition, template) {
746
+ const resolveTemplate = () => typeof template === 'function' ? template() : template;
747
+ if (isSignal(condition)) {
748
+ // Reactive: return a computed that re-evaluates when the signal changes
749
+ return computed(() => condition.value ? resolveTemplate() : null);
750
+ }
751
+ return condition ? resolveTemplate() : null;
752
+ }
753
+ //# sourceMappingURL=template.js.map