@jarenjs/view 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,35 @@
1
+ //@ts-check
2
+ /**
3
+ * @file `@jarenjs/view/helpers` — the shared, view-layer helper kernel:
4
+ * SVG vnode builders over `h()`, the URL policies that guard what may
5
+ * reach an `href`/`src`, the prefix-driven theme resolver that
6
+ * SVG-emitting components (`@jarenjs/calc`, `@jarenjs/mermaid`, …) build
7
+ * on, and the memoized `view()` projection pair the component layers
8
+ * share. Import the whole barrel (`@jarenjs/view/helpers`) or a single
9
+ * module (`@jarenjs/view/helpers/svg`, `@jarenjs/view/helpers/url`,
10
+ * `@jarenjs/view/helpers/theme`).
11
+ */
12
+
13
+ export { sanitizeHref, sanitizeUrl, encodeUrlAttribute } from './url.js';
14
+
15
+ export {
16
+ num,
17
+ coord,
18
+ polarPoint,
19
+ anchorForAngle,
20
+ svgRoot,
21
+ group,
22
+ rect,
23
+ circle,
24
+ line,
25
+ path,
26
+ polyline,
27
+ polygon,
28
+ textAt,
29
+ textLines,
30
+ polylinePath,
31
+ } from './svg.js';
32
+
33
+ export { resolveTheme } from './theme.js';
34
+ export { measureText, textWidth } from './metrics.js';
35
+ export { createProjectionMemo } from './memo.js';
@@ -0,0 +1,61 @@
1
+ //@ts-check
2
+ /**
3
+ * @file The memo pair a visual component's `view()` projection is built
4
+ * on: an LRU-memoized `compile(source)` and a reference-stable
5
+ * `view(sourceOrDoc)` that accepts either a source string or an
6
+ * already-parsed document. Reference stability is the point — an
7
+ * unchanged input returns the identical vnode, so the patcher sees it
8
+ * in O(1) (the O(change) contract).
9
+ */
10
+
11
+ import { createBoundedCache } from '@jarenjs/core/cache';
12
+
13
+ /**
14
+ * @typedef {object} ProjectionMemo
15
+ * @property {(source: string) => any} compile memoized compile
16
+ * @property {(sourceOrDoc: any) => any} view memoized vnode projection:
17
+ * a string compiles through the source memo; `null`/`undefined`
18
+ * project to `null`; any other value is treated as a parsed document
19
+ * and memoized by reference.
20
+ */
21
+
22
+ /**
23
+ * Create the memoized compile + view projection for a source-compiling
24
+ * component. The source memo is a string-keyed LRU (Map re-insertion
25
+ * order as recency); the document memo is a WeakMap keyed on the parsed
26
+ * document's identity, so documents held in app state stay cached for
27
+ * exactly as long as the state holds them.
28
+ *
29
+ * @param {object} spec
30
+ * @param {(source: string) => any} spec.compile compile a source string
31
+ * @param {(compiled: any) => any} spec.toVnode project a compiled result
32
+ * @param {(doc: object) => any} spec.docToVnode project a parsed document
33
+ * @param {number} [spec.memoLimit] LRU size of the source memo (default 32)
34
+ * @returns {ProjectionMemo}
35
+ */
36
+ export function createProjectionMemo({ compile, toVnode, docToVnode, memoLimit = 32 }) {
37
+ /** Source-string memo (the shared bounded LRU, `@jarenjs/core/cache`). */
38
+ const bySource = createBoundedCache(memoLimit);
39
+ /** Parsed-document memo (reference-keyed). */
40
+ /** @type {WeakMap<object, any>} */
41
+ const byDoc = new WeakMap();
42
+
43
+ /** @type {(source: string) => any} */
44
+ const memoCompile = (source) => bySource.getOrCreate(source, compile);
45
+
46
+ /** @type {(sourceOrDoc: any) => any} */
47
+ const view = (sourceOrDoc) => {
48
+ if (typeof sourceOrDoc === 'string') {
49
+ return toVnode(memoCompile(sourceOrDoc));
50
+ }
51
+ if (sourceOrDoc === null || sourceOrDoc === undefined) return null;
52
+ let vnode = byDoc.get(sourceOrDoc);
53
+ if (vnode === undefined) {
54
+ vnode = docToVnode(sourceOrDoc);
55
+ byDoc.set(sourceOrDoc, vnode);
56
+ }
57
+ return vnode;
58
+ };
59
+
60
+ return { compile: memoCompile, view };
61
+ }
@@ -0,0 +1,116 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Headless text metrics (design decision: no `getBBox`, no DOM).
4
+ *
5
+ * `measureText` estimates a string's rendered box from a precomputed
6
+ * per-codepoint advance-width table for a default sans-serif at unit em.
7
+ * It is deliberately an **approximation** — pixel parity with a browser
8
+ * font is a non-goal. The table is a module constant (`Float32Array`),
9
+ * the hot loop allocates nothing, and unmapped codepoints fall back to
10
+ * an average advance. Shared by the SVG-emitting components
11
+ * (`@jarenjs/mermaid` layout, `@jarenjs/charts` legends and axes).
12
+ */
13
+
14
+ /** Average advance (em) for codepoints outside the ASCII table. */
15
+ const FALLBACK_ADVANCE = 0.55;
16
+ /** Line-height multiple of the font size. */
17
+ const LINE_HEIGHT = 1.2;
18
+
19
+ /**
20
+ * ASCII printable advances (em) for a Helvetica-like sans-serif,
21
+ * indexed by `code - 32`. Compact, good enough for box sizing.
22
+ */
23
+ const ADVANCE = buildAdvanceTable();
24
+
25
+ /**
26
+ * @returns {Float32Array}
27
+ */
28
+ function buildAdvanceTable() {
29
+ const t = new Float32Array(95);
30
+ t.fill(0.556);
31
+ const set = (chars, w) => {
32
+ for (const ch of chars) t[ch.charCodeAt(0) - 32] = w;
33
+ };
34
+ set(' ', 0.278);
35
+ set('!', 0.278);
36
+ set('"', 0.355);
37
+ set("'", 0.191);
38
+ set('(', 0.333); set(')', 0.333);
39
+ set('*', 0.389);
40
+ set('+', 0.584);
41
+ set(',', 0.278); set('.', 0.278);
42
+ set('-', 0.333);
43
+ set('/', 0.278);
44
+ set('0123456789', 0.556);
45
+ set(':', 0.278); set(';', 0.278);
46
+ set('<', 0.584); set('=', 0.584); set('>', 0.584);
47
+ set('?', 0.556);
48
+ set('@', 1.015);
49
+ set('ABDEHKNPRSUVXYZ', 0.667);
50
+ set('C', 0.722); set('G', 0.722); set('O', 0.778); set('Q', 0.778);
51
+ set('D', 0.722);
52
+ set('M', 0.833); set('W', 0.944);
53
+ set('I', 0.278); set('J', 0.5); set('L', 0.556); set('F', 0.611); set('T', 0.611);
54
+ set('[', 0.278); set(']', 0.278);
55
+ set('\\', 0.278);
56
+ set('^', 0.469);
57
+ set('_', 0.556);
58
+ set('`', 0.333);
59
+ set('abcdeghnopqu', 0.556);
60
+ set('f', 0.278); set('i', 0.222); set('j', 0.222); set('l', 0.222); set('t', 0.278);
61
+ set('k', 0.5); set('r', 0.333); set('s', 0.5);
62
+ set('m', 0.833); set('w', 0.722);
63
+ set('v', 0.5); set('x', 0.5); set('y', 0.5); set('z', 0.5);
64
+ set('{', 0.334); set('}', 0.334); set('|', 0.26);
65
+ set('~', 0.584);
66
+ return t;
67
+ }
68
+
69
+ /**
70
+ * Advance (em) of a single codepoint.
71
+ * @param {number} code
72
+ * @returns {number}
73
+ */
74
+ function advanceOf(code) {
75
+ if (code >= 32 && code < 127) return ADVANCE[code - 32];
76
+ return FALLBACK_ADVANCE;
77
+ }
78
+
79
+ /**
80
+ * Measure a (possibly multi-line) string's box.
81
+ * @param {string} str
82
+ * @param {number} fontSize px
83
+ * @param {number} [weight] 400 normal, 700 bold (bold widens ~4%)
84
+ * @returns {{ width: number, height: number, lines: string[] }}
85
+ */
86
+ export function measureText(str, fontSize, weight = 400) {
87
+ const boldFactor = weight >= 700 ? 1.04 : 1;
88
+ const lines = str.length === 0 ? [''] : str.split('\n');
89
+ let maxWidth = 0;
90
+ for (let i = 0; i < lines.length; i++) {
91
+ const line = lines[i];
92
+ let w = 0;
93
+ for (let c = 0; c < line.length; c++) w += advanceOf(line.charCodeAt(c));
94
+ w *= fontSize * boldFactor;
95
+ if (w > maxWidth) maxWidth = w;
96
+ }
97
+ return {
98
+ width: maxWidth,
99
+ height: lines.length * fontSize * LINE_HEIGHT,
100
+ lines,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * The single-line advance width (em × fontSize).
106
+ * @param {string} str
107
+ * @param {number} fontSize
108
+ * @param {number} [weight]
109
+ * @returns {number}
110
+ */
111
+ export function textWidth(str, fontSize, weight = 400) {
112
+ const boldFactor = weight >= 700 ? 1.04 : 1;
113
+ let w = 0;
114
+ for (let c = 0; c < str.length; c++) w += advanceOf(str.charCodeAt(c));
115
+ return w * fontSize * boldFactor;
116
+ }
@@ -0,0 +1,233 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Shared SVG vnode builders over the view `h()` constructor — the
4
+ * single home for the small helper set that `@jarenjs/calc` and
5
+ * `@jarenjs/mermaid` (and any future SVG-emitting component) draw from.
6
+ *
7
+ * Every helper returns a tagged-array vnode; nothing here touches the DOM.
8
+ * The root is `['svg', {viewBox,…}, …]` so the view patcher namespaces the
9
+ * whole subtree (VIEW-FORMAT §5.4) and the serializer escapes text. These
10
+ * builders are geometry-defensive: coordinates flow through `num` so a
11
+ * NaN/±Inf never reaches the emitted string.
12
+ */
13
+
14
+ import { Float64 } from '@jarenjs/core/math';
15
+ import { h } from '../vnode.js';
16
+
17
+ /**
18
+ * Clamp a value to a finite number (defends the geometry against
19
+ * NaN/±Inf).
20
+ * @param {number} n
21
+ * @param {number} [fallback]
22
+ * @returns {number}
23
+ */
24
+ export function num(n, fallback = 0) {
25
+ return Number.isFinite(n) ? n : fallback;
26
+ }
27
+
28
+ /**
29
+ * Quantize a coordinate to two decimals — the precision SVG geometry is
30
+ * emitted at, which keeps path data short and makes repeated renders of the
31
+ * same shape produce identical strings. Non-finite input passes straight
32
+ * through, so pair this with `num` wherever the value reaches an attribute.
33
+ * @param {number} v
34
+ * @returns {number}
35
+ */
36
+ export function coord(v) {
37
+ return Float64.roundTo(v, 2);
38
+ }
39
+
40
+ /**
41
+ * The point at distance `r` from `(cx, cy)` along `angle` (radians,
42
+ * 0 = east, y growing downward — SVG screen orientation). Raw
43
+ * coordinates: quantization (`coord`/`num`) stays at the call site so a
44
+ * caller's emitted bytes are its own choice.
45
+ * @param {number} cx
46
+ * @param {number} cy
47
+ * @param {number} r
48
+ * @param {number} angle
49
+ * @returns {{ x: number, y: number }}
50
+ */
51
+ export function polarPoint(cx, cy, r, angle) {
52
+ return { x: cx + r * Math.cos(angle), y: cy + r * Math.sin(angle) };
53
+ }
54
+
55
+ /**
56
+ * The `text-anchor` for a label placed radially outward at `angle`
57
+ * (radians, 0 = east, y growing downward). Labels near the left or right
58
+ * of the circle read away from it (`end`/`start`); the deadband around
59
+ * ±90° keeps top and bottom labels centred rather than flipping anchor on
60
+ * a hair's difference in angle.
61
+ * @param {number} angle
62
+ * @returns {'start'|'end'|'middle'}
63
+ */
64
+ export function anchorForAngle(angle) {
65
+ const c = Math.cos(angle);
66
+ return c > 0.3 ? 'start' : c < -0.3 ? 'end' : 'middle';
67
+ }
68
+
69
+ /**
70
+ * The root `<svg>`. The caller supplies the `class` string; theme tokens
71
+ * become CSS custom properties on the element (via `theme.cssVars`) so the
72
+ * stylesheet can re-theme without a re-render, and `theme.tokens.fontFamily`
73
+ * is written as the base font.
74
+ * @param {string} className the root element's `class`
75
+ * @param {number} width
76
+ * @param {number} height
77
+ * @param {{ cssVars: Record<string,string>, tokens: Record<string,string> }} theme
78
+ * `max-width: 100%` is written inline so a standalone SVG never overflows its
79
+ * container. It is inline because the theme variables are, and that means it
80
+ * outranks any stylesheet — so a caller whose content must NOT be scaled down
81
+ * (a diagram, where shrinking scales the text below legibility) has to opt out
82
+ * here rather than in CSS.
83
+ * @param {any[]} children
84
+ * @param {string} [key]
85
+ * @param {{ fit?: boolean, fontFamily?: string }} [options] `fit: false` omits
86
+ * the inline `max-width`, leaving the element at its natural size for a
87
+ * scrolling frame; `fontFamily` overrides the theme's base font for a root
88
+ * whose type is part of its meaning (an error box quotes source, so it sets
89
+ * monospace) — it must be written here because the theme variables are
90
+ * inline, and an inline font outranks any stylesheet rule
91
+ * @returns {any}
92
+ */
93
+ export function svgRoot(className, width, height, theme, children, key, options = {}) {
94
+ const style = { ...theme.cssVars, 'font-family': options.fontFamily ?? theme.tokens.fontFamily };
95
+ if (options.fit !== false) style['max-width'] = '100%';
96
+ const props = {
97
+ class: className,
98
+ role: 'img',
99
+ xmlns: 'http://www.w3.org/2000/svg',
100
+ viewBox: `0 0 ${num(width, 1)} ${num(height, 1)}`,
101
+ width: num(width, 1),
102
+ height: num(height, 1),
103
+ style,
104
+ };
105
+ if (key !== undefined) props.key = key;
106
+ return ['svg', props, ...children];
107
+ }
108
+
109
+ /**
110
+ * A `<g>` group.
111
+ * @param {Record<string,any>} props
112
+ * @param {any[]} children
113
+ * @returns {any}
114
+ */
115
+ export function group(props, children) {
116
+ return ['g', props, ...children];
117
+ }
118
+
119
+ /**
120
+ * A `<rect>` (optionally rounded via props).
121
+ * @param {number} x @param {number} y @param {number} w @param {number} height
122
+ * @param {Record<string,any>} props
123
+ * @returns {any}
124
+ */
125
+ export function rect(x, y, w, height, props) {
126
+ return h('rect', { x: num(x), y: num(y), width: num(w), height: num(height), ...props });
127
+ }
128
+
129
+ /**
130
+ * A `<circle>`.
131
+ * @param {number} cx @param {number} cy @param {number} r
132
+ * @param {Record<string,any>} props
133
+ * @returns {any}
134
+ */
135
+ export function circle(cx, cy, r, props) {
136
+ return h('circle', { cx: num(cx), cy: num(cy), r: num(r), ...props });
137
+ }
138
+
139
+ /**
140
+ * A `<line>`.
141
+ * @param {number} x1 @param {number} y1 @param {number} x2 @param {number} y2
142
+ * @param {Record<string,any>} [props]
143
+ * @returns {any}
144
+ */
145
+ export function line(x1, y1, x2, y2, props) {
146
+ return h('line', { x1: num(x1), y1: num(y1), x2: num(x2), y2: num(y2), ...props });
147
+ }
148
+
149
+ /**
150
+ * A `<path>` from a `d` string.
151
+ * @param {string} d @param {Record<string,any>} [props]
152
+ * @returns {any}
153
+ */
154
+ export function path(d, props) {
155
+ return h('path', { d, ...props });
156
+ }
157
+
158
+ /**
159
+ * A `<polyline>` from `{x,y}` points (open; defaults to no fill).
160
+ * @param {{x:number,y:number}[]} points @param {Record<string,any>} [props]
161
+ * @returns {any}
162
+ */
163
+ export function polyline(points, props) {
164
+ const p = points.map((pt) => `${num(pt.x)},${num(pt.y)}`).join(' ');
165
+ return h('polyline', { points: p, fill: 'none', ...props });
166
+ }
167
+
168
+ /**
169
+ * A `<polygon>` from `{x,y}` points (closed).
170
+ * @param {{x:number,y:number}[]} points @param {Record<string,any>} [props]
171
+ * @returns {any}
172
+ */
173
+ export function polygon(points, props) {
174
+ const p = points.map((pt) => `${num(pt.x)},${num(pt.y)}`).join(' ');
175
+ return h('polygon', { points: p, ...props });
176
+ }
177
+
178
+ /**
179
+ * A single-line `<text>` anchored at (x,y). `str` is coerced with
180
+ * `String()` so numeric labels emit as text.
181
+ * @param {number} x @param {number} y @param {any} str @param {number} fontSize
182
+ * @param {Record<string,any>} [props]
183
+ * @returns {any}
184
+ */
185
+ export function textAt(x, y, str, fontSize, props = {}) {
186
+ return h('text', { x: num(x), y: num(y), 'font-size': fontSize, ...props }, String(str));
187
+ }
188
+
189
+ /**
190
+ * A `<text>` block, one `<tspan>` per line, vertically centered on `cy`.
191
+ * @param {number} cx @param {number} cy
192
+ * @param {string[]} lines
193
+ * @param {number} fontSize
194
+ * @param {Record<string,any>} [props]
195
+ * @returns {any}
196
+ */
197
+ export function textLines(cx, cy, lines, fontSize, props = {}) {
198
+ const total = lines.length;
199
+ const lineH = fontSize * 1.2;
200
+ const startY = cy - ((total - 1) * lineH) / 2;
201
+ const children = lines.map((ln, i) => h('tspan', {
202
+ x: num(cx),
203
+ y: num(startY + i * lineH),
204
+ 'text-anchor': 'middle',
205
+ 'dominant-baseline': 'central',
206
+ }, ln));
207
+ return h('text', {
208
+ 'font-size': fontSize,
209
+ 'text-anchor': 'middle',
210
+ ...props,
211
+ }, ...children);
212
+ }
213
+
214
+ /**
215
+ * Build an SVG path `d` from a list of `{x,y}` points, breaking the line
216
+ * on `null` entries (discontinuities / out-of-range samples). Returns
217
+ * `''` when there is nothing to draw.
218
+ * @param {Array<{x:number,y:number}|null>} points
219
+ * @returns {string}
220
+ */
221
+ export function polylinePath(points) {
222
+ let d = '';
223
+ let pen = false;
224
+ for (const pt of points) {
225
+ if (pt === null || !Number.isFinite(pt.x) || !Number.isFinite(pt.y)) {
226
+ pen = false;
227
+ continue;
228
+ }
229
+ d += (pen ? 'L' : 'M') + num(pt.x) + ' ' + num(pt.y) + ' ';
230
+ pen = true;
231
+ }
232
+ return d.trim();
233
+ }
@@ -0,0 +1,75 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Shared theme-resolution mechanics for SVG-emitting components.
4
+ *
5
+ * `resolveTheme` is the prefix-driven kernel behind each component's
6
+ * `createTheme`: it resolves a theme name (or an overrides object) into a
7
+ * flat token table of concrete values *and* a matching set of
8
+ * `--<prefix>-*` CSS custom properties. Components keep their own token
9
+ * tables and pass their CSS-variable prefix; the resolution logic lives
10
+ * here once. Theme tokens are a view concern, so this is view/helpers, not
11
+ * core — but the pure `kebabCase` transform it uses is core's.
12
+ */
13
+
14
+ import { kebabCase } from '@jarenjs/core/string';
15
+
16
+ /**
17
+ * Resolve a theme against a component's token tables.
18
+ *
19
+ * `nameOrOverrides` is either a theme name (falling back to `default` when
20
+ * unknown) or an overrides object; an overrides object may name a base via
21
+ * its `theme` property. The returned `cssVars` stamp every token as
22
+ * `--<prefix>-<kebab-case-key>`.
23
+ *
24
+ * An overrides object may also carry a reserved `vars` key: a map of token
25
+ * key → **host** custom-property name (e.g. `{ nodeFill: '--accent-soft' }`).
26
+ * Linked tokens keep their concrete value in `tokens` (so presentation
27
+ * attributes stay standalone-valid) but stamp their cssVar as
28
+ * `var(<host-property>, <concrete>)` — the stamped SVG then follows the
29
+ * host's tokens (light/dark and all) live, with the concrete color as the
30
+ * fallback outside any host. `vars` never leaks into `tokens`.
31
+ *
32
+ * @param {Record<string, Record<string, string>>} themes the component's
33
+ * named token tables (must include a `default`)
34
+ * @param {string} prefix the CSS-variable prefix (e.g. `mm`, `calc`),
35
+ * without the leading `--`
36
+ * The name `'host'` is reserved: it selects the component's own host-var
37
+ * map wholesale, which is how a consumer asks for "theme me from the page"
38
+ * without knowing which tokens are linkable. It resolves to
39
+ * `{ vars: hostVars }`, so a component that passes no `hostVars` simply has
40
+ * no `'host'` theme and falls back to `default` like any other unknown name.
41
+ *
42
+ * @param {string | Record<string, any>} [nameOrOverrides]
43
+ * @param {Record<string, string>} [hostVars] the component's token key →
44
+ * host custom-property map, used when `nameOrOverrides` is `'host'`
45
+ * @returns {{ name: string, tokens: Record<string, string>, cssVars: Record<string, string> }}
46
+ */
47
+ export function resolveTheme(themes, prefix, nameOrOverrides = 'default', hostVars = undefined) {
48
+ let name = 'default';
49
+ let base = themes.default;
50
+ let overrides = {};
51
+ let vars = null;
52
+ if (nameOrOverrides === 'host' && hostVars) nameOrOverrides = { vars: hostVars };
53
+ if (typeof nameOrOverrides === 'string') {
54
+ name = themes[nameOrOverrides] ? nameOrOverrides : 'default';
55
+ base = themes[name];
56
+ }
57
+ else if (nameOrOverrides && typeof nameOrOverrides === 'object') {
58
+ if (typeof nameOrOverrides.theme === 'string' && themes[nameOrOverrides.theme]) {
59
+ name = nameOrOverrides.theme;
60
+ base = themes[name];
61
+ }
62
+ overrides = nameOrOverrides;
63
+ if (overrides.vars && typeof overrides.vars === 'object') vars = overrides.vars;
64
+ }
65
+ const tokens = { ...base, ...overrides };
66
+ delete tokens.vars;
67
+ const cssVars = {};
68
+ for (const key of Object.keys(tokens)) {
69
+ const linked = vars !== null && typeof vars[key] === 'string' ? vars[key] : null;
70
+ cssVars['--' + prefix + '-' + kebabCase(key)] = linked !== null
71
+ ? 'var(' + linked + ', ' + tokens[key] + ')'
72
+ : tokens[key];
73
+ }
74
+ return { name, tokens, cssVars };
75
+ }
@@ -0,0 +1,154 @@
1
+ //@ts-check
2
+ /**
3
+ * @file URL safety policies for values written into vnode attributes.
4
+ *
5
+ * A URL that reaches an `href`/`src` can execute script (`javascript:`,
6
+ * `vbscript:`) or smuggle a whole document into the page's origin
7
+ * (`data:text/html`), so every producer that turns *authored* text into a
8
+ * vnode must filter it. This module is the one home for that decision.
9
+ *
10
+ * There are deliberately **two** policies, because the safe answer depends
11
+ * on where the URL came from, and picking the wrong one is the trap this
12
+ * file exists to prevent:
13
+ *
14
+ * - {@link sanitizeHref} is an **allow-list**: only the listed schemes and
15
+ * anchor/absolute/dot-relative forms survive. Use it for URLs from a
16
+ * constrained producer that is expected to emit fully-formed links — a
17
+ * Mermaid `click` directive, say. It rejects a bare relative reference
18
+ * like `image.png`, which is correct there and wrong for prose.
19
+ * - {@link sanitizeUrl} is a **deny-list**: it rejects the schemes that can
20
+ * execute or impersonate a document and passes everything else,
21
+ * including scheme-less relative references. Use it for URLs authored in
22
+ * ordinary content — Markdown link destinations and image sources — where
23
+ * `docs/guide.md` and `image.png` must keep working.
24
+ *
25
+ * Both return the trimmed URL or `null`; a `null` means the caller should
26
+ * drop the attribute rather than emit an unsafe one.
27
+ */
28
+
29
+ /** URL forms permitted on a link `href` by the allow-list policy. */
30
+ const RE_SAFE_URL = /^(https?:|mailto:|#|\/|\.)/i;
31
+
32
+ /** Schemes that can execute script or carry a document of their own. */
33
+ const UNSAFE_SCHEMES = new Set(['javascript', 'vbscript', 'data', 'file']);
34
+
35
+ /**
36
+ * The `data:` payloads an `<img>` legitimately wants. Raster only —
37
+ * `image/svg+xml` is excluded because an SVG document can carry script,
38
+ * and the contexts that make it inert are not ours to assume.
39
+ */
40
+ const RE_SAFE_DATA_IMAGE = /^data:image\/(?:gif|png|jpe?g|webp|avif)[;,]/i;
41
+
42
+ /** Everything RFC 3986 does not allow inside a scheme name. */
43
+ const RE_NOT_SCHEME_CHAR = /[^a-z0-9+.-]/gi;
44
+
45
+ /**
46
+ * The scheme a browser will act on, or `''` when the URL is relative.
47
+ *
48
+ * Characters that cannot occur in a scheme are dropped rather than treated
49
+ * as a mismatch, because a browser drops the whitespace and control
50
+ * characters inside one before resolving the URL: a tab in the middle of
51
+ * `java<TAB>script:` does not stop it navigating as `javascript:`, so a
52
+ * literal comparison would wave it through. A `/` before the colon does
53
+ * keep the URL relative — it is dropped too, but so is the rest of the
54
+ * path, and what remains no longer spells a dangerous scheme.
55
+ *
56
+ * @param {string} url a trimmed URL
57
+ * @returns {string} the lower-cased scheme, without its colon
58
+ */
59
+ function schemeOf(url) {
60
+ const colon = url.indexOf(':');
61
+ if (colon < 0) return '';
62
+ return url.slice(0, colon).replace(RE_NOT_SCHEME_CHAR, '').toLowerCase();
63
+ }
64
+
65
+ /**
66
+ * Reject `javascript:`/`data:` and other non-http(s) URLs, returning the
67
+ * trimmed URL when it is safe or `null` otherwise. An **allow-list**: a
68
+ * scheme-less relative reference (`image.png`) is rejected too, so reach
69
+ * for {@link sanitizeUrl} when relative references must survive.
70
+ *
71
+ * @param {any} url
72
+ * @returns {string|null}
73
+ */
74
+ export function sanitizeHref(url) {
75
+ if (typeof url !== 'string') return null;
76
+ const trimmed = url.trim();
77
+ return RE_SAFE_URL.test(trimmed) ? trimmed : null;
78
+ }
79
+
80
+ /**
81
+ * Reject only the URL schemes that can execute script or stand in for a
82
+ * document — `javascript:`, `vbscript:`, `file:` and `data:` other than a
83
+ * raster image — and pass everything else through, including scheme-less
84
+ * relative references. The **deny-list** policy for authored content.
85
+ *
86
+ * The scheme is read with {@link schemeOf}, which is deliberately not a
87
+ * literal prefix test — see there for why an embedded tab or NUL does not
88
+ * get a URL past this.
89
+ *
90
+ * @param {any} url
91
+ * @returns {string|null} the trimmed URL, or `null` when it must be dropped
92
+ */
93
+ export function sanitizeUrl(url) {
94
+ if (typeof url !== 'string') return null;
95
+ const trimmed = url.trim();
96
+ if (!UNSAFE_SCHEMES.has(schemeOf(trimmed))) return trimmed;
97
+ return RE_SAFE_DATA_IMAGE.test(trimmed) ? trimmed : null;
98
+ }
99
+
100
+ /**
101
+ * Characters a URL attribute may carry literally: the RFC 3986 reserved
102
+ * and unreserved sets. Everything else is percent-encoded, byte by byte,
103
+ * from its UTF-8 encoding.
104
+ */
105
+ const URL_LITERAL = new Uint8Array(128);
106
+ for (const ch of "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789;/?:@&=+$,-_.!~*'()#")
107
+ URL_LITERAL[ch.charCodeAt(0)] = 1;
108
+
109
+ /** An already-encoded octet: `%` followed by two hex digits. */
110
+ const RE_PCT_OCTET = /^%[0-9A-Fa-f]{2}/;
111
+
112
+ /**
113
+ * Percent-encode a URL for a `href`/`src` attribute.
114
+ *
115
+ * Authored text is not a URL: a Markdown destination may hold spaces,
116
+ * backslashes, backticks or any non-ASCII character, and writing those
117
+ * into an attribute produces a link a browser resolves differently than
118
+ * the author wrote — or not at all. This maps them to their UTF-8
119
+ * percent-encoding while leaving the reserved characters that carry URL
120
+ * *structure* (`/?:@&=+$,#`) alone.
121
+ *
122
+ * An existing `%XX` is passed through rather than re-encoded to `%25XX`,
123
+ * so a destination an author already encoded survives a round trip; a
124
+ * lone `%` that does not begin an octet is encoded. Sanitizing and
125
+ * encoding are separate steps on purpose — encode what
126
+ * {@link sanitizeUrl} returned, never the other way around, or an
127
+ * unsafe scheme could hide behind an escape.
128
+ *
129
+ * @param {string} url a URL that has already passed a sanitizer
130
+ * @returns {string} the attribute-ready URL
131
+ */
132
+ export function encodeUrlAttribute(url) {
133
+ let out = '';
134
+ for (let i = 0; i < url.length; i++) {
135
+ const code = url.charCodeAt(i);
136
+ if (code < 128 && URL_LITERAL[code] === 1) {
137
+ out += url[i];
138
+ continue;
139
+ }
140
+ if (code === 0x25 /* % */ && RE_PCT_OCTET.test(url.slice(i, i + 3))) {
141
+ out += url.slice(i, i + 3);
142
+ i += 2;
143
+ continue;
144
+ }
145
+ // one code POINT: a surrogate pair is one UTF-8 sequence, and
146
+ // encoding its halves separately would emit two invalid ones
147
+ const point = url.codePointAt(i);
148
+ const char = String.fromCodePoint(/** @type {number} */ (point));
149
+ i += char.length - 1;
150
+ for (const byte of new TextEncoder().encode(char))
151
+ out += '%' + byte.toString(16).toUpperCase().padStart(2, '0');
152
+ }
153
+ return out;
154
+ }