@zombie-mermaid/mermaid-parser 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,166 @@
1
+ // ============================================================================
2
+ // Class diagram types
3
+ //
4
+ // Models the parsed and positioned representations of a Mermaid class diagram.
5
+ // Class diagrams show UML class relationships, inheritance, composition, etc.
6
+ // ============================================================================
7
+
8
+ import type { NodeInteraction, StyleDirectives } from '@zombie-mermaid/core'
9
+
10
+ /**
11
+ * Parsed class diagram — logical structure from mermaid text.
12
+ *
13
+ * Extends {@link StyleDirectives} so `classDef` / `cssClass` / `style` /
14
+ * `:::` resolve through the same cascade flowcharts use (see
15
+ * packages/core/src/style-directives.ts).
16
+ */
17
+ export interface ClassDiagram extends StyleDirectives {
18
+ /** All class definitions */
19
+ classes: ClassNode[]
20
+ /** Relationships between classes */
21
+ relationships: ClassRelationship[]
22
+ /** Optional namespace groupings */
23
+ namespaces: ClassNamespace[]
24
+ /** Maps class IDs to interactions declared by `click` statements */
25
+ interactions: Map<string, NodeInteraction>
26
+ /** Notes, in source order — `note "text"` and `note for X "text"` */
27
+ notes: ClassNote[]
28
+ }
29
+
30
+ /**
31
+ * A class-diagram note. Mermaid lays an attached note out as its own node
32
+ * joined to the class by a dotted, arrowless link (classDb.getData); a free
33
+ * note is a lone node.
34
+ */
35
+ export interface ClassNote {
36
+ /** Note text; `\n` / `<br/>` in the source are already normalized to newlines */
37
+ text: string
38
+ /** Class this note is attached to (`note for X`); absent for a free note */
39
+ forClass?: string
40
+ }
41
+
42
+ export interface ClassNode {
43
+ id: string
44
+ label: string
45
+ /** Annotation like <<interface>>, <<abstract>>, <<service>>, <<enumeration>> */
46
+ annotation?: string
47
+ /** Class attributes (fields/properties) */
48
+ attributes: ClassMember[]
49
+ /** Class methods (functions) */
50
+ methods: ClassMember[]
51
+ }
52
+
53
+ export interface ClassMember {
54
+ /** Visibility: + public, - private, # protected, ~ package */
55
+ visibility: '+' | '-' | '#' | '~' | ''
56
+ /** Member name */
57
+ name: string
58
+ /** Type annotation (e.g., "String", "int", "void") */
59
+ type?: string
60
+ /** Whether the member is static (underlined in UML) */
61
+ isStatic?: boolean
62
+ /** Whether the member is abstract (italic in UML) */
63
+ isAbstract?: boolean
64
+ /** Whether the member is a method (renders with parentheses) */
65
+ isMethod?: boolean
66
+ /** Method parameters (e.g., "data", "key, val") — only for methods */
67
+ params?: string
68
+ }
69
+
70
+ /** Relationship types following UML conventions */
71
+ export type RelationshipType =
72
+ | 'inheritance' // A <|-- B (solid line, hollow triangle)
73
+ | 'composition' // A *-- B (solid line, filled diamond)
74
+ | 'aggregation' // A o-- B (solid line, hollow diamond)
75
+ | 'association' // A --> B (solid line, open arrow)
76
+ | 'dependency' // A ..> B (dashed line, open arrow)
77
+ | 'realization' // A ..|> B (dashed line, hollow triangle)
78
+
79
+ export interface ClassRelationship {
80
+ from: string
81
+ to: string
82
+ type: RelationshipType
83
+ /**
84
+ * Which end of the relationship line has the UML marker (triangle, diamond, arrow).
85
+ * Determined by the arrow syntax direction:
86
+ * - Prefix markers like `<|--`, `*--`, `o--` → 'from' (marker on left/from side)
87
+ * - Suffix markers like `..|>`, `-->`, `..>`, `--*`, `--o` → 'to' (marker on right/to side)
88
+ */
89
+ markerAt: 'from' | 'to'
90
+ /** Label on the relationship line */
91
+ label?: string
92
+ /** Cardinality at the "from" end (e.g., "1", "*", "0..1") */
93
+ fromCardinality?: string
94
+ /** Cardinality at the "to" end */
95
+ toCardinality?: string
96
+ }
97
+
98
+ export interface ClassNamespace {
99
+ name: string
100
+ classIds: string[]
101
+ }
102
+
103
+ // ============================================================================
104
+ // Positioned class diagram — ready for SVG rendering
105
+ // ============================================================================
106
+
107
+ export interface PositionedClassDiagram {
108
+ width: number
109
+ height: number
110
+ classes: PositionedClassNode[]
111
+ relationships: PositionedClassRelationship[]
112
+ notes: PositionedClassNote[]
113
+ }
114
+
115
+ export interface PositionedClassNote {
116
+ /** Layout id — never collides with a class id (class ids contain no spaces) */
117
+ id: string
118
+ text: string
119
+ /** Class this note is attached to, when that class exists in the diagram */
120
+ forClass?: string
121
+ x: number
122
+ y: number
123
+ width: number
124
+ height: number
125
+ /** Routed path of the dotted note→class link; absent for a free note */
126
+ linkPoints?: Array<{ x: number; y: number }>
127
+ }
128
+
129
+ export interface PositionedClassNode {
130
+ id: string
131
+ label: string
132
+ annotation?: string
133
+ attributes: ClassMember[]
134
+ methods: ClassMember[]
135
+ x: number
136
+ y: number
137
+ width: number
138
+ height: number
139
+ /** Height of the header section (name + annotation) */
140
+ headerHeight: number
141
+ /** Height of the attributes section */
142
+ attrHeight: number
143
+ /** Height of the methods section */
144
+ methodHeight: number
145
+ /** Interaction from a `click` statement — an href wraps the class box in an <a> */
146
+ interaction?: NodeInteraction
147
+ /** Inline styles resolved from classDef + `style` statements — override theme defaults */
148
+ inlineStyle?: Record<string, string>
149
+ /** Style class assigned via `cssClass`, `class A name`, or `:::name` — emitted onto the group's `class` attribute so external CSS can target it */
150
+ className?: string
151
+ }
152
+
153
+ export interface PositionedClassRelationship {
154
+ from: string
155
+ to: string
156
+ type: RelationshipType
157
+ /** Which end of the line has the UML marker — propagated from ClassRelationship */
158
+ markerAt: 'from' | 'to'
159
+ label?: string
160
+ fromCardinality?: string
161
+ toCardinality?: string
162
+ /** Path points from source to target */
163
+ points: Array<{ x: number; y: number }>
164
+ /** ELK-computed label center position (avoids overlaps between nearby edges) */
165
+ labelPosition?: { x: number; y: number }
166
+ }
@@ -0,0 +1,286 @@
1
+ import type {
2
+ ErDiagram,
3
+ ErEntity,
4
+ ErAttribute,
5
+ ErRelationship,
6
+ Cardinality,
7
+ } from './types.ts'
8
+ import {
9
+ normalizeBrTags,
10
+ toDirection,
11
+ type Statement,
12
+ } from '@zombie-mermaid/core'
13
+
14
+ // ============================================================================
15
+ // ER diagram parser
16
+ //
17
+ // Parses Mermaid erDiagram syntax into an ErDiagram structure.
18
+ //
19
+ // Supported syntax:
20
+ // CUSTOMER ||--o{ ORDER : places
21
+ // CUSTOMER {
22
+ // string name PK
23
+ // int age
24
+ // string email UK "user email"
25
+ // }
26
+ //
27
+ // Cardinality notation:
28
+ // || || exactly one
29
+ // |o o| zero or one
30
+ // }| |{ one or more
31
+ // }o o{ zero or more
32
+ //
33
+ // Line style:
34
+ // -- identifying (solid line)
35
+ // .. non-identifying (dashed line)
36
+ // ============================================================================
37
+
38
+ /**
39
+ * Parse a Mermaid ER diagram.
40
+ * Expects the first line to be "erDiagram".
41
+ */
42
+ // Audited for issue #100 (non-null assertions): every `!` in this file is
43
+ // either a bounds-checked loop-index array access (`lines[i]!` inside a
44
+ // `for (let i = 1; i < lines.length; ...)` loop) or a regex-mandatory-
45
+ // capture-group access after `.match()` (a group that isn't wrapped in an
46
+ // optional `(?:...)?`, so it always participates when the overall match
47
+ // succeeds). Both are the same idioms already accepted as justified
48
+ // elsewhere in this codebase (see src/parser.ts, PR #158, and this
49
+ // subsystem's layout.ts/renderer.ts audit, PR #148, which fixed the
50
+ // genuinely risky assertions there but didn't reach this file — PR #146
51
+ // separately reviewed this file's `as` casts, which are a different
52
+ // concern from these `!`s) — `noUncheckedIndexedAccess` can't see either
53
+ // guarantee, but removing the `!` would only replace a proven-safe
54
+ // assertion with an unreachable guard. Left as-is; no behavior change.
55
+ export function parseErDiagram(lines: Statement[]): ErDiagram {
56
+ const diagram: ErDiagram = {
57
+ entities: [],
58
+ relationships: [],
59
+ }
60
+
61
+ // Track entities by ID for deduplication
62
+ const entityMap = new Map<string, ErEntity>()
63
+ // Track entity body parsing
64
+ let currentEntity: ErEntity | null = null
65
+
66
+ for (let i = 1; i < lines.length; i++) {
67
+ const stmt = lines[i]!
68
+ const line = stmt.text
69
+
70
+ // --- Inside entity body ---
71
+ if (currentEntity) {
72
+ if (line === '}') {
73
+ currentEntity = null
74
+ continue
75
+ }
76
+
77
+ // Attribute line: type name [PK|FK|UK] ["comment"]
78
+ const attr = parseAttribute(line)
79
+ if (attr) {
80
+ currentEntity.attributes.push(attr)
81
+ }
82
+ continue
83
+ }
84
+
85
+ // --- direction directive: `direction TB` / `direction LR` / etc. ---
86
+ // ER diagrams have no subgraph nesting, so a single top-level direction
87
+ // applies to the whole diagram (unlike flowcharts, where `direction` can
88
+ // also appear per-subgraph).
89
+ const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i)
90
+ if (dirMatch) {
91
+ diagram.direction = toDirection(dirMatch[1]!)
92
+ continue
93
+ }
94
+
95
+ // --- Entity block start: `ENTITY_NAME {`, `id[Alias Label] {`, or
96
+ // `id["Quoted Alias"] {` — optionally with the body (and even the
97
+ // closing `}`) inline on the same line, e.g. `p[Person] { string name }`.
98
+ const entityBlockMatch = line.match(/^(\S+?)(?:\[(.+)\])?\s*\{(.*)$/)
99
+ if (entityBlockMatch) {
100
+ const id = entityBlockMatch[1]!
101
+ const aliasRaw = entityBlockMatch[2]
102
+ const alias =
103
+ aliasRaw !== undefined
104
+ ? normalizeBrTags(aliasRaw.trim().replace(/^["']|["']$/g, ''))
105
+ : undefined
106
+ const entity = ensureEntity(entityMap, id, alias)
107
+
108
+ const trailing = entityBlockMatch[3]!.trim()
109
+ const closesInline = trailing.endsWith('}')
110
+ const body = closesInline ? trailing.slice(0, -1).trim() : trailing
111
+ for (const part of body
112
+ .split(';')
113
+ .map((s) => s.trim())
114
+ .filter(Boolean)) {
115
+ const attr = parseAttribute(part)
116
+ if (attr) entity.attributes.push(attr)
117
+ }
118
+
119
+ // Only keep the block open for subsequent attribute lines when this
120
+ // line didn't already close it.
121
+ if (!closesInline) {
122
+ currentEntity = entity
123
+ }
124
+ continue
125
+ }
126
+
127
+ // --- Relationship: `ENTITY1 cardinality1--cardinality2 ENTITY2 : label` ---
128
+ const rel = parseRelationshipLine(line, stmt.line)
129
+ if (rel) {
130
+ // Ensure both entities exist
131
+ ensureEntity(entityMap, rel.entity1)
132
+ ensureEntity(entityMap, rel.entity2)
133
+ diagram.relationships.push(rel)
134
+ continue
135
+ }
136
+ }
137
+
138
+ diagram.entities = [...entityMap.values()]
139
+ return diagram
140
+ }
141
+
142
+ /**
143
+ * Ensure an entity exists in the map. When `alias` is provided (from an
144
+ * `id[Alias]` entity-block header), it becomes the display label while the
145
+ * map key — and every relationship reference — stays keyed off the raw `id`.
146
+ */
147
+ function ensureEntity(
148
+ entityMap: Map<string, ErEntity>,
149
+ id: string,
150
+ alias?: string,
151
+ ): ErEntity {
152
+ let entity = entityMap.get(id)
153
+ if (!entity) {
154
+ entity = { id, label: alias ?? id, attributes: [] }
155
+ entityMap.set(id, entity)
156
+ } else if (alias !== undefined) {
157
+ entity.label = alias
158
+ }
159
+ return entity
160
+ }
161
+
162
+ /** Parse an attribute line inside an entity block */
163
+ function parseAttribute(line: string): ErAttribute | null {
164
+ // Format: type name [PK|FK|UK [...]] ["comment"]
165
+ const match = line.match(/^(\S+)\s+(\S+)(?:\s+(.+))?$/)
166
+ if (!match) return null
167
+
168
+ const type = match[1]!
169
+ const name = match[2]!
170
+ const rest = match[3]?.trim() ?? ''
171
+
172
+ // Extract key constraints (PK, FK, UK) and optional comment
173
+ const keys: ErAttribute['keys'] = []
174
+ let comment: string | undefined
175
+
176
+ // Extract quoted comment first (supports <br> tags)
177
+ const commentMatch = rest.match(/"([^"]*)"/)
178
+ if (commentMatch) {
179
+ comment = normalizeBrTags(commentMatch[1]!)
180
+ }
181
+
182
+ // Extract key constraints
183
+ const restWithoutComment = rest.replace(/"[^"]*"/, '').trim()
184
+ for (const part of restWithoutComment.split(/\s+/)) {
185
+ const upper = part.toUpperCase()
186
+ if (upper === 'PK' || upper === 'FK' || upper === 'UK') {
187
+ keys.push(upper)
188
+ }
189
+ }
190
+
191
+ return { type, name, keys, comment }
192
+ }
193
+
194
+ /**
195
+ * Parse a relationship line.
196
+ *
197
+ * Cardinality symbols on each side of the line style:
198
+ * Left side (entity1): || |o }| }o
199
+ * Line: -- (identifying) or .. (non-identifying)
200
+ * Right side (entity2): || o| |{ o{
201
+ *
202
+ * Full pattern example: CUSTOMER ||--o{ ORDER : places
203
+ */
204
+ function parseRelationshipLine(
205
+ line: string,
206
+ lineNumber: number,
207
+ ): ErRelationship | null {
208
+ // Loosely match a line shaped like a relationship attempt: two bare
209
+ // tokens flanking a `--`/`..`-based marker, with an optional `: label`.
210
+ // A line with no such marker at all doesn't look like a relationship
211
+ // attempt and falls through silently (returns null), same as before —
212
+ // that part of the parser's leniency is unchanged.
213
+ //
214
+ // Everything that *does* match this shape is validated below and either
215
+ // returns a fully-parsed relationship or throws an actionable error.
216
+ // Before this pass, an invalid cardinality token or a missing/empty
217
+ // label silently dropped the *entire* line (both entities included)
218
+ // with zero indication anything was wrong — see issue #541 (parser
219
+ // error-message quality audit — this parser had zero throw sites before
220
+ // this pass).
221
+ const attempt = line.match(
222
+ /^(\S+)\s+(\S*(?:--|\.\.)\S*)\s+(\S+)\s*(?::\s*(.*))?$/,
223
+ )
224
+ if (!attempt) return null
225
+
226
+ const entity1 = attempt[1]!
227
+ const cardinalityStr = attempt[2]!
228
+ const entity2 = attempt[3]!
229
+ const hasLabel = attempt[4] !== undefined
230
+ const rawLabel = attempt[4]?.trim() ?? ''
231
+
232
+ // Cardinality symbols on each side of the line style:
233
+ // Left side (entity1): || |o }| }o
234
+ // Line: -- (identifying) or .. (non-identifying)
235
+ // Right side (entity2): || o| |{ o{
236
+ // `shapeMatch` only confirms the token is built from crow's-foot
237
+ // characters around a line-style marker — `parseLeftCardinality`/
238
+ // `parseRightCardinality` still reject a shape match that isn't one of
239
+ // the four valid two-character combinations per side (e.g. a lone "|").
240
+ const shapeMatch = cardinalityStr.match(/^([|o}{]*)(--|\.\.?)([|o}{]*)$/)
241
+ const cardinality1 = shapeMatch ? parseLeftCardinality(shapeMatch[1]!) : null
242
+ const cardinality2 = shapeMatch ? parseRightCardinality(shapeMatch[3]!) : null
243
+
244
+ if (!cardinality1 || !cardinality2) {
245
+ throw new Error(
246
+ `Line ${lineNumber}: Invalid ER relationship cardinality "${cardinalityStr}" in "${line}". ` +
247
+ 'Left side must be one of ||, |o, }|, }o; right side must be one of ||, o|, |{, o{ (e.g. "||--o{").',
248
+ )
249
+ }
250
+
251
+ if (!hasLabel || rawLabel.length === 0) {
252
+ throw new Error(
253
+ `Line ${lineNumber}: ER relationship "${entity1} ${cardinalityStr} ${entity2}" is missing a ": label" — expected e.g. "${entity1} ${cardinalityStr} ${entity2} : label".`,
254
+ )
255
+ }
256
+
257
+ // Strip surrounding quotes if present, then normalize br tags.
258
+ const label = normalizeBrTags(rawLabel.replace(/^["']|["']$/g, ''))
259
+ const identifying = shapeMatch![2] === '--'
260
+
261
+ return { entity1, entity2, cardinality1, cardinality2, label, identifying }
262
+ }
263
+
264
+ /**
265
+ * Parse a left-side (entity1) cardinality notation string.
266
+ * The crow's-foot character sits nearer the entity, so left- and
267
+ * right-side notations are mirror images of each other and must be
268
+ * matched exactly rather than order-normalized (sorting `}o` and `o{`
269
+ * to the same key conflates "zero or more" with malformed input).
270
+ */
271
+ function parseLeftCardinality(str: string): Cardinality | null {
272
+ if (str === '||') return 'one'
273
+ if (str === '|o') return 'zero-one'
274
+ if (str === '}|') return 'many'
275
+ if (str === '}o') return 'zero-many'
276
+ return null
277
+ }
278
+
279
+ /** Parse a right-side (entity2) cardinality notation string. */
280
+ function parseRightCardinality(str: string): Cardinality | null {
281
+ if (str === '||') return 'one'
282
+ if (str === 'o|') return 'zero-one'
283
+ if (str === '|{') return 'many'
284
+ if (str === 'o{') return 'zero-many'
285
+ return null
286
+ }
@@ -0,0 +1,99 @@
1
+ // ============================================================================
2
+ // ER diagram types
3
+ //
4
+ // Models the parsed and positioned representations of a Mermaid ER diagram.
5
+ // ER diagrams show database entities, their attributes, and relationships.
6
+ // ============================================================================
7
+
8
+ import type { Direction } from '@zombie-mermaid/core'
9
+
10
+ /** Parsed ER diagram — logical structure from mermaid text */
11
+ export interface ErDiagram {
12
+ /** All entity definitions */
13
+ entities: ErEntity[]
14
+ /** Relationships between entities */
15
+ relationships: ErRelationship[]
16
+ /**
17
+ * Overall layout direction, from a top-level `direction TB` / `direction LR`
18
+ * / `direction BT` / `direction RL` statement. `undefined` when the diagram
19
+ * doesn't specify one, in which case the layout falls back to its default.
20
+ */
21
+ direction?: Direction
22
+ }
23
+
24
+ export interface ErEntity {
25
+ id: string
26
+ /** Display name (same as id unless aliased) */
27
+ label: string
28
+ /** Entity attributes (columns) */
29
+ attributes: ErAttribute[]
30
+ }
31
+
32
+ export interface ErAttribute {
33
+ /** Data type (string, int, varchar, etc.) */
34
+ type: string
35
+ /** Attribute name */
36
+ name: string
37
+ /** Key constraints: PK, FK, UK */
38
+ keys: Array<'PK' | 'FK' | 'UK'>
39
+ /** Optional comment */
40
+ comment?: string
41
+ }
42
+
43
+ /**
44
+ * Cardinality notation (crow's foot):
45
+ * 'one' || || exactly one
46
+ * 'zero-one' |o o| zero or one
47
+ * 'many' }| |{ one or more
48
+ * 'zero-many' }o o{ zero or more
49
+ */
50
+ export type Cardinality = 'one' | 'zero-one' | 'many' | 'zero-many'
51
+
52
+ export interface ErRelationship {
53
+ entity1: string
54
+ entity2: string
55
+ /** Cardinality at entity1's end */
56
+ cardinality1: Cardinality
57
+ /** Cardinality at entity2's end */
58
+ cardinality2: Cardinality
59
+ /** Relationship verb/label (e.g., "places", "contains") */
60
+ label: string
61
+ /** Whether the relationship is identifying (solid line) or non-identifying (dashed) */
62
+ identifying: boolean
63
+ }
64
+
65
+ // ============================================================================
66
+ // Positioned ER diagram — ready for SVG rendering
67
+ // ============================================================================
68
+
69
+ export interface PositionedErDiagram {
70
+ width: number
71
+ height: number
72
+ entities: PositionedErEntity[]
73
+ relationships: PositionedErRelationship[]
74
+ }
75
+
76
+ export interface PositionedErEntity {
77
+ id: string
78
+ label: string
79
+ attributes: ErAttribute[]
80
+ x: number
81
+ y: number
82
+ width: number
83
+ height: number
84
+ /** Height of the header row */
85
+ headerHeight: number
86
+ /** Height per attribute row */
87
+ rowHeight: number
88
+ }
89
+
90
+ export interface PositionedErRelationship {
91
+ entity1: string
92
+ entity2: string
93
+ cardinality1: Cardinality
94
+ cardinality2: Cardinality
95
+ label: string
96
+ identifying: boolean
97
+ /** Path points from entity1 to entity2 */
98
+ points: Array<{ x: number; y: number }>
99
+ }