@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.
@@ -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
+ }