@nisli/core 0.54.0 → 0.55.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.
Files changed (47) hide show
  1. package/README.md +93 -3
  2. package/dist/component.js +146 -5
  3. package/dist/component.js.map +1 -1
  4. package/dist/diagnostics.d.ts +48 -0
  5. package/dist/diagnostics.js +118 -0
  6. package/dist/diagnostics.js.map +1 -0
  7. package/dist/disposable.d.ts +14 -0
  8. package/dist/disposable.js +8 -0
  9. package/dist/disposable.js.map +1 -0
  10. package/dist/emitter.d.ts +2 -1
  11. package/dist/emitter.js +2 -1
  12. package/dist/emitter.js.map +1 -1
  13. package/dist/hmr/registry.js +8 -1
  14. package/dist/hmr/registry.js.map +1 -1
  15. package/dist/index.d.ts +5 -3
  16. package/dist/index.js +6 -2
  17. package/dist/index.js.map +1 -1
  18. package/dist/injector.d.ts +9 -2
  19. package/dist/injector.js +18 -0
  20. package/dist/injector.js.map +1 -1
  21. package/dist/lifecycle.d.ts +9 -0
  22. package/dist/lifecycle.js +17 -7
  23. package/dist/lifecycle.js.map +1 -1
  24. package/dist/projection.js +27 -10
  25. package/dist/projection.js.map +1 -1
  26. package/dist/query.d.ts +134 -55
  27. package/dist/query.js +379 -188
  28. package/dist/query.js.map +1 -1
  29. package/dist/resource.d.ts +2 -1
  30. package/dist/resource.js +21 -4
  31. package/dist/resource.js.map +1 -1
  32. package/dist/settle.d.ts +61 -0
  33. package/dist/settle.js +106 -0
  34. package/dist/settle.js.map +1 -0
  35. package/dist/signal.d.ts +42 -7
  36. package/dist/signal.js +248 -68
  37. package/dist/signal.js.map +1 -1
  38. package/dist/template-audit.d.ts +64 -0
  39. package/dist/template-audit.js +246 -0
  40. package/dist/template-audit.js.map +1 -0
  41. package/dist/template.d.ts +70 -1
  42. package/dist/template.js +477 -218
  43. package/dist/template.js.map +1 -1
  44. package/dist/view-transition.d.ts +70 -0
  45. package/dist/view-transition.js +98 -0
  46. package/dist/view-transition.js.map +1 -0
  47. package/package.json +1 -1
package/dist/template.js CHANGED
@@ -11,6 +11,109 @@
11
11
  */
12
12
  import { signal, isSignal, effect, computed, untrack, } from './signal.js';
13
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
+ }
14
117
  // ── Template cache ──────────────────────────────────────────────────
15
118
  /**
16
119
  * Cache parsed templates by their static string parts.
@@ -18,6 +121,67 @@ import { isRef } from './ref.js';
18
121
  * reference per call site, we use WeakMap for efficient caching.
19
122
  */
20
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
+ }
21
185
  // ── Marker for expression slots ─────────────────────────────────────
22
186
  const MARKER_PREFIX = '<!--bk-';
23
187
  const MARKER_SUFFIX = '-->';
