@zombie-mermaid/core 2.2.1
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/LICENSE +22 -0
- package/dist/index.cjs +29 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1236 -0
- package/dist/index.d.ts +1236 -0
- package/dist/index.js +888 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/click-directive.test.ts +63 -0
- package/src/__tests__/color-utils.test.ts +186 -0
- package/src/__tests__/diagram-type.test.ts +28 -0
- package/src/__tests__/text-metrics.test.ts +486 -0
- package/src/__tests__/theme-palette-contrast.test.ts +84 -0
- package/src/__tests__/theme.test.ts +162 -0
- package/src/click-directive.ts +106 -0
- package/src/color-utils.ts +205 -0
- package/src/diagram-type.ts +35 -0
- package/src/direction-override.ts +46 -0
- package/src/direction.ts +56 -0
- package/src/generated/mono-font-subset.ts +32 -0
- package/src/index.ts +34 -0
- package/src/init-directive.ts +248 -0
- package/src/multiline-utils.ts +275 -0
- package/src/statements.ts +197 -0
- package/src/style-directives.ts +214 -0
- package/src/text-metrics.ts +520 -0
- package/src/theme.ts +712 -0
- package/src/types.ts +670 -0
package/src/theme.ts
ADDED
|
@@ -0,0 +1,712 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Theme system — CSS custom property-based theming for mermaid SVG diagrams.
|
|
3
|
+
//
|
|
4
|
+
// Architecture:
|
|
5
|
+
// - Two required variables: --bg (background) and --fg (foreground)
|
|
6
|
+
// - Five optional enrichment variables: --line, --accent, --muted, --surface, --border
|
|
7
|
+
// - Unset optionals fall back to color-mix() derivations from bg + fg
|
|
8
|
+
// - All derived values computed in a <style> block inside the SVG
|
|
9
|
+
//
|
|
10
|
+
// This means the SVG is a function of its CSS variables. The caller provides
|
|
11
|
+
// colors, and the SVG adapts. No light/dark mode detection needed.
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
import { escapeXml, escapeAttr } from './multiline-utils.ts'
|
|
15
|
+
import { parseHexColor } from './color-utils.ts'
|
|
16
|
+
import {
|
|
17
|
+
MONO_FONT_FAMILY as SVG_MONO_FONT_FAMILY,
|
|
18
|
+
MONO_FONT_FACE_CSS as SVG_MONO_FONT_FACE_CSS,
|
|
19
|
+
} from './generated/mono-font-subset.ts'
|
|
20
|
+
|
|
21
|
+
// ============================================================================
|
|
22
|
+
// Types
|
|
23
|
+
// ============================================================================
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* How the renderers emit their two inline-style surfaces — the `<style>`
|
|
27
|
+
* element(s) and the root `<svg style="--bg: …">` attribute. Both exist so a
|
|
28
|
+
* host page with a strict `Content-Security-Policy` (`style-src` without
|
|
29
|
+
* `'unsafe-inline'`) can still render diagrams with their colours intact;
|
|
30
|
+
* see `RenderOptions.nonce` / `RenderOptions.styleAttribute` in packages/core/src/types.ts
|
|
31
|
+
* and GitHub issue #216. Threaded as one object through every diagram
|
|
32
|
+
* renderer so the two options can't drift apart per diagram type.
|
|
33
|
+
*/
|
|
34
|
+
export interface SvgEmitOptions {
|
|
35
|
+
/** Value for a `nonce` attribute on every emitted `<style>` element. */
|
|
36
|
+
nonce?: string
|
|
37
|
+
/** Emit the root `style="--bg: …"` attribute. Default: true. */
|
|
38
|
+
styleAttribute?: boolean
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Diagram color configuration.
|
|
43
|
+
*
|
|
44
|
+
* Required: bg + fg give you a clean mono diagram.
|
|
45
|
+
* Optional: line, accent, muted, surface, border bring in richer color
|
|
46
|
+
* from Shiki themes or custom palettes. Each falls back to a color-mix()
|
|
47
|
+
* derivation from bg + fg if not set.
|
|
48
|
+
*/
|
|
49
|
+
export interface DiagramColors {
|
|
50
|
+
/** Background color → CSS variable --bg */
|
|
51
|
+
bg: string
|
|
52
|
+
/** Foreground / primary text color → CSS variable --fg */
|
|
53
|
+
fg: string
|
|
54
|
+
|
|
55
|
+
// -- Optional enrichment (each falls back to color-mix from bg+fg) --
|
|
56
|
+
|
|
57
|
+
/** Edge/connector color → CSS variable --line */
|
|
58
|
+
line?: string
|
|
59
|
+
/** Arrow heads, highlights, special nodes → CSS variable --accent */
|
|
60
|
+
accent?: string
|
|
61
|
+
/** Secondary text, edge labels → CSS variable --muted */
|
|
62
|
+
muted?: string
|
|
63
|
+
/** Node/box fill tint → CSS variable --surface */
|
|
64
|
+
surface?: string
|
|
65
|
+
/** Node/group stroke color → CSS variable --border */
|
|
66
|
+
border?: string
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ============================================================================
|
|
70
|
+
// Defaults
|
|
71
|
+
// ============================================================================
|
|
72
|
+
|
|
73
|
+
/** Default bg/fg when no colors are provided (zinc light) */
|
|
74
|
+
export const DEFAULTS: Readonly<{ bg: string; fg: string }> = {
|
|
75
|
+
bg: '#FFFFFF',
|
|
76
|
+
fg: '#27272A',
|
|
77
|
+
} as const
|
|
78
|
+
|
|
79
|
+
// ============================================================================
|
|
80
|
+
// color-mix() weights for derived CSS variables
|
|
81
|
+
//
|
|
82
|
+
// When an optional enrichment variable is NOT set, we compute the derived
|
|
83
|
+
// value by mixing --fg into --bg at these percentages. This produces a
|
|
84
|
+
// coherent mono hierarchy on any bg/fg combination.
|
|
85
|
+
// ============================================================================
|
|
86
|
+
|
|
87
|
+
export const MIX = {
|
|
88
|
+
/** Primary text: near-full fg */
|
|
89
|
+
text: 100, // just use --fg directly
|
|
90
|
+
/** Secondary text (group headers): fg mixed at 60% */
|
|
91
|
+
textSec: 60,
|
|
92
|
+
/** Muted text (edge labels, notes): fg mixed at 40% */
|
|
93
|
+
textMuted: 40,
|
|
94
|
+
/** Faint text (de-emphasized): fg mixed at 25% */
|
|
95
|
+
textFaint: 25,
|
|
96
|
+
/** Edge/connector lines: fg mixed at 50% for clear visibility */
|
|
97
|
+
line: 50,
|
|
98
|
+
/** Arrow head fill: fg mixed at 85% for clear visibility */
|
|
99
|
+
arrow: 85,
|
|
100
|
+
/** Node fill tint: fg mixed at 3% */
|
|
101
|
+
nodeFill: 3,
|
|
102
|
+
/** Node/group stroke: fg mixed at 20% */
|
|
103
|
+
nodeStroke: 20,
|
|
104
|
+
/** Group header band tint: fg mixed at 5% */
|
|
105
|
+
groupHeader: 5,
|
|
106
|
+
/** Inner divider strokes: fg mixed at 12% */
|
|
107
|
+
innerStroke: 12,
|
|
108
|
+
/** Key badge background opacity (ER diagrams) */
|
|
109
|
+
keyBadge: 10,
|
|
110
|
+
} as const
|
|
111
|
+
|
|
112
|
+
// ============================================================================
|
|
113
|
+
// Well-known theme palettes
|
|
114
|
+
//
|
|
115
|
+
// Curated bg/fg pairs (+ optional enrichment) for popular editor themes.
|
|
116
|
+
// Users can also extract from Shiki theme objects via fromShikiTheme().
|
|
117
|
+
// ============================================================================
|
|
118
|
+
|
|
119
|
+
export const THEMES: Record<string, DiagramColors> = {
|
|
120
|
+
'zinc-light': {
|
|
121
|
+
bg: '#FFFFFF',
|
|
122
|
+
fg: '#27272A',
|
|
123
|
+
},
|
|
124
|
+
'zinc-dark': {
|
|
125
|
+
bg: '#18181B',
|
|
126
|
+
fg: '#FAFAFA',
|
|
127
|
+
},
|
|
128
|
+
'tokyo-night': {
|
|
129
|
+
bg: '#1a1b26',
|
|
130
|
+
fg: '#a9b1d6',
|
|
131
|
+
line: '#3d59a1',
|
|
132
|
+
accent: '#7aa2f7',
|
|
133
|
+
muted: '#565f89',
|
|
134
|
+
},
|
|
135
|
+
'tokyo-night-storm': {
|
|
136
|
+
bg: '#24283b',
|
|
137
|
+
fg: '#a9b1d6',
|
|
138
|
+
line: '#3d59a1',
|
|
139
|
+
accent: '#7aa2f7',
|
|
140
|
+
muted: '#565f89',
|
|
141
|
+
},
|
|
142
|
+
'tokyo-night-light': {
|
|
143
|
+
bg: '#d5d6db',
|
|
144
|
+
fg: '#343b58',
|
|
145
|
+
line: '#34548a',
|
|
146
|
+
accent: '#34548a',
|
|
147
|
+
muted: '#9699a3',
|
|
148
|
+
},
|
|
149
|
+
'catppuccin-mocha': {
|
|
150
|
+
bg: '#1e1e2e',
|
|
151
|
+
fg: '#cdd6f4',
|
|
152
|
+
line: '#585b70',
|
|
153
|
+
accent: '#cba6f7',
|
|
154
|
+
muted: '#6c7086',
|
|
155
|
+
},
|
|
156
|
+
'catppuccin-latte': {
|
|
157
|
+
bg: '#eff1f5',
|
|
158
|
+
fg: '#4c4f69',
|
|
159
|
+
line: '#9ca0b0',
|
|
160
|
+
accent: '#8839ef',
|
|
161
|
+
muted: '#9ca0b0',
|
|
162
|
+
},
|
|
163
|
+
nord: {
|
|
164
|
+
bg: '#2e3440',
|
|
165
|
+
fg: '#d8dee9',
|
|
166
|
+
line: '#4c566a',
|
|
167
|
+
accent: '#88c0d0',
|
|
168
|
+
muted: '#616e88',
|
|
169
|
+
},
|
|
170
|
+
'nord-light': {
|
|
171
|
+
bg: '#eceff4',
|
|
172
|
+
fg: '#2e3440',
|
|
173
|
+
line: '#aab1c0',
|
|
174
|
+
accent: '#5e81ac',
|
|
175
|
+
muted: '#7b88a1',
|
|
176
|
+
},
|
|
177
|
+
dracula: {
|
|
178
|
+
bg: '#282a36',
|
|
179
|
+
fg: '#f8f8f2',
|
|
180
|
+
line: '#6272a4',
|
|
181
|
+
accent: '#bd93f9',
|
|
182
|
+
muted: '#6272a4',
|
|
183
|
+
},
|
|
184
|
+
'github-light': {
|
|
185
|
+
bg: '#ffffff',
|
|
186
|
+
fg: '#1f2328',
|
|
187
|
+
line: '#d1d9e0',
|
|
188
|
+
accent: '#0969da',
|
|
189
|
+
muted: '#59636e',
|
|
190
|
+
},
|
|
191
|
+
'github-dark': {
|
|
192
|
+
bg: '#0d1117',
|
|
193
|
+
fg: '#e6edf3',
|
|
194
|
+
line: '#3d444d',
|
|
195
|
+
accent: '#4493f8',
|
|
196
|
+
muted: '#9198a1',
|
|
197
|
+
},
|
|
198
|
+
'solarized-light': {
|
|
199
|
+
bg: '#fdf6e3',
|
|
200
|
+
fg: '#657b83',
|
|
201
|
+
line: '#93a1a1',
|
|
202
|
+
accent: '#268bd2',
|
|
203
|
+
muted: '#93a1a1',
|
|
204
|
+
},
|
|
205
|
+
'solarized-dark': {
|
|
206
|
+
bg: '#002b36',
|
|
207
|
+
fg: '#839496',
|
|
208
|
+
line: '#586e75',
|
|
209
|
+
accent: '#268bd2',
|
|
210
|
+
muted: '#586e75',
|
|
211
|
+
},
|
|
212
|
+
'one-dark': {
|
|
213
|
+
bg: '#282c34',
|
|
214
|
+
fg: '#abb2bf',
|
|
215
|
+
line: '#4b5263',
|
|
216
|
+
accent: '#c678dd',
|
|
217
|
+
muted: '#5c6370',
|
|
218
|
+
},
|
|
219
|
+
} as const
|
|
220
|
+
|
|
221
|
+
export type ThemeName = keyof typeof THEMES
|
|
222
|
+
|
|
223
|
+
// ============================================================================
|
|
224
|
+
// Shiki theme extraction
|
|
225
|
+
//
|
|
226
|
+
// Extracts DiagramColors from a Shiki ThemeRegistrationResolved object.
|
|
227
|
+
// This provides native compatibility with any VS Code / TextMate theme.
|
|
228
|
+
// ============================================================================
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Minimal subset of Shiki's ThemeRegistrationResolved that we need.
|
|
232
|
+
* We don't import from shiki to avoid a hard dependency.
|
|
233
|
+
*/
|
|
234
|
+
interface ShikiThemeLike {
|
|
235
|
+
type?: string
|
|
236
|
+
colors?: Record<string, string>
|
|
237
|
+
tokenColors?: Array<{
|
|
238
|
+
scope?: string | string[]
|
|
239
|
+
settings?: { foreground?: string }
|
|
240
|
+
}>
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Extract diagram colors from a Shiki theme object.
|
|
245
|
+
* Works with any VS Code / TextMate theme loaded by Shiki.
|
|
246
|
+
*
|
|
247
|
+
* Maps editor UI colors to diagram roles:
|
|
248
|
+
* editor.background → bg
|
|
249
|
+
* editor.foreground → fg
|
|
250
|
+
* editorLineNumber.fg → line (optional)
|
|
251
|
+
* focusBorder / keyword → accent (optional)
|
|
252
|
+
* comment token → muted (optional)
|
|
253
|
+
* editor.selectionBackground→ surface (optional)
|
|
254
|
+
* editorWidget.border → border (optional)
|
|
255
|
+
*
|
|
256
|
+
* @example
|
|
257
|
+
* ```ts
|
|
258
|
+
* import { getSingletonHighlighter } from 'shiki'
|
|
259
|
+
* import { fromShikiTheme } from 'zombie-mermaid'
|
|
260
|
+
*
|
|
261
|
+
* const hl = await getSingletonHighlighter({ themes: ['tokyo-night'] })
|
|
262
|
+
* const colors = fromShikiTheme(hl.getTheme('tokyo-night'))
|
|
263
|
+
* const svg = renderMermaidSVG(code, colors)
|
|
264
|
+
* ```
|
|
265
|
+
*/
|
|
266
|
+
export function fromShikiTheme(theme: ShikiThemeLike): DiagramColors {
|
|
267
|
+
const c = theme.colors ?? {}
|
|
268
|
+
const dark = theme.type === 'dark'
|
|
269
|
+
|
|
270
|
+
// Helper: find a token color by scope name
|
|
271
|
+
const tokenColor = (scope: string): string | undefined =>
|
|
272
|
+
theme.tokenColors?.find((t) =>
|
|
273
|
+
Array.isArray(t.scope) ? t.scope.includes(scope) : t.scope === scope,
|
|
274
|
+
)?.settings?.foreground
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
bg: c['editor.background'] ?? (dark ? '#1e1e1e' : '#ffffff'),
|
|
278
|
+
fg: c['editor.foreground'] ?? (dark ? '#d4d4d4' : '#333333'),
|
|
279
|
+
line: c['editorLineNumber.foreground'] ?? undefined,
|
|
280
|
+
accent: c['focusBorder'] ?? tokenColor('keyword') ?? undefined,
|
|
281
|
+
muted:
|
|
282
|
+
tokenColor('comment') ?? c['editorLineNumber.foreground'] ?? undefined,
|
|
283
|
+
surface: c['editor.selectionBackground'] ?? undefined,
|
|
284
|
+
border: c['editorWidget.border'] ?? undefined,
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// ============================================================================
|
|
289
|
+
// SVG style block — the CSS variable derivation system
|
|
290
|
+
//
|
|
291
|
+
// Generates the <style> content that maps user-facing variables (--bg, --fg,
|
|
292
|
+
// --line, etc.) to internal derived variables (--_text, --_line, etc.) using
|
|
293
|
+
// color-mix() fallbacks.
|
|
294
|
+
// ============================================================================
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Characters that must never appear in a `font` value once it's embedded in
|
|
298
|
+
* the generated `<style>` block — whether it's treated as a CSS `var()`
|
|
299
|
+
* reference or as a literal font name. Angle brackets would let the value
|
|
300
|
+
* break out of the `<style>...</style>` element (e.g. a literal `</style>`
|
|
301
|
+
* or an injected `<script>`); semicolons/braces would let it terminate the
|
|
302
|
+
* current CSS declaration/rule and inject new ones.
|
|
303
|
+
*/
|
|
304
|
+
const UNSAFE_FONT_CHARS_RE = /[<>{};]/
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Detect a syntactically well-formed CSS `var()` reference, e.g.
|
|
308
|
+
* `var(--font-family-body)` or `var(--font, 'Fallback Font')` (a `var()`
|
|
309
|
+
* with a quoted fallback argument). Requires balanced parens and rejects
|
|
310
|
+
* any of `UNSAFE_FONT_CHARS_RE`.
|
|
311
|
+
*
|
|
312
|
+
* `font.startsWith('var(')` alone isn't enough: a caller could pass
|
|
313
|
+
* something that merely starts with `var(` but isn't actually one (e.g.
|
|
314
|
+
* `var(--x); } .evil{...}`). Anything that fails this stricter check is
|
|
315
|
+
* treated as an untrusted/malformed value and falls back to being quoted
|
|
316
|
+
* as a literal font name below — which neutralizes it, since a quoted
|
|
317
|
+
* string isn't parsed as a `var()` call by CSS.
|
|
318
|
+
*/
|
|
319
|
+
function isSafeCssVarReference(font: string): boolean {
|
|
320
|
+
const trimmed = font.trim()
|
|
321
|
+
if (!trimmed.startsWith('var(') || !trimmed.endsWith(')')) return false
|
|
322
|
+
if (UNSAFE_FONT_CHARS_RE.test(trimmed)) return false
|
|
323
|
+
let depth = 0
|
|
324
|
+
for (const ch of trimmed) {
|
|
325
|
+
if (ch === '(') depth++
|
|
326
|
+
else if (ch === ')') {
|
|
327
|
+
depth--
|
|
328
|
+
if (depth < 0) return false
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return depth === 0
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* CSS generic font-family keywords. These are resolved by the browser/user
|
|
336
|
+
* agent to a locally available font and are never real font names a Google
|
|
337
|
+
* Fonts `@import` could fetch.
|
|
338
|
+
*/
|
|
339
|
+
const GENERIC_FONT_FAMILIES = new Set([
|
|
340
|
+
'sans-serif',
|
|
341
|
+
'serif',
|
|
342
|
+
'monospace',
|
|
343
|
+
'system-ui',
|
|
344
|
+
'ui-sans-serif',
|
|
345
|
+
'ui-serif',
|
|
346
|
+
'ui-monospace',
|
|
347
|
+
'ui-rounded',
|
|
348
|
+
'cursive',
|
|
349
|
+
'fantasy',
|
|
350
|
+
'math',
|
|
351
|
+
'emoji',
|
|
352
|
+
'fangsong',
|
|
353
|
+
])
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Detect a `font` value that names a font *stack* (comma-separated list,
|
|
357
|
+
* e.g. `"ui-sans-serif, system-ui, sans-serif"`) or a single CSS generic
|
|
358
|
+
* family keyword (e.g. `"system-ui"`) rather than a single concrete font
|
|
359
|
+
* name. Neither is something a Google Fonts `@import` could ever
|
|
360
|
+
* successfully fetch: a stack has no single `family=` value to request, and
|
|
361
|
+
* a generic keyword is resolved locally by the browser, not hosted by
|
|
362
|
+
* Google Fonts. Skipping the `@import` for these avoids baking a dead,
|
|
363
|
+
* always-404 request into the SVG (see #223).
|
|
364
|
+
*/
|
|
365
|
+
function isFontStackOrGenericFamily(font: string): boolean {
|
|
366
|
+
const trimmed = font.trim()
|
|
367
|
+
if (trimmed.includes(',')) return true
|
|
368
|
+
return GENERIC_FONT_FAMILIES.has(trimmed.toLowerCase())
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/**
|
|
372
|
+
* The opening `<style>` tag every renderer uses — with a `nonce` attribute
|
|
373
|
+
* when the caller supplied one (see `RenderOptions.nonce`, #216).
|
|
374
|
+
*
|
|
375
|
+
* All `<style>` emission points go through this one function so a nonce
|
|
376
|
+
* can't be missed on one of them: under a nonce-based CSP a single
|
|
377
|
+
* un-nonced `<style>` is silently dropped by the browser, which for the
|
|
378
|
+
* theme block means an unstyled diagram with no console hint as to why.
|
|
379
|
+
*
|
|
380
|
+
* An empty/whitespace-only nonce is treated as unset — `nonce=""` would
|
|
381
|
+
* authorise nothing and only mislead a reader into thinking it did.
|
|
382
|
+
* The value is attribute-escaped; a real nonce is base64 and never needs
|
|
383
|
+
* escaping, but the option is a plain string and this keeps a stray `"`
|
|
384
|
+
* from breaking out of the attribute.
|
|
385
|
+
*/
|
|
386
|
+
export function styleOpenTag(nonce?: string): string {
|
|
387
|
+
if (nonce === undefined || nonce.trim() === '') return '<style>'
|
|
388
|
+
return `<style nonce="${escapeAttr(nonce)}">`
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* Build the CSS variable derivation rules for the SVG <style> block.
|
|
393
|
+
*
|
|
394
|
+
* When an optional variable (--line, --accent, etc.) is set on the SVG or
|
|
395
|
+
* a parent element, it's used directly. When unset, the fallback computes
|
|
396
|
+
* a blended value from --fg and --bg using color-mix().
|
|
397
|
+
*
|
|
398
|
+
* `nonce`, when given, lands on the `<style>` element as a `nonce`
|
|
399
|
+
* attribute — see `styleOpenTag()`.
|
|
400
|
+
*
|
|
401
|
+
* `font` is user-supplied input. When it's a CSS `var(...)` reference (e.g.
|
|
402
|
+
* `var(--font-family-body)`, letting the SVG inherit a host page's
|
|
403
|
+
* design-system font), the Google Fonts `@import` is skipped — it would
|
|
404
|
+
* otherwise URL-encode the literal `var(...)` string into a `family=` query
|
|
405
|
+
* param and produce a guaranteed no-op request — and the value is emitted
|
|
406
|
+
* unquoted, since a quoted string isn't parsed as a `var()` call by CSS.
|
|
407
|
+
*
|
|
408
|
+
* The `@import` is likewise skipped when `font` is a font *stack* (a
|
|
409
|
+
* comma-separated list, e.g. `"ui-sans-serif, system-ui, sans-serif"`) or a
|
|
410
|
+
* single CSS generic family keyword (e.g. `"system-ui"`) — see
|
|
411
|
+
* `isFontStackOrGenericFamily`. Neither names a single concrete font that
|
|
412
|
+
* Google Fonts could host, so importing one always produces a dead,
|
|
413
|
+
* always-404 request (#223). This is additive to the `var()` skip above,
|
|
414
|
+
* and only affects the `@import` decision — the `text { font-family: ... }`
|
|
415
|
+
* rule still renders the literal value exactly as it does today.
|
|
416
|
+
*
|
|
417
|
+
* Any other value is treated as a literal font name: sanitized and quoted
|
|
418
|
+
* exactly as before.
|
|
419
|
+
*
|
|
420
|
+
* `hasMonoFont`'s `.mono` rule (class-diagram method signatures, ER-diagram
|
|
421
|
+
* attribute types) never reaches out to Google Fonts at all — it used to
|
|
422
|
+
* `@import` 'JetBrains Mono' from Google's CDN, a third-party network
|
|
423
|
+
* dependency a published library's default SVG output had no business
|
|
424
|
+
* making (#1061, filed as a follow-up from #1059's ASCII-side self-hosting
|
|
425
|
+
* fix). It now embeds {@link SVG_MONO_FONT_FACE_CSS} — a small, subsetted
|
|
426
|
+
* (Basic Latin + Latin-1 Supplement), self-hosted `@font-face` as a base64
|
|
427
|
+
* `woff2` data URI, generated by scripts/build-mono-font-subset.ts into
|
|
428
|
+
* ./generated/mono-font-subset.ts — directly in the SVG's own `<style>`
|
|
429
|
+
* block. Unlike `font` above, this isn't caller-configurable: it's the same
|
|
430
|
+
* embed for every consumer, on by default, with no network request either
|
|
431
|
+
* way — a strict improvement over the dead-or-alive CDN fetch it replaces,
|
|
432
|
+
* and the only way a *standalone* SVG (no surrounding host page providing
|
|
433
|
+
* its own copy of the font) renders `.mono` text correctly out of the box.
|
|
434
|
+
*/
|
|
435
|
+
export function buildStyleBlock(
|
|
436
|
+
font: string,
|
|
437
|
+
hasMonoFont: boolean,
|
|
438
|
+
nonce?: string,
|
|
439
|
+
): string {
|
|
440
|
+
const isVarReference = isSafeCssVarReference(font)
|
|
441
|
+
// Literal font names are sanitized before quoting (strip characters that
|
|
442
|
+
// could break out of the CSS string or the <style> block). A validated
|
|
443
|
+
// var() reference is passed through as-is so `var(--x, 'Fallback')` keeps
|
|
444
|
+
// its own internal quotes intact.
|
|
445
|
+
const safeFont = isVarReference ? font.trim() : font.replace(/[<>{};'"]/g, '')
|
|
446
|
+
const skipImport = isVarReference || isFontStackOrGenericFamily(font)
|
|
447
|
+
|
|
448
|
+
const fontImports = skipImport
|
|
449
|
+
? []
|
|
450
|
+
: [
|
|
451
|
+
`@import url('https://fonts.googleapis.com/css2?family=${encodeURIComponent(safeFont)}:wght@400;500;600;700&display=swap');`,
|
|
452
|
+
]
|
|
453
|
+
|
|
454
|
+
// Derived CSS variables: use override if set, else mix from bg+fg.
|
|
455
|
+
// The --_ prefix signals "private/derived" — not meant for external override.
|
|
456
|
+
const derivedVars = `
|
|
457
|
+
/* Derived from --bg and --fg (overridable via --line, --accent, etc.) */
|
|
458
|
+
--_text: var(--fg);
|
|
459
|
+
--_text-sec: var(--muted, color-mix(in srgb, var(--fg) ${MIX.textSec}%, var(--bg)));
|
|
460
|
+
--_text-muted: var(--muted, color-mix(in srgb, var(--fg) ${MIX.textMuted}%, var(--bg)));
|
|
461
|
+
--_text-faint: color-mix(in srgb, var(--fg) ${MIX.textFaint}%, var(--bg));
|
|
462
|
+
--_line: var(--line, color-mix(in srgb, var(--fg) ${MIX.line}%, var(--bg)));
|
|
463
|
+
--_arrow: var(--accent, color-mix(in srgb, var(--fg) ${MIX.arrow}%, var(--bg)));
|
|
464
|
+
--_node-fill: var(--surface, color-mix(in srgb, var(--fg) ${MIX.nodeFill}%, var(--bg)));
|
|
465
|
+
--_node-stroke: var(--border, color-mix(in srgb, var(--fg) ${MIX.nodeStroke}%, var(--bg)));
|
|
466
|
+
--_group-fill: var(--bg);
|
|
467
|
+
--_group-hdr: color-mix(in srgb, var(--fg) ${MIX.groupHeader}%, var(--bg));
|
|
468
|
+
--_inner-stroke: color-mix(in srgb, var(--fg) ${MIX.innerStroke}%, var(--bg));
|
|
469
|
+
--_key-badge: color-mix(in srgb, var(--fg) ${MIX.keyBadge}%, var(--bg));`
|
|
470
|
+
|
|
471
|
+
return [
|
|
472
|
+
styleOpenTag(nonce),
|
|
473
|
+
...(fontImports.length > 0 ? [` ${fontImports.join('\n ')}`] : []),
|
|
474
|
+
// Self-hosted, embedded @font-face for `.mono` text — see the doc
|
|
475
|
+
// comment above for why this never goes through the CDN-import/skip
|
|
476
|
+
// logic `font` above does.
|
|
477
|
+
...(hasMonoFont ? [` ${SVG_MONO_FONT_FACE_CSS}`] : []),
|
|
478
|
+
` text { font-family: ${isVarReference ? safeFont : `'${safeFont}'`}, system-ui, sans-serif; }`,
|
|
479
|
+
...(hasMonoFont
|
|
480
|
+
? [
|
|
481
|
+
` .mono { font-family: '${SVG_MONO_FONT_FAMILY}', 'SF Mono', 'Fira Code', ui-monospace, monospace; }`,
|
|
482
|
+
]
|
|
483
|
+
: []),
|
|
484
|
+
` svg {${derivedVars}`,
|
|
485
|
+
` }`,
|
|
486
|
+
'</style>',
|
|
487
|
+
].join('\n')
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
// ============================================================================
|
|
491
|
+
// Fill → text color contrast
|
|
492
|
+
//
|
|
493
|
+
// When a node has a custom `fill` (from classDef/style) but no explicit
|
|
494
|
+
// `color`, defaulting text to the ambient theme foreground can be unreadable
|
|
495
|
+
// (e.g. white text on a light pastel fill in dark mode). If the fill is a
|
|
496
|
+
// concrete, resolvable color (hex), compute a readable black/white text
|
|
497
|
+
// color from its perceptual luminance. Anything else (CSS variable
|
|
498
|
+
// references, named colors, malformed values) is left alone — the caller
|
|
499
|
+
// falls back to the theme foreground.
|
|
500
|
+
// ============================================================================
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Perceptual luminance of an sRGB color on a 0-1 scale (0 = black, 1 =
|
|
504
|
+
* white), using the ITU-R BT.601 luma weights (0.299/0.587/0.114).
|
|
505
|
+
*
|
|
506
|
+
* This is the simple, non-gamma-corrected "perceived brightness" formula
|
|
507
|
+
* (as opposed to the gamma-corrected WCAG relative-luminance formula used
|
|
508
|
+
* for contrast-ratio math). It's the right tool here: we just need a
|
|
509
|
+
* black-vs-white pick, and BT.601 luma crosses black/white legibility at
|
|
510
|
+
* ~0.5 on its own 0-1 scale, so the threshold in getReadableTextColor()
|
|
511
|
+
* lines up directly with the formula instead of needing a separately
|
|
512
|
+
* derived crossover constant. (True WCAG relative luminance would need a
|
|
513
|
+
* ~0.18 threshold, not 0.5, to give the same black/white picks — see
|
|
514
|
+
* https://www.w3.org/TR/WCAG20/#relativeluminancedef.)
|
|
515
|
+
*/
|
|
516
|
+
function perceptualLuminance(r: number, g: number, b: number): number {
|
|
517
|
+
return (0.299 * r + 0.587 * g + 0.114 * b) / 255
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Pick a readable text color for a given fill color.
|
|
522
|
+
*
|
|
523
|
+
* If `fill` is a concrete, resolvable hex color, returns pure black or
|
|
524
|
+
* white depending on the fill's perceptual luminance (threshold 0.5: light
|
|
525
|
+
* fills get dark text, dark fills get light text). If `fill` is undefined
|
|
526
|
+
* or isn't a resolvable color (a `var(...)` reference, a named CSS color,
|
|
527
|
+
* or malformed input), returns `fallback` unchanged so callers don't guess
|
|
528
|
+
* at colors they can't actually evaluate.
|
|
529
|
+
*/
|
|
530
|
+
export function getReadableTextColor(
|
|
531
|
+
fill: string | undefined,
|
|
532
|
+
fallback: string,
|
|
533
|
+
): string {
|
|
534
|
+
if (!fill) return fallback
|
|
535
|
+
const rgb = parseHexColor(fill)
|
|
536
|
+
if (!rgb) return fallback
|
|
537
|
+
const luminance = perceptualLuminance(...rgb)
|
|
538
|
+
return luminance > 0.5 ? '#000000' : '#FFFFFF'
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* Page-unique suffix generator for the root accessible-name `<title>` id.
|
|
543
|
+
*
|
|
544
|
+
* Mirrors `markerSuffix()` in packages/svg-renderer/src/renderer.ts, which solves the same
|
|
545
|
+
* "multiple SVGs inlined into one HTML page share a single id namespace"
|
|
546
|
+
* problem for arrow-marker ids — a marker's id is sanitized from its stroke
|
|
547
|
+
* color, since two markers with the same color are meant to share one
|
|
548
|
+
* definition. A title has no such natural per-call input (two diagrams can
|
|
549
|
+
* easily have the same title text and must still get distinct ids), so this
|
|
550
|
+
* increments a module-level counter instead: every `svgOpenTag()` call that
|
|
551
|
+
* renders a title gets a fresh, never-repeated id for the lifetime of the
|
|
552
|
+
* process, which is exactly what's needed for `aria-labelledby` to resolve
|
|
553
|
+
* correctly when several rendered diagrams are concatenated into one page.
|
|
554
|
+
*/
|
|
555
|
+
let titleIdCounter = 0
|
|
556
|
+
|
|
557
|
+
function nextTitleId(): string {
|
|
558
|
+
titleIdCounter += 1
|
|
559
|
+
return `zm-title-${titleIdCounter}`
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Test-only: reset the title-id counter so id assertions in tests don't
|
|
564
|
+
* depend on how many titled diagrams earlier tests in the same file rendered.
|
|
565
|
+
* Not part of the public API (not re-exported from src/index.ts).
|
|
566
|
+
*/
|
|
567
|
+
export function __resetSvgTitleIdCounterForTests(): void {
|
|
568
|
+
titleIdCounter = 0
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* The exact CSS declaration list the root `<svg style="…">` attribute
|
|
573
|
+
* carries: the theme custom properties (`--bg`, `--fg`, plus whichever
|
|
574
|
+
* enrichment variables were actually provided — unset ones fall back to the
|
|
575
|
+
* color-mix() derivations in the `<style>` block) and, unless `transparent`,
|
|
576
|
+
* a `background: var(--bg)`.
|
|
577
|
+
*
|
|
578
|
+
* Exposed (via `themeCssVariables()` in src/index.ts) so a host that turns
|
|
579
|
+
* the attribute off with `styleAttribute: false` — because its
|
|
580
|
+
* Content-Security-Policy forbids `style=` attributes, which a nonce can
|
|
581
|
+
* never authorise (#216) — can put the very same declarations in its own
|
|
582
|
+
* stylesheet on a wrapper instead. Single source of truth for the variable
|
|
583
|
+
* list: `svgOpenTag()` and the helper both call this, so the two can't
|
|
584
|
+
* drift.
|
|
585
|
+
*
|
|
586
|
+
* Emitted compactly (`--bg:#fff;--fg:#000;background:var(--bg)`) rather
|
|
587
|
+
* than pretty-printed: it's the same string the attribute has always
|
|
588
|
+
* carried, so default output stays byte-identical. Colour values are the
|
|
589
|
+
* caller's own `RenderOptions` and are not escaped here — same as before.
|
|
590
|
+
*/
|
|
591
|
+
export function themeStyleDeclarations(
|
|
592
|
+
colors: DiagramColors,
|
|
593
|
+
transparent?: boolean,
|
|
594
|
+
): string {
|
|
595
|
+
const vars = [
|
|
596
|
+
`--bg:${colors.bg}`,
|
|
597
|
+
`--fg:${colors.fg}`,
|
|
598
|
+
colors.line ? `--line:${colors.line}` : '',
|
|
599
|
+
colors.accent ? `--accent:${colors.accent}` : '',
|
|
600
|
+
colors.muted ? `--muted:${colors.muted}` : '',
|
|
601
|
+
colors.surface ? `--surface:${colors.surface}` : '',
|
|
602
|
+
colors.border ? `--border:${colors.border}` : '',
|
|
603
|
+
]
|
|
604
|
+
.filter(Boolean)
|
|
605
|
+
.join(';')
|
|
606
|
+
|
|
607
|
+
const bgStyle = transparent ? '' : ';background:var(--bg)'
|
|
608
|
+
return `${vars}${bgStyle}`
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Build the SVG opening tag with CSS variables set as inline styles.
|
|
613
|
+
* Only includes optional variables that are actually provided — unset ones
|
|
614
|
+
* will fall back to the color-mix() derivations in the <style> block.
|
|
615
|
+
*
|
|
616
|
+
* `styleAttribute: false` drops the `style="…"` attribute entirely (the
|
|
617
|
+
* host supplies those declarations from its own stylesheet — see
|
|
618
|
+
* `themeStyleDeclarations()` and `RenderOptions.styleAttribute`, #216).
|
|
619
|
+
* Every other attribute — `xmlns`, `viewBox`, `width`/`height`, the
|
|
620
|
+
* `role`/`aria-*` accessibility attributes, and anything a caller splices
|
|
621
|
+
* on afterwards (`data-src`, `data-xychart-colors`) — is unaffected.
|
|
622
|
+
*
|
|
623
|
+
* Also handles the SVG's accessible name (see `RenderOptions.title` /
|
|
624
|
+
* `RenderOptions.decorative` in packages/core/src/types.ts, and GitHub issue #215):
|
|
625
|
+
*
|
|
626
|
+
* - `decorative: true` → `aria-hidden="true"` on the root, no `role` or
|
|
627
|
+
* name. Use for a diagram already described in surrounding prose; `title`
|
|
628
|
+
* is ignored when this is set.
|
|
629
|
+
* - `title` given (and not decorative) → `role="img"` +
|
|
630
|
+
* `aria-labelledby="zm-title-N"` on the root, plus a `<title id="zm-title-N">`
|
|
631
|
+
* as the SVG's first child holding the escaped text. This is the standard
|
|
632
|
+
* SVG/WAI-ARIA "accessible name via a referenced title element" technique
|
|
633
|
+
* (see MDN "SVG accessibility" and the WAI-ARIA `img` role: an element
|
|
634
|
+
* with `role="img"` needs a computed accessible name to be meaningful to
|
|
635
|
+
* assistive tech; `aria-labelledby` pointing at a `<title>` is the
|
|
636
|
+
* documented way to supply one for inline SVG).
|
|
637
|
+
* - Neither given → `role="img"` only, no name. This still stops assistive
|
|
638
|
+
* tech from treating the SVG as a plain group (which announces every
|
|
639
|
+
* node/edge label individually, out of reading order — the core bug in
|
|
640
|
+
* #215), without fabricating a name the library can't honestly claim. It
|
|
641
|
+
* reads the same as an `<img>` with no `alt`: present as a single image,
|
|
642
|
+
* unlabeled.
|
|
643
|
+
*
|
|
644
|
+
* `hasInteractiveLinks` overrides all of the above (see #239): a `click A
|
|
645
|
+
* "url"` statement renders a real, focusable `<a href>` inside the SVG, and
|
|
646
|
+
* both `role="img"` and `aria-hidden="true"` are unsafe on an ancestor of a
|
|
647
|
+
* focusable element — `role="img"` tells assistive tech to stop descending
|
|
648
|
+
* into children (the link becomes unreachable by screen reader while still
|
|
649
|
+
* Tab-focusable), and `aria-hidden="true"` on a focusable descendant is an
|
|
650
|
+
* explicit WAI-ARIA violation (the link vanishes from the accessibility tree
|
|
651
|
+
* but not from the tab order). When any node has a link, the root gets no
|
|
652
|
+
* `role` at all — `decorative` is silently overridden rather than honored,
|
|
653
|
+
* since aria-hiding a diagram that contains an actionable link is not a safe
|
|
654
|
+
* request to fulfill — but `title`/`aria-labelledby` still apply if given:
|
|
655
|
+
* an accessible name is still valid on an element with no explicit role.
|
|
656
|
+
*
|
|
657
|
+
* The per-node/per-point `<title>` tooltips added by earlier work (nested
|
|
658
|
+
* inside `<g>` elements, with no `id` attribute) are unaffected: they never
|
|
659
|
+
* collide with the root title's generated id, and a root `<title>` alongside
|
|
660
|
+
* unrelated descendant `<title>` elements is valid SVG.
|
|
661
|
+
*
|
|
662
|
+
* @param transparent - If true, omits the background style for transparent SVGs
|
|
663
|
+
* @param title - Accessible name text. Ignored when `decorative` is true
|
|
664
|
+
* and there are no interactive links (see `hasInteractiveLinks`).
|
|
665
|
+
* @param decorative - Marks the SVG as decorative (`aria-hidden="true"`).
|
|
666
|
+
* Overridden when `hasInteractiveLinks` is true.
|
|
667
|
+
* @param hasInteractiveLinks - True if any node has a `click`-based `href`.
|
|
668
|
+
* See #239 — forces no root `role` so the link
|
|
669
|
+
* stays reachable, regardless of `decorative`.
|
|
670
|
+
* @param styleAttribute - Emit the root `style="…"` attribute. Default true.
|
|
671
|
+
*/
|
|
672
|
+
export function svgOpenTag(
|
|
673
|
+
width: number,
|
|
674
|
+
height: number,
|
|
675
|
+
colors: DiagramColors,
|
|
676
|
+
transparent?: boolean,
|
|
677
|
+
title?: string,
|
|
678
|
+
decorative?: boolean,
|
|
679
|
+
hasInteractiveLinks?: boolean,
|
|
680
|
+
styleAttribute: boolean = true,
|
|
681
|
+
): string {
|
|
682
|
+
const styleAttr = styleAttribute
|
|
683
|
+
? ` style="${themeStyleDeclarations(colors, transparent)}"`
|
|
684
|
+
: ''
|
|
685
|
+
|
|
686
|
+
let a11yAttrs = ''
|
|
687
|
+
let titleEl = ''
|
|
688
|
+
if (hasInteractiveLinks) {
|
|
689
|
+
// No root role: role="img" or aria-hidden would hide a real, focusable
|
|
690
|
+
// <a href> descendant from assistive tech while leaving it Tab-reachable.
|
|
691
|
+
if (title) {
|
|
692
|
+
const id = nextTitleId()
|
|
693
|
+
a11yAttrs = ` aria-labelledby="${id}"`
|
|
694
|
+
titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
|
|
695
|
+
}
|
|
696
|
+
} else if (decorative) {
|
|
697
|
+
a11yAttrs = ' aria-hidden="true"'
|
|
698
|
+
} else {
|
|
699
|
+
a11yAttrs = ' role="img"'
|
|
700
|
+
if (title) {
|
|
701
|
+
const id = nextTitleId()
|
|
702
|
+
a11yAttrs += ` aria-labelledby="${id}"`
|
|
703
|
+
titleEl = `\n <title id="${id}">${escapeXml(title)}</title>`
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
return (
|
|
708
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${width} ${height}" ` +
|
|
709
|
+
`width="${width}" height="${height}"${a11yAttrs}${styleAttr}>` +
|
|
710
|
+
titleEl
|
|
711
|
+
)
|
|
712
|
+
}
|