@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.
package/README.md ADDED
@@ -0,0 +1,181 @@
1
+ # @jarenjs/view
2
+
3
+ User interfaces as JSON documents. This package defines the **Jaren vnode format** — the output vocabulary a view produces the way XSLT produces HTML — and ships the two renderers that consume it:
4
+
5
+ - a **keyed DOM patcher** (`createDomRenderer`): diff the previous vnode against the next, touch only what changed, reuse keyed nodes across reorders;
6
+ - an **SSR string renderer** (`renderToString`): the same JSON to an HTML string, no DOM required.
7
+
8
+ It is the only package in the Jaren suite that touches the DOM, and it knows nothing about schemas, queries or state — a vnode is plain JSON, wherever it came from. In practice it comes from a [JSLT stylesheet](../json/docs/JSLT-FORMAT.md) compiled by [`@jarenjs/json`](../json), and the loop around it lives in [`@jarenjs/app`](../app). Its only runtime dependency is the pure, zero-dependency [`@jarenjs/core`](../core); no `eval`, CSP-safe.
9
+
10
+ The vnode grammar is published as JSON Schema in [`schemas/jaren-vnode.schema.json`](schemas/jaren-vnode.schema.json) — hand it to a constrained decoder and a language model cannot emit a document outside the *grammar*. That is a **structural** guarantee, not a safety one: a grammar-valid vnode can still carry `innerHTML`, an inline `on*` handler or a `javascript:` URL, so validation alone does **not** make an untrusted document safe to render. Rendering an untrusted view — from a tenant, a remote service, a model — requires the [safe profile](#untrusted-views-the-safe-profile) below; the default renderers trust their input, exactly like writing the DOM by hand. The normative contract is [docs/VIEW-FORMAT.md](docs/VIEW-FORMAT.md).
11
+
12
+ ## The format in one glance
13
+
14
+ ```json
15
+ ["main", {},
16
+ ["h1", { "class": "title" }, "Todos"],
17
+ ["ul", {},
18
+ [["li", { "key": 1, "on": { "click": { "action": "toggle", "with": 1 } } }, "Buy milk"],
19
+ ["li", { "key": 2 }, "Ship it"]]
20
+ ],
21
+ ["input", { "value": "", "on": { "input": "type" } }]
22
+ ]
23
+ ```
24
+
25
+ - **text** — a string or number renders as text;
26
+ - **element** — an array whose first item is a string tag: `[tag, props?, ...children]`;
27
+ - **list** — an array whose first item is *not* a string is spliced into its parent's children (exactly the shape a JSLT `[{"$apply": "$.todos[*]"}]` body produces);
28
+ - **skipped** — `null` and booleans render nothing, so `{"$if": ...}` conditions compose without wrapper nodes.
29
+
30
+ Four props are special: `key` (reconciliation identity), `on` (event bindings — opaque JSON handed to the renderer's `onEvent` hook, never functions), `memo` (a subtree-stability marker — see the performance contract), and `style` (string or object). Everything else writes through to the DOM as a property when the node has one, as an attribute otherwise; `true` renders a bare attribute, `false`/`null` remove it.
31
+
32
+ ## Usage
33
+
34
+ ### In the browser
35
+
36
+ ```javascript
37
+ import { createDomRenderer } from '@jarenjs/view';
38
+
39
+ const render = createDomRenderer(document.getElementById('app'), {
40
+ onEvent: (binding, event) => {
41
+ // `binding` is the vnode's `on` JSON, verbatim — dispatch it yourself,
42
+ // or let @jarenjs/app do this wiring for you
43
+ },
44
+ });
45
+
46
+ render(['h1', {}, 'Hello']);
47
+ render(['h1', {}, 'Goodbye']); // patches the text node in place
48
+ render.destroy(); // terminal teardown, idempotent
49
+ ```
50
+
51
+ `render.destroy()` unmounts every mounted widget exactly once (pending mounts are canceled), empties the container, and turns every later `render` call into an exact no-op — including a scheduled flush that fires after destruction. The render boundary is **serialized**: a `render` entered synchronously from inside a widget hook or event callback (an `emit` chain) never nests — it queues behind the running patch, multiple nested requests coalesce to the latest vnode, and the queued tree is applied against the committed baseline, so no widget sees `update` before its `mount` returned or receives stale previous props.
52
+
53
+ ### On the server
54
+
55
+ ```javascript
56
+ import { renderToString } from '@jarenjs/view';
57
+
58
+ renderToString(['p', { class: 'note' }, 'a < b'])
59
+ // '<p class="note">a &lt; b</p>'
60
+ ```
61
+
62
+ Text and attribute values are escaped, void elements render without end tags, `key` and `on` produce no markup. Hydration is a client-side first render into the same container (the container is emptied and rebuilt — DOM adoption is on the roadmap).
63
+
64
+ ### With JavaScript in the middle
65
+
66
+ `h(tag, props, ...children)` builds the same JSON a stylesheet would, for hand-written views and tests. The shape helpers (`isTextNode`, `isElementNode`, `propsOf`, `keyOf`, `childrenOf`, `isSameNode`) are exported for anyone building another renderer over the format.
67
+
68
+ ### Widgets — the imperative escape hatch
69
+
70
+ Some islands are irreducibly imperative: a virtualized grid, a canvas, a map, a third-party control. The reserved tag `jaren-widget` mounts a **registered widget** into a host element the patcher owns but never descends into ([VIEW-FORMAT §7](docs/VIEW-FORMAT.md)):
71
+
72
+ ```json
73
+ ["jaren-widget", {
74
+ "name": "virtual-list", "key": "list", "class": "viewport",
75
+ "props": { "rows": "$.rows", "rowHeight": 28 }
76
+ }]
77
+ ```
78
+
79
+ ```javascript
80
+ const render = createDomRenderer(container, {
81
+ onEvent: (binding, event) => dispatch(binding, event),
82
+ widgets: {
83
+ 'virtual-list': {
84
+ mount(host, props, emit) { /* build DOM, measure, listen */ return state; },
85
+ update(handle, props, prevProps) { /* re-window the visible rows */ },
86
+ unmount(handle) { /* timers, listeners and observers die here */ },
87
+ ssr: (props) => ['ul', { class: 'list' }], // declarative fallback
88
+ },
89
+ },
90
+ });
91
+ ```
92
+
93
+ `name`/`props`/`tag` configure the widget (the host tag defaults to `div`); every other prop — `key`, `class`, `on`, ... — applies to the host element as usual, and the widget node has no vnode children (the widget owns the host's subtree). `props` is compared **by reference**: with the JSLT memo option, unchanged state yields reference-equal props, so an untouched widget is never called. `mount` runs after the host is connected (grids can measure) and returns a handle threaded to `update`/`unmount`; `unmount` runs exactly once when the widget leaves the tree, even when an ancestor subtree is replaced. A throwing `mount` or `update` **poisons** the widget instead of corrupting it: siblings and the frame still complete, the first error surfaces after the frame settles, and the next render that revisits the widget replaces it with a fresh lifecycle (`unmount` runs on the old instance only when its `mount` had succeeded). `emit(binding, event)` delivers ordinary event bindings to `onEvent` — a widget composes runtime data (the clicked row id) into the binding its props carry instead of inventing an action vocabulary. `renderToString(vnode, { widgets })` serializes the host around the widget's `ssr(props)` vnode — still pure, nothing mounts.
94
+
95
+ ## Untrusted views: the safe profile
96
+
97
+ The default renderers **trust** their input. A vnode is plain JSON, and the
98
+ default `createDomRenderer`/`renderToString` write whatever it says — including
99
+ `innerHTML`, inline `on*` handlers and `javascript:` URLs. For a
100
+ source-authored view that is exactly right; it is the equivalent of writing the
101
+ DOM by hand, and it is why the grammar's guarantee is *structural*, not a
102
+ sanitizer. Validating a document against the vnode schema proves it is
103
+ well-formed, **not** that it is safe to render.
104
+
105
+ When a view arrives from somewhere you do not control — a tenant, a remote
106
+ service, a language model — pass `{ safe: true }`:
107
+
108
+ ```js
109
+ renderToString(untrustedVnode, { safe: true });
110
+ createDomRenderer(container, { safe: true, onUnsafe: (i) => log(i) });
111
+ ```
112
+
113
+ Both build the **same** policy ([`createSafePolicy`](src/safe.js)), so the
114
+ client and the server neutralize an attack identically. In safe mode:
115
+
116
+ - **tags** are restricted to an allow-list of inert HTML and SVG elements —
117
+ `script`, `iframe`, `object`, `style`, `link`, `foreignObject` and the rest
118
+ are dropped, and so is any tag whose *name* is not a bare identifier (which
119
+ closes structural injection through a tag like `div><img …`);
120
+ - **property names** must be bare identifiers too (closing attribute-name
121
+ injection), may not begin with `on`, may not be an HTML-parsing sink
122
+ (`innerHTML`, `outerHTML`, `srcdoc`, …), and may not be `is` (which upgrades
123
+ an element to a registered customized built-in on parse);
124
+ - **URL attributes** (`href`, `src`, …) are filtered through the `sanitizeUrl`
125
+ deny-list; **inline styles** carrying `expression(` or a script-scheme
126
+ `url()` are dropped;
127
+ - **`on` event bindings and `jaren-widget` nodes are stripped** — an untrusted
128
+ document must not bind the host's actions or mount imperative JavaScript, so
129
+ safe-mode views are display-oriented.
130
+
131
+ A companion schema,
132
+ [`schemas/jaren-vnode-safe.schema.json`](schemas/jaren-vnode-safe.schema.json),
133
+ gates the structural half (tag and property *names*) at validation time as
134
+ defense in depth. It cannot read a `javascript:` scheme out of a string, so
135
+ the **runtime policy is authoritative**: validate with the safe schema *and*
136
+ render with `{ safe: true }`.
137
+
138
+ **What safe mode is, and is not.** It reduces the attack surface of an
139
+ untrusted *view* to a display: it strips script, raw-HTML sinks, inline and
140
+ `on` handlers, widgets, and unsafe URLs, and the client and server strip
141
+ identically. It is **not a complete sandbox** — the allow-list still includes
142
+ anchors, forms, controls and media, so native navigation, form submission,
143
+ focus and network loads remain possible; treat safe mode as one layer under a
144
+ Content-Security-Policy, not a replacement for one. It also applies to the
145
+ **renderer only**: [`@jarenjs/app`](../app) does not pass `safe` through, and
146
+ an app document names host actions, effects and subscriptions, so a safe
147
+ *view* does not make an untrusted *app document* safe — run only self-authored
148
+ app documents. Still open, and tracked in the roadmap: caret/IME fidelity
149
+ across engines, `multiple`-select and composition behavior proven in real
150
+ Chromium/Firefox/WebKit, and a Trusted Types integration.
151
+
152
+ ### Shared view helpers — `@jarenjs/view/helpers`
153
+
154
+ The `./helpers` subpath is the single home for the small view-layer helper kernel that the suite's visual components (`@jarenjs/calc`, `@jarenjs/md`, `@jarenjs/mermaid`) share, so no component re-implements them:
155
+
156
+ - **SVG builders over `h()`** — `svgRoot(className, width, height, theme, children, key?)`, `group`, `rect`, `circle`, `line`, `path`, `polyline`, `polygon`, `textAt`, `textLines`, plus the geometry guard `num` and the path-string builder `polylinePath`. `svgRoot` takes the root `class` and (via `theme.cssVars`) the CSS variables from the caller.
157
+ - **`sanitizeHref(url)`** — the strict URL **allow-list** (http/https/mailto/`#`/`/`/`.`) for link hrefs from a constrained producer, such as a Mermaid `click` directive. It rejects a scheme-less relative reference like `image.png`.
158
+ - **`sanitizeUrl(url)`** — the URL **deny-list** for hrefs and image sources authored in ordinary content: it rejects only `javascript:`, `vbscript:`, `file:` and `data:` other than a raster image, and passes everything else, relative references included. This is what `@jarenjs/md` filters link destinations through. Both return the trimmed URL or `null`, and `null` means *drop the attribute*.
159
+ - **`resolveTheme(themes, prefix, nameOrOverrides?)`** — the prefix-driven mechanics behind each component's `createTheme`: a name-or-overrides object resolved to `{ name, tokens, cssVars }`, stamping every token as `--<prefix>-<kebab-case-key>`. Components keep their own token tables; the resolution logic lives here once. The pure `kebabCase` transform it uses comes from [`@jarenjs/core/string`](../core). One documented quirk (behavior preserved, test-asserted): when passed an *overrides object* that carries a `theme` key, that key is spread into the returned `tokens` and therefore surfaces as a `--<prefix>-theme` CSS variable — the base-selecting key leaks into the output rather than being stripped.
160
+
161
+ - **`createProjectionMemo({ compile, toVnode, docToVnode, memoLimit? })`** — the memo pair a component's `view()` projection is built on: an LRU-memoized `compile(source)` (string-keyed, default limit 32) and a reference-stable `view(sourceOrDoc)` that projects a source string through the compile memo or an already-parsed document through a WeakMap keyed on its identity. `@jarenjs/md` and `@jarenjs/mermaid` build their component-layer `view()` on it — reference stability is what lets an unchanged input patch in O(1).
162
+
163
+ Import the whole barrel (`@jarenjs/view/helpers`) or a single module (`@jarenjs/view/helpers/svg`, `@jarenjs/view/helpers/theme`).
164
+
165
+ ## Performance contract
166
+
167
+ The patcher's first check is `oldVnode === newVnode` — a reference-equal subtree is skipped in O(1), unexamined. This is designed to meet the JSLT engine's structural sharing and its `memo` option (an unchanged input subtree flows to the output by reference; `@jarenjs/app` compiles every view with `memo: true`), so views re-render in time proportional to what changed, not to the size of the page. Vnodes are never mutated or annotated by the renderer: frozen documents, cached documents and shared subtrees are always safe.
168
+
169
+ Measured, not claimed — run `npm run benchmark:view` for your own numbers, and the [benchmarks page](https://jarenjs.github.io/#/benchmarks?suite=view) publishes the latest full run with its date and machine. Two different costs get measured on a 1000-row table (output equality with React and preact asserted before timing), and keeping them apart is what makes the numbers readable:
170
+
171
+ - **The format.** A vnode is a tagged array — an array literal plus a plain object — and the renderer, patcher and SSR accept it from any producer. A **hand-written view building tagged arrays directly is the fastest element builder in the table: ~<!--bm:view.vsReact-->2.2<!--/bm-->× faster than React's production `createElement`**, ~<!--bm:view.vsPreact-->2.8<!--/bm-->× faster than preact's `h()`. Writing views by hand is fully supported; the stylesheet is opt-in per view.
172
+ - **The engine.** A JSLT stylesheet is a view as *data* — schema-validated, serializable, storable, and renderable from an untrusted source through the [safe profile](#untrusted-views-the-safe-profile) — and running that document through the generic dispatcher costs **~<!--bm:view.engineCost-->23.2<!--/bm-->× the hand-written build**. That is the published price of a capability none of the rivals has a mode for: a JSX view is code by construction, so there is no data-driven React number to compare against.
173
+ - **The re-render path.** An **unchanged document re-renders in O(1)** — the memoized transform returns the previous output by reference and the patcher skips it whole — and the vnode-level `memo` marker gives hand-written producers the same subtree skip (~12× over the child scan). For a one-row copy-on-write update the memo cuts the stylesheet frame ~1.5× against its own no-memo path; at this table size the hand-written view plus a full diff is still the fastest frame outright, and that is on the page too. SSR lands within ~1.6–2.8× of `preact-render-to-string` depending on the route, on byte-identical output.
174
+
175
+ When a producer cannot preserve the reference — it rebuilds its tree but knows a region did not change — the **`memo` prop** says so declaratively (VIEW-FORMAT §5.5): two same-node vnodes carrying equal `memo` values skip reconciliation exactly like reference-equal ones. It is a producer-owned assertion, `key`'s sibling: equal markers promise identical subtrees, and a violated promise means stale output. Measured on a reallocated parent over 10 000 shared children, the marker removes the whole per-child scan: about **448 µs** to walk the children looking for `===` skips versus about **41 µs** with the marker — **10.9×**, and the gap widens with the child count (`npm run benchmark:view`). `@jarenjs/charts`' streaming sessions are the reference consumer.
176
+
177
+ Children reconcile with a head/tail sweep plus a key map for the middle: keyed siblings move their real DOM nodes instead of recreating them; unkeyed siblings patch positionally. Event bindings are data stored on the node behind one shared proxy listener per event type — re-rendering rebinds by assignment, never through `addEventListener`.
178
+
179
+ ## Development
180
+
181
+ Unit tests live in `test/view/` at the repository root (`npm run test:view`), including the minimal DOM stub they run against. See the repository [README](../../README.md) for the full suite documentation and [ROADMAP](../../docs/ROADMAP.md) for planned work: fragment roots, DOM-adopting hydration, and the memoized rule-output layer that turns JSLT sharing into cross-frame skipping.
@@ -0,0 +1,226 @@
1
+ /**
2
+ * @file The DOM renderer — a keyed vnode-JSON patcher.
3
+ *
4
+ * This is the only module in the Jaren suite that touches the DOM. It
5
+ * follows the repository philosophy at the render level: decide once,
6
+ * then run tight loops.
7
+ *
8
+ * Reconciliation contract (see docs/VIEW-FORMAT.md §5):
9
+ *
10
+ * - `oldVnode === newVnode` skips the whole subtree in O(1). The JSLT
11
+ * engine's structural sharing (unchanged input → shared output) is what
12
+ * makes this fast path fire in practice.
13
+ * - Children reconcile with a head/tail two-pointer sweep plus a key map
14
+ * for the middle — keyed moves reuse DOM nodes, unkeyed children patch
15
+ * positionally.
16
+ * - Vnodes are never mutated or annotated: the DOM-node ↔ vnode
17
+ * correspondence lives in parallel arrays local to each patch, so
18
+ * frozen or shared vnode JSON (a JSLT output, a cached document) is
19
+ * always safe.
20
+ * - The render boundary is serialized: a `render` entered from inside
21
+ * a widget hook or event callback (a synchronous `emit` chain) never
22
+ * nests — it queues behind the running patch, nested requests
23
+ * coalesce to the latest vnode, and it applies against the committed
24
+ * baseline. No widget sees `update` before its `mount` returned or
25
+ * receives stale previous props.
26
+ *
27
+ * Event handling stores the binding JSON on the DOM node and attaches one
28
+ * shared proxy listener per event type; rebinding a handler on re-render
29
+ * never touches `addEventListener`.
30
+ *
31
+ * Widgets (VIEW-FORMAT §7): a vnode with the reserved tag `jaren-widget`
32
+ * mounts a registered JavaScript widget into a host element the patcher
33
+ * owns but never descends into. Widget bookkeeping lives on the DOM node
34
+ * (`__jarenWidget`), never on the vnode — §5.1 forbids annotating vnodes
35
+ * — and the destroy walk that guarantees `unmount` only runs when a
36
+ * widget has actually been created, so widget-free documents keep O(1)
37
+ * subtree removal.
38
+ */
39
+ export type EventBindingHandler = (binding: any, event: any) => void;
40
+ export type WidgetEmit = (binding: any, nativeEvent?: any) => void;
41
+ export type WidgetDef = {
42
+ /**
43
+ * -
44
+ * Called with the host element after it is connected to the rendered
45
+ * tree; returns an opaque handle threaded to `update`/`unmount`.
46
+ */
47
+ mount: (host: any, props: any, emit: WidgetEmit) => any;
48
+ /**
49
+ * Called when the vnode's `props` reference changed. Absent: the
50
+ * renderer falls back to `unmount` + fresh `mount` into the same host.
51
+ */
52
+ update?: (handle: any, props: any, prevProps: any) => void;
53
+ /**
54
+ * - Called exactly once when
55
+ * the widget leaves the tree; timers, listeners and observers die here.
56
+ */
57
+ unmount?: (handle: any) => void;
58
+ /**
59
+ * - A vnode for `renderToString`.
60
+ */
61
+ ssr?: (props: any) => any;
62
+ };
63
+ export type DomRendererOptions = {
64
+ /**
65
+ * - Receives every fired `on`
66
+ * binding; without it, `on` props are stored but never fired.
67
+ */
68
+ onEvent?: EventBindingHandler;
69
+ /**
70
+ * - Registered widget
71
+ * definitions by name (VIEW-FORMAT §7).
72
+ */
73
+ widgets?: Record<string, WidgetDef>;
74
+ /**
75
+ * - The document to create nodes with
76
+ * (defaults to `container.ownerDocument`).
77
+ */
78
+ document?: any;
79
+ /**
80
+ * - Render under the SAFE policy
81
+ * ({@link createSafePolicy}): treat the vnode as untrusted. Tags are
82
+ * restricted to an inert HTML/SVG allow-list, scripting-sink and inline
83
+ * `on*` properties are dropped, injection-shaped names are rejected, URL
84
+ * attributes are sanitized, and `on` event bindings are stripped. The
85
+ * default is trusted rendering — the equivalent of writing the DOM by
86
+ * hand — so a source-authored view is unaffected. Client and server share
87
+ * the one policy, so they neutralize an attack identically.
88
+ */
89
+ safe?: boolean;
90
+ /**
91
+ * - In safe mode, called for everything stripped: a disallowed
92
+ * `tag`, a rejected or sanitized-away `prop`, a stripped `on` binding
93
+ * (`event`) or a `widget`.
94
+ */
95
+ onUnsafe?: (info: {
96
+ kind: 'tag' | 'prop' | 'event' | 'widget';
97
+ name: string;
98
+ }) => void;
99
+ /**
100
+ * - Receives
101
+ * the first VALUE a widget `unmount` threw during TERMINAL teardown
102
+ * (`destroy()`, direct or deferred) — by identity, whatever host
103
+ * code threw — after every sibling cleaned up: the provenance
104
+ * channel that lets a host assign cleanup failures their own error
105
+ * policy, distinct from mount/update/render failures. Absent: the
106
+ * value surfaces after the teardown (thrown from `destroy()` or
107
+ * from the render pass that finished a deferred teardown).
108
+ */
109
+ onCleanupError?: (thrown: unknown) => void;
110
+ /**
111
+ * - Called
112
+ * once at the end of every top-level render pass: `'live'` = a
113
+ * committed live frame settled (DOM patch and widget mounts done; a
114
+ * parked widget hook error, if any, is delivered AFTER this call),
115
+ * `'destroyed'` = the pass ended in terminal teardown. Not called
116
+ * for a post-destroy no-op render or by `destroy()` itself.
117
+ */
118
+ onFrame?: (state: 'live' | 'destroyed') => void;
119
+ };
120
+ export type DomRenderer = ((vnode: any) => void) & {
121
+ destroy: () => void;
122
+ };
123
+ /**
124
+ * @callback EventBindingHandler
125
+ * @param {any} binding - The opaque `on` binding JSON from the vnode.
126
+ * @param {any} event - The native DOM event.
127
+ * @returns {void}
128
+ */
129
+ /**
130
+ * @callback WidgetEmit
131
+ * @param {any} binding - An opaque binding, delivered verbatim to the
132
+ * renderer's `onEvent` hook — the identical contract to a vnode `on`
133
+ * member (VIEW-FORMAT §4).
134
+ * @param {any} [nativeEvent] - The native event, when the emission was
135
+ * caused by one.
136
+ * @returns {void}
137
+ */
138
+ /**
139
+ * A registered widget definition (VIEW-FORMAT §7). The renderer owns the
140
+ * host element and the widget owns the host's subtree.
141
+ *
142
+ * Failure policy: a throwing `mount` or `update` poisons the widget —
143
+ * siblings and the frame still complete, the first error surfaces after
144
+ * the frame settles, and the NEXT render replaces it with a fresh
145
+ * lifecycle (`unmount` runs on the old instance only when its `mount`
146
+ * had succeeded). Recovery never depends on the producer allocating a
147
+ * fresh vnode: while any widget is poisoned the `===` subtree fast
148
+ * path is suspended, so a memoized reference-equal tree still reaches
149
+ * and replaces it. A poisoned widget never receives further `update`
150
+ * calls.
151
+ * @typedef {Object} WidgetDef
152
+ * @property {(host: any, props: any, emit: WidgetEmit) => any} mount -
153
+ * Called with the host element after it is connected to the rendered
154
+ * tree; returns an opaque handle threaded to `update`/`unmount`.
155
+ * @property {(handle: any, props: any, prevProps: any) => void} [update]
156
+ * Called when the vnode's `props` reference changed. Absent: the
157
+ * renderer falls back to `unmount` + fresh `mount` into the same host.
158
+ * @property {(handle: any) => void} [unmount] - Called exactly once when
159
+ * the widget leaves the tree; timers, listeners and observers die here.
160
+ * @property {(props: any) => any} [ssr] - A vnode for `renderToString`.
161
+ */
162
+ /**
163
+ * @typedef {Object} DomRendererOptions
164
+ * @property {EventBindingHandler} [onEvent] - Receives every fired `on`
165
+ * binding; without it, `on` props are stored but never fired.
166
+ * @property {Record<string, WidgetDef>} [widgets] - Registered widget
167
+ * definitions by name (VIEW-FORMAT §7).
168
+ * @property {any} [document] - The document to create nodes with
169
+ * (defaults to `container.ownerDocument`).
170
+ * @property {boolean} [safe=false] - Render under the SAFE policy
171
+ * ({@link createSafePolicy}): treat the vnode as untrusted. Tags are
172
+ * restricted to an inert HTML/SVG allow-list, scripting-sink and inline
173
+ * `on*` properties are dropped, injection-shaped names are rejected, URL
174
+ * attributes are sanitized, and `on` event bindings are stripped. The
175
+ * default is trusted rendering — the equivalent of writing the DOM by
176
+ * hand — so a source-authored view is unaffected. Client and server share
177
+ * the one policy, so they neutralize an attack identically.
178
+ * @property {(info: { kind: 'tag' | 'prop' | 'event' | 'widget', name: string }) => void}
179
+ * [onUnsafe] - In safe mode, called for everything stripped: a disallowed
180
+ * `tag`, a rejected or sanitized-away `prop`, a stripped `on` binding
181
+ * (`event`) or a `widget`.
182
+ * @property {(thrown: unknown) => void} [onCleanupError] - Receives
183
+ * the first VALUE a widget `unmount` threw during TERMINAL teardown
184
+ * (`destroy()`, direct or deferred) — by identity, whatever host
185
+ * code threw — after every sibling cleaned up: the provenance
186
+ * channel that lets a host assign cleanup failures their own error
187
+ * policy, distinct from mount/update/render failures. Absent: the
188
+ * value surfaces after the teardown (thrown from `destroy()` or
189
+ * from the render pass that finished a deferred teardown).
190
+ * @property {(state: 'live' | 'destroyed') => void} [onFrame] - Called
191
+ * once at the end of every top-level render pass: `'live'` = a
192
+ * committed live frame settled (DOM patch and widget mounts done; a
193
+ * parked widget hook error, if any, is delivered AFTER this call),
194
+ * `'destroyed'` = the pass ended in terminal teardown. Not called
195
+ * for a post-destroy no-op render or by `destroy()` itself.
196
+ */
197
+ /**
198
+ * The renderer returned by {@link createDomRenderer}: the patch
199
+ * function, carrying the terminal `destroy()` (VIEW-FORMAT §7.3.1).
200
+ * @typedef {((vnode: any) => void) & { destroy: () => void }} DomRenderer
201
+ */
202
+ /**
203
+ * Create a renderer bound to a container element. The returned function
204
+ * patches the container's single root node to match the given vnode;
205
+ * `render.destroy()` is the terminal teardown.
206
+ *
207
+ * @example
208
+ * const render = createDomRenderer(document.getElementById('app'), {
209
+ * onEvent: (binding, event) => dispatch(binding, event),
210
+ * });
211
+ * render(['main', {}, ['h1', {}, 'Hello']]);
212
+ * render.destroy();
213
+ *
214
+ * @param {any} container - The DOM element to render into (emptied on
215
+ * first render).
216
+ * @param {DomRendererOptions} [options]
217
+ * @returns {DomRenderer}
218
+ */
219
+ export declare function createDomRenderer(container: any, options?: DomRendererOptions): DomRenderer;
220
+ /**
221
+ * Serialize a style object to a CSS declaration string. CamelCase keys
222
+ * become kebab-case; `--custom-properties` pass through.
223
+ * @param {Record<string, any>} style
224
+ * @returns {string}
225
+ */
226
+ export declare function styleToString(style: Record<string, any>): string;
@@ -0,0 +1,15 @@
1
+ /**
2
+ * @file `@jarenjs/view/helpers` — the shared, view-layer helper kernel:
3
+ * SVG vnode builders over `h()`, the URL policies that guard what may
4
+ * reach an `href`/`src`, the prefix-driven theme resolver that
5
+ * SVG-emitting components (`@jarenjs/calc`, `@jarenjs/mermaid`, …) build
6
+ * on, and the memoized `view()` projection pair the component layers
7
+ * share. Import the whole barrel (`@jarenjs/view/helpers`) or a single
8
+ * module (`@jarenjs/view/helpers/svg`, `@jarenjs/view/helpers/url`,
9
+ * `@jarenjs/view/helpers/theme`).
10
+ */
11
+ export { sanitizeHref, sanitizeUrl, encodeUrlAttribute } from './url.js';
12
+ export { num, coord, polarPoint, anchorForAngle, svgRoot, group, rect, circle, line, path, polyline, polygon, textAt, textLines, polylinePath, } from './svg.js';
13
+ export { resolveTheme } from './theme.js';
14
+ export { measureText, textWidth } from './metrics.js';
15
+ export { createProjectionMemo } from './memo.js';
@@ -0,0 +1,49 @@
1
+ /**
2
+ * @file The memo pair a visual component's `view()` projection is built
3
+ * on: an LRU-memoized `compile(source)` and a reference-stable
4
+ * `view(sourceOrDoc)` that accepts either a source string or an
5
+ * already-parsed document. Reference stability is the point — an
6
+ * unchanged input returns the identical vnode, so the patcher sees it
7
+ * in O(1) (the O(change) contract).
8
+ */
9
+ export type ProjectionMemo = {
10
+ /**
11
+ * memoized compile
12
+ */
13
+ compile: (source: string) => any;
14
+ /**
15
+ * memoized vnode projection:
16
+ * a string compiles through the source memo; `null`/`undefined`
17
+ * project to `null`; any other value is treated as a parsed document
18
+ * and memoized by reference.
19
+ */
20
+ view: (sourceOrDoc: any) => any;
21
+ };
22
+ /**
23
+ * @typedef {object} ProjectionMemo
24
+ * @property {(source: string) => any} compile memoized compile
25
+ * @property {(sourceOrDoc: any) => any} view memoized vnode projection:
26
+ * a string compiles through the source memo; `null`/`undefined`
27
+ * project to `null`; any other value is treated as a parsed document
28
+ * and memoized by reference.
29
+ */
30
+ /**
31
+ * Create the memoized compile + view projection for a source-compiling
32
+ * component. The source memo is a string-keyed LRU (Map re-insertion
33
+ * order as recency); the document memo is a WeakMap keyed on the parsed
34
+ * document's identity, so documents held in app state stay cached for
35
+ * exactly as long as the state holds them.
36
+ *
37
+ * @param {object} spec
38
+ * @param {(source: string) => any} spec.compile compile a source string
39
+ * @param {(compiled: any) => any} spec.toVnode project a compiled result
40
+ * @param {(doc: object) => any} spec.docToVnode project a parsed document
41
+ * @param {number} [spec.memoLimit] LRU size of the source memo (default 32)
42
+ * @returns {ProjectionMemo}
43
+ */
44
+ export declare function createProjectionMemo({ compile, toVnode, docToVnode, memoLimit }: {
45
+ compile: (source: string) => any;
46
+ toVnode: (compiled: any) => any;
47
+ docToVnode: (doc: object) => any;
48
+ memoLimit?: number;
49
+ }): ProjectionMemo;
@@ -0,0 +1,31 @@
1
+ /**
2
+ * @file Headless text metrics (design decision: no `getBBox`, no DOM).
3
+ *
4
+ * `measureText` estimates a string's rendered box from a precomputed
5
+ * per-codepoint advance-width table for a default sans-serif at unit em.
6
+ * It is deliberately an **approximation** — pixel parity with a browser
7
+ * font is a non-goal. The table is a module constant (`Float32Array`),
8
+ * the hot loop allocates nothing, and unmapped codepoints fall back to
9
+ * an average advance. Shared by the SVG-emitting components
10
+ * (`@jarenjs/mermaid` layout, `@jarenjs/charts` legends and axes).
11
+ */
12
+ /**
13
+ * Measure a (possibly multi-line) string's box.
14
+ * @param {string} str
15
+ * @param {number} fontSize px
16
+ * @param {number} [weight] 400 normal, 700 bold (bold widens ~4%)
17
+ * @returns {{ width: number, height: number, lines: string[] }}
18
+ */
19
+ export declare function measureText(str: string, fontSize: number, weight?: number): {
20
+ width: number;
21
+ height: number;
22
+ lines: string[];
23
+ };
24
+ /**
25
+ * The single-line advance width (em × fontSize).
26
+ * @param {string} str
27
+ * @param {number} fontSize
28
+ * @param {number} [weight]
29
+ * @returns {number}
30
+ */
31
+ export declare function textWidth(str: string, fontSize: number, weight?: number): number;