@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/dist/cli.cjs +99 -97
- package/dist/index.cjs +949 -262
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +70 -1
- package/dist/index.d.ts +70 -1
- package/dist/index.js +942 -261
- 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/index.ts +11 -0
- package/src/kanban/mutations.ts +183 -0
- package/src/kanban/parser.ts +389 -0
- package/src/kanban/renderer.ts +564 -0
- package/src/kanban/types.ts +45 -0
- package/src/org/layout.ts +61 -55
- package/src/org/parser.ts +15 -1
- package/src/org/renderer.ts +79 -160
- package/src/sequence/renderer.ts +7 -5
package/package.json
CHANGED
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
|
|
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');
|
package/src/dgmo-router.ts
CHANGED
|
@@ -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
|
+
}
|