zombie-mermaid 2.2.2 → 2.2.4

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.
Files changed (38) hide show
  1. package/README.md +4 -4
  2. package/dist/ascii.cjs +8 -8
  3. package/dist/ascii.cjs.map +1 -1
  4. package/dist/ascii.js +1137 -1070
  5. package/dist/ascii.js.map +1 -1
  6. package/dist/cli.js +3380 -3295
  7. package/dist/index.cjs +59 -52
  8. package/dist/index.cjs.map +1 -1
  9. package/dist/index.js +1780 -1712
  10. package/dist/index.js.map +1 -1
  11. package/dist/mcp.cjs +246 -52
  12. package/dist/mcp.cjs.map +1 -1
  13. package/dist/mcp.js +246 -52
  14. package/dist/mcp.js.map +1 -1
  15. package/package.json +25 -21
  16. package/packages/ascii-renderer/src/canvas.ts +7 -4
  17. package/packages/ascii-renderer/src/class-diagram.ts +192 -12
  18. package/packages/ascii-renderer/src/draw-arrows.ts +79 -35
  19. package/packages/ascii-renderer/src/sequence.ts +42 -8
  20. package/packages/ascii-renderer/src/shapes/hexagon.ts +7 -3
  21. package/packages/ascii-renderer/src/shapes/special.ts +12 -4
  22. package/packages/core/src/generated/mono-font-subset.ts +32 -0
  23. package/packages/core/src/text-metrics.ts +22 -0
  24. package/packages/core/src/theme.ts +29 -13
  25. package/packages/mermaid-parser/src/xychart/parser.ts +44 -1
  26. package/src/__tests__/ascii-arrowhead-direction-1083.test.ts +51 -0
  27. package/src/__tests__/ascii-class-box-occupancy.test.ts +82 -43
  28. package/src/__tests__/ascii-class-detour-label-routing-487.test.ts +8 -8
  29. package/src/__tests__/ascii-class-parent-alignment-971.test.ts +139 -0
  30. package/src/__tests__/ascii-class-parent-alignment-972.test.ts +208 -0
  31. package/src/__tests__/ascii-draw-arrows-coverage.test.ts +56 -0
  32. package/src/__tests__/ascii-edge-ending-glyphs.test.ts +3 -3
  33. package/src/__tests__/ascii-sequence-form-invariants.test.ts +51 -0
  34. package/src/__tests__/text-metrics.test.ts +13 -0
  35. package/src/__tests__/theme.test.ts +30 -0
  36. package/src/__tests__/workspace-package-boundaries.test.ts +2 -2
  37. package/src/__tests__/xychart-parser-categories.test.ts +78 -0
  38. package/src/cli/html-viewer.ts +5 -0
