@jarenjs/mermaid 0.34.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 (64) hide show
  1. package/README.md +282 -0
  2. package/dist/types/ast.d.ts +210 -0
  3. package/dist/types/component/index.d.ts +81 -0
  4. package/dist/types/errors.d.ts +27 -0
  5. package/dist/types/index.d.ts +97 -0
  6. package/dist/types/interactive.d.ts +36 -0
  7. package/dist/types/layout/flowchart.d.ts +12 -0
  8. package/dist/types/layout/sequence.d.ts +12 -0
  9. package/dist/types/layout/state.d.ts +19 -0
  10. package/dist/types/parser/class.d.ts +11 -0
  11. package/dist/types/parser/config.d.ts +61 -0
  12. package/dist/types/parser/er.d.ts +11 -0
  13. package/dist/types/parser/flowchart.d.ts +26 -0
  14. package/dist/types/parser/gantt.d.ts +11 -0
  15. package/dist/types/parser/index.d.ts +22 -0
  16. package/dist/types/parser/pie.d.ts +11 -0
  17. package/dist/types/parser/sequence.d.ts +16 -0
  18. package/dist/types/parser/state.d.ts +22 -0
  19. package/dist/types/plugin.d.ts +50 -0
  20. package/dist/types/render/error.d.ts +23 -0
  21. package/dist/types/render/flowchart.d.ts +18 -0
  22. package/dist/types/render/index.d.ts +23 -0
  23. package/dist/types/render/misc.d.ts +54 -0
  24. package/dist/types/render/sequence.d.ts +16 -0
  25. package/dist/types/styles.d.ts +81 -0
  26. package/dist/types/theme.d.ts +47 -0
  27. package/dist/types/to-mermaid.d.ts +20 -0
  28. package/dist/types/utils.d.ts +47 -0
  29. package/docs/MERMAID-FORMAT.md +242 -0
  30. package/package.json +84 -0
  31. package/schemas/jaren-mermaid-ast.schema.json +78 -0
  32. package/schemas/jaren-workflow.schema.json +28 -0
  33. package/src/ast.js +252 -0
  34. package/src/component/index.js +109 -0
  35. package/src/errors.js +35 -0
  36. package/src/index.js +155 -0
  37. package/src/interactive.js +244 -0
  38. package/src/layout/flowchart.js +352 -0
  39. package/src/layout/sequence.js +178 -0
  40. package/src/layout/state.js +65 -0
  41. package/src/parser/class.js +90 -0
  42. package/src/parser/config.js +215 -0
  43. package/src/parser/er.js +86 -0
  44. package/src/parser/flowchart.js +413 -0
  45. package/src/parser/gantt.js +49 -0
  46. package/src/parser/index.js +122 -0
  47. package/src/parser/pie.js +32 -0
  48. package/src/parser/sequence.js +156 -0
  49. package/src/parser/state.js +137 -0
  50. package/src/plugin.js +76 -0
  51. package/src/render/error.js +55 -0
  52. package/src/render/flowchart.js +249 -0
  53. package/src/render/index.js +93 -0
  54. package/src/render/misc.js +135 -0
  55. package/src/render/sequence.js +152 -0
  56. package/src/styles.js +181 -0
  57. package/src/theme.js +180 -0
  58. package/src/to-mermaid.js +317 -0
  59. package/src/utils.js +64 -0
  60. package/styles/mermaid.css +115 -0
  61. package/stylesheets/dag-to-flowchart.jslt.json +62 -0
  62. package/stylesheets/flowchart-to-dag.jslt.json +29 -0
  63. package/stylesheets/state-to-workflow.jslt.json +26 -0
  64. package/stylesheets/workflow-to-state.jslt.json +41 -0
