@diagrammo/dgmo 0.2.27 → 0.2.28

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 (47) hide show
  1. package/.claude/skills/dgmo-chart/SKILL.md +107 -0
  2. package/.claude/skills/dgmo-flowchart/SKILL.md +61 -0
  3. package/.claude/skills/dgmo-generate/SKILL.md +58 -0
  4. package/.claude/skills/dgmo-sequence/SKILL.md +83 -0
  5. package/.cursorrules +117 -0
  6. package/.github/copilot-instructions.md +117 -0
  7. package/.windsurfrules +117 -0
  8. package/README.md +10 -3
  9. package/dist/cli.cjs +116 -108
  10. package/dist/index.cjs +543 -351
  11. package/dist/index.cjs.map +1 -1
  12. package/dist/index.d.cts +39 -24
  13. package/dist/index.d.ts +39 -24
  14. package/dist/index.js +540 -350
  15. package/dist/index.js.map +1 -1
  16. package/docs/ai-integration.md +125 -0
  17. package/docs/language-reference.md +784 -0
  18. package/package.json +10 -3
  19. package/src/c4/parser.ts +90 -74
  20. package/src/c4/renderer.ts +13 -12
  21. package/src/c4/types.ts +6 -4
  22. package/src/chart.ts +3 -2
  23. package/src/class/parser.ts +2 -10
  24. package/src/class/types.ts +1 -1
  25. package/src/cli.ts +130 -19
  26. package/src/d3.ts +1 -1
  27. package/src/dgmo-mermaid.ts +1 -1
  28. package/src/dgmo-router.ts +1 -1
  29. package/src/echarts.ts +33 -13
  30. package/src/er/parser.ts +34 -43
  31. package/src/er/types.ts +1 -1
  32. package/src/graph/flowchart-parser.ts +2 -25
  33. package/src/graph/types.ts +1 -1
  34. package/src/index.ts +5 -0
  35. package/src/initiative-status/parser.ts +36 -7
  36. package/src/initiative-status/types.ts +1 -1
  37. package/src/kanban/parser.ts +32 -53
  38. package/src/kanban/renderer.ts +9 -8
  39. package/src/kanban/types.ts +6 -14
  40. package/src/org/parser.ts +47 -87
  41. package/src/org/resolver.ts +11 -12
  42. package/src/sequence/parser.ts +97 -15
  43. package/src/sequence/renderer.ts +62 -69
  44. package/src/utils/arrows.ts +75 -0
  45. package/src/utils/inline-markdown.ts +75 -0
  46. package/src/utils/parsing.ts +67 -0
  47. package/src/utils/tag-groups.ts +76 -0
@@ -29,14 +29,14 @@ export function looksLikeInitiativeStatus(content: string): boolean {
29
29
  let hasIndentedArrow = false;
30
30
  for (const line of lines) {
31
31
  const trimmed = line.trim();
32
- if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
32
+ if (!trimmed || trimmed.startsWith('//')) continue;
33
33
  if (trimmed.match(/^chart\s*:/i)) continue;
34
34
  if (trimmed.match(/^title\s*:/i)) continue;
35
35
  if (trimmed.includes('->')) hasArrow = true;
36
36
  if (/\|\s*(done|wip|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
37
37
  // Indented arrow is a strong signal — only initiative-status uses this
38
38
  const isIndented = line.length > 0 && line !== trimmed && /^\s/.test(line);
39
- if (isIndented && trimmed.startsWith('->')) hasIndentedArrow = true;
39
+ if (isIndented && (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed))) hasIndentedArrow = true;
40
40
  if (hasArrow && hasStatus) return true;
41
41
  }
42
42
  return hasIndentedArrow;
@@ -68,7 +68,7 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
68
68
  groups: [],
69
69
  options: {},
70
70
  diagnostics: [],
71
- error: undefined,
71
+ error: null,
72
72
  };
73
73
 
74
74
  const lines = content.split('\n');
@@ -82,7 +82,7 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
82
82
  const trimmed = raw.trim();
83
83
 
84
84
  // Skip blanks and comments
85
- if (!trimmed || trimmed.startsWith('#') || trimmed.startsWith('//')) continue;
85
+ if (!trimmed || trimmed.startsWith('//')) continue;
86
86
 
87
87
  // chart: header
88
88
  const chartMatch = trimmed.match(/^chart\s*:\s*(.+)/i);
@@ -123,11 +123,11 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
123
123
  currentGroup = null;
124
124
  }
