@diagrammo/dgmo 0.2.20 → 0.2.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@diagrammo/dgmo",
3
- "version": "0.2.20",
3
+ "version": "0.2.21",
4
4
  "description": "DGMO diagram markup language — parser, renderer, and color system",
5
5
  "license": "MIT",
6
6
  "type": "module",
package/src/d3.ts CHANGED
@@ -5281,7 +5281,7 @@ export async function renderD3ForExport(
5281
5281
  palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5282
5282
 
5283
5283
  const orgParsed = parseOrg(content, effectivePalette);
5284
- if (orgParsed.error || orgParsed.roots.length === 0) return '';
5284
+ if (orgParsed.error) return '';
5285
5285
 
5286
5286
  // Apply interactive collapse state when provided
5287
5287
  const collapsedNodes = orgExportState?.collapsedNodes;
@@ -5349,6 +5349,49 @@ export async function renderD3ForExport(
5349
5349
  }
5350
5350
  }
5351
5351
 
5352
+ if (detectedType === 'kanban') {
5353
+ const { parseKanban } = await import('./kanban/parser');
5354
+ const { renderKanban } = await import('./kanban/renderer');
5355
+
5356
+ const isDark = theme === 'dark';
5357
+ const { getPalette } = await import('./palettes');
5358
+ const effectivePalette =
5359
+ palette ?? (isDark ? getPalette('nord').dark : getPalette('nord').light);
5360
+
5361
+ const kanbanParsed = parseKanban(content, effectivePalette);
5362
+ if (kanbanParsed.error || kanbanParsed.columns.length === 0) return '';
5363
+
5364
+ const container = document.createElement('div');
5365
+ container.style.position = 'absolute';
5366
+ container.style.left = '-9999px';
5367
+ document.body.appendChild(container);
5368
+
5369
+ try {
5370
+ renderKanban(container, kanbanParsed, effectivePalette, isDark);
5371
+
5372
+ const svgEl = container.querySelector('svg');
5373
+ if (!svgEl) return '';
5374
+
5375
+ if (theme === 'transparent') {
5376
+ svgEl.style.background = 'none';
5377
+ } else if (!svgEl.style.background) {
5378
+ svgEl.style.background = effectivePalette.bg;
5379
+ }
5380
+
5381
+ svgEl.setAttribute('xmlns', 'http://www.w3.org/2000/svg');
5382
+ svgEl.style.fontFamily = FONT_FAMILY;
5383
+
5384
+ const svgHtml = svgEl.outerHTML;
5385
+ if (options?.branding !== false) {
5386
+ const brandColor = theme === 'transparent' ? '#888' : effectivePalette.textMuted;
5387
+ return injectBranding(svgHtml, brandColor);
5388
+ }
5389
+ return svgHtml;
5390
+ } finally {
5391
+ document.body.removeChild(container);
5392
+ }
5393
+ }
5394
+
5352
5395
  if (detectedType === 'class') {
5353
5396
  const { parseClassDiagram } = await import('./class/parser');
5354
5397
  const { layoutClassDiagram } = await import('./class/layout');
@@ -9,7 +9,8 @@ import { looksLikeERDiagram, parseERDiagram } from './er/parser';
9
9
  import { parseChart } from './chart';
10
10
  import { parseEChart } from './echarts';
11
11
  import { parseD3 } from './d3';
12
- import { parseOrg } from './org/parser';
12
+ import { parseOrg, looksLikeOrg } from './org/parser';
13
+ import { parseKanban } from './kanban/parser';
13
14
  import type { DgmoError } from './diagnostics';
14
15
 
15
16
  /**
@@ -56,6 +57,7 @@ export const DGMO_CHART_TYPE_MAP: Record<string, DgmoFramework> = {
56
57
  class: 'd3',
57
58
  er: 'd3',
58
59
  org: 'd3',
60
+ kanban: 'd3',
59
61
  };
60
62
 
61
63
  /**
@@ -87,6 +89,7 @@ export function parseDgmoChartType(content: string): string | null {
87
89
  if (looksLikeFlowchart(content)) return 'flowchart';
88
90
  if (looksLikeClassDiagram(content)) return 'class';
89
91
  if (looksLikeERDiagram(content)) return 'er';
92
+ if (looksLikeOrg(content)) return 'org';
90
93
 
91
94
  return null;
92
95
  }
@@ -135,6 +138,10 @@ export function parseDgmo(content: string): { diagnostics: DgmoError[] } {
135
138
  const parsed = parseOrg(content);
136
139
  return { diagnostics: parsed.diagnostics };
137
140
  }
141
+ if (chartType === 'kanban') {
142
+ const parsed = parseKanban(content);
143
+ return { diagnostics: parsed.diagnostics };
144
+ }
138
145
  if (STANDARD_CHART_TYPES.has(chartType)) {
139
146
  const parsed = parseChart(content);
140
147
  return { diagnostics: parsed.diagnostics };
package/src/index.ts CHANGED
@@ -144,6 +144,17 @@ export type {
144
144
 
145
145
  export { renderOrg, renderOrgForExport } from './org/renderer';
146
146
 
147
+ export { parseKanban } from './kanban/parser';
148
+ export type {
149
+ ParsedKanban,
150
+ KanbanColumn,
151
+ KanbanCard,
152
+ KanbanTagGroup,
153
+ KanbanTagEntry,
154
+ } from './kanban/types';
155
+ export { computeCardMove, computeCardArchive, isArchiveColumn } from './kanban/mutations';
156
+ export { renderKanban, renderKanbanForExport } from './kanban/renderer';
157
+
147
158
  export { collapseOrgTree } from './org/collapse';
148
159
  export type { CollapsedOrgResult } from './org/collapse';
149
160
 
@@ -0,0 +1,183 @@
1
+ import type { ParsedKanban, KanbanCard, KanbanColumn } from './types';
2
+
3
+ const ARCHIVE_COLUMN_NAME = 'archive';
4
+
5
+ // ============================================================
6
+ // computeCardMove — pure function for source text mutation
7
+ // ============================================================
8
+
9
+ /**
10
+ * Compute new file content after moving a card to a different position.
11
+ *
12
+ * @param content - original file content string
13
+ * @param parsed - parsed kanban board
14
+ * @param cardId - id of the card to move
15
+ * @param targetColumnId - id of the destination column
16
+ * @param targetIndex - position within target column (0 = first card)
17
+ * @returns new content string, or null if move is invalid
18
+ */
19
+ export function computeCardMove(
20
+ content: string,
21
+ parsed: ParsedKanban,
22
+ cardId: string,
23
+ targetColumnId: string,
24
+ targetIndex: number
25
+ ): string | null {
26
+ // Find source card and column
27
+ let sourceCard: KanbanCard | null = null;
28
+ let sourceColumn: KanbanColumn | null = null;
29
+
30
+ for (const col of parsed.columns) {
31
+ for (const card of col.cards) {
32
+ if (card.id === cardId) {
33
+ sourceCard = card;
34
+ sourceColumn = col;
35
+ break;
36
+ }
37
+ }
38
+ if (sourceCard) break;
39
+ }
40
+
41
+ if (!sourceCard || !sourceColumn) return null;
42
+
43
+ const targetColumn = parsed.columns.find((c) => c.id === targetColumnId);
44
+ if (!targetColumn) return null;
45
+
46
+ const lines = content.split('\n');
47
+
48
+ // Extract the card's lines (0-based indices)
49
+ const startIdx = sourceCard.lineNumber - 1;
50
+ const endIdx = sourceCard.endLineNumber - 1;
51
+ const cardLines = lines.slice(startIdx, endIdx + 1);
52
+
53
+ // Remove the card lines from content
54
+ const withoutCard = [
55
+ ...lines.slice(0, startIdx),
56
+ ...lines.slice(endIdx + 1),
57
+ ];
58
+
59
+ // Compute insertion point (0-based index in withoutCard)
60
+ let insertIdx: number;
61
+
62
+ // Adjust target column and card line numbers after removal
63
+ // Lines after the removed range shift up by the number of removed lines
64
+ const removedCount = endIdx - startIdx + 1;
65
+ const adjustLine = (ln: number): number => {
66
+ // ln is 1-based
67
+ if (ln > endIdx + 1) return ln - removedCount;
68
+ return ln;
69
+ };
70
+
71
+ if (targetIndex === 0) {
72
+ // Insert right after column header line
73
+ const adjColLine = adjustLine(targetColumn.lineNumber);
74
+ insertIdx = adjColLine; // 0-based: insert after the column header line
75
+ } else {
76
+ // Insert after the preceding card's last line
77
+ // Get the cards in the target column, excluding the moved card
78
+ const targetCards = targetColumn.cards.filter((c) => c.id !== cardId);
79
+ const clampedIdx = Math.min(targetIndex, targetCards.length);
80
+ const precedingCard = targetCards[clampedIdx - 1];
81
+ if (!precedingCard) {
82
+ // Fallback: after column header
83
+ const adjColLine = adjustLine(targetColumn.lineNumber);
84
+ insertIdx = adjColLine;
85
+ } else {
86
+ const adjEndLine = adjustLine(precedingCard.endLineNumber);
87
+ insertIdx = adjEndLine; // 0-based: insert after this line
88
+ }
89
+ }
90
+
91
+ // Splice the card lines into the new position
92
+ const result = [
93
+ ...withoutCard.slice(0, insertIdx),
94
+ ...cardLines,
95
+ ...withoutCard.slice(insertIdx),
96
+ ];
97
+
98
+ return result.join('\n');
99
+ }
100
+
101
+ // ============================================================
102
+ // computeCardArchive — move card to an Archive section
103
+ // ============================================================
104
+
105
+ /**
106
+ * Move a card to the Archive section at the end of the file.
107
+ * Creates `== Archive ==` if it doesn't exist.
108
+ *
109
+ * @returns new content string, or null if the card is not found
110
+ */
111
+ export function computeCardArchive(
112
+ content: string,
113
+ parsed: ParsedKanban,
114
+ cardId: string
115
+ ): string | null {
116
+ let sourceCard: KanbanCard | null = null;
117
+ for (const col of parsed.columns) {
118
+ for (const card of col.cards) {
119
+ if (card.id === cardId) {
120
+ sourceCard = card;
121
+ break;
122
+ }
123
+ }
124
+ if (sourceCard) break;
125
+ }
126
+ if (!sourceCard) return null;
127
+
128
+ const lines = content.split('\n');
129
+
130
+ // Extract card lines
131
+ const startIdx = sourceCard.lineNumber - 1;
132
+ const endIdx = sourceCard.endLineNumber - 1;
133
+ const cardLines = lines.slice(startIdx, endIdx + 1);
134
+
135
+ // Remove card from its current position
136
+ const withoutCard = [
137
+ ...lines.slice(0, startIdx),
138
+ ...lines.slice(endIdx + 1),
139
+ ];
140
+
141
+ // Check if an Archive column already exists
142
+ const archiveCol = parsed.columns.find(
143
+ (c) => c.name.toLowerCase() === ARCHIVE_COLUMN_NAME
144
+ );
145
+
146
+ if (archiveCol) {
147
+ // Append to existing archive column
148
+ // Find the last line of the archive column (after removal adjustment)
149
+ const removedCount = endIdx - startIdx + 1;
150
+ let archiveEndLine = archiveCol.lineNumber;
151
+ if (archiveCol.cards.length > 0) {
152
+ const lastCard = archiveCol.cards[archiveCol.cards.length - 1];
153
+ archiveEndLine = lastCard.endLineNumber;
154
+ }
155
+ // Adjust for removed lines
156
+ if (archiveEndLine > endIdx + 1) {
157
+ archiveEndLine -= removedCount;
158
+ }
159
+ // Insert after the archive end (0-based in withoutCard)
160
+ const insertIdx = archiveEndLine; // archiveEndLine is 1-based, so this is after that line
161
+ return [
162
+ ...withoutCard.slice(0, insertIdx),
163
+ ...cardLines,
164
+ ...withoutCard.slice(insertIdx),
165
+ ].join('\n');
166
+ } else {
167
+ // Create archive section at end of file
168
+ // Ensure trailing newline before the new section
169
+ const trimmedEnd = withoutCard.length > 0 && withoutCard[withoutCard.length - 1].trim() === ''
170
+ ? withoutCard
171
+ : [...withoutCard, ''];
172
+ return [
173
+ ...trimmedEnd,
174
+ '== Archive ==',
175
+ ...cardLines,
176
+ ].join('\n');
177
+ }
178
+ }
179
+
180
+ /** Check if a column name is the archive column (case-insensitive). */
181
+ export function isArchiveColumn(name: string): boolean {
182
+ return name.toLowerCase() === ARCHIVE_COLUMN_NAME;
183
+ }
@@ -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
+ }