@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.
- package/README.md +93 -3
- package/dist/component.js +146 -5
- package/dist/component.js.map +1 -1
- package/dist/diagnostics.d.ts +48 -0
- package/dist/diagnostics.js +118 -0
- package/dist/diagnostics.js.map +1 -0
- package/dist/disposable.d.ts +14 -0
- package/dist/disposable.js +8 -0
- package/dist/disposable.js.map +1 -0
- package/dist/emitter.d.ts +2 -1
- package/dist/emitter.js +2 -1
- package/dist/emitter.js.map +1 -1
- package/dist/hmr/registry.js +8 -1
- package/dist/hmr/registry.js.map +1 -1
- package/dist/index.d.ts +5 -3
- package/dist/index.js +6 -2
- package/dist/index.js.map +1 -1
- package/dist/injector.d.ts +9 -2
- package/dist/injector.js +18 -0
- package/dist/injector.js.map +1 -1
- package/dist/lifecycle.d.ts +9 -0
- package/dist/lifecycle.js +17 -7
- package/dist/lifecycle.js.map +1 -1
- package/dist/projection.js +27 -10
- package/dist/projection.js.map +1 -1
- package/dist/query.d.ts +134 -55
- package/dist/query.js +379 -188
- package/dist/query.js.map +1 -1
- package/dist/resource.d.ts +2 -1
- package/dist/resource.js +21 -4
- package/dist/resource.js.map +1 -1
- package/dist/settle.d.ts +61 -0
- package/dist/settle.js +106 -0
- package/dist/settle.js.map +1 -0
- package/dist/signal.d.ts +42 -7
- package/dist/signal.js +248 -68
- package/dist/signal.js.map +1 -1
- package/dist/template-audit.d.ts +64 -0
- package/dist/template-audit.js +246 -0
- package/dist/template-audit.js.map +1 -0
- package/dist/template.d.ts +70 -1
- package/dist/template.js +477 -218
- package/dist/template.js.map +1 -1
- package/dist/view-transition.d.ts +70 -0
- package/dist/view-transition.js +98 -0
- package/dist/view-transition.js.map +1 -0
- 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
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
//
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
//
|
|
291
|
-
//
|
|
292
|
-
//
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
let
|
|
301
|
-
|
|
302
|
-
//
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
//
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
495
|
+
catch (_) { }
|
|
496
|
+
}
|
|
497
|
+
for (const dispose of currentFactoryDisposers) {
|
|
498
|
+
try {
|
|
499
|
+
dispose();
|
|
362
500
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
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
|
-
//
|
|
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
|
|
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
|
|
774
|
+
* Bind branded HTML content to an element's inner markup.
|
|
635
775
|
*
|
|
636
|
-
*
|
|
637
|
-
* from
|
|
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
|
-
*
|
|
640
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
850
|
-
|
|
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
|
-
//
|
|
853
|
-
|
|
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 ?
|
|
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
|