@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
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the theme/color-math helpers in packages/core/src/theme.ts.
|
|
3
|
+
*/
|
|
4
|
+
import { describe, it, expect } from 'vitest'
|
|
5
|
+
import { getReadableTextColor, buildStyleBlock } from '@zombie-mermaid/core'
|
|
6
|
+
|
|
7
|
+
describe('getReadableTextColor', () => {
|
|
8
|
+
it('returns dark text for a light pastel fill (6-digit hex)', () => {
|
|
9
|
+
expect(getReadableTextColor('#FF6B6B', 'var(--_text)')).toBe('#000000')
|
|
10
|
+
expect(getReadableTextColor('#90EE90', 'var(--_text)')).toBe('#000000')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('returns light text for a dark fill (6-digit hex)', () => {
|
|
14
|
+
expect(getReadableTextColor('#111111', 'var(--_text)')).toBe('#FFFFFF')
|
|
15
|
+
expect(getReadableTextColor('#000000', 'var(--_text)')).toBe('#FFFFFF')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('handles 3-digit shorthand hex', () => {
|
|
19
|
+
expect(getReadableTextColor('#fff', 'var(--_text)')).toBe('#000000')
|
|
20
|
+
expect(getReadableTextColor('#000', 'var(--_text)')).toBe('#FFFFFF')
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
it('ignores alpha channel in 4/8-digit hex and uses RGB luminance', () => {
|
|
24
|
+
expect(getReadableTextColor('#ffffffff', 'var(--_text)')).toBe('#000000')
|
|
25
|
+
expect(getReadableTextColor('#000000ff', 'var(--_text)')).toBe('#FFFFFF')
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('falls back when fill is undefined', () => {
|
|
29
|
+
expect(getReadableTextColor(undefined, 'var(--_text)')).toBe('var(--_text)')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('falls back when fill is a CSS variable reference', () => {
|
|
33
|
+
expect(getReadableTextColor('var(--surface)', 'var(--_text)')).toBe(
|
|
34
|
+
'var(--_text)',
|
|
35
|
+
)
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('falls back when fill is a named CSS color', () => {
|
|
39
|
+
expect(getReadableTextColor('lightpink', 'var(--_text)')).toBe(
|
|
40
|
+
'var(--_text)',
|
|
41
|
+
)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('falls back when fill is malformed or an injection attempt', () => {
|
|
45
|
+
expect(
|
|
46
|
+
getReadableTextColor('red" onmouseover="alert(1)', 'var(--_text)'),
|
|
47
|
+
).toBe('var(--_text)')
|
|
48
|
+
expect(getReadableTextColor('#gggggg', 'var(--_text)')).toBe('var(--_text)')
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('buildStyleBlock – font handling', () => {
|
|
53
|
+
it('quotes a normal font name and emits a Google Fonts @import (no regression)', () => {
|
|
54
|
+
const block = buildStyleBlock('Inter', false)
|
|
55
|
+
expect(block).toContain(
|
|
56
|
+
"@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');",
|
|
57
|
+
)
|
|
58
|
+
expect(block).toContain(
|
|
59
|
+
"text { font-family: 'Inter', system-ui, sans-serif; }",
|
|
60
|
+
)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('skips the Google Fonts @import and emits an unquoted value for a CSS var() reference', () => {
|
|
64
|
+
const block = buildStyleBlock('var(--font-family-body)', false)
|
|
65
|
+
expect(block).not.toContain('fonts.googleapis.com')
|
|
66
|
+
expect(block).toContain(
|
|
67
|
+
'text { font-family: var(--font-family-body), system-ui, sans-serif; }',
|
|
68
|
+
)
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('handles a var() reference with a quoted fallback argument', () => {
|
|
72
|
+
const block = buildStyleBlock("var(--font, 'Fallback Font')", false)
|
|
73
|
+
expect(block).not.toContain('fonts.googleapis.com')
|
|
74
|
+
expect(block).toContain(
|
|
75
|
+
"text { font-family: var(--font, 'Fallback Font'), system-ui, sans-serif; }",
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('treats a var()-like value with unbalanced parens/semicolons as untrusted and falls back to a quoted literal', () => {
|
|
80
|
+
const block = buildStyleBlock('var(--x); } .evil{color:red', false)
|
|
81
|
+
// Not treated as a var() reference: still gets a (sanitized) Google Fonts import + quoting.
|
|
82
|
+
expect(block).toContain('fonts.googleapis.com')
|
|
83
|
+
expect(block).not.toMatch(/text \{ font-family: var\(/)
|
|
84
|
+
// The sanitizer stripped the `;` and `{`/`}`, so the payload can't
|
|
85
|
+
// terminate the declaration/rule early — exactly one <style> block.
|
|
86
|
+
expect(block.match(/<style>/g)?.length).toBe(1)
|
|
87
|
+
expect(block.match(/<\/style>/g)?.length).toBe(1)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('sanitizes a malicious font value so it cannot break out of the generated <style> block', () => {
|
|
91
|
+
const malicious = '</style><script>alert(1)</script><style>'
|
|
92
|
+
const block = buildStyleBlock(malicious, false)
|
|
93
|
+
expect(block).not.toContain('</style><script>')
|
|
94
|
+
expect(block).not.toContain('<script>')
|
|
95
|
+
// The <style> block must still be well-formed: exactly one open/close pair.
|
|
96
|
+
expect(block.match(/<style>/g)?.length).toBe(1)
|
|
97
|
+
expect(block.match(/<\/style>/g)?.length).toBe(1)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('sanitizes an attempted CSS-injection payload disguised as a var() reference', () => {
|
|
101
|
+
const malicious = "var(--x, 'y'); } .evil { fill: red } /*"
|
|
102
|
+
const block = buildStyleBlock(malicious, false)
|
|
103
|
+
// Contains a semicolon, so it fails the strict var() check and is
|
|
104
|
+
// treated (and sanitized) as a literal, quoted font name instead.
|
|
105
|
+
expect(block).not.toMatch(/text \{ font-family: var\(/)
|
|
106
|
+
expect(block).not.toContain('.evil { fill: red }')
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('skips the Google Fonts @import for a comma-separated font stack (#223)', () => {
|
|
110
|
+
const block = buildStyleBlock('ui-sans-serif, system-ui, sans-serif', false)
|
|
111
|
+
expect(block).not.toContain('fonts.googleapis.com')
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
it('skips the Google Fonts @import for a bare CSS generic family keyword', () => {
|
|
115
|
+
const block = buildStyleBlock('system-ui', false)
|
|
116
|
+
expect(block).not.toContain('fonts.googleapis.com')
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('still emits the Google Fonts @import for a single real font name (regression guard)', () => {
|
|
120
|
+
const block = buildStyleBlock('Roboto', false)
|
|
121
|
+
expect(block).toContain(
|
|
122
|
+
"@import url('https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;600;700&display=swap');",
|
|
123
|
+
)
|
|
124
|
+
})
|
|
125
|
+
|
|
126
|
+
it('still renders a font stack literally in the text { font-family: ... } rule', () => {
|
|
127
|
+
const block = buildStyleBlock('ui-sans-serif, system-ui, sans-serif', false)
|
|
128
|
+
expect(block).toContain(
|
|
129
|
+
"text { font-family: 'ui-sans-serif, system-ui, sans-serif', system-ui, sans-serif; }",
|
|
130
|
+
)
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
describe('buildStyleBlock – mono font (#1061)', () => {
|
|
135
|
+
it('never reaches out to Google Fonts for the .mono rule', () => {
|
|
136
|
+
const block = buildStyleBlock('Inter', true)
|
|
137
|
+
// The main `font` still gets its own Google Fonts @import (unrelated to
|
|
138
|
+
// this regression test), but nothing in the block should ever request
|
|
139
|
+
// JetBrains Mono from a third-party CDN.
|
|
140
|
+
expect(block).not.toContain('fonts.googleapis.com/css2?family=JetBrains')
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
it('embeds a self-hosted @font-face as a base64 woff2 data URI for the mono font', () => {
|
|
144
|
+
const block = buildStyleBlock('Inter', true)
|
|
145
|
+
expect(block).toContain('@font-face')
|
|
146
|
+
expect(block).toContain('data:font/woff2;base64,')
|
|
147
|
+
expect(block).toContain("font-family: 'JetBrains Mono NL';")
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it("leads the .mono rule's font stack with the self-hosted family name", () => {
|
|
151
|
+
const block = buildStyleBlock('Inter', true)
|
|
152
|
+
expect(block).toContain(
|
|
153
|
+
" .mono { font-family: 'JetBrains Mono NL', 'SF Mono', 'Fira Code', ui-monospace, monospace; }",
|
|
154
|
+
)
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it('emits neither the @font-face embed nor the .mono rule when hasMonoFont is false', () => {
|
|
158
|
+
const block = buildStyleBlock('Inter', false)
|
|
159
|
+
expect(block).not.toContain('@font-face')
|
|
160
|
+
expect(block).not.toContain('.mono')
|
|
161
|
+
})
|
|
162
|
+
})
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import type { NodeInteraction } from './types.ts'
|
|
2
|
+
|
|
3
|
+
// ============================================================================
|
|
4
|
+
// Shared `click` directive parsing and href sanitization.
|
|
5
|
+
//
|
|
6
|
+
// Every diagram type that recognizes Mermaid's `click` statement (flowchart/
|
|
7
|
+
// state via src/parser.ts, class diagrams via
|
|
8
|
+
// packages/mermaid-parser/src/class/parser.ts) shares this exact grammar
|
|
9
|
+
// and the same href-safety rules — pulled out here so
|
|
10
|
+
// neither copy can drift from the other. See
|
|
11
|
+
// docs/decisions/no-script-interactivity.md for the tier model this
|
|
12
|
+
// implements: an `href` becomes a real `<a>` link (tier 2), a tooltip
|
|
13
|
+
// becomes a `<title>` (tier 1), and a `call`/`callback` binding is recorded
|
|
14
|
+
// as data but never executed (tier 3 stays unimplemented, permanently).
|
|
15
|
+
// ============================================================================
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Parse a `click <id> ...` statement and record the resulting
|
|
19
|
+
* {@link NodeInteraction} in `interactions`, keyed by whatever id scheme the
|
|
20
|
+
* caller's diagram type uses (flowchart/state node id, class diagram class
|
|
21
|
+
* id, ...).
|
|
22
|
+
*
|
|
23
|
+
* Supported forms:
|
|
24
|
+
* click id "url" ["tooltip"] [_blank|_self|_parent|_top]
|
|
25
|
+
* click id href "url" ["tooltip"] [_blank|_self|_parent|_top]
|
|
26
|
+
* click id call fn() ["tooltip"]
|
|
27
|
+
* click id callback fn() ["tooltip"]
|
|
28
|
+
*
|
|
29
|
+
* A line that doesn't match `click <id> ...` at all is a no-op — callers are
|
|
30
|
+
* expected to have already gated on a leading `click` keyword before calling
|
|
31
|
+
* this (see the `/^click\s+/i` checks at each call site) purely so this
|
|
32
|
+
* function's own regex can stay anchored to the id capture.
|
|
33
|
+
*/
|
|
34
|
+
export function applyClickStatement(
|
|
35
|
+
line: string,
|
|
36
|
+
interactions: Map<string, NodeInteraction>,
|
|
37
|
+
): void {
|
|
38
|
+
const match = line.match(/^click\s+([\w\p{L}-]+)\s+(.*)$/iu)
|
|
39
|
+
if (!match) return
|
|
40
|
+
|
|
41
|
+
const nodeId = match[1]!
|
|
42
|
+
let rest = match[2]!.trim()
|
|
43
|
+
|
|
44
|
+
const interaction: NodeInteraction = { ...interactions.get(nodeId) }
|
|
45
|
+
|
|
46
|
+
// `call fn()` / `callback fn()` — a script binding.
|
|
47
|
+
const callMatch = rest.match(/^(?:call|callback)\s+(.+?)\s*$/i)
|
|
48
|
+
if (callMatch) {
|
|
49
|
+
// A trailing quoted tooltip may follow the callback expression.
|
|
50
|
+
const withTooltip = callMatch[1]!.match(/^(.*?\))\s+"([^"]*)"\s*$/)
|
|
51
|
+
if (withTooltip) {
|
|
52
|
+
interaction.callback = withTooltip[1]!.trim()
|
|
53
|
+
interaction.tooltip = withTooltip[2]
|
|
54
|
+
} else {
|
|
55
|
+
interaction.callback = callMatch[1]!.trim()
|
|
56
|
+
}
|
|
57
|
+
interactions.set(nodeId, interaction)
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Optional explicit `href` keyword.
|
|
62
|
+
rest = rest.replace(/^href\s+/i, '')
|
|
63
|
+
|
|
64
|
+
// Remaining tokens: "url" ["tooltip"] [_target]
|
|
65
|
+
const quoted = [...rest.matchAll(/"([^"]*)"/g)].map((m) => m[1]!)
|
|
66
|
+
if (quoted.length > 0) interaction.href = quoted[0]
|
|
67
|
+
if (quoted.length > 1) interaction.tooltip = quoted[1]
|
|
68
|
+
|
|
69
|
+
const targetMatch = rest.match(/(_blank|_self|_parent|_top)\s*$/i)
|
|
70
|
+
if (targetMatch) interaction.target = targetMatch[1]!.toLowerCase()
|
|
71
|
+
|
|
72
|
+
if (interaction.href !== undefined || interaction.tooltip !== undefined) {
|
|
73
|
+
interactions.set(nodeId, interaction)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Accept only link schemes that cannot execute script.
|
|
79
|
+
*
|
|
80
|
+
* A `click` target comes from diagram text, which may be untrusted. An
|
|
81
|
+
* `href` of `javascript:...` (or a `data:` URL containing markup) would turn
|
|
82
|
+
* a rendered diagram into an XSS vector for any page that inlines the SVG, so
|
|
83
|
+
* anything but http/https/mailto and same-document or relative references is
|
|
84
|
+
* dropped rather than emitted.
|
|
85
|
+
*
|
|
86
|
+
* C0 controls are rejected outright, before any other check. The URL parser
|
|
87
|
+
* strips tab and newline from *anywhere* in a URL, so `java\tscript:` reaches
|
|
88
|
+
* a browser as `javascript:` — while the scheme match below sees `java\t…`,
|
|
89
|
+
* finds no scheme, and waves it through as a relative reference. Splitting a
|
|
90
|
+
* blocked scheme with a control character is the whole bypass; there is no
|
|
91
|
+
* legitimate URL with a raw control in it, so the entire range goes.
|
|
92
|
+
*/
|
|
93
|
+
export function safeHref(href: string | undefined): string | undefined {
|
|
94
|
+
if (!href) return undefined
|
|
95
|
+
|
|
96
|
+
if (/[\x00-\x1F\x7F]/.test(href)) return undefined
|
|
97
|
+
|
|
98
|
+
const trimmed = href.trim()
|
|
99
|
+
// Relative, absolute-path, and fragment references carry no scheme.
|
|
100
|
+
if (/^[./#?]/.test(trimmed)) return trimmed
|
|
101
|
+
|
|
102
|
+
const scheme = trimmed.match(/^([a-zA-Z][a-zA-Z0-9+.-]*):/)?.[1]
|
|
103
|
+
if (scheme === undefined) return trimmed
|
|
104
|
+
|
|
105
|
+
return /^(https?|mailto)$/i.test(scheme) ? trimmed : undefined
|
|
106
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// CSS color primitives — parse, mix, and format sRGB colors.
|
|
3
|
+
//
|
|
4
|
+
// Shared by the SVG theme system (packages/core/src/theme.ts), the ASCII renderer's
|
|
5
|
+
// DiagramColors → AsciiTheme bridge (src/ascii/ansi.ts), and the
|
|
6
|
+
// `resolveColors` post-processor (packages/svg-renderer/src/resolve-colors.ts). All three need
|
|
7
|
+
// the same answer to "what does `color-mix(in srgb, fg 50%, bg)` come out
|
|
8
|
+
// to?", so the arithmetic lives here exactly once — the MIX percentages in
|
|
9
|
+
// packages/core/src/theme.ts stay the single source of truth for *which* percentages the
|
|
10
|
+
// renderer uses, and this module is the single source of truth for what a
|
|
11
|
+
// percentage *means*.
|
|
12
|
+
// ============================================================================
|
|
13
|
+
|
|
14
|
+
/** An sRGB color: 0-255 channels plus a 0-1 alpha. */
|
|
15
|
+
export interface RgbaColor {
|
|
16
|
+
r: number
|
|
17
|
+
g: number
|
|
18
|
+
b: number
|
|
19
|
+
a: number
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Matches #rgb, #rgba, #rrggbb, #rrggbbaa hex color literals. */
|
|
23
|
+
const HEX_COLOR_RE =
|
|
24
|
+
/^#([0-9a-fA-F]{3}|[0-9a-fA-F]{4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Matches `rgb()` / `rgba()` in both the legacy comma syntax
|
|
28
|
+
* (`rgb(1, 2, 3)`, `rgba(1, 2, 3, 0.5)`) and the modern space syntax
|
|
29
|
+
* (`rgb(1 2 3 / 50%)`). Channels may be integers or percentages; alpha may
|
|
30
|
+
* be a 0-1 number or a percentage.
|
|
31
|
+
*/
|
|
32
|
+
const RGB_FUNC_RE =
|
|
33
|
+
/^rgba?\(\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*[, ]\s*([\d.]+%?)\s*(?:[,/]\s*([\d.]+%?)\s*)?\)$/i
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Parse a hex color string into 0-255 RGB components, ignoring any alpha
|
|
37
|
+
* digits. Returns null for anything that isn't a well-formed hex literal
|
|
38
|
+
* (e.g. `var(--foo)`, named CSS colors, or attribute-injection payloads) —
|
|
39
|
+
* the caller should treat those as unresolvable and fall back.
|
|
40
|
+
*/
|
|
41
|
+
export function parseHexColor(value: string): [number, number, number] | null {
|
|
42
|
+
const rgba = parseHexRgba(value)
|
|
43
|
+
return rgba ? [rgba.r, rgba.g, rgba.b] : null
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Parse a hex color literal (3/4/6/8 digits) into an RgbaColor, or null. */
|
|
47
|
+
export function parseHexRgba(value: string): RgbaColor | null {
|
|
48
|
+
const match = HEX_COLOR_RE.exec(value.trim())
|
|
49
|
+
if (!match) return null
|
|
50
|
+
// HEX_COLOR_RE has exactly one, non-optional capture group, so it's always
|
|
51
|
+
// populated when `match` is non-null — but that's a fact about the regex's
|
|
52
|
+
// construction, not something the type checker can see. Guard explicitly
|
|
53
|
+
// (falling back to "unresolvable", same as a non-match) rather than
|
|
54
|
+
// asserting past it, so a future edit to the pattern can't silently turn
|
|
55
|
+
// this into a runtime `undefined`.
|
|
56
|
+
const captured = match[1]
|
|
57
|
+
if (captured === undefined) return null
|
|
58
|
+
let hex = captured
|
|
59
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
60
|
+
hex = hex
|
|
61
|
+
.split('')
|
|
62
|
+
.map((c) => c + c)
|
|
63
|
+
.join('')
|
|
64
|
+
}
|
|
65
|
+
return {
|
|
66
|
+
r: parseInt(hex.slice(0, 2), 16),
|
|
67
|
+
g: parseInt(hex.slice(2, 4), 16),
|
|
68
|
+
b: parseInt(hex.slice(4, 6), 16),
|
|
69
|
+
a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) / 255 : 1,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Parse one rgb()/rgba() channel token: an integer/decimal or a percentage of 255. */
|
|
74
|
+
function parseRgbChannel(token: string): number {
|
|
75
|
+
return token.endsWith('%')
|
|
76
|
+
? (Number(token.slice(0, -1)) / 100) * 255
|
|
77
|
+
: Number(token)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Parse an alpha token: a 0-1 number or a percentage. */
|
|
81
|
+
function parseAlpha(token: string | undefined): number {
|
|
82
|
+
if (token === undefined) return 1
|
|
83
|
+
return token.endsWith('%') ? Number(token.slice(0, -1)) / 100 : Number(token)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Parse a concrete CSS color value into sRGB components.
|
|
88
|
+
*
|
|
89
|
+
* Accepts the forms this library's own theme system produces or documents:
|
|
90
|
+
* hex literals (`#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`), `rgb()`/`rgba()`,
|
|
91
|
+
* and the `transparent` keyword. Anything else — CSS named colors, `hsl()`,
|
|
92
|
+
* `var(...)`, an unresolved `color-mix(...)` — returns null so the caller
|
|
93
|
+
* can leave it untouched rather than guess.
|
|
94
|
+
*/
|
|
95
|
+
export function parseCssColor(value: string): RgbaColor | null {
|
|
96
|
+
const trimmed = value.trim()
|
|
97
|
+
if (trimmed.toLowerCase() === 'transparent') {
|
|
98
|
+
return { r: 0, g: 0, b: 0, a: 0 }
|
|
99
|
+
}
|
|
100
|
+
const hex = parseHexRgba(trimmed)
|
|
101
|
+
if (hex) return hex
|
|
102
|
+
const rgb = RGB_FUNC_RE.exec(trimmed)
|
|
103
|
+
if (rgb) {
|
|
104
|
+
const [, rTok, gTok, bTok, aTok] = rgb
|
|
105
|
+
if (rTok === undefined || gTok === undefined || bTok === undefined) {
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
const color = {
|
|
109
|
+
r: parseRgbChannel(rTok),
|
|
110
|
+
g: parseRgbChannel(gTok),
|
|
111
|
+
b: parseRgbChannel(bTok),
|
|
112
|
+
a: parseAlpha(aTok),
|
|
113
|
+
}
|
|
114
|
+
if ([color.r, color.g, color.b, color.a].some((n) => Number.isNaN(n))) {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
return color
|
|
118
|
+
}
|
|
119
|
+
return null
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Evaluate `color-mix(in srgb, c1 p1%, c2 p2%)` per CSS Color Module
|
|
124
|
+
* Level 5 (https://www.w3.org/TR/css-color-5/#color-mix): percentages are
|
|
125
|
+
* normalized so they sum to 100 (a sum under 100 additionally scales the
|
|
126
|
+
* result's alpha by `sum / 100`), and the two colors are interpolated in
|
|
127
|
+
* premultiplied-alpha space, so mixing an opaque color toward
|
|
128
|
+
* `transparent` fades its alpha without darkening it.
|
|
129
|
+
*
|
|
130
|
+
* `p1`/`p2` follow the spec's omission rules: pass `undefined` for an
|
|
131
|
+
* omitted percentage (both omitted → 50/50; one omitted → 100 minus the
|
|
132
|
+
* other). Returns null when both percentages are zero, which the spec
|
|
133
|
+
* defines as invalid.
|
|
134
|
+
*/
|
|
135
|
+
export function mixSrgb(
|
|
136
|
+
c1: RgbaColor,
|
|
137
|
+
c2: RgbaColor,
|
|
138
|
+
p1?: number,
|
|
139
|
+
p2?: number,
|
|
140
|
+
): RgbaColor | null {
|
|
141
|
+
let w1: number
|
|
142
|
+
let w2: number
|
|
143
|
+
if (p1 === undefined && p2 === undefined) {
|
|
144
|
+
w1 = 50
|
|
145
|
+
w2 = 50
|
|
146
|
+
} else if (p1 === undefined) {
|
|
147
|
+
w2 = p2 ?? 50
|
|
148
|
+
w1 = 100 - w2
|
|
149
|
+
} else if (p2 === undefined) {
|
|
150
|
+
w1 = p1
|
|
151
|
+
w2 = 100 - w1
|
|
152
|
+
} else {
|
|
153
|
+
w1 = p1
|
|
154
|
+
w2 = p2
|
|
155
|
+
}
|
|
156
|
+
const sum = w1 + w2
|
|
157
|
+
if (sum === 0) return null
|
|
158
|
+
const alphaMultiplier = sum < 100 ? sum / 100 : 1
|
|
159
|
+
w1 /= sum
|
|
160
|
+
w2 /= sum
|
|
161
|
+
|
|
162
|
+
const alpha = c1.a * w1 + c2.a * w2
|
|
163
|
+
const premul = (ch1: number, ch2: number): number =>
|
|
164
|
+
alpha === 0 ? 0 : (ch1 * c1.a * w1 + ch2 * c2.a * w2) / alpha
|
|
165
|
+
|
|
166
|
+
return {
|
|
167
|
+
r: premul(c1.r, c2.r),
|
|
168
|
+
g: premul(c1.g, c2.g),
|
|
169
|
+
b: premul(c1.b, c2.b),
|
|
170
|
+
a: alpha * alphaMultiplier,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Mix `fg` into `bg` at `pct` percent — the shape every MIX entry in
|
|
176
|
+
* packages/core/src/theme.ts uses (`color-mix(in srgb, var(--fg) N%, var(--bg))`).
|
|
177
|
+
* Returns `fg` unchanged when either input isn't a concrete color this
|
|
178
|
+
* module can parse (a `var(...)` reference, a named color).
|
|
179
|
+
*/
|
|
180
|
+
export function mixHexColors(fg: string, bg: string, pct: number): string {
|
|
181
|
+
const f = parseCssColor(fg)
|
|
182
|
+
const b = parseCssColor(bg)
|
|
183
|
+
if (!f || !b) return fg
|
|
184
|
+
const mixed = mixSrgb(f, b, pct, 100 - pct)
|
|
185
|
+
return mixed ? formatCssColor(mixed) : fg
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const clampByte = (n: number): number =>
|
|
189
|
+
Math.min(255, Math.max(0, Math.round(n)))
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Serialize a color as `#rrggbb` when fully opaque, otherwise as
|
|
193
|
+
* `rgba(r, g, b, a)` — the two forms every SVG consumer (browsers, resvg,
|
|
194
|
+
* librsvg, Inkscape) agrees on.
|
|
195
|
+
*/
|
|
196
|
+
export function formatCssColor(c: RgbaColor): string {
|
|
197
|
+
const r = clampByte(c.r)
|
|
198
|
+
const g = clampByte(c.g)
|
|
199
|
+
const b = clampByte(c.b)
|
|
200
|
+
const a = Math.min(1, Math.max(0, c.a))
|
|
201
|
+
if (a >= 1) {
|
|
202
|
+
return '#' + [r, g, b].map((n) => n.toString(16).padStart(2, '0')).join('')
|
|
203
|
+
}
|
|
204
|
+
return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`
|
|
205
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// zombie-mermaid — shared diagram-type detection
|
|
3
|
+
//
|
|
4
|
+
// Both the SVG renderer (src/index.ts) and the ASCII renderer
|
|
5
|
+
// (src/ascii/index.ts) need to classify Mermaid source text before
|
|
6
|
+
// dispatching to the right parse/layout/render pipeline. This module is the
|
|
7
|
+
// single source of truth for that classification so the two backends can
|
|
8
|
+
// never drift out of sync with each other again.
|
|
9
|
+
// ============================================================================
|
|
10
|
+
|
|
11
|
+
import { splitStatements } from './statements.ts'
|
|
12
|
+
|
|
13
|
+
/** The diagram types this library can detect and route to a renderer. */
|
|
14
|
+
export type DiagramType = 'flowchart' | 'sequence' | 'class' | 'er' | 'xychart'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Detect the diagram type from the mermaid source text.
|
|
18
|
+
* Returns the type keyword used for routing to the correct pipeline.
|
|
19
|
+
*
|
|
20
|
+
* The header keyword is the first *statement*, not the first line: Mermaid
|
|
21
|
+
* allows `;` as a statement separator, so `flowchart TD;A-->B` has its header
|
|
22
|
+
* and first edge on one line. Uses the same splitter every parser uses, so
|
|
23
|
+
* routing and parsing can never disagree about where the header ends.
|
|
24
|
+
*/
|
|
25
|
+
export function detectDiagramType(text: string): DiagramType {
|
|
26
|
+
const firstLine = splitStatements(text)[0]?.text.toLowerCase() ?? ''
|
|
27
|
+
|
|
28
|
+
if (/^xychart(?:-beta)?(?:\s|$)/.test(firstLine)) return 'xychart'
|
|
29
|
+
if (/^sequencediagram\s*$/.test(firstLine)) return 'sequence'
|
|
30
|
+
if (/^classdiagram\s*$/.test(firstLine)) return 'class'
|
|
31
|
+
if (/^erdiagram\s*$/.test(firstLine)) return 'er'
|
|
32
|
+
|
|
33
|
+
// Default: flowchart/state (handled by parseMermaid internally)
|
|
34
|
+
return 'flowchart'
|
|
35
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Render-time direction override (issue #276)
|
|
3
|
+
//
|
|
4
|
+
// `RenderOptions.direction` / `AsciiRenderOptions.direction` replace the
|
|
5
|
+
// *top-level* direction a diagram declared in its source (a flowchart's
|
|
6
|
+
// `graph LR` header, a state diagram's or ER diagram's `direction LR`
|
|
7
|
+
// line) after parsing and before layout. Nothing here touches the source
|
|
8
|
+
// text, and nothing here touches a nested subgraph's or composite state's
|
|
9
|
+
// own `direction` — those live on the subgraph objects, not on the field
|
|
10
|
+
// replaced below, so they keep winning locally over the top-level
|
|
11
|
+
// direction exactly as they do relative to the diagram's own header.
|
|
12
|
+
//
|
|
13
|
+
// Shared by the SVG entry (src/index.ts) and the ASCII entry
|
|
14
|
+
// (src/ascii/index.ts). Deliberately dependency-free beyond the
|
|
15
|
+
// `isDirection` guard so the `zombie-mermaid/ascii` subpath stays free of
|
|
16
|
+
// elkjs — the guard itself moved out of `src/parser.ts` and into this
|
|
17
|
+
// package (see `direction.ts`) so that staying dependency-free survives
|
|
18
|
+
// the workspace split (zombie-mermaid#625).
|
|
19
|
+
// ============================================================================
|
|
20
|
+
|
|
21
|
+
import type { Direction } from './types.ts'
|
|
22
|
+
import { isDirection } from './direction.ts'
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Return `diagram` with its top-level `direction` replaced by `override`,
|
|
26
|
+
* or `diagram` itself (same reference) when there is nothing to apply.
|
|
27
|
+
*
|
|
28
|
+
* The parsed diagram is never mutated — a shallow clone carries the new
|
|
29
|
+
* direction, so `parseMermaid()` output a caller holds on to stays exactly
|
|
30
|
+
* what the parser produced, and a second render of the same parsed object
|
|
31
|
+
* without the option sees the source direction again.
|
|
32
|
+
*
|
|
33
|
+
* `isDirection` re-checks the value at runtime because `Direction` is only
|
|
34
|
+
* a compile-time guarantee: a plain-JavaScript caller can pass any string.
|
|
35
|
+
* An unrecognized value is ignored (the diagram's own direction stands)
|
|
36
|
+
* rather than thrown on — the same lenient treatment every other
|
|
37
|
+
* `RenderOptions` value gets, e.g. an unknown `interactivity` or `curve`
|
|
38
|
+
* string falls back to its default instead of failing the render.
|
|
39
|
+
*/
|
|
40
|
+
export function withDirectionOverride<T extends { direction?: Direction }>(
|
|
41
|
+
diagram: T,
|
|
42
|
+
override: Direction | undefined,
|
|
43
|
+
): T {
|
|
44
|
+
if (override === undefined || !isDirection(override)) return diagram
|
|
45
|
+
return { ...diagram, direction: override }
|
|
46
|
+
}
|
package/src/direction.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// Direction token guard
|
|
3
|
+
//
|
|
4
|
+
// Lives in `core` rather than beside the flowchart parser that used to own
|
|
5
|
+
// it (`src/parser.ts`) because `direction-override.ts` — also `core`, and
|
|
6
|
+
// reached from the ASCII entry — needs it. Left in the parser, `core` would
|
|
7
|
+
// import `mermaid-parser`, i.e. the shared package would depend on one of
|
|
8
|
+
// its own consumers (zombie-mermaid#625, umbrella #620).
|
|
9
|
+
//
|
|
10
|
+
// `toDirection` joined `isDirection` here under #624 for the identical
|
|
11
|
+
// reason: it used to live in `src/parser.ts` too, with that file as its
|
|
12
|
+
// only caller, until splitting `er/parser.ts` out into
|
|
13
|
+
// `packages/mermaid-parser/` gave it a second caller that cannot import the
|
|
14
|
+
// umbrella's `src/parser.ts` without creating the same cycle `isDirection`
|
|
15
|
+
// was moved to avoid. `src/parser.ts` now imports it from here instead of
|
|
16
|
+
// defining it.
|
|
17
|
+
//
|
|
18
|
+
// `Direction` is only a compile-time guarantee, so every entry point that
|
|
19
|
+
// accepts a direction from outside (`RenderOptions.direction`, the CLI's
|
|
20
|
+
// `--direction` flag, a parsed `graph LR` header) re-checks it at runtime
|
|
21
|
+
// through this guard.
|
|
22
|
+
// ============================================================================
|
|
23
|
+
|
|
24
|
+
import type { Direction } from './types.ts'
|
|
25
|
+
|
|
26
|
+
/** True when `value` is one of the five directions Mermaid recognizes. */
|
|
27
|
+
export function isDirection(value: string): value is Direction {
|
|
28
|
+
return (
|
|
29
|
+
value === 'TD' ||
|
|
30
|
+
value === 'TB' ||
|
|
31
|
+
value === 'LR' ||
|
|
32
|
+
value === 'BT' ||
|
|
33
|
+
value === 'RL'
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Normalize a regex-captured direction token to a `Direction`.
|
|
39
|
+
*
|
|
40
|
+
* Accepts `string | undefined` because every call site passes a regex
|
|
41
|
+
* capture group value (`match[1]`, typed as possibly-`undefined` under
|
|
42
|
+
* `noUncheckedIndexedAccess`) straight through. Each call site's regex
|
|
43
|
+
* guards the capture with the same `(TD|TB|LR|BT|RL)` alternation
|
|
44
|
+
* (case-insensitively), so the group always participates in the match in
|
|
45
|
+
* practice — but that's a regex-structure guarantee the type checker can't
|
|
46
|
+
* see across the call site. Validating `undefined` here, instead of
|
|
47
|
+
* asserting non-null at each call site, turns a hypothetical violation into
|
|
48
|
+
* a clear, descriptive error rather than a raw `undefined` crash.
|
|
49
|
+
*/
|
|
50
|
+
export function toDirection(raw: string | undefined): Direction {
|
|
51
|
+
const upper = raw?.toUpperCase()
|
|
52
|
+
if (upper === undefined || !isDirection(upper)) {
|
|
53
|
+
throw new Error(`Invalid direction: "${raw}"`)
|
|
54
|
+
}
|
|
55
|
+
return upper
|
|
56
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Regenerate with `pnpm run build:mono-font`
|
|
5
|
+
* (scripts/build-mono-font-subset.ts), which subsets
|
|
6
|
+
* third_party/fonts/jetbrains-mono-nl/JetBrainsMonoNL-Regular.ttf down to
|
|
7
|
+
* Basic Latin + Latin-1 Supplement — the self-hosted, embeddable
|
|
8
|
+
* `@font-face` packages/core/src/theme.ts's `buildStyleBlock()` inlines
|
|
9
|
+
* into an SVG's own `<style>` for its `.mono` rule (class-diagram method
|
|
10
|
+
* signatures, ER-diagram attribute types), replacing a Google Fonts CDN
|
|
11
|
+
* `@import` this library's SVG output previously depended on by default
|
|
12
|
+
* (#1061). See that script's header comment for why this subset is
|
|
13
|
+
* deliberately narrower than the site's own (demo/components/generated/
|
|
14
|
+
* mono-font-subset.ts).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/** The self-hosted family name every consumer's font stack should list
|
|
18
|
+
* first — matches this subset's own `name` table entry. */
|
|
19
|
+
export const MONO_FONT_FAMILY = 'JetBrains Mono NL'
|
|
20
|
+
|
|
21
|
+
/** The `@font-face` rule embedding the subset as a base64 `woff2` data
|
|
22
|
+
* URI — no separate network request, no page-depth-relative path to get
|
|
23
|
+
* wrong (see the script header comment for why data-URI over a static
|
|
24
|
+
* asset file). */
|
|
25
|
+
export const MONO_FONT_FACE_CSS = `@font-face {
|
|
26
|
+
font-family: 'JetBrains Mono NL';
|
|
27
|
+
font-style: normal;
|
|
28
|
+
font-weight: 400;
|
|
29
|
+
font-display: swap;
|
|
30
|
+
src: url(data:font/woff2;base64,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) format('woff2');
|
|
31
|
+
unicode-range: U+0020-007E, U+00A0-00FF;
|
|
32
|
+
}`
|