@zombie-mermaid/mermaid-parser 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,242 @@
1
+ // ============================================================================
2
+ // Sequence diagram types
3
+ //
4
+ // Models the parsed and positioned representations of a Mermaid sequence diagram.
5
+ // Sequence diagrams show actor interactions over time (vertical timeline).
6
+ // ============================================================================
7
+
8
+ /** Parsed sequence diagram — logical structure from mermaid text */
9
+ export interface SequenceDiagram {
10
+ /** Ordered list of actors/participants */
11
+ actors: Actor[]
12
+ /** Messages between actors in chronological order */
13
+ messages: Message[]
14
+ /** Structural blocks (loop, alt, opt, par, critical) */
15
+ blocks: Block[]
16
+ /** Notes attached to actors */
17
+ notes: Note[]
18
+ /**
19
+ * Standalone `activate X` / `deactivate X` statements, in source order.
20
+ * The inline `+`/`-` arrow shorthand is *not* recorded here — it stays on
21
+ * `Message.activate` / `Message.deactivate` — but both feed the same
22
+ * activation stack at layout time (see layout.ts), so the two forms
23
+ * render identically.
24
+ */
25
+ activations: ActivationEvent[]
26
+ /**
27
+ * `box <color?> <label?> … end` participant groups, in source order. A box
28
+ * with no members (nothing declared inside it) is kept here but drawn by
29
+ * neither renderer.
30
+ */
31
+ boxes: ParticipantBox[]
32
+ }
33
+
34
+ /** A `box … end` group of participants (Mermaid's "Grouping / Box"). */
35
+ export interface ParticipantBox {
36
+ /** Descriptive label; empty when the box has none. */
37
+ label: string
38
+ /**
39
+ * Validated CSS colour (named, `#hex`, `rgb()`/`rgba()`, `hsl()`/`hsla()`)
40
+ * exactly as written. Unset for a transparent box — including an explicit
41
+ * `box transparent …`, and any first word that isn't a colour, which then
42
+ * counts as the start of the label (Mermaid's `parseBoxData` rule).
43
+ */
44
+ color?: string
45
+ /** Ids of the participants declared (or first used) inside the box. */
46
+ actorIds: string[]
47
+ }
48
+
49
+ /**
50
+ * One standalone `activate X` (`kind: 'start'`) or `deactivate X`
51
+ * (`kind: 'end'`) statement. Mermaid's own grammar expands the `+`/`-` arrow
52
+ * shorthand into exactly these events — `A->>+B` is a message followed by an
53
+ * `activeStart` for the recipient, `A-->>-B` a message followed by an
54
+ * `activeEnd` for the sender — so this is the primitive and the shorthand is
55
+ * sugar over it.
56
+ */
57
+ export interface ActivationEvent {
58
+ actorId: string
59
+ kind: 'start' | 'end'
60
+ /**
61
+ * Index of the message this statement follows (-1 if it precedes every
62
+ * message). The activation bar starts/ends at that message's row, which
63
+ * is where the shorthand form's bar starts/ends too.
64
+ */
65
+ afterIndex: number
66
+ }
67
+
68
+ export interface Actor {
69
+ id: string
70
+ label: string
71
+ /** 'participant' renders as a box, 'actor' renders as a stick figure */
72
+ type: 'participant' | 'actor'
73
+ /**
74
+ * Index of the message that creates this participant (`create participant
75
+ * X` on the line before it). The participant's box is drawn at that
76
+ * message's row instead of in the header, and its lifeline starts there.
77
+ * Unset for participants that exist from the top of the diagram.
78
+ */
79
+ createdAt?: number
80
+ /**
81
+ * Index of the message that destroys this participant (`destroy X` on the
82
+ * line before it). Its lifeline ends at that message's row with a cross,
83
+ * and no footer box is drawn. Unset for participants that live to the end.
84
+ */
85
+ destroyedAt?: number
86
+ }
87
+
88
+ export interface Message {
89
+ from: string
90
+ to: string
91
+ label: string
92
+ /** Arrow style: solid line or dashed line */
93
+ lineStyle: 'solid' | 'dashed'
94
+ /** Arrow head: filled (closed) or open */
95
+ arrowHead: 'filled' | 'open'
96
+ /**
97
+ * Set for a "lost message" cross-terminator (`-x`/`--x`). `arrowHead`
98
+ * stays `'filled'` for these (unchanged, to preserve existing SVG output)
99
+ * — this flag lets the ASCII renderer draw a distinct cross glyph instead
100
+ * of the plain filled arrowhead it shares with `->>`/`-->>`. See issue
101
+ * #330; not yet modeled by the SVG renderer's markers.
102
+ */
103
+ isLost?: boolean
104
+ /** Activate the target lifeline (+) */
105
+ activate?: boolean
106
+ /** Deactivate the source lifeline (-) */
107
+ deactivate?: boolean
108
+ /** Bidirectional arrow (`<<->>` or `<<-->>`) — draw an arrow head on both ends */
109
+ bidirectional?: boolean
110
+ /** Sequence number to display next to this arrow when `autonumber` is active */
111
+ seqNumber?: number
112
+ }
113
+
114
+ export interface Block {
115
+ /** Block type keyword */
116
+ type: 'loop' | 'alt' | 'opt' | 'par' | 'critical' | 'break' | 'rect'
117
+ /** Label for the block header */
118
+ label: string
119
+ /** Index of the first message inside this block */
120
+ startIndex: number
121
+ /** Index of the last message inside this block (inclusive) */
122
+ endIndex: number
123
+ /** For alt/par blocks: indices where "else"/"and" dividers appear (message indices) */
124
+ dividers: Array<{ index: number; label: string }>
125
+ }
126
+
127
+ export interface Note {
128
+ /** Which actor(s) the note is attached to */
129
+ actorIds: string[]
130
+ /** Note text content */
131
+ text: string
132
+ /** Position relative to the actor(s) */
133
+ position: 'left' | 'right' | 'over'
134
+ /** Message index after which this note appears */
135
+ afterIndex: number
136
+ }
137
+
138
+ // ============================================================================
139
+ // Positioned sequence diagram — ready for SVG rendering
140
+ // ============================================================================
141
+
142
+ export interface PositionedSequenceDiagram {
143
+ width: number
144
+ height: number
145
+ actors: PositionedActor[]
146
+ lifelines: Lifeline[]
147
+ messages: PositionedMessage[]
148
+ activations: Activation[]
149
+ blocks: PositionedBlock[]
150
+ notes: PositionedNote[]
151
+ /** `box … end` group backgrounds, drawn behind everything else. */
152
+ boxes: PositionedParticipantBox[]
153
+ }
154
+
155
+ /** A positioned `box … end` group: a full-height background behind its participants. */
156
+ export interface PositionedParticipantBox {
157
+ label: string
158
+ /** See {@link ParticipantBox.color}. */
159
+ color?: string
160
+ x: number
161
+ y: number
162
+ width: number
163
+ height: number
164
+ }
165
+
166
+ export interface PositionedActor {
167
+ id: string
168
+ label: string
169
+ type: 'participant' | 'actor'
170
+ /** Center x of the actor box */
171
+ x: number
172
+ /** Top y of the actor box */
173
+ y: number
174
+ width: number
175
+ height: number
176
+ }
177
+
178
+ /** Vertical dashed line from actor to bottom of diagram */
179
+ export interface Lifeline {
180
+ actorId: string
181
+ x: number
182
+ topY: number
183
+ bottomY: number
184
+ /**
185
+ * Set when the actor is destroyed mid-diagram (`Actor.destroyedAt`):
186
+ * `bottomY` is then the destroying message's row rather than the diagram
187
+ * bottom, and the renderer marks it with a cross.
188
+ */
189
+ destroyed?: boolean
190
+ }
191
+
192
+ export interface PositionedMessage {
193
+ from: string
194
+ to: string
195
+ label: string
196
+ lineStyle: 'solid' | 'dashed'
197
+ arrowHead: 'filled' | 'open'
198
+ /** Start point (from actor's lifeline) */
199
+ x1: number
200
+ /** End point (to actor's lifeline) */
201
+ x2: number
202
+ /** Vertical position */
203
+ y: number
204
+ /** Whether this is a self-message (same actor) */
205
+ isSelf: boolean
206
+ /** Bidirectional arrow (`<<->>` or `<<-->>`) — draw an arrow head on both ends */
207
+ bidirectional: boolean
208
+ /** Sequence number to display next to this arrow when `autonumber` is active */
209
+ seqNumber?: number
210
+ }
211
+
212
+ /** Narrow rectangle on a lifeline showing active processing */
213
+ export interface Activation {
214
+ actorId: string
215
+ x: number
216
+ topY: number
217
+ bottomY: number
218
+ width: number
219
+ }
220
+
221
+ export interface PositionedBlock {
222
+ type: Block['type']
223
+ label: string
224
+ x: number
225
+ y: number
226
+ width: number
227
+ height: number
228
+ /** Divider lines within the block (for alt/par) */
229
+ dividers: Array<{ y: number; label: string }>
230
+ }
231
+
232
+ export interface PositionedNote {
233
+ text: string
234
+ x: number
235
+ y: number
236
+ width: number
237
+ height: number
238
+ /** Actor IDs this note is attached to (for SVG attribution) */
239
+ actors?: string[]
240
+ /** Note position relative to actors (for SVG attribution) */
241
+ position?: 'left' | 'right' | 'over'
242
+ }
@@ -0,0 +1,177 @@
1
+ // ============================================================================
2
+ // XY Chart — shared color palette
3
+ //
4
+ // Generates monochromatic shades from the theme accent color.
5
+ // Series 0 = accent (or blue fallback). Series 1+ are darker/lighter
6
+ // shades of the same hue with subtle hue drift to stay in the same
7
+ // color family (like navy ↔ cyan from blue).
8
+ //
9
+ // Used by both the SVG and ASCII renderers.
10
+ // ============================================================================
11
+
12
+ /** Default accent for charts when the theme doesn't provide one. */
13
+ export const CHART_ACCENT_FALLBACK = '#3b82f6' // blue-500
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // HSL ↔ Hex conversion
17
+ // ---------------------------------------------------------------------------
18
+
19
+ function hexToHsl(hex: string): [number, number, number] {
20
+ const h = hex.replace('#', '')
21
+ const ri = parseInt(h.substring(0, 2), 16) / 255
22
+ const gi = parseInt(h.substring(2, 4), 16) / 255
23
+ const bi = parseInt(h.substring(4, 6), 16) / 255
24
+
25
+ const max = Math.max(ri, gi, bi)
26
+ const min = Math.min(ri, gi, bi)
27
+ const l = (max + min) / 2
28
+
29
+ if (max === min) return [0, 0, l * 100]
30
+
31
+ const d = max - min
32
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
33
+
34
+ let hue: number
35
+ if (max === ri) hue = ((gi - bi) / d + (gi < bi ? 6 : 0)) / 6
36
+ else if (max === gi) hue = ((bi - ri) / d + 2) / 6
37
+ else hue = ((ri - gi) / d + 4) / 6
38
+
39
+ return [hue * 360, s * 100, l * 100]
40
+ }
41
+
42
+ function hslToHex(h: number, s: number, l: number): string {
43
+ const si = s / 100
44
+ const li = l / 100
45
+
46
+ const c = (1 - Math.abs(2 * li - 1)) * si
47
+ const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
48
+ const m = li - c / 2
49
+
50
+ let r: number, g: number, b: number
51
+ if (h < 60) {
52
+ r = c
53
+ g = x
54
+ b = 0
55
+ } else if (h < 120) {
56
+ r = x
57
+ g = c
58
+ b = 0
59
+ } else if (h < 180) {
60
+ r = 0
61
+ g = c
62
+ b = x
63
+ } else if (h < 240) {
64
+ r = 0
65
+ g = x
66
+ b = c
67
+ } else if (h < 300) {
68
+ r = x
69
+ g = 0
70
+ b = c
71
+ } else {
72
+ r = c
73
+ g = 0
74
+ b = x
75
+ }
76
+
77
+ const toHex = (v: number) =>
78
+ Math.round((v + m) * 255)
79
+ .toString(16)
80
+ .padStart(2, '0')
81
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
82
+ }
83
+
84
+ // ---------------------------------------------------------------------------
85
+ // Hex ↔ RGB conversion
86
+ // ---------------------------------------------------------------------------
87
+
88
+ function hexToRgb(hex: string): [number, number, number] {
89
+ const h = hex.replace('#', '')
90
+ return [
91
+ parseInt(h.substring(0, 2), 16),
92
+ parseInt(h.substring(2, 4), 16),
93
+ parseInt(h.substring(4, 6), 16),
94
+ ]
95
+ }
96
+
97
+ function rgbToHex(r: number, g: number, b: number): string {
98
+ const toHex = (v: number) =>
99
+ Math.round(Math.max(0, Math.min(255, v)))
100
+ .toString(16)
101
+ .padStart(2, '0')
102
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Public API
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /** Check whether a string is a valid 6-digit hex color (e.g. "#3b82f6"). */
110
+ export function isValidHex(color: string): boolean {
111
+ return /^#[0-9a-fA-F]{6}$/.test(color)
112
+ }
113
+
114
+ /**
115
+ * Detect whether a background color is dark (lightness < 50%).
116
+ */
117
+ export function isDarkBackground(bgHex: string): boolean {
118
+ return hexToHsl(bgHex)[2] < 50
119
+ }
120
+
121
+ /**
122
+ * Mix two hex colors in RGB space.
123
+ * `ratio` controls how much of `fgHex` shows: 0 = pure bg, 1 = pure fg.
124
+ * Equivalent to alpha-compositing fg over bg at the given opacity.
125
+ */
126
+ export function mixHexColors(
127
+ bgHex: string,
128
+ fgHex: string,
129
+ ratio: number,
130
+ ): string {
131
+ const [br, bg, bb] = hexToRgb(bgHex)
132
+ const [fr, fg, fb] = hexToRgb(fgHex)
133
+ const inv = 1 - ratio
134
+ return rgbToHex(
135
+ br * inv + fr * ratio,
136
+ bg * inv + fg * ratio,
137
+ bb * inv + fb * ratio,
138
+ )
139
+ }
140
+
141
+ /**
142
+ * Get the hex color for a series index.
143
+ * Index 0 returns the accent color as-is.
144
+ * Index 1+ alternate between darker and lighter shades of the same hue
145
+ * with subtle hue drift (±8-12° per tier) to stay in the same family.
146
+ *
147
+ * When `bgColor` is provided, shade direction adapts to the background:
148
+ * - Light bg: odd = darker, even = lighter (default)
149
+ * - Dark bg: odd = lighter, even = darker (so shades stay visible)
150
+ */
151
+ export function getSeriesColor(
152
+ index: number,
153
+ accentColor: string,
154
+ bgColor?: string,
155
+ ): string {
156
+ if (index === 0) return accentColor
157
+ // Fall back to defaults when inputs aren't valid hex (e.g. CSS variable refs like "var(--accent)")
158
+ const safeAccent = isValidHex(accentColor)
159
+ ? accentColor
160
+ : CHART_ACCENT_FALLBACK
161
+ const safeBg = bgColor && isValidHex(bgColor) ? bgColor : undefined
162
+ const [h, s] = hexToHsl(safeAccent)
163
+ const chartS = Math.max(55, Math.min(85, s))
164
+
165
+ const tier = Math.ceil(index / 2)
166
+ const oddIndex = index % 2 === 1
167
+
168
+ // On dark backgrounds, flip: odd = lighter, even = darker
169
+ const dark = safeBg && isDarkBackground(safeBg) ? !oddIndex : oddIndex
170
+ const l = dark ? Math.max(25, 48 - tier * 13) : Math.min(78, 55 + tier * 11)
171
+
172
+ // Subtle hue drift: darker shades shift slightly negative, lighter shift positive
173
+ const hShift = (dark ? -8 : 12) * tier
174
+ const newH = (((h + hShift) % 360) + 360) % 360
175
+
176
+ return hslToHex(newH, chartS, l)
177
+ }
@@ -0,0 +1,246 @@
1
+ import type { XYChart, XYAxis, XYChartSeries } from './types.ts'
2
+ import type { Statement } from '@zombie-mermaid/core'
3
+
4
+ // ============================================================================
5
+ // XY Chart parser
6
+ //
7
+ // Parses Mermaid xychart-beta syntax into a typed XYChart structure.
8
+ //
9
+ // Supported directives:
10
+ // xychart-beta [horizontal]
11
+ // title "Chart Title"
12
+ // x-axis [label1, label2, ...] — categorical
13
+ // x-axis min --> max — numeric range
14
+ // x-axis "Axis Title" [label1, ...] — with title
15
+ // x-axis "Axis Title" min --> max — with title
16
+ // y-axis (same patterns)
17
+ // bar [val1, val2, ...]
18
+ // line [val1, val2, ...]
19
+ // ============================================================================
20
+
21
+ /**
22
+ * Parse a Mermaid xychart-beta diagram from preprocessed lines.
23
+ * Lines should already be trimmed and comment-stripped.
24
+ */
25
+ export function parseXYChart(lines: Statement[]): XYChart {
26
+ const xAxis: XYAxis = {}
27
+ const yAxis: XYAxis = {}
28
+ const series: XYChartSeries[] = []
29
+ let title: string | undefined
30
+ let horizontal = false
31
+
32
+ for (const stmt of lines) {
33
+ const line = stmt.text
34
+ // Header line — detect horizontal
35
+ if (/^xychart(-beta)?\b/i.test(line)) {
36
+ if (/\bhorizontal\b/i.test(line)) horizontal = true
37
+ continue
38
+ }
39
+
40
+ // Title
41
+ const titleMatch = line.match(/^title\s+"([^"]+)"/)
42
+ if (titleMatch) {
43
+ title = titleMatch[1]
44
+ continue
45
+ }
46
+
47
+ // x-axis with categories: x-axis "Title" [a, b, c] or x-axis [a, b, c]
48
+ const xCatMatch = line.match(/^x-axis\s+(?:"([^"]*)"\s*)?\[([^\]]+)\]/)
49
+ if (xCatMatch) {
50
+ if (xCatMatch[1]) xAxis.title = xCatMatch[1]
51
+ xAxis.categories = splitCategoryList(xCatMatch[2]!).map((s) =>
52
+ unquote(s.trim()),
53
+ )
54
+ continue
55
+ }
56
+
57
+ // x-axis with range: x-axis "Title" min --> max or x-axis min --> max
58
+ const xRangeMatch = line.match(
59
+ /^x-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/,
60
+ )
61
+ if (xRangeMatch) {
62
+ if (xRangeMatch[1]) xAxis.title = xRangeMatch[1]
63
+ xAxis.range = {
64
+ min: parseFloat(xRangeMatch[2]!),
65
+ max: parseFloat(xRangeMatch[3]!),
66
+ }
67
+ continue
68
+ }
69
+
70
+ // y-axis with range: y-axis "Title" min --> max or y-axis min --> max
71
+ const yRangeMatch = line.match(
72
+ /^y-axis\s+(?:"([^"]*)"\s+)?(-?\d+(?:\.\d+)?)\s*-->\s*(-?\d+(?:\.\d+)?)/,
73
+ )
74
+ if (yRangeMatch) {
75
+ if (yRangeMatch[1]) yAxis.title = yRangeMatch[1]
76
+ yAxis.range = {
77
+ min: parseFloat(yRangeMatch[2]!),
78
+ max: parseFloat(yRangeMatch[3]!),
79
+ }
80
+ continue
81
+ }
82
+
83
+ // y-axis with just title (no range)
84
+ const yTitleOnly = line.match(/^y-axis\s+"([^"]+)"\s*$/)
85
+ if (yTitleOnly) {
86
+ yAxis.title = yTitleOnly[1]
87
+ continue
88
+ }
89
+
90
+ // bar [...]
91
+ const barMatch = line.match(/^bar\s+\[([^\]]+)\]/)
92
+ if (barMatch) {
93
+ series.push({
94
+ type: 'bar',
95
+ data: parseNumericArray(barMatch[1]!, 'bar', line, stmt.line),
96
+ })
97
+ continue
98
+ }
99
+
100
+ // line [...]
101
+ const lineMatch = line.match(/^line\s+\[([^\]]+)\]/)
102
+ if (lineMatch) {
103
+ series.push({
104
+ type: 'line',
105
+ data: parseNumericArray(lineMatch[1]!, 'line', line, stmt.line),
106
+ })
107
+ continue
108
+ }
109
+
110
+ // None of the recognized directive shapes matched. A line that doesn't
111
+ // start with any known xychart-beta keyword at all falls through
112
+ // silently below, same as before — but a line that *does* start with
113
+ // one of the five keyworded directives (x-axis/y-axis/bar/line/title)
114
+ // and still failed every pattern above is almost certainly an attempt
115
+ // at that directive with broken syntax (an unclosed bracket, a missing
116
+ // quote, a malformed range). Previously this was silently dropped —
117
+ // the diagram would render with that axis/series just missing and no
118
+ // indication why. Surface an actionable error instead. See issue #541
119
+ // (parser error-message quality audit — this parser had zero throw
120
+ // sites before this pass).
121
+ const keywordMatch = line.match(/^(x-axis|y-axis|bar|line|title)\b/i)
122
+ if (keywordMatch) {
123
+ throw malformedDirectiveError(
124
+ keywordMatch[1]!.toLowerCase(),
125
+ line,
126
+ stmt.line,
127
+ )
128
+ }
129
+ }
130
+
131
+ // Auto-derive y-axis range from data if not specified
132
+ if (!yAxis.range && series.length > 0) {
133
+ const allValues = series.flatMap((s) => s.data)
134
+ let min = Math.min(...allValues)
135
+ let max = Math.max(...allValues)
136
+ const span = max - min || 1
137
+ // Add 10% padding
138
+ min = min - span * 0.1
139
+ max = max + span * 0.1
140
+ // Floor to 0 if all values are positive and min is close to 0
141
+ if (min > 0 && min < span * 0.5) min = 0
142
+ yAxis.range = { min, max }
143
+ }
144
+
145
+ // Fallback y-axis range
146
+ if (!yAxis.range) {
147
+ yAxis.range = { min: 0, max: 100 }
148
+ }
149
+
150
+ return { title, horizontal, xAxis, yAxis, series }
151
+ }
152
+
153
+ /**
154
+ * Split a categorical x-axis bracket's comma-separated contents into raw
155
+ * item strings, treating a comma *inside* a double-quoted item as literal
156
+ * text rather than a delimiter. A naive `.split(',')` would break
157
+ * `["Jan, Feb", "Mar"]` into three pieces instead of two — quoting a value
158
+ * that itself contains a comma is exactly the case quoting exists for. Each
159
+ * returned raw item is still trimmed and unquoted by the caller.
160
+ */
161
+ function splitCategoryList(str: string): string[] {
162
+ const items: string[] = []
163
+ let current = ''
164
+ let inQuotes = false
165
+ for (const char of str) {
166
+ if (char === '"') {
167
+ inQuotes = !inQuotes
168
+ current += char
169
+ } else if (char === ',' && !inQuotes) {
170
+ items.push(current)
171
+ current = ''
172
+ } else {
173
+ current += char
174
+ }
175
+ }
176
+ items.push(current)
177
+ return items
178
+ }
179
+
180
+ /**
181
+ * Strip one matching pair of leading/trailing double quotes from a category
182
+ * item, if present — mirroring how an axis *title* capture group already
183
+ * unquotes via its regex. `x-axis [A, B, C]` items are only split and
184
+ * trimmed, so a quoted item like `"CLI output / logs"` previously kept its
185
+ * literal quote characters in the rendered label. See issue #1087.
186
+ */
187
+ function unquote(value: string): string {
188
+ if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) {
189
+ return value.slice(1, -1)
190
+ }
191
+ return value
192
+ }
193
+
194
+ /**
195
+ * Parse a `bar`/`line` series' comma-separated bracket contents into
196
+ * numbers, throwing on any element that isn't a valid number instead of
197
+ * silently coercing it to `NaN` (which serializes as `null` and can
198
+ * propagate into layout math with no indication anything was wrong — see
199
+ * issue #541's audit finding on this exact parser).
200
+ */
201
+ function parseNumericArray(
202
+ str: string,
203
+ seriesType: 'bar' | 'line',
204
+ line: string,
205
+ lineNumber: number,
206
+ ): number[] {
207
+ return str.split(',').map((raw, index) => {
208
+ const trimmed = raw.trim()
209
+ const value = parseFloat(trimmed)
210
+ if (trimmed.length === 0 || Number.isNaN(value)) {
211
+ throw new Error(
212
+ `Line ${lineNumber}: Invalid numeric value ${JSON.stringify(trimmed)} at position ${
213
+ index + 1
214
+ } in "${line}". Every value in a ${seriesType} [...] list must be a number.`,
215
+ )
216
+ }
217
+ return value
218
+ })
219
+ }
220
+
221
+ /** Expected-syntax hint per xychart-beta keyword, for `malformedDirectiveError`. */
222
+ const XYCHART_DIRECTIVE_HELP: Record<string, string> = {
223
+ 'x-axis':
224
+ 'x-axis [A, B, C] (categories) or x-axis 0 --> 100 (numeric range), either optionally preceded by a quoted title',
225
+ 'y-axis':
226
+ 'y-axis 0 --> 100 (numeric range) or y-axis "Title" (title only), optionally preceded by a quoted title before a range',
227
+ bar: 'bar [10, 20, 30] — a comma-separated numeric array in square brackets',
228
+ line: 'line [10, 20, 30] — a comma-separated numeric array in square brackets',
229
+ title: 'title "Chart Title" — a double-quoted string',
230
+ }
231
+
232
+ /**
233
+ * Build the error for a line that starts with a recognized xychart-beta
234
+ * keyword (x-axis/y-axis/bar/line/title) but doesn't match that keyword's
235
+ * expected syntax in any of the forms this parser supports.
236
+ */
237
+ function malformedDirectiveError(
238
+ keyword: string,
239
+ line: string,
240
+ lineNumber: number,
241
+ ): Error {
242
+ const help = XYCHART_DIRECTIVE_HELP[keyword] ?? keyword
243
+ return new Error(
244
+ `Line ${lineNumber}: Malformed xychart-beta "${keyword}" directive: "${line}". Expected: ${help}.`,
245
+ )
246
+ }