@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/src/html.js ADDED
@@ -0,0 +1,197 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Server-side rendering: vnode JSON → HTML string.
4
+ *
5
+ * No DOM, no state — a pure fold over the vnode tree, usable anywhere
6
+ * (Node, workers, edge runtimes). `on` bindings and `key`s are renderer
7
+ * instructions and produce no markup; hydration is a client-side
8
+ * re-render into the same container (see docs/VIEW-FORMAT.md §6). A
9
+ * `jaren-widget` vnode serializes its host element around the widget's
10
+ * declarative `ssr` fallback (§7) — still pure, no widget is mounted.
11
+ */
12
+
13
+ import {
14
+ isTextNode,
15
+ isElementNode,
16
+ propsOf,
17
+ childrenOf,
18
+ WIDGET_TAG,
19
+ } from './vnode.js';
20
+ import { styleToString } from './dom.js';
21
+ import { createSafePolicy } from './safe.js';
22
+
23
+ /** Void elements per the HTML standard: no children, no end tag. */
24
+ const VOID_ELEMENTS = new Set([
25
+ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input',
26
+ 'link', 'meta', 'param', 'source', 'track', 'wbr',
27
+ ]);
28
+
29
+ /** Props that never serialize to markup. */
30
+ const SKIP_PROPS = new Set(['key', 'on', 'memo']);
31
+
32
+ /** Widget-vnode props that configure the widget, not the host element. */
33
+ const WIDGET_SKIP_PROPS = new Set(['key', 'on', 'memo', 'name', 'props', 'tag']);
34
+
35
+ /**
36
+ * Escape text content: `&`, `<`, `>`.
37
+ * @param {string} text
38
+ * @returns {string}
39
+ */
40
+ export function escapeText(text) {
41
+ return text.replace(/[&<>]/g, (ch) => (
42
+ ch === '&' ? '&amp;' : ch === '<' ? '&lt;' : '&gt;'
43
+ ));
44
+ }
45
+
46
+ /**
47
+ * Escape a double-quoted attribute value: `&`, `"`.
48
+ * @param {string} value
49
+ * @returns {string}
50
+ */
51
+ export function escapeAttribute(value) {
52
+ return value.replace(/[&"]/g, (ch) => (ch === '&' ? '&amp;' : '&quot;'));
53
+ }
54
+
55
+ /**
56
+ * Serialize a props object to an attribute string over a skip set.
57
+ *
58
+ * The attribute NAME is trusted here: in the default (trusted) mode it comes
59
+ * from a source-authored stylesheet, so it is emitted as written. Under a
60
+ * safe `policy` it is not trusted — the policy rejects an injection-shaped
61
+ * name and sanitizes a URL value, which is what stops `{ 'x onfocus': … }`
62
+ * from breaking out of the attribute list. The policy is the same object the
63
+ * DOM renderer uses, so the two strip an attack identically.
64
+ * @param {Record<string, any>} props
65
+ * @param {Set<string>} skip
66
+ * @param {import('./safe.js').SafePolicy | null} policy
67
+ * @returns {string}
68
+ */
69
+ function serializeProps(props, skip, policy, onUnsafe) {
70
+ let out = '';
71
+ for (const name in props) {
72
+ if (skip.has(name)) continue;
73
+ let attr = name;
74
+ let value = props[name];
75
+ if (policy !== null) {
76
+ const decided = policy.prop(attr, value);
77
+ // Omit a rejected NAME or a cleared VALUE. Omission — not an empty
78
+ // attribute — is what the DOM renderer's `removeAttribute` also does in
79
+ // safe mode, so the two outputs match.
80
+ if (decided === null || decided.value === null) {
81
+ if (onUnsafe !== null) onUnsafe({ kind: 'prop', name });
82
+ continue;
83
+ }
84
+ attr = decided.name;
85
+ value = decided.value;
86
+ }
87
+ if (value == null || value === false) continue;
88
+ if (attr === 'style' && typeof value === 'object') {
89
+ value = styleToString(value);
90
+ if (value === '') continue;
91
+ }
92
+ out += value === true
93
+ ? ' ' + attr
94
+ : ' ' + attr + '="' + escapeAttribute(String(value)) + '"';
95
+ }
96
+ return out;
97
+ }
98
+
99
+ /**
100
+ * @typedef {Object} RenderToStringOptions
101
+ * @property {Record<string, { ssr?: (props: any) => any }>} [widgets] -
102
+ * Registered widget definitions by name: a `jaren-widget` vnode
103
+ * serializes its host element around the widget's `ssr(props)` vnode
104
+ * when the widget is registered and has one, else empty
105
+ * (VIEW-FORMAT §7).
106
+ * @property {boolean} [safe=false] - Serialize under the SAFE policy
107
+ * ({@link createSafePolicy}): treat the vnode as untrusted. Disallowed
108
+ * tags, scripting-sink and inline `on*` properties, injection-shaped tag
109
+ * and attribute names, and unsafe URLs are stripped, and widget vnodes are
110
+ * dropped. This is the SAME policy {@link createDomRenderer} applies, so a
111
+ * document renders to the same safe markup on the server as on the client.
112
+ * @property {(info: { kind: 'tag' | 'prop' | 'event' | 'widget', name: string }) => void}
113
+ * [onUnsafe] - In safe mode, called for everything stripped: a `tag`, a
114
+ * `prop`, an `on` binding (`event`) or a `widget`.
115
+ */
116
+
117
+ /**
118
+ * Render a vnode JSON document to an HTML string.
119
+ *
120
+ * @example
121
+ * renderToString(['p', { class: 'note' }, 'a < b'])
122
+ * // -> '<p class="note">a &lt; b</p>'
123
+ *
124
+ * @param {any} vnode
125
+ * @param {RenderToStringOptions} [options]
126
+ * @returns {string}
127
+ */
128
+ export function renderToString(vnode, options = {}) {
129
+ const policy = options.safe ? createSafePolicy() : null;
130
+ const onUnsafe = typeof options.onUnsafe === 'function' ? options.onUnsafe : null;
131
+ return renderNode(vnode, options.widgets, policy, onUnsafe);
132
+ }
133
+
134
+ /**
135
+ * The recursive fold. The policy (or null) and the report sink are carried
136
+ * rather than rebuilt per node, so a `safe: true` document constructs one
137
+ * policy for the whole tree.
138
+ * @param {any} vnode
139
+ * @param {Record<string, { ssr?: (props: any) => any }> | undefined} widgets
140
+ * @param {import('./safe.js').SafePolicy | null} policy
141
+ * @param {((info: { kind: string, name: string }) => void) | null} onUnsafe
142
+ * @returns {string}
143
+ */
144
+ function renderNode(vnode, widgets, policy, onUnsafe) {
145
+ if (isTextNode(vnode)) {
146
+ return escapeText(String(vnode));
147
+ }
148
+ if (!isElementNode(vnode)) {
149
+ return '';
150
+ }
151
+ const tag = vnode[0];
152
+ const props = propsOf(vnode);
153
+ if (tag === WIDGET_TAG) {
154
+ // A widget is arbitrary imperative JS. In safe mode an untrusted document
155
+ // must not mount one, so it drops to nothing — the same nothing the DOM
156
+ // renderer produces for a widget in safe mode.
157
+ if (policy !== null) {
158
+ if (onUnsafe !== null) onUnsafe({ kind: 'widget', name: String(props.name ?? '') });
159
+ return '';
160
+ }
161
+ const hostTag = typeof props.tag === 'string' && props.tag !== '' ? props.tag : 'div';
162
+ const def = widgets !== undefined && typeof props.name === 'string'
163
+ && Object.hasOwn(widgets, props.name) ? widgets[props.name] : undefined;
164
+ // one read: acquisition and invocation are one boundary here too —
165
+ // renderToString is pure and offers no isolation, so a hostile
166
+ // `ssr` accessor propagates to the caller exactly like a throwing
167
+ // `ssr()` (the documented behavior for both)
168
+ const ssr = def !== undefined ? def.ssr : undefined;
169
+ const inner = ssr !== undefined
170
+ ? renderNode(ssr.call(def, props.props ?? null), widgets, policy, onUnsafe)
171
+ : '';
172
+ return '<' + hostTag + serializeProps(props, WIDGET_SKIP_PROPS, policy, onUnsafe) + '>'
173
+ + inner + '</' + hostTag + '>';
174
+ }
175
+ // Safe mode: a disallowed or injection-shaped tag drops to the empty
176
+ // string, matching the DOM renderer's empty text node.
177
+ if (policy !== null && policy.tag(tag) === null) {
178
+ if (onUnsafe !== null) onUnsafe({ kind: 'tag', name: String(tag) });
179
+ return '';
180
+ }
181
+ // Report a stripped `on` binding for observability parity with the DOM
182
+ // renderer — SSR never emits `on`, but a host still wants to know an
183
+ // untrusted document tried to bind an action.
184
+ if (policy !== null && onUnsafe !== null && props.on !== undefined) {
185
+ onUnsafe({ kind: 'event', name: 'on' });
186
+ }
187
+ let out = '<' + tag + serializeProps(props, SKIP_PROPS, policy, onUnsafe);
188
+ if (VOID_ELEMENTS.has(tag)) {
189
+ return out + '>';
190
+ }
191
+ out += '>';
192
+ const children = childrenOf(vnode);
193
+ for (let i = 0; i < children.length; i++) {
194
+ out += renderNode(children[i], widgets, policy, onUnsafe);
195
+ }
196
+ return out + '</' + tag + '>';
197
+ }
package/src/index.js ADDED
@@ -0,0 +1,34 @@
1
+ //@ts-check
2
+ /**
3
+ * @file @jarenjs/view — the vnode JSON contract, the keyed DOM patcher
4
+ * and the SSR string renderer. See README.md and docs/VIEW-FORMAT.md.
5
+ */
6
+
7
+ export {
8
+ EMPTY_PROPS,
9
+ WIDGET_TAG,
10
+ h,
11
+ isTextNode,
12
+ isElementNode,
13
+ isSkippedNode,
14
+ isSameNode,
15
+ isWidgetNode,
16
+ propsOf,
17
+ keyOf,
18
+ childrenOf,
19
+ } from './vnode.js';
20
+
21
+ export {
22
+ createDomRenderer,
23
+ styleToString,
24
+ } from './dom.js';
25
+
26
+ export {
27
+ renderToString,
28
+ escapeText,
29
+ escapeAttribute,
30
+ } from './html.js';
31
+
32
+ export {
33
+ createSafePolicy,
34
+ } from './safe.js';
package/src/safe.js ADDED
@@ -0,0 +1,278 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The safe render policy: one decision surface for untrusted vnodes,
4
+ * shared by the DOM renderer and the SSR serializer so the two can never
5
+ * disagree about what is safe.
6
+ *
7
+ * **Why this exists.** A vnode is plain JSON, and the published grammar
8
+ * ([`jaren-vnode.schema.json`](../schemas/jaren-vnode.schema.json)) proves a
9
+ * document is a well-formed *interface* — it does not prove the document is
10
+ * *safe to render*. The default (trusted) renderer writes any DOM property a
11
+ * node has, including `innerHTML` and inline `on*` handlers, and the SSR
12
+ * serializer trusts the tag and attribute *names* it is given. For a
13
+ * source-authored view that is exactly right — it is the equivalent of
14
+ * writing the DOM by hand. For a view that arrives from somewhere you do not
15
+ * control — a tenant, a remote service, a language model — it is not: a
16
+ * schema-valid document can still carry `['div', { innerHTML: '<img
17
+ * onerror=…>' }]` or a tag spelled `div><script>`.
18
+ *
19
+ * Schema validation is a structural gate, not a sanitizer. This module is the
20
+ * sanitizer. Pass `{ safe: true }` to {@link createDomRenderer} or
21
+ * {@link renderToString} and every element flows through the decisions here:
22
+ *
23
+ * - **Tags** must be on an allow-list of known-inert HTML and SVG elements.
24
+ * `script`, `iframe`, `object`, `style`, `link`, `foreignObject` and the
25
+ * rest are dropped, and so is any tag whose *name* is not a bare
26
+ * identifier — which is what closes structural injection through the tag
27
+ * (`'div><img src=x onerror=alert(1)'`).
28
+ * - **Property names** must be bare identifiers too (closing attribute-name
29
+ * injection like `'x onfocus'`), must not begin with `on` (no inline
30
+ * handlers), and must not be a known scripting sink (`innerHTML`,
31
+ * `outerHTML`, `srcdoc`, …).
32
+ * - **URL attributes** (`href`, `src`, `action`, …) are filtered through the
33
+ * deny-list {@link sanitizeUrl}, so `javascript:` and a document-carrying
34
+ * `data:` are dropped while ordinary links survive.
35
+ * - **Inline `style`** is dropped when it carries the classic CSS vectors
36
+ * (`expression(`, or a `url()` with a script scheme).
37
+ * - **Event bindings** (`on`) are dropped: an untrusted document must not be
38
+ * able to bind the host's application actions. Safe-mode views are
39
+ * display-oriented by construction.
40
+ *
41
+ * The policy is a set of pure decisions. The DOM renderer and the SSR
42
+ * serializer call the *same* functions, which is what guarantees the client
43
+ * and the server neutralize an attack identically.
44
+ */
45
+
46
+ import { sanitizeUrl } from './helpers/url.js';
47
+
48
+ /** A tag or property name that is a bare identifier: a letter, then letters,
49
+ * digits or hyphens. No spaces, `>`, `=`, `/`, colons or quotes — the
50
+ * characters every structural-injection payload needs. */
51
+ const RE_SAFE_NAME = /^[A-Za-z][A-Za-z0-9-]*$/;
52
+
53
+ /**
54
+ * The HTML elements a user interface legitimately renders. An allow-list, not
55
+ * a deny-list, because a deny-list silently admits the next dangerous element
56
+ * a browser adds. Document structure (`html`, `head`, `body`, `title`),
57
+ * script and style carriers (`script`, `style`, `link`, `meta`, `base`,
58
+ * `noscript`, `template`), and document-embedding elements (`iframe`,
59
+ * `object`, `embed`, `frame`, `frameset`, `portal`) are all absent by design.
60
+ */
61
+ const SAFE_HTML_TAGS = new Set([
62
+ // sectioning & grouping
63
+ 'main', 'section', 'article', 'aside', 'nav', 'header', 'footer', 'hgroup',
64
+ 'address', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
65
+ 'div', 'p', 'hr', 'pre', 'blockquote', 'ol', 'ul', 'li', 'dl', 'dt', 'dd',
66
+ 'figure', 'figcaption', 'menu',
67
+ // text-level
68
+ 'a', 'span', 'strong', 'em', 'b', 'i', 'u', 's', 'small', 'mark', 'abbr',
69
+ 'cite', 'q', 'code', 'kbd', 'samp', 'var', 'sub', 'sup', 'time', 'data',
70
+ 'wbr', 'br', 'bdi', 'bdo', 'ruby', 'rt', 'rp', 'dfn', 'ins', 'del',
71
+ // tables
72
+ 'table', 'caption', 'colgroup', 'col', 'thead', 'tbody', 'tfoot',
73
+ 'tr', 'td', 'th',
74
+ // forms
75
+ 'form', 'label', 'input', 'button', 'select', 'option', 'optgroup',
76
+ 'textarea', 'fieldset', 'legend', 'datalist', 'output', 'progress', 'meter',
77
+ // media & embedded (URL-bearing attributes are sanitized separately)
78
+ 'img', 'picture', 'source', 'audio', 'video', 'track', 'canvas',
79
+ // interactive
80
+ 'details', 'summary', 'dialog',
81
+ ]);
82
+
83
+ /**
84
+ * The SVG drawing subset. `foreignObject` is excluded — it re-enters HTML and
85
+ * would reopen every vector above — and so are `script` and the event-bearing
86
+ * SMIL elements.
87
+ */
88
+ const SAFE_SVG_TAGS = new Set([
89
+ 'svg', 'g', 'defs', 'symbol', 'use', 'marker', 'mask', 'clipPath', 'pattern',
90
+ 'path', 'rect', 'circle', 'ellipse', 'line', 'polyline', 'polygon',
91
+ 'text', 'tspan', 'textPath', 'image',
92
+ 'linearGradient', 'radialGradient', 'stop',
93
+ 'title', 'desc',
94
+ ]);
95
+
96
+ /** Property names denied outright: the sinks that PARSE their value as HTML.
97
+ * `textContent`/`innerText` are deliberately absent — they set text, which a
98
+ * browser escapes, so they are not injection vectors. Compared
99
+ * case-insensitively. */
100
+ const DANGEROUS_PROPS = new Set([
101
+ 'innerhtml', 'outerhtml', 'insertadjacenthtml',
102
+ 'dangerouslysetinnerhtml', 'srcdoc',
103
+ ]);
104
+
105
+ /** Attributes whose value is a single URL, filtered through the deny-list
106
+ * sanitizer. Compared case-insensitively. */
107
+ const URL_ATTRS = new Set([
108
+ 'href', 'src', 'action', 'formaction', 'poster', 'background',
109
+ 'cite', 'longdesc', 'data',
110
+ ]);
111
+
112
+ /** Attributes whose value is a URL *list*: `srcset` is comma-separated
113
+ * `url descriptor` candidates, `ping` is a whitespace-separated URL list.
114
+ * Running the whole string through a single-URL check would miss an unsafe
115
+ * candidate after the first, so each is parsed and every URL is sanitized. */
116
+ const URL_LIST_ATTRS = new Set(['srcset', 'ping']);
117
+
118
+ /** Inline-style values that carry a CSS execution vector. */
119
+ const RE_DANGEROUS_STYLE = /expression\s*\(|url\s*\(\s*['"]?\s*(?:javascript|vbscript|data):/i;
120
+
121
+ /** A CSS property name a safe style object may carry: a plain identifier or a
122
+ * `--custom-property`. A key with `:`, `;`, `(` or whitespace is not a
123
+ * property name — it is a declaration smuggled through the key, which is how
124
+ * `styleToString` concatenation turns `{ 'x:url(javascript:…)': 'y' }` into a
125
+ * live rule. */
126
+ const RE_SAFE_STYLE_KEY = /^(?:--[A-Za-z0-9-]+|[A-Za-z][A-Za-z0-9-]*)$/;
127
+
128
+ /**
129
+ * @typedef {Object} SafePolicy
130
+ * @property {(tag: string) => string | null} tag - The tag to render, or
131
+ * `null` to drop the element and its subtree.
132
+ * @property {(name: string, value: any) => { name: string, value: any } | null}
133
+ * prop - The name/value to write, `{ name, value: null }` to clear an
134
+ * existing attribute (a sanitized-away URL, a dangerous style), or `null` to
135
+ * drop the property entirely (a rejected name).
136
+ * @property {boolean} dropsEvents - Whether `on` bindings are stripped (always
137
+ * true for the default policy; the renderers read this to skip the `on`
138
+ * path in safe mode).
139
+ */
140
+
141
+ /**
142
+ * Build the default safe policy. Stateless and cheap; a renderer builds one
143
+ * per `safe: true` and hands the *same* object to every element it renders.
144
+ *
145
+ * The policy is a set of **pure decisions** — it neither writes nor reports.
146
+ * The renderer owns `onUnsafe` and reports at the point it acts on a
147
+ * rejection, which is what lets it report the strips a policy never sees (an
148
+ * `on` binding, a widget) with one contract.
149
+ * @returns {SafePolicy}
150
+ */
151
+ export function createSafePolicy() {
152
+ return {
153
+ dropsEvents: true,
154
+
155
+ tag(tag) {
156
+ if (typeof tag === 'string' && RE_SAFE_NAME.test(tag)
157
+ && (SAFE_HTML_TAGS.has(tag) || SAFE_SVG_TAGS.has(tag))) {
158
+ return tag;
159
+ }
160
+ return null;
161
+ },
162
+
163
+ prop(name, value) {
164
+ const lower = name.toLowerCase();
165
+ // A name that is not a bare identifier is structural injection, whatever
166
+ // it spells: reject before it can reach a serializer or a DOM property.
167
+ // `is` is a bare identifier but a capability escape: it upgrades an
168
+ // element to a registered customized built-in when the markup is
169
+ // PARSED (an untrusted document's safe SSR would run a host-registered
170
+ // constructor), so it is denied outright.
171
+ if (!RE_SAFE_NAME.test(name) || lower.startsWith('on')
172
+ || lower === 'is' || DANGEROUS_PROPS.has(lower)) {
173
+ return null;
174
+ }
175
+ if (URL_ATTRS.has(lower)) {
176
+ const safe = sanitizeUrl(value);
177
+ // Keep the (safe) name but clear the value when the URL is unsafe: a
178
+ // patch from a good URL to a bad one must remove the old attribute.
179
+ return { name, value: safe };
180
+ }
181
+ if (URL_LIST_ATTRS.has(lower)) {
182
+ return { name, value: sanitizeUrlList(value, lower === 'srcset') };
183
+ }
184
+ if (lower === 'style') {
185
+ return { name, value: safeStyle(value) };
186
+ }
187
+ return { name, value };
188
+ },
189
+ };
190
+ }
191
+
192
+ /**
193
+ * Sanitize a URL-list attribute. Every candidate's URL is checked; a single
194
+ * unsafe candidate drops the whole attribute, because a browser would still
195
+ * act on the safe ones around it and the intent is already hostile.
196
+ * @param {any} value
197
+ * @param {boolean} isSrcset - `srcset` (a candidate list) vs `ping`
198
+ * (whitespace-separated URLs)
199
+ * @returns {string | null}
200
+ */
201
+ function sanitizeUrlList(value, isSrcset) {
202
+ if (typeof value !== 'string') return null;
203
+ if (!isSrcset) {
204
+ const urls = value.split(/\s+/).filter((u) => u !== '');
205
+ for (const url of urls) if (sanitizeUrl(url) === null) return null;
206
+ return urls.length > 0 ? urls.join(' ') : null;
207
+ }
208
+ const candidates = parseSrcset(value);
209
+ if (candidates === null) return null;
210
+ const out = [];
211
+ for (const { url, descriptor } of candidates) {
212
+ if (sanitizeUrl(url) === null) return null;
213
+ out.push(descriptor === '' ? url : `${url} ${descriptor}`);
214
+ }
215
+ return out.length > 0 ? out.join(', ') : null;
216
+ }
217
+
218
+ /**
219
+ * Parse an `srcset` into `{ url, descriptor }` candidates. A candidate's URL
220
+ * runs up to the next ASCII whitespace, so a comma INSIDE a URL — a `data:`
221
+ * image is full of them — stays part of the URL rather than splitting it,
222
+ * which the naive comma-split got wrong. Follows the shape of the HTML srcset
223
+ * algorithm closely enough to validate every URL; the descriptor is opaque to
224
+ * a safety check.
225
+ * @param {string} s
226
+ * @returns {Array<{ url: string, descriptor: string }> | null}
227
+ */
228
+ function parseSrcset(s) {
229
+ const isWs = (c) => c === ' ' || c === '\t' || c === '\n' || c === '\f' || c === '\r';
230
+ const out = [];
231
+ let i = 0;
232
+ while (i < s.length) {
233
+ while (i < s.length && (isWs(s[i]) || s[i] === ',')) i++;
234
+ if (i >= s.length) break;
235
+ let url = '';
236
+ while (i < s.length && !isWs(s[i])) { url += s[i]; i++; }
237
+ // trailing commas on the URL are candidate separators, not part of it.
238
+ let hadTrailingComma = false;
239
+ while (url.endsWith(',')) { url = url.slice(0, -1); hadTrailingComma = true; }
240
+ let descriptor = '';
241
+ if (!hadTrailingComma) {
242
+ while (i < s.length && isWs(s[i])) i++;
243
+ while (i < s.length && s[i] !== ',') { descriptor += s[i]; i++; }
244
+ if (i < s.length) i++; // consume the separating comma
245
+ }
246
+ if (url !== '') out.push({ url, descriptor: descriptor.trim() });
247
+ }
248
+ return out.length > 0 ? out : null;
249
+ }
250
+
251
+ /**
252
+ * The safe form of an inline-style value: `null` when it carries a CSS
253
+ * execution vector or an injection-shaped property name, otherwise the value
254
+ * unchanged (a string, or an object the renderer will serialize). Both the
255
+ * KEYS and the values are checked — a payload smuggled through an object key
256
+ * survives `styleToString`'s concatenation otherwise.
257
+ * @param {any} value
258
+ * @returns {any}
259
+ */
260
+ function safeStyle(value) {
261
+ if (typeof value === 'string') {
262
+ return RE_DANGEROUS_STYLE.test(value) ? null : value;
263
+ }
264
+ if (value !== null && typeof value === 'object') {
265
+ for (const key in value) {
266
+ if (!RE_SAFE_STYLE_KEY.test(key) || RE_DANGEROUS_STYLE.test(key)) return null;
267
+ const v = value[key];
268
+ if (typeof v === 'string' && RE_DANGEROUS_STYLE.test(v)) return null;
269
+ }
270
+ return value;
271
+ }
272
+ return value;
273
+ }
274
+
275
+ export {
276
+ SAFE_HTML_TAGS, SAFE_SVG_TAGS, DANGEROUS_PROPS,
277
+ URL_ATTRS, URL_LIST_ATTRS, RE_SAFE_NAME,
278
+ };
package/src/vnode.js ADDED
@@ -0,0 +1,170 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The Jaren vnode contract — the JSON vocabulary for user interfaces.
4
+ *
5
+ * A vnode is a plain JSON value, designed to be the *output* of a JSLT
6
+ * stylesheet (`@jarenjs/json/jslt`) the way HTML is the output of XSLT:
7
+ *
8
+ * - text: a `string` or a `number` (rendered as `String(value)`)
9
+ * - element: an array whose first item is a string tag:
10
+ * `[tag, props?, ...children]`
11
+ * - list: an array whose first item is NOT a string — spliced into the
12
+ * parent's children in place (the natural shape of a JSLT
13
+ * `[{ "$apply": ... }]` body)
14
+ * - skipped: `null`, `undefined`, `true` and `false` render nothing
15
+ * (so `{"$if": ...}` bodies compose without wrappers)
16
+ *
17
+ * The second item of an element is its props object when it is a plain
18
+ * object; otherwise it is the first child. Recognized special props:
19
+ *
20
+ * - `key` — reconciliation identity for keyed children (never rendered)
21
+ * - `on` — `{ [eventType]: binding }`; the binding is opaque JSON handed
22
+ * to the renderer's `onEvent` hook (in `@jarenjs/app`: an
23
+ * action name or `{ "action": name, "with": payload }`)
24
+ * - `style` — a CSS string or an object of declarations
25
+ *
26
+ * Everything in this module is allocation-light and does no validation
27
+ * beyond shape dispatch: the vnode grammar is published as JSON Schema in
28
+ * `schemas/jaren-vnode.schema.json` for validating untrusted documents.
29
+ */
30
+
31
+ import { isJsonObject } from '@jarenjs/core/object';
32
+
33
+ /** Frozen empty props object shared by all prop-less elements. */
34
+ export const EMPTY_PROPS = Object.freeze({});
35
+
36
+ /**
37
+ * The reserved widget tag (VIEW-FORMAT §7). A valid custom-element name,
38
+ * so a renderer that predates the widget vocabulary degrades to an inert,
39
+ * harmless element — and the dash makes collision with real HTML tags
40
+ * impossible. (`$widget` is impossible: in query/JSLT rule bodies a
41
+ * string leaf starting with `$` is a path expression.)
42
+ */
43
+ export const WIDGET_TAG = 'jaren-widget';
44
+
45
+ /**
46
+ * A vnode JSON value.
47
+ * @typedef {string | number | boolean | null | undefined | VNodeElement | VNodeJson[]} VNodeJson
48
+ */
49
+ /**
50
+ * An element vnode: `[tag, props?, ...children]`.
51
+ * @typedef {Array<any>} VNodeElement
52
+ */
53
+
54
+ /**
55
+ * Is this vnode a text node (string or finite number)?
56
+ * @param {any} vnode
57
+ * @returns {vnode is string | number}
58
+ */
59
+ export function isTextNode(vnode) {
60
+ const t = typeof vnode;
61
+ return t === 'string' || t === 'number';
62
+ }
63
+
64
+ /**
65
+ * Is this vnode an element (`[tag, ...]` with a string tag)?
66
+ * @param {any} vnode
67
+ * @returns {vnode is VNodeElement}
68
+ */
69
+ export function isElementNode(vnode) {
70
+ return Array.isArray(vnode) && typeof vnode[0] === 'string';
71
+ }
72
+
73
+ /**
74
+ * Is this value skipped by the renderer (`null`/`undefined`/booleans)?
75
+ * @param {any} vnode
76
+ * @returns {boolean}
77
+ */
78
+ export function isSkippedNode(vnode) {
79
+ return vnode == null || vnode === true || vnode === false;
80
+ }
81
+
82
+ /**
83
+ * Is this vnode a widget node (`["jaren-widget", props]`, VIEW-FORMAT §7)?
84
+ * @param {any} vnode
85
+ * @returns {vnode is VNodeElement}
86
+ */
87
+ export function isWidgetNode(vnode) {
88
+ return Array.isArray(vnode) && vnode[0] === WIDGET_TAG;
89
+ }
90
+
91
+ /**
92
+ * The props of an element vnode; `EMPTY_PROPS` when it has none.
93
+ * @param {VNodeElement} vnode
94
+ * @returns {Record<string, any>}
95
+ */
96
+ export function propsOf(vnode) {
97
+ const second = vnode.length > 1 ? vnode[1] : undefined;
98
+ return isJsonObject(second) ? second : EMPTY_PROPS;
99
+ }
100
+
101
+ /**
102
+ * The reconciliation key of an element vnode, or `undefined`.
103
+ * @param {any} vnode
104
+ * @returns {string | number | undefined}
105
+ */
106
+ export function keyOf(vnode) {
107
+ return isElementNode(vnode) ? propsOf(vnode).key : undefined;
108
+ }
109
+
110
+ /**
111
+ * The renderable children of an element vnode as a flat array: nested
112
+ * lists are spliced in place, skipped values are dropped.
113
+ * @param {VNodeElement} vnode
114
+ * @returns {VNodeJson[]}
115
+ */
116
+ export function childrenOf(vnode) {
117
+ const start = vnode.length > 1 && isJsonObject(vnode[1]) ? 2 : 1;
118
+ /** @type {VNodeJson[]} */
119
+ const out = [];
120
+ for (let i = start; i < vnode.length; i++) {
121
+ appendChild(out, vnode[i]);
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /**
127
+ * Append one child value to `out`, splicing lists and dropping skipped
128
+ * values.
129
+ * @param {VNodeJson[]} out
130
+ * @param {any} child
131
+ */
132
+ function appendChild(out, child) {
133
+ if (isSkippedNode(child)) return;
134
+ if (Array.isArray(child) && typeof child[0] !== 'string') {
135
+ for (let i = 0; i < child.length; i++) appendChild(out, child[i]);
136
+ return;
137
+ }
138
+ out.push(child);
139
+ }
140
+
141
+ /**
142
+ * Convenience element constructor for JavaScript-side views and tests —
143
+ * produces the same JSON an authored stylesheet would.
144
+ *
145
+ * @example
146
+ * h('li', { key: 7, class: 'todo' }, 'Buy milk')
147
+ * // -> ['li', { key: 7, class: 'todo' }, 'Buy milk']
148
+ *
149
+ * @param {string} tag
150
+ * @param {Record<string, any>} [props]
151
+ * @param {...any} children
152
+ * @returns {VNodeElement}
153
+ */
154
+ export function h(tag, props, ...children) {
155
+ return [tag, props ?? EMPTY_PROPS, ...children];
156
+ }
157
+
158
+ /**
159
+ * Two vnodes are "the same node" for reconciliation when a patch in place
160
+ * is possible: both text, or elements with equal tag and equal key.
161
+ * @param {VNodeJson} a
162
+ * @param {VNodeJson} b
163
+ * @returns {boolean}
164
+ */
165
+ export function isSameNode(a, b) {
166
+ if (isTextNode(a)) return isTextNode(b);
167
+ if (isTextNode(b)) return false;
168
+ return /** @type {VNodeElement} */ (a)[0] === /** @type {VNodeElement} */ (b)[0]
169
+ && keyOf(a) === keyOf(b);
170
+ }