@nisli/core 0.56.0 → 0.57.1

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.
Files changed (114) hide show
  1. package/README.md +70 -9
  2. package/dist/bundler/component.js +409 -0
  3. package/dist/bundler/component.js.map +7 -0
  4. package/dist/bundler/context.js +27 -0
  5. package/dist/bundler/context.js.map +7 -0
  6. package/dist/bundler/diagnostics.js +55 -0
  7. package/dist/bundler/diagnostics.js.map +7 -0
  8. package/dist/bundler/disposable.js +9 -0
  9. package/dist/bundler/disposable.js.map +7 -0
  10. package/dist/bundler/element-context.js +47 -0
  11. package/dist/bundler/element-context.js.map +7 -0
  12. package/dist/bundler/emitter.js +70 -0
  13. package/dist/bundler/emitter.js.map +7 -0
  14. package/dist/bundler/hmr/registry.js +72 -0
  15. package/dist/bundler/hmr/registry.js.map +7 -0
  16. package/dist/bundler/hmr/transform.js +30 -0
  17. package/dist/bundler/hmr/transform.js.map +7 -0
  18. package/dist/bundler/index.js +103 -0
  19. package/dist/bundler/index.js.map +7 -0
  20. package/dist/bundler/injector.js +75 -0
  21. package/dist/bundler/injector.js.map +7 -0
  22. package/dist/bundler/lifecycle.js +56 -0
  23. package/dist/bundler/lifecycle.js.map +7 -0
  24. package/dist/bundler/projection.js +92 -0
  25. package/dist/bundler/projection.js.map +7 -0
  26. package/dist/bundler/query.js +302 -0
  27. package/dist/bundler/query.js.map +7 -0
  28. package/dist/bundler/ref.js +16 -0
  29. package/dist/bundler/ref.js.map +7 -0
  30. package/dist/bundler/resource.js +99 -0
  31. package/dist/bundler/resource.js.map +7 -0
  32. package/dist/bundler/settle.js +43 -0
  33. package/dist/bundler/settle.js.map +7 -0
  34. package/dist/bundler/signal.js +403 -0
  35. package/dist/bundler/signal.js.map +7 -0
  36. package/dist/bundler/template-audit.js +162 -0
  37. package/dist/bundler/template-audit.js.map +7 -0
  38. package/dist/bundler/template.js +922 -0
  39. package/dist/bundler/template.js.map +7 -0
  40. package/dist/bundler/view-transition.js +20 -0
  41. package/dist/bundler/view-transition.js.map +7 -0
  42. package/dist/bundler/vite-hmr/plugin.js +32 -0
  43. package/dist/bundler/vite-hmr/plugin.js.map +7 -0
  44. package/dist/bundler/vite-hmr/runtime.js +8 -0
  45. package/dist/bundler/vite-hmr/runtime.js.map +7 -0
  46. package/dist/index.js +2682 -47
  47. package/dist/index.js.map +7 -1
  48. package/dist/index.production.js +2 -0
  49. package/dist/index.production.js.map +6 -0
  50. package/dist/{diagnostics.d.ts → types/diagnostics.d.ts} +8 -15
  51. package/dist/{template-audit.d.ts → types/template-audit.d.ts} +4 -5
  52. package/dist/{template.d.ts → types/template.d.ts} +2 -1
  53. package/package.json +15 -10
  54. package/dist/component.js +0 -633
  55. package/dist/component.js.map +0 -1
  56. package/dist/context.js +0 -55
  57. package/dist/context.js.map +0 -1
  58. package/dist/diagnostics.js +0 -118
  59. package/dist/diagnostics.js.map +0 -1
  60. package/dist/disposable.js +0 -8
  61. package/dist/disposable.js.map +0 -1
  62. package/dist/element-context.js +0 -108
  63. package/dist/element-context.js.map +0 -1
  64. package/dist/emitter.js +0 -91
  65. package/dist/emitter.js.map +0 -1
  66. package/dist/hmr/registry.js +0 -136
  67. package/dist/hmr/registry.js.map +0 -1
  68. package/dist/hmr/transform.js +0 -52
  69. package/dist/hmr/transform.js.map +0 -1
  70. package/dist/injector.js +0 -118
  71. package/dist/injector.js.map +0 -1
  72. package/dist/lifecycle.js +0 -117
  73. package/dist/lifecycle.js.map +0 -1
  74. package/dist/projection.js +0 -179
  75. package/dist/projection.js.map +0 -1
  76. package/dist/query.js +0 -429
  77. package/dist/query.js.map +0 -1
  78. package/dist/ref.js +0 -43
  79. package/dist/ref.js.map +0 -1
  80. package/dist/resource.js +0 -129
  81. package/dist/resource.js.map +0 -1
  82. package/dist/settle.js +0 -106
  83. package/dist/settle.js.map +0 -1
  84. package/dist/signal.js +0 -645
  85. package/dist/signal.js.map +0 -1
  86. package/dist/template-audit.js +0 -246
  87. package/dist/template-audit.js.map +0 -1
  88. package/dist/template.js +0 -1244
  89. package/dist/template.js.map +0 -1
  90. package/dist/view-transition.js +0 -98
  91. package/dist/view-transition.js.map +0 -1
  92. package/dist/vite-hmr/plugin.js +0 -62
  93. package/dist/vite-hmr/plugin.js.map +0 -1
  94. package/dist/vite-hmr/runtime.js +0 -17
  95. package/dist/vite-hmr/runtime.js.map +0 -1
  96. /package/dist/{component.d.ts → types/component.d.ts} +0 -0
  97. /package/dist/{context.d.ts → types/context.d.ts} +0 -0
  98. /package/dist/{disposable.d.ts → types/disposable.d.ts} +0 -0
  99. /package/dist/{element-context.d.ts → types/element-context.d.ts} +0 -0
  100. /package/dist/{emitter.d.ts → types/emitter.d.ts} +0 -0
  101. /package/dist/{hmr → types/hmr}/registry.d.ts +0 -0
  102. /package/dist/{hmr → types/hmr}/transform.d.ts +0 -0
  103. /package/dist/{index.d.ts → types/index.d.ts} +0 -0
  104. /package/dist/{injector.d.ts → types/injector.d.ts} +0 -0
  105. /package/dist/{lifecycle.d.ts → types/lifecycle.d.ts} +0 -0
  106. /package/dist/{projection.d.ts → types/projection.d.ts} +0 -0
  107. /package/dist/{query.d.ts → types/query.d.ts} +0 -0
  108. /package/dist/{ref.d.ts → types/ref.d.ts} +0 -0
  109. /package/dist/{resource.d.ts → types/resource.d.ts} +0 -0
  110. /package/dist/{settle.d.ts → types/settle.d.ts} +0 -0
  111. /package/dist/{signal.d.ts → types/signal.d.ts} +0 -0
  112. /package/dist/{view-transition.d.ts → types/view-transition.d.ts} +0 -0
  113. /package/dist/{vite-hmr → types/vite-hmr}/plugin.d.ts +0 -0
  114. /package/dist/{vite-hmr → types/vite-hmr}/runtime.d.ts +0 -0