@@ -0,0 +1,178 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Sequence layout: actor lifeline x-positions, message y-advance,
4
+ * activation bars, note boxes and nested block frames
5
+ * (loop/alt/opt/par). Pure and deterministic; emits a host-free
6
+ * `PositionedDiagram`. Geometry is an approximation over the headless
7
+ * text metrics (no DOM) — pixel parity is a non-goal.
8
+ */
9
+
10
+ import { textWidth } from '@jarenjs/view/helpers';
11
+ import { coord as round } from '../utils.js';
12
+
13
+ const FONT_SIZE = 14;
14
+ const ACTOR_H = 34;
15
+ const ACTOR_MIN_W = 80;
16
+ const ACTOR_PAD = 20;
17
+ const ACTOR_GAP = 60;
18
+ const TOP = 12;
19
+ const MARGIN = 12;
20
+ const MSG_GAP = 40;
21
+ const SELF_GAP = 52;
22
+ const NOTE_H = 34;
23
+ const NOTE_PAD = 10;
24
+ const ACT_W = 10;
25
+ const BLOCK_LABEL_H = 24;
26
+ const BLOCK_PAD = 12;
27
+
28
+ /**
29
+ * @param {any} ast sequence AST
30
+ * @returns {any} PositionedDiagram
31
+ */
32
+ export function layoutSequence(ast) {
33
+ const actors = ast.participants.map((p) => ({
34
+ id: p.id,
35
+ label: p.label,
36
+ kind: p.kind,
37
+ w: Math.max(ACTOR_MIN_W, textWidth(p.label, FONT_SIZE, 700) + 2 * ACTOR_PAD),
38
+ }));
39
+ /** @type {Map<string, any>} */
40
+ const byId = new Map();
41
+
42
+ // Actor centers, left to right.
43
+ let cursor = MARGIN;
44
+ for (let i = 0; i < actors.length; i++) {
45
+ const a = actors[i];
46
+ a.x = cursor + a.w / 2;
47
+ a.boxX = cursor;
48
+ a.boxY = TOP;
49
+ a.h = ACTOR_H;
50
+ byId.set(a.id, a);
51
+ cursor += a.w + ACTOR_GAP;
52
+ }
53
+ const width = Math.max(cursor - ACTOR_GAP + MARGIN, ACTOR_MIN_W + 2 * MARGIN);
54
+
55
+ const centerOf = (id) => (byId.get(id)?.x ?? MARGIN);
56
+
57
+ const messages = [];
58
+ const notes = [];
59
+ const activations = [];
60
+ const blocks = [];
61
+ /** @type {Map<string, number[]>} activation start-y stack per actor */
62
+ const actStacks = new Map();
63
+
64
+ const state = { y: TOP + ACTOR_H + MSG_GAP };
65
+
66
+ const activate = (id) => {
67
+ if (!actStacks.has(id)) actStacks.set(id, []);
68
+ actStacks.get(id).push(state.y);
69
+ };
70
+ const deactivate = (id) => {
71
+ const stack = actStacks.get(id);
72
+ if (stack && stack.length) {
73
+ const startY = stack.pop();
74
+ const a = byId.get(id);
75
+ if (a) activations.push({ x: a.x - ACT_W / 2, y: startY, w: ACT_W, h: Math.max(state.y - startY, MSG_GAP / 2) });
76
+ }
77
+ };
78
+
79
+ /**
80
+ * @param {any[]} statements
81
+ */
82
+ const walk = (statements) => {
83
+ for (const stmt of statements) {
84
+ if (stmt.kind === 'message') {
85
+ const x1 = centerOf(stmt.from);
86
+ const x2 = centerOf(stmt.to);
87
+ if (stmt.activation === 'activate') activate(stmt.to);
88
+ if (stmt.from === stmt.to) {
89
+ messages.push({ x1, y: state.y, x2, label: stmt.text, line: stmt.line, head: stmt.head, self: true });
90
+ state.y += SELF_GAP;
91
+ }
92
+ else {
93
+ messages.push({ x1, y: state.y, x2, label: stmt.text, line: stmt.line, head: stmt.head, self: false });
94
+ state.y += MSG_GAP;
95
+ }
96
+ if (stmt.activation === 'deactivate') deactivate(stmt.from);
97
+ }
98
+ else if (stmt.kind === 'note') {
99
+ const xs = stmt.actors.map(centerOf);
100
+ const minX = Math.min(...xs);
101
+ const maxX = Math.max(...xs);
102
+ const textW = textWidth(stmt.text, FONT_SIZE) + 2 * NOTE_PAD;
103
+ let x, w;
104
+ if (stmt.placement === 'over') {
105
+ const span = maxX - minX;
106
+ w = Math.max(textW, span + ACTOR_MIN_W);
107
+ x = (minX + maxX) / 2 - w / 2;
108
+ }
109
+ else if (stmt.placement === 'left of') {
110
+ w = textW; x = minX - w - 10;
111
+ }
112
+ else {
113
+ w = textW; x = maxX + 10;
114
+ }
115
+ notes.push({ x, y: state.y, w, h: NOTE_H, text: stmt.text });
116
+ state.y += NOTE_H + 10;
117
+ }
118
+ else if (stmt.kind === 'activate') activate(stmt.actor);
119
+ else if (stmt.kind === 'deactivate') deactivate(stmt.actor);
120
+ else if (stmt.kind === 'block') {
121
+ const startY = state.y;
122
+ state.y += BLOCK_LABEL_H + BLOCK_PAD;
123
+ const dividers = [];
124
+ for (let bi = 0; bi < stmt.branches.length; bi++) {
125
+ if (bi > 0) {
126
+ dividers.push({ y: state.y, label: stmt.branches[bi].label });
127
+ state.y += BLOCK_LABEL_H;
128
+ }
129
+ walk(stmt.branches[bi].statements);
130
+ }
131
+ state.y += BLOCK_PAD;
132
+ const bx = MARGIN + 4;
133
+ blocks.push({
134
+ x: bx,
135
+ y: startY,
136
+ w: width - 2 * bx,
137
+ h: state.y - startY,
138
+ label: stmt.branches[0].label,
139
+ blockType: stmt.blockType,
140
+ dividers,
141
+ });
142
+ state.y += MSG_GAP / 2;
143
+ }
144
+ }
145
+ };
146
+
147
+ walk(ast.statements);
148
+
149
+ // Close any dangling activations at the bottom.
150
+ for (const [id, stack] of actStacks) {
151
+ while (stack.length) {
152
+ const startY = stack.pop();
153
+ const a = byId.get(id);
154
+ if (a) activations.push({ x: a.x - ACT_W / 2, y: startY, w: ACT_W, h: Math.max(state.y - startY, MSG_GAP / 2) });
155
+ }
156
+ }
157
+
158
+ const lineBottom = state.y + 6;
159
+ const height = lineBottom + ACTOR_H + MARGIN;
160
+
161
+ return {
162
+ type: 'sequence',
163
+ width: round(width),
164
+ height: round(height),
165
+ fontSize: FONT_SIZE,
166
+ lineTop: TOP + ACTOR_H,
167
+ lineBottom: round(lineBottom),
168
+ actors: actors.map((a) => ({
169
+ id: a.id, label: a.label, kind: a.kind,
170
+ x: round(a.x), boxX: round(a.boxX), boxY: a.boxY, w: round(a.w), h: a.h,
171
+ bottomY: round(lineBottom),
172
+ })),
173
+ messages: messages.map((m) => ({ ...m, x1: round(m.x1), x2: round(m.x2), y: round(m.y) })),
174
+ notes: notes.map((n) => ({ ...n, x: round(n.x), y: round(n.y), w: round(n.w) })),
175
+ activations: activations.map((a) => ({ x: round(a.x), y: round(a.y), w: a.w, h: round(a.h) })),
176
+ blocks: blocks.map((b) => ({ ...b, x: round(b.x), y: round(b.y), w: round(b.w), h: round(b.h), dividers: b.dividers.map((d) => ({ y: round(d.y), label: d.label })) })),
177
+ };
178
+ }
@@ -0,0 +1,65 @@
1
+ //@ts-check
2
+ /**
3
+ * @file State-diagram layout: an ADAPTER, not a second algorithm. The
4
+ * state AST (states + labeled transitions + `[*]` pseudo-states) maps
5
+ * onto the flowchart layout's input vocabulary — states as rounded
6
+ * nodes, transition labels as edge labels, the start/end pseudo-states
7
+ * as synthetic `statedot`/`doublecircle` nodes — and `layoutFlowchart`
8
+ * does the ranking, ordering and routing. Same AST → identical
9
+ * geometry, like every layout in this component.
10
+ *
11
+ * Composite states arrive flattened from the parser (the `parent`
12
+ * field is recorded but not drawn as a cluster in v1 — the honest
13
+ * limitation lives in MERMAID-FORMAT §6).
14
+ */
15
+
16
+ import { layoutFlowchart } from './flowchart.js';
17
+
18
+ /** Synthetic ids for the `[*]` pseudo-states (never valid state ids
19
+ * in source, since `[*]` is the only spelling the parser reserves). */
20
+ const START_ID = '__start';
21
+ const END_ID = '__end';
22
+
23
+ /**
24
+ * Lay out a state AST through the flowchart engine.
25
+ * @param {any} ast state AST ({ states, transitions })
26
+ * @returns {any} PositionedDiagram
27
+ */
28
+ export function layoutState(ast) {
29
+ const nodes = [];
30
+ const edges = [];
31
+ let hasStart = false;
32
+ let hasEnd = false;
33
+
34
+ for (const t of ast.transitions) {
35
+ if (t.from === '[*]') hasStart = true;
36
+ if (t.to === '[*]') hasEnd = true;
37
+ }
38
+ if (hasStart) nodes.push({ id: START_ID, label: '', shape: 'statedot' });
39
+ for (const s of ast.states) {
40
+ nodes.push({ id: s.id, label: s.label ?? s.id, shape: 'round' });
41
+ }
42
+ if (hasEnd) nodes.push({ id: END_ID, label: '', shape: 'doublecircle' });
43
+
44
+ for (const t of ast.transitions) {
45
+ edges.push({
46
+ from: t.from === '[*]' ? START_ID : t.from,
47
+ to: t.to === '[*]' ? END_ID : t.to,
48
+ label: t.label ?? null,
49
+ stroke: 'solid',
50
+ head: 'arrow',
51
+ tail: 'none',
52
+ length: 2,
53
+ });
54
+ }
55
+
56
+ return layoutFlowchart({
57
+ direction: 'TB',
58
+ nodes,
59
+ edges,
60
+ subgraphs: [],
61
+ classDefs: [],
62
+ classes: [],
63
+ styles: [],
64
+ });
65
+ }
@@ -0,0 +1,90 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Class-diagram grammar → class AST. Supports `class Foo { … }`
4
+ * bodies, `Foo : +member` line form, and relations
5
+ * (`<|--`, `*--`, `o--`, `-->`, `..>`, `..|>`) with optional `: label`.
6
+ * Geometry-free: members and relations preserve declaration order.
7
+ */
8
+
9
+ import { collectBraceBody } from '../utils.js';
10
+
11
+ /** Relation operator between two class names, optional `: label`. */
12
+ const RE_RELATION = /^(\S+)\s+([<>|*o.]{0,2}(?:--|\.\.)[<>|*o.]{0,2})\s+(\S+)(?:\s*:\s*(.*))?$/;
13
+ /** `ClassName : member` line form. */
14
+ const RE_MEMBER_LINE = /^(\S+)\s*:\s*(.+)$/;
15
+
16
+ /**
17
+ * @param {string[]} lines
18
+ * @returns {object}
19
+ */
20
+ export function parseClass(lines) {
21
+ /** @type {Map<string, { name: string, label: string, members: object[] }>} */
22
+ const classMap = new Map();
23
+ const order = [];
24
+ const relations = [];
25
+
26
+ const ensure = (name) => {
27
+ let cls = classMap.get(name);
28
+ if (cls === undefined) {
29
+ cls = { name, label: name, members: [] };
30
+ classMap.set(name, cls);
31
+ order.push(name);
32
+ }
33
+ return cls;
34
+ };
35
+
36
+ for (let li = 0; li < lines.length; li++) {
37
+ let line = lines[li].trim();
38
+ if (line === '' || line.startsWith('%%')) continue;
39
+
40
+ // class Foo { ... } (single or multi-line body)
41
+ if (line.startsWith('class ')) {
42
+ const rest = line.slice('class '.length).trim();
43
+ const brace = rest.indexOf('{');
44
+ if (brace === -1) { ensure(rest.replace(/[~<].*$/, '').trim()); continue; }
45
+ const name = rest.slice(0, brace).trim();
46
+ const cls = ensure(name);
47
+ // Consume until closing brace across lines.
48
+ let body;
49
+ ({ body, li } = collectBraceBody(lines, li, rest.slice(brace + 1)));
50
+ for (const raw of body.split('\n')) {
51
+ const mem = raw.trim();
52
+ if (mem !== '') cls.members.push(member(mem));
53
+ }
54
+ continue;
55
+ }
56
+
57
+ const rel = RE_RELATION.exec(line);
58
+ if (rel !== null) {
59
+ ensure(rel[1]);
60
+ ensure(rel[3]);
61
+ relations.push({ from: rel[1], to: rel[3], type: rel[2], label: rel[4] ? rel[4].trim() : null });
62
+ continue;
63
+ }
64
+
65
+ const ml = RE_MEMBER_LINE.exec(line);
66
+ if (ml !== null && !line.startsWith('class')) {
67
+ ensure(ml[1]).members.push(member(ml[2].trim()));
68
+ continue;
69
+ }
70
+ }
71
+
72
+ return { classes: order.map((n) => classMap.get(n)), relations };
73
+ }
74
+
75
+ /**
76
+ * Classify a member as a method (has `()`) or attribute, capturing a
77
+ * leading visibility marker.
78
+ * @param {string} text
79
+ * @returns {object}
80
+ */
81
+ function member(text) {
82
+ let visibility = null;
83
+ const first = text.charCodeAt(0);
84
+ if (first === 0x2b || first === 0x2d || first === 0x23 || first === 0x7e) {
85
+ visibility = text[0];
86
+ text = text.slice(1);
87
+ }
88
+ const kind = text.indexOf('(') !== -1 ? 'method' : 'attribute';
89
+ return { kind, visibility, text: text.trim() };
90
+ }
@@ -0,0 +1,215 @@
1
+ //@ts-check
2
+ /**
3
+ * @file Config extraction: the leading `---` front-matter block and any
4
+ * `%%{init: {...}}%%` directives → a plain-JSON `config` (MERMAID-FORMAT
5
+ * §3).
6
+ *
7
+ * The raw source is never consumed here — this returns the cleaned body
8
+ * (front-matter, init directives and `%%` comments removed) plus the
9
+ * merged config and title, and the original source stays in the
10
+ * Markdown fence `value` so `toMarkdown` round-trips verbatim.
11
+ *
12
+ * Front-matter is a YAML subset. The engine may NOT statically import
13
+ * `@jarenjs/md` (two-layer rule), so a small built-in subset parser is
14
+ * the default; a host that already has `@jarenjs/md` can inject its
15
+ * richer `parseFrontmatter` through `options.parseFrontmatter`.
16
+ */
17
+
18
+ /** A leading front-matter fence: `---` on its own line at the very top. */
19
+ const FM_OPEN = /^---[ \t]*$/;
20
+ /** An `%%{init: {...}}%%` (or `%%{ init: ... }%%`) directive line. */
21
+ const RE_INIT = /^\s*%%\{\s*(?:init|initialize)\s*:\s*([\s\S]*?)\}%%\s*$/;
22
+ /** A whole-line `%%` comment (but not an `%%{...}%%` directive). */
23
+ const RE_COMMENT = /^\s*%%(?!\{)/;
24
+
25
+ /**
26
+ * @typedef {object} MermaidConfigResult
27
+ * @property {Record<string, any>} config merged config object
28
+ * @property {string|null} title front-matter `title`, if any
29
+ * @property {string} body source with front-matter/init/comments stripped
30
+ */
31
+
32
+ /**
33
+ * Extract config + title and return the cleaned diagram body.
34
+ * @param {string} source
35
+ * @param {{ parseFrontmatter?: (text: string) => any }} [options]
36
+ * @returns {MermaidConfigResult}
37
+ */
38
+ export function parseMermaidConfig(source, options = {}) {
39
+ const normalized = source.replace(/\r\n?/g, '\n');
40
+ /** @type {Record<string, any>} */
41
+ let config = {};
42
+ let title = null;
43
+ let rest = normalized;
44
+
45
+ // 1. Front-matter: `---` … `---` only when it is the very first line.
46
+ const firstNl = rest.indexOf('\n');
47
+ if (firstNl !== -1 && FM_OPEN.test(rest.slice(0, firstNl))) {
48
+ const closeAt = findFrontmatterClose(rest, firstNl + 1);
49
+ if (closeAt !== -1) {
50
+ const fmText = rest.slice(firstNl + 1, closeAt.start);
51
+ const parsed = options.parseFrontmatter
52
+ ? safeCall(options.parseFrontmatter, fmText)
53
+ : parseYamlSubset(fmText);
54
+ if (parsed && typeof parsed === 'object') {
55
+ if (typeof parsed.title === 'string') title = parsed.title;
56
+ if (parsed.config && typeof parsed.config === 'object') {
57
+ config = mergeConfig(config, parsed.config);
58
+ }
59
+ }
60
+ rest = rest.slice(closeAt.end);
61
+ }
62
+ }
63
+
64
+ // 2. Init directives + comment stripping, line by line.
65
+ const lines = rest.split('\n');
66
+ const out = [];
67
+ for (let i = 0; i < lines.length; i++) {
68
+ const line = lines[i];
69
+ const initMatch = RE_INIT.exec(line);
70
+ if (initMatch) {
71
+ const obj = parseLooseObject(initMatch[1]);
72
+ if (obj && typeof obj === 'object') config = mergeConfig(config, obj);
73
+ continue;
74
+ }
75
+ if (RE_COMMENT.test(line)) continue;
76
+ out.push(line);
77
+ }
78
+
79
+ return { config, title, body: out.join('\n') };
80
+ }
81
+
82
+ /**
83
+ * Find the closing `---` fence starting at `from`.
84
+ * @param {string} text
85
+ * @param {number} from
86
+ * @returns {{ start: number, end: number } | -1}
87
+ */
88
+ function findFrontmatterClose(text, from) {
89
+ let pos = from;
90
+ while (pos < text.length) {
91
+ let nl = text.indexOf('\n', pos);
92
+ if (nl === -1) nl = text.length;
93
+ const line = text.slice(pos, nl);
94
+ if (FM_OPEN.test(line)) {
95
+ return { start: pos, end: nl === text.length ? nl : nl + 1 };
96
+ }
97
+ pos = nl + 1;
98
+ if (nl === text.length) break;
99
+ }
100
+ return -1;
101
+ }
102
+
103
+ /**
104
+ * Parse a small YAML subset: `key: value` and one level of nesting via
105
+ * two-space indentation. Values are scalars (string/number/bool/null).
106
+ * Deliberately minimal — the documented default when `@jarenjs/md`'s
107
+ * parser is not injected.
108
+ * @param {string} text
109
+ * @returns {Record<string, any>}
110
+ */
111
+ export function parseYamlSubset(text) {
112
+ const lines = text.split('\n');
113
+ const root = {};
114
+ /** @type {{ indent: number, obj: Record<string, any> }[]} */
115
+ const stack = [{ indent: -1, obj: root }];
116
+ for (let i = 0; i < lines.length; i++) {
117
+ const raw = lines[i];
118
+ if (raw.trim() === '' || raw.trim().startsWith('#')) continue;
119
+ let indent = 0;
120
+ while (indent < raw.length && raw.charCodeAt(indent) === 0x20) indent++;
121
+ const colon = raw.indexOf(':', indent);
122
+ if (colon === -1) continue;
123
+ const key = raw.slice(indent, colon).trim();
124
+ const valueText = raw.slice(colon + 1).trim();
125
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) stack.pop();
126
+ const parent = stack[stack.length - 1].obj;
127
+ if (valueText === '') {
128
+ const child = {};
129
+ parent[key] = child;
130
+ stack.push({ indent, obj: child });
131
+ }
132
+ else {
133
+ parent[key] = parseScalar(valueText);
134
+ }
135
+ }
136
+ return root;
137
+ }
138
+
139
+ /**
140
+ * @param {string} v
141
+ * @returns {any}
142
+ */
143
+ function parseScalar(v) {
144
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
145
+ return v.slice(1, -1);
146
+ }
147
+ if (v === 'true') return true;
148
+ if (v === 'false') return false;
149
+ if (v === 'null' || v === '~') return null;
150
+ if (v !== '' && !Number.isNaN(Number(v))) return Number(v);
151
+ return v;
152
+ }
153
+
154
+ /**
155
+ * Parse the relaxed object inside `%%{init: … }%%`. Tries strict JSON
156
+ * first, then a light normalization (quote bare keys, single→double
157
+ * quotes). Returns `null` on failure (config parsing never throws).
158
+ * @param {string} text
159
+ * @returns {Record<string, any> | null}
160
+ */
161
+ export function parseLooseObject(text) {
162
+ const trimmed = text.trim();
163
+ const src = trimmed.startsWith('{') ? trimmed : '{' + trimmed + '}';
164
+ try {
165
+ return JSON.parse(src);
166
+ }
167
+ catch {
168
+ // fall through to lenient
169
+ }
170
+ try {
171
+ const lenient = src
172
+ .replace(/'/g, '"')
173
+ .replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
174
+ return JSON.parse(lenient);
175
+ }
176
+ catch {
177
+ return null;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Shallow-merge nested config objects (one level deep for per-type
183
+ * keys like `flowchart`), returning a fresh object.
184
+ * @param {Record<string, any>} base
185
+ * @param {Record<string, any>} extra
186
+ * @returns {Record<string, any>}
187
+ */
188
+ function mergeConfig(base, extra) {
189
+ const out = { ...base };
190
+ for (const key of Object.keys(extra)) {
191
+ const value = extra[key];
192
+ if (value && typeof value === 'object' && !Array.isArray(value)
193
+ && out[key] && typeof out[key] === 'object' && !Array.isArray(out[key])) {
194
+ out[key] = { ...out[key], ...value };
195
+ }
196
+ else {
197
+ out[key] = value;
198
+ }
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * @param {(text: string) => any} fn
205
+ * @param {string} text
206
+ * @returns {any}
207
+ */
208
+ function safeCall(fn, text) {
209
+ try {
210
+ return fn(text);
211
+ }
212
+ catch {
213
+ return null;
214
+ }
215
+ }
@@ -0,0 +1,86 @@
1
+ //@ts-check
2
+ /**
3
+ * @file ER-diagram grammar → ER AST. Entity blocks
4
+ * (`CUSTOMER { string name PK }`) and relationships
5
+ * (`CUSTOMER ||--o{ ORDER : places`). Cardinality tokens are preserved
6
+ * verbatim for a faithful, geometry-free model.
7
+ */
8
+
9
+ import { collectBraceBody } from '../utils.js';
10
+
11
+ /** `LEFT <cardl>--<cardr> RIGHT : label` (relationship). */
12
+ const RE_REL = /^(\S+)\s+([|}{o]{1,2})(--|\.\.)([|}{o]{1,2})\s+(\S+)\s*:\s*(.*)$/;
13
+
14
+ /**
15
+ * @param {string[]} lines
16
+ * @returns {object}
17
+ */
18
+ export function parseEr(lines) {
19
+ /** @type {Map<string, { name: string, attributes: object[] }>} */
20
+ const entityMap = new Map();
21
+ const order = [];
22
+ const relationships = [];
23
+
24
+ const ensure = (name) => {
25
+ let ent = entityMap.get(name);
26
+ if (ent === undefined) {
27
+ ent = { name, attributes: [] };
28
+ entityMap.set(name, ent);
29
+ order.push(name);
30
+ }
31
+ return ent;
32
+ };
33
+
34
+ for (let li = 0; li < lines.length; li++) {
35
+ let line = lines[li].trim();
36
+ if (line === '' || line.startsWith('%%')) continue;
37
+
38
+ // Relationships are matched BEFORE the '{' entity-block check: a
39
+ // cardinality token can itself contain a brace (`CUSTOMER ||--o{ ORDER`),
40
+ // so `indexOf('{')` alone would misread the relationship as an entity
41
+ // block. An entity opener (`CUSTOMER {`) has no `--`/`..` connector, so
42
+ // it can never match RE_REL.
43
+ const rel = RE_REL.exec(line);
44
+ if (rel !== null) {
45
+ ensure(rel[1]);
46
+ ensure(rel[5]);
47
+ relationships.push({
48
+ left: rel[1],
49
+ right: rel[5],
50
+ leftCard: rel[2],
51
+ rightCard: rel[4],
52
+ identifying: rel[3] === '--',
53
+ label: rel[6].trim(),
54
+ });
55
+ continue;
56
+ }
57
+
58
+ const brace = line.indexOf('{');
59
+ if (brace !== -1) {
60
+ const name = line.slice(0, brace).trim();
61
+ const ent = ensure(name);
62
+ let body;
63
+ ({ body, li } = collectBraceBody(lines, li, line.slice(brace + 1)));
64
+ for (const raw of body.split('\n')) {
65
+ const attr = raw.trim();
66
+ if (attr === '') continue;
67
+ const parts = attr.split(/\s+/);
68
+ entityAttr(ent, parts);
69
+ }
70
+ continue;
71
+ }
72
+ }
73
+
74
+ return { entities: order.map((n) => entityMap.get(n)), relationships };
75
+ }
76
+
77
+ /**
78
+ * @param {{ attributes: object[] }} ent
79
+ * @param {string[]} parts `[type, name, key?, "comment"?]`
80
+ */
81
+ function entityAttr(ent, parts) {
82
+ const type = parts[0] ?? '';
83
+ const name = parts[1] ?? '';
84
+ const keys = parts.slice(2).filter((p) => p === 'PK' || p === 'FK' || p === 'UK');
85
+ ent.attributes.push({ type, name, keys });
86
+ }