@diagrammo/dgmo 0.2.26 → 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.
- package/.claude/skills/dgmo-chart/SKILL.md +107 -0
- package/.claude/skills/dgmo-flowchart/SKILL.md +61 -0
- package/.claude/skills/dgmo-generate/SKILL.md +58 -0
- package/.claude/skills/dgmo-sequence/SKILL.md +83 -0
- package/.cursorrules +117 -0
- package/.github/copilot-instructions.md +117 -0
- package/.windsurfrules +117 -0
- package/README.md +10 -3
- package/dist/cli.cjs +116 -108
- package/dist/index.cjs +563 -356
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -24
- package/dist/index.d.ts +39 -24
- package/dist/index.js +560 -355
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +125 -0
- package/docs/language-reference.md +784 -0
- package/package.json +10 -3
- package/src/c4/parser.ts +90 -74
- package/src/c4/renderer.ts +13 -12
- package/src/c4/types.ts +6 -4
- package/src/chart.ts +3 -2
- package/src/class/parser.ts +2 -10
- package/src/class/types.ts +1 -1
- package/src/cli.ts +135 -19
- package/src/d3.ts +1 -1
- package/src/dgmo-mermaid.ts +1 -1
- package/src/dgmo-router.ts +1 -1
- package/src/echarts.ts +33 -13
- package/src/er/parser.ts +34 -43
- package/src/er/types.ts +1 -1
- package/src/graph/flowchart-parser.ts +2 -25
- package/src/graph/types.ts +1 -1
- package/src/index.ts +5 -0
- package/src/initiative-status/parser.ts +57 -11
- package/src/initiative-status/types.ts +1 -1
- package/src/kanban/parser.ts +32 -53
- package/src/kanban/renderer.ts +9 -8
- package/src/kanban/types.ts +6 -14
- package/src/org/parser.ts +47 -87
- package/src/org/resolver.ts +11 -12
- package/src/sequence/parser.ts +97 -15
- package/src/sequence/renderer.ts +62 -69
- package/src/utils/arrows.ts +75 -0
- package/src/utils/inline-markdown.ts +75 -0
- package/src/utils/parsing.ts +67 -0
- package/src/utils/tag-groups.ts +76 -0
|
@@ -26,16 +26,20 @@ export function looksLikeInitiativeStatus(content: string): boolean {
|
|
|
26
26
|
const lines = content.split('\n');
|
|
27
27
|
let hasArrow = false;
|
|
28
28
|
let hasStatus = false;
|
|
29
|
+
let hasIndentedArrow = false;
|
|
29
30
|
for (const line of lines) {
|
|
30
31
|
const trimmed = line.trim();
|
|
31
|
-
if (!trimmed || trimmed.startsWith('
|
|
32
|
+
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
32
33
|
if (trimmed.match(/^chart\s*:/i)) continue;
|
|
33
34
|
if (trimmed.match(/^title\s*:/i)) continue;
|
|
34
35
|
if (trimmed.includes('->')) hasArrow = true;
|
|
35
36
|
if (/\|\s*(done|wip|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
|
|
37
|
+
// Indented arrow is a strong signal — only initiative-status uses this
|
|
38
|
+
const isIndented = line.length > 0 && line !== trimmed && /^\s/.test(line);
|
|
39
|
+
if (isIndented && (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed))) hasIndentedArrow = true;
|
|
36
40
|
if (hasArrow && hasStatus) return true;
|
|
37
41
|
}
|
|
38
|
-
return
|
|
42
|
+
return hasIndentedArrow;
|
|
39
43
|
}
|
|
40
44
|
|
|
41
45
|
// ============================================================
|
|
@@ -44,7 +48,7 @@ export function looksLikeInitiativeStatus(content: string): boolean {
|
|
|
44
48
|
|
|
45
49
|
function parseStatus(raw: string, line: number, diagnostics: DgmoError[]): InitiativeStatus {
|
|
46
50
|
const trimmed = raw.trim().toLowerCase();
|
|
47
|
-
if (!trimmed) return
|
|
51
|
+
if (!trimmed) return 'na';
|
|
48
52
|
if (VALID_STATUSES.includes(trimmed)) return trimmed as InitiativeStatus;
|
|
49
53
|
|
|
50
54
|
// Unknown status — emit warning with suggestion
|
|
@@ -64,12 +68,13 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
64
68
|
groups: [],
|
|
65
69
|
options: {},
|
|
66
70
|
diagnostics: [],
|
|
67
|
-
error:
|
|
71
|
+
error: null,
|
|
68
72
|
};
|
|
69
73
|
|
|
70
74
|
const lines = content.split('\n');
|
|
71
75
|
const nodeLabels = new Set<string>();
|
|
72
76
|
let currentGroup: ISGroup | null = null;
|
|
77
|
+
let lastNodeLabel: string | null = null;
|
|
73
78
|
|
|
74
79
|
for (let i = 0; i < lines.length; i++) {
|
|
75
80
|
const lineNum = i + 1; // 1-based
|
|
@@ -77,7 +82,7 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
77
82
|
const trimmed = raw.trim();
|
|
78
83
|
|
|
79
84
|
// Skip blanks and comments
|
|
80
|
-
if (!trimmed || trimmed.startsWith('
|
|
85
|
+
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
81
86
|
|
|
82
87
|
// chart: header
|
|
83
88
|
const chartMatch = trimmed.match(/^chart\s*:\s*(.+)/i);
|
|
@@ -118,9 +123,20 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
118
123
|
currentGroup = null;
|
|
119
124
|
}
|
|
120
125
|
|
|
121
|
-
// Edge: contains `->`
|
|
126
|
+
// Edge: contains `->` or labeled form `-label->`
|
|
122
127
|
if (trimmed.includes('->')) {
|
|
123
|
-
|
|
128
|
+
let edgeText = trimmed;
|
|
129
|
+
// Indented `-> Target` or `-label-> Target` shorthand
|
|
130
|
+
if (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed)) {
|
|
131
|
+
if (!lastNodeLabel) {
|
|
132
|
+
result.diagnostics.push(
|
|
133
|
+
makeDgmoError(lineNum, 'Indented edge has no preceding node to use as source', 'warning')
|
|
134
|
+
);
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
edgeText = `${lastNodeLabel} ${trimmed}`;
|
|
138
|
+
}
|
|
139
|
+
const edge = parseEdgeLine(edgeText, lineNum, result.diagnostics);
|
|
124
140
|
if (edge) result.edges.push(edge);
|
|
125
141
|
continue;
|
|
126
142
|
}
|
|
@@ -128,6 +144,7 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
128
144
|
// Node: everything else
|
|
129
145
|
const node = parseNodeLine(trimmed, lineNum, result.diagnostics);
|
|
130
146
|
if (node) {
|
|
147
|
+
lastNodeLabel = node.label;
|
|
131
148
|
if (nodeLabels.has(node.label)) {
|
|
132
149
|
result.diagnostics.push(
|
|
133
150
|
makeDgmoError(lineNum, `Duplicate node "${node.label}"`, 'warning')
|
|
@@ -156,7 +173,7 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
156
173
|
);
|
|
157
174
|
// Auto-create an implicit node
|
|
158
175
|
if (!result.nodes.some((n) => n.label === edge.source)) {
|
|
159
|
-
result.nodes.push({ label: edge.source, status:
|
|
176
|
+
result.nodes.push({ label: edge.source, status: 'na', shape: inferParticipantType(edge.source), lineNumber: edge.lineNumber });
|
|
160
177
|
nodeLabels.add(edge.source);
|
|
161
178
|
}
|
|
162
179
|
}
|
|
@@ -165,7 +182,7 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
165
182
|
makeDgmoError(edge.lineNumber, `Edge target "${edge.target}" is not a declared node`, 'warning')
|
|
166
183
|
);
|
|
167
184
|
if (!result.nodes.some((n) => n.label === edge.target)) {
|
|
168
|
-
result.nodes.push({ label: edge.target, status:
|
|
185
|
+
result.nodes.push({ label: edge.target, status: 'na', shape: inferParticipantType(edge.target), lineNumber: edge.lineNumber });
|
|
169
186
|
nodeLabels.add(edge.target);
|
|
170
187
|
}
|
|
171
188
|
}
|
|
@@ -193,7 +210,7 @@ function parseNodeLine(
|
|
|
193
210
|
const status = parseStatus(statusRaw, lineNum, diagnostics);
|
|
194
211
|
return { label, status, shape: inferParticipantType(label), lineNumber: lineNum };
|
|
195
212
|
}
|
|
196
|
-
return { label: trimmed, status:
|
|
213
|
+
return { label: trimmed, status: 'na', shape: inferParticipantType(trimmed), lineNumber: lineNum };
|
|
197
214
|
}
|
|
198
215
|
|
|
199
216
|
function parseEdgeLine(
|
|
@@ -205,6 +222,35 @@ function parseEdgeLine(
|
|
|
205
222
|
// or: <source> -> <target> | <status>
|
|
206
223
|
// or: <source> -> <target>: <label>
|
|
207
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
|
+
}
|
|
208
254
|
|
|
209
255
|
const arrowIdx = trimmed.indexOf('->');
|
|
210
256
|
if (arrowIdx < 0) return null;
|
|
@@ -218,7 +264,7 @@ function parseEdgeLine(
|
|
|
218
264
|
}
|
|
219
265
|
|
|
220
266
|
// Extract status from end (after last |)
|
|
221
|
-
let status: InitiativeStatus =
|
|
267
|
+
let status: InitiativeStatus = 'na';
|
|
222
268
|
const lastPipe = rest.lastIndexOf('|');
|
|
223
269
|
if (lastPipe >= 0) {
|
|
224
270
|
const statusRaw = rest.slice(lastPipe + 1).trim();
|
package/src/kanban/parser.ts
CHANGED
|
@@ -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 && !
|
|
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);
|
package/src/kanban/renderer.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/kanban/types.ts
CHANGED
|
@@ -1,18 +1,10 @@
|
|
|
1
1
|
import type { DgmoError } from '../diagnostics';
|
|
2
|
+
import type { TagGroup, TagEntry } from '../utils/tag-groups';
|
|
2
3
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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 (`##
|
|
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 (
|
|
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
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
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
|
|
172
|
+
pushError(lineNumber, 'Tag groups must appear before org content');
|
|
210
173
|
continue;
|
|
211
174
|
}
|
|
212
|
-
|
|
213
|
-
|
|
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:
|
|
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(),
|
|
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
|
|
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}`,
|
package/src/org/resolver.ts
CHANGED
|
@@ -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
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
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
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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 (
|
|
430
|
+
if (isTagBlockHeading(prev)) return true;
|
|
432
431
|
if (allLines[i].match(/^\s+/)) continue; // another entry
|
|
433
432
|
return false;
|
|
434
433
|
}
|