@zombie-mermaid/mermaid-parser 4.1.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.
@@ -0,0 +1,452 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { parsePieChart } from '../index.ts'
3
+
4
+ // The first block is lukilabs/beautiful-mermaid#151's pie-parser.test.ts
5
+ // (birenroy), adapted. Where #151 accepted something Mermaid rejects, or
6
+ // read it differently, the case now asserts Mermaid's behavior and says so.
7
+ // The strictness cases after it cover Mermaid rules #151 didn't enforce,
8
+ // several of which #150's (Daniele-rolli) parser already did.
9
+
10
+ describe('upstream #151: parsePieChart', () => {
11
+ it('parses basic pie chart with double-quoted labels', () => {
12
+ const chart = parsePieChart(`pie
13
+ "Dogs" : 386
14
+ "Cats" : 85
15
+ "Rats" : 15`)
16
+ expect(chart).toEqual({
17
+ showData: false,
18
+ slices: [
19
+ { label: 'Dogs', value: 386 },
20
+ { label: 'Cats', value: 85 },
21
+ { label: 'Rats', value: 15 },
22
+ ],
23
+ })
24
+ })
25
+
26
+ it('parses title in header line: pie title <title>', () => {
27
+ const chart = parsePieChart(`pie title Pets adopted by volunteers
28
+ "Dogs" : 386
29
+ "Cats" : 85`)
30
+ expect(chart.title).toBe('Pets adopted by volunteers')
31
+ expect(chart.showData).toBe(false)
32
+ expect(chart.slices).toHaveLength(2)
33
+ })
34
+
35
+ // #151 stripped the quotes. Mermaid's title is the raw rest of the line,
36
+ // so the quotes are part of it.
37
+ it('keeps quotes in a quoted title, as Mermaid does', () => {
38
+ const chart = parsePieChart(`pie title "Pets adopted by volunteers"
39
+ "Dogs" : 386`)
40
+ expect(chart.title).toBe('"Pets adopted by volunteers"')
41
+ })
42
+
43
+ it('parses standalone title directive', () => {
44
+ const chart = parsePieChart(`pie
45
+ title Key Elements in Product X
46
+ "Calcium" : 42.96
47
+ "Potassium" : 50.05`)
48
+ expect(chart.title).toBe('Key Elements in Product X')
49
+ expect(chart.slices).toHaveLength(2)
50
+ expect(chart.slices[0]!.value).toBe(42.96)
51
+ })
52
+
53
+ it('parses showData on the header line', () => {
54
+ const chart = parsePieChart(`pie showData title Key elements
55
+ "Calcium" : 42.96
56
+ "Potassium" : 50.05`)
57
+ expect(chart.showData).toBe(true)
58
+ expect(chart.title).toBe('Key elements')
59
+ })
60
+
61
+ // #151 accepted a standalone `showData` line. Mermaid's grammar only
62
+ // allows it directly after `pie`.
63
+ it('rejects showData on its own line', () => {
64
+ expect(() =>
65
+ parsePieChart(`pie
66
+ showData
67
+ title Elements
68
+ "A" : 10`),
69
+ ).toThrow(/Line 2: .*showData is only allowed directly after "pie"/)
70
+ })
71
+
72
+ it('parses single-quoted labels', () => {
73
+ const chart = parsePieChart(`pie
74
+ 'Calcium' : 42.96
75
+ "Magnesium" : 10.01`)
76
+ expect(chart.slices).toEqual([
77
+ { label: 'Calcium', value: 42.96 },
78
+ { label: 'Magnesium', value: 10.01 },
79
+ ])
80
+ })
81
+
82
+ // #151 accepted unquoted labels. Mermaid requires quotes.
83
+ it('rejects unquoted slice labels', () => {
84
+ expect(() =>
85
+ parsePieChart(`pie
86
+ Potassium : 50.05`),
87
+ ).toThrow(/Line 2: .*slice labels must be quoted/)
88
+ })
89
+
90
+ it('parses quoted labels containing colons', () => {
91
+ const chart = parsePieChart(`pie
92
+ "Ratio 1:2" : 30
93
+ "Ratio 2:3" : 70`)
94
+ expect(chart.slices).toEqual([
95
+ { label: 'Ratio 1:2', value: 30 },
96
+ { label: 'Ratio 2:3', value: 70 },
97
+ ])
98
+ })
99
+
100
+ it('ignores comments and empty lines', () => {
101
+ const chart = parsePieChart(`pie
102
+ %% This is a comment
103
+ "Slice A" : 10
104
+
105
+ %% Another comment
106
+ "Slice B" : 20`)
107
+ expect(chart.slices).toHaveLength(2)
108
+ })
109
+
110
+ it('does not read "showdata" inside a title as the showData flag', () => {
111
+ const chart = parsePieChart(`pie title How to showdata properly
112
+ "A" : 10`)
113
+ expect(chart.showData).toBe(false)
114
+ expect(chart.title).toBe('How to showdata properly')
115
+ })
116
+
117
+ // #151 accepted `title: …`. In Mermaid's grammar `title` must be followed
118
+ // by whitespace, so the colon is a syntax error.
119
+ it('rejects title: with a colon', () => {
120
+ expect(() =>
121
+ parsePieChart(`pie
122
+ title: My Chart
123
+ "A" : 10`),
124
+ ).toThrow(/Line 2: /)
125
+ })
126
+
127
+ // #151 dropped accTitle/accDescr. They are now kept.
128
+ it('keeps accTitle and accDescr alongside the slices', () => {
129
+ const chart = parsePieChart(`pie title Accessibility Chart
130
+ accTitle: 2024 Chart
131
+ accDescr: An accessibility description
132
+ "A" : 10
133
+ "B" : 20`)
134
+ expect(chart).toEqual({
135
+ title: 'Accessibility Chart',
136
+ accTitle: '2024 Chart',
137
+ accDescr: 'An accessibility description',
138
+ showData: false,
139
+ slices: [
140
+ { label: 'A', value: 10 },
141
+ { label: 'B', value: 20 },
142
+ ],
143
+ })
144
+ })
145
+ })
146
+
147
+ describe('Mermaid docs examples', () => {
148
+ it('parses the basic example', () => {
149
+ const chart = parsePieChart(`pie title Pets adopted by volunteers
150
+ "Dogs" : 386
151
+ "Cats" : 85
152
+ "Rats" : 15`)
153
+ expect(chart.title).toBe('Pets adopted by volunteers')
154
+ expect(chart.slices.map((s) => s.label)).toEqual(['Dogs', 'Cats', 'Rats'])
155
+ })
156
+
157
+ it('parses the showData example body', () => {
158
+ const chart = parsePieChart(`pie showData
159
+ title Key elements in Product X
160
+ "Calcium" : 42.96
161
+ "Potassium" : 50.05
162
+ "Magnesium" : 10.01
163
+ "Iron" : 5`)
164
+ expect(chart.showData).toBe(true)
165
+ expect(chart.title).toBe('Key elements in Product X')
166
+ expect(chart.slices.map((s) => s.value)).toEqual([42.96, 50.05, 10.01, 5])
167
+ })
168
+ })
169
+
170
+ describe('values', () => {
171
+ it("rejects a negative value with Mermaid's message and the line number", () => {
172
+ expect(() => parsePieChart('pie\n"A" : 1\n"B" : -2')).toThrow(
173
+ 'Line 3: "B" has invalid value: -2. Negative values are not allowed in pie charts. All slice values must be >= 0.',
174
+ )
175
+ })
176
+
177
+ it('rejects a negative value even when its label repeats an earlier one', () => {
178
+ expect(() => parsePieChart('pie\n"A" : 1\n"A" : -2')).toThrow(
179
+ /"A" has invalid value: -2/,
180
+ )
181
+ })
182
+
183
+ it('reports a syntax error before a negative value earlier in the chart', () => {
184
+ expect(() => parsePieChart('pie\n"A" : -1\nnonsense')).toThrow(/^Line 3: /)
185
+ })
186
+
187
+ it('accepts zero', () => {
188
+ expect(parsePieChart('pie\n"A" : 0\n"B" : 1').slices).toEqual([
189
+ { label: 'A', value: 0 },
190
+ { label: 'B', value: 1 },
191
+ ])
192
+ })
193
+
194
+ it('accepts -0 (Mermaid only rejects values < 0) and stores it as 0', () => {
195
+ const [slice] = parsePieChart('pie\n"A" : -0').slices
196
+ expect(Object.is(slice!.value, 0)).toBe(true)
197
+ })
198
+
199
+ it('accepts any number of decimal places and leading zeros before a decimal point', () => {
200
+ expect(parsePieChart('pie\n"A" : 1.23456\n"B" : 00.5').slices).toEqual([
201
+ { label: 'A', value: 1.23456 },
202
+ { label: 'B', value: 0.5 },
203
+ ])
204
+ })
205
+
206
+ it.each([
207
+ ['a plus sign', '+5'],
208
+ ['a plus sign on a decimal', '+5.5'],
209
+ ['a leading dot', '.5'],
210
+ ['a trailing dot', '5.'],
211
+ ['an exponent', '1e3'],
212
+ ['a leading zero on an integer', '05'],
213
+ ['two dots', '1.2.3'],
214
+ ['a unit suffix', '10px'],
215
+ ['a word', 'ten'],
216
+ ['a space after the minus', '- 5'],
217
+ ])('rejects a value with %s (%s)', (_name, value) => {
218
+ expect(() => parsePieChart(`pie\n"A" : ${value}`)).toThrow(/^Line 2: /)
219
+ })
220
+
221
+ it('rejects a slice with no value', () => {
222
+ expect(() => parsePieChart('pie\n"A" :')).toThrow(/expected a number/)
223
+ expect(() => parsePieChart('pie\n"A" : \n"B" : 1')).toThrow(
224
+ /Line 2: .*expected a number/,
225
+ )
226
+ })
227
+
228
+ it('rejects a slice with no colon', () => {
229
+ expect(() => parsePieChart('pie\n"A" 10')).toThrow(
230
+ /Line 2: .*expected ":" after the slice label/,
231
+ )
232
+ })
233
+ })
234
+
235
+ describe('labels', () => {
236
+ it('keeps the first value of a repeated label, in its original position', () => {
237
+ expect(parsePieChart('pie\n"A" : 1\n"B" : 2\n"A" : 3').slices).toEqual([
238
+ { label: 'A', value: 1 },
239
+ { label: 'B', value: 2 },
240
+ ])
241
+ })
242
+
243
+ it('keeps slices in source order rather than sorting them', () => {
244
+ expect(
245
+ parsePieChart('pie\n"small" : 1\n"big" : 100\n"mid" : 10').slices.map(
246
+ (s) => s.label,
247
+ ),
248
+ ).toEqual(['small', 'big', 'mid'])
249
+ })
250
+
251
+ it('resolves backslash escapes the way Langium does', () => {
252
+ expect(
253
+ parsePieChart(String.raw`pie
254
+ "say \"hi\"" : 1
255
+ 'it\'s' : 2
256
+ "tab\there" : 3`).slices.map((s) => s.label),
257
+ ).toEqual(['say "hi"', "it's", 'tab\there'])
258
+ })
259
+
260
+ // Langium's convertEscapeCharacter maps exactly these seven letters; any
261
+ // other escaped character stands for itself. Checked against
262
+ // @mermaid-js/parser 2.0.1, which turns
263
+ // "b\bf\fn\nr\rt\tv\v0\0q\"z\z" into "b\bf\fn\nr\rt\tv\u000b0\u0000q\"zz".
264
+ it.each([
265
+ ['\\b', '\b'],
266
+ ['\\f', '\f'],
267
+ ['\\n', '\n'],
268
+ ['\\r', '\r'],
269
+ ['\\t', '\t'],
270
+ ['\\v', '\v'],
271
+ ['\\0', '\0'],
272
+ ['\\z', 'z'],
273
+ ['\\\\', '\\'],
274
+ ])('resolves the %s escape in a label', (escape, resolved) => {
275
+ expect(parsePieChart(`pie\n"a${escape}b" : 1`).slices).toEqual([
276
+ { label: `a${resolved}b`, value: 1 },
277
+ ])
278
+ })
279
+
280
+ it('keeps the other quote character literally', () => {
281
+ expect(
282
+ parsePieChart(`pie\n"it's" : 1\n'say "hi"' : 2`).slices.map(
283
+ (s) => s.label,
284
+ ),
285
+ ).toEqual(["it's", 'say "hi"'])
286
+ })
287
+
288
+ it('keeps surrounding spaces and allows an empty label', () => {
289
+ expect(
290
+ parsePieChart('pie\n" padded " : 1\n"" : 2').slices.map((s) => s.label),
291
+ ).toEqual([' padded ', ''])
292
+ })
293
+
294
+ it('treats ; and %% inside a label as text', () => {
295
+ expect(
296
+ parsePieChart('pie\n"a; b" : 1\n"50%% off" : 2').slices.map(
297
+ (s) => s.label,
298
+ ),
299
+ ).toEqual(['a; b', '50%% off'])
300
+ })
301
+
302
+ it('accepts a quoted label that spans lines, as the grammar does', () => {
303
+ const chart = parsePieChart('pie\n"two\nlines" : 1\n"B" : 2')
304
+ expect(chart.slices[0]!.label).toBe('two\nlines')
305
+ })
306
+
307
+ it('rejects an unterminated quoted label', () => {
308
+ expect(() => parsePieChart('pie\n"A : 1')).toThrow(
309
+ /Line 2: Unterminated quoted label/,
310
+ )
311
+ })
312
+ })
313
+
314
+ describe('statements and structure', () => {
315
+ it('accepts a bare pie with no slices (Mermaid renders an empty chart)', () => {
316
+ expect(parsePieChart('pie')).toEqual({ showData: false, slices: [] })
317
+ expect(parsePieChart('pie title Nothing yet')).toEqual({
318
+ title: 'Nothing yet',
319
+ showData: false,
320
+ slices: [],
321
+ })
322
+ })
323
+
324
+ it('accepts a slice on the header line, as the grammar does', () => {
325
+ expect(parsePieChart('pie "A" : 1\n"B" : 2').slices).toHaveLength(2)
326
+ })
327
+
328
+ it('rejects two statements on one line', () => {
329
+ expect(() => parsePieChart('pie\n"A" : 1 "B" : 2')).toThrow(
330
+ /Line 2: .*expected end of line after the slice value/,
331
+ )
332
+ })
333
+
334
+ it('does not treat ; as a statement separator', () => {
335
+ expect(() => parsePieChart('pie\n"A" : 1; "B" : 2')).toThrow(/Line 2: /)
336
+ expect(() => parsePieChart('pie;"A" : 1')).toThrow(/Line 1: /)
337
+ })
338
+
339
+ it('keeps ; in a title, which runs to the end of the line', () => {
340
+ expect(parsePieChart('pie title A; B').title).toBe('A; B')
341
+ })
342
+
343
+ it('rejects unknown statements', () => {
344
+ expect(() => parsePieChart('pie\n"A" : 1\nnonsense here')).toThrow(
345
+ /Line 3: Unrecognized statement.*"nonsense here"/,
346
+ )
347
+ })
348
+
349
+ it('rejects unknown header modifiers', () => {
350
+ expect(() => parsePieChart('pie banana\n"A" : 1')).toThrow(/Line 1: /)
351
+ })
352
+
353
+ it('rejects a second pie header', () => {
354
+ expect(() => parsePieChart('pie\npie')).toThrow(
355
+ /Line 2: .*may only appear once/,
356
+ )
357
+ })
358
+
359
+ it('rejects input that does not start with pie', () => {
360
+ expect(() => parsePieChart('"A" : 1')).toThrow(/expected the "pie" header/)
361
+ expect(() => parsePieChart('')).toThrow(/expected the "pie" header/)
362
+ })
363
+
364
+ it('allows blank lines and comments before the header', () => {
365
+ expect(parsePieChart('\n%% note\n \npie\n"A" : 1').slices).toHaveLength(1)
366
+ })
367
+
368
+ it('matches keywords case-sensitively', () => {
369
+ expect(() => parsePieChart('Pie\n"A" : 1')).toThrow()
370
+ expect(() => parsePieChart('pie showdata\n"A" : 1')).toThrow()
371
+ expect(() => parsePieChart('pie\nTitle X')).toThrow()
372
+ expect(() => parsePieChart('pie\naccdescr: X')).toThrow()
373
+ })
374
+
375
+ it('requires whitespace after the pie and showData keywords', () => {
376
+ expect(() => parsePieChart('pieshowData')).toThrow()
377
+ expect(() => parsePieChart('pie showDatatitle X')).toThrow()
378
+ })
379
+
380
+ it('allows a comment directly after a keyword', () => {
381
+ expect(parsePieChart('pie%% c\n"A" : 1').slices).toHaveLength(1)
382
+ expect(parsePieChart('pie showData%% c\n"A" : 1').showData).toBe(true)
383
+ })
384
+
385
+ it('accepts CRLF line endings', () => {
386
+ expect(
387
+ parsePieChart('pie title T\r\n"A" : 1\r\n"B" : 2\r\n').slices,
388
+ ).toHaveLength(2)
389
+ })
390
+
391
+ it('skips %%{init}%% directives, including multi-line ones', () => {
392
+ const chart = parsePieChart(
393
+ '%%{init: {"pie": {"textPosition": 0.5}}}%%\npie\n%%{\n init: {}\n}%%\n"A" : 1',
394
+ )
395
+ expect(chart.slices).toEqual([{ label: 'A', value: 1 }])
396
+ })
397
+
398
+ it('reports physical line numbers across a multi-line directive', () => {
399
+ expect(() => parsePieChart('%%{\ninit: {}\n}%%\npie\nbad')).toThrow(
400
+ /^Line 5: /,
401
+ )
402
+ })
403
+ })
404
+
405
+ describe('title and accessibility', () => {
406
+ it('strips a trailing %% comment from title/accTitle/accDescr', () => {
407
+ const chart = parsePieChart(`pie
408
+ title My title %% note
409
+ accTitle: Acc %% note
410
+ accDescr: Desc %% note`)
411
+ expect(chart.title).toBe('My title')
412
+ expect(chart.accTitle).toBe('Acc')
413
+ expect(chart.accDescr).toBe('Desc')
414
+ })
415
+
416
+ it('strips a comment even after an apostrophe in the title', () => {
417
+ expect(parsePieChart("pie title Bob's pets %% note").title).toBe(
418
+ "Bob's pets",
419
+ )
420
+ })
421
+
422
+ it('collapses runs of spaces and tabs', () => {
423
+ expect(parsePieChart('pie title a \t b').title).toBe('a b')
424
+ })
425
+
426
+ it('lets the last title win, and treats an empty title as unset', () => {
427
+ expect(parsePieChart('pie title A\ntitle B').title).toBe('B')
428
+ expect(parsePieChart('pie title A\ntitle').title).toBeUndefined()
429
+ })
430
+
431
+ it('treats empty accTitle/accDescr as unset', () => {
432
+ const chart = parsePieChart('pie\naccTitle:\naccDescr {}')
433
+ expect(chart.accTitle).toBeUndefined()
434
+ expect(chart.accDescr).toBeUndefined()
435
+ })
436
+
437
+ it('accepts accTitle/accDescr with space before the colon', () => {
438
+ const chart = parsePieChart('pie\naccTitle : T\naccDescr : D')
439
+ expect(chart.accTitle).toBe('T')
440
+ expect(chart.accDescr).toBe('D')
441
+ })
442
+
443
+ it('rejects accTitle without a colon', () => {
444
+ expect(() => parsePieChart('pie\naccTitle T')).toThrow(/Line 2: /)
445
+ })
446
+
447
+ it('rejects text after a multi-line accDescr block on the same line', () => {
448
+ expect(() => parsePieChart('pie\naccDescr {\n d\n} "A" : 1')).toThrow(
449
+ /Line 4: .*expected end of line after accDescr/,
450
+ )
451
+ })
452
+ })
@@ -65,7 +65,7 @@ export { toDirection } from '@zombie-mermaid/core'
65
65
  * way sequence/class/ER/xychart were before issue #541.
