@zombie-mermaid/core 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,34 @@
1
+ // ============================================================================
2
+ // @zombie-mermaid/core — the module set both renderers import
3
+ //
4
+ // Every file re-exported below was grep-verified (zombie-mermaid#625,
5
+ // umbrella #620) as reachable from BOTH front doors — `src/index.ts` (SVG)
6
+ // and `src/ascii/index.ts` (ASCII) — either directly or through
7
+ // `src/parser.ts`, which both call. Nothing here imports anything outside
8
+ // this package, so `core` is a sink in the workspace graph: the ASCII
9
+ // renderer can depend on it without dragging in `elkjs` or any SVG
10
+ // emission code.
11
+ //
12
+ // The one external reference is a type-only `import type { ElkNode }` in
13
+ // `types.ts` (for `RenderOptions.layoutCache`), erased before bundling.
14
+ //
15
+ // A barrel rather than per-file subpath exports: `#620`'s recommendation 4
16
+ // has the umbrella's three entries eventually become thin re-exports of
17
+ // real packages, which needs a real package API. `sideEffects: false` plus
18
+ // this package's total absence of module-level side effects is what keeps
19
+ // the barrel from widening any bundle — verified against the pre-split
20
+ // `dist/` byte-for-byte.
21
+ // ============================================================================
22
+
23
+ export * from './click-directive.ts'
24
+ export * from './color-utils.ts'
25
+ export * from './diagram-type.ts'
26
+ export * from './direction.ts'
27
+ export * from './direction-override.ts'
28
+ export * from './init-directive.ts'
29
+ export * from './multiline-utils.ts'
30
+ export * from './statements.ts'
31
+ export * from './style-directives.ts'
32
+ export * from './text-metrics.ts'
33
+ export * from './theme.ts'
34
+ export * from './types.ts'
@@ -0,0 +1,248 @@
1
+ // ============================================================================
2
+ // zombie-mermaid — %%{init: ...}%% configuration directives
3
+ //
4
+ // Mermaid lets a diagram carry its own configuration inline:
5
+ //
6
+ // %%{init: {"theme": "dark", "flowchart": {"curve": "basis"}}}%%
7
+ // flowchart TD
8
+ // A --> B
9
+ //
10
+ // The payload is JSON-ish: Mermaid accepts unquoted keys and single quotes,
11
+ // which strict JSON.parse rejects, so it is normalized before parsing.
12
+ //
13
+ // A directive only ever *supplies defaults*. Explicit render options passed
14
+ // by the caller win, because the caller is closer to the user's intent than
15
+ // text embedded in a diagram — and because a diagram from an untrusted source
16
+ // should not be able to override the host application's rendering choices.
17
+ // ============================================================================
18
+
19
+ import type { RenderOptions } from './types.ts'
20
+
21
+ /** How an edge path is interpolated between its routed points. */
22
+ export type CurveStyle =
23
+ 'linear' | 'basis' | 'natural' | 'step' | 'stepBefore' | 'stepAfter'
24
+
25
+ const CURVE_STYLES = new Set<CurveStyle>([
26
+ 'linear',
27
+ 'basis',
28
+ 'natural',
29
+ 'step',
30
+ 'stepBefore',
31
+ 'stepAfter',
32
+ ])
33
+
34
+ /** Configuration a diagram can set for itself via `%%{init: ...}%%`. */
35
+ export interface InitConfig {
36
+ theme?: string
37
+ /** Flowchart edge interpolation. */
38
+ curve?: CurveStyle
39
+ /** Recognized but not acted on — see `IGNORED_KEYS`. */
40
+ ignored: string[]
41
+ }
42
+
43
+ /**
44
+ * Directive keys that are parsed and deliberately not acted on, with the
45
+ * reason. Reported on the result so a caller can surface them rather than
46
+ * having the setting vanish silently.
47
+ */
48
+ const IGNORED_KEYS: Record<string, string> = {
49
+ theme:
50
+ "colors come from the caller's bg/fg render options, which are usually CSS variables so a diagram inherits the host page's light/dark. A diagram-supplied theme would hard-code colors that fight it. Mermaid's theme names (default/dark/forest/neutral) also have no equivalent in this renderer's palettes — pass `bg`/`fg`, or a THEMES entry, instead",
51
+ securitylevel:
52
+ 'this renderer emits static SVG and never executes diagram-supplied script, so there is no sandbox to configure',
53
+ defaultrenderer:
54
+ 'ELK is the only layout engine; dagre/elk selection has no effect',
55
+ fontfamily: 'use the `font` render option instead',
56
+ htmllabels:
57
+ 'labels are always rendered as SVG text; there is no HTML label mode',
58
+ maxtextsize: 'no text-size limit is enforced',
59
+ startonload: 'not a browser auto-render integration',
60
+ }
61
+
62
+ /**
63
+ * Matches a `%%{init: ... }%%` or `%%{initialize: ... }%%` directive.
64
+ *
65
+ * The body is captured lazily up to the closing `}%%` so a directive sharing
66
+ * a line with other content does not swallow it.
67
+ */
68
+ const INIT_DIRECTIVE = /^\s*%%\{\s*(?:init|initialize)\s*:\s*([\s\S]*?)\}%%/i
69
+
70
+ /** True if `line` is an init directive. */
71
+ export function isInitDirective(line: string): boolean {
72
+ return INIT_DIRECTIVE.test(line)
73
+ }
74
+
75
+ /**
76
+ * Turn Mermaid's relaxed JSON into strict JSON.
77
+ *
78
+ * Mermaid accepts `{theme: 'dark'}`; JSON.parse does not. Quoting is applied
79
+ * only outside string literals so a value containing a colon, a brace, or an
80
+ * apostrophe survives intact.
81
+ */
82
+ function normalizeRelaxedJson(text: string): string {
83
+ let out = ''
84
+ let quote: '"' | "'" | null = null
85
+
86
+ for (let i = 0; i < text.length; i++) {
87
+ const ch = text[i]!
88
+
89
+ if (quote !== null) {
90
+ if (ch === '\\' && i + 1 < text.length) {
91
+ /*
92
+ * Consume the escape and its target together. Without this, the
93
+ * backslash is emitted bare and the quote it protects reads as the
94
+ * end of the string — so strict, valid JSON like {"theme": "a\"b"}
95
+ * re-emits unbalanced and the whole directive is dropped.
96
+ *
97
+ * JSON has no \' escape, so a single-quoted string's \' becomes a
98
+ * bare apostrophe. Every other escape passes through untouched.
99
+ */
100
+ const next = text[i + 1]!
101
+ out += next === "'" ? "'" : `\\${next}`
102
+ i++
103
+ } else if (ch === quote) {
104
+ quote = null
105
+ out += '"'
106
+ } else if (ch === '"') {
107
+ // A double quote inside a single-quoted string must be escaped once
108
+ // the string is re-emitted with double quotes.
109
+ out += '\\"'
110
+ } else {
111
+ out += ch
112
+ }
113
+ continue
114
+ }
115
+
116
+ if (ch === '"' || ch === "'") {
117
+ quote = ch
118
+ out += '"'
119
+ continue
120
+ }
121
+
122
+ // Bare key: an identifier run followed (after spaces) by a colon.
123
+ if (/[A-Za-z_$]/.test(ch)) {
124
+ let j = i
125
+ while (j < text.length && /[\w$-]/.test(text[j]!)) j++
126
+ const word = text.slice(i, j)
127
+ let k = j
128
+ while (k < text.length && /\s/.test(text[k]!)) k++
129
+
130
+ if (text[k] === ':') {
131
+ out += `"${word}"`
132
+ i = j - 1
133
+ continue
134
+ }
135
+
136
+ // A bare word in value position — `true`, `false`, `null` are already
137
+ // valid JSON; anything else is quoted so it parses as a string.
138
+ out += /^(true|false|null)$/i.test(word)
139
+ ? word.toLowerCase()
140
+ : `"${word}"`
141
+ i = j - 1
142
+ continue
143
+ }
144
+
145
+ out += ch
146
+ }
147
+
148
+ return out
149
+ }
150
+
151
+ /**
152
+ * Parse an init directive's payload.
153
+ *
154
+ * Returns `undefined` when the line is not a directive or its payload cannot
155
+ * be parsed. A malformed directive is ignored rather than fatal: Mermaid
156
+ * treats config as advisory, and failing an entire diagram over a stray brace
157
+ * in a comment-like construct would be a poor trade.
158
+ */
159
+ export function parseInitDirective(line: string): InitConfig | undefined {
160
+ const match = line.match(INIT_DIRECTIVE)
161
+ if (!match) return undefined
162
+
163
+ let payload: unknown
164
+ try {
165
+ payload = JSON.parse(normalizeRelaxedJson(match[1]!))
166
+ } catch {
167
+ return undefined
168
+ }
169
+
170
+ if (typeof payload !== 'object' || payload === null) return undefined
171
+
172
+ const config: InitConfig = { ignored: [] }
173
+ const record: Record<string, unknown> = { ...payload }
174
+
175
+ for (const [key, value] of Object.entries(record)) {
176
+ const lower = key.toLowerCase()
177
+
178
+ if (lower === 'theme' && typeof value === 'string') {
179
+ /*
180
+ * Kept on the result as metadata for a caller that wants to act on it,
181
+ * but listed as ignored: it is parsed and deliberately not applied. See
182
+ * IGNORED_KEYS for why.
183
+ */
184
+ config.theme = value
185
+ config.ignored.push(key)
186
+ continue
187
+ }
188
+
189
+ if (lower === 'flowchart' && typeof value === 'object' && value !== null) {
190
+ const flowchart: Record<string, unknown> = { ...value }
191
+ for (const [fk, fv] of Object.entries(flowchart)) {
192
+ if (fk.toLowerCase() === 'curve' && typeof fv === 'string') {
193
+ if (CURVE_STYLES.has(fv as CurveStyle)) {
194
+ config.curve = fv as CurveStyle
195
+ }
196
+ continue
197
+ }
198
+ if (fk.toLowerCase() in IGNORED_KEYS) config.ignored.push(fk)
199
+ }
200
+ continue
201
+ }
202
+
203
+ if (lower in IGNORED_KEYS) config.ignored.push(key)
204
+ }
205
+
206
+ return config
207
+ }
208
+
209
+ /**
210
+ * Scan a whole diagram for init directives and merge them.
211
+ *
212
+ * Later directives win over earlier ones, matching Mermaid, where a second
213
+ * directive overrides the first.
214
+ */
215
+ export function extractInitConfig(lines: string[]): InitConfig {
216
+ const merged: InitConfig = { ignored: [] }
217
+
218
+ for (const line of lines) {
219
+ const config = parseInitDirective(line)
220
+ if (!config) continue
221
+ if (config.theme !== undefined) merged.theme = config.theme
222
+ if (config.curve !== undefined) merged.curve = config.curve
223
+ merged.ignored.push(...config.ignored)
224
+ }
225
+
226
+ return merged
227
+ }
228
+
229
+ /**
230
+ * Fold an init config into render options.
231
+ *
232
+ * Caller-supplied options always win: a directive supplies a default, never
233
+ * an override. See the module header for why.
234
+ */
235
+ export function applyInitConfig(
236
+ options: RenderOptions,
237
+ config: InitConfig,
238
+ ): RenderOptions {
239
+ if (config.curve === undefined || options.curve !== undefined) return options
240
+ return { ...options, curve: config.curve }
241
+ }
242
+
243
+ /** Human-readable note about directive keys that were parsed but not applied. */
244
+ export function describeIgnored(config: InitConfig): string[] {
245
+ return [...new Set(config.ignored)].map(
246
+ (key) => `${key}: ${IGNORED_KEYS[key.toLowerCase()] ?? 'not supported'}`,
247
+ )
248
+ }
@@ -0,0 +1,275 @@
1
+ // ============================================================================
2
+ // Multi-line Text Rendering Utilities
3
+ //
4
+ // Shared utilities for rendering multi-line text in SVG using <tspan> elements.
5
+ // Supports inline formatting: <b>, <i>, <u>, <s> mapped to SVG attributes.
6
+ // Used across all diagram types (flowcharts, state, sequence, class, ER).
7
+ // ============================================================================
8
+
9
+ import { LINE_HEIGHT_RATIO } from './text-metrics.ts'
10
+
11
+ /**
12
+ * Normalize label text: strip surrounding quotes, convert <br> tags and
13
+ * literal \n sequences to newline characters. Strips unsupported HTML tags
14
+ * but preserves formatting tags (<b>, <i>, <u>, <s>) for SVG rendering.
15
+ */
16
+ export function normalizeBrTags(label: string): string {
17
+ // Strip surrounding double quotes (Mermaid uses them for special chars in labels)
18
+ const unquoted =
19
+ label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label
20
+
21
+ /*
22
+ * Mermaid's markdown-string form wraps the label in backticks —
23
+ * `A["` + '`**bold**`' + `"]`. The markdown conversion below already runs
24
+ * unconditionally, so the backticks only need removing; left in place they
25
+ * rendered as literal characters in the label.
26
+ */
27
+ const unfenced =
28
+ unquoted.length >= 2 && unquoted.startsWith('`') && unquoted.endsWith('`')
29
+ ? unquoted.slice(1, -1)
30
+ : unquoted
31
+
32
+ return (
33
+ unfenced
34
+ .replace(/<br\s*\/?>/gi, '\n')
35
+ .replace(/\\n/g, '\n')
36
+ .replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, '')
37
+ // Markdown formatting → HTML tags (order matters: ** before *)
38
+ .replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
39
+ .replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g, '<i>$1</i>')
40
+ .replace(/~~(.+?)~~/g, '<s>$1</s>')
41
+ )
42
+ }
43
+
44
+ /**
45
+ * Strip all inline formatting tags from text, keeping only plain text.
46
+ * Used for text measurement where tag characters shouldn't affect width.
47
+ */
48
+ export function stripFormattingTags(text: string): string {
49
+ return text.replace(/<\/?(?:b|strong|i|em|u|s|del)\s*>/gi, '')
50
+ }
51
+
52
+ /**
53
+ * Escape special XML characters in text content.
54
+ */
55
+ export function escapeXml(text: string): string {
56
+ return text
57
+ .replace(/&/g, '&amp;')
58
+ .replace(/</g, '&lt;')
59
+ .replace(/>/g, '&gt;')
60
+ .replace(/"/g, '&quot;')
61
+ .replace(/'/g, '&#39;')
62
+ }
63
+
64
+ /**
65
+ * Escape a string for use as an XML/HTML attribute value.
66
+ * Escapes quotes and ampersands to prevent attribute injection.
67
+ *
68
+ * Deliberately distinct from `escapeXml()` above: attribute values in this
69
+ * codebase are always double-quoted, so single quotes don't need escaping —
70
+ * `escapeXml()` escapes them too (for safe embedding in either quote style),
71
+ * which would change output if reused here.
72
+ */
73
+ export function escapeAttr(value: string): string {
74
+ return value
75
+ .replace(/&/g, '&amp;')
76
+ .replace(/"/g, '&quot;')
77
+ .replace(/</g, '&lt;')
78
+ .replace(/>/g, '&gt;')
79
+ }
80
+
81
+ // ============================================================================
82
+ // Inline formatting: <b>, <i>, <u>, <s> → SVG tspan attributes
83
+ // ============================================================================
84
+
85
+ interface StyledSegment {
86
+ text: string
87
+ bold: boolean
88
+ italic: boolean
89
+ underline: boolean
90
+ strikethrough: boolean
91
+ }
92
+
93
+ /** Regex to match opening/closing formatting tags */
94
+ const FORMAT_TAG_REGEX = /<(\/)?(?:(b|strong)|(i|em)|(u)|(s|del))\s*>/gi
95
+
96
+ /**
97
+ * Parse a line of text into styled segments based on inline formatting tags.
98
+ * Supports nesting: `<b>bold <i>both</i> bold</b>`.
99
+ */
100
+ function parseInlineFormatting(line: string): StyledSegment[] {
101
+ const segments: StyledSegment[] = []
102
+ let bold = false,
103
+ italic = false,
104
+ underline = false,
105
+ strikethrough = false
106
+ let lastIndex = 0
107
+
108
+ // Reset lastIndex for global regex
109
+ FORMAT_TAG_REGEX.lastIndex = 0
110
+
111
+ let match: RegExpExecArray | null
112
+ while ((match = FORMAT_TAG_REGEX.exec(line)) !== null) {
113
+ // Capture text before this tag
114
+ if (match.index > lastIndex) {
115
+ segments.push({
116
+ text: line.slice(lastIndex, match.index),
117
+ bold,
118
+ italic,
119
+ underline,
120
+ strikethrough,
121
+ })
122
+ }
123
+ lastIndex = match.index + match[0].length
124
+
125
+ const isClosing = Boolean(match[1])
126
+ // match[2] = b|strong, match[3] = i|em, match[4] = u, match[5] = s|del
127
+ if (match[2]) bold = !isClosing
128
+ else if (match[3]) italic = !isClosing
129
+ else if (match[4]) underline = !isClosing
130
+ else if (match[5]) strikethrough = !isClosing
131
+ }
132
+
133
+ // Remaining text after last tag
134
+ if (lastIndex < line.length) {
135
+ segments.push({
136
+ text: line.slice(lastIndex),
137
+ bold,
138
+ italic,
139
+ underline,
140
+ strikethrough,
141
+ })
142
+ }
143
+
144
+ return segments
145
+ }
146
+
147
+ /** Check if a line contains any formatting tags */
148
+ const HAS_FORMAT_TAGS = /<\/?(?:b|strong|i|em|u|s|del)\s*>/i
149
+
150
+ /**
151
+ * Render a line's content as SVG, with inline formatting applied as tspan attributes.
152
+ * Returns raw SVG content (no wrapping tspan — caller provides positioning).
153
+ */
154
+ function renderLineContent(line: string): string {
155
+ // Fast path: no formatting tags
156
+ if (!HAS_FORMAT_TAGS.test(line)) return escapeXml(line)
157
+
158
+ const segments = parseInlineFormatting(line)
159
+ if (segments.length === 0) return ''
160
+
161
+ // If all segments are unstyled, just escape
162
+ const allPlain = segments.every(
163
+ (s) => !s.bold && !s.italic && !s.underline && !s.strikethrough,
164
+ )
165
+ if (allPlain) return segments.map((s) => escapeXml(s.text)).join('')
166
+
167
+ return segments
168
+ .map((seg) => {
169
+ const escaped = escapeXml(seg.text)
170
+ if (!seg.bold && !seg.italic && !seg.underline && !seg.strikethrough)
171
+ return escaped
172
+
173
+ const attrs: string[] = []
174
+ if (seg.bold) attrs.push('font-weight="bold"')
175
+ if (seg.italic) attrs.push('font-style="italic"')
176
+ // SVG text-decoration can combine values
177
+ const deco: string[] = []
178
+ if (seg.underline) deco.push('underline')
179
+ if (seg.strikethrough) deco.push('line-through')
180
+ if (deco.length) attrs.push(`text-decoration="${deco.join(' ')}"`)
181
+
182
+ return `<tspan ${attrs.join(' ')}>${escaped}</tspan>`
183
+ })
184
+ .join('')
185
+ }
186
+
187
+ // ============================================================================
188
+ // Multi-line text rendering
189
+ // ============================================================================
190
+
191
+ /**
192
+ * Render a multi-line text element with proper vertical centering.
193
+ *
194
+ * For single-line text, returns a simple <text> element.
195
+ * For multi-line text (containing \n), returns <text> with <tspan> children.
196
+ * Inline formatting tags (<b>, <i>, <u>, <s>) are rendered as SVG attributes.
197
+ *
198
+ * @param text - The text to render (may contain \n and formatting tags)
199
+ * @param cx - Center x coordinate
200
+ * @param cy - Center y coordinate
201
+ * @param fontSize - Font size in pixels
202
+ * @param attrs - Additional SVG attributes (e.g., 'text-anchor="middle" fill="var(--_text)"')
203
+ * @param baselineShift - Baseline shift for vertical alignment (default 0.35)
204
+ * @returns SVG text element string
205
+ */
206
+ export function renderMultilineText(
207
+ text: string,
208
+ cx: number,
209
+ cy: number,
210
+ fontSize: number,
211
+ attrs: string,
212
+ baselineShift: number = 0.35,
213
+ ): string {
214
+ const lines = text.split('\n')
215
+
216
+ // Single line — simple text element
217
+ if (lines.length === 1) {
218
+ const dy = fontSize * baselineShift
219
+ return `<text x="${cx}" y="${cy}" ${attrs} dy="${dy}">${renderLineContent(text)}</text>`
220
+ }
221
+
222
+ // Multi-line — use tspan elements with vertical centering
223
+ const lineHeight = fontSize * LINE_HEIGHT_RATIO
224
+ // First line dy: shift up by (n-1)/2 line heights, then add baseline shift
225
+ const firstDy =
226
+ -((lines.length - 1) / 2) * lineHeight + fontSize * baselineShift
227
+
228
+ const tspans = lines
229
+ .map((line, i) => {
230
+ const dy = i === 0 ? firstDy : lineHeight
231
+ return `<tspan x="${cx}" dy="${dy}">${renderLineContent(line)}</tspan>`
232
+ })
233
+ .join('')
234
+
235
+ return `<text x="${cx}" y="${cy}" ${attrs}>${tspans}</text>`
236
+ }
237
+
238
+ /**
239
+ * Render a multi-line text element with a background rectangle (pill).
240
+ *
241
+ * Used for edge labels that need a background for readability.
242
+ *
243
+ * @param text - The text to render (may contain \n)
244
+ * @param cx - Center x coordinate
245
+ * @param cy - Center y coordinate
246
+ * @param textWidth - Pre-calculated text width (max line width)
247
+ * @param textHeight - Pre-calculated text height (lines × lineHeight)
248
+ * @param fontSize - Font size in pixels
249
+ * @param padding - Padding around text
250
+ * @param textAttrs - SVG attributes for the text element
251
+ * @param bgAttrs - SVG attributes for the background rect
252
+ * @returns SVG elements string (rect + text)
253
+ */
254
+ export function renderMultilineTextWithBackground(
255
+ text: string,
256
+ cx: number,
257
+ cy: number,
258
+ textWidth: number,
259
+ textHeight: number,
260
+ fontSize: number,
261
+ padding: number,
262
+ textAttrs: string,
263
+ bgAttrs: string,
264
+ ): string {
265
+ const bgWidth = textWidth + padding * 2
266
+ const bgHeight = textHeight + padding * 2
267
+
268
+ const rect =
269
+ `<rect x="${cx - bgWidth / 2}" y="${cy - bgHeight / 2}" ` +
270
+ `width="${bgWidth}" height="${bgHeight}" ${bgAttrs} />`
271
+
272
+ const textEl = renderMultilineText(text, cx, cy, fontSize, textAttrs)
273
+
274
+ return `${rect}\n${textEl}`
275
+ }