@diagrammo/dgmo 0.8.21 → 0.8.22

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 (93) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +143 -93
  4. package/dist/editor.cjs +17 -3
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +17 -3
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +12 -2
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +12 -2
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +19997 -14886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +331 -8
  15. package/dist/index.d.ts +331 -8
  16. package/dist/index.js +19984 -14889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-sitemap.md +18 -1
  19. package/docs/guide/chart-tech-radar.md +219 -0
  20. package/docs/guide/registry.json +1 -0
  21. package/docs/language-reference.md +116 -6
  22. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  23. package/gallery/fixtures/c4-full.dgmo +2 -2
  24. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  25. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  26. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  27. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  28. package/gallery/fixtures/gantt-full.dgmo +2 -2
  29. package/gallery/fixtures/gantt.dgmo +2 -2
  30. package/gallery/fixtures/infra-full.dgmo +2 -2
  31. package/gallery/fixtures/infra.dgmo +1 -1
  32. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  33. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  34. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  35. package/gallery/fixtures/tech-radar.dgmo +36 -0
  36. package/gallery/fixtures/timeline.dgmo +1 -1
  37. package/package.json +1 -1
  38. package/src/boxes-and-lines/layout.ts +309 -33
  39. package/src/boxes-and-lines/parser.ts +86 -10
  40. package/src/boxes-and-lines/renderer.ts +250 -91
  41. package/src/boxes-and-lines/types.ts +1 -1
  42. package/src/c4/layout.ts +8 -8
  43. package/src/c4/parser.ts +35 -2
  44. package/src/c4/renderer.ts +19 -3
  45. package/src/c4/types.ts +1 -0
  46. package/src/chart.ts +14 -7
  47. package/src/completion.ts +227 -0
  48. package/src/cycle/layout.ts +732 -0
  49. package/src/cycle/parser.ts +352 -0
  50. package/src/cycle/renderer.ts +539 -0
  51. package/src/cycle/types.ts +77 -0
  52. package/src/d3.ts +87 -8
  53. package/src/dgmo-router.ts +9 -0
  54. package/src/echarts.ts +7 -4
  55. package/src/editor/dgmo.grammar +5 -1
  56. package/src/editor/dgmo.grammar.js +1 -1
  57. package/src/editor/keywords.ts +14 -0
  58. package/src/gantt/parser.ts +2 -8
  59. package/src/graph/flowchart-parser.ts +15 -21
  60. package/src/graph/state-parser.ts +5 -10
  61. package/src/index.ts +50 -0
  62. package/src/infra/layout.ts +218 -74
  63. package/src/infra/parser.ts +30 -6
  64. package/src/infra/renderer.ts +14 -8
  65. package/src/infra/types.ts +10 -3
  66. package/src/journey-map/layout.ts +386 -0
  67. package/src/journey-map/parser.ts +540 -0
  68. package/src/journey-map/renderer.ts +1456 -0
  69. package/src/journey-map/types.ts +47 -0
  70. package/src/kanban/parser.ts +3 -10
  71. package/src/kanban/renderer.ts +31 -15
  72. package/src/mindmap/parser.ts +12 -18
  73. package/src/mindmap/renderer.ts +14 -13
  74. package/src/mindmap/text-wrap.ts +22 -12
  75. package/src/mindmap/types.ts +2 -2
  76. package/src/org/parser.ts +2 -6
  77. package/src/sequence/renderer.ts +144 -38
  78. package/src/sharing.ts +1 -0
  79. package/src/sitemap/layout.ts +21 -6
  80. package/src/sitemap/parser.ts +26 -17
  81. package/src/sitemap/renderer.ts +34 -0
  82. package/src/sitemap/types.ts +1 -0
  83. package/src/tech-radar/index.ts +14 -0
  84. package/src/tech-radar/interactive.ts +1058 -0
  85. package/src/tech-radar/layout.ts +190 -0
  86. package/src/tech-radar/parser.ts +385 -0
  87. package/src/tech-radar/renderer.ts +1159 -0
  88. package/src/tech-radar/shared.ts +187 -0
  89. package/src/tech-radar/types.ts +81 -0
  90. package/src/utils/description-helpers.ts +33 -0
  91. package/src/utils/legend-layout.ts +3 -1
  92. package/src/utils/parsing.ts +46 -7
  93. package/src/utils/tag-groups.ts +46 -60
