@zombie-mermaid/mermaid-parser 4.0.0 → 4.2.0

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/src/index.ts CHANGED
@@ -2,8 +2,9 @@
2
2
  // @zombie-mermaid/mermaid-parser — per-type diagram parsers
3
3
  //
4
4
  // Class, ER, sequence, and XY chart diagrams have no shared generic model
5
- // the way flowcharts/state diagrams do (`MermaidGraph`, parsed by the
6
- // umbrella's own `src/parser.ts`). Both `svg-renderer` and `ascii-renderer`
5
+ // the way flowcharts/state diagrams do (`MermaidGraph`, parsed by
6
+ // `flowchart-parser.ts` here, moved from the umbrella's `src/parser.ts`
7
+ // under #1318 so both renderers share one copy). Both `svg-renderer` and `ascii-renderer`
7
8
  // import each type's parse function and types directly — confirmed by grep
8
9
  // (zombie-mermaid#624, umbrella #620, `monorepo-conversion-scoping.md`
9
10
  // finding 2) — so this package's public API is every per-type parse
@@ -53,4 +54,8 @@ export * from './xychart/parser.ts'
53
54
  export * from './xychart/types.ts'
54
55
  export * from './xychart/colors.ts'
55
56
 
57
+ export * from './pie/parser.ts'
58
+ export * from './pie/types.ts'
59
+
56
60
  export * from './expanded-shapes.ts'