package/dist/template.js DELETED
@@ -1,1244 +0,0 @@
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, untrack, } from './signal.js';
13
- import { isRef } from './ref.js';
14
- // First-parse audit (ADR 0030.2 T4). The import is static — core has no
15
- // build-time dev/prod define yet (a named Wave-1 deliverable, §8) — but every
16
- // entry point is a no-op behind template-audit's own dev flag.
17
- import { auditTemplate, captureCallsite } from './template-audit.js';
18
- const canMoveWithin = (parent) => typeof parent.moveBefore === 'function';
19
- /**
20
- * Value-returning untrack. signal.ts's `untrack` is `(fn: () => void) => void`
21
- * (the value-returning form is a 0030.2 §3 papercut owned elsewhere); this
22
- * local wrapper closes over the result until that lands.
23
- */
24
- function untracked(fn) {
25
- let result;
26
- untrack(() => { result = fn(); });
27
- return result;
28
- }
29
- /**
30
- * Mark a string as trusted raw HTML. `html:inner` accepts ONLY branded
31
- * values — a bare string throws N106 (the accidental-XSS-sink class closes,
32
- * ADR 0030.2 §3). NEVER wrap user-generated input.
33
- */
34
- export function raw(value) {
35
- return { __raw: true, value };
36
- }
37
- function isRawHtml(value) {
38
- return (typeof value === 'object'
39
- && value !== null
40
- && '__raw' in value
41
- && value.__raw === true
42
- && typeof value.value === 'string');
43
- }
44
- /**
45
- * Mark a string as untrusted markup to be sanitized at the sink — the brand
46
- * for user-generated HTML, which must NEVER go through `raw()`.
47
- *
48
- * `html:inner` writes it with the platform's `Element.setHTML()` (the spec's
49
- * XSS-safe default sanitizer) when the engine has one, otherwise with the hook
50
- * registered by {@link setSanitizerFallback}. With NEITHER available the
51
- * binding throws N107: it never falls back to `innerHTML`. nisli bundles no
52
- * sanitizer — the trust decision, and its bytes, stay in the app.
53
- */
54
- export function sanitized(value) {
55
- return { __sanitize: true, value };
56
- }
57
- function isSanitizedHtml(value) {
58
- return (typeof value === 'object'
59
- && value !== null
60
- && '__sanitize' in value
61
- && value.__sanitize === true
62
- && typeof value.value === 'string');
63
- }
64
- let sanitizerFallback = null;
65
- /**
66
- * Register the sanitizing writer used for `sanitized()` values on engines
67
- * WITHOUT native `Element.setHTML()` (Safari today, older Chromium/Firefox),
68
- * or `null` to unregister. Native `setHTML()` always wins where it exists, so
69
- * modern engines never pay the hook. nisli ships no sanitizer; wire one:
70
- *
71
- * setSanitizerFallback((el, html) => {
72
- * el.innerHTML = DOMPurify.sanitize(html);
73
- * });
74
- */
75
- export function setSanitizerFallback(fn) {
76
- sanitizerFallback = fn;
77
- }
78
- const nativeSetHtml = (el, html) => {
79
- el.setHTML(html);
80
- };
81
- /**
82
- * The sanitizing writer for this element, or `null` when the engine has none
83
- * and the app registered none — the fail-closed signal N107 is thrown on.
84
- *
85
- * The method must be the one on `Element.prototype`, not merely *a* callable
86
- * named `setHTML`. A duck-typed check here is a sanitizer bypass: an element
87
- * carrying its own `setHTML` — DOM clobbering, an unsafe polyfill, a library
88
- * shim — would be trusted as the platform sanitizer and would preempt the
89
- * fallback the app explicitly registered as safe. Verified: with an
90
- * own-property `setHTML`, the registered fallback was never called and
91
- * `<img src=x onerror=…>` reached innerHTML intact.
92
- *
93
- * Read from the prototype per write rather than captured at module load, so a
94
- * late polyfill or a test's stub is still honoured; identity is what is
95
- * checked, not existence. Replacing `Element.prototype.setHTML` wholesale is
96
- * out of scope — an attacker who can do that owns the realm regardless.
97
- */
98
- function sanitizerFor(el) {
99
- const platform = typeof Element === 'function'
100
- ? Element.prototype.setHTML
101
- : undefined;
102
- return typeof platform === 'function'
103
- && el.setHTML === platform
104
- ? nativeSetHtml
105
- : sanitizerFallback;
106
- }
107
- function isTrustedHtml(value) {
108
- const tt = globalThis.trustedTypes;
109
- return tt !== undefined && tt.isHTML(value);
110
- }
111
- // TODO(diagnostics): replace with the core diagnostics leaf (parallel
112
- // worktree); template-layer codes N101–N107 register there. N105–N107 are
113
- // thrown (correctness/security guards), unlike the warn-level audit codes.
114
- function nisliError(code, message) {
115
- return new Error(`[nisli] ${code}: ${message}`);
116
- }
117
- // ── Template cache ──────────────────────────────────────────────────
118
- /**
119
- * Cache parsed templates by their static string parts.
120
- * Since tagged templates always produce the same static strings array
121
- * reference per call site, we use WeakMap for efficient caching.
122
- */
123
- const templateCache = new WeakMap();
124
- /** Parses performed since module load — test-only regression hook (issue 0015). */
125
- let parseCount = 0;
126
- /** @internal Test-only: number of actual `<template>` parses performed. */
127
- export function __templateParseCount() {
128
- return parseCount;
129
- }
130
- /**
131
- * Parse-once (ADR 0030.2 T4, issue 0015): build the marker HTML string and
132
- * parse it into a `<template>` exactly once per callsite, keyed by
133
- * TemplateStringsArray identity. The string depends only on the static
134
- * `strings` (marker indices are positional), so every TemplateResult from the
135
- * same callsite shares one parsed template and clones per mount.
136
- *
137
- * The first parse also runs the one-time static audit (template-audit.ts)
138
- * over the pristine parsed element — it still carries the raw `@event`/
139
- * `class:`/marker attributes that processAttributes later strips from clones.
140
- */
141
- function parseTemplate(strings, valueCount) {
142
- let template = templateCache.get(strings);
143
- if (template)
144
- return template;
145
- // Build the HTML string with markers.
146
- // Track HTML parsing state to auto-quote markers in unquoted
147
- // attribute positions where the > in --> would close the tag.
148
- // See ADR 0069.
149
- let htmlStr = '';
150
- let inTag = false;
151
- let quoteChar = null;
152
- for (let i = 0; i < strings.length; i++) {
153
- const s = strings[i] ?? '';
154
- for (let c = 0; c < s.length; c++) {
155
- const ch = s.charAt(c);
156
- if (quoteChar) {
157
- if (ch === quoteChar)
158
- quoteChar = null;
159
- }
160
- else if (inTag) {
161
- if (ch === '>')
162
- inTag = false;
163
- else if (ch === '"' || ch === "'")
164
- quoteChar = ch;
165
- }
166
- else {
167
- if (ch === '<')
168
- inTag = true;
169
- }
170
- }
171
- htmlStr += s;
172
- if (i < valueCount) {
173
- const needsQuotes = inTag && !quoteChar && /=\s*$/.test(s);
174
- htmlStr += needsQuotes ? `"${createMarker(i)}"` : createMarker(i);
175
- }
176
- }
177
- parseCount++;
178
- template = document.createElement('template');
179
- template.innerHTML = htmlStr;
180
- templateCache.set(strings, template);
181
- // Dev-only first-parse audit (N101–N104); no-op when disabled.
182
- auditTemplate(strings, template);
183
- return template;
184
- }
185
- // ── Marker for expression slots ─────────────────────────────────────
186
- const MARKER_PREFIX = '<!--bk-';
187
- const MARKER_SUFFIX = '-->';
188
- const ATTR_MARKER = 'bk-';
189
- /**
190
- * Generate a unique marker for each expression slot.
191
- * We use HTML comments as markers in text positions and
192
- * special attribute prefixes for attribute positions.
193
- */
194
- function createMarker(index) {
195
- return `${MARKER_PREFIX}${index}${MARKER_SUFFIX}`;
196
- }
197
- // ── html tagged template ────────────────────────────────────────────
198
- /**
199
- * Tagged template literal for creating reactive DOM templates.
200
- *
201
- * ```ts
202
- * const name = signal('World');
203
- * const greeting = html`<h1>Hello, ${name}!</h1>`;
204
- * ```
205
- *
206
- * Signals in expression slots are detected automatically and
207
- * create fine-grained bindings. When the signal changes, only
208
- * the specific text node or attribute updates — no diffing needed.
209
- */
210
- export function html(strings, ...values) {
211
- const bindings = [];
212
- const disposers = [];
213
- // Dev-only: remember the authoring callsite for first-parse audit
214
- // diagnostics — by first-mount time this frame is gone from the stack.
215
- captureCallsite(strings);
216
- let mounted = false;
217
- return {
218
- __templateResult: true,
219
- mount(host) {
220
- // Single-mount guard (ADR 0030.2 T4 + §8): a second mount of a live,
221
- // un-disposed result with value bindings would attach a second set of
222
- // bindings to the same shared arrays — shared-binding corruption.
223
- // Zero-binding templates (the registry's shared static icon pattern)
224
- // stay multi-mountable; dispose() → mount() stays legal.
225
- if (mounted && values.length > 0) {
226
- throw nisliError('N105', `mount() on an already-mounted TemplateResult with ${values.length} value ` +
227
- `binding(s) — dispose() it first or create a fresh template per mount. ` +
228
- `Template starts: ${JSON.stringify((strings[0] ?? '').slice(0, 60))}`);
229
- }
230
- mounted = true;
231
- // Parse-once: the parsed template is cached per callsite; each mount
232
- // only pays the clone + binding walk. The per-mount processNode walk
233
- // below REMAINS by design until the ADR 0014-class work — caching
234
- // removes the *parse*, not the walk (ADR 0030.2 §8).
235
- const template = parseTemplate(strings, values.length);
236
- const fragment = template.content.cloneNode(true);
237
- // Walk the DOM tree and replace markers with bindings
238
- processNode(fragment, values, bindings, disposers);
239
- // Mount into host
240
- host.appendChild(fragment);
241
- },
242
- dispose() {
243
- for (const d of disposers) {
244
- try {
245
- d();
246
- }
247
- catch (_) { }
248
- }
249
- disposers.length = 0;
250
- for (const b of bindings) {
251
- if ('dispose' in b && b.dispose) {
252
- try {
253
- b.dispose();
254
- }
255
- catch (_) { }
256
- }
257
- }
258
- bindings.length = 0;
259
- mounted = false;
260
- },
261
- };
262
- }
263
- // ── DOM walking and binding creation ────────────────────────────────
264
- function processNode(node, values, bindings, disposers) {
265
- // Process element attributes
266
- if (node.nodeType === Node.ELEMENT_NODE) {
267
- const el = node;
268
- processAttributes(el, values, bindings, disposers);
269
- }
270
- // Process text nodes and comments (markers)
271
- if (node.nodeType === Node.COMMENT_NODE) {
272
- const comment = node;
273
- const text = comment.textContent || '';
274
- // Check if this is one of our markers
275
- if (text.startsWith('bk-') && !isNaN(Number(text.slice(3)))) {
276
- const index = Number(text.slice(3));
277
- const value = values[index];
278
- replaceMarkerWithBinding(comment, value, bindings, disposers);
279
- return; // Don't recurse into replaced content
280
- }
281
- }
282
- // Process child nodes (make a copy since we may mutate)
283
- const children = [...node.childNodes];
284
- for (const child of children) {
285
- processNode(child, values, bindings, disposers);
286
- }
287
- }
288
- function processAttributes(el, values, bindings, disposers) {
289
- // We need to find attributes that contain markers
290
- const attrsToRemove = [];
291
- for (const attr of [...el.attributes]) {
292
- const name = attr.name;
293
- const attrValue = attr.value;
294
- // Check for @event bindings
295
- if (name.startsWith('@')) {
296
- const eventParts = name.slice(1).split('.');
297
- const eventName = eventParts[0] ?? '';
298
- const modifiers = eventParts.slice(1);
299
- // The value should be a marker containing the handler
300
- const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
301
- if (markerMatch) {
302
- const index = Number(markerMatch[1]);
303
- const handler = values[index];
304
- if (typeof handler === 'function') {
305
- // Cleanup is registered on the EventBinding (removes the installed
306
- // safeHandler); a manual removeEventListener here would target the
307
- // original handler and no-op. See bindEvent (UI-33-R).
308
- bindEvent(el, eventName, handler, modifiers, bindings);
309
- }
310
- }
311
- attrsToRemove.push(name);
312
- continue;
313
- }
314
- // Check for class:name bindings
315
- if (name.startsWith('class:')) {
316
- const className = name.slice(6);
317
- const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
318
- if (markerMatch) {
319
- const index = Number(markerMatch[1]);
320
- const value = values[index];
321
- bindClass(el, className, value, bindings, disposers);
322
- }
323
- attrsToRemove.push(name);
324
- continue;
325
- }
326
- // Check for html:inner binding (trusted HTML rendering)
327
- if (name === 'html:inner') {
328
- const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
329
- if (markerMatch) {
330
- const index = Number(markerMatch[1]);
331
- const value = values[index];
332
- bindInnerHtml(el, value, bindings, disposers);
333
- }
334
- attrsToRemove.push(name);
335
- continue;
336
- }
337
- // Check for ref binding: ref="${myRef}"
338
- if (name === 'ref' && attrValue.includes(MARKER_PREFIX)) {
339
- const markerMatch = attrValue.match(/<!--bk-(\d+)-->/);
340
- if (markerMatch) {
341
- const index = Number(markerMatch[1]);
342
- const value = values[index];
343
- if (isRef(value)) {
344
- value.current = el;
345
- disposers.push(() => { value.current = null; });
346
- }
347
- }
348
- attrsToRemove.push(name);
349
- continue;
350
- }
351
- // Check for regular attribute bindings with markers
352
- if (attrValue.includes(MARKER_PREFIX)) {
353
- const markers = [...attrValue.matchAll(/<!--bk-(\d+)-->/g)];
354
- if (markers.length) {
355
- const first = markers[0];
356
- // Single expression = entire value: preserve raw type and signal
357
- if (markers.length === 1 && first && attrValue === first[0]) {
358
- bindAttribute(el, name, values[Number(first[1])], bindings, disposers);
359
- }
360
- else {
361
- // Mixed static + dynamic: resolve markers into string
362
- const resolve = () => attrValue.replace(/<!--bk-(\d+)-->/g, (_, i) => {
363
- const v = values[Number(i)];
364
- const raw = isSignal(v) ? v.value : v;
365
- return raw == null || raw === false ? '' : String(raw);
366
- });
367
- const hasSignals = markers.some(m => isSignal(values[Number(m[1])]));
368
- bindAttribute(el, name, hasSignals ? computed(resolve) : resolve(), bindings, disposers);
369
- }
370
- }
371
- continue;
372
- }
373
- }
374
- for (const name of attrsToRemove) {
375
- el.removeAttribute(name);
376
- }
377
- }
378
- /**
379
- * Mount a factory result (from component() factory functions) into a DOM element.
380
- * Handles prop forwarding (with signal subscriptions) and host-level class attrs.
381
- */
382
- function mountFactoryResult(factory) {
383
- const el = document.createElement(factory.tagName);
384
- const localDisposers = [];
385
- for (const [key, raw] of Object.entries(factory.props)) {
386
- if (isSignal(raw)) {
387
- const sig = raw;
388
- const unsub = sig.subscribe((newVal) => {
389
- el._setProp?.(key, newVal);
390
- });
391
- localDisposers.push(unsub);
392
- }
393
- else {
394
- el._setProp?.(key, raw);
395
- }
396
- }
397
- if (factory.hostAttrs?.class != null) {
398
- const classVal = factory.hostAttrs.class;
399
- if (isSignal(classVal)) {
400
- const sig = classVal;
401
- let prevClasses = [];
402
- const applyClasses = (raw) => {
403
- const next = raw ? raw.split(/\s+/).filter(Boolean) : [];
404
- for (const cls of prevClasses) {
405
- if (!next.includes(cls))
406
- el.classList.remove(cls);
407
- }
408
- for (const cls of next) {
409
- if (!prevClasses.includes(cls))
410
- el.classList.add(cls);
411
- }
412
- prevClasses = next;
413
- };
414
- const unsub = sig.subscribe(applyClasses);
415
- localDisposers.push(unsub);
416
- }
417
- else if (typeof classVal === 'string' && classVal) {
418
- for (const cls of classVal.split(/\s+/).filter(Boolean)) {
419
- el.classList.add(cls);
420
- }
421
- }
422
- }
423
- return {
424
- element: el,
425
- dispose() {
426
- for (const dispose of localDisposers.splice(0)) {
427
- try {
428
- dispose();
429
- }
430
- catch (_) { /* swallow */ }
431
- }
432
- },
433
- };
434
- }
435
- function replaceMarkerWithBinding(comment, value, bindings, disposers) {
436
- const parent = comment.parentNode;
437
- if (!parent)
438
- return;
439
- if (isSignal(value)) {
440
- // Every reactive child is a slot because its future values may have any
441
- // documented child type. The slot still keeps a single Text node alive for
442
- // primitive-to-primitive updates, preserving the O(1) text fast path.
443
- const startMarker = document.createComment('slot-start');
444
- const endMarker = document.createComment('slot-end');
445
- parent.replaceChild(endMarker, comment);
446
- parent.insertBefore(startMarker, endMarker);
447
- let currentResults = [];
448
- let currentNodes = [];
449
- let currentFactoryDisposers = [];
450
- let currentText = null;
451
- // Sentinel distinct from any resolvable value (including null/undefined)
452
- // so the FIRST effect run always mounts. See ADR 0008.1.
453
- const NOT_RENDERED = Symbol('not-rendered');
454
- let lastValue = NOT_RENDERED;
455
- const dispose = effect(() => {
456
- const newValue = value.value;
457
- // Use endMarker.parentNode — the captured `parent` may be a
458
- // DocumentFragment that was already appended to the real DOM,
459
- // leaving the markers reparented under the actual DOM element.
460
- const liveParent = endMarker.parentNode;
461
- if (!liveParent)
462
- return;
463
- // Memoize by referential identity. A computed/signal only notifies
464
- // when its value changes by Object.is, so an EQUAL value here means
465
- // this effect was re-triggered by some OTHER dependency (a tracking
466
- // leak), not by an actual change to this slot. Re-mounting identical
467
- // content would destroy live DOM (and reset scroll/focus) for nothing.
468
- // See ADR 0008.1.
469
- if (newValue === lastValue)
470
- return;
471
- lastValue = newValue;
472
- const isTemplate = Boolean(newValue
473
- && typeof newValue === 'object'
474
- && '__templateResult' in newValue);
475
- const isFactory = Boolean(newValue
476
- && typeof newValue === 'object'
477
- && '__type' in newValue
478
- && newValue.__type === 'factory');
479
- const isPrimitive = newValue != null
480
- && newValue !== false
481
- && !Array.isArray(newValue)
482
- && !isTemplate
483
- && !isFactory;
484
- if (isPrimitive && currentText) {
485
- currentText.data = String(newValue);
486
- return;
487
- }
488
- // Dispose and remove the previous slot value before mounting the next.
489
- // Ownership is per VALUE, not per outer template lifetime: factory prop
490
- // subscriptions must stop as soon as their child leaves (issue 0011).
491
- for (const r of currentResults) {
492
- try {
493
- r.dispose();
494
- }
495
- catch (_) { }
496
- }
497
- for (const dispose of currentFactoryDisposers) {
498
- try {
499
- dispose();
500
- }
501
- catch (_) { }
502
- }
503
- for (const node of currentNodes) {
504
- node.parentNode?.removeChild(node);
505
- }
506
- currentNodes = [];
507
- currentResults = [];
508
- currentFactoryDisposers = [];
509
- currentText = null;
510
- if (newValue == null) {
511
- // null/undefined — nothing to mount
512
- }
513
- else if (Array.isArray(newValue)) {
514
- // Array of TemplateResults (or mixed content)
515
- for (const item of newValue) {
516
- if (item && typeof item === 'object' && '__templateResult' in item) {
517
- const tpl = item;
518
- const wrapper = document.createDocumentFragment();
519
- tpl.mount(wrapper);
520
- const nodes = [...wrapper.childNodes];
521
- currentNodes.push(...nodes);
522
- currentResults.push(tpl);
523
- liveParent.insertBefore(wrapper, endMarker);
524
- }
525
- else if (item && typeof item === 'object' && '__type' in item && item.__type === 'factory') {
526
- const mounted = mountFactoryResult(item);
527
- currentNodes.push(mounted.element);
528
- currentFactoryDisposers.push(mounted.dispose);
529
- liveParent.insertBefore(mounted.element, endMarker);
530
- }
531
- else if (item != null && item !== false) {
532
- const textNode = document.createTextNode(String(item));
533
- currentNodes.push(textNode);
534
- liveParent.insertBefore(textNode, endMarker);
535
- }
536
- }
537
- }
538
- else if (isTemplate) {
539
- // Single TemplateResult
540
- const tpl = newValue;
541
- const wrapper = document.createDocumentFragment();
542
- tpl.mount(wrapper);
543
- currentNodes = [...wrapper.childNodes];
544
- currentResults.push(tpl);
545
- liveParent.insertBefore(wrapper, endMarker);
546
- }
547
- else if (isFactory) {
548
- // Factory result — create child element
549
- const mounted = mountFactoryResult(newValue);
550
- currentNodes.push(mounted.element);
551
- currentFactoryDisposers.push(mounted.dispose);
552
- liveParent.insertBefore(mounted.element, endMarker);
553
- }
554
- else if (newValue !== false) {
555
- currentText = document.createTextNode(String(newValue));
556
- currentNodes.push(currentText);
557
- liveParent.insertBefore(currentText, endMarker);
558
- }
559
- });
560
- disposers.push(dispose);
561
- // TemplateResult.dispose() intentionally leaves mounted DOM in place, but
562
- // it must still dispose bindings owned by the CURRENT reactive slot value.
563
- disposers.push(() => {
564
- for (const r of currentResults.splice(0)) {
565
- try {
566
- r.dispose();
567
- }
568
- catch (_) { }
569
- }
570
- for (const factoryDispose of currentFactoryDisposers.splice(0)) {
571
- try {
572
- factoryDispose();
573
- }
574
- catch (_) { }
575
- }
576
- currentNodes = [];
577
- currentText = null;
578
- });
579
- }
580
- else if (value && typeof value === 'object' && '__templateResult' in value) {
581
- // Nested template result — mount it
582
- const result = value;
583
- const startMarker = document.createComment('tpl-start');
584
- const endMarker = document.createComment('tpl-end');
585
- parent.replaceChild(endMarker, comment);
586
- parent.insertBefore(startMarker, endMarker);
587
- // Create a wrapper element to mount into
588
- const wrapper = document.createDocumentFragment();
589
- result.mount(wrapper);
590
- parent.insertBefore(wrapper, endMarker);
591
- disposers.push(() => result.dispose());
592
- }
593
- else if (value && typeof value === 'object' && '__type' in value && value.__type === 'factory') {
594
- // Factory result — create child element
595
- const mounted = mountFactoryResult(value);
596
- parent.replaceChild(mounted.element, comment);
597
- disposers.push(mounted.dispose);
598
- }
599
- else if (Array.isArray(value)) {
600
- // Array of template results
601
- const startMarker = document.createComment('list-start');
602
- const endMarker = document.createComment('list-end');
603
- parent.replaceChild(endMarker, comment);
604
- parent.insertBefore(startMarker, endMarker);
605
- for (const item of value) {
606
- if (item && typeof item === 'object' && '__templateResult' in item) {
607
- const wrapper = document.createDocumentFragment();
608
- item.mount(wrapper);
609
- parent.insertBefore(wrapper, endMarker);
610
- disposers.push(() => item.dispose());
611
- }
612
- else if (item && typeof item === 'object' && '__type' in item && item.__type === 'factory') {
613
- const mounted = mountFactoryResult(item);
614
- parent.insertBefore(mounted.element, endMarker);
615
- disposers.push(mounted.dispose);
616
- }
617
- else {
618
- const textNode = document.createTextNode(String(item));
619
- parent.insertBefore(textNode, endMarker);
620
- }
621
- }
622
- }
623
- else if (value == null || value === false) {
624
- // null, undefined, false — render nothing
625
- parent.removeChild(comment);
626
- }
627
- else {
628
- // Primitive value — render as text
629
- const textNode = document.createTextNode(String(value));
630
- parent.replaceChild(textNode, comment);
631
- }
632
- }
633
- function bindAttribute(el, name, value, bindings, disposers) {
634
- // class attribute gets special handling to avoid conflicts with
635
- // class:name directives (see bindClassAttribute for details).
636
- if (name === 'class') {
637
- bindClassAttribute(el, value, bindings, disposers);
638
- return;
639
- }
640
- // Auto-resolution: framework components get _setProp for custom props
641
- // (preserves types). Standard HTML attributes (id, style, data-*, aria-*,
642
- // and — ADR 0030.2 §8 — title/role/tabindex/name) always use setAttribute
643
- // even on framework components: routed through _setProp they were silently
644
- // dead (no component declares them as props).
645
- const isHtmlAttr = name === 'id' || name === 'style'
646
- || name === 'slot' || name === 'title' || name === 'role'
647
- || name === 'tabindex' || name === 'name'
648
- || name.startsWith('data-') || name.startsWith('aria-');
649
- const hasPropSetter = !isHtmlAttr && typeof el._setProp === 'function';
650
- if (hasPropSetter) {
651
- if (isSignal(value)) {
652
- const binding = { type: 'attribute', element: el, name };
653
- bindings.push(binding);
654
- const dispose = effect(() => {
655
- el._setProp(name, value.value);
656
- });
657
- binding.dispose = dispose;
658
- disposers.push(dispose);
659
- }
660
- else {
661
- el._setProp(name, value);
662
- }
663
- return;
664
- }
665
- bindPlainAttribute(el, name, value, bindings, disposers);
666
- }
667
- /**
668
- * Bind a value as a plain HTML attribute via `setAttribute`, reactive when it
669
- * is a signal, with `html`'s boolean/null semantics (true → present empty,
670
- * false/null/undefined → absent). NEVER uses `_setProp` — this is the shared
671
- * attribute path used by both `bindAttribute` (its non-component branch) and
672
- * `el()`, whose contract is *plain-HTML authoring*: a framework component tag
673
- * reached this way receives its values as attributes and resolves them through
674
- * its `attr()`/`boolAttr()` fallbacks, not typed prop setters.
675
- */
676
- function bindPlainAttribute(el, name, value, bindings, disposers) {
677
- if (isSignal(value)) {
678
- const binding = { type: 'attribute', element: el, name };
679
- bindings.push(binding);
680
- const dispose = effect(() => {
681
- const v = value.value;
682
- if (v == null || v === false) {
683
- el.removeAttribute(name);
684
- }
685
- else if (v === true) {
686
- el.setAttribute(name, '');
687
- }
688
- else {
689
- el.setAttribute(name, String(v));
690
- }
691
- });
692
- binding.dispose = dispose;
693
- disposers.push(dispose);
694
- }
695
- else {
696
- if (value == null || value === false) {
697
- el.removeAttribute(name);
698
- }
699
- else if (value === true) {
700
- el.setAttribute(name, '');
701
- }
702
- else {
703
- el.setAttribute(name, String(value));
704
- }
705
- }
706
- }
707
- /**
708
- * Bind a reactive class attribute using classList.add/remove instead of
709
- * setAttribute('class', ...). This prevents the class attribute binding
710
- * from overwriting classes toggled by class:name directives.
711
- *
712
- * The problem: setAttribute('class', 'foo bar') replaces ALL classes,
713
- * wiping out any classes added by classList.toggle() from class:name
714
- * bindings. By tracking which classes "belong" to the class attribute
715
- * and using classList operations, we only manage our own classes.
716
- */
717
- function bindClassAttribute(el, value, bindings, disposers) {
718
- // On first call, we must clear the parser-set class attribute which
719
- // contains raw marker text (e.g. "badge status-<!--bk-0-->").
720
- // We snapshot any non-marker classes set by the parser before clearing.
721
- let initialized = false;
722
- let prevClasses = [];
723
- const applyClasses = (raw) => {
724
- if (!initialized) {
725
- // Clear the parser's class attribute (contains marker text)
726
- el.setAttribute('class', '');
727
- initialized = true;
728
- }
729
- const str = raw == null || raw === false ? '' : String(raw);
730
- const next = str.split(/\s+/).filter(Boolean);
731
- // Remove classes no longer in the attribute value
732
- for (const cls of prevClasses) {
733
- if (!next.includes(cls)) {
734
- el.classList.remove(cls);
735
- }
736
- }
737
- // Add new classes
738
- for (const cls of next) {
739
- if (!prevClasses.includes(cls)) {
740
- el.classList.add(cls);
741
- }
742
- }
743
- prevClasses = next;
744
- };
745
- if (isSignal(value)) {
746
- const binding = { type: 'attribute', element: el, name: 'class' };
747
- bindings.push(binding);
748
- const dispose = effect(() => {
749
- applyClasses(value.value);
750
- });
751
- binding.dispose = dispose;
752
- disposers.push(dispose);
753
- }
754
- else {
755
- applyClasses(value);
756
- }
757
- }
758
- function bindClass(el, className, value, bindings, disposers) {
759
- if (isSignal(value)) {
760
- const binding = { type: 'class', element: el, className };
761
- bindings.push(binding);
762
- const dispose = effect(() => {
763
- const v = value.value;
764
- el.classList.toggle(className, !!v);
765
- });
766
- binding.dispose = dispose;
767
- disposers.push(dispose);
768
- }
769
- else {
770
- el.classList.toggle(className, !!value);
771
- }
772
- }
773
- /**
774
- * Bind branded HTML content to an element's inner markup.
775
- *
776
- * Never a bare string — a brand is the author's explicit decision about where
777
- * the markup came from, which is what closes the accidental-XSS-sink class
778
- * (ADR 0030.2 §3/§8). Three shapes resolve, in the order they are checked:
779
- *
780
- * - `raw()` (core owns the `__raw` brand; @nisli/ssg re-exports it):
781
- * author-asserted trust → today's `innerHTML` sink, unchanged.
782
- * NEVER wrap user-generated input in raw().
783
- * - `sanitized()` → the sanitizer sink: native `Element.setHTML()`, else the
784
- * `setSanitizerFallback()` hook, else N107. It NEVER reaches `innerHTML`
785
- * (ADR 0019:124–128, bet-09(c)).
786
- * - A native `TrustedHTML` object → the same `innerHTML` sink as `raw()`,
787
- * passed through unwrapped so Trusted-Types-enforcing apps can assign
788
- * policy output directly.
789
- *
790
- * Anything else throws N106. Supports both static values and reactive
791
- * `Signal`s of them; when the signal changes the content is rewritten through
792
- * the same branch. null/undefined (branded or not) clears the content.
793
- *
794
- * Usage in templates:
795
- * html`<span html:inner="${raw(highlightedHtml)}"></span>`
796
- * html`<span html:inner="${sanitized(userComment)}"></span>`
797
- */
798
- function bindInnerHtml(el, value, bindings, disposers) {
799
- // PURE — validates the brand and proves a sink exists without writing
800
- // anything, so the eager signal check below can run it for its throw alone.
801
- // A SanitizedHtml resolves to ITSELF (the writer routes it to the
802
- // sanitizer); everything else resolves to the innerHTML payload.
803
- const resolveHtml = (v) => {
804
- if (v == null)
805
- return '';
806
- if (isRawHtml(v)) {
807
- // Both brands at once: trust is not resolvable in favour of the raw
808
- // sink, so reject rather than guess which one the author meant.
809
- if (isSanitizedHtml(v)) {
810
- throw nisliError('N106', `html:inner rejects a value branded both raw() and sanitized() — the ` +
811
- `trust decision is ambiguous. Pick one brand.`);
812
- }
813
- return v.value;
814
- }
815
- if (isSanitizedHtml(v)) {
816
- // FAIL CLOSED. Untrusted markup is only ever eligible to be written once
817
- // a sanitizing sink is proven to exist; no native setHTML() and no
818
- // registered hook is a hard error, because silently downgrading to
819
- // innerHTML would reopen the exact XSS hole this brand closes.
820
- if (sanitizerFor(el) === null) {
821
- throw nisliError('N107', `html:inner cannot render sanitized() markup: this engine has no native ` +
822
- `Element.setHTML() and no fallback is registered. Wire one at startup — ` +
823
- `setSanitizerFallback((el, html) => { el.innerHTML = DOMPurify.sanitize(html) }) ` +
824
- `— nisli ships no sanitizer and will not fall back to innerHTML.`);
825
- }
826
- return v;
827
- }
828
- if (isTrustedHtml(v))
829
- return v;
830
- throw nisliError('N106', `html:inner rejects unbranded values (got ${typeof v}). Wrap trusted markup ` +
831
- `in raw(html), untrusted markup in sanitized(html) — and never wrap ` +
832
- `user-generated input in raw().`);
833
- };
834
- const writeHtml = (v) => {
835
- const resolved = resolveHtml(v);
836
- if (isSanitizedHtml(resolved)) {
837
- // The sanitizer sink. resolveHtml has already proven a writer exists,
838
- // and `el.innerHTML` is unreachable from this branch by construction.
839
- sanitizerFor(el)?.(el, resolved.value);
840
- return;
841
- }
842
- // The raw sink, unchanged: a plain string, or a TrustedHTML object handed
843
- // over WITHOUT stringifying it (stringifying would defeat the CSP check).
844
- // The cast only satisfies the DOM library's `string` typing.
845
- el.innerHTML = resolved;
846
- };
847
- if (isSignal(value)) {
848
- // Validate the INITIAL value untracked and eagerly so an unbranded or
849
- // unsanitizable signal throws synchronously out of mount(); later invalid
850
- // values throw inside the effect and land in effect error containment
851
- // (console.error, element content untouched).
852
- untracked(() => resolveHtml(value.value));
853
- const binding = { type: 'innerHtml', element: el };
854
- bindings.push(binding);
855
- const dispose = effect(() => {
856
- writeHtml(value.value);
857
- });
858
- binding.dispose = dispose;
859
- disposers.push(dispose);
860
- }
861
- else {
862
- writeHtml(value);
863
- }
864
- }
865
- function bindEvent(el, eventName, handler, modifiers, bindings) {
866
- // One canonical INSTALLED identity that every removal path references — the
867
- // EventBinding `dispose` below AND the `once` self-removal. Forward-declared
868
- // so the modifier wrappers close over it before it is assigned; it is only
869
- // ever *called* after addEventListener, so the reference always resolves.
870
- // This identity-mismatch class bit el() dispose, html dispose, and once — a
871
- // single installed handler that all removals target closes it (UI-33-R2).
872
- let safeHandler;
873
- // Compose the modifier chain (inner); safeHandler wraps it in try/catch below.
874
- let composed = handler;
875
- if (modifiers.includes('stop')) {
876
- const inner = composed;
877
- composed = (e) => {
878
- e.stopPropagation();
879
- inner(e);
880
- };
881
- }
882
- if (modifiers.includes('prevent')) {
883
- const inner = composed;
884
- composed = (e) => {
885
- e.preventDefault();
886
- inner(e);
887
- };
888
- }
889
- if (modifiers.includes('once')) {
890
- const inner = composed;
891
- composed = (e) => {
892
- // Remove the ACTUALLY-INSTALLED listener, not this intermediate wrapper.
893
- el.removeEventListener(eventName, safeHandler);
894
- inner(e);
895
- };
896
- }
897
- // Keyboard modifiers
898
- for (const mod of modifiers) {
899
- if (['enter', 'escape', 'space', 'tab'].includes(mod)) {
900
- const keyMap = {
901
- enter: 'Enter',
902
- escape: 'Escape',
903
- space: ' ',
904
- tab: 'Tab',
905
- };
906
- const inner = composed;
907
- composed = (e) => {
908
- if (e.key === keyMap[mod]) {
909
- inner(e);
910
- }
911
- };
912
- }
913
- }
914
- // Wrap in try/catch for error containment — THIS is the installed handler.
915
- // Handler bodies run UNTRACKED (ADR 0030.2 §8, surfaced by the T5 review):
916
- // synchronous dispatchEvent from inside an effect would otherwise run the
917
- // handler under that effect's observer — widening the effect's dependency
918
- // set with every signal the handler reads and mis-attributing its writes.
919
- safeHandler = (e) => {
920
- try {
921
- untrack(() => composed(e));
922
- }
923
- catch (err) {
924
- console.error(`Event handler error for '${eventName}':`, err);
925
- }
926
- };
927
- el.addEventListener(eventName, safeHandler);
928
- // Register cleanup of the ACTUALLY-INSTALLED handler (safeHandler), not the
929
- // caller's original — their identities differ, so a removeEventListener with
930
- // the original is a silent no-op (the UI-33-R leak). Both `html` dispose and
931
- // `el()` dispose iterate bindings and call `dispose`, so this is the single
932
- // correct cleanup path for event listeners.
933
- bindings.push({
934
- type: 'event',
935
- element: el,
936
- eventName,
937
- handler: safeHandler,
938
- modifiers,
939
- dispose: () => el.removeEventListener(eventName, safeHandler),
940
- });
941
- }
942
- // ── each() reactive list rendering ──────────────────────────────────
943
- /** Brand for each result detection */
944
- const EACH_BRAND = Symbol.for('backlog.each');
945
- /**
946
- * Reactive list rendering with keyed reconciliation.
947
- *
948
- * Renders a list of items from a signal, tracking each item by key.
949
- * When the array changes, only affected DOM nodes are added, removed,
950
- * or reordered — existing items update in-place via their signals.
951
- *
952
- * ```ts
953
- * const tasks = signal([{ id: '1', title: 'A' }, { id: '2', title: 'B' }]);
954
- * html`<ul>${each(tasks, t => t.id, (task, index) =>
955
- * html`<li>${computed(() => task.value.title)}</li>`
956
- * )}</ul>`
957
- * ```
958
- */
959
- /**
960
- * Adapt whatever a template callback returned into a mountable TemplateResult.
961
- * Factory descriptors (the value `component()` factories actually return) are
962
- * mounted through the same path every other slot uses (issue 0021).
963
- */
964
- function asTemplateResult(result) {
965
- if (result && typeof result === 'object' && '__templateResult' in result) {
966
- return result;
967
- }
968
- if (result && typeof result === 'object' && result.__type === 'factory') {
969
- let mounted = null;
970
- return {
971
- __templateResult: true,
972
- mount(host) {
973
- mounted = mountFactoryResult(result);
974
- host.appendChild(mounted.element);
975
- },
976
- dispose() {
977
- mounted?.dispose();
978
- mounted?.element.remove();
979
- mounted = null;
980
- },
981
- };
982
- }
983
- throw new TypeError(`each(): template callback must return a TemplateResult or a component factory result, got ${result === null ? 'null' : typeof result}`);
984
- }
985
- export function each(items, keyFn, templateFn) {
986
- let startMarker;
987
- let endMarker;
988
- let entries = [];
989
- let effectDispose = null;
990
- return {
991
- __templateResult: true,
992
- mount(host) {
993
- startMarker = document.createComment('each-start');
994
- endMarker = document.createComment('each-end');
995
- host.appendChild(startMarker);
996
- host.appendChild(endMarker);
997
- effectDispose = effect(() => {
998
- const newItems = items.value;
999
- reconcile(newItems);
1000
- });
1001
- },
1002
- dispose() {
1003
- if (effectDispose) {
1004
- effectDispose();
1005
- effectDispose = null;
1006
- }
1007
- for (const entry of entries) {
1008
- entry.templateResult.dispose();
1009
- }
1010
- entries = [];
1011
- },
1012
- };
1013
- function reconcile(newItems) {
1014
- const parent = endMarker.parentNode;
1015
- if (!parent)
1016
- return;
1017
- // Validate the whole key set before updating item signals, mounting new
1018
- // entries, or touching DOM. A duplicate update must leave the last valid
1019
- // reconciliation intact so a later corrected array can recover normally.
1020
- const keyedItems = [];
1021
- const keyIndices = new Map();
1022
- for (let i = 0; i < newItems.length; i++) {
1023
- const item = newItems[i];
1024
- if (item === undefined)
1025
- continue;
1026
- const key = keyFn(item, i);
1027
- const previousIndex = keyIndices.get(key);
1028
- if (previousIndex !== undefined) {
1029
- const formattedKey = typeof key === 'string' ? JSON.stringify(key) : String(key);
1030
- console.error(`each() requires unique keys. Duplicate key ${formattedKey} ` +
1031
- `found at indices ${previousIndex} and ${i}; reconciliation skipped.`);
1032
- return;
1033
- }
1034
- keyIndices.set(key, i);
1035
- keyedItems.push({ item, index: i, key });
1036
- }
1037
- // Build old key → entry map
1038
- const oldMap = new Map();
1039
- for (const entry of entries) {
1040
- oldMap.set(entry.key, entry);
1041
- }
1042
- // Build new entries list
1043
- const newEntries = [];
1044
- const newKeys = new Set();
1045
- for (const { item, index, key } of keyedItems) {
1046
- newKeys.add(key);
1047
- const existing = oldMap.get(key);
1048
- if (existing) {
1049
- // Reuse — update signals in place
1050
- existing.itemSignal.value = item;
1051
- existing.indexSignal.value = index;
1052
- newEntries.push(existing);
1053
- }
1054
- else {
1055
- // Create new entry with a stable wrapper element.
1056
- // Using display:contents so the wrapper is invisible to layout —
1057
- // inner reactive content can freely swap DOM nodes without
1058
- // invalidating the each() reconciler's node tracking.
1059
- const itemSignal = signal(item);
1060
- const indexSignal = signal(index);
1061
- const wrapper = document.createElement('each-item');
1062
- wrapper.style.display = 'contents';
1063
- const templateResult = asTemplateResult(templateFn(itemSignal, indexSignal));
1064
- templateResult.mount(wrapper);
1065
- newEntries.push({ key, itemSignal, indexSignal, templateResult, wrapper });
1066
- }
1067
- }
1068
- // Remove entries whose key is gone
1069
- for (const entry of entries) {
1070
- if (!newKeys.has(entry.key)) {
1071
- entry.templateResult.dispose();
1072
- entry.wrapper.parentNode?.removeChild(entry.wrapper);
1073
- }
1074
- }
1075
- // Reorder DOM nodes to match new order.
1076
- // Each entry has exactly one stable wrapper element, so positioning
1077
- // is simple and immune to inner reactive content swaps.
1078
- let cursor = startMarker;
1079
- for (const entry of newEntries) {
1080
- const nextSibling = cursor.nextSibling;
1081
- if (nextSibling !== entry.wrapper) {
1082
- if (entry.wrapper.parentNode === parent && canMoveWithin(parent)) {
1083
- // Already attached under this parent: atomic move preconditions hold.
1084
- parent.moveBefore(entry.wrapper, nextSibling);
1085
- }
1086
- else {
1087
- // Fresh wrapper, or a browser without moveBefore().
1088
- parent.insertBefore(entry.wrapper, nextSibling);
1089
- }
1090
- }
1091
- cursor = entry.wrapper;
1092
- }
1093
- entries = newEntries;
1094
- }
1095
- }
1096
- /**
1097
- * Conditional rendering helper (ADR 0030.2 §3, boolean-gated with `else`).
1098
- *
1099
- * Shows `template` when the condition is truthy, `elseTemplate` (optional)
1100
- * when falsy. Both arms accept a TemplateResult or a lazy callback
1101
- * `() => TemplateResult`; callbacks are only evaluated for the active arm.
1102
- *
1103
- * Semantics for signal conditions:
1104
- * - The gate is `!!condition.value` — a truthy→truthy (or falsy→falsy)
1105
- * transition returns the memoized previous result, so the live branch is
1106
- * NEVER rebuilt (no state/scroll/focus loss) and callbacks never re-run.
1107
- * - Branch callbacks evaluate UNTRACKED: a signal read during branch
1108
- * construction does not subscribe the when() computed, so it cannot
1109
- * trigger a rebuild. Reactive parts inside a branch must be interpolated
1110
- * as signals/computeds (values), not read raw at construction time.
1111
- */
1112
- export function when(condition, template, elseTemplate) {
1113
- const resolveBranch = (branch) => {
1114
- if (branch === undefined)
1115
- return null;
1116
- if (typeof branch === 'function')
1117
- return untracked(branch);
1118
- return branch;
1119
- };
1120
- if (isSignal(condition)) {
1121
- // Boolean-gated memo: notify() marks observers dirty EAGERLY (before
1122
- // value-equality is known), so a computed chain alone cannot stop a
1123
- // truthy→truthy recompute from re-evaluating the branch. The memo pins
1124
- // branch evaluation to actual boolean FLIPS; equal gates return the
1125
- // cached result, which the slot then memoizes by identity (ADR 0008.1).
1126
- let lastGate;
1127
- let lastResult = null;
1128
- return computed(() => {
1129
- const gate = !!condition.value;
1130
- if (gate === lastGate)
1131
- return lastResult;
1132
- const next = gate ? resolveBranch(template) : resolveBranch(elseTemplate);
1133
- // Commit the memo only after a successful resolve so a throwing branch
1134
- // callback retries on the next read (computed error containment).
1135
- lastGate = gate;
1136
- lastResult = next;
1137
- return next;
1138
- });
1139
- }
1140
- return condition ? resolveBranch(template) : resolveBranch(elseTemplate);
1141
- }
1142
- /**
1143
- * Build a DOM element by a (possibly *dynamic*) tag name with reactive
1144
- * attributes, event handlers, and children, returned as a mountable/disposable
1145
- * `TemplateResult` — so it composes in an `html` slot:
1146
- * `` html`${el(tag, { class: cls }, children)}` ``.
1147
- *
1148
- * `el()` is the "author plain HTML programmatically" primitive. It exists for
1149
- * the one thing `html` cannot express — a tag name chosen at runtime — while
1150
- * the `html` PARSER STAYS 100% STATIC: `el()` is a separate construction path
1151
- * that shares `html`'s binding helpers (`bindPlainAttribute`, `bindClassAttribute`,
1152
- * `bindEvent`, and the `replaceMarkerWithBinding` slot mounter). Prefer `html`
1153
- * for static tags and typed factories for typed composition.
1154
- *
1155
- * Semantics (ADR 0025 item 11):
1156
- * - Props are **HTML attributes**: `class` uses the reactive class binder, every
1157
- * other key uses `setAttribute` with `html`'s boolean/null rules — NEVER
1158
- * `_setProp`. A framework component tag reached via `el()` receives its values
1159
- * as attributes and resolves them through its `attr()`/`boolAttr()` fallbacks.
1160
- * - `ref` assigns `.current`; `on: { event: handler }` adds auto-removed listeners.
1161
- * - Children accept the full `html` text-slot range — string/number, signals
1162
- * (reactive text or reactive template slot), `TemplateResult`, factory results,
1163
- * and arrays — via the shared slot mounter.
1164
- * - v1 is HTML-only: SVG/namespaced tags (`createElementNS`) are deferred until a
1165
- * real consumer needs them.
1166
- */
1167
- export function el(tag, props = {}, children) {
1168
- let node = null;
1169
- const bindings = [];
1170
- const disposers = [];
1171
- return {
1172
- __templateResult: true,
1173
- mount(host) {
1174
- const element = document.createElement(tag);
1175
- node = element;
1176
- for (const [key, val] of Object.entries(props)) {
1177
- if (val === undefined)
1178
- continue;
1179
- if (key === 'ref') {
1180
- if (isRef(val)) {
1181
- val.current = element;
1182
- disposers.push(() => { val.current = null; });
1183
- }
1184
- continue;
1185
- }
1186
- if (key === 'on') {
1187
- // bindEvent registers correct disposal on the EventBinding (removes
1188
- // the installed safeHandler); no manual removeEventListener (UI-33-R).
1189
- for (const [event, handler] of Object.entries(val)) {
1190
- bindEvent(element, event, handler, [], bindings);
1191
- }
1192
- continue;
1193
- }
1194
- if (key === 'class') {
1195
- bindClassAttribute(element, val, bindings, disposers);
1196
- continue;
1197
- }
1198
- bindPlainAttribute(element, key, val, bindings, disposers);
1199
- }
1200
- // Children reuse html's text-slot mounter: append a comment anchor per
1201
- // child and let replaceMarkerWithBinding resolve its type in place.
1202
- const flat = [];
1203
- flattenChildren(children, flat);
1204
- for (const child of flat) {
1205
- const anchor = document.createComment('');
1206
- element.appendChild(anchor);
1207
- replaceMarkerWithBinding(anchor, child, bindings, disposers);
1208
- }
1209
- host.appendChild(element);
1210
- },
1211
- dispose() {
1212
- for (const d of disposers) {
1213
- try {
1214
- d();
1215
- }
1216
- catch (_) { /* swallow */ }
1217
- }
1218
- disposers.length = 0;
1219
- for (const b of bindings) {
1220
- if ('dispose' in b && b.dispose) {
1221
- try {
1222
- b.dispose();
1223
- }
1224
- catch (_) { /* swallow */ }
1225
- }
1226
- }
1227
- bindings.length = 0;
1228
- node?.parentNode?.removeChild(node);
1229
- node = null;
1230
- },
1231
- };
1232
- }
1233
- function flattenChildren(children, out) {
1234
- if (children === undefined)
1235
- return;
1236
- if (Array.isArray(children)) {
1237
- for (const c of children)
1238
- flattenChildren(c, out);
1239
- }
1240
- else {
1241
- out.push(children);
1242
- }
1243
- }
1244
- //# sourceMappingURL=template.js.map