@zombie-mermaid/ascii-renderer 3.0.0 → 3.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.
Files changed (37) hide show
  1. package/dist/index.cjs +7 -7
  2. package/dist/index.cjs.map +1 -1
  3. package/dist/index.js +2484 -1467
  4. package/dist/index.js.map +1 -1
  5. package/package.json +3 -3
  6. package/src/__tests__/ascii-bt-label-order.test.ts +97 -0
  7. package/src/__tests__/ascii-cluster-exit-anchoring-1148.test.ts +774 -0
  8. package/src/__tests__/ascii-cluster-exit-lanes-1182.test.ts +375 -0
  9. package/src/__tests__/ascii-fan-in-then-fan-out-upstream-63.test.ts +78 -0
  10. package/src/__tests__/ascii-fan-out-upstream-113.test.ts +101 -0
  11. package/src/__tests__/ascii-role-colors.test.ts +328 -0
  12. package/src/__tests__/ascii-subgraph-back-edge-1197.test.ts +212 -0
  13. package/src/__tests__/ascii-subgraph-label-border-clip.test.ts +4 -2
  14. package/src/__tests__/ascii-subgraph-title-edge-1222.test.ts +106 -0
  15. package/src/__tests__/c4-ascii.test.ts +311 -0
  16. package/src/__tests__/testdata/ascii/subgraph_direction_override.txt +21 -21
  17. package/src/__tests__/testdata/ascii/subgraph_mixed_nodes_td.txt +1 -1
  18. package/src/__tests__/xychart-ascii-ticks.test.ts +34 -0
  19. package/src/ansi.ts +16 -2
  20. package/src/architecture.ts +33 -0
  21. package/src/c4-diagram.ts +1087 -0
  22. package/src/canvas.ts +78 -8
  23. package/src/cluster-boundary.ts +429 -0
  24. package/src/converter.ts +18 -2
  25. package/src/draw-arrows.ts +120 -18
  26. package/src/draw-boxes.ts +1 -0
  27. package/src/draw-subgraphs.ts +102 -2
  28. package/src/draw.ts +57 -3
  29. package/src/edge-cell-styles.ts +20 -1
  30. package/src/edge-routing.ts +77 -1
  31. package/src/flowchart.ts +28 -3
  32. package/src/frame-avoidance.ts +235 -0
  33. package/src/grid.ts +78 -19
  34. package/src/pathfinder.ts +22 -2
  35. package/src/registry.ts +8 -0
  36. package/src/types.ts +69 -0
  37. package/src/xychart.ts +24 -5
