@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.
- package/LICENSE +22 -0
- package/dist/index.cjs +3 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +879 -0
- package/dist/index.d.ts +879 -0
- package/dist/index.js +994 -0
- package/dist/index.js.map +1 -0
- package/package.json +35 -0
- package/src/__tests__/box-color.test.ts +80 -0
- package/src/__tests__/sequence-activation-fix.test.ts +91 -0
- package/src/__tests__/xychart-colors.test.ts +149 -0
- package/src/class/format.ts +15 -0
- package/src/class/parser.ts +593 -0
- package/src/class/types.ts +166 -0
- package/src/er/parser.ts +286 -0
- package/src/er/types.ts +99 -0
- package/src/expanded-shapes.ts +376 -0
- package/src/index.ts +48 -0
- package/src/sequence/activation-check.ts +149 -0
- package/src/sequence/activation-fix.ts +112 -0
- package/src/sequence/box-color.ts +85 -0
- package/src/sequence/parser.ts +613 -0
- package/src/sequence/types.ts +242 -0
- package/src/xychart/colors.ts +177 -0
- package/src/xychart/parser.ts +246 -0
- package/src/xychart/types.ts +150 -0
|
@@ -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
|
+
}
|