@@ -0,0 +1,208 @@
1
+ /**
2
+ * Regression tests for issue #972 — "ASCII class-diagram layout:
3
+ * multi-parent / overlap resolution," the second implementation stage of
4
+ * #964's fix (docs/decisions/ascii-class-diagram-x-coordinate-assignment-970.md),
5
+ * building on #971's single-parent alignment + universal compaction pass.
6
+ *
7
+ * #971 only computed a real desired position for a *singleton* block (no
8
+ * sibling at its level shares its exact parent-id set) with exactly one
9
+ * qualifying (strictly-shallower) parent. This fix adds the remaining
10
+ * block shapes #970's decision named: a block of more than one class
11
+ * sharing one qualifying-parent-set spreads evenly around that shared
12
+ * center; a block with more than one qualifying parent centers on the
13
+ * mean of their centers. Both reuse #971's exact compaction pass
14
+ * unchanged, which already handles "an aligned position collides with a
15
+ * sibling" — #972 does not add new nudging logic, only new
16
+ * desired-position computations for `if (qualifying.length === 0)` this
17
+ * pass now escapes into.
18
+ */
19
+
20
+ import { describe, it, expect } from 'vitest'
21
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
22
+ import { findBoxRect, rectsOverlap } from './helpers/ascii-form.ts'
23
+
24
+ function boxCenter(rect: { x0: number; x1: number }): number {
25
+ return rect.x0 + Math.floor((rect.x1 - rect.x0 + 1) / 2)
26
+ }
27
+
28
+ describe('ASCII class diagram — multi-parent / overlap resolution (issue #972)', () => {
29
+ it('two children of the same parent spread evenly around its center, not packed from column 0', () => {
30
+ // WidePadding gives Animal real room on both sides -- proves genuine
31
+ // symmetric spreading, not just an edge-of-canvas clamp coincidence.
32
+ const src = `classDiagram
33
+ class WidePadding {
34
+ +String p0
35
+ +String p1
36
+ +String p2
37
+ }
38
+ class Animal
39
+ class Dog
40
+ class Cat
41
+ Animal <|-- Dog
42
+ Animal <|-- Cat`
43
+ const ascii = renderMermaidASCII(src, { useAscii: true })
44
+ const animal = findBoxRect(ascii, 'Animal')
45
+ const dog = findBoxRect(ascii, 'Dog')
46
+ const cat = findBoxRect(ascii, 'Cat')
47
+
48
+ // Declaration order preserved: Dog left of Cat, neither overlapping.
49
+ expect(dog.x1).toBeLessThan(cat.x0)
50
+ // The pair's combined footprint centers on Animal's own center.
51
+ const blockCenter = dog.x0 + Math.floor((cat.x1 - dog.x0 + 1) / 2)
52
+ expect(Math.abs(blockCenter - boxCenter(animal))).toBeLessThanOrEqual(1)
53
+ // Neither child sits at column 0 (the pre-#972 leftover artifact).
54
+ expect(dog.x0).toBeGreaterThan(0)
55
+ })
56
+
57
+ it('a child with two parents at different levels centers on the mean of their centers', () => {
58
+ // Controller (level 0) -> Model (level 1) -> View (level 2); View also
59
+ // has a direct edge from Controller. View's qualifying parents are
60
+ // both Controller and Model (both strictly shallower than View), so
61
+ // its desired center is their mean -- not just Controller's alone.
62
+ const src = `classDiagram
63
+ class Controller {
64
+ +handleInput() void
65
+ }
66
+ class Model {
67
+ +getData() void
68
+ }
69
+ class View {
70
+ +render() void
71
+ }
72
+ Controller --> Model
73
+ Controller --> View
74
+ Model ..> View`
75
+ const ascii = renderMermaidASCII(src, { useAscii: true })
76
+ const controller = findBoxRect(ascii, 'Controller')
77
+ const model = findBoxRect(ascii, 'Model')
78
+ const view = findBoxRect(ascii, 'View')
79
+
80
+ const expectedCenter = Math.round(
81
+ (boxCenter(controller) + boxCenter(model)) / 2,
82
+ )
83
+ expect(Math.abs(boxCenter(view) - expectedCenter)).toBeLessThanOrEqual(1)
84
+ // Not simply parked back at column 0 the way an unresolved block used to be.
85
+ expect(view.x0).toBeGreaterThan(0)
86
+ })
87
+
88
+ it('a multi-child block whose desired position collides with an earlier sibling is pushed right without overlapping it', () => {
89
+ // Two parents both near the canvas's left edge: EarlyParent's own
90
+ // child (EarlySibling) is placed first; WidePair's two children want
91
+ // to center on WidePair, which sits close enough that their desired
92
+ // block would otherwise land on top of EarlySibling. The universal
93
+ // compaction pass (#971, reused unchanged by #972) must push the
94
+ // whole block right of EarlySibling instead of overlapping it.
95
+ const src = `classDiagram
96
+ class EarlyParent
97
+ class WidePair
98
+ class EarlySibling
99
+ class ChildA
100
+ class ChildB
101
+ EarlyParent --> EarlySibling
102
+ WidePair --> ChildA
103
+ WidePair --> ChildB`
104
+ const ascii = renderMermaidASCII(src, { useAscii: true })
105
+ const names = [
106
+ 'EarlyParent',
107
+ 'WidePair',
108
+ 'EarlySibling',
109
+ 'ChildA',
110
+ 'ChildB',
111
+ ]
112
+ const rects = names.map((n) => ({ n, r: findBoxRect(ascii, n) }))
113
+ for (let i = 0; i < rects.length; i++) {
114
+ for (let j = i + 1; j < rects.length; j++) {
115
+ expect(
116
+ rectsOverlap(rects[i]!.r, rects[j]!.r),
117
+ `${rects[i]!.n} and ${rects[j]!.n} boxes overlap`,
118
+ ).toBe(false)
119
+ }
120
+ }
121
+ // Declaration order preserved even after compaction pushes the block right.
122
+ const earlySibling = findBoxRect(ascii, 'EarlySibling')
123
+ const childA = findBoxRect(ascii, 'ChildA')
124
+ expect(earlySibling.x1).toBeLessThan(childA.x0)
125
+ })
126
+
127
+ it('a block with no qualifying parents (a rootless cycle with two same-level "parent" edges) keeps its plain baseline position', () => {
128
+ // A and B mutually reference each other with no external root: both
129
+ // land at level 0, and neither's same-level "parent" edge qualifies --
130
+ // #972 must not spread or converge this shape, only #971's untouched
131
+ // no-resolvable-parents fallback applies.
132
+ const src = `classDiagram
133
+ class A
134
+ class B
135
+ A --> B
136
+ B --> A`
137
+ const ascii = renderMermaidASCII(src, { useAscii: true })
138
+ const a = findBoxRect(ascii, 'A')
139
+ const b = findBoxRect(ascii, 'B')
140
+ expect(a.x0).toBe(0)
141
+ expect(a.x1).toBeLessThan(b.x0)
142
+ })
143
+
144
+ // Regression for a CodeRabbit finding on #972's own PR (thread
145
+ // PRRT_kwDOT5ndxc6hpu-C): two classes with no resolvable parents but the
146
+ // exact same (non-qualifying) parent-id set used to be merged into one
147
+ // joint placement unit the moment the *first* of them was reached in
148
+ // declaration order -- silently pulling an unrelated class declared
149
+ // *between* them ahead of its own position, since it hadn't been placed
150
+ // yet when the merged pair's combined placement consumed the columns it
151
+ // would have occupied.
152
+ it('an unrelated class declared between two same-parent-set siblings with no qualifying parent keeps its own declaration-order position', () => {
153
+ // Q and R share the exact same parent-id set ({P}), but P is at the
154
+ // same level as Q/R (a 3-way mutual-reference cycle), so neither
155
+ // qualifies -- both are "no resolvable parents" blocks on their own.
156
+ // Interloper is a plain, unrelated root declared between Q and R.
157
+ const src = `classDiagram
158
+ class P
159
+ class Q
160
+ class Interloper
161
+ class R
162
+ P --> Q
163
+ P --> R
164
+ Q --> P
165
+ R --> P`
166
+ const ascii = renderMermaidASCII(src, { useAscii: true })
167
+ const q = findBoxRect(ascii, 'Q')
168
+ const interloper = findBoxRect(ascii, 'Interloper')
169
+ const r = findBoxRect(ascii, 'R')
170
+ // Declared order Q, Interloper, R must survive as rendered left-to-right order.
171
+ expect(q.x1).toBeLessThan(interloper.x0)
172
+ expect(interloper.x1).toBeLessThan(r.x0)
173
+ })
174
+
175
+ // Regression for a CodeRabbit finding on #972's own PR (thread
176
+ // PRRT_kwDOT5ndxc6hpu-M): a multi-member block's desired left edge used
177
+ // to be derived from centering the *padded slot* span (every member's
178
+ // own reach padding, including the outermost edges with no neighbor to
179
+ // separate from) on the shared parent center, rather than the *visible
180
+ // box* span -- so a block whose first member has real reach padding
181
+ // (e.g. its own outgoing relationship carries a long label) and whose
182
+ // last member has none rendered visibly off-center from the parent it
183
+ // was supposed to align under.
184
+ it('a multi-child block with asymmetric reach padding on one child still visually centers on the shared parent', () => {
185
+ const src = `classDiagram
186
+ class Padding {
187
+ +String p0
188
+ +String p1
189
+ +String p2
190
+ +String p3
191
+ }
192
+ class Animal
193
+ class Dog
194
+ class Cat
195
+ class SomewhereElse
196
+ Animal <|-- Dog
197
+ Animal <|-- Cat
198
+ Dog --> SomewhereElse : a moderately long label`
199
+ const ascii = renderMermaidASCII(src, { useAscii: true })
200
+ const animal = findBoxRect(ascii, 'Animal')
201
+ const dog = findBoxRect(ascii, 'Dog')
202
+ const cat = findBoxRect(ascii, 'Cat')
203
+ const visibleBlockCenter = dog.x0 + Math.floor((cat.x1 - dog.x0 + 1) / 2)
204
+ expect(
205
+ Math.abs(visibleBlockCenter - boxCenter(animal)),
206
+ ).toBeLessThanOrEqual(1)
207
+ })
208
+ })
@@ -5,6 +5,21 @@
5
5
 
