@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.
@@ -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&amp;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
+ }
@@ -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
+ }`