61
+ export * from './flowchart-parser.ts'
@@ -0,0 +1,393 @@
1
+ import type { PieChart, PieSlice } from './types.ts'
2
+
3
+ // ============================================================================
4
+ // Pie chart parser
5
+ //
6
+ // Parses Mermaid `pie` syntax into a typed PieChart, matching what Mermaid
7
+ // itself accepts and rejects. Mermaid parses pie charts with a Langium
8
+ // grammar (mermaid-js/mermaid packages/parser/src/language/pie/pie.langium
9
+ // plus common/common.langium) and then validates slices in pieDb.ts. This
10
+ // file is a small hand-written lexer + recursive-descent parser over the same
11
+ // token patterns, so the accepted language is the same:
12
+ //
13
+ // pie [showData] [title <text> | accTitle: <text> | accDescr …]
14
+ // title <text> (own line; rest of line, unquoted)
15
+ // accTitle: <text>
16
+ // accDescr: <text>
17
+ // accDescr { <multi-line text> }
18
+ // "Label" : 42.5 (or 'Label'; label MUST be quoted)
19
+ //
20
+ // Mermaid's rules, each mirrored below:
21
+ // - `showData` is only valid directly after `pie`; a `showData` line on
22
+ // its own is a syntax error.
23
+ // - Numbers are `-?\d+\.\d+` or `-?(0|[1-9]\d*)` — no `+`, no exponent,
24
+ // no leading `.`, no trailing `.`.
25
+ // - Everything after a complete statement on the same line (other than a
26
+ // `%%` comment) is a syntax error.
27
+ // - A negative value is an error (pieDb.addSection), reported only once
28
+ // the whole chart has parsed, and before the duplicate-label check.
29
+ // - A zero value is accepted.
30
+ // - A repeated label keeps its first value; later ones are ignored.
31
+ // - A chart with no slices (a bare `pie`) is valid.
32
+ // - `title`/`accTitle`/`accDescr` may repeat; the last one wins, and an
33
+ // empty one counts as unset.
34
+ // - Keywords are case-sensitive (`Pie`, `showdata`, `Title` are errors).
35
+ //
36
+ // Why raw text and not `Statement[]` like the other mermaid-parser parsers:
37
+ // `splitStatements` splits on `;` and treats `'` as a quote when looking for
38
+ // `%%`, neither of which Mermaid's pie grammar does — `title A; B` is one
39
+ // title there, and a quoted label may even span lines. Lexing the source
40
+ // directly keeps those cases identical to Mermaid. The SVG registry's
41
+ // `parse(lines, text)` already hands every parser the raw text too.
42
+ //
43
+ // Earlier ports: lukilabs/beautiful-mermaid#151 (birenroy) — the starting
44
+ // point for the model and tests — and #150 (Daniele-rolli), whose parser
45
+ // rejected unknown lines instead of dropping them.
46
+ // ============================================================================
47
+
48
+ type TokenKind =
49
+ | 'NEWLINE'
50
+ | 'PIE'
51
+ | 'SHOW_DATA'
52
+ | 'COLON'
53
+ | 'NUMBER'
54
+ | 'STRING'
55
+ | 'TITLE'
56
+ | 'ACC_TITLE'
57
+ | 'ACC_DESCR'
58
+
59
+ interface Token {
60
+ kind: TokenKind
61
+ text: string
62
+ /** 1-based source line the token starts on. */
63
+ line: number
64
+ }
65
+
66
+ /**
67
+ * Token patterns in Langium's lexing order: whitespace first, then
68
+ * keywords (longest first), then terminals. `null` kinds are hidden (skipped).
69
+ * Each is sticky so it only matches at the current position.
70
+ *
71
+ * `pie`/`showData` carry the `(?:(?=%%)|(?!\S))` suffix Mermaid's
72
+ * `AbstractMermaidTokenBuilder` adds: the keyword must be followed by
73
+ * whitespace, a comment, or the end of the text.
74
+ */
75
+ const TOKEN_PATTERNS: ReadonlyArray<readonly [TokenKind | null, RegExp]> = [
76
+ [null, /[\t ]+/y],
77
+ ['SHOW_DATA', /showData(?:(?=%%)|(?!\S))/y],
78
+ ['PIE', /pie(?:(?=%%)|(?!\S))/y],
79
+ ['COLON', /:/y],
80
+ ['NUMBER', /-?[0-9]+\.[0-9]+(?!\.)|-?(?:0|[1-9][0-9]*)(?!\.)/y],
81
+ [
82
+ 'ACC_DESCR',
83
+ /[\t ]*accDescr(?:[\t ]*:(?:[^\n\r]*?(?=%%)|[^\n\r]*)|\s*\{[^}]*\})/y,
84
+ ],
85
+ ['ACC_TITLE', /[\t ]*accTitle[\t ]*:(?:[^\n\r]*?(?=%%)|[^\n\r]*)/y],
86
+ ['TITLE', /[\t ]*title(?:[\t ][^\n\r]*?(?=%%)|[\t ][^\n\r]*|)/y],
87
+ ['STRING', /"(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'/y],
88
+ ['NEWLINE', /\n/y],
89
+ [null, /[\t ]*%%[^\n\r]*/y],
90
+ ]
91
+
92
+ /** Mermaid's `directiveRegex` (diagram-api/regexes.ts), verbatim. */
93
+ const DIRECTIVE_REGEX =
94
+ /%{2}{\s*(?:(\w+)\s*:|(\w+))\s*(?:(\w+)|((?:(?!}%{2}).|\r?\n)*))?\s*(?:}%{2})?/gi
95
+
96
+ /** Value extractors from Mermaid's common/matcher.ts. */
97
+ const ACC_DESCR_VALUE = /accDescr(?:[\t ]*:([^\n\r]*)|\s*\{([^}]*)\})/
98
+ const ACC_TITLE_VALUE = /accTitle[\t ]*:([^\n\r]*)/
99
+ const TITLE_VALUE = /title([\t ][^\n\r]*|)/
100
+
101
+ const SYNTAX_HELP =
102
+ 'A pie chart is `pie [showData] [title <text>]` followed by lines of `"Label" : value` (label quoted with " or \'), `title <text>`, `accTitle: <text>`, or `accDescr: <text>`.'
103
+
104
+ /**
105
+ * Mirror Mermaid's text preprocessing (preprocess.ts `cleanupText` and
106
+ * `removeDirectives`) that runs before the grammar sees the source:
107
+ * - CRLF / lone CR become LF;
108
+ * - double-quoted attributes inside HTML-like tags become single-quoted;
109
+ * - `%%{ … }%%` directives are removed. Unlike Mermaid, the newlines a
110
+ * multi-line directive spans are kept, so line numbers in errors still
111
+ * point at the caller's source.
112
+ */
113
+ function preprocess(text: string): string {
114
+ return text
115
+ .replace(/\r\n?/g, '\n')
116
+ .replace(
117
+ /<(\w+)([^>]*)>/g,
118
+ (_match, tag: string, attributes: string) =>
119
+ '<' + tag + attributes.replace(/="([^"]*)"/g, "='$1'") + '>',
120
+ )
121
+ .replace(DIRECTIVE_REGEX, (directive) => directive.replace(/[^\n]/g, ''))
122
+ }
123
+
124
+ function countNewlines(text: string): number {
125
+ let count = 0
126
+ for (const ch of text) if (ch === '\n') count++
127
+ return count
128
+ }
129
+
130
+ /** Build a `Line N: …` error quoting the offending source line. */
131
+ function lineError(
132
+ lines: readonly string[],
133
+ line: number,
134
+ message: string,
135
+ ): Error {
136
+ /* v8 ignore next -- every caller passes a line inside `lines` */
137
+ const source = (lines[line - 1] ?? '').trim()
138
+ return new Error(`Line ${line}: ${message} in "${source}". ${SYNTAX_HELP}`)
139
+ }
140
+
141
+ /** What the lexer expected at a position it couldn't tokenize. */
142
+ function lexHint(source: string, pos: number, previous?: Token): string {
143
+ const ch = source[pos]
144
+ if (ch === '"' || ch === "'") {
145
+ return `Unterminated quoted label (missing closing ${ch})`
146
+ }
147
+ if (previous?.kind === 'COLON') {
148
+ return 'Invalid slice value — expected a number like 42 or 42.5 after ":"'
149
+ }
150
+ const atLineStart =
151
+ previous === undefined ||
152
+ previous.kind === 'NEWLINE' ||
153
+ previous.kind === 'PIE' ||
154
+ previous.kind === 'SHOW_DATA'
155
+ if (atLineStart) {
156
+ return 'Unrecognized statement — slice labels must be quoted, e.g. "Dogs" : 42'
157
+ }
158
+ return `Unexpected text ${JSON.stringify(source.slice(pos).split('\n')[0])}`
159
+ }
160
+
161
+ function tokenize(source: string, lines: readonly string[]): Token[] {
162
+ const tokens: Token[] = []
163
+ let pos = 0
164
+ let line = 1
165
+
166
+ while (pos < source.length) {
167
+ let matched = false
168
+ for (const [kind, pattern] of TOKEN_PATTERNS) {
169
+ pattern.lastIndex = pos
170
+ const match = pattern.exec(source)
171
+ if (match === null || match[0].length === 0) continue
172
+ const text = match[0]
173
+ if (kind !== null) tokens.push({ kind, text, line })
174
+ line += countNewlines(text)
175
+ pos += text.length
176
+ matched = true
177
+ break
178
+ }
179
+ if (!matched) {
180
+ throw lineError(lines, line, lexHint(source, pos, tokens.at(-1)))
181
+ }
182
+ }
183
+
184
+ return tokens
185
+ }
186
+
187
+ /**
188
+ * Langium's default `STRING` value conversion (ValueConverter.convertString):
189
+ * drop the surrounding quotes and resolve backslash escapes. Mermaid's pie
190
+ * value converter doesn't override it for slice labels.
191
+ */
192
+ function convertString(input: string): string {
193
+ let result = ''
194
+ for (let i = 1; i < input.length - 1; i++) {
195
+ const ch = input.charAt(i)
196
+ if (ch !== '\\') {
197
+ result += ch
198
+ continue
199
+ }
200
+ const escaped = input.charAt(++i)
201
+ switch (escaped) {
202
+ case 'b':
203
+ result += '\b'
204
+ break
205
+ case 'f':
206
+ result += '\f'
207
+ break
208
+ case 'n':
209
+ result += '\n'
210
+ break
211
+ case 'r':
212
+ result += '\r'
213
+ break
214
+ case 't':
215
+ result += '\t'
216
+ break
217
+ case 'v':
218
+ result += '\v'
219
+ break
220
+ case '0':
221
+ result += '\0'
222
+ break
223
+ default:
224
+ result += escaped
225
+ }
226
+ }
227
+ return result
228
+ }
229
+
230
+ /**
231
+ * Mermaid's `AbstractMermaidValueConverter.runCommonConverter` for the
232
+ * TITLE / ACC_TITLE / ACC_DESCR tokens.
233
+ */
234
+ function convertTitleLike(kind: TokenKind, text: string): string {
235
+ const regex =
236
+ kind === 'ACC_DESCR'
237
+ ? ACC_DESCR_VALUE
238
+ : kind === 'ACC_TITLE'
239
+ ? ACC_TITLE_VALUE
240
+ : TITLE_VALUE
241
+ const match = regex.exec(text)
242
+ if (match?.[1] !== undefined) {
243
+ return match[1].trim().replace(/[\t ]{2,}/gm, ' ')
244
+ }
245
+ // Only the `accDescr { … }` form leaves group 1 unset, and it always sets
246
+ // group 2: every TITLE / ACC_TITLE / ACC_DESCR token matches its value
247
+ // regex. (Mermaid's converter returns undefined if neither group is set.)
248
+ /* v8 ignore next */
249
+ const braced = match?.[2] ?? ''
250
+ return braced
251
+ .replace(/^\s*/gm, '')
252
+ .replace(/\s+$/gm, '')
253
+ .replace(/[\t ]{2,}/gm, ' ')
254
+ .replace(/[\n\r]{2,}/gm, '\n')
255
+ }
256
+
257
+ const DESCRIBE: Record<TokenKind, string> = {
258
+ NEWLINE: 'end of line',
259
+ PIE: '"pie"',
260
+ SHOW_DATA: '"showData"',
261
+ COLON: '":"',
262
+ NUMBER: 'number',
263
+ STRING: 'quoted label',
264
+ TITLE: 'title',
265
+ ACC_TITLE: 'accTitle',
266
+ ACC_DESCR: 'accDescr',
267
+ }
268
+
269
+ interface RawSlice extends PieSlice {
270
+ line: number
271
+ }
272
+
273
+ /**
274
+ * Parse Mermaid pie chart source text.
275
+ *
276
+ * Throws `Line N: …` errors (the shape the MCP diagnostics expect) for
277
+ * anything Mermaid's own parser would reject.
278
+ */
279
+ export function parsePieChart(text: string): PieChart {
280
+ const source = preprocess(text)
281
+ const lines = source.split('\n')
282
+ const tokens = tokenize(source, lines)
283
+ let i = 0
284
+
285
+ const unexpected = (token: Token | undefined, expected: string): Error => {
286
+ if (token === undefined) {
287
+ return lineError(
288
+ lines,
289
+ lines.length,
290
+ `Unexpected end of input — expected ${expected}`,
291
+ )
292
+ }
293
+ let message = `Unexpected ${DESCRIBE[token.kind]} — expected ${expected}`
294
+ if (token.kind === 'SHOW_DATA') {
295
+ message +=
296
+ '; showData is only allowed directly after "pie" on the header line'
297
+ } else if (token.kind === 'PIE') {
298
+ message += '; the "pie" header may only appear once'
299
+ }
300
+ return lineError(lines, token.line, message)
301
+ }
302
+
303
+ /** Statements end at a newline or the end of the text (Mermaid's `EOL`). */
304
+ const expectEndOfStatement = (what: string): void => {
305
+ const next = tokens[i]
306
+ if (next !== undefined && next.kind !== 'NEWLINE') {
307
+ throw unexpected(next, `end of line after ${what}`)
308
+ }
309
+ }
310
+
311
+ while (tokens[i]?.kind === 'NEWLINE') i++
312
+ if (tokens[i]?.kind !== 'PIE') {
313
+ throw unexpected(tokens[i], 'the "pie" header')
314
+ }
315
+ i++
316
+
317
+ let showData = false
318
+ if (tokens[i]?.kind === 'SHOW_DATA') {
319
+ showData = true
320
+ i++
321
+ }
322
+
323
+ let title = ''
324
+ let accTitle = ''
325
+ let accDescr = ''
326
+ const rawSlices: RawSlice[] = []
327
+
328
+ while (i < tokens.length) {
329
+ const token = tokens[i]!
330
+ switch (token.kind) {
331
+ case 'NEWLINE':
332
+ i++
333
+ break
334
+ case 'TITLE':
335
+ case 'ACC_TITLE':
336
+ case 'ACC_DESCR': {
337
+ const value = convertTitleLike(token.kind, token.text)
338
+ if (token.kind === 'TITLE') title = value
339
+ else if (token.kind === 'ACC_TITLE') accTitle = value
340
+ else accDescr = value
341
+ i++
342
+ expectEndOfStatement(DESCRIBE[token.kind])
343
+ break
344
+ }
345
+ case 'STRING': {
346
+ i++
347
+ if (tokens[i]?.kind !== 'COLON') {
348
+ throw unexpected(tokens[i], '":" after the slice label')
349
+ }
350
+ i++
351
+ const valueToken = tokens[i]
352
+ if (valueToken?.kind !== 'NUMBER') {
353
+ throw unexpected(valueToken, 'a number after ":"')
354
+ }
355
+ i++
356
+ expectEndOfStatement('the slice value')
357
+ rawSlices.push({
358
+ label: convertString(token.text),
359
+ value: Number(valueToken.text),
360
+ line: token.line,
361
+ })
362
+ break
363
+ }
364
+ default:
365
+ throw unexpected(
366
+ token,
367
+ 'a quoted slice label, title, accTitle, or accDescr',
368
+ )
369
+ }
370
+ }
371
+
372
+ // Mermaid's pieDb.addSection, applied in source order once the whole
373
+ // chart has parsed: negative check first, then first-label-wins.
374
+ const slices: PieSlice[] = []
375
+ const seen = new Set<string>()
376
+ for (const { label, value, line } of rawSlices) {
377
+ if (value < 0) {
378
+ throw new Error(
379
+ `Line ${line}: "${label}" has invalid value: ${value}. Negative values are not allowed in pie charts. All slice values must be >= 0.`,
380
+ )
381
+ }
382
+ if (seen.has(label)) continue
383
+ seen.add(label)
384
+ // `-0` passes Mermaid's `< 0` check; store it as plain 0.
385
+ slices.push({ label, value: value === 0 ? 0 : value })
386
+ }
387
+
388
+ const chart: PieChart = { showData, slices }
389
+ if (title) chart.title = title
390
+ if (accTitle) chart.accTitle = accTitle
391
+ if (accDescr) chart.accDescr = accDescr
392
+ return chart
393
+ }
@@ -0,0 +1,109 @@
1
+ // ============================================================================
2
+ // Pie chart types
3
+ //
4
+ // The parsed (logical) model of a Mermaid `pie` diagram, plus the positioned
5
+ // model the SVG renderer draws (`PositionedPieChart`, produced by
6
+ // `layoutPieChart` in `@zombie-mermaid/svg-renderer`). The ASCII renderer
7
+ // works from the parsed `PieChart` directly, like xychart's does.
8
+ //
9
+ // Shape adapted from lukilabs/beautiful-mermaid#151 (birenroy), extended with
10
+ // the accessibility fields Mermaid's own pie grammar accepts.
11
+ // ============================================================================
12
+
13
+ /** One slice of a pie chart, in source order. */
14
+ export interface PieSlice {
15
+ /** Slice label, with its surrounding quotes removed and escapes resolved. */
16
+ label: string
17
+ /** Slice value. Never negative; zero is allowed (Mermaid accepts it). */
18
+ value: number
19
+ }
20
+
21
+ /** A parsed Mermaid pie chart. */
22
+ export interface PieChart {
23
+ /** `title …`, either on the `pie` header line or on its own line. */
24
+ title?: string
25
+ /** `accTitle: …` accessible title. */
26
+ accTitle?: string
27
+ /** `accDescr: …` or multi-line `accDescr { … }` accessible description. */
28
+ accDescr?: string
29
+ /** `pie showData` — show each slice's raw value next to its legend label. */
30
+ showData: boolean
31
+ /**
32
+ * Slices in source order. A label that repeats keeps its first value and
33
+ * position; later duplicates are ignored, as in Mermaid. May be empty:
34
+ * Mermaid accepts a bare `pie` with no slices.
35
+ */
36
+ slices: PieSlice[]
37
+ }
38
+
39
+ // ============================================================================
40
+ // Positioned pie chart — ready for SVG rendering
41
+ //
42
+ // Geometry follows Mermaid's own pieRenderer.ts: a 450px-tall frame, a pie of
43
+ // radius 185 centred at (225, 225) before any viewBox shift, slices drawn
44
+ // clockwise from 12 o'clock in source order, percentage labels at 0.75 of the
45
+ // radius, and a legend to the right. All coordinates here are absolute SVG
46
+ // user units with the viewBox already shifted to start at (0, 0).
47
+ // ============================================================================
48
+
49
+ /** One drawn slice. Slices under 1% of the total are never positioned. */
50
+ export interface PositionedPieSlice {
51
+ /** Slice label (as parsed). */
52
+ label: string
53
+ /** Slice value (as parsed). */
54
+ value: number
55
+ /**
56
+ * Palette slot: the slice's index among *all* slices in source order,
57
+ * modulo 12 — Mermaid's `pie1`..`pie12` colour scale is keyed by every
58
+ * label, including slices too small to draw, and wraps after 12.
59
+ */
60
+ colorIndex: number
61
+ /** Start angle, radians clockwise from 12 o'clock. */
62
+ startAngle: number
63
+ /** End angle, radians clockwise from 12 o'clock. */
64
+ endAngle: number
65
+ /** SVG path data for the wedge (or full circle for a 100% slice). */
66
+ path: string
67
+ /** Percentage label, `((value / total) * 100).toFixed(0) + '%'` as in Mermaid. */
68
+ percentText: string
69
+ /** Anchor of the percentage label (text-anchor middle, alphabetic baseline). */
70
+ labelX: number
71
+ labelY: number
72
+ }
73
+
74
+ /** One legend row: a colour swatch and its label. Every slice gets one. */
75
+ export interface PositionedPieLegendItem {
76
+ /** Row text: the label, or `label [value]` when `showData` is on. */
77
+ text: string
78
+ colorIndex: number
79
+ /** Top-left corner of the square swatch. */
80
+ x: number
81
+ y: number
82
+ /** Swatch side length. */
83
+ size: number
84
+ /** Text anchor (text-anchor start, alphabetic baseline). */
85
+ textX: number
86
+ textY: number
87
+ }
88
+
89
+ export interface PositionedPieChart {
90
+ width: number
91
+ height: number
92
+ /** Pie centre. */
93
+ cx: number
94
+ cy: number
95
+ /** Slice radius. */
96
+ radius: number
97
+ /** Radius of the outline circle drawn around the pie. */
98
+ outerRadius: number
99
+ /** Title text and its anchor (text-anchor middle, alphabetic baseline). */
100
+ title?: { text: string; x: number; y: number }
101
+ /** Drawn slices, in source order. */
102
+ slices: PositionedPieSlice[]
103
+ /** Legend rows, one per parsed slice, in source order. */
104
+ legend: PositionedPieLegendItem[]
105
+ /** `accTitle`, carried through for the SVG accessible name. */
106
+ accTitle?: string
107
+ /** `accDescr`, carried through for the SVG `<desc>`. */
108
+ accDescr?: string
109
+ }