@diagrammo/dgmo 0.47.0 → 0.48.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.
Files changed (345) hide show
  1. package/dist/advanced.js +11 -11
  2. package/dist/auto.js +16 -16
  3. package/dist/auto.mjs +41 -12
  4. package/dist/block.js +4 -4
  5. package/dist/{chunk-FNB3PNGF.js → chunk-7DTWEFUE.js} +3 -3
  6. package/dist/{chunk-2DXMZRWJ.js → chunk-B65G5DNL.js} +8 -1
  7. package/dist/{chunk-RZCF2VVM.js → chunk-B7JUCQUA.js} +3 -1
  8. package/dist/{chunk-DU7W62N5.js → chunk-FANX6UJR.js} +3 -3
  9. package/dist/{chunk-6FZYCKXB.js → chunk-HSH73DP6.js} +2 -2
  10. package/dist/{chunk-P4RH4OSP.js → chunk-ILKORZ3H.js} +2 -2
  11. package/dist/{chunk-Y5QGVTVC.js → chunk-JTSMEYIZ.js} +7 -3
  12. package/dist/{chunk-RCERWOJR.js → chunk-ODZVHU3R.js} +1 -2
  13. package/dist/{chunk-DZAQBW35.js → chunk-SGZYXELT.js} +3 -7
  14. package/dist/{chunk-CUVHQHZ7.js → chunk-THXCFRTU.js} +3 -3
  15. package/dist/{chunk-IH6ZMVQ5.js → chunk-UC7TFHUM.js} +29 -11
  16. package/dist/{chunk-EMS2SK26.js → chunk-ZBWMSD5N.js} +3 -1
  17. package/dist/cli.cjs +15 -15
  18. package/dist/{dgmo-router-DZCIDUO5.js → dgmo-router-X35VLZLM.js} +3 -3
  19. package/dist/element.js +16 -16
  20. package/dist/element.mjs +41 -12
  21. package/dist/{flowchart-renderer-ZAEOL4DX.js → flowchart-renderer-SNDSII4D.js} +2 -2
  22. package/dist/index.d.ts +4 -2
  23. package/dist/index.js +4 -4
  24. package/dist/{parser-FUSKFGZN.js → parser-2EWECDSA.js} +2 -2
  25. package/dist/{renderer-UWA5XS74.js → renderer-4JHRUBE6.js} +3 -3
  26. package/dist/{renderer-WZRREQ2P.js → renderer-5MYBMBYO.js} +2 -2
  27. package/dist/{renderer-TOCOVLHS.js → renderer-B2OMQIQG.js} +3 -3
  28. package/dist/{renderer-2XQQQ6QR.js → renderer-N43WR5GV.js} +3 -3
  29. package/dist/{renderer-T7YKC5VP.js → renderer-ZRNG54CW.js} +3 -3
  30. package/dist/{state-renderer-RFYHSP4K.js → state-renderer-B4DQBM6M.js} +2 -2
  31. package/package.json +23 -71
  32. package/dist/advanced.cjs +0 -71648
  33. package/dist/advanced.d.cts +0 -5719
  34. package/dist/auto.cjs +0 -70648
  35. package/dist/auto.d.cts +0 -54
  36. package/dist/block.cjs +0 -70208
  37. package/dist/block.d.cts +0 -115
  38. package/dist/editor.cjs +0 -526
  39. package/dist/editor.d.cts +0 -26
  40. package/dist/element.cjs +0 -70530
  41. package/dist/element.d.cts +0 -62
  42. package/dist/highlight.cjs +0 -852
  43. package/dist/highlight.d.cts +0 -49
  44. package/dist/index.cjs +0 -70491
  45. package/dist/index.d.cts +0 -160
  46. package/dist/pert.cjs +0 -325
  47. package/dist/pert.d.cts +0 -559
  48. package/dist/themes-1-CuKpeH.d.cts +0 -908
  49. package/src/advanced.ts +0 -873
  50. package/src/arc/parser.ts +0 -15
  51. package/src/arc/renderer.ts +0 -633
  52. package/src/auto/index.ts +0 -621
  53. package/src/auto/shared.ts +0 -370
  54. package/src/auto/styles.ts +0 -73
  55. package/src/block/layout.ts +0 -198
  56. package/src/block/parser.ts +0 -427
  57. package/src/block/renderer.ts +0 -484
  58. package/src/block/types.ts +0 -67
  59. package/src/boxes-and-lines/collapse.ts +0 -96
  60. package/src/boxes-and-lines/focus.ts +0 -257
  61. package/src/boxes-and-lines/label-placement.ts +0 -339
  62. package/src/boxes-and-lines/layout-grouped.ts +0 -912
  63. package/src/boxes-and-lines/layout-layered.ts +0 -722
  64. package/src/boxes-and-lines/layout-search.ts +0 -1678
  65. package/src/boxes-and-lines/layout.ts +0 -398
  66. package/src/boxes-and-lines/parser.ts +0 -1181
  67. package/src/boxes-and-lines/renderer.ts +0 -1465
  68. package/src/boxes-and-lines/types.ts +0 -66
  69. package/src/c4/layout.ts +0 -2441
  70. package/src/c4/parser.ts +0 -1158
  71. package/src/c4/renderer.ts +0 -2008
  72. package/src/c4/types.ts +0 -89
  73. package/src/chart-type-registry.ts +0 -529
  74. package/src/chart-types.ts +0 -225
  75. package/src/chart.ts +0 -685
  76. package/src/charts-d3/bar.ts +0 -338
  77. package/src/charts-d3/chord.ts +0 -140
  78. package/src/charts-d3/function.ts +0 -147
  79. package/src/charts-d3/funnel.ts +0 -104
  80. package/src/charts-d3/heatmap.ts +0 -145
  81. package/src/charts-d3/index.ts +0 -337
  82. package/src/charts-d3/interactions.ts +0 -477
  83. package/src/charts-d3/line.ts +0 -344
  84. package/src/charts-d3/mount.ts +0 -151
  85. package/src/charts-d3/pie.ts +0 -117
  86. package/src/charts-d3/polar.ts +0 -89
  87. package/src/charts-d3/radar.ts +0 -0
  88. package/src/charts-d3/sankey.ts +0 -0
  89. package/src/charts-d3/scatter.ts +0 -185
  90. package/src/charts-d3/shared.ts +0 -208
  91. package/src/class/layout.ts +0 -326
  92. package/src/class/parser.ts +0 -635
  93. package/src/class/renderer.ts +0 -793
  94. package/src/class/types.ts +0 -58
  95. package/src/cli-banner.ts +0 -106
  96. package/src/cli.ts +0 -1080
  97. package/src/colors-diagnostics.ts +0 -35
  98. package/src/colors.ts +0 -379
  99. package/src/completion-types.ts +0 -27
  100. package/src/completion.ts +0 -2668
  101. package/src/cycle/layout.ts +0 -1026
  102. package/src/cycle/parser.ts +0 -387
  103. package/src/cycle/renderer.ts +0 -653
  104. package/src/cycle/types.ts +0 -101
  105. package/src/d3.ts +0 -1434
  106. package/src/data-chart-parser.ts +0 -1007
  107. package/src/dgmo-router.ts +0 -296
  108. package/src/diagnostics-registry.ts +0 -108
  109. package/src/diagnostics.ts +0 -0
  110. package/src/dimensions.ts +0 -20
  111. package/src/directives-registry.ts +0 -396
  112. package/src/editor/dgmo.grammar +0 -76
  113. package/src/editor/dgmo.grammar.d.ts +0 -2
  114. package/src/editor/dgmo.grammar.js +0 -18
  115. package/src/editor/dgmo.grammar.terms.d.ts +0 -5
  116. package/src/editor/dgmo.grammar.terms.js +0 -35
  117. package/src/editor/highlight-api.ts +0 -596
  118. package/src/editor/highlight.ts +0 -36
  119. package/src/editor/index.ts +0 -27
  120. package/src/editor/keywords.ts +0 -128
  121. package/src/editor/tokens.ts +0 -30
  122. package/src/element/index.ts +0 -432
  123. package/src/embed/css.ts +0 -312
  124. package/src/embed/escape.ts +0 -15
  125. package/src/embed/index.ts +0 -280
  126. package/src/er/classify.ts +0 -228
  127. package/src/er/layout.ts +0 -530
  128. package/src/er/parser.ts +0 -750
  129. package/src/er/renderer.ts +0 -725
  130. package/src/er/types.ts +0 -52
  131. package/src/error-card.ts +0 -269
  132. package/src/event-line/diagnostics.ts +0 -117
  133. package/src/event-line/parser.ts +0 -643
  134. package/src/event-line/renderer.ts +0 -1742
  135. package/src/event-line/types.ts +0 -74
  136. package/src/fonts.ts +0 -3
  137. package/src/gantt/calculator.ts +0 -979
  138. package/src/gantt/parser.ts +0 -1641
  139. package/src/gantt/renderer.ts +0 -3787
  140. package/src/gantt/resolver.ts +0 -172
  141. package/src/gantt/types.ts +0 -206
  142. package/src/graph/diagnostics.ts +0 -64
  143. package/src/graph/edge-spline.ts +0 -29
  144. package/src/graph/flowchart-parser.ts +0 -759
  145. package/src/graph/flowchart-renderer.ts +0 -817
  146. package/src/graph/layout.ts +0 -472
  147. package/src/graph/notes.ts +0 -21
  148. package/src/graph/state-collapse.ts +0 -78
  149. package/src/graph/state-parser.ts +0 -574
  150. package/src/graph/state-renderer.ts +0 -653
  151. package/src/graph/types.ts +0 -65
  152. package/src/index.ts +0 -261
  153. package/src/infra/compute.ts +0 -1385
  154. package/src/infra/layout.ts +0 -888
  155. package/src/infra/parser.ts +0 -1234
  156. package/src/infra/renderer.ts +0 -2534
  157. package/src/infra/roles.ts +0 -75
  158. package/src/infra/types.ts +0 -216
  159. package/src/infra/validation.ts +0 -201
  160. package/src/journey-map/layout.ts +0 -490
  161. package/src/journey-map/parser.ts +0 -557
  162. package/src/journey-map/renderer.ts +0 -1580
  163. package/src/journey-map/types.ts +0 -47
  164. package/src/kanban/mutations.ts +0 -175
  165. package/src/kanban/parser.ts +0 -509
  166. package/src/kanban/renderer.ts +0 -1449
  167. package/src/kanban/types.ts +0 -39
  168. package/src/label-layout.ts +0 -368
  169. package/src/map/colorize.ts +0 -54
  170. package/src/map/completion.ts +0 -316
  171. package/src/map/context-labels.ts +0 -609
  172. package/src/map/data/PROVENANCE.json +0 -1
  173. package/src/map/data/README.md +0 -40
  174. package/src/map/data/airport-collisions.json +0 -1
  175. package/src/map/data/airports.json +0 -1
  176. package/src/map/data/gazetteer.json +0 -1
  177. package/src/map/data/lakes.json +0 -1
  178. package/src/map/data/mountain-ranges.json +0 -1
  179. package/src/map/data/na-lakes.json +0 -1
  180. package/src/map/data/na-land.json +0 -1
  181. package/src/map/data/region-names.json +0 -1
  182. package/src/map/data/rivers.json +0 -1
  183. package/src/map/data/types.ts +0 -121
  184. package/src/map/data/us-states.json +0 -1
  185. package/src/map/data/water-bodies.json +0 -1
  186. package/src/map/data/world-coarse.json +0 -1
  187. package/src/map/data/world-detail.json +0 -1
  188. package/src/map/diagnostics.ts +0 -134
  189. package/src/map/dimensions.ts +0 -150
  190. package/src/map/geo-query.ts +0 -295
  191. package/src/map/geo.ts +0 -438
  192. package/src/map/invert.ts +0 -111
  193. package/src/map/layout.ts +0 -5069
  194. package/src/map/legend-band.ts +0 -127
  195. package/src/map/load-data.ts +0 -186
  196. package/src/map/parser.ts +0 -783
  197. package/src/map/renderer.ts +0 -1445
  198. package/src/map/resolved-types.ts +0 -160
  199. package/src/map/resolver.ts +0 -1146
  200. package/src/map/types.ts +0 -194
  201. package/src/mindmap/collapse.ts +0 -67
  202. package/src/mindmap/layout.ts +0 -649
  203. package/src/mindmap/parser.ts +0 -430
  204. package/src/mindmap/renderer.ts +0 -637
  205. package/src/mindmap/text-wrap.ts +0 -218
  206. package/src/mindmap/types.ts +0 -56
  207. package/src/org/collapse.ts +0 -151
  208. package/src/org/layout.ts +0 -1262
  209. package/src/org/parser.ts +0 -519
  210. package/src/org/renderer.ts +0 -866
  211. package/src/org/resolver.ts +0 -636
  212. package/src/palettes/atlas.ts +0 -77
  213. package/src/palettes/blueprint.ts +0 -73
  214. package/src/palettes/catppuccin.ts +0 -84
  215. package/src/palettes/color-utils.ts +0 -443
  216. package/src/palettes/index.ts +0 -67
  217. package/src/palettes/nord.ts +0 -67
  218. package/src/palettes/registry.ts +0 -114
  219. package/src/palettes/slate.ts +0 -73
  220. package/src/palettes/tidewater.ts +0 -73
  221. package/src/palettes/tokyo-night.ts +0 -86
  222. package/src/palettes/types.ts +0 -88
  223. package/src/pert/analyzer.ts +0 -1183
  224. package/src/pert/diagnostics.ts +0 -110
  225. package/src/pert/index.ts +0 -43
  226. package/src/pert/internal.ts +0 -252
  227. package/src/pert/layout.ts +0 -971
  228. package/src/pert/monte-carlo.ts +0 -446
  229. package/src/pert/parser.ts +0 -1606
  230. package/src/pert/renderer.ts +0 -4009
  231. package/src/pert/share-normalize.ts +0 -62
  232. package/src/pert/types.ts +0 -604
  233. package/src/pyramid/parser.ts +0 -185
  234. package/src/pyramid/renderer.ts +0 -768
  235. package/src/pyramid/types.ts +0 -28
  236. package/src/quadrant/parser.ts +0 -18
  237. package/src/quadrant/renderer.ts +0 -638
  238. package/src/raci/diagnostics.ts +0 -213
  239. package/src/raci/index.ts +0 -23
  240. package/src/raci/mutations.ts +0 -321
  241. package/src/raci/parser.ts +0 -699
  242. package/src/raci/renderer.ts +0 -1627
  243. package/src/raci/types.ts +0 -82
  244. package/src/raci/variants.ts +0 -320
  245. package/src/render.ts +0 -268
  246. package/src/ring/parser.ts +0 -212
  247. package/src/ring/renderer.ts +0 -490
  248. package/src/ring/types.ts +0 -27
  249. package/src/sequence/collapse.ts +0 -174
  250. package/src/sequence/parser.ts +0 -1596
  251. package/src/sequence/participant-inference.ts +0 -152
  252. package/src/sequence/renderer.ts +0 -3168
  253. package/src/sequence/tag-resolution.ts +0 -164
  254. package/src/sharing.ts +0 -211
  255. package/src/sitemap/collapse.ts +0 -100
  256. package/src/sitemap/layout.ts +0 -925
  257. package/src/sitemap/parser.ts +0 -755
  258. package/src/sitemap/renderer.ts +0 -828
  259. package/src/sitemap/types.ts +0 -42
  260. package/src/slope/parser.ts +0 -18
  261. package/src/slope/renderer.ts +0 -378
  262. package/src/swimlane/diagnostics.ts +0 -94
  263. package/src/swimlane/layout.ts +0 -597
  264. package/src/swimlane/parser.ts +0 -613
  265. package/src/swimlane/renderer.ts +0 -681
  266. package/src/swimlane/types.ts +0 -151
  267. package/src/tech-radar/interactive.ts +0 -1129
  268. package/src/tech-radar/layout.ts +0 -197
  269. package/src/tech-radar/parser.ts +0 -436
  270. package/src/tech-radar/renderer.ts +0 -1239
  271. package/src/tech-radar/shared.ts +0 -151
  272. package/src/tech-radar/types.ts +0 -86
  273. package/src/themes.ts +0 -22
  274. package/src/timeline/parser.ts +0 -472
  275. package/src/timeline/renderer.ts +0 -3024
  276. package/src/timeline/types.ts +0 -33
  277. package/src/timeline/viz-parser.ts +0 -21
  278. package/src/treemap/diagnostics.ts +0 -62
  279. package/src/treemap/layout-radial.ts +0 -233
  280. package/src/treemap/layout.ts +0 -189
  281. package/src/treemap/parser.ts +0 -463
  282. package/src/treemap/renderer-radial.ts +0 -438
  283. package/src/treemap/renderer.ts +0 -572
  284. package/src/treemap/treemap-shared.ts +0 -309
  285. package/src/treemap/types.ts +0 -60
  286. package/src/utils/arrow-markers.ts +0 -51
  287. package/src/utils/arrows-diagnostics.ts +0 -62
  288. package/src/utils/arrows.ts +0 -227
  289. package/src/utils/brand.ts +0 -61
  290. package/src/utils/card.ts +0 -183
  291. package/src/utils/collapse-engine/tree-edges.ts +0 -115
  292. package/src/utils/collapse-engine/tree.ts +0 -117
  293. package/src/utils/d3-helpers.ts +0 -219
  294. package/src/utils/d3-types.ts +0 -9
  295. package/src/utils/description-helpers.ts +0 -38
  296. package/src/utils/duration.ts +0 -312
  297. package/src/utils/export-container.ts +0 -41
  298. package/src/utils/expr-eval.ts +0 -27
  299. package/src/utils/fit-canvas.ts +0 -64
  300. package/src/utils/hover-styles.ts +0 -673
  301. package/src/utils/inline-markdown.ts +0 -96
  302. package/src/utils/legend-constants.ts +0 -48
  303. package/src/utils/legend-d3.ts +0 -618
  304. package/src/utils/legend-integration.ts +0 -107
  305. package/src/utils/legend-layout.ts +0 -776
  306. package/src/utils/legend-svg.ts +0 -214
  307. package/src/utils/legend-types.ts +0 -272
  308. package/src/utils/name-normalize.ts +0 -163
  309. package/src/utils/note-box/constants.ts +0 -25
  310. package/src/utils/note-box/index.ts +0 -11
  311. package/src/utils/note-box/metrics.ts +0 -90
  312. package/src/utils/note-box/svg.ts +0 -331
  313. package/src/utils/notes/bounds.ts +0 -30
  314. package/src/utils/notes/build.ts +0 -131
  315. package/src/utils/notes/index.ts +0 -18
  316. package/src/utils/notes/model.ts +0 -19
  317. package/src/utils/notes/parse.ts +0 -131
  318. package/src/utils/notes/place.ts +0 -177
  319. package/src/utils/notes/resolve.ts +0 -88
  320. package/src/utils/number-format.ts +0 -36
  321. package/src/utils/parsing.ts +0 -953
  322. package/src/utils/reserved-key-registry.ts +0 -239
  323. package/src/utils/safe-href.ts +0 -38
  324. package/src/utils/scaling.ts +0 -97
  325. package/src/utils/svg-embed.ts +0 -251
  326. package/src/utils/tag-groups.ts +0 -607
  327. package/src/utils/text-measure.ts +0 -122
  328. package/src/utils/time-ticks.ts +0 -253
  329. package/src/utils/title-constants.ts +0 -24
  330. package/src/utils/visual-conventions.ts +0 -61
  331. package/src/utils/wrapped-desc.ts +0 -87
  332. package/src/venn/parser.ts +0 -18
  333. package/src/venn/renderer.ts +0 -1047
  334. package/src/version-control/diagnostics.ts +0 -67
  335. package/src/version-control/parser.ts +0 -544
  336. package/src/version-control/renderer.ts +0 -724
  337. package/src/version-control/types.ts +0 -100
  338. package/src/visualizations/parse.ts +0 -1560
  339. package/src/visualizations/types.ts +0 -262
  340. package/src/wireframe/layout.ts +0 -479
  341. package/src/wireframe/parser.ts +0 -1114
  342. package/src/wireframe/renderer.ts +0 -1318
  343. package/src/wireframe/types.ts +0 -110
  344. package/src/wordcloud/parser.ts +0 -18
  345. package/src/wordcloud/renderer.ts +0 -357