package/src/c4/layout.ts CHANGED
@@ -689,7 +689,7 @@ export function computeC4NodeDimensions(
689
689
  // (no type label — containers are the default in container view)
690
690
  let height = CARD_V_PAD + NAME_HEIGHT;
691
691
 
692
- const desc = el.metadata['description'];
692
+ const desc = el.description?.join('\n');
693
693
  if (desc) {
694
694
  const contentWidth = width - CARD_H_PAD * 2;
695
695
  const lines = wrapText(desc, contentWidth, DESC_CHAR_WIDTH);
@@ -719,7 +719,7 @@ export function computeC4NodeDimensions(
719
719
  // Context card layout: type + name | divider | description
720
720
  let height = CARD_V_PAD + TYPE_LABEL_HEIGHT + DIVIDER_GAP + NAME_HEIGHT;
721
721
 
722
- const desc = el.metadata['description'];
722
+ const desc = el.description?.join('\n');
723
723
  if (desc) {
724
724
  const contentWidth = width - CARD_H_PAD * 2;
725
725
  const lines = wrapText(desc, contentWidth, DESC_CHAR_WIDTH);
@@ -887,7 +887,7 @@ export function layoutC4Context(
887
887
  id: el.name,
888
888
  name: el.name,
889
889
  type: el.type as 'person' | 'system',
890
- description: el.metadata['description'],
890
+ description: el.description?.join('\n'),
891
891
  metadata: el.metadata,
892
892
  lineNumber: el.lineNumber,
893
893
  color,
@@ -1241,7 +1241,7 @@ export function layoutC4Containers(
1241
1241
  id: el.name,
1242
1242
  name: el.name,
1243
1243
  type: 'container',
1244
- description: el.metadata['description'],
1244
+ description: el.description?.join('\n'),
1245
1245
  metadata: el.metadata,
1246
1246
  lineNumber: el.lineNumber,
1247
1247
  color,
@@ -1267,7 +1267,7 @@ export function layoutC4Containers(
1267
1267
  id: el.name,
1268
1268
  name: el.name,
1269
1269
  type: el.type as 'person' | 'system',
1270
- description: el.metadata['description'],
1270
+ description: el.description?.join('\n'),
1271
1271
  metadata: el.metadata,
1272
1272
  lineNumber: el.lineNumber,
1273
1273
  color,
@@ -1787,7 +1787,7 @@ export function layoutC4Components(
1787
1787
  id: el.name,
1788
1788
  name: el.name,
1789
1789
  type: 'component',
1790
- description: el.metadata['description'],
1790
+ description: el.description?.join('\n'),
1791
1791
  metadata: el.metadata,
1792
1792
  lineNumber: el.lineNumber,
1793
1793
  color,
@@ -1813,7 +1813,7 @@ export function layoutC4Components(
1813
1813
  id: el.name,
1814
1814
  name: el.name,
1815
1815
  type: el.type as 'person' | 'system' | 'container',
1816
- description: el.metadata['description'],
1816
+ description: el.description?.join('\n'),
1817
1817
  metadata: el.metadata,
1818
1818
  lineNumber: el.lineNumber,
1819
1819
  color,
@@ -2236,7 +2236,7 @@ export function layoutC4Deployment(
2236
2236
  id: r.element.name,
2237
2237
  name: r.element.name,
2238
2238
  type: 'container',
2239
- description: r.element.metadata['description'],
2239
+ description: r.element.description?.join('\n'),
2240
2240
  metadata: r.element.metadata,
2241
2241
  lineNumber: r.element.lineNumber,
2242
2242
  color,
package/src/c4/parser.ts CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  parseFirstLine,
21
21
  OPTION_NOCOLON_RE,
22
22
  } from '../utils/parsing';
23
+ import { tryStripDescriptionKeyword } from '../utils/description-helpers';
23
24
  import type {
24
25
  ParsedC4,
25
26
  C4Element,
@@ -695,11 +696,20 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
695
696
  explicitShape ??
696
697
  inferC4Shape(namePart, metadata.tech ?? metadata.technology);
697
698
 
699
+ // Extract description from pipe metadata into dedicated field
700
+ let isADescription: string[] | undefined;
701
+ if ('description' in metadata) {
702
+ const descVal = metadata['description'].trim();
703
+ if (descVal) isADescription = [descVal];
704
+ delete metadata['description'];
705
+ }
706
+
698
707
  const element: C4Element = {
699
708
  name: namePart,
700
709
  type: elementType,
701
710
  shape,
702
711
  metadata,
712
+ description: isADescription,
703
713
  children: [],
704
714
  groups: [],
705
715
  relationships: [],
@@ -762,11 +772,20 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
762
772
  explicitShape ??
763
773
  inferC4Shape(namePart, metadata.tech ?? metadata.technology);
764
774
 
775
+ // Extract description from pipe metadata into dedicated field
776
+ let prefixDescription: string[] | undefined;
777
+ if ('description' in metadata) {
778
+ const descVal = metadata['description'].trim();
779
+ if (descVal) prefixDescription = [descVal];
780
+ delete metadata['description'];
781
+ }
782
+
765
783
  const element: C4Element = {
766
784
  name: namePart,
767
785
  type: elementType,
768
786
  shape,
769
787
  metadata,
788
+ description: prefixDescription,
770
789
  children: [],
771
790
  groups: [],
772
791
  relationships: [],
@@ -805,6 +824,15 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
805
824
 
806
825
  const key = aliasMap.get(rawKey) ?? rawKey;
807
826
  const value = metadataMatch[2].trim();
827
+
828
+ // Extract description into dedicated field
829
+ if (key === 'description') {
830
+ if (!parentEntry.element.description)
831
+ parentEntry.element.description = [];
832
+ parentEntry.element.description.push(value);
833
+ continue;
834
+ }
835
+
808
836
  parentEntry.element.metadata[key] = value;
809
837
  continue;
810
838
  }
@@ -821,9 +849,14 @@ export function parseC4(content: string, palette?: PaletteColors): ParsedC4 {
821
849
  }
822
850
  }
823
851
 
824
- // If inside a parent, could be an unkeyed description or misc text — ignore gracefully
852
+ // If inside a parent, try as keyword-based or keywordless description
825
853
  const parent = findParentElement(indent, stack);
826
- if (!parent) {
854
+ if (parent) {
855
+ const descResult = tryStripDescriptionKeyword(trimmed);
856
+ const descText = descResult.isKeyword ? descResult.text : trimmed;
857
+ if (!parent.element.description) parent.element.description = [];
858
+ parent.element.description.push(descText);
859
+ } else {
827
860
  pushError(lineNumber, `Unexpected content: "${trimmed}"`);
828
861
  }
829
862
  }
@@ -8,6 +8,7 @@ import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import { renderInlineText } from '../utils/inline-markdown';
11
+ import { preprocessDescriptionLine } from '../utils/description-helpers';
11
12
  import type { ParsedC4 } from './types';
12
13
  import type { C4LayoutResult, C4LayoutEdge } from './layout';
13
14
  import { parseC4 } from './parser';
@@ -572,7 +573,12 @@ export function renderC4Context(
572
573
  .attr('dominant-baseline', 'central')
573
574
  .attr('fill', palette.textMuted)
574
575
  .attr('font-size', DESC_FONT_SIZE);
575
- renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
576
+ renderInlineText(
577
+ textEl,
578
+ preprocessDescriptionLine(line),
579
+ palette,
580
+ DESC_FONT_SIZE
581
+ );
576
582
  yPos += DESC_LINE_HEIGHT;
577
583
  }
578
584
  }
@@ -1641,7 +1647,12 @@ export function renderC4Containers(
1641
1647
  .attr('dominant-baseline', 'central')
1642
1648
  .attr('fill', palette.textMuted)
1643
1649
  .attr('font-size', DESC_FONT_SIZE);
1644
- renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
1650
+ renderInlineText(
1651
+ textEl,
1652
+ preprocessDescriptionLine(line),
1653
+ palette,
1654
+ DESC_FONT_SIZE
1655
+ );
1645
1656
  yPos += DESC_LINE_HEIGHT;
1646
1657
  }
1647
1658
  }
@@ -1720,7 +1731,12 @@ export function renderC4Containers(
1720
1731
  .attr('dominant-baseline', 'central')
1721
1732
  .attr('fill', palette.textMuted)
1722
1733
  .attr('font-size', DESC_FONT_SIZE);
1723
- renderInlineText(textEl, line, palette, DESC_FONT_SIZE);
1734
+ renderInlineText(
1735
+ textEl,
1736
+ preprocessDescriptionLine(line),
1737
+ palette,
1738
+ DESC_FONT_SIZE
1739
+ );
1724
1740
  yPos += DESC_LINE_HEIGHT;
1725
1741
  }
1726
1742
  }
package/src/c4/types.ts CHANGED
@@ -53,6 +53,7 @@ export interface C4Element {
53
53
  type: C4ElementType;
54
54
  shape: C4Shape;
55
55
  metadata: Record<string, string>;
56
+ description?: string[];
56
57
  children: C4Element[];
57
58
  groups: C4Group[];
58
59
  relationships: C4Relationship[];
package/src/chart.ts CHANGED
@@ -64,6 +64,7 @@ import type { PaletteColors } from './palettes';
64
64
  import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
65
65
  import {
66
66
  extractColor,
67
+ normalizeNumericToken,
67
68
  parseFirstLine,
68
69
  parseSeriesNames,
69
70
  } from './utils/parsing';
@@ -528,7 +529,8 @@ export function parseDataRowValues(
528
529
  // Find how many trailing comma-separated parts are numeric
529
530
  let numericCount = 0;
530
531
  for (let j = commaParts.length - 1; j >= 0; j--) {
531
- const part = commaParts[j].trim();
532
+ const part =
533
+ normalizeNumericToken(commaParts[j].trim()) ?? commaParts[j].trim();
532
534
  if (part && !isNaN(parseFloat(part)) && isFinite(Number(part))) {
533
535
  numericCount++;
534
536
  } else {
@@ -545,7 +547,9 @@ export function parseDataRowValues(
545
547
  // Split firstPart from the right: last space-separated token must be numeric
546
548
  const lastSpaceIdx = firstPart.lastIndexOf(' ');
547
549
  if (lastSpaceIdx >= 0) {
548
- const possibleFirstVal = firstPart.substring(lastSpaceIdx + 1).trim();
550
+ const rawFirstVal = firstPart.substring(lastSpaceIdx + 1).trim();
551
+ const possibleFirstVal =
552
+ normalizeNumericToken(rawFirstVal) ?? rawFirstVal;
549
553
  if (
550
554
  possibleFirstVal &&
551
555
  !isNaN(parseFloat(possibleFirstVal)) &&
@@ -555,7 +559,8 @@ export function parseDataRowValues(
555
559
  if (label) {
556
560
  const values = [parseFloat(possibleFirstVal)];
557
561
  for (const p of extraValueParts) {
558
- values.push(parseFloat(p.trim()));
562
+ const normP = normalizeNumericToken(p.trim()) ?? p.trim();
563
+ values.push(parseFloat(normP));
559
564
  }
560
565
  return { label, values };
561
566
  }
@@ -577,8 +582,9 @@ export function parseDataRowValues(
577
582
  let idx = tokens.length - 1;
578
583
  while (idx >= 1 && values.length < limit) {
579
584
  const tok = tokens[idx];
580
- const num = parseFloat(tok);
581
- if (isNaN(num) || !isFinite(Number(tok))) break;
585
+ const normTok = normalizeNumericToken(tok) ?? tok;
586
+ const num = parseFloat(normTok);
587
+ if (isNaN(num) || !isFinite(Number(normTok))) break;
582
588
  values.unshift(num);
583
589
  idx--;
584
590
  }
@@ -590,8 +596,9 @@ export function parseDataRowValues(
590
596
 
591
597
  // Single-value mode: only the last space-separated token
592
598
  const lastToken = tokens[tokens.length - 1];
593
- const num = parseFloat(lastToken);
594
- if (isNaN(num) || !isFinite(Number(lastToken))) return null;
599
+ const normalizedLast = normalizeNumericToken(lastToken) ?? lastToken;
600
+ const num = parseFloat(normalizedLast);
601
+ if (isNaN(num) || !isFinite(Number(normalizedLast))) return null;
595
602
 
596
603
  const label = tokens.slice(0, -1).join(' ');
597
604
  if (!label) return null;
package/src/completion.ts CHANGED
@@ -349,6 +349,38 @@ export const COMPLETION_REGISTRY = new Map<string, DirectiveSpec>([
349
349
  'active-tag': { description: 'Active tag group name' },
350
350
  }),
351
351
  ],
352
+ [
353
+ 'tech-radar',
354
+ withGlobals({
355
+ rings: { description: 'Ring names block (innermost to outermost)' },
356
+ quadrant: {
357
+ description:
358
+ 'Quadrant position (top-left, top-right, bottom-left, bottom-right)',
359
+ },
360
+ ring: { description: 'Ring assignment for a blip' },
361
+ trend: { description: 'Blip trend (new, up, down, stable)' },
362
+ color: { description: 'Override quadrant color' },
363
+ }),
364
+ ],
365
+ [
366
+ 'cycle',
367
+ withGlobals({
368
+ 'direction-counterclockwise': {
369
+ description: 'Reverse cycle direction to counterclockwise',
370
+ },
371
+ 'hide-descriptions': { description: 'Hide node and edge descriptions' },
372
+ 'circle-nodes': {
373
+ description: 'Render nodes as circles instead of rectangles',
374
+ },
375
+ }),
376
+ ],
377
+ [
378
+ 'journey-map',
379
+ withGlobals({
380
+ 'no-legend': { description: 'Hide the score legend' },
381
+ persona: { description: 'Define the journey persona' },
382
+ }),
383
+ ],
352
384
  ]);
353
385
 
354
386
  // ============================================================
@@ -394,6 +426,9 @@ const CHART_TYPE_DESCRIPTIONS: Record<string, string> = {
394
426
  'boxes-and-lines': 'Boxes and lines diagram',
395
427
  mindmap: 'Mindmap diagram',
396
428
  wireframe: 'UI wireframe diagram',
429
+ 'tech-radar': 'Technology adoption radar (ThoughtWorks style)',
430
+ cycle: 'Cycle diagram (circular process flow)',
431
+ 'journey-map': 'User journey map with emotion curve',
397
432
  };
398
433
 
399
434
  /** All chart types with descriptions, for chart type autocomplete. Excludes `multi-line` alias. */
@@ -537,6 +572,38 @@ export const PIPE_METADATA = new Map<
537
572
  edge: {},
538
573
  },
539
574
  ],
575
+ [
576
+ 'tech-radar',
577
+ {
578
+ node: {
579
+ quadrant: {
580
+ description: 'Quadrant position',
581
+ values: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
582
+ },
583
+ ring: { description: 'Ring assignment for blip' },
584
+ trend: {
585
+ description: 'Blip trend indicator',
586
+ values: ['new', 'up', 'down', 'stable'],
587
+ },
588
+ color: { description: 'Override quadrant color' },
589
+ },
590
+ edge: {},
591
+ },
592
+ ],
593
+ [
594
+ 'cycle',
595
+ {
596
+ node: {
597
+ color: { description: 'Node fill color (palette name)' },
598
+ span: { description: 'Relative arc distance to next node' },
599
+ description: { description: 'Node description text' },
600
+ },
601
+ edge: {
602
+ color: { description: 'Edge stroke color (palette name)' },
603
+ width: { description: 'Edge stroke width in pixels' },
604
+ },
605
+ },
606
+ ],
540
607
  ]);
541
608
 
542
609
  // ============================================================
@@ -1035,3 +1102,163 @@ registerExtractor('sitemap', extractSitemapSymbols);
1035
1102
  registerExtractor('c4', extractC4Symbols);
1036
1103
  registerExtractor('gantt', extractGanttSymbols);
1037
1104
  registerExtractor('boxes-and-lines', extractBoxesAndLinesSymbols);
1105
+ registerExtractor('tech-radar', extractTechRadarSymbols);
1106
+ registerExtractor('cycle', extractCycleSymbols);
1107
+ registerExtractor('journey-map', extractJourneyMapSymbols);
1108
+
1109
+ function extractTechRadarSymbols(docText: string): DiagramSymbols {
1110
+ const entities: string[] = [];
1111
+ const keywords: string[] = [
1112
+ 'rings',
1113
+ 'quadrant',
1114
+ 'ring',
1115
+ 'trend',
1116
+ 'new',
1117
+ 'up',
1118
+ 'down',
1119
+ 'stable',
1120
+ 'top-left',
1121
+ 'top-right',
1122
+ 'bottom-left',
1123
+ 'bottom-right',
1124
+ 'alias',
1125
+ 'aka',
1126
+ 'color',
1127
+ ];
1128
+
1129
+ // Extract ring names and aliases from the rings block
1130
+ const lines = docText.split('\n');
1131
+ let inRings = false;
1132
+ for (const line of lines) {
1133
+ const trimmed = line.trim();
1134
+ if (trimmed.toLowerCase() === 'rings') {
1135
+ inRings = true;
1136
+ continue;
1137
+ }
1138
+ if (inRings) {
1139
+ if (!trimmed || (line[0] !== ' ' && line[0] !== '\t')) {
1140
+ inRings = false;
1141
+ continue;
1142
+ }
1143
+ // Parse ring name (and alias)
1144
+ const aliasMatch = trimmed.match(/^(.+?)\s+(?:alias|aka)\s+(\S+)\s*$/i);
1145
+ if (aliasMatch) {
1146
+ entities.push(aliasMatch[1].trim());
1147
+ entities.push(aliasMatch[2].trim());
1148
+ } else {
1149
+ entities.push(trimmed);
1150
+ }
1151
+ }
1152
+ }
1153
+
1154
+ return { kind: 'tech-radar', entities, keywords };
1155
+ }
1156
+
1157
+ // ============================================================
1158
+ // Cycle extractor
1159
+ // ============================================================
1160
+
1161
+ function extractCycleSymbols(docText: string): DiagramSymbols {
1162
+ const lines = docText.split('\n');
1163
+ const entities: string[] = [];
1164
+ let pastFirstLine = false;
1165
+
1166
+ for (const line of lines) {
1167
+ const trimmed = line.trim();
1168
+ if (!trimmed || trimmed.startsWith('//')) continue;
1169
+
1170
+ if (!pastFirstLine) {
1171
+ pastFirstLine = true;
1172
+ continue;
1173
+ }
1174
+
1175
+ // Skip directives/metadata
1176
+ const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
1177
+ if (METADATA_KEY_SET.has(firstToken)) continue;
1178
+ if (
1179
+ firstToken === 'direction-counterclockwise' ||
1180
+ firstToken === 'circle-nodes' ||
1181
+ firstToken === 'hide-descriptions'
1182
+ )
1183
+ continue;
1184
+
1185
+ // Skip indented lines (descriptions, edges)
1186
+ if (line[0] === ' ' || line[0] === '\t') continue;
1187
+
1188
+ // Node label (strip pipe metadata)
1189
+ const label = trimmed.split('|')[0].trim();
1190
+ if (label && !entities.includes(label)) entities.push(label);
1191
+ }
1192
+
1193
+ return {
1194
+ kind: 'cycle',
1195
+ entities,
1196
+ keywords: [
1197
+ 'direction-counterclockwise',
1198
+ 'hide-descriptions',
1199
+ 'circle-nodes',
1200
+ ],
1201
+ };
1202
+ }
1203
+
1204
+ function extractJourneyMapSymbols(docText: string): DiagramSymbols {
1205
+ const lines = docText.split('\n');
1206
+ const entities: string[] = [];
1207
+ let pastFirstLine = false;
1208
+
1209
+ for (const line of lines) {
1210
+ const trimmed = line.trim();
1211
+ if (!trimmed || trimmed.startsWith('//')) continue;
1212
+
1213
+ if (!pastFirstLine) {
1214
+ pastFirstLine = true;
1215
+ continue;
1216
+ }
1217
+
1218
+ // Skip directives/metadata at indent 0
1219
+ const firstToken = trimmed.split(/\s+/)[0].toLowerCase();
1220
+ if (METADATA_KEY_SET.has(firstToken)) continue;
1221
+ if (
1222
+ firstToken === 'persona' ||
1223
+ firstToken === 'tag' ||
1224
+ firstToken === 'no-legend'
1225
+ )
1226
+ continue;
1227
+
1228
+ const isIndented = line[0] === ' ' || line[0] === '\t';
1229
+
1230
+ // Skip deep-indented lines (annotations, descriptions under steps)
1231
+ // but keep singly-indented lines (steps within phases)
1232
+ if (isIndented) {
1233
+ // Annotation/description keywords — skip
1234
+ if (/^(pain|opportunity|thought|description)\s*:/i.test(trimmed))
1235
+ continue;
1236
+ // Tag group entries — skip
1237
+ if (/^\S+\([^)]+\)/.test(trimmed)) continue;
1238
+ }
1239
+
1240
+ // Phase header
1241
+ const phaseMatch = trimmed.match(/^\[(.+?)\]$/);
1242
+ if (phaseMatch) {
1243
+ entities.push(phaseMatch[1].trim());
1244
+ continue;
1245
+ }
1246
+
1247
+ // Step label (strip pipe metadata) — works for both indent 0 and indented steps
1248
+ const label = trimmed.split('|')[0].trim();
1249
+ if (label && !entities.includes(label)) entities.push(label);
1250
+ }
1251
+
1252
+ return {
1253
+ kind: 'journey-map',
1254
+ entities,
1255
+ keywords: [
1256
+ 'persona',
1257
+ 'no-legend',
1258
+ 'pain',
1259
+ 'opportunity',
1260
+ 'thought',
1261
+ 'description',
1262
+ ],
1263
+ };
1264
+ }