6
6
  import { describe, it, expect } from 'vitest'
7
7
  import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
8
+ import {
9
+ unicodeArrowChar,
10
+ asciiArrowChar,
11
+ } from '../../packages/ascii-renderer/src/draw-arrows.ts'
12
+ import {
13
+ Up,
14
+ Down,
15
+ Left,
16
+ Right,
17
+ UpperRight,
18
+ UpperLeft,
19
+ LowerRight,
20
+ LowerLeft,
21
+ Middle,
22
+ } from '../../packages/ascii-renderer/src/types.ts'
8
23
 
9
24
  describe('ASCII arrow drawing: no-arrow edges', () => {
10
25
  it('renders a solid no-arrow edge without an arrowhead in unicode mode', () => {
@@ -157,6 +172,47 @@ describe('ASCII arrow drawing: bidirectional edges', () => {
157
172
  })
158
173
  })
159
174
 
175
+ describe('unicodeArrowChar / asciiArrowChar: direction-to-glyph mapping', () => {
176
+ // Direct unit coverage for every Direction, including LowerLeft — the
177
+ // ELK/pathfinder layout this module draws from only produces a diagonal
178
+ // direction via determinePath's rare Case-4 fallback, and every
179
+ // hand-written or gallery diagram that triggers it happens to need the
180
+ // target down-and-*right* of its source, never down-and-left. See
181
+ // issue #1062's drawArrowHead refactor.
182
+ it('maps all four orthogonal directions to filled triangles (unicode)', () => {
183
+ expect(unicodeArrowChar(Up)).toBe('▲')
184
+ expect(unicodeArrowChar(Down)).toBe('▼')
185
+ expect(unicodeArrowChar(Left)).toBe('◄')
186
+ expect(unicodeArrowChar(Right)).toBe('►')
187
+ })
188
+
189
+ it('maps all four diagonal directions to Arrows-block glyphs (unicode)', () => {
190
+ expect(unicodeArrowChar(UpperRight)).toBe('↗')
191
+ expect(unicodeArrowChar(UpperLeft)).toBe('↖')
192
+ expect(unicodeArrowChar(LowerRight)).toBe('↘')
193
+ expect(unicodeArrowChar(LowerLeft)).toBe('↙')
194
+ })
195
+
196
+ it('returns undefined for Middle (no arrowhead glyph)', () => {
197
+ expect(unicodeArrowChar(Middle)).toBeUndefined()
198
+ expect(asciiArrowChar(Middle)).toBeUndefined()
199
+ })
200
+
201
+ it('maps all four orthogonal directions to ASCII carets (ascii mode)', () => {
202
+ expect(asciiArrowChar(Up)).toBe('^')
203
+ expect(asciiArrowChar(Down)).toBe('v')
204
+ expect(asciiArrowChar(Left)).toBe('<')
205
+ expect(asciiArrowChar(Right)).toBe('>')
206
+ })
207
+
208
+ it('has no diagonal glyphs in ASCII mode', () => {
209
+ expect(asciiArrowChar(UpperRight)).toBeUndefined()
210
+ expect(asciiArrowChar(UpperLeft)).toBeUndefined()
211
+ expect(asciiArrowChar(LowerRight)).toBeUndefined()
212
+ expect(asciiArrowChar(LowerLeft)).toBeUndefined()
213
+ })
214
+ })
215
+
160
216
  describe('ASCII arrow drawing: upward edge labels', () => {
161
217
  it('offsets the label on an edge that routes upward', () => {
162
218
  const result = renderMermaidASCII(`
@@ -19,7 +19,7 @@ describe('ASCII flowchart edges: --o circle-end / --x cross-end glyphs', () => {
19
19
  A --o B
20
20
  `)
21
21
  expect(result).toContain('○')
22
- expect(result).not.toMatch(/[►◄▲▼◢◣◤◥]/)
22
+ expect(result).not.toMatch(/[►◄▲▼◢◣◤◥↖↗↘↙]/)
23
23
  })
24
24
 
25
25
  it('draws an x glyph for --x in unicode mode', () => {
@@ -28,7 +28,7 @@ describe('ASCII flowchart edges: --o circle-end / --x cross-end glyphs', () => {
28
28
  A --x B
29
29
  `)
30
30
  expect(result).toContain('✕')
31
- expect(result).not.toMatch(/[►◄▲▼◢◣◤◥]/)
31
+ expect(result).not.toMatch(/[►◄▲▼◢◣◤◥↖↗↘↙]/)
32
32
  })
33
33
 
34
34
  it('draws a circle glyph for --o in ascii mode', () => {
@@ -138,7 +138,7 @@ describe('ASCII flowchart edges: --o circle-end / --x cross-end glyphs', () => {
138
138
  F --o C
139
139
  `)
140
140
  expect(result).toContain('○')
141
- expect(result).not.toMatch(/[►◄▲▼◢◣◤◥]/)
141
+ expect(result).not.toMatch(/[►◄▲▼◢◣◤◥↖↗↘↙]/)
142
142
  })
143
143
 
144
144
  // A mixed fan-in (one edge --o, another plain -->) has no single correct
@@ -280,6 +280,57 @@ describe('ASCII sequence — notes do not collide with an unrelated lifeline', (
280
280
  expect(carolCol < noteRect.x0 || carolCol > noteRect.x1).toBe(true)
281
281
  })
282
282
 
283
+ // Fixed by issue #992: a single-actor `Note over` centers on that actor's
284
+ // own lifeline (`nx = llX[aIdx] - Math.floor(nWidth / 2)`), but neither
285
+ // half of that width was reserved during layout — so a note wider than
286
+ // the actor's own column spilled into whichever neighbor was closer,
287
+ // exactly the shape cases A-C above already fixed for `left`/`right`
288
+ // notes. This is the exact repro from the issue (two actors, a note over
289
+ // the first one). A *multi*-actor `Note over A,B` is unaffected —
290
+ // spanning both actors is its intended shape (see the "note over two
291
+ // actors" case below), not this bug.
292
+ it("single-actor 'over' note on the first actor does not overlap the next actor's lifeline (issue #992 repro)", () => {
293
+ const src = `sequenceDiagram
294
+ participant A as Alice
295
+ participant B as Bob
296
+ Note over A: ${LONG_LABEL}
297
+ A->>B: hi`
298
+ const ascii = renderMermaidASCII(src, { useAscii: true })
299
+ const noteRect = findBoxRect(ascii, LONG_LABEL)
300
+ const bobCol = lifelineColumn(ascii, 'Bob')
301
+ expect(bobCol < noteRect.x0 || bobCol > noteRect.x1).toBe(true)
302
+ })
303
+
304
+ // Same fix, but a middle actor: both halves of the note (left half toward
305
+ // Alice, right half toward Carol) need to be reserved, not just the
306
+ // leftmost-actor case above.
307
+ it("single-actor 'over' note on a middle actor does not overlap either neighbor's lifeline", () => {
308
+ const src = `sequenceDiagram
309
+ participant A as Alice
310
+ participant B as Bob
311
+ participant C as Carol
312
+ A->>B: hi
313
+ Note over B: ${LONG_LABEL}`
314
+ const ascii = renderMermaidASCII(src, { useAscii: true })
315
+ const noteRect = findBoxRect(ascii, LONG_LABEL)
316
+ const aliceCol = lifelineColumn(ascii, 'Alice')
317
+ const carolCol = lifelineColumn(ascii, 'Carol')
318
+ expect(aliceCol < noteRect.x0 || aliceCol > noteRect.x1).toBe(true)
319
+ expect(carolCol < noteRect.x0 || carolCol > noteRect.x1).toBe(true)
320
+ })
321
+
322
+ it("short single-actor 'over' note does not overlap the next actor", () => {
323
+ const src = `sequenceDiagram
324
+ participant A as Alice
325
+ participant B as Bob
326
+ Note over A: ok
327
+ A->>B: hi`
328
+ const ascii = renderMermaidASCII(src, { useAscii: true })
329
+ const noteRect = findBoxRect(ascii, 'ok')
330
+ const bobCol = lifelineColumn(ascii, 'Bob')
331
+ expect(bobCol < noteRect.x0 || bobCol > noteRect.x1).toBe(true)
332
+ })
333
+
283
334
  // Regression locks: short notes, which don't hit any of the three bugs
284
335
  // above, must keep rendering without collision.
285
336
  it('short note left of a middle actor does not overlap its left neighbor', () => {
@@ -469,5 +469,18 @@ describe('isWideChar', () => {
469
469
  expect(isWideChar('◥')).toBe(false)
470
470
  expect(isWideChar('○')).toBe(false)
471
471
  expect(isWideChar('✕')).toBe(false)
472
+ // Diagonal edge-routing arrowheads (draw-arrows.ts's drawArrowHead) as
473
+ // of issue #1062 — JetBrains Mono NL has no glyph for the ◢◣◤◥ set
474
+ // above, so these Arrows-block glyphs are drawn instead.
475
+ expect(isWideChar('↖')).toBe(false)
476
+ expect(isWideChar('↗')).toBe(false)
477
+ expect(isWideChar('↘')).toBe(false)
478
+ expect(isWideChar('↙')).toBe(false)
479
+ // Same Arrows-block misclassification, not drawn by the renderer itself
480
+ // but reachable if typed into node/edge/note label text.
481
+ expect(isWideChar('↔')).toBe(false)
482
+ expect(isWideChar('↕')).toBe(false)
483
+ expect(isWideChar('↩')).toBe(false)
484
+ expect(isWideChar('↪')).toBe(false)
472
485
  })
473
486
  })
@@ -130,3 +130,33 @@ describe('buildStyleBlock – font handling', () => {
130
130
  )
131
131
  })
132
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
+ })
@@ -226,7 +226,7 @@ describe('workspace package manifests', () => {
226
226
  // no longer `"private": true` — and its own independent build
227
227
  // (`packages/<name>/vite.config.ts`, via `vite.config.package.ts`).
228
228
  //
229
- // That is preparation only. The umbrella (`vite.config.lib.ts`) still
229
+ // That is preparation only. The umbrella (`config/vite.config.lib.ts`) still
230
230
  // bundles all five packages' source directly into its own `dist/` (they
231
231
  // are absent from `isExternal` there, same as before #769) — none of
232
232
  // them is a real external `dependencies` entry of `zombie-mermaid`, none
@@ -234,7 +234,7 @@ describe('workspace package manifests', () => {
234
234
  // none has ever actually been published to npm. Externalizing them for
235
235
  // real is a separate, future PR gated on completing npm's one-time
236
236
  // trusted-publishing setup for each new package name — see
237
- // RELEASING.md's "Future: multi-package publish" section.
237
+ // docs/RELEASING.md's "Future: multi-package publish" section.
238
238
  it.each(['core', 'mermaid-parser', 'svg-renderer', 'ascii-renderer', 'mcp'])(
239
239
  '%s is publish-ready in shape under the @zombie-mermaid/ scope (not yet externalized or published)',
240
240
  (pkg) => {
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Category-label unquoting tests for the xychart-beta parser (issue #1087).
3
+ *
4
+ * `x-axis [A, "B", C]` categorical items were only `.split(',').map(trim)`,
5
+ * so a quoted item like `"CLI output / logs"` kept its literal quote
6
+ * characters in the rendered label instead of being unquoted the way an
7
+ * axis *title* already is. These tests assert each category item is
8
+ * unquoted the same way, and that a comma embedded inside a quoted item
9
+ * (e.g. `"Jan, Feb"`) is treated as literal text rather than a delimiter.
10
+ */
11
+ import { describe, it, expect } from 'vitest'
12
+ import { parseXYChart as parseXYChartStatements } from '@zombie-mermaid/mermaid-parser'
13
+ import { splitStatements } from '@zombie-mermaid/core'
14
+
15
+ /** Helper to parse — preprocesses text the same way index.ts does */
16
+ function parseXYChart(sourceLines: string[]) {
17
+ return parseXYChartStatements(splitStatements(sourceLines.join('\n')))
18
+ }
19
+
20
+ describe('parseXYChart – categorical x-axis quoting', () => {
21
+ it('strips quotes from quoted category items', () => {
22
+ const chart = parseXYChart([
23
+ 'xychart-beta',
24
+ 'x-axis ["Total used", "CLI output / logs"]',
25
+ 'bar [86, 18]',
26
+ ])
27
+ expect(chart.xAxis.categories).toEqual(['Total used', 'CLI output / logs'])
28
+ })
29
+
30
+ it('handles a mix of quoted and unquoted category items', () => {
31
+ const chart = parseXYChart([
32
+ 'xychart-beta',
33
+ 'x-axis [Jan, "Feb 2024", Mar]',
34
+ 'bar [1, 2, 3]',
35
+ ])
36
+ expect(chart.xAxis.categories).toEqual(['Jan', 'Feb 2024', 'Mar'])
37
+ })
38
+
39
+ it('still unquotes categories when an axis title is also present', () => {
40
+ const chart = parseXYChart([
41
+ 'xychart-beta',
42
+ 'x-axis "Month" ["Jan", "Feb"]',
43
+ 'bar [1, 2]',
44
+ ])
45
+ expect(chart.xAxis.title).toBe('Month')
46
+ expect(chart.xAxis.categories).toEqual(['Jan', 'Feb'])
47
+ })
48
+
49
+ it('leaves unquoted category items unchanged', () => {
50
+ const chart = parseXYChart([
51
+ 'xychart-beta',
52
+ 'x-axis [Jan, Feb, Mar]',
53
+ 'bar [1, 2, 3]',
54
+ ])
55
+ expect(chart.xAxis.categories).toEqual(['Jan', 'Feb', 'Mar'])
56
+ })
57
+
58
+ it('treats a comma inside a quoted item as literal text, not a delimiter', () => {
59
+ const chart = parseXYChart([
60
+ 'xychart-beta',
61
+ 'x-axis ["Jan, Feb", "Mar"]',
62
+ 'bar [1, 2]',
63
+ ])
64
+ // A naive split(',') would break this into three pieces ('"Jan',
65
+ // ' Feb"', ' "Mar"') instead of two -- quoting a value that itself
66
+ // contains a comma is exactly the case quoting exists for.
67
+ expect(chart.xAxis.categories).toEqual(['Jan, Feb', 'Mar'])
68
+ })
69
+
70
+ it('handles multiple comma-containing quoted items in one list', () => {
71
+ const chart = parseXYChart([
72
+ 'xychart-beta',
73
+ 'x-axis ["A, B", "C, D, E", F]',
74
+ 'bar [1, 2, 3]',
75
+ ])
76
+ expect(chart.xAxis.categories).toEqual(['A, B', 'C, D, E', 'F'])
77
+ })
78
+ })
@@ -128,6 +128,11 @@ const PAGE_CSS = `
128
128
  @media print {
129
129
  #toolbar { display: none; }
130
130
  #stage { position: static; overflow: visible; }
131
+ /* Genuinely needs !important (zombie-mermaid#1080 audit): the client
132
+ * script's apply() sets #diagram's transform inline on every pan/zoom,
133
+ * so whatever pan/zoom state the viewer was left in is a live inline
134
+ * style this print rule must beat -- there's no static default to
135
+ * hoist it to instead. */
131
136
  #diagram { position: static; transform: none !important; }
132
137
  }
133
138
  `.trim()