@@ -0,0 +1,311 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { splitStatements } from '@zombie-mermaid/core'
3
+ import { parseC4Diagram } from '@zombie-mermaid/mermaid-parser'
4
+ import { renderMermaidASCII } from '../../../../src/index.ts'
5
+ import { rankElements } from '../c4-diagram.ts'
6
+
7
+ function ranks(src: string): Record<string, number> {
8
+ const d = parseC4Diagram(splitStatements(src))
9
+ return Object.fromEntries(
10
+ rankElements(
11
+ d.elements.map((e) => e.alias),
12
+ d.relationships,
13
+ ),
14
+ )
15
+ }
16
+
17
+ /** [column, row] of the first occurrence of `needle`. */
18
+ function find(out: string, needle: string): [number, number] {
19
+ const lines = out.split('\n')
20
+ for (const [row, line] of lines.entries()) {
21
+ const col = line.indexOf(needle)
22
+ if (col >= 0) return [col, row]
23
+ }
24
+ throw new Error(`"${needle}" not found in:\n${out}`)
25
+ }
26
+
27
+ describe('rankElements', () => {
28
+ it('ranks along relationships and lets Rel_U reverse them', () => {
29
+ expect(
30
+ ranks(`C4Context
31
+ System(a, "A")
32
+ System(b, "B")
33
+ System(c, "C")
34
+ Rel(a, b, "x")
35
+ Rel_U(b, c, "up")`),
36
+ ).toEqual({ a: 0, b: 1, c: 0 })
37
+ })
38
+
39
+ it('puts Rel_R / Rel_L pairs in one rank', () => {
40
+ expect(
41
+ ranks(`C4Context
42
+ System(a, "A")
43
+ System(b, "B")
44
+ System(c, "C")
45
+ Rel(a, b, "x")
46
+ Rel_R(b, c, "side")`),
47
+ ).toEqual({ a: 0, b: 1, c: 1 })
48
+ })
49
+
50
+ it('survives cycles', () => {
51
+ const r = ranks(`C4Context
52
+ System(a, "A")
53
+ System(b, "B")
54
+ Rel(a, b, "x")
55
+ Rel(b, a, "y")`)
56
+ expect(r.a).toBe(0)
57
+ expect(r.b).toBe(1)
58
+ })
59
+
60
+ it('ignores relationships to unknown or self endpoints', () => {
61
+ expect(
62
+ ranks('C4Context\nSystem(a, "A")\nRel(a, a, "loop")\nRel_R(a, a, "l")'),
63
+ ).toEqual({ a: 0 })
64
+ })
65
+ })
66
+
67
+ describe('C4 ASCII rendering', () => {
68
+ const SRC = `C4Container
69
+ title Shop
70
+ Person(u, "User")
71
+ System_Boundary(sys, "Shop") {
72
+ Container(web, "Web App", "React", "Storefront")
73
+ ContainerDb(db, "Database", "PostgreSQL")
74
+ ContainerQueue(q, "Events", "Kafka")
75
+ }
76
+ System_Ext(pay, "Payments")
77
+ Rel(u, web, "Browses", "HTTPS")
78
+ Rel(web, db, "Reads", "SQL")
79
+ BiRel(web, q, "Publishes")
80
+ Rel(web, pay, "Charges")`
81
+
82
+ it('draws a person glyph above the name', () => {
83
+ const out = renderMermaidASCII(SRC)
84
+ const [, glyphRow] = find(out, '/|\\')
85
+ const [, nameRow] = find(out, 'User')
86
+ expect(glyphRow).toBeLessThan(nameRow)
87
+ expect(renderMermaidASCII('C4Context\nSystem(s, "S")')).not.toContain(
88
+ '/|\\',
89
+ )
90
+ })
91
+
92
+ it('draws the diagram title, boundary title, type lines and technology text', () => {
93
+ const out = renderMermaidASCII(SRC)
94
+ for (const t of [
95
+ 'Shop',
96
+ '[Person]',
97
+ '[Container: React]',
98
+ '[Container: PostgreSQL]',
99
+ '[Software System]',
100
+ '[HTTPS]',
101
+ '[SQL]',
102
+ 'Storefront',
103
+ ]) {
104
+ expect(out).toContain(t)
105
+ }
106
+ // The diagram title is the first text row, above everything else.
107
+ expect(find(out, 'Shop')[1]).toBeLessThan(find(out, 'User')[1])
108
+ })
109
+
110
+ it('flows top to bottom and BT reverses the rows', () => {
111
+ const src = 'C4Context\nSystem(a, "Aaa")\nSystem(b, "Bbb")\nRel(a, b, "x")'
112
+ const td = renderMermaidASCII(src)
113
+ expect(find(td, 'Aaa')[1]).toBeLessThan(find(td, 'Bbb')[1])
114
+ const bt = renderMermaidASCII(src, { direction: 'BT' })
115
+ expect(find(bt, 'Bbb')[1]).toBeLessThan(find(bt, 'Aaa')[1])
116
+ // Arrowhead points at the target in both.
117
+ expect(td).toContain('▼')
118
+ })
119
+
120
+ it('honors Rel_R: same row, source on the left, label between', () => {
121
+ const out = renderMermaidASCII(
122
+ 'C4Context\nSystem(a, "Aaa")\nSystem(b, "Bbb")\nRel_R(a, b, "sends to")',
123
+ )
124
+ const [ax, ay] = find(out, 'Aaa')
125
+ const [bx, by] = find(out, 'Bbb')
126
+ expect(ay).toBe(by)
127
+ expect(ax).toBeLessThan(bx)
128
+ const [lx] = find(out, 'sends to')
129
+ expect(lx).toBeGreaterThan(ax)
130
+ expect(lx).toBeLessThan(bx)
131
+ expect(out).toContain('►')
132
+ })
133
+
134
+ it('honors Rel_L and Rel_U', () => {
135
+ const l = renderMermaidASCII(
136
+ 'C4Context\nSystem(a, "Aaa")\nSystem(b, "Bbb")\nRel_L(a, b, "x")',
137
+ )
138
+ expect(find(l, 'Bbb')[0]).toBeLessThan(find(l, 'Aaa')[0])
139
+ const u = renderMermaidASCII(
140
+ 'C4Context\nSystem(a, "Aaa")\nSystem(b, "Bbb")\nRel_U(a, b, "x")',
141
+ )
142
+ expect(find(u, 'Bbb')[1]).toBeLessThan(find(u, 'Aaa')[1])
143
+ })
144
+
145
+ it('draws BiRel arrowheads at both ends', () => {
146
+ const out = renderMermaidASCII(
147
+ 'C4Context\nSystem(a, "Aaa")\nSystem(b, "Bbb")\nBiRel(a, b, "x")',
148
+ )
149
+ expect(out).toContain('▼')
150
+ expect(out).toContain('▲')
151
+ })
152
+
153
+ it('uses only ASCII characters with useAscii', () => {
154
+ const out = renderMermaidASCII(SRC, { useAscii: true })
155
+ expect(out).toMatch(/^[\x00-\x7f]*$/)
156
+ expect(out).toContain('/|\\')
157
+ expect(out).toContain('v')
158
+ })
159
+
160
+ it('marks external elements with a dashed border and queues with parentheses', () => {
161
+ const out = renderMermaidASCII(SRC)
162
+ expect(out).toContain('┄')
163
+ expect(out).toMatch(/\( +Events +\)/)
164
+ })
165
+
166
+ it('draws a cylinder cap for databases', () => {
167
+ const out = renderMermaidASCII(SRC)
168
+ expect(out).toMatch(/├─+┤/)
169
+ })
170
+
171
+ it('keeps every element box intact (no route runs through one)', () => {
172
+ const out = renderMermaidASCII(SRC)
173
+ const lines = out.split('\n')
174
+ for (const l of lines.filter((l) => l.includes('Web App'))) {
175
+ expect(l).toMatch(/│ +Web App +│/)
176
+ }
177
+ for (const name of ['Database', 'Events', 'Payments']) {
178
+ const row = lines.find((l) => l.includes(name))!
179
+ expect(row).toMatch(new RegExp(`[│(┆] +${name} +[│)┆]`))
180
+ }
181
+ })
182
+
183
+ it('nests boundaries and routes to a boundary', () => {
184
+ const out = renderMermaidASCII(`C4Deployment
185
+ Deployment_Node(dc, "DC", "Ubuntu") {
186
+ Deployment_Node(k, "K8s", "Kubernetes") {
187
+ Container(app, "App", "Java")
188
+ }
189
+ }
190
+ Deployment_Node(empty, "Empty", "x")
191
+ ContainerDb(db, "DB", "Pg")
192
+ Rel(app, db, "reads")
193
+ Rel(db, empty, "to empty")`)
194
+ expect(out).toContain('[Ubuntu]')
195
+ expect(out).toContain('[Kubernetes]')
196
+ const [, dcRow] = find(out, 'DC')
197
+ const [, k8sRow] = find(out, 'K8s')
198
+ const [, appRow] = find(out, 'App')
199
+ expect(dcRow).toBeLessThan(k8sRow)
200
+ expect(k8sRow).toBeLessThan(appRow)
201
+ // The empty node ranks below the database that points at it.
202
+ expect(find(out, 'DB')[1]).toBeLessThan(find(out, 'Empty')[1])
203
+ })
204
+
205
+ it('renders a lone element, empty input and self relationships', () => {
206
+ expect(renderMermaidASCII('C4Context\nSystem(a, "Solo")')).toContain('Solo')
207
+ expect(renderMermaidASCII('C4Context')).toBe('')
208
+ expect(
209
+ renderMermaidASCII('C4Context\nSystem(a, "A")\nRel(a, a, "self")'),
210
+ ).toContain('A')
211
+ })
212
+
213
+ it('wraps long names and descriptions', () => {
214
+ const out = renderMermaidASCII(
215
+ 'C4Context\nSystem(a, "A very long system name that must wrap", "and an equally long description that also wraps")',
216
+ )
217
+ const longest = Math.max(...out.split('\n').map((l) => l.length))
218
+ expect(longest).toBeLessThan(40)
219
+ })
220
+
221
+ it('emits ANSI color when asked', () => {
222
+ const out = renderMermaidASCII(SRC, { colorMode: 'ansi16' })
223
+ expect(out).toMatch(/\x1b\[/)
224
+ })
225
+
226
+ it('is deterministic', () => {
227
+ expect(renderMermaidASCII(SRC)).toBe(renderMermaidASCII(SRC))
228
+ })
229
+ })
230
+
231
+ describe('relationship labels stay readable', () => {
232
+ /** The rows between the box holding `upper` and the next box below it. */
233
+ function between(out: string, upper: string): string[] {
234
+ const lines = out.split('\n')
235
+ const top = lines.findIndex((l) => l.includes(upper))
236
+ const bottom = lines.findIndex((l, i) => i > top && /└─/.test(l))
237
+ const next = lines.findIndex((l, i) => i > bottom && /┌─/.test(l))
238
+ return lines.slice(bottom + 1, next)
239
+ }
240
+
241
+ const DYNAMIC = `C4Dynamic
242
+ Person(u, "User")
243
+ System(a, "App")
244
+ System(b, "Backend")
245
+ Rel(u, a, "Clicks")
246
+ Rel(a, b, "Requests")
247
+ Rel_Back(b, a, "Responds")
248
+ BiRel(u, b, "Streams")`
249
+
250
+ it('gives every label on a crowded gap its own room and leaves the routes whole', () => {
251
+ const out = renderMermaidASCII(DYNAMIC)
252
+ for (const word of [
253
+ '1:',
254
+ 'Clicks',
255
+ '2:',
256
+ 'Requests',
257
+ '3:',
258
+ 'Responds',
259
+ '4:',
260
+ 'Streams',
261
+ ]) {
262
+ expect(out, word).toContain(word)
263
+ }
264
+ // Two routes run side by side from App to Backend; a label drawn over
265
+ // either would break the pair on its row (arrowhead rows excepted).
266
+ const gap = between(out, 'App')
267
+ expect(gap.length).toBeGreaterThanOrEqual(6)
268
+ for (const row of gap.filter((l) => !/[▼▲]/.test(l))) {
269
+ expect(row, row).toContain('││')
270
+ }
271
+ })
272
+
273
+ it('wraps a long label beside its route instead of drawing it over the route', () => {
274
+ const out = renderMermaidASCII(`C4Container
275
+ Container(w, "Web", "React")
276
+ Container(a, "API", "Node")
277
+ Rel(w, a, "Calls the API with a long label", "JSON/HTTPS")`)
278
+ for (const word of [
279
+ 'Calls',
280
+ 'the',
281
+ 'API',
282
+ 'with',
283
+ 'a',
284
+ 'long',
285
+ 'label',
286
+ '[JSON/HTTPS]',
287
+ ]) {
288
+ expect(out, word).toContain(word)
289
+ }
290
+ // The route is one unbroken vertical line between the boxes: its column
291
+ // (where the arrowhead lands) holds a line or the arrowhead on every row.
292
+ const gap = between(out, 'Web').filter((l) => l.trim() !== '')
293
+ const col = gap.map((l) => l.indexOf('▼')).find((c) => c >= 0)!
294
+ expect(col).toBeGreaterThanOrEqual(0)
295
+ for (const row of gap) expect('│▼').toContain(row[col] ?? ' ')
296
+ })
297
+
298
+ it('grows a gap with the labels that cross it', () => {
299
+ const rows = (labels: number): number => {
300
+ const rels = Array.from(
301
+ { length: labels },
302
+ (_, i) => `Rel(a, b, "label ${i}")`,
303
+ )
304
+ const out = renderMermaidASCII(
305
+ `C4Context\n System(a, "A")\n System(b, "B")\n ${rels.join('\n ')}`,
306
+ )
307
+ return between(out, 'A').length
308
+ }
309
+ expect(rows(4)).toBeGreaterThan(rows(1))
310
+ })
311
+ })
@@ -16,27 +16,27 @@ B --> Y
16
16
  |
17
17
  |
18
18
  |
19
- +---|----+
20
- |LR Group|
21
- | | |
22
- | v |
23
- | +---+ |
24
- | | | |
25
- | | A | |
26
- | | | |
27
- | +-+-+ |
28
- | | |
29
- | | |
30
- | | |
31
- | | |
32
- | v |
33
- | +---+ |
34
- | | | |
35
- | | B | |
36
- | | | |
37
- | +-+-+ |
38
- | | |
39
- +---|----+
19
+ +---|-----+
20
+ | LR|Group|
21
+ | | |
22
+ | v |
23
+ | +---+ |
24
+ | | | |
25
+ | | A | |
26
+ | | | |
27
+ | +-+-+ |
28
+ | | |
29
+ | | |
30
+ | | |
31
+ | | |
32
+ | v |
33
+ | +---+ |
34
+ | | | |
35
+ | | B | |
36
+ | | | |
37
+ | +-+-+ |
38
+ | | |
39
+ +---|-----+
40
40
  |
41
41
  |
42
42
  v
@@ -18,7 +18,7 @@ B --> Y
18
18
  |
19
19
  |
20
20
  +---|---+
21
- | one |
21
+ |one| |
22
22
  | | |
23
23
  | v |
24
24
  | +---+ |
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Value-axis tick labels for narrow and degenerate ranges (#1234).
3
+ */
4
+ import { describe, it, expect } from 'vitest'
5
+ import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
6
+
7
+ function render(text: string): string {
8
+ return renderMermaidASCII(text, { colorMode: 'none' })
9
+ }
10
+
11
+ describe('xychart ASCII – narrow-range value-axis labels', () => {
12
+ it('labels a 99-102 range with fractional ticks, not repeated integers', () => {
13
+ const result = render(`xychart-beta
14
+ x-axis [A, B, C, D]
15
+ bar [100, 102, 99, 101]`)
16
+ expect(result).toContain('99.5')
17
+ expect(result).toContain('100.5')
18
+ })
19
+
20
+ it('renders a single tick label for a zero-width explicit range', () => {
21
+ const result = render(`xychart-beta
22
+ x-axis [A]
23
+ y-axis 5 --> 5
24
+ bar [5]`)
25
+ expect(result).toContain('5')
26
+ })
27
+
28
+ it('labels the value axis of a horizontal chart with fractional ticks', () => {
29
+ const result = render(`xychart-beta horizontal
30
+ x-axis [A, B]
31
+ bar [100, 102]`)
32
+ expect(result).toMatch(/\d\.\d/)
33
+ })
34
+ })
package/src/ansi.ts CHANGED
@@ -253,9 +253,23 @@ function escapeHtml(text: string): string {
253
253
  return text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
254
254
  }
255
255
 
256
- /** Wrap text in a <span> with an inline color style. */
256
+ /** A run made only of full blocks (U+2588), as drawn by bar and area fills. */
257
+ const SOLID_BLOCK_RUN = /^█+$/
258
+
259
+ /**
260
+ * Wrap text in a <span> with an inline color style. A run of full blocks also
261
+ * gets a background of the same color: a browser draws each `█` as an
262
+ * anti-aliased glyph, and where a cell edge falls inside a device pixel (any
263
+ * fractional display scale, e.g. 125% or 150%) the two neighbouring glyphs
264
+ * each cover only part of that pixel and composite to 75-81% of the fill,
265
+ * leaving a hairline seam between cells. A background is snapped to whole
266
+ * device pixels, so the glyph edges land on a solid fill instead.
267
+ */
257
268
  function htmlSpan(hex: string, text: string): string {
258
- return `<span style="color:${hex}">${escapeHtml(text)}</span>`
269
+ const style = SOLID_BLOCK_RUN.test(text)
270
+ ? `color:${hex};background:${hex}`
271
+ : `color:${hex}`
272
+ return `<span style="${style}">${escapeHtml(text)}</span>`
259
273
  }
260
274
 
261
275
  // ============================================================================
@@ -0,0 +1,33 @@
1
+ // ============================================================================
2
+ // ASCII renderer — architecture diagram
3
+ //
4
+ // Lowers the parsed diagram to the flowchart model (`architectureToGraph`)
5
+ // and reuses the flowchart grid layout + drawing pipeline.
6
+ // ============================================================================
7
+
8
+ import { splitStatements, withDirectionOverride } from '@zombie-mermaid/core'
9
+ import {
10
+ parseArchitecture,
11
+ architectureToGraph,
12
+ } from '@zombie-mermaid/mermaid-parser'
13
+ import { renderGraphAscii } from './flowchart.ts'
14
+ import type { FlowchartAsciiExtras } from './flowchart.ts'
15
+ import type { AsciiConfig, AsciiTheme, ColorMode } from './types.ts'
16
+
17
+ /** Render an `architecture-beta` diagram to ASCII/Unicode text art. */
18
+ export function renderArchitectureAscii(
19
+ text: string,
20
+ config: AsciiConfig,
21
+ colorMode: ColorMode,
22
+ theme: AsciiTheme,
23
+ extras: FlowchartAsciiExtras = {},
24
+ ): string {
25
+ const graph = architectureToGraph(parseArchitecture(splitStatements(text)))
26
+ return renderGraphAscii(
27
+ withDirectionOverride(graph, extras.direction),
28
+ config,
29
+ colorMode,
30
+ theme,
31
+ extras,
32
+ )
33
+ }