zombie-mermaid 4.2.2 → 4.2.3
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/package.json +9 -7
- package/src/__tests__/architecture-integration.test.ts +0 -217
- package/src/__tests__/ascii-ansi.test.ts +0 -539
- package/src/__tests__/ascii-draw-bundles.test.ts +0 -849
- package/src/__tests__/ascii-edge-bundling-routing.test.ts +0 -214
- package/src/__tests__/ascii-edge-cross-style-overlap.test.ts +0 -216
- package/src/__tests__/ascii-grid-deep-chain.test.ts +0 -64
- package/src/__tests__/ascii-hyperlinks.test.ts +0 -364
- package/src/__tests__/ascii-nested-subgraph-sibling-order-444.test.ts +0 -108
- package/src/__tests__/ascii-nonmember-beside-frame-1283.test.ts +0 -113
- package/src/__tests__/ascii-package-boundary.test.ts +0 -291
- package/src/__tests__/ascii-parallel-edges-329.test.ts +0 -315
- package/src/__tests__/ascii-parallel-lane-border-1230.test.ts +0 -71
- package/src/__tests__/ascii-reverse-direction-edges-629.test.ts +0 -282
- package/src/__tests__/ascii-stacked-frame-geometry-1285.test.ts +0 -107
- package/src/__tests__/ascii-subgraph-bbox.test.ts +0 -139
- package/src/__tests__/ascii-subgraph-later-edgeless-301.test.ts +0 -277
- package/src/__tests__/browser.test.ts +0 -34
- package/src/__tests__/c4-integration.test.ts +0 -161
- package/src/__tests__/c4-orientation.test.ts +0 -162
- package/src/__tests__/c4-upstream-integration.test.ts +0 -211
- package/src/__tests__/class-click-interactions.test.ts +0 -232
- package/src/__tests__/class-integration.test.ts +0 -154
- package/src/__tests__/class-notes.test.ts +0 -317
- package/src/__tests__/class-parser-error-messages.test.ts +0 -162
- package/src/__tests__/class-parser.test.ts +0 -347
- package/src/__tests__/class-styling.test.ts +0 -485
- package/src/__tests__/cli-diagrams.test.ts +0 -106
- package/src/__tests__/cli-e2e.test.ts +0 -275
- package/src/__tests__/cli-entrypoint.test.ts +0 -146
- package/src/__tests__/cli-mcp-dispatch.test.ts +0 -68
- package/src/__tests__/cli-mcp.test.ts +0 -44
- package/src/__tests__/cli-png.test.ts +0 -35
- package/src/__tests__/cli-render.test.ts +0 -988
- package/src/__tests__/cli-test-helpers.ts +0 -54
- package/src/__tests__/cli-web-dispatch.test.ts +0 -62
- package/src/__tests__/cli-web.test.ts +0 -203
- package/src/__tests__/cli.test.ts +0 -1027
- package/src/__tests__/compound-flat-layout.test.ts +0 -901
- package/src/__tests__/direction-override.test.ts +0 -397
- package/src/__tests__/edge-approach-direction.test.ts +0 -295
- package/src/__tests__/edge-bundling-node-clearance.test.ts +0 -179
- package/src/__tests__/elk-layout-cache.test.ts +0 -223
- package/src/__tests__/er-click-unsupported.test.ts +0 -69
- package/src/__tests__/er-integration.test.ts +0 -531
- package/src/__tests__/er-parser-error-messages.test.ts +0 -82
- package/src/__tests__/er-parser.test.ts +0 -294
- package/src/__tests__/expanded-shapes.test.ts +0 -229
- package/src/__tests__/flowchart-back-edge-prereversal-1239.test.ts +0 -212
- package/src/__tests__/flowchart-syntax-gaps.test.ts +0 -371
- package/src/__tests__/guides-examples.test.ts +0 -181
- package/src/__tests__/integration.test.ts +0 -729
- package/src/__tests__/interactivity-config.test.ts +0 -723
- package/src/__tests__/interactivity-option.test.ts +0 -173
- package/src/__tests__/layout-disconnected.test.ts +0 -384
- package/src/__tests__/layout-engine.test.ts +0 -91
- package/src/__tests__/linkstyle.test.ts +0 -133
- package/src/__tests__/mcp-render-ascii-tool.test.ts +0 -95
- package/src/__tests__/mcp-render-svg-theme-guard.test.ts +0 -36
- package/src/__tests__/mcp-render-svg-tool.test.ts +0 -118
- package/src/__tests__/mermaid-entity-codes.test.ts +0 -114
- package/src/__tests__/monospace-metrics.test.ts +0 -74
- package/src/__tests__/multiline-labels.test.ts +0 -924
- package/src/__tests__/nested-subgraph-direction-crossing.test.ts +0 -322
- package/src/__tests__/nested-subgraph-edge-container-offset.test.ts +0 -151
- package/src/__tests__/package-info.test.ts +0 -51
- package/src/__tests__/parser.test.ts +0 -1544
- package/src/__tests__/render-options-overrides.test.ts +0 -257
- package/src/__tests__/render-options-per-type.test.ts +0 -178
- package/src/__tests__/renderer.test.ts +0 -743
- package/src/__tests__/resolve-colors.test.ts +0 -448
- package/src/__tests__/self-loop-edges.test.ts +0 -112
- package/src/__tests__/sequence-activation-check.test.ts +0 -219
- package/src/__tests__/sequence-integration.test.ts +0 -312
- package/src/__tests__/sequence-layout.test.ts +0 -1103
- package/src/__tests__/sequence-parser-syntax-errors.test.ts +0 -179
- package/src/__tests__/sequence-parser.test.ts +0 -821
- package/src/__tests__/start-marker-only-links-1386.test.ts +0 -104
- package/src/__tests__/state-composite-order-1287.test.ts +0 -132
- package/src/__tests__/state-flow-order-1240.test.ts +0 -113
- package/src/__tests__/state-styling.test.ts +0 -150
- package/src/__tests__/statements.test.ts +0 -264
- package/src/__tests__/styles.test.ts +0 -220
- package/src/__tests__/subgraph-title-edge-gap-1239.test.ts +0 -238
- package/src/__tests__/svg-accessible-name-conformance.test.ts +0 -276
- package/src/__tests__/svg-accessible-name.test.ts +0 -421
- package/src/__tests__/svg-csp-style-options.test.ts +0 -427
- package/src/__tests__/workspace-package-boundaries.test.ts +0 -336
- package/src/__tests__/xychart-integration.test.ts +0 -137
- package/src/__tests__/xychart-layout.test.ts +0 -205
- package/src/__tests__/xychart-parser-categories.test.ts +0 -78
- package/src/__tests__/xychart-parser-errors.test.ts +0 -95
|
@@ -1,364 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Opt-in OSC 8 terminal hyperlinks in ASCII output (issue #216, piece 4).
|
|
3
|
-
*
|
|
4
|
-
* `renderMermaidASCII(text, { hyperlinks: true })` wraps the label of every
|
|
5
|
-
* node whose `click` directive declared a safe href in an OSC 8 escape
|
|
6
|
-
* pair. The sequences are zero-width, so the load-bearing property is that
|
|
7
|
-
* stripping them yields exactly the non-hyperlinked output — checked here
|
|
8
|
-
* per feature, and against every gallery sample in
|
|
9
|
-
* __tests__/ascii-hyperlinks-samples.test.ts (which lives outside src/ so it
|
|
10
|
-
* can import samples-data.ts without tripping tsconfig's rootDir).
|
|
11
|
-
*/
|
|
12
|
-
import { describe, it, expect } from 'vitest'
|
|
13
|
-
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
14
|
-
import {
|
|
15
|
-
OSC8_CLOSE,
|
|
16
|
-
OSC8_SEQUENCE,
|
|
17
|
-
osc8Open,
|
|
18
|
-
stripOsc8,
|
|
19
|
-
joinWithLinks,
|
|
20
|
-
LinkRunTracker,
|
|
21
|
-
markBoxLabelLinks,
|
|
22
|
-
mkLinkCanvas,
|
|
23
|
-
} from '../../packages/ascii-renderer/src/hyperlinks.ts'
|
|
24
|
-
import {
|
|
25
|
-
colorizeLine,
|
|
26
|
-
DEFAULT_ASCII_THEME,
|
|
27
|
-
} from '../../packages/ascii-renderer/src/ansi.ts'
|
|
28
|
-
import { mkCanvas, write } from '../../packages/ascii-renderer/src/canvas.ts'
|
|
29
|
-
import { addCoordsOverlay } from '../../packages/ascii-renderer/src/coords.ts'
|
|
30
|
-
import { runRender } from '../cli/render.ts'
|
|
31
|
-
import { createMockStdout, renderArgs } from './cli-test-helpers.ts'
|
|
32
|
-
import type { CharRole } from '../../packages/ascii-renderer/src/types.ts'
|
|
33
|
-
|
|
34
|
-
const OSC8_PREFIX = '\x1b]8;;'
|
|
35
|
-
|
|
36
|
-
/** Every `open … close` span in `output`: [href, visible text]. */
|
|
37
|
-
function linkSpans(output: string): [string, string][] {
|
|
38
|
-
const spans: [string, string][] = []
|
|
39
|
-
const re = /\x1b\]8;;([^\x1b]*)\x1b\\([\s\S]*?)\x1b\]8;;\x1b\\/g
|
|
40
|
-
for (const m of output.matchAll(re)) spans.push([m[1]!, m[2]!])
|
|
41
|
-
return spans
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
const LINKED_FLOW = `graph LR
|
|
45
|
-
A[Docs] --> B[Other]
|
|
46
|
-
click A "https://example.com" "Docs"`
|
|
47
|
-
|
|
48
|
-
describe('renderMermaidASCII – hyperlinks option', () => {
|
|
49
|
-
it('is off by default: no OSC 8 anywhere, even with click hrefs present', () => {
|
|
50
|
-
expect(renderMermaidASCII(LINKED_FLOW)).not.toContain(OSC8_PREFIX)
|
|
51
|
-
expect(
|
|
52
|
-
renderMermaidASCII(LINKED_FLOW, { hyperlinks: false }),
|
|
53
|
-
).not.toContain(OSC8_PREFIX)
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
it('wraps only the linked node label when enabled', () => {
|
|
57
|
-
const out = renderMermaidASCII(LINKED_FLOW, { hyperlinks: true })
|
|
58
|
-
expect(linkSpans(out)).toEqual([['https://example.com', 'Docs']])
|
|
59
|
-
// The unlinked node's label is present and bare.
|
|
60
|
-
expect(stripOsc8(out)).toContain('Other')
|
|
61
|
-
expect(out).toContain(`${osc8Open('https://example.com')}Docs${OSC8_CLOSE}`)
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
it('never changes layout: stripping the sequences gives the plain render', () => {
|
|
65
|
-
const plain = renderMermaidASCII(LINKED_FLOW)
|
|
66
|
-
const linked = renderMermaidASCII(LINKED_FLOW, { hyperlinks: true })
|
|
67
|
-
expect(linked).not.toBe(plain)
|
|
68
|
-
expect(stripOsc8(linked)).toBe(plain)
|
|
69
|
-
})
|
|
70
|
-
|
|
71
|
-
it('links a multi-word label as one span, interior space included', () => {
|
|
72
|
-
const out = renderMermaidASCII(
|
|
73
|
-
`graph TD
|
|
74
|
-
A[Web Server] --> B[DB]
|
|
75
|
-
click A "https://example.com/web"`,
|
|
76
|
-
{ hyperlinks: true },
|
|
77
|
-
)
|
|
78
|
-
expect(linkSpans(out)).toEqual([['https://example.com/web', 'Web Server']])
|
|
79
|
-
})
|
|
80
|
-
|
|
81
|
-
it('links each line of a multi-line label separately', () => {
|
|
82
|
-
const out = renderMermaidASCII(
|
|
83
|
-
`graph TD
|
|
84
|
-
A["Line one<br/>Line two"] --> B[Next]
|
|
85
|
-
click A "https://example.com/multi"`,
|
|
86
|
-
{ hyperlinks: true },
|
|
87
|
-
)
|
|
88
|
-
expect(linkSpans(out)).toEqual([
|
|
89
|
-
['https://example.com/multi', 'Line one'],
|
|
90
|
-
['https://example.com/multi', 'Line two'],
|
|
91
|
-
])
|
|
92
|
-
expect(stripOsc8(out)).toBe(
|
|
93
|
-
renderMermaidASCII(
|
|
94
|
-
`graph TD
|
|
95
|
-
A["Line one<br/>Line two"] --> B[Next]
|
|
96
|
-
click A "https://example.com/multi"`,
|
|
97
|
-
),
|
|
98
|
-
)
|
|
99
|
-
})
|
|
100
|
-
|
|
101
|
-
it('links only the node that declared the click when labels repeat', () => {
|
|
102
|
-
const src = `graph LR
|
|
103
|
-
A[Same] --> B[Same]
|
|
104
|
-
click B "https://example.com/b"`
|
|
105
|
-
const out = renderMermaidASCII(src, { hyperlinks: true })
|
|
106
|
-
expect(linkSpans(out)).toEqual([['https://example.com/b', 'Same']])
|
|
107
|
-
// Both labels are still rendered; exactly one of them is wrapped.
|
|
108
|
-
expect(stripOsc8(out).match(/Same/g)).toHaveLength(2)
|
|
109
|
-
})
|
|
110
|
-
|
|
111
|
-
it('links the whole label when another label is a prefix of it', () => {
|
|
112
|
-
const out = renderMermaidASCII(
|
|
113
|
-
`graph LR
|
|
114
|
-
A[Doc] --> B[Docs]
|
|
115
|
-
click B "https://example.com/docs"`,
|
|
116
|
-
{ hyperlinks: true },
|
|
117
|
-
)
|
|
118
|
-
expect(linkSpans(out)).toEqual([['https://example.com/docs', 'Docs']])
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it.each([
|
|
122
|
-
['javascript:alert(1)'],
|
|
123
|
-
['data:text/html,<script>alert(1)</script>'],
|
|
124
|
-
['vbscript:msgbox'],
|
|
125
|
-
])('never emits an unsafe href (%s)', (href) => {
|
|
126
|
-
const out = renderMermaidASCII(
|
|
127
|
-
`graph LR
|
|
128
|
-
A[Docs] --> B[Other]
|
|
129
|
-
click A "${href}"`,
|
|
130
|
-
{ hyperlinks: true },
|
|
131
|
-
)
|
|
132
|
-
expect(out).not.toContain(OSC8_PREFIX)
|
|
133
|
-
expect(out).not.toContain(href)
|
|
134
|
-
})
|
|
135
|
-
|
|
136
|
-
it('emits nothing for a callback-only interaction', () => {
|
|
137
|
-
const out = renderMermaidASCII(
|
|
138
|
-
`graph LR
|
|
139
|
-
A[Docs] --> B[Other]
|
|
140
|
-
click A call showDetail() "A tooltip"`,
|
|
141
|
-
{ hyperlinks: true },
|
|
142
|
-
)
|
|
143
|
-
expect(out).not.toContain(OSC8_PREFIX)
|
|
144
|
-
expect(out).not.toContain('showDetail')
|
|
145
|
-
})
|
|
146
|
-
|
|
147
|
-
it('accepts mailto and relative hrefs', () => {
|
|
148
|
-
const out = renderMermaidASCII(
|
|
149
|
-
`graph LR
|
|
150
|
-
A[Mail] --> B[Local]
|
|
151
|
-
click A "mailto:me@example.com"
|
|
152
|
-
click B "./guide.html"`,
|
|
153
|
-
{ hyperlinks: true },
|
|
154
|
-
)
|
|
155
|
-
expect(linkSpans(out)).toEqual([
|
|
156
|
-
['mailto:me@example.com', 'Mail'],
|
|
157
|
-
['./guide.html', 'Local'],
|
|
158
|
-
])
|
|
159
|
-
})
|
|
160
|
-
|
|
161
|
-
it('keeps links on their labels after the BT vertical flip', () => {
|
|
162
|
-
const src = `graph BT
|
|
163
|
-
A[Bottom] --> B[Top]
|
|
164
|
-
click B "https://example.com/top"`
|
|
165
|
-
const out = renderMermaidASCII(src, { hyperlinks: true })
|
|
166
|
-
expect(linkSpans(out)).toEqual([['https://example.com/top', 'Top']])
|
|
167
|
-
expect(stripOsc8(out)).toBe(renderMermaidASCII(src))
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('links a class name in a class diagram, but not its annotation or members', () => {
|
|
171
|
-
const src = `classDiagram
|
|
172
|
-
class Animal {
|
|
173
|
-
<<interface>>
|
|
174
|
-
+String name
|
|
175
|
-
+speak()
|
|
176
|
-
}
|
|
177
|
-
class Dog
|
|
178
|
-
Animal <|-- Dog
|
|
179
|
-
click Animal href "https://example.com/animal"`
|
|
180
|
-
const out = renderMermaidASCII(src, { hyperlinks: true })
|
|
181
|
-
expect(linkSpans(out)).toEqual([['https://example.com/animal', 'Animal']])
|
|
182
|
-
expect(stripOsc8(out)).toBe(renderMermaidASCII(src))
|
|
183
|
-
})
|
|
184
|
-
|
|
185
|
-
it("is ignored in 'html' color mode (browser mockups never get escapes)", () => {
|
|
186
|
-
const out = renderMermaidASCII(LINKED_FLOW, {
|
|
187
|
-
hyperlinks: true,
|
|
188
|
-
colorMode: 'html',
|
|
189
|
-
})
|
|
190
|
-
expect(out).not.toContain('\x1b')
|
|
191
|
-
expect(out).toContain('<span')
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
it('composes with ANSI colors without disturbing the SGR runs', () => {
|
|
195
|
-
const plain = renderMermaidASCII(LINKED_FLOW, { colorMode: 'truecolor' })
|
|
196
|
-
const linked = renderMermaidASCII(LINKED_FLOW, {
|
|
197
|
-
colorMode: 'truecolor',
|
|
198
|
-
hyperlinks: true,
|
|
199
|
-
})
|
|
200
|
-
expect(plain).toContain('\x1b[38;2;')
|
|
201
|
-
expect(linkSpans(linked).map(([href]) => href)).toEqual([
|
|
202
|
-
'https://example.com',
|
|
203
|
-
])
|
|
204
|
-
expect(stripOsc8(linked)).toBe(plain)
|
|
205
|
-
})
|
|
206
|
-
|
|
207
|
-
it('does not widen the --coords ruler', () => {
|
|
208
|
-
const plain = addCoordsOverlay(renderMermaidASCII(LINKED_FLOW))
|
|
209
|
-
const linked = addCoordsOverlay(
|
|
210
|
-
renderMermaidASCII(LINKED_FLOW, { hyperlinks: true }),
|
|
211
|
-
)
|
|
212
|
-
expect(linked.split('\n')[0]).toBe(plain.split('\n')[0])
|
|
213
|
-
expect(stripOsc8(linked)).toBe(plain)
|
|
214
|
-
})
|
|
215
|
-
})
|
|
216
|
-
|
|
217
|
-
describe('hyperlinks – escape sequence helpers', () => {
|
|
218
|
-
it('uses the ESC \\ string terminator, not BEL', () => {
|
|
219
|
-
expect(osc8Open('https://example.com')).toBe(
|
|
220
|
-
'\x1b]8;;https://example.com\x1b\\',
|
|
221
|
-
)
|
|
222
|
-
expect(OSC8_CLOSE).toBe('\x1b]8;;\x1b\\')
|
|
223
|
-
expect(osc8Open('https://example.com')).not.toContain('\x07')
|
|
224
|
-
})
|
|
225
|
-
|
|
226
|
-
it('percent-encodes characters outside printable ASCII, leaving the rest alone', () => {
|
|
227
|
-
expect(osc8Open('https://例.jp/a b?q=1&r=%20#x')).toBe(
|
|
228
|
-
'\x1b]8;;https://%E4%BE%8B.jp/a%20b?q=1&r=%20#x\x1b\\',
|
|
229
|
-
)
|
|
230
|
-
// A surrogate pair encodes as one code point, not two lone surrogates.
|
|
231
|
-
expect(osc8Open('https://x.test/😀')).toBe(
|
|
232
|
-
'\x1b]8;;https://x.test/%F0%9F%98%80\x1b\\',
|
|
233
|
-
)
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
it('stripOsc8 removes ST- and BEL-terminated sequences and nothing else', () => {
|
|
237
|
-
const st = `${osc8Open('https://a.test')}A${OSC8_CLOSE} \x1b[31mB\x1b[0m`
|
|
238
|
-
expect(stripOsc8(st)).toBe('A \x1b[31mB\x1b[0m')
|
|
239
|
-
const bel = '\x1b]8;;https://a.test\x07A\x1b]8;;\x07'
|
|
240
|
-
expect(stripOsc8(bel)).toBe('A')
|
|
241
|
-
expect(OSC8_SEQUENCE.flags).toContain('g')
|
|
242
|
-
})
|
|
243
|
-
|
|
244
|
-
it('LinkRunTracker closes and reopens between adjacent different links', () => {
|
|
245
|
-
const t = new LinkRunTracker()
|
|
246
|
-
expect(t.advance(null)).toBe('')
|
|
247
|
-
expect(t.advance('https://a.test')).toBe(osc8Open('https://a.test'))
|
|
248
|
-
expect(t.advance('https://a.test')).toBe('')
|
|
249
|
-
expect(t.advance('https://b.test')).toBe(
|
|
250
|
-
OSC8_CLOSE + osc8Open('https://b.test'),
|
|
251
|
-
)
|
|
252
|
-
expect(t.advance(null)).toBe(OSC8_CLOSE)
|
|
253
|
-
expect(t.finish()).toBe('')
|
|
254
|
-
t.advance('https://c.test')
|
|
255
|
-
expect(t.finish()).toBe(OSC8_CLOSE)
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
it('joinWithLinks wraps runs and closes a run open at end of line', () => {
|
|
259
|
-
const chars = ['x', 'y', 'z', 'w']
|
|
260
|
-
const links = [null, 'https://a.test', 'https://a.test', 'https://b.test']
|
|
261
|
-
expect(joinWithLinks(chars, links)).toBe(
|
|
262
|
-
`x${osc8Open('https://a.test')}yz${OSC8_CLOSE}${osc8Open('https://b.test')}w${OSC8_CLOSE}`,
|
|
263
|
-
)
|
|
264
|
-
expect(joinWithLinks(chars, [null, null, null, null])).toBe('xyzw')
|
|
265
|
-
})
|
|
266
|
-
|
|
267
|
-
it('colorizeLine keeps SGR grouping intact around a link inside a color run', () => {
|
|
268
|
-
// Zero border padding: the label touches the border, so the link
|
|
269
|
-
// boundary falls inside what would otherwise be one 'border' SGR run if
|
|
270
|
-
// the label's first char were border-like. Assert the general property.
|
|
271
|
-
const chars = ['│', '-', 'x', '│']
|
|
272
|
-
const roles: (CharRole | null)[] = ['border', 'border', 'text', 'border']
|
|
273
|
-
const links = [null, 'https://a.test', 'https://a.test', null]
|
|
274
|
-
const plain = colorizeLine(chars, roles, DEFAULT_ASCII_THEME, 'truecolor')
|
|
275
|
-
const linked = colorizeLine(
|
|
276
|
-
chars,
|
|
277
|
-
roles,
|
|
278
|
-
DEFAULT_ASCII_THEME,
|
|
279
|
-
'truecolor',
|
|
280
|
-
links,
|
|
281
|
-
)
|
|
282
|
-
expect(stripOsc8(linked)).toBe(plain)
|
|
283
|
-
expect(linkSpans(stripSgr(linked))).toEqual([['https://a.test', '-x']])
|
|
284
|
-
})
|
|
285
|
-
|
|
286
|
-
it('markBoxLabelLinks marks first..last non-space cells of the requested interior rows', () => {
|
|
287
|
-
// 7 wide x 5 tall box: row 1 "ab cd", row 2 blank, row 3 "e"
|
|
288
|
-
const box = mkCanvas(6, 4)
|
|
289
|
-
for (let x = 1; x <= 5; x++) {
|
|
290
|
-
write(box, x, 0, '─')
|
|
291
|
-
write(box, x, 4, '─')
|
|
292
|
-
}
|
|
293
|
-
for (let y = 1; y <= 3; y++) {
|
|
294
|
-
write(box, 0, y, '│')
|
|
295
|
-
write(box, 6, y, '│')
|
|
296
|
-
}
|
|
297
|
-
for (const [i, ch] of ['a', 'b', ' ', 'c', 'd'].entries())
|
|
298
|
-
write(box, 1 + i, 1, ch)
|
|
299
|
-
write(box, 3, 3, 'e')
|
|
300
|
-
|
|
301
|
-
const all = mkLinkCanvas(20, 20)
|
|
302
|
-
markBoxLabelLinks(all, box, { x: 10, y: 5 }, 'https://a.test')
|
|
303
|
-
const marked = (lc: (string | null)[][]) =>
|
|
304
|
-
lc.flatMap((col, x) =>
|
|
305
|
-
col.flatMap((v, y) => (v === null ? [] : [`${x},${y}`])),
|
|
306
|
-
)
|
|
307
|
-
expect(marked(all).sort()).toEqual(
|
|
308
|
-
['11,6', '12,6', '13,6', '14,6', '15,6', '13,8'].sort(),
|
|
309
|
-
)
|
|
310
|
-
|
|
311
|
-
const headerOnly = mkLinkCanvas(20, 20)
|
|
312
|
-
markBoxLabelLinks(headerOnly, box, { x: 0, y: 0 }, 'https://a.test', {
|
|
313
|
-
from: 3,
|
|
314
|
-
to: 3,
|
|
315
|
-
})
|
|
316
|
-
expect(marked(headerOnly)).toEqual(['3,3'])
|
|
317
|
-
|
|
318
|
-
// Out-of-range cells are clipped, not thrown on.
|
|
319
|
-
const tiny = mkLinkCanvas(1, 1)
|
|
320
|
-
expect(() =>
|
|
321
|
-
markBoxLabelLinks(tiny, box, { x: 0, y: 0 }, 'https://a.test'),
|
|
322
|
-
).not.toThrow()
|
|
323
|
-
})
|
|
324
|
-
})
|
|
325
|
-
|
|
326
|
-
describe('CLI – render --ascii --hyperlinks', () => {
|
|
327
|
-
it('emits OSC 8 only when the flag is set', async () => {
|
|
328
|
-
const off = createMockStdout()
|
|
329
|
-
await runRender(renderArgs({ ascii: true }), off, LINKED_FLOW)
|
|
330
|
-
expect(off.output()).not.toContain(OSC8_PREFIX)
|
|
331
|
-
|
|
332
|
-
const on = createMockStdout()
|
|
333
|
-
await runRender(
|
|
334
|
-
renderArgs({ ascii: true, hyperlinks: true }),
|
|
335
|
-
on,
|
|
336
|
-
LINKED_FLOW,
|
|
337
|
-
)
|
|
338
|
-
expect(linkSpans(on.output())).toEqual([['https://example.com', 'Docs']])
|
|
339
|
-
expect(stripOsc8(on.output())).toBe(off.output())
|
|
340
|
-
})
|
|
341
|
-
|
|
342
|
-
it('measures --max-width on visible columns, ignoring the escapes', async () => {
|
|
343
|
-
const stderr = createMockStdout()
|
|
344
|
-
const out = createMockStdout()
|
|
345
|
-
// Wide enough that the plain render fits; the escape bytes alone would
|
|
346
|
-
// push a naive length measurement past the limit and trigger a warning.
|
|
347
|
-
const width = renderMermaidASCII(LINKED_FLOW)
|
|
348
|
-
.split('\n')
|
|
349
|
-
.reduce((m, l) => Math.max(m, l.length), 0)
|
|
350
|
-
await runRender(
|
|
351
|
-
renderArgs({ ascii: true, hyperlinks: true, maxWidth: width }),
|
|
352
|
-
out,
|
|
353
|
-
LINKED_FLOW,
|
|
354
|
-
stderr,
|
|
355
|
-
)
|
|
356
|
-
expect(stderr.output()).toBe('')
|
|
357
|
-
expect(out.output()).toContain(OSC8_PREFIX)
|
|
358
|
-
})
|
|
359
|
-
})
|
|
360
|
-
|
|
361
|
-
/** SGR-only strip, for asserting link spans inside colored output. */
|
|
362
|
-
function stripSgr(text: string): string {
|
|
363
|
-
return text.replace(/\x1b\[[0-9;]*m/g, '')
|
|
364
|
-
}
|
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression test for #444 — "Flowchart: nested subgraph left-right order
|
|
3
|
-
* doesn't match mermaid.js."
|
|
4
|
-
*
|
|
5
|
-
* Root cause: `placeReachableChildren` (src/ascii/grid.ts) placed a node's
|
|
6
|
-
* children in raw edge-declaration order, so for
|
|
7
|
-
*
|
|
8
|
-
* subgraph Cloud
|
|
9
|
-
* subgraph us-east [US East Region]
|
|
10
|
-
* A[Web Server] --> B[App Server]
|
|
11
|
-
* end
|
|
12
|
-
* subgraph us-west [US West Region]
|
|
13
|
-
* C[Web Server] --> D[App Server]
|
|
14
|
-
* end
|
|
15
|
-
* end
|
|
16
|
-
* E[Load Balancer] --> A
|
|
17
|
-
* E --> C
|
|
18
|
-
*
|
|
19
|
-
* `E`'s children were placed in the order its outgoing edges were declared
|
|
20
|
-
* (`E --> A` before `E --> C`), putting `us-east` on the left and `us-west`
|
|
21
|
-
* on the right. Real mermaid.js (verified against its bundled
|
|
22
|
-
* flowDb.getData() — see the comment on `compareBySiblingSubgraphOrder` in
|
|
23
|
-
* src/ascii/grid.ts, and the matching comment in
|
|
24
|
-
* packages/svg-renderer/src/layout-engine/to-elk.ts's `mermaidToElk`) instead orders sibling
|
|
25
|
-
* subgraphs in *reversed declaration order*, independent of which one a
|
|
26
|
-
* shared parent's edges happen to target first — putting `us-west` (the
|
|
27
|
-
* second-declared sibling) on the left and `us-east` on the right.
|
|
28
|
-
*
|
|
29
|
-
* The fix adds `compareBySiblingSubgraphOrder` to reorder same-level
|
|
30
|
-
* children that land in different sibling subgraphs, plus a matching fix to
|
|
31
|
-
* `placeReachableChildren`'s outer traversal order (visiting placed nodes in
|
|
32
|
-
* cross-axis order rather than raw declaration order) so a child's
|
|
33
|
-
* column/row stays aligned with its own parent once sibling order can
|
|
34
|
-
* diverge from declaration order.
|
|
35
|
-
*/
|
|
36
|
-
import { describe, it, expect } from 'vitest'
|
|
37
|
-
import { parseMermaid } from '@zombie-mermaid/mermaid-parser'
|
|
38
|
-
import { convertToAsciiGraph } from '../../packages/ascii-renderer/src/converter.ts'
|
|
39
|
-
import { createMapping } from '../../packages/ascii-renderer/src/grid.ts'
|
|
40
|
-
import { renderMermaidASCII } from '@zombie-mermaid/ascii-renderer'
|
|
41
|
-
import type {
|
|
42
|
-
AsciiConfig,
|
|
43
|
-
AsciiGraph,
|
|
44
|
-
} from '../../packages/ascii-renderer/src/types.ts'
|
|
45
|
-
import { requireGridCoord } from '../../packages/ascii-renderer/src/types.ts'
|
|
46
|
-
|
|
47
|
-
function layoutFlowchart(source: string): AsciiGraph {
|
|
48
|
-
const parsed = parseMermaid(source)
|
|
49
|
-
const config: AsciiConfig = {
|
|
50
|
-
useAscii: false,
|
|
51
|
-
paddingX: 5,
|
|
52
|
-
paddingY: 5,
|
|
53
|
-
boxBorderPadding: 1,
|
|
54
|
-
graphDirection: 'TD',
|
|
55
|
-
}
|
|
56
|
-
const graph = convertToAsciiGraph(parsed, config)
|
|
57
|
-
createMapping(graph)
|
|
58
|
-
return graph
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
function findNode(graph: AsciiGraph, name: string) {
|
|
62
|
-
const node = graph.nodes.find((n) => n.name === name)
|
|
63
|
-
if (!node) throw new Error(`node "${name}" not found`)
|
|
64
|
-
return node
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
describe('ASCII nested-subgraph sibling left-right order matches mermaid.js (#444)', () => {
|
|
68
|
-
const repro = `graph TD
|
|
69
|
-
subgraph Cloud
|
|
70
|
-
subgraph us-east [US East Region]
|
|
71
|
-
A[Web Server] --> B[App Server]
|
|
72
|
-
end
|
|
73
|
-
subgraph us-west [US West Region]
|
|
74
|
-
C[Web Server] --> D[App Server]
|
|
75
|
-
end
|
|
76
|
-
end
|
|
77
|
-
E[Load Balancer] --> A
|
|
78
|
-
E --> C`
|
|
79
|
-
|
|
80
|
-
it('places us-west (declared second) left of us-east (declared first)', () => {
|
|
81
|
-
const graph = layoutFlowchart(repro)
|
|
82
|
-
const a = requireGridCoord(findNode(graph, 'A')) // us-east member
|
|
83
|
-
const c = requireGridCoord(findNode(graph, 'C')) // us-west member
|
|
84
|
-
expect(c.x).toBeLessThan(a.x)
|
|
85
|
-
})
|
|
86
|
-
|
|
87
|
-
it('keeps each nested subgraph member aligned in the same column as its sibling', () => {
|
|
88
|
-
const graph = layoutFlowchart(repro)
|
|
89
|
-
const a = requireGridCoord(findNode(graph, 'A'))
|
|
90
|
-
const b = requireGridCoord(findNode(graph, 'B'))
|
|
91
|
-
const c = requireGridCoord(findNode(graph, 'C'))
|
|
92
|
-
const d = requireGridCoord(findNode(graph, 'D'))
|
|
93
|
-
expect(b.x).toBe(a.x) // A --> B stay in us-east's column
|
|
94
|
-
expect(d.x).toBe(c.x) // C --> D stay in us-west's column
|
|
95
|
-
})
|
|
96
|
-
|
|
97
|
-
it('renders "US West" before "US East" in the ASCII output', () => {
|
|
98
|
-
// The label may wrap around a routed edge line (e.g. "US East│Region"),
|
|
99
|
-
// so match on the unambiguous "US West"/"US East" prefix rather than
|
|
100
|
-
// the full two-word label.
|
|
101
|
-
const output = renderMermaidASCII(repro)
|
|
102
|
-
const westIdx = output.indexOf('US West')
|
|
103
|
-
const eastIdx = output.indexOf('US East')
|
|
104
|
-
expect(westIdx).toBeGreaterThan(-1)
|
|
105
|
-
expect(eastIdx).toBeGreaterThan(-1)
|
|
106
|
-
expect(westIdx).toBeLessThan(eastIdx)
|
|
107
|
-
})
|
|
108
|
-
})
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Regression tests for #1283 — layout of a non-member node placed beside a
|
|
3
|
-
* subgraph frame (follow-up to #1252/#1278).
|
|
4
|
-
*
|
|
5
|
-
* `separateNonMembersFromFrames` re-places `Y` just past the frame, but left
|
|
6
|
-
* its parent `W` where it was, so `W --> Y` wrapped around from W's side
|
|
7
|
-
* instead of dropping straight down. A free-standing parent that only feeds
|
|
8
|
-
* the moved node now moves to the same column.
|
|
9
|
-
*/
|
|
10
|
-
import { describe, it, expect } from 'vitest'
|
|
11
|
-
import { parseMermaid } from '@zombie-mermaid/mermaid-parser'
|
|
12
|
-
import { convertToAsciiGraph } from '../../packages/ascii-renderer/src/converter.ts'
|
|
13
|
-
import { createMapping } from '../../packages/ascii-renderer/src/grid.ts'
|
|
14
|
-
import type {
|
|
15
|
-
AsciiConfig,
|
|
16
|
-
AsciiGraph,
|
|
17
|
-
} from '../../packages/ascii-renderer/src/types.ts'
|
|
18
|
-
|
|
19
|
-
function layout(source: string, direction: 'TD' | 'LR'): AsciiGraph {
|
|
20
|
-
const config: AsciiConfig = {
|
|
21
|
-
useAscii: false,
|
|
22
|
-
paddingX: 5,
|
|
23
|
-
paddingY: 5,
|
|
24
|
-
boxBorderPadding: 1,
|
|
25
|
-
graphDirection: direction,
|
|
26
|
-
}
|
|
27
|
-
const graph = convertToAsciiGraph(parseMermaid(source), config)
|
|
28
|
-
createMapping(graph)
|
|
29
|
-
return graph
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function grid(graph: AsciiGraph, name: string) {
|
|
33
|
-
const gc = graph.nodes.find((n) => n.name === name)?.gridCoord
|
|
34
|
-
if (!gc) throw new Error(`node ${name} not placed`)
|
|
35
|
-
return gc
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
describe('ASCII: a non-member moved off a frame keeps its parent aligned (#1283)', () => {
|
|
39
|
-
it('keeps W directly above Y in TD', () => {
|
|
40
|
-
const graph = layout(
|
|
41
|
-
`graph TD
|
|
42
|
-
subgraph Sub
|
|
43
|
-
A-->B
|
|
44
|
-
A-->C
|
|
45
|
-
end
|
|
46
|
-
X-->A
|
|
47
|
-
W-->Y
|
|
48
|
-
Y-->A`,
|
|
49
|
-
'TD',
|
|
50
|
-
)
|
|
51
|
-
expect(grid(graph, 'W').x).toBe(grid(graph, 'Y').x)
|
|
52
|
-
expect(grid(graph, 'W').y).toBeLessThan(grid(graph, 'Y').y)
|
|
53
|
-
const edge = graph.edges.find(
|
|
54
|
-
(e) => e.from.name === 'W' && e.to.name === 'Y',
|
|
55
|
-
)!
|
|
56
|
-
// A straight drop: every path point shares one column.
|
|
57
|
-
expect(new Set(edge.path.map((p) => p.x)).size).toBe(1)
|
|
58
|
-
})
|
|
59
|
-
|
|
60
|
-
it('keeps W level with Y in LR', () => {
|
|
61
|
-
const graph = layout(
|
|
62
|
-
`graph LR
|
|
63
|
-
subgraph Sub
|
|
64
|
-
A-->B
|
|
65
|
-
A-->C
|
|
66
|
-
end
|
|
67
|
-
X-->A
|
|
68
|
-
W-->Y
|
|
69
|
-
Y-->A`,
|
|
70
|
-
'LR',
|
|
71
|
-
)
|
|
72
|
-
expect(grid(graph, 'W').y).toBe(grid(graph, 'Y').y)
|
|
73
|
-
})
|
|
74
|
-
const frame = `
|
|
75
|
-
subgraph Sub
|
|
76
|
-
A-->B
|
|
77
|
-
A-->C
|
|
78
|
-
end`
|
|
79
|
-
|
|
80
|
-
it('leaves a parent with two children where it is', () => {
|
|
81
|
-
const base = layout(`graph TD${frame}\nX-->A\nW-->Y\nW-->Q\nY-->A`, 'TD')
|
|
82
|
-
expect(grid(base, 'W').x).not.toBe(grid(base, 'Y').x)
|
|
83
|
-
})
|
|
84
|
-
|
|
85
|
-
it('leaves a parent that has its own incoming edge where it is', () => {
|
|
86
|
-
const g = layout(`graph TD${frame}\nX-->A\nR-->W\nW-->Y\nY-->A`, 'TD')
|
|
87
|
-
expect(g.nodes.every((n) => n.gridCoord)).toBe(true)
|
|
88
|
-
})
|
|
89
|
-
|
|
90
|
-
it('does not move anything when Y has two parents', () => {
|
|
91
|
-
const g = layout(`graph TD${frame}\nX-->A\nW-->Y\nV-->Y\nY-->A`, 'TD')
|
|
92
|
-
// Longest-path layering ranks A below Y, so Y no longer lands in the
|
|
93
|
-
// frame's span and nothing is moved: both parents stay on the root row
|
|
94
|
-
// in their own columns.
|
|
95
|
-
expect(grid(g, 'W').y).toBe(grid(g, 'V').y)
|
|
96
|
-
expect(grid(g, 'W').x).not.toBe(grid(g, 'V').x)
|
|
97
|
-
expect(grid(g, 'Y').y).toBeGreaterThan(grid(g, 'W').y)
|
|
98
|
-
})
|
|
99
|
-
|
|
100
|
-
it('leaves a parent that is itself in a subgraph alone', () => {
|
|
101
|
-
const g = layout(
|
|
102
|
-
`graph TD${frame}\nX-->A\nsubgraph Other\nW\nend\nW-->Y\nY-->A`,
|
|
103
|
-
'TD',
|
|
104
|
-
)
|
|
105
|
-
expect(g.nodes.every((n) => n.gridCoord)).toBe(true)
|
|
106
|
-
})
|
|
107
|
-
|
|
108
|
-
it('leaves a parent that is already aligned in place', () => {
|
|
109
|
-
const g = layout(`graph TD${frame}\nX-->A\nZ\nW-->Y\nY-->A`, 'TD')
|
|
110
|
-
expect(grid(g, 'W').x).toBe(grid(g, 'Y').x)
|
|
111
|
-
expect(grid(g, 'W').y).toBeLessThan(grid(g, 'Y').y)
|
|
112
|
-
})
|
|
113
|
-
})
|