@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/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 === '&' ? '&' : ch === '<' ? '<' : '>'
|
|
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 === '&' ? '&' : '"'));
|
|
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 < 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
|
+
}
|