@diagrammo/dgmo 0.2.20 → 0.2.22
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.cjs +101 -99
- package/dist/index.cjs +983 -282
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +72 -1
- package/dist/index.d.ts +72 -1
- package/dist/index.js +976 -281
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/d3.ts +44 -1
- package/src/dgmo-router.ts +8 -1
- package/src/echarts.ts +7 -8
- package/src/index.ts +11 -0
- package/src/kanban/mutations.ts +183 -0
- package/src/kanban/parser.ts +389 -0
- package/src/kanban/renderer.ts +566 -0
- package/src/kanban/types.ts +45 -0
- package/src/org/layout.ts +92 -66
- package/src/org/parser.ts +15 -1
- package/src/org/renderer.ts +94 -167
- package/src/sequence/renderer.ts +7 -5
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
import { resolveColor } from '../colors';
|
|
2
|
+
import type { PaletteColors } from '../palettes';
|
|
3
|
+
import type { DgmoError } from '../diagnostics';
|
|
4
|
+
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
5
|
+
import type {
|
|
6
|
+
ParsedKanban,
|
|
7
|
+
KanbanColumn,
|
|
8
|
+
KanbanCard,
|
|
9
|
+
KanbanTagGroup,
|
|
10
|
+
KanbanTagEntry,
|
|
11
|
+
} from './types';
|
|
12
|
+
|
|
13
|
+
// ============================================================
|
|
14
|
+
// Regex patterns
|
|
15
|
+
// ============================================================
|
|
16
|
+
|
|
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
|
+
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
|
+
|
|
52
|
+
// ============================================================
|
|
53
|
+
// Parser
|
|
54
|
+
// ============================================================
|
|
55
|
+
|
|
56
|
+
export function parseKanban(
|
|
57
|
+
content: string,
|
|
58
|
+
palette?: PaletteColors
|
|
59
|
+
): ParsedKanban {
|
|
60
|
+
const result: ParsedKanban = {
|
|
61
|
+
type: 'kanban',
|
|
62
|
+
columns: [],
|
|
63
|
+
tagGroups: [],
|
|
64
|
+
options: {},
|
|
65
|
+
diagnostics: [],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const fail = (line: number, message: string): ParsedKanban => {
|
|
69
|
+
const diag = makeDgmoError(line, message);
|
|
70
|
+
result.diagnostics.push(diag);
|
|
71
|
+
result.error = formatDgmoError(diag);
|
|
72
|
+
return result;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const warn = (line: number, message: string): void => {
|
|
76
|
+
result.diagnostics.push(makeDgmoError(line, message, 'warning'));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
if (!content || !content.trim()) {
|
|
80
|
+
return fail(0, 'No content provided');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const lines = content.split('\n');
|
|
84
|
+
let contentStarted = false;
|
|
85
|
+
let currentTagGroup: KanbanTagGroup | null = null;
|
|
86
|
+
let currentColumn: KanbanColumn | null = null;
|
|
87
|
+
let currentCard: KanbanCard | null = null;
|
|
88
|
+
let columnCounter = 0;
|
|
89
|
+
let cardCounter = 0;
|
|
90
|
+
|
|
91
|
+
// Alias map: alias (lowercased) → group name (lowercased)
|
|
92
|
+
const aliasMap = new Map<string, string>();
|
|
93
|
+
|
|
94
|
+
// Build a lookup for tag group entries (for validation)
|
|
95
|
+
const tagValueSets = new Map<string, Set<string>>();
|
|
96
|
+
|
|
97
|
+
for (let i = 0; i < lines.length; i++) {
|
|
98
|
+
const line = lines[i];
|
|
99
|
+
const lineNumber = i + 1;
|
|
100
|
+
const trimmed = line.trim();
|
|
101
|
+
|
|
102
|
+
// Skip empty lines
|
|
103
|
+
if (!trimmed) {
|
|
104
|
+
if (currentTagGroup) currentTagGroup = null;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// Skip comments
|
|
109
|
+
if (trimmed.startsWith('//')) continue;
|
|
110
|
+
|
|
111
|
+
// --- Header phase ---
|
|
112
|
+
|
|
113
|
+
// chart: type
|
|
114
|
+
if (!contentStarted && !currentTagGroup) {
|
|
115
|
+
const chartMatch = trimmed.match(CHART_TYPE_RE);
|
|
116
|
+
if (chartMatch) {
|
|
117
|
+
const chartType = chartMatch[1].trim().toLowerCase();
|
|
118
|
+
if (chartType !== 'kanban') {
|
|
119
|
+
const allTypes = [
|
|
120
|
+
'kanban',
|
|
121
|
+
'org',
|
|
122
|
+
'class',
|
|
123
|
+
'flowchart',
|
|
124
|
+
'sequence',
|
|
125
|
+
'er',
|
|
126
|
+
'bar',
|
|
127
|
+
'line',
|
|
128
|
+
'pie',
|
|
129
|
+
];
|
|
130
|
+
let msg = `Expected chart type "kanban", got "${chartType}"`;
|
|
131
|
+
const hint = suggest(chartType, allTypes);
|
|
132
|
+
if (hint) msg += `. ${hint}`;
|
|
133
|
+
return fail(lineNumber, msg);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// title: value
|
|
140
|
+
if (!contentStarted && !currentTagGroup) {
|
|
141
|
+
const titleMatch = trimmed.match(TITLE_RE);
|
|
142
|
+
if (titleMatch) {
|
|
143
|
+
result.title = titleMatch[1].trim();
|
|
144
|
+
result.titleLineNumber = lineNumber;
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Generic header options (key: value before content/tag groups)
|
|
150
|
+
if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
|
|
151
|
+
const optMatch = trimmed.match(OPTION_RE);
|
|
152
|
+
if (optMatch && !trimmed.startsWith('##') && !COLUMN_RE.test(trimmed)) {
|
|
153
|
+
const key = optMatch[1].trim().toLowerCase();
|
|
154
|
+
if (key !== 'chart' && key !== 'title') {
|
|
155
|
+
result.options[key] = optMatch[2].trim();
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
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
|
+
// Tag group entries (indented Value(color) [default] under ## heading)
|
|
180
|
+
if (currentTagGroup && !contentStarted) {
|
|
181
|
+
const indent = measureIndent(line);
|
|
182
|
+
if (indent > 0) {
|
|
183
|
+
const isDefault = /\bdefault\s*$/.test(trimmed);
|
|
184
|
+
const entryText = isDefault
|
|
185
|
+
? trimmed.replace(/\s+default\s*$/, '').trim()
|
|
186
|
+
: trimmed;
|
|
187
|
+
const { label, color } = extractColor(entryText, palette);
|
|
188
|
+
if (!color) {
|
|
189
|
+
warn(
|
|
190
|
+
lineNumber,
|
|
191
|
+
`Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
|
|
192
|
+
);
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (isDefault) {
|
|
196
|
+
currentTagGroup.defaultValue = label;
|
|
197
|
+
}
|
|
198
|
+
currentTagGroup.entries.push({
|
|
199
|
+
value: label,
|
|
200
|
+
color,
|
|
201
|
+
lineNumber,
|
|
202
|
+
});
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
// Non-indented line after tag group — fall through
|
|
206
|
+
currentTagGroup = null;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// --- Content phase ---
|
|
210
|
+
|
|
211
|
+
// Column delimiter: == Name == or == Name [wip: N] ==
|
|
212
|
+
const columnMatch = trimmed.match(COLUMN_RE);
|
|
213
|
+
if (columnMatch) {
|
|
214
|
+
contentStarted = true;
|
|
215
|
+
currentTagGroup = null;
|
|
216
|
+
|
|
217
|
+
// Finalize previous card's endLineNumber
|
|
218
|
+
if (currentCard) {
|
|
219
|
+
currentCard.endLineNumber = lineNumber - 1;
|
|
220
|
+
// Walk back over trailing empty lines
|
|
221
|
+
while (
|
|
222
|
+
currentCard.endLineNumber > currentCard.lineNumber &&
|
|
223
|
+
!lines[currentCard.endLineNumber - 1].trim()
|
|
224
|
+
) {
|
|
225
|
+
currentCard.endLineNumber--;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
currentCard = null;
|
|
229
|
+
|
|
230
|
+
columnCounter++;
|
|
231
|
+
const rawColName = columnMatch[1].trim();
|
|
232
|
+
const wipStr = columnMatch[2];
|
|
233
|
+
const { label: colName, color: colColor } = extractColor(
|
|
234
|
+
rawColName,
|
|
235
|
+
palette
|
|
236
|
+
);
|
|
237
|
+
currentColumn = {
|
|
238
|
+
id: `col-${columnCounter}`,
|
|
239
|
+
name: colName,
|
|
240
|
+
wipLimit: wipStr ? parseInt(wipStr, 10) : undefined,
|
|
241
|
+
color: colColor,
|
|
242
|
+
cards: [],
|
|
243
|
+
lineNumber,
|
|
244
|
+
};
|
|
245
|
+
result.columns.push(currentColumn);
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// If we hit a non-column, non-header line and haven't started content yet,
|
|
250
|
+
// it's invalid (cards without a column)
|
|
251
|
+
if (!contentStarted) {
|
|
252
|
+
// Could be the first column, or an error
|
|
253
|
+
// For permissiveness, skip these lines silently (they might be blank
|
|
254
|
+
// lines or just whitespace between header and columns)
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!currentColumn) {
|
|
259
|
+
// Content line before any column — skip with warning
|
|
260
|
+
warn(lineNumber, 'Card line found before any column');
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const indent = measureIndent(line);
|
|
265
|
+
|
|
266
|
+
// Detail lines: indented under a card
|
|
267
|
+
if (indent > 0 && currentCard) {
|
|
268
|
+
currentCard.details.push(trimmed);
|
|
269
|
+
currentCard.endLineNumber = lineNumber;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// New card line (non-indented within a column)
|
|
274
|
+
// Finalize previous card
|
|
275
|
+
if (currentCard) {
|
|
276
|
+
// endLineNumber already tracked
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
cardCounter++;
|
|
280
|
+
const card = parseCardLine(
|
|
281
|
+
trimmed,
|
|
282
|
+
lineNumber,
|
|
283
|
+
cardCounter,
|
|
284
|
+
aliasMap,
|
|
285
|
+
palette
|
|
286
|
+
);
|
|
287
|
+
currentCard = card;
|
|
288
|
+
currentColumn.cards.push(card);
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Finalize last card's endLineNumber
|
|
292
|
+
if (currentCard) {
|
|
293
|
+
// Already tracked via detail lines or card line itself
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// Build tag value sets for validation
|
|
297
|
+
for (const group of result.tagGroups) {
|
|
298
|
+
const values = new Set(group.entries.map((e) => e.value.toLowerCase()));
|
|
299
|
+
tagValueSets.set(group.name.toLowerCase(), values);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Validate WIP limits
|
|
303
|
+
for (const col of result.columns) {
|
|
304
|
+
if (col.wipLimit != null && col.cards.length > col.wipLimit) {
|
|
305
|
+
warn(
|
|
306
|
+
col.lineNumber,
|
|
307
|
+
`Column "${col.name}" has ${col.cards.length} cards but WIP limit is ${col.wipLimit}`
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// Validate tag values on cards
|
|
313
|
+
for (const col of result.columns) {
|
|
314
|
+
for (const card of col.cards) {
|
|
315
|
+
for (const [tagKey, tagValue] of Object.entries(card.tags)) {
|
|
316
|
+
const groupKey = aliasMap.get(tagKey.toLowerCase()) ?? tagKey.toLowerCase();
|
|
317
|
+
const validValues = tagValueSets.get(groupKey);
|
|
318
|
+
if (validValues && !validValues.has(tagValue.toLowerCase())) {
|
|
319
|
+
const entries = result.tagGroups
|
|
320
|
+
.find((g) => g.name.toLowerCase() === groupKey)
|
|
321
|
+
?.entries.map((e) => e.value);
|
|
322
|
+
let msg = `Unknown tag value "${tagValue}" for group "${groupKey}"`;
|
|
323
|
+
if (entries) {
|
|
324
|
+
const hint = suggest(tagValue, entries);
|
|
325
|
+
if (hint) msg += `. ${hint}`;
|
|
326
|
+
}
|
|
327
|
+
warn(card.lineNumber, msg);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
if (result.columns.length === 0 && !result.error) {
|
|
334
|
+
return fail(1, 'No columns found. Use == Column Name == to define columns');
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
return result;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
// ============================================================
|
|
341
|
+
// Card line parser
|
|
342
|
+
// ============================================================
|
|
343
|
+
|
|
344
|
+
function parseCardLine(
|
|
345
|
+
trimmed: string,
|
|
346
|
+
lineNumber: number,
|
|
347
|
+
counter: number,
|
|
348
|
+
aliasMap: Map<string, string>,
|
|
349
|
+
palette?: PaletteColors
|
|
350
|
+
): KanbanCard {
|
|
351
|
+
// Split on first pipe: Title | tag: value, tag: value
|
|
352
|
+
const pipeIdx = trimmed.indexOf('|');
|
|
353
|
+
let rawTitle: string;
|
|
354
|
+
let tagsStr: string | null = null;
|
|
355
|
+
|
|
356
|
+
if (pipeIdx >= 0) {
|
|
357
|
+
rawTitle = trimmed.substring(0, pipeIdx).trim();
|
|
358
|
+
tagsStr = trimmed.substring(pipeIdx + 1).trim();
|
|
359
|
+
} else {
|
|
360
|
+
rawTitle = trimmed;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Extract optional color suffix from title
|
|
364
|
+
const { label: title, color } = extractColor(rawTitle, palette);
|
|
365
|
+
|
|
366
|
+
// Parse tags: comma-separated key: value pairs
|
|
367
|
+
const tags: Record<string, string> = {};
|
|
368
|
+
if (tagsStr) {
|
|
369
|
+
for (const part of tagsStr.split(',')) {
|
|
370
|
+
const colonIdx = part.indexOf(':');
|
|
371
|
+
if (colonIdx > 0) {
|
|
372
|
+
const rawKey = part.substring(0, colonIdx).trim().toLowerCase();
|
|
373
|
+
const key = aliasMap.get(rawKey) ?? rawKey;
|
|
374
|
+
const value = part.substring(colonIdx + 1).trim();
|
|
375
|
+
tags[key] = value;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return {
|
|
381
|
+
id: `card-${counter}`,
|
|
382
|
+
title,
|
|
383
|
+
tags,
|
|
384
|
+
details: [],
|
|
385
|
+
lineNumber,
|
|
386
|
+
endLineNumber: lineNumber,
|
|
387
|
+
color,
|
|
388
|
+
};
|
|
389
|
+
}
|