66
66
  */
67
67
  const SUPPORTED_HEADERS =
68
- '"graph <dir>"/"flowchart <dir>" (dir: TD, TB, LR, BT, RL), "stateDiagram-v2", "sequenceDiagram", "classDiagram", "erDiagram", "xychart-beta", "C4Context"/"C4Container"/"C4Component"/"C4Dynamic"/"C4Deployment", "architecture-beta"'
68
+ '"graph <dir>"/"flowchart <dir>" (dir: TD, TB, LR, BT, RL), "stateDiagram-v2", "sequenceDiagram", "classDiagram", "erDiagram", "xychart-beta", "C4Context"/"C4Container"/"C4Component"/"C4Dynamic"/"C4Deployment", "architecture-beta", "pie"'
69
69
 
70
70
  /**
71
71
  * Best-guess canonical header for a header line that looks like an attempt
@@ -76,8 +76,8 @@ const SUPPORTED_HEADERS =
76
76
  * any of those — in which case the generic "supported headers" list in the
77
77
  * thrown error is the best we can do.
78
78
  *
79
- * This only recognizes the four *other* multi-word headers
80
- * (sequenceDiagram/classDiagram/erDiagram/xychart-beta/stateDiagram-v2);
79
+ * This only recognizes the *other* headers
80
+ * (sequenceDiagram/classDiagram/erDiagram/xychart-beta/stateDiagram-v2/pie);
81
81
  * "graph"/"flowchart" typos are handled by the direction-specific message
82
82
  * below instead, since those already matched the diagram-type keyword and
83
83
  * just have a bad or missing direction token.
@@ -89,6 +89,8 @@ function suggestedHeaderFor(header: string): string | undefined {
89
89
  if (/^er/.test(lower)) return 'erDiagram'
90
90
  if (/^xychart/.test(lower)) return 'xychart-beta'
91
91
  if (/^state/.test(lower)) return 'stateDiagram-v2'
92
+ // Mermaid's `pie` keyword is case-sensitive, so `PieChart` lands here.
93
+ if (/^pie/.test(lower)) return 'pie'
92
94
  return undefined
93
95
  }
94
96
 
@@ -691,7 +693,8 @@ function ensureStateNode(
691
693
  *
692
694
  * Optional label: -->|label text|
693
695
  */
694
- const ARROW_REGEX = /^(<|o|x)?(-{2,}|={2,}|-\.+-|~{3,})(>|o|x)?(?:\|([^|]*)\|)?/
696
+ const ARROW_REGEX =
697
+ /^(<|o|x)?(-{2,}|={2,}|-\.+-|~{3,})(>|o|x)?(?:\s*\|([^|]*)\|)?/
695
698
 
696
699
  /**
697
700
  * Link bodies that are only a link when a start or end marker accompanies
package/src/index.ts CHANGED
@@ -54,5 +54,8 @@ export * from './xychart/parser.ts'
54
54
  export * from './xychart/types.ts'
55
55
  export * from './xychart/colors.ts'
56
56
 
57
+ export * from './pie/parser.ts'
58
+ export * from './pie/types.ts'
59
+
57
60
  export * from './expanded-shapes.ts'
58
61
  export * from './flowchart-parser.ts'