125
125
 
126
- // Edge: contains `->`
126
+ // Edge: contains `->` or labeled form `-label->`
127
127
  if (trimmed.includes('->')) {
128
128
  let edgeText = trimmed;
129
- // Indented `-> Target` shorthand — prepend the last node label as source
130
- if (trimmed.startsWith('->')) {
129
+ // Indented `-> Target` or `-label-> Target` shorthand
130
+ if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
131
131
  if (!lastNodeLabel) {
132
132
  result.diagnostics.push(
133
133
  makeDgmoError(lineNum, 'Indented edge has no preceding node to use as source', 'warning')
@@ -222,6 +222,35 @@ function parseEdgeLine(
222
222
  // or: <source> -> <target> | <status>
223
223
  // or: <source> -> <target>: <label>
224
224
  // or: <source> -> <target>
225
+ // or: <source> -<label>-> <target> [| <status>]
226
+
227
+ // Check for labeled arrow form: SOURCE -LABEL-> TARGET [| status]
228
+ const labeledMatch = trimmed.match(/^(\S+)\s+-(.+)->\s+(.+)$/);
229
+ if (labeledMatch) {
230
+ const source = labeledMatch[1];
231
+ const label = labeledMatch[2].trim();
232
+ let targetRest = labeledMatch[3].trim();
233
+
234
+ if (label) {
235
+ // Extract status from end (after last |)
236
+ let status: InitiativeStatus = 'na';
237
+ const lastPipe = targetRest.lastIndexOf('|');
238
+ if (lastPipe >= 0) {
239
+ const statusRaw = targetRest.slice(lastPipe + 1).trim();
240
+ status = parseStatus(statusRaw, lineNum, diagnostics);
241
+ targetRest = targetRest.slice(0, lastPipe).trim();
242
+ }
243
+
244
+ const target = targetRest.trim();
245
+ if (!target) {
246
+ diagnostics.push(makeDgmoError(lineNum, 'Edge is missing target'));
247
+ return null;
248
+ }
249
+
250
+ return { source, target, label, status, lineNumber: lineNum };
251
+ }
252
+ // Empty label — fall through to plain arrow parsing
253
+ }
225
254
 
226
255
  const arrowIdx = trimmed.indexOf('->');
227
256
  if (arrowIdx < 0) return null;
@@ -39,5 +39,5 @@ export interface ParsedInitiativeStatus {
39
39
  groups: ISGroup[];
40
40
  options: Record<string, string>;
41
41
  diagnostics: DgmoError[];
42
- error?: string;
42
+ error: string | null;
43
43
  }
@@ -1,7 +1,14 @@
1
- import { resolveColor } from '../colors';
2
1
  import type { PaletteColors } from '../palettes';
3
2
  import type { DgmoError } from '../diagnostics';
4
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
+ import { matchTagBlockHeading } from '../utils/tag-groups';
5
+ import {
6
+ measureIndent,
7
+ extractColor,
8
+ CHART_TYPE_RE,
9
+ TITLE_RE,
10
+ OPTION_RE,
11
+ } from '../utils/parsing';
5
12
  import type {
6
13
  ParsedKanban,
7
14
  KanbanColumn,
@@ -14,40 +21,7 @@ import type {
14
21
  // Regex patterns
15
22
  // ============================================================
16
23
 
17
- const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
18
- const TITLE_RE = /^title\s*:\s*(.+)/i;
19
- const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
20
- const GROUP_HEADING_RE =
21
- /^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
22
24
  const COLUMN_RE = /^==\s+(.+?)\s*(?:\[wip:\s*(\d+)\])?\s*==$/;
23
- const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
24
-
25
- // ============================================================
26
- // Helpers
27
- // ============================================================
28
-
29
- function measureIndent(line: string): number {
30
- let indent = 0;
31
- for (const ch of line) {
32
- if (ch === ' ') indent++;
33
- else if (ch === '\t') indent += 4;
34
- else break;
35
- }
36
- return indent;
37
- }
38
-
39
- function extractColor(
40
- label: string,
41
- palette?: PaletteColors
42
- ): { label: string; color?: string } {
43
- const m = label.match(COLOR_SUFFIX_RE);
44
- if (!m) return { label };
45
- const colorName = m[1].trim();
46
- return {
47
- label: label.substring(0, m.index!).trim(),
48
- color: resolveColor(colorName, palette),
49
- };
50
- }
51
25
 
52
26
  // ============================================================
53
27
  // Parser
@@ -63,6 +37,7 @@ export function parseKanban(
63
37
  tagGroups: [],
64
38
  options: {},
65
39
  diagnostics: [],
40
+ error: null,
66
41
  };
67
42
 
68
43
  const fail = (line: number, message: string): ParsedKanban => {
@@ -146,10 +121,32 @@ export function parseKanban(
146
121
  }
147
122
  }
148
123
 
124
+ // Tag group heading — `tag: Name` (new) or `## Name` (deprecated)
125
+ // Must be checked BEFORE OPTION_RE to prevent `tag: Rank` being swallowed as option
126
+ if (!contentStarted) {
127
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
128
+ if (tagBlockMatch) {
129
+ if (tagBlockMatch.deprecated) {
130
+ warn(lineNumber, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`);
131
+ }
132
+ currentTagGroup = {
133
+ name: tagBlockMatch.name,
134
+ alias: tagBlockMatch.alias,
135
+ entries: [],
136
+ lineNumber,
137
+ };
138
+ if (tagBlockMatch.alias) {
139
+ aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
140
+ }
141
+ result.tagGroups.push(currentTagGroup);
142
+ continue;
143
+ }
144
+ }
145
+
149
146
  // Generic header options (key: value before content/tag groups)
150
147
  if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
151
148
  const optMatch = trimmed.match(OPTION_RE);
152
- if (optMatch && !trimmed.startsWith('##') && !COLUMN_RE.test(trimmed)) {
149
+ if (optMatch && !COLUMN_RE.test(trimmed)) {
153
150
  const key = optMatch[1].trim().toLowerCase();
154
151
  if (key !== 'chart' && key !== 'title') {
155
152
  result.options[key] = optMatch[2].trim();
@@ -158,24 +155,6 @@ export function parseKanban(
158
155
  }
159
156
  }
160
157
 
161
- // ## Tag group heading
162
- const groupMatch = trimmed.match(GROUP_HEADING_RE);
163
- if (groupMatch && !contentStarted) {
164
- const groupName = groupMatch[1].trim();
165
- const alias = groupMatch[2] || undefined;
166
- currentTagGroup = {
167
- name: groupName,
168
- alias,
169
- entries: [],
170
- lineNumber,
171
- };
172
- if (alias) {
173
- aliasMap.set(alias.toLowerCase(), groupName.toLowerCase());
174
- }
175
- result.tagGroups.push(currentTagGroup);
176
- continue;
177
- }
178
-
179
158
  // Tag group entries (indented Value(color) [default] under ## heading)
180
159
  if (currentTagGroup && !contentStarted) {
181
160
  const indent = measureIndent(line);
@@ -5,6 +5,7 @@
5
5
  import * as d3Selection from 'd3-selection';
6
6
  import { FONT_FAMILY } from '../fonts';
7
7
  import type { PaletteColors } from '../palettes';
8
+ import { renderInlineText } from '../utils/inline-markdown';
8
9
  import type { ParsedKanban, KanbanColumn, KanbanCard, KanbanTagGroup } from './types';
9
10
  import { parseKanban } from './parser';
10
11
  import { isArchiveColumn } from './mutations';
@@ -498,14 +499,14 @@ export function renderKanban(
498
499
  .attr('stroke', cardStroke)
499
500
  .attr('stroke-width', CARD_STROKE_WIDTH);
500
501
 
501
- // Card title
502
- cg.append('text')
502
+ // Card title (inline markdown)
503
+ const titleEl = cg.append('text')
503
504
  .attr('x', cx + CARD_PADDING_X)
504
505
  .attr('y', cy + CARD_PADDING_Y + CARD_TITLE_FONT_SIZE)
505
506
  .attr('font-size', CARD_TITLE_FONT_SIZE)
506
507
  .attr('font-weight', '500')
507
- .attr('fill', palette.text)
508
- .text(card.title);
508
+ .attr('fill', palette.text);
509
+ renderInlineText(titleEl, card.title, palette, CARD_TITLE_FONT_SIZE);
509
510
 
510
511
  // Separator + metadata
511
512
  if (hasMeta) {
@@ -542,14 +543,14 @@ export function renderKanban(
542
543
  metaY += CARD_META_LINE_HEIGHT;
543
544
  }
544
545
 
545
- // Detail lines
546
+ // Detail lines (inline markdown)
546
547
  for (const detail of card.details) {
547
- cg.append('text')
548
+ const detailEl = cg.append('text')
548
549
  .attr('x', cx + CARD_PADDING_X)
549
550
  .attr('y', metaY)
550
551
  .attr('font-size', CARD_META_FONT_SIZE)
551
- .attr('fill', palette.textMuted)
552
- .text(detail);
552
+ .attr('fill', palette.textMuted);
553
+ renderInlineText(detailEl, detail, palette, CARD_META_FONT_SIZE);
553
554
 
554
555
  metaY += CARD_META_LINE_HEIGHT;
555
556
  }
@@ -1,18 +1,10 @@
1
1
  import type { DgmoError } from '../diagnostics';
2
+ import type { TagGroup, TagEntry } from '../utils/tag-groups';
2
3
 
3
- export interface KanbanTagEntry {
4
- value: string;
5
- color: string;
6
- lineNumber: number;
7
- }
8
-
9
- export interface KanbanTagGroup {
10
- name: string;
11
- alias?: string;
12
- entries: KanbanTagEntry[];
13
- defaultValue?: string;
14
- lineNumber: number;
15
- }
4
+ /** @deprecated Use `TagEntry` from `utils/tag-groups` */
5
+ export type KanbanTagEntry = TagEntry;
6
+ /** @deprecated Use `TagGroup` from `utils/tag-groups` */
7
+ export type KanbanTagGroup = TagGroup;
16
8
 
17
9
  export interface KanbanCard {
18
10
  id: string;
@@ -41,5 +33,5 @@ export interface ParsedKanban {
41
33
  tagGroups: KanbanTagGroup[];
42
34
  options: Record<string, string>;
43
35
  diagnostics: DgmoError[];
44
- error?: string;
36
+ error: string | null;
45
37
  }
package/src/org/parser.ts CHANGED
@@ -1,26 +1,25 @@
1
- import { resolveColor } from '../colors';
2
1
  import type { PaletteColors } from '../palettes';
3
2
  import type { DgmoError } from '../diagnostics';
4
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
+ import type { TagGroup, TagEntry } from '../utils/tag-groups';
5
+ import { isTagBlockHeading, matchTagBlockHeading } from '../utils/tag-groups';
6
+ import {
7
+ measureIndent,
8
+ extractColor,
9
+ parsePipeMetadata,
10
+ CHART_TYPE_RE,
11
+ TITLE_RE,
12
+ OPTION_RE,
13
+ } from '../utils/parsing';
5
14
 
6
15
  // ============================================================
7
16
  // Types
8
17
  // ============================================================
9
18
 
10
- export interface OrgTagEntry {
11
- value: string;
12
- color: string;
13
- lineNumber: number;
14
- }
15
-
16
- export interface OrgTagGroup {
17
- name: string;
18
- alias?: string;
19
- entries: OrgTagEntry[];
20
- /** Value of the entry marked `default` (nodes without metadata get this) */
21
- defaultValue?: string;
22
- lineNumber: number;
23
- }
19
+ /** @deprecated Use `TagEntry` from `utils/tag-groups` */
20
+ export type OrgTagEntry = TagEntry;
21
+ /** @deprecated Use `TagGroup` from `utils/tag-groups` */
22
+ export type OrgTagGroup = TagGroup;
24
23
 
25
24
  export interface OrgNode {
26
25
  id: string;
@@ -47,48 +46,19 @@ export interface ParsedOrg {
47
46
  // Helpers
48
47
  // ============================================================
49
48
 
50
- function measureIndent(line: string): number {
51
- let indent = 0;
52
- for (const ch of line) {
53
- if (ch === ' ') indent++;
54
- else if (ch === '\t') indent += 4;
55
- else break;
56
- }
57
- return indent;
58
- }
59
-
60
- const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
61
-
62
- function extractColor(
63
- label: string,
64
- palette?: PaletteColors
65
- ): { label: string; color?: string } {
66
- const m = label.match(COLOR_SUFFIX_RE);
67
- if (!m) return { label };
68
- const colorName = m[1].trim();
69
- return {
70
- label: label.substring(0, m.index!).trim(),
71
- color: resolveColor(colorName, palette),
72
- };
73
- }
74
-
75
- const GROUP_HEADING_RE = /^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
76
49
  const CONTAINER_RE = /^\[([^\]]+)\]$/;
77
50
  const METADATA_RE = /^([^:]+):\s*(.+)$/;
78
- const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
79
- const TITLE_RE = /^title\s*:\s*(.+)/i;
80
- const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
81
51
 
82
52
  // ============================================================
83
53
  // Inference
84
54
  // ============================================================
85
55
 
86
- /** Returns true if content contains tag group headings (`## ...`), suggesting an org chart. */
56
+ /** Returns true if content contains tag group headings (`tag: …` or `## …`), suggesting an org chart. */
87
57
  export function looksLikeOrg(content: string): boolean {
88
58
  for (const line of content.split('\n')) {
89
59
  const trimmed = line.trim();
90
60
  if (!trimmed || trimmed.startsWith('//')) continue;
91
- if (GROUP_HEADING_RE.test(trimmed)) return true;
61
+ if (isTagBlockHeading(trimmed)) return true;
92
62
  }
93
63
  return false;
94
64
  }
@@ -125,6 +95,11 @@ export function parseOrg(
125
95
  if (!result.error) result.error = formatDgmoError(diag);
126
96
  };
127
97
 
98
+ /** Push a non-fatal warning (does not set result.error). */
99
+ const pushWarning = (line: number, message: string): void => {
100
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
101
+ };
102
+
128
103
  if (!content || !content.trim()) {
129
104
  return fail(0, 'No content provided');
130
105
  }
@@ -189,41 +164,43 @@ export function parseOrg(
189
164
  }
190
165
  }
191
166
 
192
- // Generic header options (key: value lines before content/tag groups)
193
- // Only match non-indented lines with simple hyphenated keys
194
- if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
195
- const optMatch = trimmed.match(OPTION_RE);
196
- if (optMatch && !trimmed.startsWith('##')) {
197
- const key = optMatch[1].trim().toLowerCase();
198
- if (key !== 'chart' && key !== 'title') {
199
- result.options[key] = optMatch[2].trim();
200
- continue;
201
- }
202
- }
203
- }
204
-
205
- // ## Tag group heading
206
- const groupMatch = trimmed.match(GROUP_HEADING_RE);
207
- if (groupMatch) {
167
+ // Tag group heading — `tag: Name` (new) or `## Name` (deprecated)
168
+ // Must be checked BEFORE OPTION_RE to prevent `tag: Rank` being swallowed as option `tag=Rank`
169
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
170
+ if (tagBlockMatch) {
208
171
  if (contentStarted) {
209
- pushError(lineNumber, 'Tag groups (##) must appear before org content');
172
+ pushError(lineNumber, 'Tag groups must appear before org content');
210
173
  continue;
211
174
  }
212
- const groupName = groupMatch[1].trim();
213
- const alias = groupMatch[2] || undefined;
175
+ if (tagBlockMatch.deprecated) {
176
+ pushWarning(lineNumber, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`);
177
+ }
214
178
  currentTagGroup = {
215
- name: groupName,
216
- alias,
179
+ name: tagBlockMatch.name,
180
+ alias: tagBlockMatch.alias,
217
181
  entries: [],
218
182
  lineNumber,
219
183
  };
220
- if (alias) {
221
- aliasMap.set(alias.toLowerCase(), groupName.toLowerCase());
184
+ if (tagBlockMatch.alias) {
185
+ aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
222
186
  }
223
187
  result.tagGroups.push(currentTagGroup);
224
188
  continue;
225
189
  }
226
190
 
191
+ // Generic header options (key: value lines before content/tag groups)
192
+ // Only match non-indented lines with simple hyphenated keys
193
+ if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
194
+ const optMatch = trimmed.match(OPTION_RE);
195
+ if (optMatch) {
196
+ const key = optMatch[1].trim().toLowerCase();
197
+ if (key !== 'chart' && key !== 'title') {
198
+ result.options[key] = optMatch[2].trim();
199
+ continue;
200
+ }
201
+ }
202
+ }
203
+
227
204
  // Tag group entries (indented Value(color) [default] under ## heading)
228
205
  if (currentTagGroup && !contentStarted) {
229
206
  const indent = measureIndent(line);
@@ -342,24 +319,7 @@ function parseNodeLabel(
342
319
  let rawLabel = segments[0];
343
320
  const { label, color } = extractColor(rawLabel, palette);
344
321
 
345
- const metadata: Record<string, string> = {};
346
- // Collect all metadata parts: split pipe segments further on commas
347
- const metaParts: string[] = [];
348
- for (let j = 1; j < segments.length; j++) {
349
- for (const part of segments[j].split(',')) {
350
- const trimmedPart = part.trim();
351
- if (trimmedPart) metaParts.push(trimmedPart);
352
- }
353
- }
354
- for (const part of metaParts) {
355
- const colonIdx = part.indexOf(':');
356
- if (colonIdx > 0) {
357
- const rawKey = part.substring(0, colonIdx).trim().toLowerCase();
358
- const key = aliasMap.get(rawKey) ?? rawKey;
359
- const value = part.substring(colonIdx + 1).trim();
360
- metadata[key] = value;
361
- }
362
- }
322
+ const metadata = parsePipeMetadata(segments, aliasMap);
363
323
 
364
324
  return {
365
325
  id: `node-${counter}`,
@@ -1,5 +1,6 @@
1
1
  import type { DgmoError } from '../diagnostics';
2
2
  import { makeDgmoError } from '../diagnostics';
3
+ import { isTagBlockHeading, matchTagBlockHeading } from '../utils/tag-groups';
3
4
 
4
5
  // ============================================================
5
6
  // Types
@@ -25,7 +26,6 @@ const IMPORT_RE = /^(\s+)import:\s+(.+\.dgmo)\s*$/i;
25
26
  const TAGS_RE = /^tags:\s+(.+\.dgmo)\s*$/i;
26
27
  const HEADER_RE = /^(chart|title)\s*:/i;
27
28
  const OPTION_RE = /^[a-z][a-z0-9-]*\s*:/i;
28
- const GROUP_HEADING_RE = /^##\s+/;
29
29
 
30
30
  // ============================================================
31
31
  // Path Helpers (pure string ops — no Node `path` dependency)
@@ -67,10 +67,9 @@ function extractTagGroups(lines: string[]): TagGroupBlock[] {
67
67
 
68
68
  for (const line of lines) {
69
69
  const trimmed = line.trim();
70
- if (GROUP_HEADING_RE.test(trimmed)) {
71
- // Extract group name (everything after "## " up to optional alias/color)
72
- const nameMatch = trimmed.match(/^##\s+(.+?)(?:\s+alias\s+\w+)?(?:\s*\([^)]+\))?\s*$/);
73
- const name = nameMatch ? nameMatch[1].trim().toLowerCase() : trimmed.substring(3).trim().toLowerCase();
70
+ const headingMatch = matchTagBlockHeading(trimmed);
71
+ if (headingMatch) {
72
+ const name = headingMatch.name.toLowerCase();
74
73
  current = { name, lines: [line] };
75
74
  blocks.push(current);
76
75
  } else if (current) {
@@ -144,7 +143,7 @@ function parseFileHeader(lines: string[]): ParsedHeader {
144
143
  }
145
144
 
146
145
  // Other option-like header lines (non-indented key: value)
147
- if (OPTION_RE.test(trimmed) && !trimmed.startsWith('##') && !lines[i].match(/^\s/)) {
146
+ if (OPTION_RE.test(trimmed) && !isTagBlockHeading(trimmed) && !lines[i].match(/^\s/)) {
148
147
  // Check it's not a content line (node with metadata)
149
148
  const key = trimmed.split(':')[0].trim().toLowerCase();
150
149
  if (key !== 'chart' && key !== 'title' && !trimmed.includes('|')) {
@@ -207,7 +206,7 @@ async function resolveFile(
207
206
  headerLines.push(lines[i]);
208
207
  continue;
209
208
  }
210
- if (GROUP_HEADING_RE.test(trimmed)) continue; // skip inline tag group headings
209
+ if (isTagBlockHeading(trimmed)) continue; // skip inline tag group headings
211
210
  if (lines[i] !== trimmed) continue; // skip tag group entries (indented lines)
212
211
 
213
212
  const tagsMatch = trimmed.match(TAGS_RE);
@@ -248,7 +247,7 @@ async function resolveFile(
248
247
  if (!importMatch) {
249
248
  // Pass through — skip inline tag group lines (already extracted above)
250
249
  const trimmed = line.trim();
251
- if (GROUP_HEADING_RE.test(trimmed) || (inlineTagGroups.length > 0 && isTagGroupEntry(line, bodyLines, i))) {
250
+ if (isTagBlockHeading(trimmed) || (inlineTagGroups.length > 0 && isTagGroupEntry(line, bodyLines, i))) {
252
251
  continue;
253
252
  }
254
253
  resolvedBodyLines.push(line);
@@ -386,7 +385,7 @@ function findBodyStart(lines: string[]): number {
386
385
  }
387
386
 
388
387
  // Tag group heading
389
- if (GROUP_HEADING_RE.test(trimmed)) {
388
+ if (isTagBlockHeading(trimmed)) {
390
389
  inTagGroup = true;
391
390
  continue;
392
391
  }
@@ -405,7 +404,7 @@ function findBodyStart(lines: string[]): number {
405
404
  if (TAGS_RE.test(trimmed)) continue;
406
405
 
407
406
  // Option-like lines (non-indented key: value before content)
408
- if (OPTION_RE.test(trimmed) && !lines[i].match(/^\s/) && !trimmed.includes('|')) {
407
+ if (OPTION_RE.test(trimmed) && !isTagBlockHeading(trimmed) && !lines[i].match(/^\s/) && !trimmed.includes('|')) {
409
408
  const key = trimmed.split(':')[0].trim().toLowerCase();
410
409
  if (key !== 'chart' && key !== 'title') {
411
410
  continue;
@@ -420,7 +419,7 @@ function findBodyStart(lines: string[]): number {
420
419
  }
421
420
 
422
421
  /**
423
- * Check if a line is a tag group entry (indented line under a ## heading).
422
+ * Check if a line is a tag group entry (indented line under a tag block heading).
424
423
  */
425
424
  function isTagGroupEntry(line: string, allLines: string[], index: number): boolean {
426
425
  if (!line.match(/^\s+/)) return false;
@@ -428,7 +427,7 @@ function isTagGroupEntry(line: string, allLines: string[], index: number): boole
428
427
  for (let i = index - 1; i >= 0; i--) {
429
428
  const prev = allLines[i].trim();
430
429
  if (prev === '' || prev.startsWith('//')) continue;
431
- if (GROUP_HEADING_RE.test(prev)) return true;
430
+ if (isTagBlockHeading(prev)) return true;
432
431
  if (allLines[i].match(/^\s+/)) continue; // another entry
433
432
  return false;
434
433
  }