@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 +181 -0
- package/dist/types/dom.d.ts +226 -0
- package/dist/types/helpers/index.d.ts +15 -0
- package/dist/types/helpers/memo.d.ts +49 -0
- package/dist/types/helpers/metrics.d.ts +31 -0
- package/dist/types/helpers/svg.d.ts +164 -0
- package/dist/types/helpers/theme.d.ts +47 -0
- package/dist/types/helpers/url.d.ts +71 -0
- package/dist/types/html.d.ts +80 -0
- package/dist/types/index.d.ts +8 -0
- package/dist/types/safe.d.ts +121 -0
- package/dist/types/vnode.d.ts +113 -0
- package/docs/VIEW-FORMAT.md +538 -0
- package/package.json +63 -0
- package/schemas/jaren-vnode-safe.schema.json +92 -0
- package/schemas/jaren-vnode.schema.json +127 -0
- package/src/dom.js +1173 -0
- package/src/helpers/index.js +35 -0
- package/src/helpers/memo.js +61 -0
- package/src/helpers/metrics.js +116 -0
- package/src/helpers/svg.js +233 -0
- package/src/helpers/theme.js +75 -0
- package/src/helpers/url.js +154 -0
- package/src/html.js +197 -0
- package/src/index.js +34 -0
- package/src/safe.js +278 -0
- package/src/vnode.js +170 -0
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 < 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;
|