@@ -1,1234 +0,0 @@
1
- // ============================================================
2
- // Infra Chart Parser
3
- // ============================================================
4
- //
5
- // Parses `infra [Title]` syntax into a structured InfraModel.
6
- // Handles: chart metadata, component blocks with indented properties
7
- // and connections, [Group] containers, tag groups, pipe metadata.
8
-
9
- import { tagAttrKey } from '../utils/tag-groups';
10
- import {
11
- emit,
12
- formatDgmoError,
13
- makeDgmoError,
14
- NAME_DIAGNOSTIC_CODES,
15
- nameMergedMessage,
16
- suggest,
17
- } from '../diagnostics';
18
- import { GRAPH_DX } from '../graph/diagnostics';
19
- import { tryStripDescriptionKeyword } from '../utils/description-helpers';
20
- import { parseInArrowLabel } from '../utils/arrows';
21
- import {
22
- measureIndent,
23
- parseFirstLine,
24
- OPTION_NOCOLON_RE,
25
- tryParseSharedOption,
26
- warnUnknownMetaKeys,
27
- } from '../utils/parsing';
28
- import { isRecognizedColorName } from '../colors';
29
- import { normalizeName, displayName } from '../utils/name-normalize';
30
- import {
31
- matchTagBlockHeading,
32
- stripDefaultModifier,
33
- validateTagGroupNames,
34
- } from '../utils/tag-groups';
35
- import { INFRA_REGISTRY, withTagAliases } from '../utils/reserved-key-registry';
36
- import type {
37
- ParsedInfra,
38
- InfraNode,
39
- InfraGroup,
40
- InfraTagGroup,
41
- } from './types';
42
- import { INFRA_BEHAVIOR_KEYS, EDGE_ONLY_KEYS } from './types';
43
- import { INFRA_TOP_LEVEL_OPTION_SET } from '../directives-registry';
44
- import type { Writable } from '../utils/brand';
45
-
46
- // ============================================================
47
- // Regex patterns
48
- // ============================================================
49
-
50
- // Connection: -label-> Target or -> Target (pipe metadata handled by extractPipeMetadata)
51
- const CONNECTION_RE = /^-(?:([^-].*?))?->\s*(.+?)\s*$/;
52
-
53
- // Simple connection shorthand: -> Target (no label, no dash prefix needed for edge)
54
- const SIMPLE_CONNECTION_RE = /^->\s*(.+?)\s*$/;
55
-
56
- // Async connection: ~label~> Target or ~> Target
57
- const ASYNC_CONNECTION_RE = /^~(?:([^~].*?))?~>\s*(.+?)\s*$/;
58
-
59
- // Async simple connection shorthand: ~> Target
60
- const ASYNC_SIMPLE_CONNECTION_RE = /^~>\s*(.+?)\s*$/;
61
-
62
- // Deprecated xN fanout suffix (e.g. "x5" at end of line)
63
- const DEPRECATED_FANOUT_RE = /\bx(\d+)\s*$/;
64
-
65
- // Group declaration: [Group Name] with optional `as <alias>` and optional
66
- // metadata (legacy `| ...` or new same-line `key: value` per §1.4).
67
- // Capture order: 1=label, 2=alias, 3=metadata tail.
68
- const GROUP_RE =
69
- /^\[([^\]]+)\]\s*(?:as\s+([A-Za-z][A-Za-z0-9_]{0,11})\s*)?((?:\|.*)|(?:[a-zA-Z_]\w*\s*:.*))?$/;
70
-
71
- // Tag value: `Name` or `Name color` (trailing-token color form). Color is
72
- // extracted via the shared `extractColor` helper at use-site (see
73
- // `dgmo/src/utils/parsing.ts:extractColor`), not via this regex. This regex
74
- // just confirms the line shape is a valid tag value (no reserved sigils).
75
- const TAG_VALUE_RE = /^(\w[\w\s]+?)\s*$/;
76
-
77
- // Component line. Accepts either a quoted name ("name with | : reserved chars")
78
- // or a bare name (multi-word allowed; must start with letter/underscore so digit-
79
- // only or sigil-led lines fall through to other branches). Metadata follows
80
- // after a `|` separator (legacy) or as same-line `key: value` (§1.4).
81
- //
82
- // Named captures: qname (quoted content), uname (bare name), alias (`as <x>`
83
- // postfix), meta (the metadata tail). qname/uname are mutually exclusive.
84
- //
85
- // A component declaration: a quoted display name OR a bare name, an OPTIONAL
86
- // `as <alias>` postfix (alias mirrors peelAlias: letter-led, ≤12 chars), then
87
- // optional same-line metadata. The `as` capture is what makes a quoted name +
88
- // alias parse — `"Web App" as web` — which the bare-name path got for free by
89
- // sweeping the alias into the lazy name group and peeling it later, but the
90
- // quoted path could not reach past the closing quote.
91
- const COMPONENT_RE =
92
- /^(?:"(?<qname>[^"]+)"|(?<uname>[a-zA-Z_][\w -]*?))(?:\s+as\s+(?<alias>[A-Za-z][A-Za-z0-9_]{0,11}))?\s*(?<meta>(?:\|.*)|(?:[a-zA-Z_]\w*\s*:.*))?$/;
93
-
94
- // Legacy pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated).
95
- // Kept for transitional back-compat extraction; new same-line metadata
96
- // (§1.4) is preferred and extracted via SAMELINE_META_RE below.
97
- const PIPE_META_RE = /[|,]\s*(\w+)\s*:\s*([^|,]+)/g;
98
- // Same-line metadata (post-0.18.0 §1.4): `k: v, k2: v2` after any name
99
- // region — matches a colon-bearing key-value, comma-separated.
100
- const SAMELINE_META_RE = /(?:^|[\s,])(\w+)\s*:\s*([^,]+?)(?=\s*,|\s*$)/g;
101
-
102
- // Property: key: value (colon-separated)
103
- const PROPERTY_RE = /^([\w-]+):\s*(.+)$/;
104
-
105
- // Percentage value: 80% or 99.99%
106
- const PERCENT_RE = /^([\d.]+)%$/;
107
-
108
- // Range value: N-M (for instances)
109
- const RANGE_RE = /^(\d+)-(\d+)$/;
110
-
111
- // Node names that act as the traffic entry point (edge node)
112
- const EDGE_NODE_NAMES = new Set(['edge', 'internet']);
113
-
114
- // Known top-level option keys (space-separated, no colon). Derived from the
115
- // single-source directives registry; the anti-drift guard
116
- // (tests/highlight-coverage.test.ts) cross-checks it against the editor sets.
117
- export const INFRA_TOP_LEVEL_OPTIONS: ReadonlySet<string> =
118
- INFRA_TOP_LEVEL_OPTION_SET;
119
- const TOP_LEVEL_OPTIONS = INFRA_TOP_LEVEL_OPTIONS;
120
-
121
- // ============================================================
122
- // Helpers
123
- // ============================================================
124
-
125
- function nodeId(name: string): string {
126
- return normalizeName(name);
127
- }
128
-
129
- function groupId(name: string): string {
130
- return `[${normalizeName(name)}]`;
131
- }
132
-
133
- function parsePropertyValue(raw: string): string | number {
134
- const pct = raw.match(PERCENT_RE);
135
- // Capture group 1 in-bounds after successful match.
136
- if (pct) return parseFloat(pct[1]!);
137
-
138
- const num = parseFloat(raw);
139
- if (!isNaN(num) && String(num) === raw.trim()) return num;
140
-
141
- return raw.trim();
142
- }
143
-
144
- function extractPipeMetadata(rest: string): {
145
- tags: Record<string, string>;
146
- clean: string;
147
- hadPipe: boolean;
148
- } {
149
- const tags: Record<string, string> = {};
150
- let clean = rest;
151
- let match: RegExpExecArray | null;
152
- const hadPipe = rest.includes('|');
153
- if (hadPipe) {
154
- PIPE_META_RE.lastIndex = 0;
155
- while ((match = PIPE_META_RE.exec(rest)) !== null) {
156
- tags[match[1]!.trim()] = match[2]!.trim();
157
- clean = clean.replace(match[0], '');
158
- }
159
- return { tags, clean: clean.trim(), hadPipe: true };
160
- }
161
- // §1.4 same-line metadata: no pipe, but `key: value, key2: value2`
162
- // may follow the name. Extract from right-to-left so the leftmost
163
- // name region is preserved as `clean`.
164
- SAMELINE_META_RE.lastIndex = 0;
165
- let firstMatchIdx = -1;
166
- while ((match = SAMELINE_META_RE.exec(rest)) !== null) {
167
- tags[match[1]!.trim()] = match[2]!.trim();
168
- if (firstMatchIdx === -1) firstMatchIdx = match.index;
169
- }
170
- if (firstMatchIdx >= 0) {
171
- clean = rest.substring(0, firstMatchIdx).trim();
172
- }
173
- return { tags, clean: clean.trim(), hadPipe: false };
174
- }
175
-
176
- // Detect unparsed pipe metadata left in a target name after extractPipeMetadata.
177
- // Common case: `split 100%` without a colon isn't picked up by PIPE_META_RE.
178
- const UNPARSED_SPLIT_RE = /\bsplit\s+(\d+)%/;
179
-
180
- function warnUnparsedPipeMeta(
181
- targetName: string,
182
- lineNumber: number,
183
- warnFn: (line: number, message: string) => void
184
- ): void {
185
- if (!targetName.includes('|')) return;
186
- const splitMatch = targetName.match(UNPARSED_SPLIT_RE);
187
- if (splitMatch) {
188
- warnFn(
189
- lineNumber,
190
- `'split ${splitMatch[1]}%' needs a colon — use 'split: ${splitMatch[1]}%'`
191
- );
192
- } else {
193
- warnFn(
194
- lineNumber,
195
- `Unparsed pipe metadata in target — pipe values use 'key: value' syntax`
196
- );
197
- }
198
- }
199
-
200
- // ============================================================
201
- // Parser
202
- // ============================================================
203
-
204
- export function parseInfra(content: string): ParsedInfra {
205
- const lines = content.split('\n');
206
- const options: Record<string, string> = {};
207
- const result: Writable<ParsedInfra> = {
208
- type: 'infra',
209
- title: null,
210
- titleLineNumber: null,
211
- direction: 'LR',
212
- nodes: [],
213
- edges: [],
214
- groups: [],
215
- tagGroups: [],
216
- options,
217
- diagnostics: [],
218
- error: null,
219
- };
220
-
221
- const nodeMap = new Map<string, Writable<InfraNode>>();
222
- // Sidecar mutable tag-records, keyed by node id. The same Record object is
223
- // also referenced from `node.tags`; mutating it here updates both views.
224
- const nodeMutableTags = new Map<string, Record<string, string>>();
225
-
226
- // Push a description line onto a node. Manages the underlying mutable
227
- // array and assigns it back so `node.description` stays in sync.
228
- function pushDescription(node: Writable<InfraNode>, text: string): void {
229
- const existing = (node.description as string[] | undefined) ?? [];
230
- existing.push(text);
231
- node.description = existing;
232
- }
233
-
234
- // Tag-alias set grows as `tag Name as <x>` groups are parsed.
235
- const tagAliasSet = new Set<string>();
236
- const EDGE_PIPE_REGISTRY = {
237
- keys: new Set(['split', 'fanout']),
238
- tagAliases: new Set<string>(),
239
- } as const;
240
-
241
- // Per-parse alias literal → canonical node id (TD-18). Per C8.
242
- const nameAliasMap = new Map<string, string>();
243
- function peelAlias(label: string): { label: string; alias?: string } {
244
- const trimmed = label.trim();
245
- const m = trimmed.match(/^(.*?)\s+as\s+([A-Za-z][A-Za-z0-9_]{0,11})\s*$/);
246
- if (!m) return { label: trimmed };
247
- // Capture groups 1 & 2 in-bounds after successful match.
248
- return { label: m[1]!.trim(), ...(m[2] !== undefined && { alias: m[2] }) };
249
- }
250
-
251
- // Infra nodes have no "is a <type>" declaration — capability comes from
252
- // properties (cache-hit, buffer, drain-rate, …). Strip the suffix if a
253
- // user wrote it (likely copying from sequence/c4 syntax) and warn.
254
- const IS_A_SUFFIX = /^(.*?)\s+is\s+an?\s+[A-Za-z][\w-]*\s*$/i;
255
- function peelInfraDecorations(
256
- rawName: string,
257
- lineNumber: number
258
- ): { label: string; alias?: string } {
259
- const peeled = peelAlias(rawName);
260
- const m = peeled.label.match(IS_A_SUFFIX);
261
- if (!m) return peeled;
262
- warn(
263
- lineNumber,
264
- `Infra nodes don't use 'is a <type>' — types are inferred from properties (cache-hit, buffer, drain-rate, …). Drop the 'is a' suffix.`
265
- );
266
- // Capture group 1 in-bounds after successful match.
267
- return {
268
- label: m[1]!.trim(),
269
- ...(peeled.alias !== undefined && { alias: peeled.alias }),
270
- };
271
- }
272
- /**
273
- * Resolve a connection-target token. If the token exactly matches a
274
- * declared alias, return the bound canonical id. Otherwise fall
275
- * through to the normal `nodeId()` hash.
276
- */
277
- function resolveTargetId(rawName: string): string {
278
- const aliasResolved = nameAliasMap.get(rawName.trim());
279
- if (aliasResolved !== undefined) return aliasResolved;
280
- return nodeId(rawName);
281
- }
282
-
283
- // Tracks first-seen display form for each normalized edge-target id.
284
- // Used to give forward-reference stubs a readable label (e.g. 'CDN'
285
- // instead of the lowercased internal 'cdn').
286
- const targetDisplays = new Map<string, string>();
287
- const rememberTargetDisplay = (key: string, display: string): void => {
288
- if (!targetDisplays.has(key)) targetDisplays.set(key, display.trim());
289
- };
290
-
291
- const setError = (line: number, message: string) => {
292
- const diag = makeDgmoError(line, message);
293
- result.diagnostics.push(diag);
294
- if (!result.error) result.error = formatDgmoError(diag);
295
- };
296
-
297
- const warn = (line: number, message: string) => {
298
- result.diagnostics.push(makeDgmoError(line, message, 'warning'));
299
- };
300
-
301
- // Track parser state
302
- let currentNode: Writable<InfraNode> | null = null;
303
- let currentGroup: Writable<InfraGroup> | null = null;
304
- let currentTagGroup: Writable<InfraTagGroup> | null = null;
305
- let baseIndent = 0; // indent of the current component line
306
- // The `infra [Title]` declaration is only legal on the first content line.
307
- // parseFirstLine() must run ONCE, there — otherwise a later bare node line
308
- // whose name is a chart-type keyword (e.g. an edge-block header `timeline`)
309
- // is mis-read as a nested chart declaration and the parse aborts.
310
- let firstLineConsumed = false;
311
-
312
- function finishCurrentNode() {
313
- if (!currentNode) return;
314
- const key = currentNode.id;
315
- const existing = nodeMap.get(key);
316
- if (existing) {
317
- const incomingDisplay = displayName(currentNode.label);
318
- const existingDisplay = displayName(existing.label);
319
- // A bare reference line that is a declared alias of `existing` (e.g. an
320
- // edge-block header `web` for a node `"Web App" as web`) is an intentional
321
- // pointer, not a near-duplicate name — don't flag it as a case/whitespace
322
- // typo. Genuine collisions (no matching alias) still warn.
323
- const isAliasRef = nameAliasMap.get(currentNode.label) === key;
324
- if (incomingDisplay !== existingDisplay && !isAliasRef) {
325
- result.diagnostics.push(
326
- makeDgmoError(
327
- currentNode.lineNumber,
328
- nameMergedMessage({
329
- incomingDisplay,
330
- incomingLine: currentNode.lineNumber,
331
- existingDisplay,
332
- existingLine: existing.lineNumber,
333
- }),
334
- 'warning',
335
- NAME_DIAGNOSTIC_CODES.NAME_MERGED
336
- )
337
- );
338
- }
339
- currentNode = null;
340
- return;
341
- }
342
- // Validate mutual exclusion: concurrency vs instances/max-rps
343
- const keys = new Set(currentNode.properties.map((p) => p.key));
344
- if (
345
- keys.has('concurrency') &&
346
- (keys.has('instances') || keys.has('max-rps'))
347
- ) {
348
- const conflicting = [
349
- keys.has('instances') ? 'instances' : '',
350
- keys.has('max-rps') ? 'max-rps' : '',
351
- ]
352
- .filter(Boolean)
353
- .join(', ');
354
- warn(
355
- currentNode.lineNumber,
356
- `'concurrency' (serverless) is mutually exclusive with ${conflicting}. Serverless nodes scale via concurrency, not instances.`
357
- );
358
- }
359
- // Validate mutual exclusion: buffer (queue) vs max-rps (service)
360
- if (keys.has('buffer') && keys.has('max-rps')) {
361
- warn(
362
- currentNode.lineNumber,
363
- `'buffer' (queue) and 'max-rps' (service) represent different capacity models. A queue buffers messages; a service processes them.`
364
- );
365
- }
366
- nodeMap.set(key, currentNode);
367
- result.nodes.push(currentNode);
368
- currentNode = null;
369
- }
370
-
371
- function finishCurrentTagGroup() {
372
- if (currentTagGroup) {
373
- result.tagGroups.push(currentTagGroup);
374
- }
375
- currentTagGroup = null;
376
- }
377
-
378
- for (let i = 0; i < lines.length; i++) {
379
- // In-bounds by loop guard.
380
- const raw = lines[i]!;
381
- const lineNumber = i + 1;
382
- const trimmed = raw.trim();
383
- const indent = measureIndent(raw);
384
-
385
- // Skip empty lines and comments
386
- if (!trimmed || trimmed.startsWith('//')) continue;
387
-
388
- // Skip markdown section headers
389
- if (/^#{2,}\s+/.test(trimmed)) continue;
390
-
391
- // ---- Top-level metadata (no indent) ----
392
- if (indent === 0) {
393
- // Close any open blocks
394
- if (indent === 0 && currentNode && !trimmed.startsWith('-')) {
395
- finishCurrentNode();
396
- }
397
-
398
- // First line: `infra [Title]` or legacy `chart: infra` — only ever
399
- // attempted on the first content line (see firstLineConsumed). A node or
400
- // alias named like a chart type elsewhere is a normal node, not a redecl.
401
- if (!firstLineConsumed) {
402
- firstLineConsumed = true;
403
- const firstLineResult = parseFirstLine(trimmed);
404
- if (firstLineResult) {
405
- if (firstLineResult.chartType !== 'infra') {
406
- setError(
407
- lineNumber,
408
- `Expected chart type 'infra', got '${firstLineResult.chartType}'`
409
- );
410
- }
411
- if (firstLineResult.title) {
412
- result.title = firstLineResult.title;
413
- result.titleLineNumber = lineNumber;
414
- }
415
- continue;
416
- }
417
- }
418
-
419
- // direction-tb — bare boolean to switch to top-to-bottom (default is LR)
420
- if (/^direction-tb$/i.test(trimmed)) {
421
- result.direction = 'TB';
422
- continue;
423
- }
424
-
425
- if (trimmed === 'animate' || trimmed === 'no-animate') {
426
- warn(
427
- lineNumber,
428
- '"animate" / "no-animate" have been removed — animation is controlled by the rendering context.'
429
- );
430
- continue;
431
- }
432
-
433
- if (tryParseSharedOption(trimmed, options)) {
434
- continue;
435
- }
436
-
437
- // Top-level options: `key value` (space-separated, no colon)
438
- const optMatch = trimmed.match(OPTION_NOCOLON_RE);
439
- // Capture groups 1 & 2 in-bounds after successful match.
440
- if (optMatch && TOP_LEVEL_OPTIONS.has(optMatch[1]!.toLowerCase())) {
441
- options[optMatch[1]!.toLowerCase()] = optMatch[2]!.trim();
442
- continue;
443
- }
444
-
445
- // Tag group: `tag Name as <alias>` (via shared matchTagBlockHeading)
446
- const tagMatch = matchTagBlockHeading(trimmed);
447
- if (tagMatch) {
448
- finishCurrentNode();
449
- finishCurrentTagGroup();
450
- if (tagMatch.alias) tagAliasSet.add(normalizeName(tagMatch.alias));
451
- currentTagGroup = {
452
- name: tagMatch.name,
453
- alias: tagMatch.alias ?? null,
454
- values: [],
455
- lineNumber,
456
- };
457
- continue;
458
- }
459
-
460
- // [Group Name] [as <alias>] [| t: Engineering] — TD-18 native `as` postfix
461
- const groupMatch = trimmed.match(GROUP_RE);
462
- if (groupMatch) {
463
- finishCurrentNode();
464
- finishCurrentTagGroup();
465
- // Capture group 1 in-bounds after successful match.
466
- const gLabel = groupMatch[1]!.trim();
467
- const groupAlias = groupMatch[2];
468
- const gId = groupId(gLabel);
469
- if (groupAlias) nameAliasMap.set(groupAlias, gId);
470
- const groupMeta = groupMatch[3]
471
- ? extractPipeMetadata(groupMatch[3]).tags
472
- : undefined;
473
- if (groupMeta) {
474
- warnUnknownMetaKeys(
475
- groupMeta,
476
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
477
- (msg) => warn(lineNumber, msg)
478
- );
479
- }
480
- const hasMeta = groupMeta && Object.keys(groupMeta).length > 0;
481
- const newGroup: Writable<InfraGroup> = {
482
- id: gId,
483
- label: gLabel,
484
- ...(hasMeta && { metadata: groupMeta }),
485
- lineNumber,
486
- };
487
- currentGroup = newGroup;
488
- result.groups.push(newGroup);
489
- continue;
490
- }
491
-
492
- // Component at top level (no indent) — supports `as` postfix
493
- const compMatch = trimmed.match(COMPONENT_RE);
494
- if (compMatch) {
495
- finishCurrentNode();
496
- finishCurrentTagGroup();
497
-
498
- const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
499
- const rawName = (qname ?? uname ?? '').trim();
500
- const peeled = peelInfraDecorations(rawName, lineNumber);
501
- const name = peeled.label;
502
- // Explicit `as` capture wins; fall back to an alias the peeler split out
503
- // of a bare name (and the quoted-literal `"Foo as bar"` legacy case).
504
- const alias = asAlias ?? peeled.alias;
505
- const rest = meta ?? '';
506
- const { tags } = extractPipeMetadata(rest);
507
- warnUnknownMetaKeys(
508
- tags,
509
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
510
- (msg) => warn(lineNumber, msg)
511
- );
512
- // A bare reference line may be an alias for an already-declared node
513
- // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
514
- // through the alias map first so an edge-source/reference merges into
515
- // that node instead of spawning a duplicate stub.
516
- const id = nameAliasMap.get(name) ?? nodeId(name);
517
- if (alias) nameAliasMap.set(alias, id);
518
- const isEdge = EDGE_NODE_NAMES.has(id.toLowerCase());
519
-
520
- currentNode = {
521
- id,
522
- label: name,
523
- properties: [],
524
- groupId: null,
525
- tags,
526
- isEdge,
527
- lineNumber,
528
- };
529
- nodeMutableTags.set(id, tags);
530
- currentGroup = null;
531
- baseIndent = 0;
532
- continue;
533
- }
534
- }
535
-
536
- // ---- Indented lines ----
537
-
538
- // Tag value inside tag group — first value is the default unless another is marked `default`
539
- if (currentTagGroup && indent > 0) {
540
- const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
541
- // Trailing-token color (universal rule, §1.5): peel off a lowercase
542
- // recognized color word from the end of the line. Downstream stores
543
- // the raw color NAME (not the palette hex) so the renderer can resolve
544
- // against whichever theme/palette is active at render time.
545
- const lastSpaceIdx = cleanEntry.lastIndexOf(' ');
546
- let valueName = cleanEntry;
547
- let rawColor: string | undefined;
548
- if (lastSpaceIdx > 0) {
549
- const trailing = cleanEntry.substring(lastSpaceIdx + 1);
550
- if (isRecognizedColorName(trailing)) {
551
- rawColor = trailing;
552
- valueName = cleanEntry.substring(0, lastSpaceIdx).trimEnd();
553
- }
554
- }
555
- const tvMatch = valueName.match(TAG_VALUE_RE);
556
- if (tvMatch || /^\w+$/.test(valueName)) {
557
- currentTagGroup.values.push({
558
- name: valueName,
559
- ...(rawColor !== undefined && { color: rawColor }),
560
- });
561
- if (isDefault) {
562
- currentTagGroup.defaultValue = valueName;
563
- } else if (currentTagGroup.values.length === 1) {
564
- currentTagGroup.defaultValue = valueName;
565
- }
566
- continue;
567
- }
568
- warn(
569
- lineNumber,
570
- `Invalid tag value '${trimmed}' in tag group '${currentTagGroup.name}' — a value is a bare word with an optional trailing color (e.g. 'Production red'); no ':' or '|'.`
571
- );
572
- continue;
573
- }
574
-
575
- // Inside a [Group] but no current node — group properties or component declaration
576
- if (currentGroup && !currentNode && indent > 0) {
577
- // Group-level properties (instances, collapsed)
578
- const propMatch = trimmed.match(PROPERTY_RE);
579
- if (propMatch) {
580
- // Capture groups 1 & 2 in-bounds after successful match.
581
- const key = propMatch[1]!.toLowerCase();
582
- const val = propMatch[2]!.trim();
583
- if (key === 'instances') {
584
- const rangeM = val.match(RANGE_RE);
585
- if (rangeM) {
586
- currentGroup.instances = val;
587
- } else {
588
- const num = parseInt(val, 10);
589
- if (!isNaN(num)) currentGroup.instances = num;
590
- }
591
- continue;
592
- }
593
- if (key === 'collapsed') {
594
- currentGroup.collapsed = val.toLowerCase() === 'true';
595
- continue;
596
- }
597
- // Fall through to component matching — could be a component name
598
- // that happens to match PROPERTY_RE (e.g., "MyService: v2")
599
- }
600
-
601
- const compMatch = trimmed.match(COMPONENT_RE);
602
- if (compMatch) {
603
- finishCurrentTagGroup();
604
- const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
605
- const rawName = (qname ?? uname ?? '').trim();
606
- const peeled = peelInfraDecorations(rawName, lineNumber);
607
- const name = peeled.label;
608
- const alias = asAlias ?? peeled.alias;
609
- const rest = meta ?? '';
610
- const { tags: nodeTags } = extractPipeMetadata(rest);
611
- warnUnknownMetaKeys(
612
- nodeTags,
613
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
614
- (msg) => warn(lineNumber, msg)
615
- );
616
- // A bare reference line may be an alias for an already-declared node
617
- // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
618
- // through the alias map first so an edge-source/reference merges into
619
- // that node instead of spawning a duplicate stub.
620
- const id = nameAliasMap.get(name) ?? nodeId(name);
621
- if (alias) nameAliasMap.set(alias, id);
622
- // Cascade group metadata into node tags; node-level metadata overrides
623
- const tags: Record<string, string> = currentGroup.metadata
624
- ? { ...currentGroup.metadata, ...nodeTags }
625
- : nodeTags;
626
-
627
- currentNode = {
628
- id,
629
- label: name,
630
- properties: [],
631
- groupId: currentGroup.id,
632
- tags,
633
- isEdge: false,
634
- lineNumber,
635
- };
636
- nodeMutableTags.set(id, tags);
637
- baseIndent = indent;
638
- continue;
639
- }
640
- }
641
-
642
- // Inside a component block — properties and connections
643
- if (currentNode && indent > baseIndent) {
644
- // Detect deprecated xN fanout syntax
645
- const deprecatedFanout = trimmed.match(DEPRECATED_FANOUT_RE);
646
- if (
647
- deprecatedFanout &&
648
- (trimmed.startsWith('->') ||
649
- trimmed.startsWith('-') ||
650
- trimmed.startsWith('~'))
651
- ) {
652
- const n = deprecatedFanout[1];
653
- setError(
654
- lineNumber,
655
- `'x${n}' fanout syntax is no longer supported — use 'fanout: ${n}' instead`
656
- );
657
- continue;
658
- }
659
-
660
- // Async simple connection: ~> Target
661
- const asyncSimpleConn = trimmed.match(ASYNC_SIMPLE_CONNECTION_RE);
662
- if (asyncSimpleConn) {
663
- // Capture group 1 in-bounds after successful match.
664
- const targetRaw = asyncSimpleConn[1]!.trim();
665
- const pipeMeta = extractPipeMetadata(targetRaw);
666
- const targetName = pipeMeta.clean || targetRaw;
667
- warnUnparsedPipeMeta(targetName, lineNumber, warn);
668
- warnUnknownMetaKeys(pipeMeta.tags, EDGE_PIPE_REGISTRY, (msg) =>
669
- warn(lineNumber, msg)
670
- );
671
- const split = pipeMeta.tags['split']
672
- ? parseFloat(pipeMeta.tags['split'])
673
- : null;
674
- const fanoutRaw = pipeMeta.tags['fanout']
675
- ? parseInt(pipeMeta.tags['fanout'], 10)
676
- : null;
677
- if (fanoutRaw !== null && fanoutRaw < 1) {
678
- warn(
679
- lineNumber,
680
- `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
681
- );
682
- }
683
- const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
684
- const targetIdAsync = resolveTargetId(targetName);
685
- rememberTargetDisplay(targetIdAsync, targetName);
686
- result.edges.push({
687
- sourceId: currentNode.id,
688
- targetId: targetIdAsync,
689
- label: '',
690
- async: true,
691
- split,
692
- fanout,
693
- lineNumber,
694
- });
695
- continue;
696
- }
697
-
698
- // Async labeled connection: ~label~> Target
699
- const asyncConnMatch = trimmed.match(ASYNC_CONNECTION_RE);
700
- if (asyncConnMatch) {
701
- const rawLabel = asyncConnMatch[1] ?? '';
702
- const labelResult = parseInArrowLabel(rawLabel, lineNumber);
703
- result.diagnostics.push(...labelResult.diagnostics);
704
- const label = labelResult.label ?? '';
705
- // Capture group 2 in-bounds after successful match.
706
- const targetRaw = asyncConnMatch[2]!.trim();
707
- const pipeMeta = extractPipeMetadata(targetRaw);
708
- const targetName = pipeMeta.clean || targetRaw;
709
- warnUnparsedPipeMeta(targetName, lineNumber, warn);
710
- warnUnknownMetaKeys(pipeMeta.tags, EDGE_PIPE_REGISTRY, (msg) =>
711
- warn(lineNumber, msg)
712
- );
713
- const split = pipeMeta.tags['split']
714
- ? parseFloat(pipeMeta.tags['split'])
715
- : null;
716
- const fanoutRaw = pipeMeta.tags['fanout']
717
- ? parseInt(pipeMeta.tags['fanout'], 10)
718
- : null;
719
- if (fanoutRaw !== null && fanoutRaw < 1) {
720
- warn(
721
- lineNumber,
722
- `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
723
- );
724
- }
725
- const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
726
-
727
- let targetId: string;
728
- const targetGroupMatch = targetName.match(/^\[([^\]]+)\]/);
729
- if (targetGroupMatch) {
730
- // Capture group 1 in-bounds after successful match.
731
- targetId = groupId(targetGroupMatch[1]!);
732
- rememberTargetDisplay(targetId, `[${targetGroupMatch[1]}]`);
733
- } else {
734
- targetId = resolveTargetId(targetName);
735
- rememberTargetDisplay(targetId, targetName);
736
- }
737
-
738
- result.edges.push({
739
- sourceId: currentNode.id,
740
- targetId,
741
- label,
742
- async: true,
743
- split,
744
- fanout,
745
- lineNumber,
746
- });
747
- continue;
748
- }
749
-
750
- // Simple connection: -> Target or -> Target | fanout: 5
751
- const simpleConn = trimmed.match(SIMPLE_CONNECTION_RE);
752
- if (simpleConn) {
753
- // Capture group 1 in-bounds after successful match.
754
- const targetRaw = simpleConn[1]!.trim();
755
- const pipeMeta = extractPipeMetadata(targetRaw);
756
- const targetName = pipeMeta.clean || targetRaw;
757
- warnUnparsedPipeMeta(targetName, lineNumber, warn);
758
- warnUnknownMetaKeys(pipeMeta.tags, EDGE_PIPE_REGISTRY, (msg) =>
759
- warn(lineNumber, msg)
760
- );
761
- const split = pipeMeta.tags['split']
762
- ? parseFloat(pipeMeta.tags['split'])
763
- : null;
764
- const fanoutRaw = pipeMeta.tags['fanout']
765
- ? parseInt(pipeMeta.tags['fanout'], 10)
766
- : null;
767
- if (fanoutRaw !== null && fanoutRaw < 1) {
768
- warn(
769
- lineNumber,
770
- `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
771
- );
772
- }
773
- const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
774
- const targetIdSimple = resolveTargetId(targetName);
775
- rememberTargetDisplay(targetIdSimple, targetName);
776
- result.edges.push({
777
- sourceId: currentNode.id,
778
- targetId: targetIdSimple,
779
- label: '',
780
- async: false,
781
- split,
782
- fanout,
783
- lineNumber,
784
- });
785
- continue;
786
- }
787
-
788
- // Labeled connection: -label-> Target | split: N%, fanout: 3
789
- const connMatch = trimmed.match(CONNECTION_RE);
790
- if (connMatch) {
791
- const rawLabel = connMatch[1] ?? '';
792
- const labelResult = parseInArrowLabel(rawLabel, lineNumber);
793
- result.diagnostics.push(...labelResult.diagnostics);
794
- const label = labelResult.label ?? '';
795
- // Capture group 2 in-bounds after successful match.
796
- const targetRaw = connMatch[2]!.trim();
797
- const pipeMeta = extractPipeMetadata(targetRaw);
798
- const targetName = pipeMeta.clean || targetRaw;
799
- warnUnparsedPipeMeta(targetName, lineNumber, warn);
800
- warnUnknownMetaKeys(pipeMeta.tags, EDGE_PIPE_REGISTRY, (msg) =>
801
- warn(lineNumber, msg)
802
- );
803
- const split = pipeMeta.tags['split']
804
- ? parseFloat(pipeMeta.tags['split'])
805
- : null;
806
- const fanoutRaw = pipeMeta.tags['fanout']
807
- ? parseInt(pipeMeta.tags['fanout'], 10)
808
- : null;
809
- if (fanoutRaw !== null && fanoutRaw < 1) {
810
- warn(
811
- lineNumber,
812
- `Fan-out multiplier must be at least 1 (got fanout: ${fanoutRaw}). Ignoring.`
813
- );
814
- }
815
- const fanout = fanoutRaw !== null && fanoutRaw >= 1 ? fanoutRaw : null;
816
-
817
- // Target might be a group ref like [API Pods] or an alias literal.
818
- let targetId: string;
819
- const targetGroupMatch = targetName.match(/^\[([^\]]+)\]/);
820
- if (targetGroupMatch) {
821
- // Capture group 1 in-bounds after successful match.
822
- targetId = groupId(targetGroupMatch[1]!);
823
- rememberTargetDisplay(targetId, `[${targetGroupMatch[1]}]`);
824
- } else {
825
- targetId = resolveTargetId(targetName);
826
- rememberTargetDisplay(targetId, targetName);
827
- }
828
-
829
- result.edges.push({
830
- sourceId: currentNode.id,
831
- targetId,
832
- label,
833
- async: false,
834
- split,
835
- fanout,
836
- lineNumber,
837
- });
838
- continue;
839
- }
840
-
841
- // Empty description (no value) — silently skip rather than emitting "Unexpected line"
842
- if (/^description\s*:?\s*$/i.test(trimmed)) continue;
843
-
844
- // Property: key: value
845
- const propMatch = trimmed.match(PROPERTY_RE);
846
- if (propMatch) {
847
- // Capture groups 1 & 2 in-bounds after successful match.
848
- const key = propMatch[1]!.toLowerCase();
849
- const rawVal = propMatch[2]!.trim();
850
-
851
- // description is display metadata, not a behavior key; silently ignored on edge nodes.
852
- // Single-line only — no length enforcement, but keep it short for legibility.
853
- if (key === 'description' && currentNode) {
854
- if (!currentNode.isEdge) {
855
- pushDescription(currentNode, rawVal);
856
- }
857
- continue;
858
- }
859
-
860
- // Indented tag assignment — `t: Edge` on its own line, the indented twin
861
- // of same-line `Name t: Edge`. A tag alias is neither a behavior property
862
- // nor 'description', so without this it falls through to the unknown-key
863
- // path and is mis-collected as description text, silently dropping the
864
- // node's tier. currentNode.tags is the same object held in
865
- // nodeMutableTags, so a direct assign is enough.
866
- if (currentNode && tagAliasSet.has(normalizeName(key))) {
867
- const mut = nodeMutableTags.get(currentNode.id);
868
- if (mut) mut[tagAttrKey(key)] = rawVal;
869
- continue;
870
- }
871
-
872
- // Unknown keys: decide between property typo warning vs description collection.
873
- // Heuristic: if the key looks like a plausible property (alphanumeric-hyphen, close
874
- // match to a known key, or the value looks numeric/percentage), warn as typo.
875
- // Otherwise treat the whole line as description text.
876
- if (!INFRA_BEHAVIOR_KEYS.has(key) && !EDGE_ONLY_KEYS.has(key)) {
877
- const allKeys = [...INFRA_BEHAVIOR_KEYS, ...EDGE_ONLY_KEYS];
878
- const hint = suggest(key, allKeys);
879
- const valueLooksNumeric = /^[\d.]+%?$/.test(rawVal);
880
- if (hint || valueLooksNumeric) {
881
- // Likely a typo — warn
882
- let msg = `Unknown infra property '${key}'.`;
883
- if (hint) msg += ` ${hint}`;
884
- else
885
- msg +=
886
- ` Properties are colon-keyed 'key: value' from the known schema` +
887
- ` (e.g. latency-ms, max-rps, cache-hit, instances, concurrency).`;
888
- warn(lineNumber, msg);
889
- } else if (!currentNode.isEdge) {
890
- // Likely prose — collect as description
891
- pushDescription(currentNode, trimmed);
892
- continue;
893
- }
894
- continue;
895
- }
896
-
897
- // Validate edge-only keys
898
- if (EDGE_ONLY_KEYS.has(key) && !currentNode.isEdge) {
899
- warn(
900
- lineNumber,
901
- `Property '${key}' belongs on the entry point — declare it indented under an 'internet' or 'edge' node, not on '${currentNode.label}'.`
902
- );
903
- }
904
-
905
- const value = parsePropertyValue(rawVal);
906
- currentNode.properties.push({ key, value, lineNumber });
907
- continue;
908
- }
909
-
910
- // Indented line inside a component: only the `description` keyword
911
- // attaches as a description; a bare prose line is no longer auto-promoted.
912
- const descResult = tryStripDescriptionKeyword(trimmed);
913
- if (descResult.isKeyword && !descResult.needsColon) {
914
- if (currentNode.isEdge) {
915
- // description on edge nodes is silently ignored
916
- continue;
917
- }
918
- pushDescription(currentNode, descResult.text);
919
- continue;
920
- }
921
- warn(
922
- lineNumber,
923
- `Unexpected line inside component '${currentNode.label}'. Expected a property (key: value), connection (-> Target), or a description (description: text).`
924
- );
925
- continue;
926
- }
927
-
928
- // Component inside group (same indent as group children)
929
- if (currentGroup && indent > 0) {
930
- finishCurrentNode();
931
-
932
- const compMatch = trimmed.match(COMPONENT_RE);
933
- if (compMatch) {
934
- const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
935
- const rawName = (qname ?? uname ?? '').trim();
936
- const peeled = peelInfraDecorations(rawName, lineNumber);
937
- const name = peeled.label;
938
- const alias = asAlias ?? peeled.alias;
939
- const rest = meta ?? '';
940
- const { tags: nodeTags } = extractPipeMetadata(rest);
941
- warnUnknownMetaKeys(
942
- nodeTags,
943
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
944
- (msg) => warn(lineNumber, msg)
945
- );
946
- // A bare reference line may be an alias for an already-declared node
947
- // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
948
- // through the alias map first so an edge-source/reference merges into
949
- // that node instead of spawning a duplicate stub.
950
- const id = nameAliasMap.get(name) ?? nodeId(name);
951
- if (alias) nameAliasMap.set(alias, id);
952
- const tags: Record<string, string> = currentGroup.metadata
953
- ? { ...currentGroup.metadata, ...nodeTags }
954
- : nodeTags;
955
-
956
- currentNode = {
957
- id,
958
- label: name,
959
- properties: [],
960
- groupId: currentGroup.id,
961
- tags,
962
- isEdge: false,
963
- lineNumber,
964
- };
965
- nodeMutableTags.set(id, tags);
966
- baseIndent = indent;
967
- continue;
968
- }
969
- }
970
-
971
- // If we reach here and indent is 0, try as a top-level component
972
- if (indent === 0) {
973
- const compMatch = trimmed.match(COMPONENT_RE);
974
- if (compMatch) {
975
- finishCurrentNode();
976
- finishCurrentTagGroup();
977
- currentGroup = null;
978
-
979
- const { qname, uname, alias: asAlias, meta } = compMatch.groups!;
980
- const rawName = (qname ?? uname ?? '').trim();
981
- const peeled = peelInfraDecorations(rawName, lineNumber);
982
- const name = peeled.label;
983
- const alias = asAlias ?? peeled.alias;
984
- const rest = meta ?? '';
985
- const { tags } = extractPipeMetadata(rest);
986
- warnUnknownMetaKeys(
987
- tags,
988
- withTagAliases(INFRA_REGISTRY, tagAliasSet),
989
- (msg) => warn(lineNumber, msg)
990
- );
991
- // A bare reference line may be an alias for an already-declared node
992
- // (its label-slug differs from the alias, e.g. "Web App" as web). Resolve
993
- // through the alias map first so an edge-source/reference merges into
994
- // that node instead of spawning a duplicate stub.
995
- const id = nameAliasMap.get(name) ?? nodeId(name);
996
- if (alias) nameAliasMap.set(alias, id);
997
-
998
- currentNode = {
999
- id,
1000
- label: name,
1001
- properties: [],
1002
- groupId: null,
1003
- tags,
1004
- isEdge: EDGE_NODE_NAMES.has(id.toLowerCase()),
1005
- lineNumber,
1006
- };
1007
- nodeMutableTags.set(id, tags);
1008
- baseIndent = 0;
1009
- continue;
1010
- }
1011
- }
1012
-
1013
- // Catch-all: nothing matched this line
1014
- warn(
1015
- lineNumber,
1016
- currentGroup
1017
- ? `Unexpected line: '${trimmed}'. Inside a group, indent a component name under the '[Group]'.`
1018
- : `Unexpected line: '${trimmed}'. Expected a node ('Api Gateway'), a group ('[Backend]'), a tag group ('tag Tier as t'), or an option ('default-rps 100'); node properties go indented under a node as 'key: value'.`
1019
- );
1020
- }
1021
-
1022
- // Flush last open blocks
1023
- finishCurrentNode();
1024
- finishCurrentTagGroup();
1025
-
1026
- // Ensure referenced targets exist (create stub nodes for forward references)
1027
- for (const edge of result.edges) {
1028
- if (!nodeMap.has(edge.targetId)) {
1029
- // Check if target is a group
1030
- const isGroup = result.groups.some((g) => g.id === edge.targetId);
1031
- if (!isGroup) {
1032
- // Create a stub node for forward-referenced targets. Display label
1033
- // recovers the user's first-seen casing so the rendered SVG shows
1034
- // 'CDN' instead of the lowercased internal id.
1035
- const stubDisplay = targetDisplays.get(edge.targetId) ?? edge.targetId;
1036
- const stubTags: Record<string, string> = {};
1037
- const stub: Writable<InfraNode> = {
1038
- id: edge.targetId,
1039
- label: stubDisplay,
1040
- properties: [],
1041
- groupId: null,
1042
- tags: stubTags,
1043
- isEdge: false,
1044
- lineNumber: edge.lineNumber,
1045
- };
1046
- // stub.id is edge.targetId, already-normalized via nodeId() at edge construction
1047
- // eslint-disable-next-line name-normalize/required-at-insertion
1048
- nodeMap.set(stub.id, stub);
1049
- result.nodes.push(stub);
1050
- nodeMutableTags.set(stub.id, stubTags);
1051
- }
1052
- }
1053
- }
1054
-
1055
- // Inject default tag values into nodes that don't have one
1056
- for (const tg of result.tagGroups) {
1057
- if (!tg.defaultValue) continue;
1058
- const key = tagAttrKey(tg.alias ?? tg.name);
1059
- for (const node of result.nodes) {
1060
- if (node.isEdge) continue;
1061
- if (!(key in node.tags)) {
1062
- const tags = nodeMutableTags.get(node.id);
1063
- if (tags) tags[key] = tg.defaultValue;
1064
- }
1065
- }
1066
- }
1067
-
1068
- validateTagGroupNames(result.tagGroups, warn, setError);
1069
-
1070
- checkReachability(result);
1071
-
1072
- return result;
1073
- }
1074
-
1075
- /**
1076
- * Reachability lint: an infra diagram traces request traffic flowing inward
1077
- * from an `internet`/`edge` entry, so every node must have a directed path
1078
- * back to an entry. A node nothing routes to carries no traffic — it's dead
1079
- * and doesn't belong. Emits `warning`-severity diagnostics (never blocks
1080
- * rendering). Mirrors the compute model: an edge targeting a `[Group]`
1081
- * delivers to all of that group's children.
1082
- */
1083
- function checkReachability(result: Writable<ParsedInfra>): void {
1084
- if (result.nodes.length === 0) return;
1085
-
1086
- const entries = result.nodes.filter((n) => n.isEdge);
1087
-
1088
- // No entry at all: the rule can't be satisfied. One diagnostic, not N.
1089
- if (entries.length === 0) {
1090
- const line = result.titleLineNumber ?? result.nodes[0]?.lineNumber ?? 1;
1091
- result.diagnostics.push(emit(GRAPH_DX.INFRA_NO_ENTRY, line));
1092
- return;
1093
- }
1094
-
1095
- // groupId -> child node ids (for expanding edges that target a [Group]).
1096
- const groupChildMap = new Map<string, string[]>();
1097
- for (const node of result.nodes) {
1098
- if (node.groupId) {
1099
- const list = groupChildMap.get(node.groupId) ?? [];
1100
- list.push(node.id);
1101
- // node.groupId is already a normalized id from parse time.
1102
- // eslint-disable-next-line name-normalize/required-at-insertion
1103
- groupChildMap.set(node.groupId, list);
1104
- }
1105
- }
1106
-
1107
- // Outbound adjacency: an edge to a group reaches every child of that group.
1108
- const outbound = new Map<string, string[]>();
1109
- for (const edge of result.edges) {
1110
- const targets = groupChildMap.get(edge.targetId) ?? [edge.targetId];
1111
- const list = outbound.get(edge.sourceId) ?? [];
1112
- list.push(...targets);
1113
- outbound.set(edge.sourceId, list);
1114
- }
1115
-
1116
- // BFS from every entry node.
1117
- const reachable = new Set<string>();
1118
- const queue: string[] = [];
1119
- for (const entry of entries) {
1120
- reachable.add(entry.id);
1121
- queue.push(entry.id);
1122
- }
1123
- while (queue.length > 0) {
1124
- const current = queue.shift()!;
1125
- for (const next of outbound.get(current) ?? []) {
1126
- if (!reachable.has(next)) {
1127
- reachable.add(next);
1128
- queue.push(next);
1129
- }
1130
- }
1131
- }
1132
-
1133
- for (const node of result.nodes) {
1134
- if (node.isEdge || reachable.has(node.id)) continue;
1135
- result.diagnostics.push(
1136
- emit(GRAPH_DX.INFRA_UNREACHABLE, node.lineNumber, { label: node.label })
1137
- );
1138
- }
1139
- }
1140
-
1141
- // ============================================================
1142
- // Symbol extraction (for completion API)
1143
- // ============================================================
1144
-
1145
- import type { DiagramSymbols } from '../completion-types';
1146
-
1147
- /**
1148
- * Extract component names (entities) from infra document text.
1149
- * Used by the dgmo completion API for ghost hints and popup completions.
1150
- */
1151
- /** Strip ` as <alias>` and ` is a/an <type>` decorations from a node name
1152
- * so completion suggests the bare identifier the user will reference. */
1153
- function stripNodeDecorations(name: string): string {
1154
- let s = name.trim();
1155
- const aliasMatch = s.match(/^(.*?)\s+as\s+[A-Za-z][A-Za-z0-9_]{0,11}\s*$/);
1156
- // Capture group 1 in-bounds after successful match.
1157
- if (aliasMatch) s = aliasMatch[1]!.trim();
1158
- const isAMatch = s.match(/^(.*?)\s+is\s+an?\s+[A-Za-z][\w-]*\s*$/i);
1159
- // Capture group 1 in-bounds after successful match.
1160
- if (isAMatch) s = isAMatch[1]!.trim();
1161
- return s;
1162
- }
1163
-
1164
- export function extractSymbols(docText: string): DiagramSymbols {
1165
- const entities: string[] = [];
1166
- let inMetadata = true;
1167
- let inTagGroup = false;
1168
- for (const rawLine of docText.split('\n')) {
1169
- const line = rawLine.trim();
1170
- if (line.length === 0) continue;
1171
- const indented = /^\s/.test(rawLine);
1172
-
1173
- // Metadata phase: skip until first non-metadata root-level line.
1174
- // Metadata includes: `infra [Title]`, `chart: type`, `direction X`, `slo-*`, etc.
1175
- if (inMetadata) {
1176
- if (!indented) {
1177
- // Recognize new-style bare options (`key value`) and old-style (`key: value`)
1178
- const firstLine = parseFirstLine(line);
1179
- if (firstLine) continue; // chart type line
1180
- if (/^(?:direction-tb|animate|no-animate|slo-|default-)/i.test(line))
1181
- continue;
1182
- if (/^[a-z-]+\s*:/i.test(line)) continue; // legacy colon options
1183
- inMetadata = false;
1184
- } else {
1185
- continue;
1186
- }
1187
- }
1188
-
1189
- if (!indented) {
1190
- // Root-level: tag group declaration, group header, or component
1191
- if (/^tag\s/i.test(line)) {
1192
- inTagGroup = true;
1193
- continue;
1194
- }
1195
- if (/^tag\s*:/i.test(line)) {
1196
- inTagGroup = true;
1197
- continue;
1198
- } // legacy
1199
- inTagGroup = false;
1200
- if (/^\[/.test(line)) continue; // [Group] header
1201
- const m = COMPONENT_RE.exec(line);
1202
- if (m) {
1203
- const name = stripNodeDecorations((m[1] ?? m[2] ?? '').trim());
1204
- if (name && !entities.includes(name)) entities.push(name);
1205
- }
1206
- } else {
1207
- // Indented: skip tag values, connections, and properties; extract grouped components
1208
- if (inTagGroup) continue;
1209
- if (/^->/.test(line)) continue; // simple connection
1210
- if (/^~>/.test(line)) continue; // async simple connection
1211
- if (/^-[^>]+-?>/.test(line)) continue; // labeled connection
1212
- if (/^~[^~]+~>/.test(line)) continue; // async labeled connection
1213
- if (/^\w[\w-]*\s*:/.test(line)) continue; // property (key: value) legacy
1214
- // New-style property: first token is a known behavior/property key
1215
- // String.split always returns at least one element; line.length > 0 here.
1216
- const firstToken = line.split(/\s/)[0]!.toLowerCase();
1217
- if (
1218
- (INFRA_BEHAVIOR_KEYS.has(firstToken) ||
1219
- EDGE_ONLY_KEYS.has(firstToken) ||
1220
- firstToken === 'description' ||
1221
- firstToken === 'instances' ||
1222
- firstToken === 'collapsed') &&
1223
- /\s/.test(line)
1224
- )
1225
- continue;
1226
- const m = COMPONENT_RE.exec(line);
1227
- if (m) {
1228
- const name = stripNodeDecorations((m[1] ?? m[2] ?? '').trim());
1229
- if (name && !entities.includes(name)) entities.push(name);
1230
- }
1231
- }
1232
- }
1233
- return { kind: 'infra', entities };
1234
- }