@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
|
@@ -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
|
+
}
|