@jarenjs/view 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,538 @@
1
+ # The Jaren View Format
2
+
3
+ **Version 0.1 — Specification**
4
+
5
+ Module: `@jarenjs/view`. This document is the language contract for the
6
+ Jaren vnode format — the JSON vocabulary user interfaces are written in —
7
+ and the behavioral contract every renderer of the format MUST honor. It
8
+ plays the role for views that [QUERY-FORMAT](../../json/docs/QUERY-FORMAT.md)
9
+ plays for queries and [JSLT-FORMAT](../../json/docs/JSLT-FORMAT.md) for
10
+ stylesheets: vnodes are the intended *output* vocabulary of JSLT rule
11
+ bodies, closing the XSLT triangle — match with JSONPath, type with JSON
12
+ Schema, produce vnodes.
13
+
14
+ ## 1. Introduction
15
+
16
+ ### 1.1 What this format is
17
+
18
+ A **vnode** is a plain JSON value describing a fragment of user
19
+ interface. There are no functions anywhere in a vnode document: event
20
+ handlers are data (§4), so a complete interface is serializable,
21
+ schema-validatable, diffable, and generatable by a constrained decoder.
22
+ The grammar is published as JSON Schema in
23
+ [`schemas/jaren-vnode.schema.json`](../schemas/jaren-vnode.schema.json).
24
+
25
+ Schema validity is a **structural** property, not a safety one. A
26
+ grammar-valid vnode can still carry an `innerHTML` sink, an inline `on*`
27
+ handler or a `javascript:` URL, so validation alone does not make an
28
+ untrusted document safe to render — the default renderers trust their
29
+ input. Rendering an untrusted document requires the SAFE profile (§8).
30
+
31
+ ### 1.2 Conformance and normative language
32
+
33
+ The key words **MUST**, **MUST NOT**, **SHOULD**, **SHOULD NOT**,
34
+ **RECOMMENDED**, **MAY**, and **OPTIONAL** are to be interpreted as
35
+ described in RFC 2119.
36
+
37
+ - A **producer** (a JSLT stylesheet, a hand-written view, a model)
38
+ emits vnode documents and MUST emit documents valid per §2–§4.
39
+ - A **renderer** consumes vnode documents. Two renderer classes are
40
+ specified: **patching** renderers that maintain a live tree (§5) and
41
+ **serializing** renderers that emit markup (§6). The reference
42
+ implementations are `createDomRenderer` and `renderToString`.
43
+
44
+ ### 1.3 Terminology
45
+
46
+ - **Vnode** — any value of the format (§2).
47
+ - **Element** — a vnode of the form `[tag, props?, ...children]`.
48
+ - **Text** — a string or number vnode.
49
+ - **List** — an array vnode spliced into its parent's children.
50
+ - **Binding** — the opaque JSON value of one `on` member (§4).
51
+ - **Reconciliation** — patching a live tree from one vnode to the next.
52
+
53
+ ## 2. The vnode document
54
+
55
+ A vnode is classified by shape, in this order:
56
+
57
+ 1. **Text** — a `string` or a `number`. Renders as
58
+ `String(value)`. Numbers are text so query results embed without
59
+ conversion; `NaN` and infinities are producer errors.
60
+ 2. **Skipped** — `null` and the booleans `true`/`false` render nothing
61
+ and occupy no child position. This makes `{"$if": ...}` bodies and
62
+ boolean short-circuits compose without wrapper nodes, and it means
63
+ an *empty sequence* from a query (which `@jarenjs/json` maps to
64
+ `undefined`) also renders nothing.
65
+ 3. **Element** — an array whose first item is a string: `[tag,
66
+ props?, ...children]`. The tag MUST be a non-empty string. If the
67
+ second item is a plain object (not an array, not `null`) it is the
68
+ **props**; otherwise it is the first child. The tag
69
+ `"jaren-widget"` is reserved: it marks a widget node (§7).
70
+ 4. **List** — any other array. Its items are spliced into the parent's
71
+ children **in place**, recursively. A list at the root of a document
72
+ is a producer error in 0.1 (renderers MAY reject it): the root MUST
73
+ be a text or element vnode.
74
+
75
+ The props-detection rule (element item 2) means an element whose first
76
+ child is itself an object-shaped value cannot omit props: write
77
+ `["div", {}, child]`. Producers SHOULD always write the props object;
78
+ `h()` does.
79
+
80
+ **Rationale for the tagged-array form** (non-normative): rule bodies in
81
+ JSLT are query documents where object members with `$`-prefixed names
82
+ are operators; an object-shaped element vocabulary (`{"tag": ...}` or
83
+ `{"$": ...}`) would collide with or shadow that namespace. Arrays with a
84
+ literal string head pass through the query engine untouched, and the
85
+ list form is exactly what an `[{"$apply": ...}]` array-constructor body
86
+ produces. The same tagged-pair shape underlies the JTLT front-end.
87
+
88
+ ## 3. Props
89
+
90
+ Prop values MUST be JSON values. Four names are renderer instructions:
91
+
92
+ - **`key`** — a string or number giving the element identity among its
93
+ siblings (§5.3). Never rendered.
94
+ - **`on`** — an object `{ [eventType]: binding }` (§4). Never rendered.
95
+ - **`memo`** — a producer-owned subtree-stability marker (§5.5). Never
96
+ rendered.
97
+ - **`style`** — a CSS declaration string, or an object of declarations.
98
+ Object keys in camelCase are converted to kebab-case; keys starting
99
+ with `--` pass through. `null`/`false` members are dropped.
100
+
101
+ Every other prop **writes through**:
102
+
103
+ - A patching renderer targeting the DOM SHOULD assign `node[name]` when
104
+ the node has that property (form controls: `value`, `checked`, ...)
105
+ and use attributes otherwise; elements in a foreign namespace (§5.4)
106
+ always use attributes.
107
+ - `true` renders as a bare attribute, `false` and `null` remove the
108
+ attribute / clear the property.
109
+ - Prop names are used as-is: producers write `class`, not `className`.
110
+
111
+ A **controlled form value** is authoritative. A patching renderer MUST
112
+ reassert `value`/`checked` on a form control against the control's
113
+ **live** DOM property, not against the previous vnode's value: a user
114
+ edit may have moved the live value since the last frame even when the
115
+ vnode value is unchanged, and the authoritative value MUST win. A
116
+ renderer SHOULD compare the live value first and write only on a genuine
117
+ difference, so an already-matching control is not rewritten (which
118
+ preserves the caret).
119
+
120
+ This reassertion MUST survive the sharing (§5.1) and `memo` (§5.5) fast
121
+ paths: a control inside a subtree those paths skip still holds
122
+ authoritative state, so a conforming renderer reconciles controls from
123
+ something other than the per-frame prop diff. The reference
124
+ implementation keeps a registry of controlled nodes and reconciles it
125
+ once per settled pass — which also lets a `select` resolve its value
126
+ after its options exist and a `multiple` select apply an array of
127
+ values. Composition/IME coordination is not yet specified (§8).
128
+
129
+ ## 4. Events are data
130
+
131
+ The value of an `on` member is an opaque JSON **binding**. The view
132
+ layer MUST deliver the binding verbatim to the environment's event hook
133
+ (`onEvent(binding, nativeEvent)`) and MUST NOT interpret it. What a
134
+ binding means belongs to the layer above; in `@jarenjs/app` it is an
135
+ action name or `{"action": name, "with"?: payload, "event"?: [...],
136
+ "preventDefault"?: bool, "stopPropagation"?: bool}`
137
+ ([APP-FORMAT](../../app/docs/APP-FORMAT.md) §4).
138
+
139
+ Because bindings are data, a producer builds event payloads at *render
140
+ time*: a JSLT rule that renders the node at `$path` can embed that
141
+ location — or any value in scope — inside the binding. This replaces
142
+ closure capture and payload-creator functions.
143
+
144
+ A patching renderer MUST rebind by data: attaching, changing or
145
+ removing a binding across renders MUST NOT accumulate native listeners.
146
+ The reference implementation stores the current `on` object on the DOM
147
+ node behind one shared proxy listener per event type.
148
+
149
+ ## 5. Reconciliation
150
+
151
+ ### 5.1 The sharing fast path
152
+
153
+ For any subtree where `oldVnode === newVnode`, a patching renderer
154
+ MUST skip reconciliation entirely — no descent, no prop diff. This is
155
+ the load-bearing clause of the format's performance story: the JSLT
156
+ engine guarantees that unchanged input flows to output by reference
157
+ (share disposition, rebuild-only-if-changed), and the JSON Patch
158
+ engine's copy-on-write guarantees the same for state. Reference
159
+ equality is therefore *evidence of no change*, end to end.
160
+
161
+ The corollary constraint: a renderer MUST NOT mutate or annotate
162
+ vnodes (no `.elm` backpointers, no normalization in place). Vnode
163
+ documents may be frozen, cached, or shared between subtrees.
164
+
165
+ ### 5.2 Patch in place or replace
166
+
167
+ Two vnodes are **the same node** when both are text, or both are
168
+ elements with equal tag and equal `key`. Same nodes patch in place
169
+ (text: update the character data; element: diff props, reconcile
170
+ children). Different nodes replace: the old subtree is discarded and
171
+ the new one built fresh.
172
+
173
+ ### 5.3 Children and keys
174
+
175
+ Child reconciliation MUST preserve the DOM node identity of keyed
176
+ elements that appear on both sides, moving them instead of recreating
177
+ them. The reference algorithm is a head/tail two-pointer sweep with a
178
+ lazily built key map for the middle; its guarantees, which every
179
+ implementation MUST match:
180
+
181
+ - keys are unique among siblings (duplicate keys are a producer error;
182
+ behavior is unspecified);
183
+ - an element vnode with a key never patches against one with a
184
+ different key or without one;
185
+ - unkeyed children reconcile positionally.
186
+
187
+ Mixed keyed/unkeyed sibling lists are legal but SHOULD be avoided;
188
+ reorder efficiency is only specified for fully keyed lists.
189
+
190
+ ### 5.4 Namespaces
191
+
192
+ An element with tag `svg` and its descendants are created in the SVG
193
+ namespace. Re-entering HTML through `foreignObject` is not supported in
194
+ 0.1.
195
+
196
+ ### 5.5 The memo marker
197
+
198
+ §5.1's fast path needs the *same object* on both sides, which a
199
+ producer that rebuilds its tree cannot always give. The `memo` prop is
200
+ the declared alternative: when two **same-node** element vnodes (§5.2 —
201
+ equal tag, equal `key`) both carry a `memo` prop and the two values are
202
+ equal by `Object.is`, a patching renderer MUST skip the subtree —
203
+ no prop diff, no descent — exactly as if the vnodes were reference
204
+ equal.
205
+
206
+ - The equality is `Object.is` over the raw prop values — producers use
207
+ version counters, revision strings, or any stable token; an object
208
+ identity works exactly like §5.1.
209
+ - `memo` is a **producer-owned correctness assertion**, `key`'s
210
+ sibling: equal markers on same-identity nodes promise the subtrees
211
+ render identically. A violated promise means stale output — the same
212
+ class of producer error as a duplicate key. An absent `memo` on
213
+ either side means the ordinary diff runs; `undefined` never matches.
214
+ - The skip shares §5.1's soundness condition in the reference
215
+ renderer: while any widget is poisoned (§7.3) the marker is ignored,
216
+ so a memo-stable subtree can never leave a poisoned widget inert.
217
+ - `memo` is never rendered and never serialized (§3, §6).
218
+
219
+ ## 6. Serialization
220
+
221
+ `renderToString(vnode)` MUST produce markup equivalent to what a
222
+ patching renderer would build: text and attribute values escaped (`&`,
223
+ `<`, `>` in text; `&`, `"` in double-quoted attributes), void elements
224
+ (`br`, `img`, `input`, ...) without end tags, `key` and `on` producing
225
+ no output, boolean and style props serialized per §3. Serialization is
226
+ pure: no state, no DOM, safe in any runtime.
227
+
228
+ Hydration in 0.1 is a client-side first render into the same container
229
+ (empty and rebuild). Adopting existing server-rendered DOM is a
230
+ roadmap item, not part of this contract.
231
+
232
+ ## 7. Widgets
233
+
234
+ The escape hatch for irreducibly imperative islands — virtualized
235
+ grids, canvas, maps, third-party controls — mirroring the effect
236
+ registry of APP-FORMAT: JavaScript enters only at named, registered
237
+ boundaries. The format's side of the contract stays data; the
238
+ imperative side is a **registered widget**. The environment owns state
239
+ and orchestration; the widget owns its DOM.
240
+
241
+ ### 7.1 The widget vnode
242
+
243
+ A widget node is an element vnode with the **reserved tag
244
+ `"jaren-widget"`**:
245
+
246
+ ```json
247
+ ["jaren-widget", {
248
+ "name": "virtual-grid",
249
+ "props": { "rows": "$.visibleRows", "rowHeight": 28 },
250
+ "tag": "div",
251
+ "key": "grid",
252
+ "class": "grid-host"
253
+ }]
254
+ ```
255
+
256
+ - **`name`** (REQUIRED) — the registered widget name, a non-empty
257
+ string. An unregistered name is a configuration/producer error; the
258
+ reference renderer throws.
259
+ - **`props`** (OPTIONAL) — the widget's JSON props, any JSON value;
260
+ `null` when absent. Compared **by reference** across renders — the
261
+ widget-level reading of §5.1's sharing contract: with the JSLT `memo`
262
+ option, unchanged state yields reference-equal props, so an untouched
263
+ widget is never poked.
264
+ - **`tag`** (OPTIONAL) — the host element's tag, default `"div"`
265
+ (producers inside an `svg` subtree pick an SVG container like `"g"`).
266
+ - Every other prop (`key`, `class`, `style`, `on`, `id`, ...) applies
267
+ to the **host element** exactly as on any element vnode (§3, §4).
268
+ - A widget node MUST NOT have vnode children — the widget owns the
269
+ host's subtree and a patching renderer never descends into it.
270
+ Children present is a producer error; the reference renderer throws.
271
+
272
+ **Why this tag** (non-normative, load-bearing): `$widget` is impossible
273
+ — in query/JSLT rule bodies a string leaf starting with `$` is a path
274
+ expression (QUERY-FORMAT §3.2, `JQ0004`), so every view stylesheet
275
+ would need `$$widget` escapes. `jaren-widget` is a valid custom-element
276
+ name, so a renderer that predates this section degrades to an inert,
277
+ harmless element; and the dash makes collision with real HTML tags
278
+ impossible.
279
+
280
+ ### 7.2 The widget definition
281
+
282
+ A widget is registered JavaScript with this shape:
283
+
284
+ ```js
285
+ {
286
+ mount(host, props, emit), // REQUIRED → returns a handle
287
+ update(handle, props, prevProps),// OPTIONAL
288
+ unmount(handle), // OPTIONAL cleanup
289
+ ssr(props), // OPTIONAL → a vnode for serialization
290
+ }
291
+ ```
292
+
293
+ - **`mount`** MUST be called with the host element **after** it is
294
+ connected to the rendered tree (grids measure layout at mount). It
295
+ returns an opaque handle threaded to `update`/`unmount`.
296
+ - **`update`** is called when the vnode's `props` **reference**
297
+ changed. When `update` is absent and props changed, the renderer
298
+ MUST fall back to `unmount` + a fresh `mount` into the same host.
299
+ - **`unmount`** MUST be called exactly once when the widget leaves the
300
+ tree — including when an *ancestor* subtree is removed or replaced.
301
+ This is where timers, listeners and observers die.
302
+ - **`emit(binding, nativeEvent?)`** — the function `mount` receives. It
303
+ delivers `(binding, event)` **verbatim** to the environment's event
304
+ hook, the identical contract to §4. A widget never invents its own
305
+ action vocabulary: bindings arrive in its `props` (authored by the
306
+ view stylesheet), and a widget that must attach runtime data (the
307
+ clicked row id, the visible range) composes the binding it was given
308
+ with that data — in `@jarenjs/app` terms, merges it into `with`. The
309
+ single point of binding interpretation stays the layer above.
310
+
311
+ ### 7.3 Registration and reconciliation
312
+
313
+ Widgets are registered on the renderer:
314
+ `createDomRenderer(container, { onEvent, widgets, document })`, with
315
+ `widgets` a `Record<string, WidgetDef>`; `renderToString` takes an
316
+ OPTIONAL `{ widgets }` (§7.4). Reconciliation semantics a patching
317
+ renderer MUST honor:
318
+
319
+ - **Create** — the host element is created from `tag`, the host props
320
+ are applied (`name`/`props`/`tag` configure the widget and never
321
+ reach the DOM; `on` wires exactly as in §4), and the mount is
322
+ deferred: mounts run **after the patch completes**, in document
323
+ order, when every host is connected. A mount MAY dispatch through
324
+ `emit` synchronously; environments batch as usual, and the render
325
+ boundary itself is serialized (§7.3.2).
326
+ - **Patch in place** — two widget vnodes with equal `key`: a changed
327
+ `name` or host `tag` is a replace (destroy the old widget, create
328
+ and mount the new one in a fresh host). Otherwise the host props
329
+ diff normally, the renderer never touches the host's `childNodes`,
330
+ and a changed `props` reference reaches the widget through `update`
331
+ (or the unmount/remount fallback of §7.2). A reference-equal `props`
332
+ MUST NOT call into the widget at all.
333
+ - **Destroy** — when a subtree containing widgets is removed or
334
+ replaced, the renderer MUST call each widget's `unmount(handle)`
335
+ exactly once, without descending into any widget's host subtree (the
336
+ widget's own DOM may contain anything). The observable contract is
337
+ **ownership-exactness**: cleanup MUST be independent of speculative
338
+ or uncommitted desired-vnode state and exact for every resource the
339
+ renderer actually acquired — a vnode is a description that may be
340
+ old, new, or (after a mid-pass `destroy()`) only partially
341
+ committed, so it cannot be the authority. The reference
342
+ implementation walks the live DOM by its widget-host markers; a live
343
+ acquisition registry or a precise partial-commit model conforms
344
+ equally, provided the exactness holds. A throwing `unmount` MUST NOT
345
+ prevent sibling widgets from unmounting: the walk finishes, then the
346
+ first error surfaces. The walk snapshots each renderer-owned child
347
+ list before invoking hooks, so an `unmount` that detaches its own
348
+ host cannot shift a sibling out of the cleanup — though detaching,
349
+ replacing or reparenting the renderer-owned host is OUTSIDE the
350
+ widget's boundary (the widget owns the host's *subtree*; the host
351
+ element and its position belong to the renderer), and hosts SHOULD
352
+ NOT rely on it beyond this cleanup hardening.
353
+ - **Hook failure** — a throwing `mount` or `update` **poisons** the
354
+ widget: sibling widgets and the frame still complete, the first
355
+ error surfaces after the frame settles, and the NEXT render MUST
356
+ replace it with a fresh lifecycle rather than keep patching it.
357
+ Recovery MUST NOT depend on vnode reference identity: a producer
358
+ that reuses the failed vnode (or a reference-equal ancestor — the
359
+ JSLT memo does exactly this for unaffected subtrees) still recovers,
360
+ so a conforming renderer suspends its `===` subtree fast path while
361
+ any widget is poisoned. A poisoned widget never receives further
362
+ `update` calls; `unmount` runs on the old instance only when its
363
+ `mount` had succeeded (a mount that threw acquired nothing). A
364
+ half-mounted handle receiving updates, or a widget left permanently
365
+ inert by structural sharing, is non-conforming.
366
+
367
+ ### 7.3.1 Renderer destroy
368
+
369
+ `createDomRenderer` returns a render function carrying a
370
+ **`destroy()`** member — the terminal teardown:
371
+
372
+ - every mounted widget in the rendered tree unmounts **exactly once**;
373
+ widgets still queued for mount never mount;
374
+ - the container is left **empty** (`textContent = ''`);
375
+ - later `render` calls are exact no-ops (a scheduled flush racing a
376
+ teardown cannot resurrect the DOM);
377
+ - `destroy()` is idempotent;
378
+ - a throwing widget `unmount` MUST NOT stop the teardown: the walk
379
+ completes, then the first error surfaces — the same isolation rule
380
+ as §7.3's destroy walk.
381
+
382
+ A host that merely wants a widget-free tree still renders a widget-free
383
+ frame; `destroy()` is for ending the renderer's life (`app.destroy()`
384
+ calls it). Thrown-value policy: the renderer never interprets what a
385
+ widget hook throws — the first thrown value of a frame or teardown
386
+ surfaces BY IDENTITY, whatever it is (`null` and `undefined`
387
+ included; parked failures are presence records, so no thrown value
388
+ can read as "nothing was thrown"). The `onFrame` option reports frame
389
+ settlement (`'live'` or `'destroyed'`) once per top-level render pass,
390
+ BEFORE a parked hook error is delivered — the channel a host uses to
391
+ run committed-frame work (the app loop's `afterRender`) without being
392
+ starved by error delivery. Dual-failure settlement: the parked hook
393
+ failure is copied and cleared BEFORE the frame callback runs, so a
394
+ throwing callback can neither hide it nor push it into a later frame;
395
+ the callback is isolated, and when both fail the parked hook failure
396
+ is primary — a host that must not lose its own callback failure
397
+ isolates that callback itself, exactly as `createApp` does. EVERY
398
+ failure of one frame stays observable regardless of how many walks or
399
+ hooks produced it: the first surfaces by identity, and from the second
400
+ on the frame delivers one framework-owned `AggregateError` over the
401
+ originals in occurrence order — separate sibling removals included; a
402
+ host-thrown value (an `AggregateError` of the host's own included) is
403
+ stored untouched as one element, never inspected. Capability
404
+ ACQUISITION is part of every widget boundary: reading `update`,
405
+ `unmount` or `ssr` off a definition executes host code when the
406
+ definition uses accessors or proxies, so the read shares the boundary
407
+ and policy of the call — a hostile `update` lookup poisons like a
408
+ throwing update, a hostile `unmount` lookup collects like a throwing
409
+ unmount (siblings unmount, the container empties), and a hostile `ssr`
410
+ lookup propagates from `renderToString` exactly like a throwing
411
+ `ssr()` (serialization is pure and offers no isolation). A `destroy()` entered from inside a widget
412
+ hook or nested render is still terminal: the active pass stops **immediately** — no
413
+ later sibling observes another `mount` or `update` in that frame, and
414
+ the no-`update` recycle fallback MUST NOT begin its fresh `mount` when
415
+ its `unmount` requested the destroy (the ended acquisition is recorded
416
+ so teardown does not unmount it a second time), and the fallback's
417
+ failure handling is PHASE-SENSITIVE: a failure before the old
418
+ instance's `unmount` had its exactly-once chance (a hostile `unmount`
419
+ lookup) leaves the old acquisition OWNED — the widget poisons with
420
+ update-failure semantics so replacement or terminal destroy still
421
+ releases the resource; only a failure after the teardown attempt takes
422
+ mount-failure semantics (nothing is left to release) — and the teardown runs
423
+ as the pass unwinds. Because teardown drains the renderer's live
424
+ resources rather than pairing a vnode against the DOM, it is exact
425
+ even when the aborted pass had structurally diverged from the
426
+ committed tree (insertions, removals, replacements or keyed moves
427
+ before or after the destroying widget): every successfully mounted
428
+ instance — a mount hook that itself requested the destroy included —
429
+ unmounts exactly once, pending mounts are canceled, the container ends
430
+ empty even when an unmount throws, and no internal traversal error is
431
+ ever produced. Terminal-cleanup errors carry provenance: a host may
432
+ register `onCleanupError` to receive the first unmount error after
433
+ every sibling cleaned up (the app loop uses this to assign its stable
434
+ cleanup code), instead of having it surface indistinguishably from a
435
+ render failure.
436
+
437
+ ### 7.3.2 Render serialization
438
+
439
+ The public render boundary MUST NOT re-enter itself. A `render` call
440
+ made synchronously from inside a widget `mount`/`update` or an event
441
+ callback (an `emit` chain) queues behind the running patch instead of
442
+ nesting; multiple nested requests **coalesce to the latest vnode**
443
+ (every call carries the full desired tree, so intermediate trees are
444
+ redundant); the queued tree applies after the current frame, against
445
+ the committed baseline. Consequences a conforming renderer exhibits:
446
+
447
+ - no `update` is delivered before the widget's `mount` has returned
448
+ its handle;
449
+ - every `update` receives the last committed props as its previous
450
+ props, never a stale outer value;
451
+ - the app-integrated path (`createApp`'s FIFO transaction queue) and
452
+ the direct renderer path observe the same ordering discipline: the
453
+ app queue serializes *dispatches*, the renderer serializes *frames*.
454
+
455
+ ### 7.4 Serialization
456
+
457
+ `renderToString(vnode, { widgets })`: a widget node serializes its
458
+ host element with the host props (widget-local members excluded),
459
+ containing the serialized `ssr(props)` vnode when the widget is
460
+ registered and has `ssr`, else empty content. Serialization stays pure
461
+ — no state, no DOM, no widget is mounted; the client-side first render
462
+ mounts widgets as usual (§6).
463
+
464
+ ## 8. The safe profile
465
+
466
+ The default renderers **trust** their input (§1.1): every prop writes
467
+ through, so a producer that owns its stylesheet can reach `innerHTML`, an
468
+ `on*` handler or any DOM property, exactly as it could writing the DOM by
469
+ hand. A vnode from a source you do **not** control — a tenant, a remote
470
+ service, a model — is different, and schema validity does not close the
471
+ gap. A renderer therefore MUST offer a **safe mode** for untrusted
472
+ documents, selected by a `safe` option on both the patching and
473
+ serializing renderers.
474
+
475
+ Under the safe profile a renderer MUST:
476
+
477
+ 1. **Restrict tags to an allow-list** of inert HTML and SVG elements, and
478
+ MUST drop any element whose tag is not on it — including any tag whose
479
+ name is not a bare identifier (`^[A-Za-z][A-Za-z0-9-]*$`), which closes
480
+ structural injection through the tag. `script`, `iframe`, `object`,
481
+ `embed`, `style`, `link`, `meta`, `base`, `foreignObject` and other
482
+ document-embedding or script/style-carrying elements MUST NOT be on the
483
+ allow-list.
484
+ 2. **Reject a property whose name is not a bare identifier** (closing
485
+ attribute-name injection), **whose name begins with `on`** (no inline
486
+ handlers), **which is an HTML-parsing sink** (`innerHTML`, `outerHTML`,
487
+ `srcdoc`, `insertAdjacentHTML`, `dangerouslySetInnerHTML`), or **which is
488
+ `is`** (it upgrades an element to a registered customized built-in when the
489
+ markup is parsed, running host code).
490
+ 3. **Sanitize URL attributes** (`href`, `src`, `action`, ...) through a
491
+ deny-list that rejects `javascript:`, `vbscript:`, `file:` and
492
+ document-carrying `data:`, and **drop inline styles** carrying
493
+ `expression(` or a script-scheme `url()`.
494
+ 4. **Strip `on` bindings and `jaren-widget` nodes**: an untrusted document
495
+ MUST NOT bind host actions or mount imperative code. Safe-mode views are
496
+ display-oriented.
497
+
498
+ A dropped element serializes to nothing and patches to an empty node; the
499
+ patching and serializing renderers MUST make the **same** decision for a
500
+ given node, so a document renders to the same safe result on the client
501
+ and the server. The reference implementation is one shared policy
502
+ (`createSafePolicy`) that both renderers call.
503
+
504
+ The companion schema
505
+ [`schemas/jaren-vnode-safe.schema.json`](../schemas/jaren-vnode-safe.schema.json)
506
+ expresses the structural half — the tag and property-name constraints —
507
+ as a validation-time gate. It cannot inspect a URL or style **value**, so
508
+ the runtime policy is authoritative; a host validating untrusted input
509
+ SHOULD do both.
510
+
511
+ Safe mode reduces an untrusted view to a display; it is **not a complete
512
+ sandbox**. The tag allow-list still admits anchors, forms, controls and media,
513
+ so native navigation, form submission, focus and network loads remain
514
+ possible. It is also a **renderer** policy: a host embedding it in a larger
515
+ runtime MUST NOT assume that runtime inherits it (`@jarenjs/app`, for
516
+ instance, does not forward `safe`, and an app document names host actions and
517
+ effects — so a safe *view* does not make an untrusted *app document* safe).
518
+
519
+ Out of scope for this version, and NOT to be assumed: caret/IME fidelity under
520
+ safe rewrites across browser engines, MathML, `multiple`-select and
521
+ composition behavior proven in all three engines, and a Trusted Types
522
+ integration. Safe mode is one layer under a Content-Security-Policy, not a
523
+ substitute for one.
524
+
525
+ ## 9. Open items (roadmap, non-normative)
526
+
527
+ - **Fragment / multi-root documents** — a list at the root.
528
+ - **DOM-adopting hydration** (§6).
529
+ - ~~Memoized rule outputs~~ — **shipped**: the JSLT engine's `memo`
530
+ option (on by default in `@jarenjs/app`) caches rule outputs by
531
+ (location, value reference) with compile-time eligibility analysis,
532
+ so unchanged *state* yields reference-equal *vnodes* across frames
533
+ and §5.1 fires for whole branches.
534
+ - ~~Component escape hatch~~ — **shipped**: the registered-widget
535
+ vocabulary of §7.
536
+ - ~~A renderer `destroy()`~~ — **shipped**: §7.3.1.
537
+ - **A `properties`-vs-`attributes` normative table** replacing the
538
+ `name in node` heuristic of §3.
package/package.json ADDED
@@ -0,0 +1,63 @@
1
+ {
2
+ "name": "@jarenjs/view",
3
+ "private": false,
4
+ "version": "0.34.0",
5
+ "type": "module",
6
+ "main": "./src/index.js",
7
+ "types": "./dist/types/index.d.ts",
8
+ "sideEffects": false,
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/types/index.d.ts",
12
+ "default": "./src/index.js"
13
+ },
14
+ "./helpers": {
15
+ "types": "./dist/types/helpers/index.d.ts",
16
+ "default": "./src/helpers/index.js"
17
+ },
18
+ "./helpers/*": {
19
+ "types": "./dist/types/helpers/*.d.ts",
20
+ "default": "./src/helpers/*.js"
21
+ },
22
+ "./schemas/*": "./schemas/*",
23
+ "./package.json": "./package.json"
24
+ },
25
+ "files": [
26
+ "dist/types/",
27
+ "src/",
28
+ "docs/",
29
+ "schemas/"
30
+ ],
31
+ "description": "The Jaren vnode format: user interfaces as JSON documents, with a keyed DOM patcher and an SSR string renderer",
32
+ "author": "joham",
33
+ "repository": {
34
+ "type": "git",
35
+ "url": "git+https://github.com/jklarenbeek/jarenjs.git",
36
+ "directory": "packages/view"
37
+ },
38
+ "license": "MIT",
39
+ "engines": {
40
+ "node": ">=24"
41
+ },
42
+ "publishConfig": {
43
+ "access": "public",
44
+ "registry": "https://registry.npmjs.org/"
45
+ },
46
+ "keywords": [
47
+ "jaren",
48
+ "json",
49
+ "vnode",
50
+ "vdom",
51
+ "view",
52
+ "renderer",
53
+ "ssr"
54
+ ],
55
+ "dependencies": {
56
+ "@jarenjs/core": "^0.34.0"
57
+ },
58
+ "scripts": {
59
+ "build": "npm run build:types",
60
+ "build:types": "tsc -p tsconfig.json",
61
+ "prepack": "npm run build:types"
62
+ }
63
+ }