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.