@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.
@@ -0,0 +1,197 @@
1
+ // ============================================================================
2
+ // zombie-mermaid — shared statement splitting
3
+ //
4
+ // Every parser entry point (flowchart/state in src/parser.ts, the four
5
+ // specialized parsers dispatched from src/index.ts and src/ascii/index.ts)
6
+ // needs the same thing: turn raw Mermaid source into a list of trimmed,
7
+ // comment-free statements. They each used to do it inline with
8
+ // `.split('\n').map(trim).filter(...)`, which meant semicolon separators —
9
+ // valid Mermaid, and the form `detectDiagramType` already assumed when
10
+ // isolating the header — were understood by nobody.
11
+ //
12
+ // This module is the single source of truth for "what counts as one
13
+ // statement".
14
+ // ============================================================================
15
+
16
+ /**
17
+ * Matches a character reference in either spelling Mermaid accepts.
18
+ *
19
+ * HTML/XML form: `&`, `#`, `😀`.
20
+ * Mermaid's own form, which uses `#` where HTML uses `&`: `#59;`, `#9829;`,
21
+ * `#quot;` — the documented way to put a literal semicolon, quote, or symbol
22
+ * into a label.
23
+ *
24
+ * Both end in a semicolon that belongs to the reference, not to statement
25
+ * separation. Splitting one apart corrupts the label *and* invents a
26
+ * statement out of the remainder.
27
+ */
28
+ const ENTITY_AT = /^[&#](?:#?[0-9]+|#?[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*);/
29
+
30
+ /** Characters that can open a character reference. */
31
+ const ENTITY_STARTERS = new Set(['&', '#'])
32
+
33
+ /**
34
+ * Index of the `%%` that starts a comment on this line, or -1.
35
+ *
36
+ * A Mermaid comment runs to the end of the line, so everything after it —
37
+ * including any further `;`-separated fragments — is commented out. Scanning
38
+ * for it before splitting is what makes that true: dropping fragments that
39
+ * merely *begin* with `%%` afterwards leaves `graph TD; A-->B; %% note; C-->D`
40
+ * still parsing `C-->D` as code.
41
+ *
42
+ * Quote- and reference-aware for the same reasons the splitter is, so a `%%`
43
+ * inside a label is text rather than a comment marker.
44
+ */
45
+ function commentStart(line: string): number {
46
+ let quote: '"' | "'" | null = null
47
+
48
+ for (let i = 0; i < line.length; i++) {
49
+ const ch = line[i]!
50
+
51
+ if (quote !== null) {
52
+ if (ch === quote) quote = null
53
+ continue
54
+ }
55
+ if (ch === '"' || ch === "'") {
56
+ quote = ch
57
+ continue
58
+ }
59
+ if (ENTITY_STARTERS.has(ch)) {
60
+ const entity = ENTITY_AT.exec(line.slice(i))
61
+ if (entity) {
62
+ i += entity[0].length - 1
63
+ continue
64
+ }
65
+ }
66
+ if (ch === '%' && line[i + 1] === '%') return i
67
+ }
68
+
69
+ return -1
70
+ }
71
+
72
+ /**
73
+ * Split one already-newline-separated line on its statement-separating
74
+ * semicolons.
75
+ *
76
+ * A semicolon separates statements unless it is:
77
+ * - inside a quoted string (`A["a; b"]`), or
78
+ * - the terminator of a character reference (`A[&amp;]`).
79
+ *
80
+ * Quote tracking is deliberately simple — Mermaid has no escape sequence
81
+ * inside quoted labels, so a quote character always opens or closes a span.
82
+ */
83
+ function splitOnSemicolons(line: string): string[] {
84
+ const parts: string[] = []
85
+ let current = ''
86
+ let quote: '"' | "'" | null = null
87
+
88
+ for (let i = 0; i < line.length; i++) {
89
+ const ch = line[i]!
90
+
91
+ if (quote !== null) {
92
+ current += ch
93
+ if (ch === quote) quote = null
94
+ continue
95
+ }
96
+
97
+ if (ch === '"' || ch === "'") {
98
+ quote = ch
99
+ current += ch
100
+ continue
101
+ }
102
+
103
+ if (ENTITY_STARTERS.has(ch)) {
104
+ // Copy a whole character reference in one go so its trailing ';' can't
105
+ // be read as a separator. Guarded on '#' as well as '&': Mermaid's own
106
+ // `#59;` spelling is far more common in diagrams than the HTML one.
107
+ const entity = ENTITY_AT.exec(line.slice(i))
108
+ if (entity) {
109
+ current += entity[0]
110
+ i += entity[0].length - 1
111
+ continue
112
+ }
113
+ }
114
+
115
+ if (ch === ';') {
116
+ parts.push(current)
117
+ current = ''
118
+ continue
119
+ }
120
+
121
+ current += ch
122
+ }
123
+
124
+ parts.push(current)
125
+ return parts
126
+ }
127
+
128
+ /**
129
+ * One statement produced by the splitter, paired with the 1-based physical
130
+ * source line it came from.
131
+ *
132
+ * `line` is captured while walking `text.split('\n')`, before blank lines
133
+ * and `%%` comment lines are dropped — so it survives being the original
134
+ * source line number even though a statement's array index no longer does.
135
+ * This is what lets every parser built on top of `splitStatements`/
136
+ * `splitStatementsByLine` report *where* an error is, not just quote back
137
+ * the offending statement's text (see issue #760).
138
+ */
139
+ export interface Statement {
140
+ text: string
141
+ line: number
142
+ }
143
+
144
+ /**
145
+ * Split Mermaid source into trimmed statements, grouped by the physical
146
+ * source line each one came from — dropping blank lines and `%%` comment
147
+ * lines.
148
+ *
149
+ * Newlines and semicolons both separate statements, matching Mermaid's own
150
+ * `graph TD; A-->B;` form. Comments are removed *before* semicolon splitting
151
+ * so that a `;` inside a comment can't resurrect the rest of that line as
152
+ * code.
153
+ *
154
+ * The grouping is what lets a caller tell a genuine multi-line continuation
155
+ * (crossing from one line's group into the next) apart from an explicit
156
+ * `;`-separated statement on the *same* line (a later entry within one
157
+ * group) — see `src/parser.ts`'s `mergeContinuationLines`, which only
158
+ * treats the former as mergeable.
159
+ */
160
+ export function splitStatementsByLine(text: string): Statement[][] {
161
+ const groups: Statement[][] = []
162
+ const rawLines = text.split('\n')
163
+
164
+ for (let lineIndex = 0; lineIndex < rawLines.length; lineIndex++) {
165
+ let line = rawLines[lineIndex]!.trim()
166
+ const lineNumber = lineIndex + 1
167
+
168
+ /*
169
+ * Cut the comment off first. A Mermaid comment runs to end of line, so
170
+ * anything after `%%` is commented out — including further `;`-separated
171
+ * fragments. Splitting first and discarding fragments that start with
172
+ * `%%` would leave `A-->B; %% note; C-->D` parsing `C-->D` as code.
173
+ */
174
+ const comment = commentStart(line)
175
+ if (comment !== -1) line = line.slice(0, comment).trim()
176
+ if (line.length === 0) continue
177
+
178
+ const statements: Statement[] = []
179
+ for (const part of splitOnSemicolons(line)) {
180
+ const statement = part.trim()
181
+ if (statement.length === 0) continue
182
+ statements.push({ text: statement, line: lineNumber })
183
+ }
184
+ if (statements.length > 0) groups.push(statements)
185
+ }
186
+
187
+ return groups
188
+ }
189
+
190
+ /**
191
+ * Split Mermaid source into trimmed statements, dropping blank lines and
192
+ * `%%` comment lines. See `splitStatementsByLine` for the line-grouped form
193
+ * this flattens.
194
+ */
195
+ export function splitStatements(text: string): Statement[] {
196
+ return splitStatementsByLine(text).flat()
197
+ }
@@ -0,0 +1,214 @@
1
+ // ============================================================================
2
+ // Style directives — `classDef`, `class A,B name`, `cssClass "A,B" name`,
3
+ // `style A ...`, and the `A:::name` shorthand.
4
+ //
5
+ // Mermaid's flowchart and class diagrams share one styling grammar
6
+ // (https://mermaid.js.org/syntax/flowchart.html#styling-and-classes,
7
+ // https://mermaid.js.org/syntax/classDiagram.html#styling). The flowchart
8
+ // parser grew the first implementation; this module is that implementation
9
+ // lifted out so the class-diagram parser can reuse it rather than copy it
10
+ // (issue #420). Both parsers feed a `StyleDirectives` bag, and both layouts
11
+ // resolve a node's final style through `resolveNodeStyle` with the same
12
+ // cascade — so a `classDef default` means the same thing in every diagram
13
+ // type that supports it.
14
+ // ============================================================================
15
+
16
+ /** The three maps a styling-aware diagram carries. `MermaidGraph` and
17
+ * `ClassDiagram` both satisfy this structurally. */
18
+ export interface StyleDirectives {
19
+ /** Maps class names to their style properties (from `classDef name prop:val`) */
20
+ classDefs: Map<string, Record<string, string>>
21
+ /** Maps node IDs to their assigned class name (from `class A,B name`, `cssClass "A,B" name`, or `A:::name`) */
22
+ classAssignments: Map<string, string>
23
+ /** Maps node IDs to inline styles (from `style X fill:#f00,stroke:#333`) */
24
+ nodeStyles: Map<string, Record<string, string>>
25
+ }
26
+
27
+ /** Parse "fill:#f00,stroke:#333" style property strings into a Record */
28
+ export function parseStyleProps(propsStr: string): Record<string, string> {
29
+ // Strip trailing semicolons — Mermaid tolerates them (e.g. `stroke:#f00;`)
30
+ const cleaned = propsStr.replace(/;\s*$/, '')
31
+ const props: Record<string, string> = {}
32
+ for (const pair of cleaned.split(',')) {
33
+ const colonIdx = pair.indexOf(':')
34
+ if (colonIdx > 0) {
35
+ const key = pair.slice(0, colonIdx).trim()
36
+ const val = pair.slice(colonIdx + 1).trim()
37
+ if (key && val) {
38
+ props[key] = val
39
+ }
40
+ }
41
+ }
42
+ return props
43
+ }
44
+
45
+ /**
46
+ * `classDef name prop:val,prop:val` — define a named style class.
47
+ *
48
+ * Mermaid's `classList` rule accepts a comma-separated list of names
49
+ * (`classDef a,b font-size:12pt`) in both the flowchart and class grammars;
50
+ * every name in the list gets the same properties.
51
+ *
52
+ * Returns true when `line` was a classDef statement (and has been applied).
53
+ */
54
+ export function tryApplyClassDef(
55
+ line: string,
56
+ target: StyleDirectives,
57
+ ): boolean {
58
+ const match = line.match(/^classDef\s+([\w,-]+)\s+(.+)$/)
59
+ if (!match) return false
60
+ const props = parseStyleProps(match[2]!)
61
+ for (const name of match[1]!.split(',')) {
62
+ const trimmed = name.trim()
63
+ if (trimmed) target.classDefs.set(trimmed, props)
64
+ }
65
+ return true
66
+ }
67
+
68
+ /**
69
+ * `class A,B className` — attach a style class to one or more nodes.
70
+ *
71
+ * Allows an optional trailing semicolon (`class A,B foo;`) — Mermaid treats
72
+ * it as valid/optional, and `classDef`/`style` already tolerate it via their
73
+ * `(.+)$` capture. Without this, the semicolon form fails to match here and
74
+ * falls through to node parsing, rendering a stray node labelled "class".
75
+ *
76
+ * Returns true when `line` was a class-assignment statement.
77
+ */
78
+ export function tryApplyClassAssignment(
79
+ line: string,
80
+ target: StyleDirectives,
81
+ ): boolean {
82
+ const match = line.match(/^class\s+([\w,-]+)\s+([\w-]+)\s*;?\s*$/)
83
+ if (!match) return false
84
+ const className = match[2]!
85
+ for (const id of match[1]!.split(',')) {
86
+ target.classAssignments.set(id.trim(), className)
87
+ }
88
+ return true
89
+ }
90
+
91
+ /**
92
+ * `cssClass "A,B" className` — the class-diagram grammar's own attachment
93
+ * form (`cssClassStatement: CSSCLASS STR ALPHA` in classDiagram.jison).
94
+ * Same effect as `class A,B className`.
95
+ *
96
+ * Returns true when `line` was a cssClass statement.
97
+ */
98
+ export function tryApplyCssClass(
99
+ line: string,
100
+ target: StyleDirectives,
101
+ ): boolean {
102
+ const match = line.match(/^cssClass\s+"([^"]*)"\s+([\w-]+)\s*;?\s*$/)
103
+ if (!match) return false
104
+ const className = match[2]!
105
+ for (const id of match[1]!.split(',')) {
106
+ const trimmed = id.trim()
107
+ if (trimmed) target.classAssignments.set(trimmed, className)
108
+ }
109
+ return true
110
+ }
111
+
112
+ /**
113
+ * `style A,B fill:#f00,stroke:#333` — inline style on specific nodes.
114
+ * Repeated `style` statements for the same node merge property by property.
115
+ *
116
+ * Returns true when `line` was a style statement.
117
+ */
118
+ export function tryApplyStyleStatement(
119
+ line: string,
120
+ target: StyleDirectives,
121
+ ): boolean {
122
+ const match = line.match(/^style\s+([\w,-]+)\s+(.+)$/)
123
+ if (!match) return false
124
+ const props = parseStyleProps(match[2]!)
125
+ for (const id of match[1]!.split(',').map((s) => s.trim())) {
126
+ target.nodeStyles.set(id, { ...target.nodeStyles.get(id), ...props })
127
+ }
128
+ return true
129
+ }
130
+
131
+ /**
132
+ * Split the `:::className` shorthand off a node/class identifier.
133
+ *
134
+ * `Animal:::someclass` → `{ id: 'Animal', className: 'someclass' }`;
135
+ * an identifier without the shorthand comes back unchanged with no class.
136
+ * Class names follow CSS identifier conventions (word characters and
137
+ * hyphens), the same constraint the flowchart parser's CLASS_SHORTHAND_REGEX
138
+ * applies.
139
+ */
140
+ export function splitClassShorthand(identifier: string): {
141
+ id: string
142
+ className?: string
143
+ } {
144
+ const match = identifier.match(/^(.+?):::([\w][\w-]*)$/)
145
+ if (!match) return { id: identifier }
146
+ return { id: match[1]!, className: match[2]! }
147
+ }
148
+
149
+ /**
150
+ * Resolve the final inline style for a node from classDefs and nodeStyles.
151
+ *
152
+ * Cascade, weakest to strongest:
153
+ * 1. `classDef default` — Mermaid's implicit base for every node
154
+ * 2. the node's own assigned class (`class A foo` / `A:::foo`)
155
+ * 3. an explicit `style A ...` directive
156
+ *
157
+ * Returns undefined when nothing in the cascade applies, so callers can fall
158
+ * back to theme defaults without an empty-object check.
159
+ */
160
+ export function resolveNodeStyle(
161
+ nodeId: string,
162
+ directives: StyleDirectives,
163
+ ): Record<string, string> | undefined {
164
+ let result: Record<string, string> | undefined
165
+
166
+ /*
167
+ * `classDef default` applies to every node without being assigned. It was
168
+ * previously honored only when a node named it explicitly (`class X
169
+ * default`), which silently diverges from Mermaid: a diagram styling all
170
+ * its nodes via `classDef default` rendered unstyled with no error.
171
+ */
172
+ const defaultDef = directives.classDefs.get('default')
173
+ if (defaultDef) {
174
+ result = { ...defaultDef }
175
+ }
176
+
177
+ // Then the node's own class, overriding the default property by property.
178
+ const className = directives.classAssignments.get(nodeId)
179
+ if (className) {
180
+ const classDef = directives.classDefs.get(className)
181
+ if (classDef) {
182
+ result = result ? { ...result, ...classDef } : { ...classDef }
183
+ }
184
+ }
185
+
186
+ // Then, apply explicit style directives (override class styles)
187
+ const nodeStyle = directives.nodeStyles.get(nodeId)
188
+ if (nodeStyle) {
189
+ result = result ? { ...result, ...nodeStyle } : { ...nodeStyle }
190
+ }
191
+
192
+ return result
193
+ }
194
+
195
+ /**
196
+ * Validate a user-authored class name (from `:::className` or
197
+ * `class A className`) before it's emitted into the SVG `class` attribute.
198
+ *
199
+ * The parsers already constrain class names to word characters and hyphens
200
+ * (see `splitClassShorthand` above and the flowchart parser's
201
+ * CLASS_SHORTHAND_REGEX), so this is a defense-in-depth allowlist rather
202
+ * than an escaping step — a class name can't be made "safe" by escaping
203
+ * since any character other than a valid CSS identifier character would
204
+ * break the class token itself, not just the surrounding attribute quotes.
205
+ * Anything that doesn't match a valid CSS identifier (letters, digits,
206
+ * underscore, hyphen; not starting with a digit or a hyphen+digit) is
207
+ * dropped rather than emitted.
208
+ */
209
+ export function sanitizeClassName(
210
+ className: string | undefined,
211
+ ): string | undefined {
212
+ if (!className) return undefined
213
+ return /^-?[a-zA-Z_][a-zA-Z0-9_-]*$/.test(className) ? className : undefined
214
+ }