PageSourceSearch

https://sparkchart.pro/mermaid-sequence-parser.js

js sparkchart.pro collected 2026-10-02 02:06:16 UTC 21,973 bytes, 599 lines download raw bytes

1/**
2 * Mermaid Sequence Diagram Parser
3 * Parses Mermaid sequence diagram syntax into the AST format used by ast-renderer.js.
4 */
5
6/**
7 * Parse Mermaid text into an AST structure.
8 * @param {string} input
9 * @returns {Ast}
10 */
11function parseMermaidToAst(input) {
12    const lines = input.split('\n');
13
14    /**
15     * Color names map
16     */
17    const colorNames = {
18        "AliceBlue": "#F0F8FF",
19        "AntiqueWhite": "#FAEBD7",
20        "Aqua": "#00FFFF",
21        "Aquamarine": "#7FFFD4",
22        "Azure": "#F0FFFF",
23        "Beige": "#F5F5DC",
24        "Bisque": "#FFE4C4",
25        "Black": "#000000",
26        "BlanchedAlmond": "#FFEBCD",
27        "Blue": "#0000FF",
28        "BlueViolet": "#8A2BE2",
29        "Brown": "#A52A2A",
30        "BurlyWood": "#DEB887",
31        "CadetBlue": "#5F9EA0",
32        "Chartreuse": "#7FFF00",
33        "Chocolate": "#D2691E",
34        "Coral": "#FF7F50",
35        "CornflowerBlue": "#6495ED",
36        "Cornsilk": "#FFF8DC",
37        "Crimson": "#DC143C",
38        "Cyan": "#00FFFF",
39        "DarkBlue": "#00008B",
40        "DarkCyan": "#008B8B",
41        "DarkGoldenRod": "#B8860B",
42        "DarkGray": "#A9A9A9",
43        "DarkGreen": "#006400",
44        "DarkGrey": "#A9A9A9",
45        "DarkKhaki": "#BDB76B",
46        "DarkMagenta": "#8B008B",
47        "DarkOliveGreen": "#556B2F",
48        "DarkOrchid": "#9932CC",
49        "DarkRed": "#8B0000",
50        "DarkSalmon": "#E9967A",
51        "DarkSeaGreen": "#8FBC8F",
52        "DarkSlateBlue": "#483D8B",
53        "DarkSlateGray": "#2F4F4F",
54        "DarkSlateGrey": "#2F4F4F",
55        "DarkTurquoise": "#00CED1",
56        "DarkViolet": "#9400D3",
57        "Darkorange": "#FF8C00",
58        "DeepPink": "#FF1493",
59        "DeepSkyBlue": "#00BFFF",
60        "DimGray": "#696969",
61        "DimGrey": "#696969",
62        "DodgerBlue": "#1E90FF",
63        "FireBrick": "#B22222",
64        "FloralWhite": "#FFFAF0",
65        "ForestGreen": "#228B22",
66        "Fuchsia": "#FF00FF",
67        "Gainsboro": "#DCDCDC",
68        "GhostWhite": "#F8F8FF",
69        "Gold": "#FFD700",
70        "GoldenRod": "#DAA520",
71        "Gray": "#808080",
72        "Green": "#008000",
73        "GreenYellow": "#ADFF2F",
74        "Grey": "#808080",
75        "HoneyDew": "#F0FFF0",
76        "HotPink": "#FF69B4",
77        "IndianRed": "#CD5C5C",
78        "Indigo": "#4B0082",
79        "Ivory": "#FFFFF0",
80        "Khaki": "#F0E68C",
81        "Lavender": "#E6E6FA",
82        "LavenderBlush": "#FFF0F5",
83        "LawnGreen": "#7CFC00",
84        "LemonChiffon": "#FFFACD",
85        "LightBlue": "#ADD8E6",
86        "LightCoral": "#F08080",
87        "LightCyan": "#E0FFFF",
88        "LightGoldenRodYellow": "#FAFAD2",
89        "LightGray": "#D3D3D3",
90        "LightGreen": "#90EE90",
91        "LightGrey": "#D3D3D3",
92        "LightPink": "#FFB6C1",
93        "LightSalmon": "#FFA07A",
94        "LightSeaGreen": "#20B2AA",
95        "LightSkyBlue": "#87CEFA",
96        "LightSlateGray": "#778899",
97        "LightSlateGrey": "#778899",
98        "LightSteelBlue": "#B0C4DE",
99        "LightYellow": "#FFFFE0",
100        "Lime": "#00FF00",
101        "LimeGreen": "#32CD32",
102        "Linen": "#FAF0E6",
103        "Magenta": "#FF00FF",
104        "Maroon": "#800000",
105        "MediumAquaMarine": "#66CDAA",
106        "MediumBlue": "#0000CD",
107        "MediumOrchid": "#BA55D3",
108        "MediumPurple": "#9370DB",
109        "MediumSeaGreen": "#3CB371",
110        "MediumSlateBlue": "#7B68EE",
111        "MediumSpringGreen": "#00FA9A",
112        "MediumTurquoise": "#48D1CC",
113        "MediumVioletRed": "#C71585",
114        "MidnightBlue": "#191970",
115        "MintCream": "#F5FFFA",
116        "MistyRose": "#FFE4E1",
117        "Moccasin": "#FFE4B5",
118        "NavajoWhite": "#FFDEAD",
119        "Navy": "#000080",
120        "OldLace": "#FDF5E6",
121        "Olive": "#808000",
122        "OliveDrab": "#6B8E23",
123        "Orange": "#FFA500",
124        "OrangeRed": "#FF4500",
125        "Orchid": "#DA70D6",
126        "PaleGoldenRod": "#EEE8AA",
127        "PaleGreen": "#98FB98",
128        "PaleTurquoise": "#AFEEEE",
129        "PaleVioletRed": "#DB7093",
130        "PapayaWhip": "#FFEFD5",
131        "PeachPuff": "#FFDAB9",
132        "Peru": "#CD853F",
133        "Pink": "#FFC0CB",
134        "Plum": "#DDA0DD",
135        "PowderBlue": "#B0E0E6",
136        "Purple": "#800080",
137        "Red": "#FF0000",
138        "RosyBrown": "#BC8F8F",
139        "RoyalBlue": "#4169E1",
140        "SaddleBrown": "#8B4513",
141        "Salmon": "#FA8072",
142        "SandyBrown": "#F4A460",
143        "SeaGreen": "#2E8B57",
144        "SeaShell": "#FFF5EE",
145        "Sienna": "#A0522D",
146        "Silver": "#C0C0C0",
147        "SkyBlue": "#87CEEB",
148        "SlateBlue": "#6A5ACD",
149        "SlateGray": "#708090",
150        "SlateGrey": "#708090",
151        "Snow": "#FFFAFA",
152        "SpringGreen": "#00FF7F",
153        "SteelBlue": "#4682B4",
154        "Tan": "#D2B48C",
155        "Teal": "#008080",
156        "Thistle": "#D8BFD8",
157        "Tomato": "#FF6347",
158        "Turquoise": "#40E0D0",
159        "Violet": "#EE82EE",
160        "Wheat": "#F5DEB3",
161        "White": "#FFFFFF",
162        "WhiteSmoke": "#F5F5F5",
163        "Yellow": "#FFFF00",
164        "YellowGreen": "#9ACD32"
165    };
166
167    /** @type {TitleNode|null} */
168    let title = null;
169    /** @type {ParticipantNode[]} */
170    const participants = [];
171    const participantSet = new Set();
172    /** @type {Ast['statements']} */
173    const statements = [];
174
175    // Queue to hold destroy statements until after the next signal
176    const pendingDestroyStatements = [];
177
178    // Helper to add participant
179    const addParticipant = (name, alias, line, options = {}) => {
180        // In Mermaid: participant A as Alice
181        // A is the ID (alias), Alice is the displayed name.
182        // If just participant Alice, ID=Alice, Name=Alice.
183
184        // In our AST: name is displayed, alias is ID.
185        const pName = name;
186        const pAlias = alias || name;
187
188        const key = pAlias.toLowerCase();
189        if (!participantSet.has(key)) {
190            const participant = {
191                type: 'Participant',
192                name: pName.replace(/<br\/>/g, "\n"),
193                alias: pAlias,
194                line: line // Store actual line number
195            };
196
197            // Add optional flags
198            if (options.isActor) participant.isActor = true;
199            if (options.isEntity) participant.isEntity = true;
200            if (options.isBoundary) participant.isBoundary = true;
201            if (options.isControl) participant.isControl = true;
202            if (options.isDatabase) participant.isDatabase = true;
203            if (options.isCollections) participant.isCollections = true;
204            if (options.isQueue) participant.isQueue = true;
205            if (options.isDynamic) participant.isDynamic = true;
206
207            participants.push(participant);
208            participantSet.add(key);
209        }
210    };
211
212    const ensureParticipant = (name, line) => {
213        if (!name || name === '*') return;
214        const trimmed = name.trim();
215        const key = trimmed.toLowerCase();
216        // Check if exists by alias or name
217        const exists = participants.some(p =>
218            (p.alias || '').toLowerCase() === key || p.name.toLowerCase() === key
219        );
220        if (!exists) {
221            addParticipant(trimmed, trimmed, line);
222        }
223    };
224
225    // Stack for nested blocks (loop, alt, opt, etc.)
226    // We need to map Mermaid blocks to Frame/FrameEnd/BoxStart/BoxEnd
227    // Mermaid: loop, alt, opt, par, rect
228    // Our AST: Frame (type=loop/alt/opt/par), FrameEnd, BoxStart (rect), BoxEnd
229    const blockStack = [];
230
231    lines.forEach((line, index) => {
232        const trimmed = line.trim();
233        if (!trimmed || trimmed.startsWith('%%')) return; // Skip empty lines and comments
234        if (trimmed === 'sequenceDiagram') return; // Skip header
235
236        const lineNum = index + 1;
237
238        // 1. Create statements (must come before regular participant parsing)
239        // create participant Database
240        // create entity "Order" as order
241        // create actor "User" as user
242        const createMatch = trimmed.match(/^create\s+(participant|actor|boundary|control|entity|database|collections|queue)\s+((?:".*?"|[^"\s]+)(?:\s+as\s+(?:".*?"|[^"\s]+))?)/i);
243        if (createMatch) {
244            const type = createMatch[1].toLowerCase();
245            const rest = createMatch[2];
246
247            // Determine participant type flags
248            const options = { isDynamic: true };
249            if (type === 'actor') options.isActor = true;
250            else if (type === 'entity') options.isEntity = true;
251            else if (type === 'boundary') options.isBoundary = true;
252            else if (type === 'control') options.isControl = true;
253            else if (type === 'database') options.isDatabase = true;
254            else if (type === 'collections') options.isCollections = true;
255            else if (type === 'queue') options.isQueue = true;
256
257            // Check for 'as'
258            // In create statements: create entity "Order" as order
259            // "Order" is the name, "order" is the alias
260            const asMatch = rest.match(/^(".*?"|[^"\s]+)\s+as\s+(".*?"|[^"\s]+)$/);
261            debugger;
262            let participantAlias;
263            if (asMatch) {
264                let name = asMatch[2].replace(/"/g, ''); // First part is the name
265                let alias = asMatch[1].replace(/"/g, ''); // Second part (after "as") is the alias
266                participantAlias = alias;
267                addParticipant(name, alias, lineNum, options);
268            } else {
269                let name = rest.replace(/"/g, '');
270                participantAlias = name;
271                addParticipant(name, name, lineNum, options);
272            }
273
274            // Add Create statement
275            statements.push({
276                type: 'Create',
277                participant: participantAlias,
278                line: lineNum
279            });
280
281            return;
282        }
283
284        // 2. Participants / Actors (regular declarations)
285        // participant [Name] or participant [Alias] as [Name]
286        const partMatch = trimmed.match(/^(participant|actor|boundary|control|entity|database|collections|queue)\s+((?:".*?"|[^"\s]+)(?:\s+as\s+(?:".*?"|[^"\s]+))?)/i);
287        if (partMatch) {
288            const type = partMatch[1].toLowerCase();
289            const rest = partMatch[2];
290
291            // Determine participant type flags
292            const options = {};
293            if (type === 'actor') options.isActor = true;
294            else if (type === 'entity') options.isEntity = true;
295            else if (type === 'boundary') options.isBoundary = true;
296            else if (type === 'control') options.isControl = true;
297            else if (type === 'database') options.isDatabase = true;
298            else if (type === 'collections') options.isCollections = true;
299            else if (type === 'queue') options.isQueue = true;
300
301            // Check for 'as'
302            const asMatch = rest.match(/^(".*?"|[^"\s]+)\s+as\s+(".*?"|[^"\s]+)$/);
303            if (asMatch) {
304                let alias = asMatch[1].replace(/"/g, '');
305                let name = asMatch[2].replace(/"/g, '');
306                addParticipant(name, alias, lineNum, options);
307            } else {
308                let name = rest.replace(/"/g, '');
309                addParticipant(name, name, lineNum, options);
310            }
311            return;
312        }
313
314        // 3. Activate / Deactivate / Destroy
315        const activateMatch = trimmed.match(/^(activate|deactivate|destroy)\s+(.+)$/);
316        if (activateMatch) {
317            const keyword = activateMatch[1].toLowerCase();
318            const target = activateMatch[2].trim().replace(/"/g, '');
319            ensureParticipant(target, lineNum);
320
321            if (keyword === 'destroy') {
322                // Don't add destroy immediately - queue it for after the next signal
323                pendingDestroyStatements.push({ type: 'Deactivate', target, line: lineNum });
324                pendingDestroyStatements.push({ type: 'Destroy', target, line: lineNum });
325                return;
326            }
327
328            const type = keyword === 'activate' ? 'Activate' : 'Deactivate';
329
330            if (type === 'Activate') {
331                // Check if we can attach to the last signal
332                // Find the last signal in statements
333                let lastSignalIndex = -1;
334                for (let i = statements.length - 1; i >= 0; i--) {
335                    if (statements[i].type === 'Signal') {
336                        lastSignalIndex = i;
337                        break;
338                    }
339                }
340
341                if (lastSignalIndex !== -1 && statements[lastSignalIndex].to === target) {
342                    // Insert Activate node BEFORE the signal
343                    statements.splice(lastSignalIndex, 0, { type, target, line: lineNum });
344                    return;
345                }
346            }
347
348            statements.push({ type, target, line: lineNum });
349            return;
350        }
351
352        // 4. Notes
353        // Note [right of|left of|over] [Target]: Text
354        const noteMatch = trimmed.match(/^(?:Note|note)\s+(right of|left of|over)\s+([^:]+):\s*(.*)$/i);
355        if (noteMatch) {
356            const posRaw = noteMatch[1].toLowerCase();
357            const targetsRaw = noteMatch[2];
358            const text = noteMatch[3];
359
360            let position = 'over';
361            if (posRaw === 'right of') position = 'right';
362            else if (posRaw === 'left of') position = 'left';
363
364            const targets = targetsRaw.split(',').map(t => t.trim().replace(/"/g, ''));
365            targets.forEach(t => ensureParticipant(t));
366
367            statements.push({
368                type: 'Note',
369                position,
370                targets,
371                text: text.replace(/<br\/>/g, "\n"), // Handle Mermaid line breaks
372                line: lineNum
373            });
374            return;
375        }
376
377        // 5. Loops / Alt / Opt / Par / Rect / Box / Critical / Break
378        const loopMatch = trimmed.match(/^(loop|alt|opt|par|rect|box|critical|break)(?:\s+(.*))?$/);
379        if (loopMatch) {
380            const type = loopMatch[1];
381            let label = loopMatch[2] || '';
382
383            if (type === 'box') {
384                // box [Color] [Label] -> BoxStart
385
386                let color = undefined;
387                let title = label;
388
389                // Check if first word is a valid color
390                const parts = label.split(/\s+/);
391                if (parts.length > 0) {
392                    const potentialColor = parts[0];
393                    // Basic check for color names
394                    // If window.colorNames is not available, try to guess if it's a color
395                    const colorMap = colorNames;
396
397                    // Case-insensitive check
398                    const colorKey = Object.keys(colorMap).find(k => k.toLowerCase() === potentialColor.toLowerCase());
399
400                    if (colorKey) {
401                        color = colorMap[colorKey]; // Use hex value
402                        title = parts.slice(1).join(' ');
403                    } else if (potentialColor.startsWith('#') || potentialColor.startsWith('rgb')) {
404                        color = potentialColor;
405                        title = parts.slice(1).join(' ');
406                    } else if (/^[a-z]+$/i.test(potentialColor) && !potentialColor.includes('"')) {
407                    }
408                }
409
410                statements.push({ type: 'BoxStart', color, title, line: lineNum });
411                blockStack.push('Box');
412            } else {
413                // Frame
414                statements.push({ type: 'Frame', frame: type, label, line: lineNum });
415                blockStack.push('Frame');
416            }
417            return;
418        }
419
420        // 6. Else / And / Option
421        const elseMatch = trimmed.match(/^(else|and|option)(?:\s+(.*))?$/);
422        if (elseMatch) {
423            const type = elseMatch[1];
424            const label = elseMatch[2] || '';
425
426            if (type === 'else') {
427                statements.push({ type: 'FrameElse', label, line: lineNum });
428            } else if (type === 'and') {
429                statements.push({ type: 'FrameAnd', label, line: lineNum });
430            } else if (type === 'option') {
431                statements.push({ type: 'FrameOption', label, line: lineNum });
432            }
433            return;
434        }
435
436        // 7. End (close block)
437        if (trimmed === 'end') {
438            const lastBlock = blockStack.pop();
439            // Check if we popped a Box or Frame
440            if (lastBlock === 'Box') {
441                statements.push({ type: 'BoxEnd', line: lineNum });
442            } else {
443                statements.push({ type: 'FrameEnd', line: lineNum });
444            }
445            return;
446        }
447
448        // 8. Signals
449        // A->B: Text
450        // ...
451        const signalMatch = trimmed.match(/^(".*?"|[^"\s\-<]+)\s*(<<-->>|<<->>|-->>|->>|-->|->|--x|-x|--\)|-\))\s*([+\-]?)\s*(".*?"|[^"\s:\+]+)\s*([+\-]?)(?::\s*(.*))?$/);
452        if (signalMatch) {
453            let from = signalMatch[1].replace(/"/g, '');
454            let arrow = signalMatch[2];
455            let fromAction = signalMatch[3]; // + or - or empty
456            let to = signalMatch[4].replace(/"/g, '');
457            let toAction = signalMatch[5]; // + or - or empty
458            let text = signalMatch[6] || '';
459
460            ensureParticipant(from, lineNum);
461            ensureParticipant(to, lineNum);
462
463            let style = 'solid';
464            let arrowType = 'sync'; // 'sync' (filled) or 'async' (open) or 'cross'
465            let arrowStyle = 'solid'; // 'solid', 'async', 'cross', 'open'
466
467            // Default Markers
468            let markerTo = 'arrow';
469            let markerFrom = 'none';
470
471            if (arrow.includes('--')) style = 'dashed';
472
473            // Determine Arrow Specs
474            if (arrow.startsWith('<<')) {
475                // Bi-directional
476                arrowType = 'sync';
477                arrowStyle = 'solid'; // or user might like 'async' if open arrows? Usually solid
478                // <<->>
479            } else if (arrow.endsWith('>>')) {
480                // ->> or -->> (Standard filled arrow) -> "sync"
481                arrowType = 'sync';
482                arrowStyle = 'solid';
483            } else if (arrow.endsWith('x')) {
484                // -x or --x (Destroy / Cross)
485                arrowType = 'cross';
486                arrowStyle = 'cross';
487                markerTo = 'cross'; // Ensure renderer sees cross
488            } else if (arrow.endsWith(')')) {
489                // -) or --) (Async / Open Arrow)
490                arrowType = 'async';
491                arrowStyle = 'async'; // User JSON shows 'async' for -)
492            } else if (arrow === '->' || arrow === '-->') {
493                // Standard arrow defaults
494                // We use 'solid' (filled) because 'async' (open) marker depends on stroke color,
495                // which defaults to none (invisible) if not provided. Filled defaults to black.
496                arrowType = 'none';
497                arrowStyle = 'solid';
498            }
499
500            // Handle activation shorthand
501            let activateTarget = false;
502            let deactivateSource = false;
503
504            if (fromAction === '+' || toAction === '+') activateTarget = true;
505            if (fromAction === '-' || toAction === '-') deactivateSource = true;
506
507            statements.push({
508                type: 'Signal',
509                from,
510                to,
511                style,
512                arrow: arrowType,
513                arrowType: 'forward',
514                arrowStyle,
515                leftMarker: 'none',
516                rightMarker: 'arrow',
517                slant: null,
518                description: fixCharacterReference(text.replace(/<br\/>/g, "\n")),
519                line: lineNum,
520                bidirectional: arrow.startsWith('<<'),
521                markerFrom,
522                markerTo,
523                isIncoming: false,
524                isOutgoing: false,
525                activateTarget,
526                deactivateSource
527            });
528
529            // After adding signal, flush any pending destroy statements
530            if (pendingDestroyStatements.length > 0) {
531                statements.push(...pendingDestroyStatements);
532                pendingDestroyStatements.length = 0; // Clear the queue
533            }
534
535            return;
536        }
537
538        // 9. Title
539        const titleMatch = trimmed.match(/^title:\s*(.*)$/);
540        if (titleMatch) {
541            title = { type: 'Title', text: titleMatch[1], line: lineNum };
542            return;
543        }
544
545    });
546
547    return {
548        title,
549        header: null,
550        footer: null,
551        participants,
552        statements
553    };
554}
555
556// Export for usage
557if (typeof window !== 'undefined') {
558    window.parseMermaidToAst = parseMermaidToAst;
559}
560if (typeof module !== 'undefined') {
561    module.exports = { parseMermaidToAst };
562}
563
564/**
565 * Replaces patterns like #XXXX; with &XXXX; in a string, 
566 * where XXXX are exactly four digits.
567 * * @param {string} inputString The string to process.
568 * @returns {string} The modified string.
569 */
570function fixCharacterReference(inputString) {
571    // Regex matches:
572    // #       -> Literal '#'
573    // ([a-zA-Z0-9]+) -> Captures alphanumeric code (numeric or named entity)
574    // ;       -> Literal ';'
575    const regex = /#([a-zA-Z0-9]+);/g;
576
577    return inputString.replace(regex, (match, code) => {
578        // If it's a number (base 10)
579        if (/^\d+$/.test(code)) {
580            try {
581                return String.fromCodePoint(parseInt(code, 10));
582            } catch (e) {
583                return match;
584            }
585        }
586        // If it's a named entity (e.g., "infin", "hearts")
587        else if (typeof document !== 'undefined') {
588            try {
589                const txt = document.createElement("textarea");
590                txt.innerHTML = "&" + code + ";";
591                return txt.value;
592            } catch (e) {
593                return match;
594            }
595        }
596
597        return match;
598    });
599}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.