@@ -46,44 +210,29 @@ function createMarker(index) {
46
210
  export function html(strings, ...values) {
47
211
  const bindings = [];
48
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;
49
217
  return {
50
218
  __templateResult: true,
51
219
  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
- }
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))}`);
83
229
  }
84
- // Parse the HTML
85
- const template = document.createElement('template');
86
- template.innerHTML = htmlStr;
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);
87
236
  const fragment = template.content.cloneNode(true);
88
237
  // Walk the DOM tree and replace markers with bindings
89
238
  processNode(fragment, values, bindings, disposers);
@@ -107,6 +256,7 @@ export function html(strings, ...values) {
107
256
  }
108
257
  }
109
258
  bindings.length = 0;
259
+ mounted = false;
110
260
  },
111
261
  };
112
262
  }
@@ -287,159 +437,145 @@ function replaceMarkerWithBinding(comment, value, bindings, disposers) {
287
437
  if (!parent)
288
438
  return;
289
439
  if (isSignal(value)) {
290
- // Probe the current value WITHOUT tracking. This read runs during mount,
291
- // which may execute inside an enclosing reactive scope (a parent reactive
292
- // slot's effect, or an each() reconcile effect). The real subscription for
293
- // THIS slot is established by the dedicated inner effect(s) below — the
294
- // probe is only used to decide which kind of binding to create. If the
295
- // probe were tracked, every nested `${signal}` slot would leak its signal
296
- // (and its transitive deps) into the enclosing effect, so unrelated state
297
- // changes would tear down and rebuild the whole subtree. Mount must never
298
- // establish dependencies in the enclosing scope — same principle as the
299
- // child-component connectedCallback isolation (ADR 0008 Gap 1).
300
- let signalValue;
301
- untrack(() => { signalValue = value.value; });
302
- // Check if the signal holds a TemplateResult, factory, null, undefined, or array
303
- // (e.g., from reactive when(), computed(() => items.map(renderFn)), or factory results)
304
- const isReactiveSlot = signalValue === null || signalValue === undefined
305
- || (signalValue && typeof signalValue === 'object' && '__templateResult' in signalValue)
306
- || (signalValue && typeof signalValue === 'object' && '__type' in signalValue)
307
- || Array.isArray(signalValue);
308
- if (isReactiveSlot) {
309
- // Reactive template slot — mount/unmount templates as signal changes
310
- const startMarker = document.createComment('slot-start');
311
- const endMarker = document.createComment('slot-end');
312
- parent.replaceChild(endMarker, comment);
313
- parent.insertBefore(startMarker, endMarker);
314
- let currentResults = [];
315
- let currentNodes = [];
316
- let currentFactoryDisposers = [];
317
- // Sentinel distinct from any resolvable value (including null/undefined)
318
- // so the FIRST effect run always mounts. See ADR 0008.1.
319
- const NOT_RENDERED = Symbol('not-rendered');
320
- let lastValue = NOT_RENDERED;
321
- const dispose = effect(() => {
322
- const newValue = value.value;
323
- // Use endMarker.parentNode — the captured `parent` may be a
324
- // DocumentFragment that was already appended to the real DOM,
325
- // leaving the markers reparented under the actual DOM element.
326
- const liveParent = endMarker.parentNode;
327
- if (!liveParent)
328
- return;
329
- // Memoize by referential identity. A computed/signal only notifies
330
- // when its value changes by Object.is, so an EQUAL value here means
331
- // this effect was re-triggered by some OTHER dependency (a tracking
332
- // leak), not by an actual change to this slot. Re-mounting identical
333
- // content would destroy live DOM (and reset scroll/focus) for nothing.
334
- // Defense-in-depth against the leak class fixed at the probe in this
335
- // same file. See ADR 0008.1.
336
- if (newValue === lastValue)
337
- return;
338
- lastValue = newValue;
339
- // Dispose and remove the previous slot value before mounting the next.
340
- // Ownership is per VALUE, not per outer template lifetime: factory prop
341
- // subscriptions must stop as soon as their child leaves (issue 0011).
342
- for (const r of currentResults) {
343
- try {
344
- r.dispose();
345
- }
346
- catch (_) { }
347
- }
348
- for (const dispose of currentFactoryDisposers) {
349
- try {
350
- dispose();
351
- }
352
- catch (_) { }
353
- }
354
- for (const node of currentNodes) {
355
- node.parentNode?.removeChild(node);
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();
356
494
  }
357
- currentNodes = [];
358
- currentResults = [];
359
- currentFactoryDisposers = [];
360
- if (newValue == null) {
361
- // null/undefined — nothing to mount
495
+ catch (_) { }
496
+ }
497
+ for (const dispose of currentFactoryDisposers) {
498
+ try {
499
+ dispose();
362
500
  }
363
- else if (Array.isArray(newValue)) {
364
- // Array of TemplateResults (or mixed content)
365
- for (const item of newValue) {
366
- if (item && typeof item === 'object' && '__templateResult' in item) {
367
- const tpl = item;
368
- const wrapper = document.createDocumentFragment();
369
- tpl.mount(wrapper);
370
- const nodes = [...wrapper.childNodes];
371
- currentNodes.push(...nodes);
372
- currentResults.push(tpl);
373
- liveParent.insertBefore(wrapper, endMarker);
374
- }
375
- else if (item && typeof item === 'object' && '__type' in item && item.__type === 'factory') {
376
- const mounted = mountFactoryResult(item);
377
- currentNodes.push(mounted.element);
378
- currentFactoryDisposers.push(mounted.dispose);
379
- liveParent.insertBefore(mounted.element, endMarker);
380
- }
381
- else if (item != null && item !== false) {
382
- const textNode = document.createTextNode(String(item));
383
- currentNodes.push(textNode);
384
- liveParent.insertBefore(textNode, endMarker);
385
- }
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);
386
524
  }
387
- }
388
- else if (typeof newValue === 'object' && '__templateResult' in newValue) {
389
- // Single TemplateResult
390
- const tpl = newValue;
391
- const wrapper = document.createDocumentFragment();
392
- tpl.mount(wrapper);
393
- currentNodes = [...wrapper.childNodes];
394
- currentResults.push(tpl);
395
- liveParent.insertBefore(wrapper, endMarker);
396
- }
397
- else if (typeof newValue === 'object' && '__type' in newValue && newValue.__type === 'factory') {
398
- // Factory result — create child element
399
- const mounted = mountFactoryResult(newValue);
400
- currentNodes.push(mounted.element);
401
- currentFactoryDisposers.push(mounted.dispose);
402
- liveParent.insertBefore(mounted.element, endMarker);
403
- }
404
- else if (newValue !== false) {
405
- // Primitive reached from an initially null/undefined reactive slot.
406
- const textNode = document.createTextNode(String(newValue));
407
- currentNodes.push(textNode);
408
- liveParent.insertBefore(textNode, endMarker);
409
- }
410
- });
411
- disposers.push(dispose);
412
- // TemplateResult.dispose() intentionally leaves mounted DOM in place, but
413
- // it must still dispose bindings owned by the CURRENT reactive slot value.
414
- // Previously that cleanup only happened on the next value change.
415
- disposers.push(() => {
416
- for (const r of currentResults.splice(0)) {
417
- try {
418
- r.dispose();
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);
419
530
  }
420
- catch (_) { }
421
- }
422
- for (const factoryDispose of currentFactoryDisposers.splice(0)) {
423
- try {
424
- factoryDispose();
531
+ else if (item != null && item !== false) {
532
+ const textNode = document.createTextNode(String(item));
533
+ currentNodes.push(textNode);
534
+ liveParent.insertBefore(textNode, endMarker);
425
535
  }
426
- catch (_) { }
427
536
  }
428
- currentNodes = [];
429
- });
430
- }
431
- else {
432
- // Primitive signal — create a reactive text node
433
- const textNode = document.createTextNode(String(signalValue));
434
- parent.replaceChild(textNode, comment);
435
- const binding = { type: 'text', node: textNode };
436
- bindings.push(binding);
437
- const dispose = effect(() => {
438
- textNode.data = String(value.value);
439
- });
440
- binding.dispose = dispose;
441
- disposers.push(dispose);
442
- }
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
+ });
443
579
  }
444
580
  else if (value && typeof value === 'object' && '__templateResult' in value) {
445
581
  // Nested template result — mount it
@@ -502,10 +638,14 @@ function bindAttribute(el, name, value, bindings, disposers) {
502
638
  return;
503
639
  }
504
640
  // Auto-resolution: framework components get _setProp for custom props
505
- // (preserves types). Standard HTML attributes (id, style, data-*,
506
- // aria-*) always use setAttribute even on framework components.
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).
507
645
  const isHtmlAttr = name === 'id' || name === 'style'
508
- || name === 'slot' || name.startsWith('data-') || name.startsWith('aria-');
646
+ || name === 'slot' || name === 'title' || name === 'role'
647
+ || name === 'tabindex' || name === 'name'
648
+ || name.startsWith('data-') || name.startsWith('aria-');
509
649
  const hasPropSetter = !isHtmlAttr && typeof el._setProp === 'function';
510
650
  if (hasPropSetter) {
511
651
  if (isSignal(value)) {
@@ -631,30 +771,95 @@ function bindClass(el, className, value, bindings, disposers) {
631
771
  }
632
772
  }
633
773
  /**
634
- * Bind trusted HTML content to an element's innerHTML.
774
+ * Bind branded HTML content to an element's inner markup.
635
775
  *
636
- * WARNING: This is for trusted content only (e.g., highlighted search results
637
- * from @orama/highlight, diff2html output). NEVER use with user-generated input.
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:
638
779
  *
639
- * Supports both static strings and reactive Signal<string> values.
640
- * When the signal changes, innerHTML is updated reactively.
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.
641
793
  *
642
794
  * Usage in templates:
643
- * html`<span html:inner="${highlightedHtml}"></span>`
795
+ * html`<span html:inner="${raw(highlightedHtml)}"></span>`
796
+ * html`<span html:inner="${sanitized(userComment)}"></span>`
644
797
  */
645
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
+ };
646
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));
647
853
  const binding = { type: 'innerHtml', element: el };
648
854
  bindings.push(binding);
649
855
  const dispose = effect(() => {
650
- const v = value.value;
651
- el.innerHTML = v == null ? '' : String(v);
856
+ writeHtml(value.value);
652
857
  });
653
858
  binding.dispose = dispose;
654
859
  disposers.push(dispose);
655
860
  }
656
861
  else {
657
- el.innerHTML = value == null ? '' : String(value);
862
+ writeHtml(value);
658
863
  }
659
864
  }
660
865
  function bindEvent(el, eventName, handler, modifiers, bindings) {
@@ -707,9 +912,13 @@ function bindEvent(el, eventName, handler, modifiers, bindings) {
707
912
  }
708
913
  }
709
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.
710
919
  safeHandler = (e) => {
711
920
  try {
712
- composed(e);
921
+ untrack(() => composed(e));
713
922
  }
714
923
  catch (err) {
715
924
  console.error(`Event handler error for '${eventName}':`, err);
@@ -730,17 +939,6 @@ function bindEvent(el, eventName, handler, modifiers, bindings) {
730
939
  dispose: () => el.removeEventListener(eventName, safeHandler),
731
940
  });
732
941
  }
733
- /**
734
- * Conditional rendering helper.
735
- * Shows the template when condition is truthy.
736
- *
737
- * Supports both static and reactive (signal) conditions.
738
- * For signal conditions, returns a computed that reactively switches
739
- * between the template and null.
740
- *
741
- * The template argument can be a TemplateResult or a lazy callback
742
- * `() => TemplateResult` to avoid evaluating expensive branches.
743
- */
744
942
  // ── each() reactive list rendering ──────────────────────────────────
745
943
  /** Brand for each result detection */
746
944
  const EACH_BRAND = Symbol.for('backlog.each');
@@ -790,6 +988,26 @@ export function each(items, keyFn, templateFn) {
790
988
  const parent = endMarker.parentNode;
791
989
  if (!parent)
792
990
  return;
991
+ // Validate the whole key set before updating item signals, mounting new
992
+ // entries, or touching DOM. A duplicate update must leave the last valid
993
+ // reconciliation intact so a later corrected array can recover normally.
994
+ const keyedItems = [];
995
+ const keyIndices = new Map();
996
+ for (let i = 0; i < newItems.length; i++) {
997
+ const item = newItems[i];
998
+ if (item === undefined)
999
+ continue;
1000
+ const key = keyFn(item, i);
1001
+ const previousIndex = keyIndices.get(key);
1002
+ if (previousIndex !== undefined) {
1003
+ const formattedKey = typeof key === 'string' ? JSON.stringify(key) : String(key);
1004
+ console.error(`each() requires unique keys. Duplicate key ${formattedKey} ` +
1005
+ `found at indices ${previousIndex} and ${i}; reconciliation skipped.`);
1006
+ return;
1007
+ }
1008
+ keyIndices.set(key, i);
1009
+ keyedItems.push({ item, index: i, key });
1010
+ }
793
1011
  // Build old key → entry map
794
1012
  const oldMap = new Map();
795
1013
  for (const entry of entries) {
@@ -798,17 +1016,13 @@ export function each(items, keyFn, templateFn) {
798
1016
  // Build new entries list
799
1017
  const newEntries = [];
800
1018
  const newKeys = new Set();
801
- for (let i = 0; i < newItems.length; i++) {
802
- const item = newItems[i];
803
- if (item === undefined)
804
- continue;
805
- const key = keyFn(item, i);
1019
+ for (const { item, index, key } of keyedItems) {
806
1020
  newKeys.add(key);
807
1021
  const existing = oldMap.get(key);
808
1022
  if (existing) {
809
1023
  // Reuse — update signals in place
810
1024
  existing.itemSignal.value = item;
811
- existing.indexSignal.value = i;
1025
+ existing.indexSignal.value = index;
812
1026
  newEntries.push(existing);
813
1027
  }
814
1028
  else {
@@ -817,7 +1031,7 @@ export function each(items, keyFn, templateFn) {
817
1031
  // inner reactive content can freely swap DOM nodes without
818
1032
  // invalidating the each() reconciler's node tracking.
819
1033
  const itemSignal = signal(item);
820
- const indexSignal = signal(i);
1034
+ const indexSignal = signal(index);
821
1035
  const wrapper = document.createElement('each-item');
822
1036
  wrapper.style.display = 'contents';
823
1037
  const templateResult = templateFn(itemSignal, indexSignal);
@@ -839,20 +1053,65 @@ export function each(items, keyFn, templateFn) {
839
1053
  for (const entry of newEntries) {
840
1054
  const nextSibling = cursor.nextSibling;
841
1055
  if (nextSibling !== entry.wrapper) {
842
- parent.insertBefore(entry.wrapper, nextSibling);
1056
+ if (entry.wrapper.parentNode === parent && canMoveWithin(parent)) {
1057
+ // Already attached under this parent: atomic move preconditions hold.
1058
+ parent.moveBefore(entry.wrapper, nextSibling);
1059
+ }
1060
+ else {
1061
+ // Fresh wrapper, or a browser without moveBefore().
1062
+ parent.insertBefore(entry.wrapper, nextSibling);
1063
+ }
843
1064
  }
844
1065
  cursor = entry.wrapper;
845
1066
  }
846
1067
  entries = newEntries;
847
1068
  }
848
1069
  }
849
- export function when(condition, template) {
850
- const resolveTemplate = () => typeof template === 'function' ? template() : template;
1070
+ /**
1071
+ * Conditional rendering helper (ADR 0030.2 §3, boolean-gated with `else`).
1072
+ *
1073
+ * Shows `template` when the condition is truthy, `elseTemplate` (optional)
1074
+ * when falsy. Both arms accept a TemplateResult or a lazy callback
1075
+ * `() => TemplateResult`; callbacks are only evaluated for the active arm.
1076
+ *
1077
+ * Semantics for signal conditions:
1078
+ * - The gate is `!!condition.value` — a truthy→truthy (or falsy→falsy)
1079
+ * transition returns the memoized previous result, so the live branch is
1080
+ * NEVER rebuilt (no state/scroll/focus loss) and callbacks never re-run.
1081
+ * - Branch callbacks evaluate UNTRACKED: a signal read during branch
1082
+ * construction does not subscribe the when() computed, so it cannot
1083
+ * trigger a rebuild. Reactive parts inside a branch must be interpolated
1084
+ * as signals/computeds (values), not read raw at construction time.
1085
+ */
1086
+ export function when(condition, template, elseTemplate) {
1087
+ const resolveBranch = (branch) => {
1088
+ if (branch === undefined)
1089
+ return null;
1090
+ if (typeof branch === 'function')
1091
+ return untracked(branch);
1092
+ return branch;
1093
+ };
851
1094
  if (isSignal(condition)) {
852
- // Reactive: return a computed that re-evaluates when the signal changes
853
- return computed(() => condition.value ? resolveTemplate() : null);
1095
+ // Boolean-gated memo: notify() marks observers dirty EAGERLY (before
1096
+ // value-equality is known), so a computed chain alone cannot stop a
1097
+ // truthy→truthy recompute from re-evaluating the branch. The memo pins
1098
+ // branch evaluation to actual boolean FLIPS; equal gates return the
1099
+ // cached result, which the slot then memoizes by identity (ADR 0008.1).
1100
+ let lastGate;
1101
+ let lastResult = null;
1102
+ return computed(() => {
1103
+ const gate = !!condition.value;
1104
+ if (gate === lastGate)
1105
+ return lastResult;
1106
+ const next = gate ? resolveBranch(template) : resolveBranch(elseTemplate);
1107
+ // Commit the memo only after a successful resolve so a throwing branch
1108
+ // callback retries on the next read (computed error containment).
1109
+ lastGate = gate;
1110
+ lastResult = next;
1111
+ return next;
1112
+ });
854
1113
  }
855
- return condition ? resolveTemplate() : null;
1114
+ return condition ? resolveBranch(template) : resolveBranch(elseTemplate);
856
1115
  }
857
1116
  /**
858
1117
  * Build a DOM element by a (possibly *dynamic*) tag name with reactive