@diagrammo/dgmo 0.7.3 → 0.8.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.
- package/AGENTS.md +15 -20
- package/README.md +56 -58
- package/dist/cli.cjs +188 -181
- package/dist/index.cjs +3522 -1072
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -43
- package/dist/index.d.ts +196 -43
- package/dist/index.js +3509 -1072
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +629 -289
- package/package.json +1 -1
- package/src/c4/layout.ts +6 -9
- package/src/c4/parser.ts +189 -83
- package/src/c4/renderer.ts +8 -9
- package/src/chart.ts +296 -83
- package/src/class/parser.ts +54 -37
- package/src/class/renderer.ts +8 -8
- package/src/cli.ts +8 -8
- package/src/colors.ts +4 -1
- package/src/completion.ts +757 -10
- package/src/d3.ts +324 -78
- package/src/dgmo-router.ts +63 -8
- package/src/echarts.ts +735 -241
- package/src/er/parser.ts +94 -76
- package/src/er/renderer.ts +6 -5
- package/src/gantt/parser.ts +144 -69
- package/src/gantt/renderer.ts +50 -14
- package/src/gantt/types.ts +3 -3
- package/src/graph/flowchart-parser.ts +97 -37
- package/src/graph/flowchart-renderer.ts +4 -3
- package/src/graph/state-parser.ts +50 -31
- package/src/graph/state-renderer.ts +4 -3
- package/src/index.ts +14 -5
- package/src/infra/compute.ts +1 -0
- package/src/infra/layout.ts +3 -0
- package/src/infra/parser.ts +291 -92
- package/src/infra/renderer.ts +172 -30
- package/src/infra/types.ts +5 -0
- package/src/initiative-status/layout.ts +1 -1
- package/src/initiative-status/parser.ts +121 -47
- package/src/initiative-status/renderer.ts +42 -23
- package/src/initiative-status/types.ts +10 -2
- package/src/kanban/parser.ts +60 -37
- package/src/kanban/renderer.ts +2 -2
- package/src/kanban/types.ts +1 -0
- package/src/org/layout.ts +9 -9
- package/src/org/parser.ts +39 -40
- package/src/org/renderer.ts +5 -6
- package/src/org/resolver.ts +26 -19
- package/src/render.ts +1 -1
- package/src/sequence/parser.ts +304 -95
- package/src/sequence/renderer.ts +9 -9
- package/src/sitemap/layout.ts +3 -4
- package/src/sitemap/parser.ts +57 -49
- package/src/sitemap/renderer.ts +6 -7
- package/src/utils/arrows.ts +25 -6
- package/src/utils/duration.ts +43 -7
- package/src/utils/legend-constants.ts +26 -0
- package/src/utils/legend-svg.ts +167 -0
- package/src/utils/parsing.ts +247 -7
- package/src/utils/tag-groups.ts +160 -15
- package/src/utils/title-constants.ts +9 -0
|
@@ -11,11 +11,11 @@ import type {
|
|
|
11
11
|
ISGroup,
|
|
12
12
|
InitiativeStatus,
|
|
13
13
|
} from './types';
|
|
14
|
-
import { VALID_STATUSES } from './types';
|
|
14
|
+
import { VALID_STATUSES, STATUS_ALIASES } from './types';
|
|
15
15
|
import { inferParticipantType } from '../sequence/participant-inference';
|
|
16
16
|
import { matchTagBlockHeading, injectDefaultTagMetadata, validateTagValues } from '../utils/tag-groups';
|
|
17
17
|
import type { TagGroup } from '../utils/tag-groups';
|
|
18
|
-
import { extractColor } from '../utils/parsing';
|
|
18
|
+
import { extractColor, parseFirstLine, ALL_CHART_TYPES, OPTION_NOCOLON_RE } from '../utils/parsing';
|
|
19
19
|
|
|
20
20
|
// ============================================================
|
|
21
21
|
// Heuristic — does this content look like an initiative-status diagram?
|
|
@@ -35,8 +35,10 @@ export function looksLikeInitiativeStatus(content: string): boolean {
|
|
|
35
35
|
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
36
36
|
if (trimmed.match(/^chart\s*:/i)) continue;
|
|
37
37
|
if (trimmed.match(/^title\s*:/i)) continue;
|
|
38
|
+
// Skip new-style first line (bare chart type name)
|
|
39
|
+
if (parseFirstLine(trimmed)) continue;
|
|
38
40
|
if (trimmed.includes('->')) hasArrow = true;
|
|
39
|
-
if (/\|\s*(done|wip|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
|
|
41
|
+
if (/\|\s*(done|doing|wip|blocked|paused|waiting|todo|na)\s*$/i.test(trimmed)) hasStatus = true;
|
|
40
42
|
// Indented arrow is a strong signal — only initiative-status uses this
|
|
41
43
|
const isIndented = line.length > 0 && line !== trimmed && /^\s/.test(line);
|
|
42
44
|
if (isIndented && (trimmed.startsWith('->') || /^-[^>].*->/.test(trimmed))) hasIndentedArrow = true;
|
|
@@ -80,18 +82,36 @@ export function parseNodeMetadata(
|
|
|
80
82
|
// key: value pair
|
|
81
83
|
const rawKey = trimmed.slice(0, colonIdx).trim().toLowerCase();
|
|
82
84
|
const value = trimmed.slice(colonIdx + 1).trim();
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
85
|
+
|
|
86
|
+
// Handle explicit `status: keyword` form
|
|
87
|
+
if (rawKey === 'status') {
|
|
88
|
+
hadStatusWord = true;
|
|
89
|
+
const lower = value.toLowerCase();
|
|
90
|
+
const canonical = STATUS_ALIASES[lower] ?? lower;
|
|
91
|
+
if (VALID_STATUSES.includes(canonical)) {
|
|
92
|
+
status = canonical as InitiativeStatus;
|
|
93
|
+
} else if (lineNum !== undefined && diagnostics) {
|
|
94
|
+
const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
|
|
95
|
+
const hint = suggest(lower, allKnown);
|
|
96
|
+
const msg = `Unknown status "${value}"${hint ? `. ${hint}` : ''}`;
|
|
97
|
+
diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
// Resolve alias to group name
|
|
101
|
+
const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
|
|
102
|
+
metadata[resolvedKey] = value;
|
|
103
|
+
}
|
|
86
104
|
} else {
|
|
87
|
-
// Bare word — check if it's a status keyword
|
|
105
|
+
// Bare word — check if it's a status keyword (or alias)
|
|
88
106
|
hadStatusWord = true;
|
|
89
107
|
const lower = trimmed.toLowerCase();
|
|
90
|
-
|
|
91
|
-
|
|
108
|
+
const canonical = STATUS_ALIASES[lower] ?? lower;
|
|
109
|
+
if (VALID_STATUSES.includes(canonical)) {
|
|
110
|
+
status = canonical as InitiativeStatus;
|
|
92
111
|
} else if (lineNum !== undefined && diagnostics) {
|
|
93
112
|
// Unknown bare word — likely a status typo, emit warning
|
|
94
|
-
const
|
|
113
|
+
const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
|
|
114
|
+
const hint = suggest(lower, allKnown);
|
|
95
115
|
const msg = `Unknown status "${trimmed}"${hint ? `. ${hint}` : ''}`;
|
|
96
116
|
diagnostics.push(makeDgmoError(lineNum, msg, 'warning'));
|
|
97
117
|
}
|
|
@@ -108,10 +128,12 @@ export function parseNodeMetadata(
|
|
|
108
128
|
function parseStatus(raw: string, line: number, diagnostics: DgmoError[]): InitiativeStatus {
|
|
109
129
|
const trimmed = raw.trim().toLowerCase();
|
|
110
130
|
if (!trimmed) return 'na';
|
|
111
|
-
|
|
131
|
+
const canonical = STATUS_ALIASES[trimmed] ?? trimmed;
|
|
132
|
+
if (VALID_STATUSES.includes(canonical)) return canonical as InitiativeStatus;
|
|
112
133
|
|
|
113
134
|
// Unknown status — emit warning with suggestion
|
|
114
|
-
const
|
|
135
|
+
const allKnown = [...VALID_STATUSES, ...Object.keys(STATUS_ALIASES)];
|
|
136
|
+
const hint = suggest(trimmed, allKnown);
|
|
115
137
|
const msg = `Unknown status "${raw.trim()}"${hint ? `. ${hint}` : ''}`;
|
|
116
138
|
diagnostics.push(makeDgmoError(line, msg, 'warning'));
|
|
117
139
|
return null;
|
|
@@ -165,36 +187,32 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
165
187
|
// Skip blanks and comments
|
|
166
188
|
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
167
189
|
|
|
168
|
-
// chart:
|
|
169
|
-
const
|
|
170
|
-
if (
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
const diag = makeDgmoError(lineNum, `Expected chart type "initiative-status", got "${chartType}"`);
|
|
190
|
+
// First line: chart type + optional title (new syntax: `initiative-status My Dashboard`)
|
|
191
|
+
const firstLineResult = parseFirstLine(trimmed);
|
|
192
|
+
if (firstLineResult && !contentStarted) {
|
|
193
|
+
if (firstLineResult.chartType !== 'initiative-status') {
|
|
194
|
+
const diag = makeDgmoError(lineNum, `Expected chart type "initiative-status", got "${firstLineResult.chartType}"`);
|
|
174
195
|
result.diagnostics.push(diag);
|
|
175
196
|
result.error = formatDgmoError(diag);
|
|
176
197
|
return result;
|
|
177
198
|
}
|
|
199
|
+
if (firstLineResult.title) {
|
|
200
|
+
result.title = firstLineResult.title;
|
|
201
|
+
result.titleLineNumber = lineNum;
|
|
202
|
+
}
|
|
178
203
|
continue;
|
|
179
204
|
}
|
|
180
205
|
|
|
181
|
-
//
|
|
182
|
-
const
|
|
183
|
-
if (
|
|
184
|
-
|
|
185
|
-
result.titleLineNumber = lineNum;
|
|
186
|
-
continue;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
// hide: directive — parse before tag blocks and content
|
|
190
|
-
const hideMatch = trimmed.match(/^hide\s*:\s*(.+)/i);
|
|
191
|
-
if (hideMatch) {
|
|
206
|
+
// hide directive (no colon): `hide phase Planning, phase Review`
|
|
207
|
+
const hideMatch = trimmed.match(/^hide\s+(.+)/i);
|
|
208
|
+
if (hideMatch && !trimmed.match(/^hide\s*\|/)) {
|
|
209
|
+
// Parse comma-separated tag-value pairs: `phase Planning, phase Review`
|
|
192
210
|
const pairs = hideMatch[1].split(',');
|
|
193
211
|
for (const pair of pairs) {
|
|
194
|
-
const
|
|
195
|
-
if (
|
|
196
|
-
const groupKey =
|
|
197
|
-
const value =
|
|
212
|
+
const tokens = pair.trim().split(/\s+/);
|
|
213
|
+
if (tokens.length >= 2) {
|
|
214
|
+
const groupKey = tokens[0].toLowerCase();
|
|
215
|
+
const value = tokens.slice(1).join(' ').toLowerCase();
|
|
198
216
|
if (groupKey && value) {
|
|
199
217
|
if (!result.initialHiddenTagValues.has(groupKey)) {
|
|
200
218
|
result.initialHiddenTagValues.set(groupKey, new Set());
|
|
@@ -206,6 +224,20 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
206
224
|
continue;
|
|
207
225
|
}
|
|
208
226
|
|
|
227
|
+
// Options (space-separated, non-indented): `active-tag Priority`
|
|
228
|
+
if (!contentStarted && measureIndent(raw) === 0) {
|
|
229
|
+
const optMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
230
|
+
if (optMatch) {
|
|
231
|
+
const key = optMatch[1].toLowerCase();
|
|
232
|
+
const value = optMatch[2].trim();
|
|
233
|
+
// Only recognize known option keys (not node content)
|
|
234
|
+
if (key === 'active-tag' || key === 'sort') {
|
|
235
|
+
result.options[key] = value;
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
209
241
|
// Tag group heading — must be checked BEFORE group/node/edge matching
|
|
210
242
|
const tagBlockMatch = matchTagBlockHeading(trimmed);
|
|
211
243
|
if (tagBlockMatch) {
|
|
@@ -216,7 +248,10 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
216
248
|
continue;
|
|
217
249
|
}
|
|
218
250
|
if (tagBlockMatch.deprecated) {
|
|
219
|
-
|
|
251
|
+
result.diagnostics.push(
|
|
252
|
+
makeDgmoError(lineNum, `'## ${tagBlockMatch.name}' is no longer supported — use 'tag ${tagBlockMatch.name}' instead`)
|
|
253
|
+
);
|
|
254
|
+
continue;
|
|
220
255
|
}
|
|
221
256
|
currentTagGroup = {
|
|
222
257
|
name: tagBlockMatch.name,
|
|
@@ -227,35 +262,47 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
227
262
|
if (tagBlockMatch.alias) {
|
|
228
263
|
aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
|
|
229
264
|
}
|
|
265
|
+
// Handle inline values from single-line tag declaration
|
|
266
|
+
if (tagBlockMatch.inlineValues) {
|
|
267
|
+
for (const rawVal of tagBlockMatch.inlineValues) {
|
|
268
|
+
const { label, color } = extractColor(rawVal);
|
|
269
|
+
currentTagGroup.entries.push({
|
|
270
|
+
value: label,
|
|
271
|
+
color: color ?? '',
|
|
272
|
+
lineNumber: lineNum,
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
// First entry is the default
|
|
276
|
+
if (currentTagGroup.entries.length > 0) {
|
|
277
|
+
currentTagGroup.defaultValue = currentTagGroup.entries[0].value;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
230
280
|
result.tagGroups.push(currentTagGroup);
|
|
231
281
|
continue;
|
|
232
282
|
}
|
|
233
283
|
|
|
234
|
-
// Tag group entries (indented Value(color)
|
|
284
|
+
// Tag group entries (indented Value(color) under tag heading — first value is the default)
|
|
235
285
|
if (currentTagGroup && !contentStarted) {
|
|
236
286
|
const indent = measureIndent(raw);
|
|
237
287
|
if (indent > 0) {
|
|
238
|
-
const
|
|
239
|
-
const entryText = isDefault
|
|
240
|
-
? trimmed.replace(/\s+default\s*$/i, '').trim()
|
|
241
|
-
: trimmed;
|
|
242
|
-
const { label, color } = extractColor(entryText);
|
|
243
|
-
if (isDefault) {
|
|
244
|
-
currentTagGroup.defaultValue = label;
|
|
245
|
-
}
|
|
288
|
+
const { label, color } = extractColor(trimmed);
|
|
246
289
|
currentTagGroup.entries.push({
|
|
247
290
|
value: label,
|
|
248
291
|
color: color ?? '',
|
|
249
292
|
lineNumber: lineNum,
|
|
250
293
|
});
|
|
294
|
+
// First entry is the default
|
|
295
|
+
if (currentTagGroup.entries.length === 1) {
|
|
296
|
+
currentTagGroup.defaultValue = label;
|
|
297
|
+
}
|
|
251
298
|
continue;
|
|
252
299
|
}
|
|
253
300
|
// Non-indented line after tag group — close and fall through
|
|
254
301
|
currentTagGroup = null;
|
|
255
302
|
}
|
|
256
303
|
|
|
257
|
-
// Group header: [Group Name]
|
|
258
|
-
const groupMatch = trimmed.match(/^\[(
|
|
304
|
+
// Group header: [Group Name] or [Group Name] | metadata
|
|
305
|
+
const groupMatch = trimmed.match(/^\[(.+?)\]\s*(?:\|\s*(.+))?$/);
|
|
259
306
|
if (groupMatch) {
|
|
260
307
|
contentStarted = true;
|
|
261
308
|
currentTagGroup = null;
|
|
@@ -263,7 +310,26 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
263
310
|
if (currentGroup) {
|
|
264
311
|
result.groups.push(currentGroup);
|
|
265
312
|
}
|
|
266
|
-
|
|
313
|
+
const groupMeta: Record<string, string> = {};
|
|
314
|
+
if (groupMatch[2]) {
|
|
315
|
+
// Parse pipe metadata for group (only key:value pairs, no status)
|
|
316
|
+
const items = groupMatch[2].split(',');
|
|
317
|
+
for (const item of items) {
|
|
318
|
+
const ci = item.indexOf(':');
|
|
319
|
+
if (ci >= 0) {
|
|
320
|
+
const rawKey = item.slice(0, ci).trim().toLowerCase();
|
|
321
|
+
const value = item.slice(ci + 1).trim();
|
|
322
|
+
const resolvedKey = aliasMap.get(rawKey) ?? rawKey;
|
|
323
|
+
groupMeta[resolvedKey] = value;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
currentGroup = {
|
|
328
|
+
label: groupMatch[1],
|
|
329
|
+
nodeLabels: [],
|
|
330
|
+
lineNumber: lineNum,
|
|
331
|
+
metadata: Object.keys(groupMeta).length > 0 ? groupMeta : undefined,
|
|
332
|
+
};
|
|
267
333
|
continue;
|
|
268
334
|
}
|
|
269
335
|
|
|
@@ -307,6 +373,14 @@ export function parseInitiativeStatus(content: string): ParsedInitiativeStatus {
|
|
|
307
373
|
} else {
|
|
308
374
|
nodeLabels.add(node.label);
|
|
309
375
|
}
|
|
376
|
+
// Cascade group metadata into node (group provides defaults, node overrides)
|
|
377
|
+
if (currentGroup && isIndented && currentGroup.metadata) {
|
|
378
|
+
for (const [key, val] of Object.entries(currentGroup.metadata)) {
|
|
379
|
+
if (!(key in node.metadata)) {
|
|
380
|
+
node.metadata[key] = val;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
}
|
|
310
384
|
result.nodes.push(node);
|
|
311
385
|
// Add to current group if indented
|
|
312
386
|
if (currentGroup && isIndented) {
|
|
@@ -395,7 +469,7 @@ function parseEdgeLine(
|
|
|
395
469
|
// or: <source> -<label>-> <target> [| <status>]
|
|
396
470
|
|
|
397
471
|
// Check for labeled arrow form: SOURCE -LABEL-> TARGET [| status]
|
|
398
|
-
const labeledMatch = trimmed.match(/^(\S+)\s
|
|
472
|
+
const labeledMatch = trimmed.match(/^(\S+)\s*-(.+)->\s*(.+)$/);
|
|
399
473
|
if (labeledMatch) {
|
|
400
474
|
const source = labeledMatch[1];
|
|
401
475
|
const label = labeledMatch[2].trim();
|
|
@@ -10,15 +10,15 @@ import {
|
|
|
10
10
|
LEGEND_HEIGHT,
|
|
11
11
|
LEGEND_PILL_PAD,
|
|
12
12
|
LEGEND_PILL_FONT_SIZE,
|
|
13
|
-
LEGEND_PILL_FONT_W,
|
|
14
13
|
LEGEND_CAPSULE_PAD,
|
|
15
14
|
LEGEND_DOT_R,
|
|
16
15
|
LEGEND_ENTRY_FONT_SIZE,
|
|
17
|
-
LEGEND_ENTRY_FONT_W,
|
|
18
16
|
LEGEND_ENTRY_DOT_GAP,
|
|
19
17
|
LEGEND_ENTRY_TRAIL,
|
|
20
18
|
LEGEND_GROUP_GAP,
|
|
19
|
+
measureLegendText,
|
|
21
20
|
} from '../utils/legend-constants';
|
|
21
|
+
import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT, TITLE_Y } from '../utils/title-constants';
|
|
22
22
|
import { contrastText, mix } from '../palettes/color-utils';
|
|
23
23
|
import type { TagGroup } from '../utils/tag-groups';
|
|
24
24
|
import type { PaletteColors } from '../palettes';
|
|
@@ -55,11 +55,12 @@ const COLLAPSE_BAR_HEIGHT = 6;
|
|
|
55
55
|
|
|
56
56
|
function statusColor(status: InitiativeStatus, palette: PaletteColors, isDark: boolean): string {
|
|
57
57
|
switch (status) {
|
|
58
|
-
case 'done':
|
|
59
|
-
case '
|
|
60
|
-
case '
|
|
61
|
-
case '
|
|
62
|
-
|
|
58
|
+
case 'done': return palette.colors.green;
|
|
59
|
+
case 'doing': return palette.colors.blue;
|
|
60
|
+
case 'blocked': return palette.colors.orange;
|
|
61
|
+
case 'todo': return palette.colors.red;
|
|
62
|
+
case 'na': return isDark ? palette.colors.gray : '#2e3440';
|
|
63
|
+
default: return palette.textMuted;
|
|
63
64
|
}
|
|
64
65
|
}
|
|
65
66
|
|
|
@@ -91,13 +92,14 @@ interface ISLegendEntry {
|
|
|
91
92
|
}
|
|
92
93
|
|
|
93
94
|
const IS_STATUS_LABELS: Record<string, string> = {
|
|
94
|
-
done:
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
95
|
+
done: 'Done',
|
|
96
|
+
doing: 'In Progress',
|
|
97
|
+
blocked: 'Blocked',
|
|
98
|
+
todo: 'To Do',
|
|
99
|
+
na: 'N/A',
|
|
98
100
|
};
|
|
99
101
|
|
|
100
|
-
const IS_STATUS_ORDER: InitiativeStatus[] = ['todo', '
|
|
102
|
+
const IS_STATUS_ORDER: InitiativeStatus[] = ['todo', 'blocked', 'doing', 'done', 'na'];
|
|
101
103
|
|
|
102
104
|
function collectStatuses(parsed: ParsedInitiativeStatus): ISLegendEntry[] {
|
|
103
105
|
const present = new Set<string>();
|
|
@@ -114,7 +116,7 @@ const LEGEND_GROUP_NAME = 'Status';
|
|
|
114
116
|
function legendEntriesWidth(entries: ISLegendEntry[]): number {
|
|
115
117
|
let w = 0;
|
|
116
118
|
for (const e of entries) {
|
|
117
|
-
w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label
|
|
119
|
+
w += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.label, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
|
|
118
120
|
}
|
|
119
121
|
return w;
|
|
120
122
|
}
|
|
@@ -556,11 +558,11 @@ export function renderInitiativeStatus(
|
|
|
556
558
|
.append('text')
|
|
557
559
|
.attr('class', 'chart-title')
|
|
558
560
|
.attr('x', width / 2)
|
|
559
|
-
.attr('y',
|
|
561
|
+
.attr('y', TITLE_Y)
|
|
560
562
|
.attr('text-anchor', 'middle')
|
|
561
563
|
.attr('fill', palette.text)
|
|
562
|
-
.attr('font-size',
|
|
563
|
-
.attr('font-weight',
|
|
564
|
+
.attr('font-size', TITLE_FONT_SIZE)
|
|
565
|
+
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
564
566
|
.style('cursor', onClickItem && parsed.titleLineNumber ? 'pointer' : 'default')
|
|
565
567
|
.text(parsed.title);
|
|
566
568
|
|
|
@@ -599,7 +601,7 @@ export function renderInitiativeStatus(
|
|
|
599
601
|
color: statusColor(e.statusKey, palette, isDark),
|
|
600
602
|
value: e.statusKey ?? 'na',
|
|
601
603
|
}));
|
|
602
|
-
const pillW = LEGEND_GROUP_NAME
|
|
604
|
+
const pillW = measureLegendText(LEGEND_GROUP_NAME, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
603
605
|
const entrW = legendEntriesWidth(legendEntries);
|
|
604
606
|
legendGroups.push({
|
|
605
607
|
name: LEGEND_GROUP_NAME,
|
|
@@ -617,10 +619,10 @@ export function renderInitiativeStatus(
|
|
|
617
619
|
color: e.color || palette.textMuted,
|
|
618
620
|
value: e.value.toLowerCase(),
|
|
619
621
|
}));
|
|
620
|
-
const pillW = tg.name
|
|
622
|
+
const pillW = measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
621
623
|
let entrW = 0;
|
|
622
624
|
for (const e of entries) {
|
|
623
|
-
entrW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + e.label
|
|
625
|
+
entrW += LEGEND_DOT_R * 2 + LEGEND_ENTRY_DOT_GAP + measureLegendText(e.label, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
|
|
624
626
|
}
|
|
625
627
|
legendGroups.push({
|
|
626
628
|
name: tg.name,
|
|
@@ -645,7 +647,7 @@ export function renderInitiativeStatus(
|
|
|
645
647
|
let totalLegendW = 0;
|
|
646
648
|
for (const lg of visibleLegendGroups) {
|
|
647
649
|
const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
|
|
648
|
-
const pillW = lg.name
|
|
650
|
+
const pillW = measureLegendText(lg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
649
651
|
totalLegendW += isActive ? lg.width : pillW;
|
|
650
652
|
totalLegendW += LEGEND_GROUP_GAP;
|
|
651
653
|
}
|
|
@@ -663,7 +665,7 @@ export function renderInitiativeStatus(
|
|
|
663
665
|
|
|
664
666
|
for (const lg of visibleLegendGroups) {
|
|
665
667
|
const isActive = lg.isStatus ? isStatusExpanded : (activeKey === lg.key);
|
|
666
|
-
const pillW = lg.name
|
|
668
|
+
const pillW = measureLegendText(lg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
667
669
|
const pillH = LEGEND_HEIGHT - (isActive ? LEGEND_CAPSULE_PAD * 2 : 0);
|
|
668
670
|
const groupW = isActive ? lg.width : pillW;
|
|
669
671
|
|
|
@@ -737,7 +739,7 @@ export function renderInitiativeStatus(
|
|
|
737
739
|
|
|
738
740
|
for (const entry of lg.entries) {
|
|
739
741
|
const isHidden = hiddenSet?.has(entry.value) ?? false;
|
|
740
|
-
const estimatedTextW = entry.label
|
|
742
|
+
const estimatedTextW = measureLegendText(entry.label, LEGEND_ENTRY_FONT_SIZE);
|
|
741
743
|
|
|
742
744
|
const entryG = gEl.append('g')
|
|
743
745
|
.attr('data-legend-entry', entry.value)
|
|
@@ -1024,7 +1026,7 @@ export function renderInitiativeStatus(
|
|
|
1024
1026
|
.attr('stroke', 'transparent')
|
|
1025
1027
|
.attr('stroke-width', Math.max(6, Math.round(16 / (edge.parallelCount ?? 1))));
|
|
1026
1028
|
|
|
1027
|
-
edgeG
|
|
1029
|
+
const edgePath = edgeG
|
|
1028
1030
|
.append('path')
|
|
1029
1031
|
.attr('d', pathD)
|
|
1030
1032
|
.attr('fill', 'none')
|
|
@@ -1032,6 +1034,11 @@ export function renderInitiativeStatus(
|
|
|
1032
1034
|
.attr('stroke-width', EDGE_STROKE_WIDTH)
|
|
1033
1035
|
.attr('marker-end', `url(#${markerId})`)
|
|
1034
1036
|
.attr('class', 'is-edge');
|
|
1037
|
+
|
|
1038
|
+
// Dashed stroke for 'todo' edges
|
|
1039
|
+
if (edge.status === 'todo') {
|
|
1040
|
+
edgePath.attr('stroke-dasharray', '6 3');
|
|
1041
|
+
}
|
|
1035
1042
|
}
|
|
1036
1043
|
|
|
1037
1044
|
// Edge label placed on its own path
|
|
@@ -1104,6 +1111,18 @@ export function renderInitiativeStatus(
|
|
|
1104
1111
|
const stroke = nodeStroke(node.status, palette, isDark);
|
|
1105
1112
|
renderNodeShape(nodeG, node.shape, node.width, node.height, fill, stroke);
|
|
1106
1113
|
|
|
1114
|
+
// Apply dashed border for 'todo' status
|
|
1115
|
+
if (node.status === 'todo') {
|
|
1116
|
+
nodeG.selectAll('rect, ellipse, polygon, circle')
|
|
1117
|
+
.each(function () {
|
|
1118
|
+
const el = d3Selection.select(this);
|
|
1119
|
+
// Only dash stroked elements (not fills or transparent hit areas)
|
|
1120
|
+
if (el.attr('stroke') && el.attr('stroke') !== 'none' && el.attr('stroke') !== 'transparent') {
|
|
1121
|
+
el.attr('stroke-dasharray', '6 3');
|
|
1122
|
+
}
|
|
1123
|
+
});
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1107
1126
|
const textColor = contrastText(fill, '#eceff4', '#2e3440');
|
|
1108
1127
|
|
|
1109
1128
|
// Label placement: actors put label below the figure, others center inside
|
|
@@ -6,9 +6,16 @@ import type { DgmoError } from '../diagnostics';
|
|
|
6
6
|
import type { ParticipantType } from '../sequence/parser';
|
|
7
7
|
import type { TagGroup } from '../utils/tag-groups';
|
|
8
8
|
|
|
9
|
-
export type InitiativeStatus = 'done' | '
|
|
9
|
+
export type InitiativeStatus = 'done' | 'doing' | 'blocked' | 'todo' | 'na' | null;
|
|
10
10
|
|
|
11
|
-
export const VALID_STATUSES: readonly string[] = ['done', '
|
|
11
|
+
export const VALID_STATUSES: readonly string[] = ['done', 'doing', 'blocked', 'todo', 'na'];
|
|
12
|
+
|
|
13
|
+
/** Aliases that map to canonical status values during parsing. */
|
|
14
|
+
export const STATUS_ALIASES: Record<string, string> = {
|
|
15
|
+
wip: 'doing',
|
|
16
|
+
paused: 'blocked',
|
|
17
|
+
waiting: 'blocked',
|
|
18
|
+
};
|
|
12
19
|
|
|
13
20
|
export interface ISNode {
|
|
14
21
|
label: string;
|
|
@@ -31,6 +38,7 @@ export interface ISGroup {
|
|
|
31
38
|
label: string;
|
|
32
39
|
nodeLabels: string[];
|
|
33
40
|
lineNumber: number;
|
|
41
|
+
metadata?: Record<string, string>;
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
export interface ParsedInitiativeStatus {
|
package/src/kanban/parser.ts
CHANGED
|
@@ -5,9 +5,9 @@ import { matchTagBlockHeading } from '../utils/tag-groups';
|
|
|
5
5
|
import {
|
|
6
6
|
measureIndent,
|
|
7
7
|
extractColor,
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
parsePipeMetadata,
|
|
9
|
+
parseFirstLine,
|
|
10
|
+
OPTION_NOCOLON_RE,
|
|
11
11
|
} from '../utils/parsing';
|
|
12
12
|
import type {
|
|
13
13
|
ParsedKanban,
|
|
@@ -25,6 +25,14 @@ const COLUMN_RE = /^\[(.+?)\](?:\s*\(([^)]+)\))?\s*(?:\|\s*(.+))?$/;
|
|
|
25
25
|
// Legacy delimiter
|
|
26
26
|
const LEGACY_COLUMN_RE = /^==\s+(.+?)\s*(?:\[wip:\s*(\d+)\])?\s*==$/;
|
|
27
27
|
|
|
28
|
+
/** Known kanban options (key-value). */
|
|
29
|
+
const KNOWN_OPTIONS = new Set([
|
|
30
|
+
'color-off', 'hide',
|
|
31
|
+
]);
|
|
32
|
+
/** Known kanban boolean options (bare keyword = on). */
|
|
33
|
+
const KNOWN_BOOLEANS = new Set<string>([
|
|
34
|
+
]);
|
|
35
|
+
|
|
28
36
|
// ============================================================
|
|
29
37
|
// Parser
|
|
30
38
|
// ============================================================
|
|
@@ -88,12 +96,11 @@ export function parseKanban(
|
|
|
88
96
|
|
|
89
97
|
// --- Header phase ---
|
|
90
98
|
|
|
91
|
-
// chart
|
|
99
|
+
// Extract chart type + title from first line (e.g. `kanban Sprint 12`)
|
|
92
100
|
if (!contentStarted && !currentTagGroup) {
|
|
93
|
-
const
|
|
94
|
-
if (
|
|
95
|
-
|
|
96
|
-
if (chartType !== 'kanban') {
|
|
101
|
+
const firstLine = parseFirstLine(trimmed);
|
|
102
|
+
if (firstLine) {
|
|
103
|
+
if (firstLine.chartType !== 'kanban') {
|
|
97
104
|
const allTypes = [
|
|
98
105
|
'kanban',
|
|
99
106
|
'org',
|
|
@@ -105,21 +112,15 @@ export function parseKanban(
|
|
|
105
112
|
'line',
|
|
106
113
|
'pie',
|
|
107
114
|
];
|
|
108
|
-
let msg = `Expected chart type "kanban", got "${chartType}"`;
|
|
109
|
-
const hint = suggest(chartType, allTypes);
|
|
115
|
+
let msg = `Expected chart type "kanban", got "${firstLine.chartType}"`;
|
|
116
|
+
const hint = suggest(firstLine.chartType, allTypes);
|
|
110
117
|
if (hint) msg += `. ${hint}`;
|
|
111
118
|
return fail(lineNumber, msg);
|
|
112
119
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
// title: value
|
|
118
|
-
if (!contentStarted && !currentTagGroup) {
|
|
119
|
-
const titleMatch = trimmed.match(TITLE_RE);
|
|
120
|
-
if (titleMatch) {
|
|
121
|
-
result.title = titleMatch[1].trim();
|
|
122
|
-
result.titleLineNumber = lineNumber;
|
|
120
|
+
if (firstLine.title) {
|
|
121
|
+
result.title = firstLine.title;
|
|
122
|
+
result.titleLineNumber = lineNumber;
|
|
123
|
+
}
|
|
123
124
|
continue;
|
|
124
125
|
}
|
|
125
126
|
}
|
|
@@ -130,7 +131,8 @@ export function parseKanban(
|
|
|
130
131
|
const tagBlockMatch = matchTagBlockHeading(trimmed);
|
|
131
132
|
if (tagBlockMatch) {
|
|
132
133
|
if (tagBlockMatch.deprecated) {
|
|
133
|
-
|
|
134
|
+
result.diagnostics.push(makeDgmoError(lineNumber, `'## ${tagBlockMatch.name}' is no longer supported — use 'tag: ${tagBlockMatch.name}' instead`));
|
|
135
|
+
continue;
|
|
134
136
|
}
|
|
135
137
|
currentTagGroup = {
|
|
136
138
|
name: tagBlockMatch.name,
|
|
@@ -146,27 +148,30 @@ export function parseKanban(
|
|
|
146
148
|
}
|
|
147
149
|
}
|
|
148
150
|
|
|
149
|
-
// Generic header options (
|
|
151
|
+
// Generic header options (space-separated: `key value` or bare boolean `key`)
|
|
152
|
+
// Only match known option keys to avoid swallowing content lines
|
|
150
153
|
if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
|
|
151
|
-
const optMatch = trimmed.match(
|
|
154
|
+
const optMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
152
155
|
if (optMatch && !COLUMN_RE.test(trimmed)) {
|
|
153
156
|
const key = optMatch[1].trim().toLowerCase();
|
|
154
|
-
if (key
|
|
157
|
+
if (KNOWN_OPTIONS.has(key)) {
|
|
155
158
|
result.options[key] = optMatch[2].trim();
|
|
156
159
|
continue;
|
|
157
160
|
}
|
|
158
161
|
}
|
|
162
|
+
// Bare boolean option (single keyword, no value)
|
|
163
|
+
if (KNOWN_BOOLEANS.has(trimmed.toLowerCase()) && !COLUMN_RE.test(trimmed)) {
|
|
164
|
+
result.options[trimmed.toLowerCase()] = 'on';
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
159
167
|
}
|
|
160
168
|
|
|
161
|
-
// Tag group entries (indented Value(color)
|
|
169
|
+
// Tag group entries (indented Value(color) under tag heading)
|
|
170
|
+
// First entry is implicitly the default.
|
|
162
171
|
if (currentTagGroup && !contentStarted) {
|
|
163
172
|
const indent = measureIndent(line);
|
|
164
173
|
if (indent > 0) {
|
|
165
|
-
const
|
|
166
|
-
const entryText = isDefault
|
|
167
|
-
? trimmed.replace(/\s+default\s*$/, '').trim()
|
|
168
|
-
: trimmed;
|
|
169
|
-
const { label, color } = extractColor(entryText, palette);
|
|
174
|
+
const { label, color } = extractColor(trimmed, palette);
|
|
170
175
|
if (!color) {
|
|
171
176
|
warn(
|
|
172
177
|
lineNumber,
|
|
@@ -174,7 +179,8 @@ export function parseKanban(
|
|
|
174
179
|
);
|
|
175
180
|
continue;
|
|
176
181
|
}
|
|
177
|
-
|
|
182
|
+
// First entry is the default
|
|
183
|
+
if (currentTagGroup.entries.length === 0) {
|
|
178
184
|
currentTagGroup.defaultValue = label;
|
|
179
185
|
}
|
|
180
186
|
currentTagGroup.entries.push({
|
|
@@ -196,7 +202,7 @@ export function parseKanban(
|
|
|
196
202
|
if (LEGACY_COLUMN_RE.test(trimmed)) {
|
|
197
203
|
const legacyMatch = trimmed.match(LEGACY_COLUMN_RE)!;
|
|
198
204
|
const name = legacyMatch[1].replace(/\s*\(.*\)\s*$/, '').trim();
|
|
199
|
-
|
|
205
|
+
result.diagnostics.push(makeDgmoError(lineNumber, `'== ${name} ==' is no longer supported. Use '[${name}]' instead`));
|
|
200
206
|
continue;
|
|
201
207
|
}
|
|
202
208
|
|
|
@@ -221,16 +227,22 @@ export function parseKanban(
|
|
|
221
227
|
columnCounter++;
|
|
222
228
|
const colName = columnMatch[1].trim();
|
|
223
229
|
const colColor = columnMatch[2]
|
|
224
|
-
? resolveColor(columnMatch[2].trim(), palette)
|
|
230
|
+
? resolveColor(columnMatch[2].trim(), palette) ?? undefined
|
|
225
231
|
: undefined;
|
|
226
232
|
|
|
227
|
-
// Parse
|
|
233
|
+
// Parse pipe metadata (e.g., "| wip: 3, t: Sprint1")
|
|
228
234
|
let wipLimit: number | undefined;
|
|
235
|
+
const columnMetadata: Record<string, string> = {};
|
|
229
236
|
const pipeStr = columnMatch[3];
|
|
230
237
|
if (pipeStr) {
|
|
231
|
-
const
|
|
232
|
-
|
|
233
|
-
|
|
238
|
+
const pipeSegments = ['', pipeStr];
|
|
239
|
+
Object.assign(columnMetadata, parsePipeMetadata(pipeSegments, aliasMap));
|
|
240
|
+
// Extract wip from metadata
|
|
241
|
+
if (columnMetadata.wip) {
|
|
242
|
+
const wipVal = parseInt(columnMetadata.wip, 10);
|
|
243
|
+
if (!isNaN(wipVal)) {
|
|
244
|
+
wipLimit = wipVal;
|
|
245
|
+
}
|
|
234
246
|
}
|
|
235
247
|
}
|
|
236
248
|
|
|
@@ -241,6 +253,7 @@ export function parseKanban(
|
|
|
241
253
|
color: colColor,
|
|
242
254
|
cards: [],
|
|
243
255
|
lineNumber,
|
|
256
|
+
metadata: columnMetadata,
|
|
244
257
|
};
|
|
245
258
|
result.columns.push(currentColumn);
|
|
246
259
|
continue;
|
|
@@ -274,6 +287,16 @@ export function parseKanban(
|
|
|
274
287
|
aliasMap,
|
|
275
288
|
palette
|
|
276
289
|
);
|
|
290
|
+
// Cascade column metadata to card tags (card overrides on conflict)
|
|
291
|
+
// Exclude 'wip' from cascading — it's a column-level property, not a card tag
|
|
292
|
+
if (currentColumn.metadata) {
|
|
293
|
+
for (const [key, value] of Object.entries(currentColumn.metadata)) {
|
|
294
|
+
if (key === 'wip') continue;
|
|
295
|
+
if (!(key in card.tags)) {
|
|
296
|
+
card.tags[key] = value;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
277
300
|
cardBaseIndent = indent;
|
|
278
301
|
currentCard = card;
|
|
279
302
|
currentColumn.cards.push(card);
|