@diagrammo/dgmo 0.4.3 → 0.5.0

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/src/er/parser.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { resolveColor } from '../colors';
2
2
  import type { PaletteColors } from '../palettes';
3
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
- import { measureIndent } from '../utils/parsing';
4
+ import { measureIndent, extractColor, parsePipeMetadata } from '../utils/parsing';
5
+ import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
6
+ import type { TagGroup } from '../utils/tag-groups';
5
7
  import type {
6
8
  ParsedERDiagram,
7
9
  ERTable,
@@ -22,9 +24,9 @@ function tableId(name: string): string {
22
24
  // Regex patterns
23
25
  // ============================================================
24
26
 
25
- // Table declaration: table_name or table_name (color)
27
+ // Table declaration: table_name or table_name (color) or table_name | key: value
26
28
  // Allows lowercase, uppercase, underscores, digits — must start with letter or underscore
27
- const TABLE_DECL_RE = /^([a-zA-Z_]\w*)(?:\s+\(([^)]+)\))?\s*$/;
29
+ const TABLE_DECL_RE = /^([a-zA-Z_]\w*)(?:\s*\(([^)]+)\))?(?:\s*\|(.+))?$/;
28
30
 
29
31
  // Column: name: type [constraints] or name [constraints] or name: type or name
30
32
  const COLUMN_RE = /^(\w+)(?:\s*:\s*(\w[\w()]*(?:\s*\[\])?))?(?:\s+\[([^\]]+)\])?\s*$/;
@@ -143,6 +145,7 @@ export function parseERDiagram(
143
145
  options: {},
144
146
  tables: [],
145
147
  relationships: [],
148
+ tagGroups: [],
146
149
  diagnostics: [],
147
150
  error: null,
148
151
  };
@@ -163,6 +166,8 @@ export function parseERDiagram(
163
166
  const tableMap = new Map<string, ERTable>();
164
167
  let currentTable: ERTable | null = null;
165
168
  let contentStarted = false;
169
+ let currentTagGroup: TagGroup | null = null;
170
+ const aliasMap = new Map<string, string>();
166
171
 
167
172
  function getOrCreateTable(name: string, lineNumber: number): ERTable {
168
173
  const id = tableId(name);
@@ -173,6 +178,7 @@ export function parseERDiagram(
173
178
  id,
174
179
  name,
175
180
  columns: [],
181
+ metadata: {},
176
182
  lineNumber,
177
183
  };
178
184
  tableMap.set(id, table);
@@ -195,6 +201,52 @@ export function parseERDiagram(
195
201
  // Skip comments
196
202
  if (trimmed.startsWith('//')) continue;
197
203
 
204
+ // Tag group heading — `tag: Name` or deprecated `## Name`
205
+ if (!contentStarted && indent === 0) {
206
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
207
+ if (tagBlockMatch) {
208
+ if (tagBlockMatch.deprecated) {
209
+ result.diagnostics.push(makeDgmoError(lineNumber,
210
+ `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`, 'warning'));
211
+ }
212
+ currentTagGroup = {
213
+ name: tagBlockMatch.name,
214
+ alias: tagBlockMatch.alias,
215
+ entries: [],
216
+ lineNumber,
217
+ };
218
+ if (tagBlockMatch.alias) {
219
+ aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
220
+ }
221
+ result.tagGroups.push(currentTagGroup);
222
+ continue;
223
+ }
224
+ }
225
+
226
+ // Tag group entries (indented under tag: heading)
227
+ if (currentTagGroup && !contentStarted && indent > 0) {
228
+ const isDefault = /\bdefault\s*$/.test(trimmed);
229
+ const entryText = isDefault
230
+ ? trimmed.replace(/\s+default\s*$/, '').trim()
231
+ : trimmed;
232
+ const { label, color } = extractColor(entryText, palette);
233
+ if (!color) {
234
+ result.diagnostics.push(makeDgmoError(lineNumber,
235
+ `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`, 'warning'));
236
+ continue;
237
+ }
238
+ if (isDefault) {
239
+ currentTagGroup.defaultValue = label;
240
+ }
241
+ currentTagGroup.entries.push({ value: label, color, lineNumber });
242
+ continue;
243
+ }
244
+
245
+ // End tag group on non-indented line
246
+ if (currentTagGroup && indent === 0) {
247
+ currentTagGroup = null;
248
+ }
249
+
198
250
  // Metadata directives (before content)
199
251
  if (!contentStarted && indent === 0 && /^[a-z][a-z0-9-]*\s*:/i.test(trimmed)) {
200
252
  const colonIdx = trimmed.indexOf(':');
@@ -296,6 +348,14 @@ export function parseERDiagram(
296
348
  if (color) table.color = color;
297
349
  table.lineNumber = lineNumber;
298
350
 
351
+ // Parse pipe metadata: TableName(color) | key: value, key2: value2
352
+ const pipeStr = tableDecl[3]?.trim();
353
+ if (pipeStr) {
354
+ // parsePipeMetadata skips index 0 (name segment), so prepend empty
355
+ const meta = parsePipeMetadata(['', pipeStr], aliasMap);
356
+ Object.assign(table.metadata, meta);
357
+ }
358
+
299
359
  currentTable = table;
300
360
  continue;
301
361
  }
@@ -308,6 +368,31 @@ export function parseERDiagram(
308
368
  result.error = formatDgmoError(diag);
309
369
  }
310
370
 
371
+ // Validate tag values on tables
372
+ if (result.tagGroups.length > 0) {
373
+ const tagEntities = result.tables.map((t) => ({
374
+ metadata: t.metadata,
375
+ lineNumber: t.lineNumber,
376
+ }));
377
+ validateTagValues(
378
+ tagEntities,
379
+ result.tagGroups,
380
+ (line, msg) => result.diagnostics.push(makeDgmoError(line, msg, 'warning')),
381
+ suggest,
382
+ );
383
+
384
+ // Inject defaults for tables without explicit tags
385
+ for (const group of result.tagGroups) {
386
+ if (!group.defaultValue) continue;
387
+ const key = group.name.toLowerCase();
388
+ for (const table of result.tables) {
389
+ if (!table.metadata[key]) {
390
+ table.metadata[key] = group.defaultValue;
391
+ }
392
+ }
393
+ }
394
+ }
395
+
311
396
  // Warn about isolated tables (not in any relationship)
312
397
  if (result.tables.length >= 2 && result.relationships.length >= 1 && !result.error) {
313
398
  const connectedIds = new Set<string>();
@@ -8,6 +8,7 @@ import { FONT_FAMILY } from '../fonts';
8
8
  import type { PaletteColors } from '../palettes';
9
9
  import { mix } from '../palettes/color-utils';
10
10
  import { getSeriesColors } from '../palettes';
11
+ import { resolveTagColor } from '../utils/tag-groups';
11
12
  import type { ParsedERDiagram, ERConstraint } from './types';
12
13
  import type { ERLayoutResult, ERLayoutNode, ERLayoutEdge } from './layout';
13
14
  import { parseERDiagram } from './parser';
@@ -200,7 +201,8 @@ export function renderERDiagram(
200
201
  palette: PaletteColors,
201
202
  isDark: boolean,
202
203
  onClickItem?: (lineNumber: number) => void,
203
- exportDims?: { width?: number; height?: number }
204
+ exportDims?: { width?: number; height?: number },
205
+ activeTagGroup?: string | null
204
206
  ): void {
205
207
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
206
208
 
@@ -338,7 +340,8 @@ export function renderERDiagram(
338
340
  // ── Nodes (top layer) ──
339
341
  for (let ni = 0; ni < layout.nodes.length; ni++) {
340
342
  const node = layout.nodes[ni];
341
- const nodeColor = node.color ?? seriesColors[ni % seriesColors.length];
343
+ const tagColor = resolveTagColor(node.metadata, parsed.tagGroups, activeTagGroup ?? null);
344
+ const nodeColor = node.color ?? tagColor ?? seriesColors[ni % seriesColors.length];
342
345
 
343
346
  const nodeG = contentG
344
347
  .append('g')
@@ -347,6 +350,15 @@ export function renderERDiagram(
347
350
  .attr('data-line-number', String(node.lineNumber))
348
351
  .attr('data-node-id', node.id);
349
352
 
353
+ // Set data-tag-* attributes for legend hover
354
+ if (activeTagGroup) {
355
+ const tagKey = activeTagGroup.toLowerCase();
356
+ const tagValue = node.metadata[tagKey];
357
+ if (tagValue) {
358
+ nodeG.attr(`data-tag-${tagKey}`, tagValue.toLowerCase());
359
+ }
360
+ }
361
+
350
362
  if (onClickItem) {
351
363
  nodeG.style('cursor', 'pointer').on('click', () => {
352
364
  onClickItem(node.lineNumber);
@@ -430,6 +442,83 @@ export function renderERDiagram(
430
442
  }
431
443
  }
432
444
  }
445
+
446
+ // ── Tag Legend ──
447
+ if (parsed.tagGroups.length > 0) {
448
+ const LEGEND_Y_PAD = 16;
449
+ const LEGEND_PILL_H = 22;
450
+ const LEGEND_PILL_RX = 11;
451
+ const LEGEND_PILL_PAD = 10;
452
+ const LEGEND_GAP = 8;
453
+ const LEGEND_FONT_SIZE = 11;
454
+ const LEGEND_GROUP_GAP = 16;
455
+
456
+ const legendG = svg.append('g')
457
+ .attr('class', 'er-tag-legend');
458
+
459
+ let legendX = DIAGRAM_PADDING;
460
+ let legendY = height - DIAGRAM_PADDING;
461
+
462
+ for (const group of parsed.tagGroups) {
463
+ const groupG = legendG.append('g')
464
+ .attr('data-legend-group', group.name.toLowerCase());
465
+
466
+ // Group label
467
+ const labelText = groupG.append('text')
468
+ .attr('x', legendX)
469
+ .attr('y', legendY + LEGEND_PILL_H / 2)
470
+ .attr('dominant-baseline', 'central')
471
+ .attr('fill', palette.textMuted)
472
+ .attr('font-size', LEGEND_FONT_SIZE)
473
+ .attr('font-family', FONT_FAMILY)
474
+ .text(`${group.name}:`);
475
+
476
+ const labelWidth = (labelText.node()?.getComputedTextLength?.() ?? group.name.length * 7) + 6;
477
+ legendX += labelWidth;
478
+
479
+ // Entries
480
+ for (const entry of group.entries) {
481
+ const pillG = groupG.append('g')
482
+ .attr('data-legend-entry', entry.value.toLowerCase())
483
+ .style('cursor', 'pointer');
484
+
485
+ // Estimate text width
486
+ const tmpText = legendG.append('text')
487
+ .attr('font-size', LEGEND_FONT_SIZE)
488
+ .attr('font-family', FONT_FAMILY)
489
+ .text(entry.value);
490
+ const textW = tmpText.node()?.getComputedTextLength?.() ?? entry.value.length * 7;
491
+ tmpText.remove();
492
+
493
+ const pillW = textW + LEGEND_PILL_PAD * 2;
494
+
495
+ pillG.append('rect')
496
+ .attr('x', legendX)
497
+ .attr('y', legendY)
498
+ .attr('width', pillW)
499
+ .attr('height', LEGEND_PILL_H)
500
+ .attr('rx', LEGEND_PILL_RX)
501
+ .attr('ry', LEGEND_PILL_RX)
502
+ .attr('fill', mix(entry.color, isDark ? palette.surface : palette.bg, 25))
503
+ .attr('stroke', entry.color)
504
+ .attr('stroke-width', 1);
505
+
506
+ pillG.append('text')
507
+ .attr('x', legendX + pillW / 2)
508
+ .attr('y', legendY + LEGEND_PILL_H / 2)
509
+ .attr('text-anchor', 'middle')
510
+ .attr('dominant-baseline', 'central')
511
+ .attr('fill', palette.text)
512
+ .attr('font-size', LEGEND_FONT_SIZE)
513
+ .attr('font-family', FONT_FAMILY)
514
+ .text(entry.value);
515
+
516
+ legendX += pillW + LEGEND_GAP;
517
+ }
518
+
519
+ legendX += LEGEND_GROUP_GAP;
520
+ }
521
+ }
433
522
  }
434
523
 
435
524
  // ============================================================
package/src/er/types.ts CHANGED
@@ -18,6 +18,7 @@ export interface ERTable {
18
18
  name: string;
19
19
  color?: string;
20
20
  columns: ERColumn[];
21
+ metadata: Record<string, string>;
21
22
  lineNumber: number;
22
23
  }
23
24
 
@@ -30,6 +31,7 @@ export interface ERRelationship {
30
31
  }
31
32
 
32
33
  import type { DgmoError } from '../diagnostics';
34
+ import type { TagGroup } from '../utils/tag-groups';
33
35
 
34
36
  export interface ParsedERDiagram {
35
37
  type: 'er';
@@ -38,6 +40,7 @@ export interface ParsedERDiagram {
38
40
  options: Record<string, string>;
39
41
  tables: ERTable[];
40
42
  relationships: ERRelationship[];
43
+ tagGroups: TagGroup[];
41
44
  diagnostics: DgmoError[];
42
45
  error: string | null;
43
46
  }
@@ -335,7 +335,7 @@ function formatUptime(fraction: number): string {
335
335
  // Layout engine
336
336
  // ============================================================
337
337
 
338
- export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: string | null): InfraLayoutResult {
338
+ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: string | null, collapsedNodes?: Set<string> | null): InfraLayoutResult {
339
339
  if (computed.nodes.length === 0) {
340
340
  return { nodes: [], edges: [], groups: [], options: {}, width: 0, height: 0 };
341
341
  }
@@ -363,9 +363,12 @@ export function layoutInfra(computed: ComputedInfraModel, selectedNodeId?: strin
363
363
  const widthMap = new Map<string, number>();
364
364
  const heightMap = new Map<string, number>();
365
365
  for (const node of computed.nodes) {
366
- const expanded = node.id === selectedNodeId;
366
+ const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
367
+ const expanded = !isNodeCollapsed && node.id === selectedNodeId;
367
368
  const width = computeNodeWidth(node, expanded, computed.options);
368
- const height = computeNodeHeight(node, expanded, computed.options);
369
+ const height = isNodeCollapsed
370
+ ? NODE_HEADER_HEIGHT + NODE_PAD_BOTTOM
371
+ : computeNodeHeight(node, expanded, computed.options);
369
372
  widthMap.set(node.id, width);
370
373
  heightMap.set(node.id, height);
371
374
  const inGroup = groupedNodeIds.has(node.id);
@@ -43,8 +43,8 @@ const TAG_VALUE_RE = /^(\w[\w\s]*?)(?:\(([^)]+)\))?(\s+default)?\s*$/;
43
43
  // Component line: ComponentName or ComponentName | t: Backend | env: Prod
44
44
  const COMPONENT_RE = /^([a-zA-Z_][\w]*)(.*)$/;
45
45
 
46
- // Pipe metadata: | key: value
47
- const PIPE_META_RE = /\|\s*(\w+)\s*:\s*([^|]+)/g;
46
+ // Pipe metadata: | key: value or | k1: v1, k2: v2 (comma-separated)
47
+ const PIPE_META_RE = /[|,]\s*(\w+)\s*:\s*([^|,]+)/g;
48
48
 
49
49
  // Property: key: value
50
50
  const PROPERTY_RE = /^([\w-]+)\s*:\s*(.+)$/;
@@ -606,6 +606,23 @@ function renderEdgeLabels(
606
606
  }
607
607
  }
608
608
 
609
+ /** Returns the resolved tag color for a node's active tag group, or null if not applicable. */
610
+ function resolveActiveTagStroke(
611
+ node: InfraLayoutNode,
612
+ activeGroup: string,
613
+ tagGroups: InfraTagGroup[],
614
+ palette: PaletteColors,
615
+ ): string | null {
616
+ const tg = tagGroups.find((t) => t.name.toLowerCase() === activeGroup.toLowerCase());
617
+ if (!tg) return null;
618
+ const tagKey = (tg.alias ?? tg.name).toLowerCase();
619
+ const tagVal = node.tags[tagKey];
620
+ if (!tagVal) return null;
621
+ const tv = tg.values.find((v) => v.name.toLowerCase() === tagVal.toLowerCase());
622
+ if (!tv?.color) return null;
623
+ return resolveColor(tv.color, palette);
624
+ }
625
+
609
626
  function renderNodes(
610
627
  svg: d3Selection.Selection<SVGGElement, unknown, null, undefined>,
611
628
  nodes: InfraLayoutNode[],
@@ -615,11 +632,22 @@ function renderNodes(
615
632
  selectedNodeId?: string | null,
616
633
  activeGroup?: string | null,
617
634
  diagramOptions?: Record<string, string>,
635
+ collapsedNodes?: Set<string> | null,
636
+ tagGroups?: InfraTagGroup[],
618
637
  ) {
619
638
  const mutedColor = palette.textMuted;
620
639
 
621
640
  for (const node of nodes) {
622
- const { fill, stroke, textFill } = nodeColor(node, palette, isDark);
641
+ let { fill, stroke, textFill } = nodeColor(node, palette, isDark);
642
+
643
+ // When a tag legend is active, override border color with tag color
644
+ if (activeGroup && tagGroups && !node.isEdge) {
645
+ const tagStroke = resolveActiveTagStroke(node, activeGroup, tagGroups, palette);
646
+ if (tagStroke) {
647
+ stroke = tagStroke;
648
+ fill = mix(palette.bg, tagStroke, isDark ? 88 : 94);
649
+ }
650
+ }
623
651
  let cls = 'infra-node';
624
652
  if (animate && node.isEdge) {
625
653
  cls += ' infra-node-edge-throb';
@@ -632,12 +660,13 @@ function renderNodes(
632
660
  const g = svg.append('g')
633
661
  .attr('class', cls)
634
662
  .attr('data-line-number', node.lineNumber)
635
- .attr('data-infra-node', node.id);
663
+ .attr('data-infra-node', node.id)
664
+ .attr('data-node-collapse', node.id)
665
+ .style('cursor', 'pointer');
636
666
 
637
- // Collapsed group nodes: toggle attribute + pointer cursor
667
+ // Collapsed group nodes: toggle attribute
638
668
  if (node.id.startsWith('[')) {
639
- g.attr('data-node-toggle', node.id)
640
- .style('cursor', 'pointer');
669
+ g.attr('data-node-toggle', node.id);
641
670
  }
642
671
 
643
672
  // Expose tag values for legend hover dimming
@@ -681,8 +710,22 @@ function renderNodes(
681
710
  .attr('fill', textFill)
682
711
  .text(node.label);
683
712
 
684
- // --- Key-value rows below header ---
685
- {
713
+ // --- Key-value rows below header (skipped for collapsed nodes) ---
714
+ const isNodeCollapsed = collapsedNodes?.has(node.id) ?? false;
715
+ if (isNodeCollapsed) {
716
+ // Collapsed: show a subtle chevron indicator at the bottom of the header
717
+ const chevronY = y + node.height - 6;
718
+ g.append('text')
719
+ .attr('x', node.x)
720
+ .attr('y', chevronY)
721
+ .attr('text-anchor', 'middle')
722
+ .attr('font-family', FONT_FAMILY)
723
+ .attr('font-size', 8)
724
+ .attr('fill', textFill)
725
+ .attr('opacity', 0.5)
726
+ .text('▼');
727
+ }
728
+ if (!isNodeCollapsed) {
686
729
  const expanded = node.id === selectedNodeId;
687
730
  // Declared properties only shown when node is selected (expanded)
688
731
  const displayProps = (!node.isEdge && expanded) ? getDisplayProps(node, expanded, diagramOptions) : [];
@@ -1380,6 +1423,7 @@ export function renderInfra(
1380
1423
  playback?: InfraPlaybackState | null,
1381
1424
  selectedNodeId?: string | null,
1382
1425
  exportMode?: boolean,
1426
+ collapsedNodes?: Set<string> | null,
1383
1427
  ) {
1384
1428
  // Clear previous render (preserve tooltips if any)
1385
1429
  d3Selection.select(container).selectAll(':not([data-d3-tooltip])').remove();
@@ -1470,7 +1514,7 @@ export function renderInfra(
1470
1514
  // Render layers: groups (back), edge paths, nodes, reject particles, edge labels (front)
1471
1515
  renderGroups(svg, layout.groups, palette, isDark);
1472
1516
  renderEdgePaths(svg, layout.edges, layout.nodes, palette, isDark, shouldAnimate);
1473
- renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, selectedNodeId, activeGroup, layout.options);
1517
+ renderNodes(svg, layout.nodes, palette, isDark, shouldAnimate, selectedNodeId, activeGroup, layout.options, collapsedNodes, tagGroups ?? []);
1474
1518
  if (shouldAnimate) {
1475
1519
  renderRejectParticles(svg, layout.nodes);
1476
1520
  }
@@ -171,7 +171,7 @@ export function computeCardArchive(
171
171
  : [...withoutCard, ''];
172
172
  return [
173
173
  ...trimmedEnd,
174
- '== Archive ==',
174
+ '[Archive]',
175
175
  ...cardLines,
176
176
  ].join('\n');
177
177
  }
@@ -1,6 +1,7 @@
1
1
  import type { PaletteColors } from '../palettes';
2
2
  import type { DgmoError } from '../diagnostics';
3
3
  import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
4
+ import { resolveColor } from '../colors';
4
5
  import { matchTagBlockHeading } from '../utils/tag-groups';
5
6
  import {
6
7
  measureIndent,
@@ -21,7 +22,10 @@ import type {
21
22
  // Regex patterns
22
23
  // ============================================================
23
24
 
24
- const COLUMN_RE = /^==\s+(.+?)\s*(?:\[wip:\s*(\d+)\])?\s*==$/;
25
+ // [Column Name], [Column Name](color), [Column Name] | wip: 3, etc.
26
+ const COLUMN_RE = /^\[(.+?)\](?:\s*\(([^)]+)\))?\s*(?:\|\s*(.+))?$/;
27
+ // Legacy delimiter
28
+ const LEGACY_COLUMN_RE = /^==\s+(.+?)\s*(?:\[wip:\s*(\d+)\])?\s*==$/;
25
29
 
26
30
  // ============================================================
27
31
  // Parser
@@ -60,6 +64,7 @@ export function parseKanban(
60
64
  let currentTagGroup: KanbanTagGroup | null = null;
61
65
  let currentColumn: KanbanColumn | null = null;
62
66
  let currentCard: KanbanCard | null = null;
67
+ let cardBaseIndent = 0; // indent level of current card (for detail detection)
63
68
  let columnCounter = 0;
64
69
  let cardCounter = 0;
65
70
 
@@ -155,7 +160,7 @@ export function parseKanban(
155
160
  }
156
161
  }
157
162
 
158
- // Tag group entries (indented Value(color) [default] under ## heading)
163
+ // Tag group entries (indented Value(color) [default] under tag: heading)
159
164
  if (currentTagGroup && !contentStarted) {
160
165
  const indent = measureIndent(line);
161
166
  if (indent > 0) {
@@ -187,8 +192,18 @@ export function parseKanban(
187
192
 
188
193
  // --- Content phase ---
189
194
 
190
- // Column delimiter: == Name == or == Name [wip: N] ==
191
- const columnMatch = trimmed.match(COLUMN_RE);
195
+ const indent = measureIndent(line);
196
+
197
+ // Reject legacy == Column == syntax
198
+ if (LEGACY_COLUMN_RE.test(trimmed)) {
199
+ const legacyMatch = trimmed.match(LEGACY_COLUMN_RE)!;
200
+ const name = legacyMatch[1].replace(/\s*\(.*\)\s*$/, '').trim();
201
+ warn(lineNumber, `'== ${name} ==' is no longer supported. Use '[${name}]' instead`);
202
+ continue;
203
+ }
204
+
205
+ // [Column] header at indent 0
206
+ const columnMatch = indent === 0 ? trimmed.match(COLUMN_RE) : null;
192
207
  if (columnMatch) {
193
208
  contentStarted = true;
194
209
  currentTagGroup = null;
@@ -196,7 +211,6 @@ export function parseKanban(
196
211
  // Finalize previous card's endLineNumber
197
212
  if (currentCard) {
198
213
  currentCard.endLineNumber = lineNumber - 1;
199
- // Walk back over trailing empty lines
200
214
  while (
201
215
  currentCard.endLineNumber > currentCard.lineNumber &&
202
216
  !lines[currentCard.endLineNumber - 1].trim()
@@ -207,16 +221,25 @@ export function parseKanban(
207
221
  currentCard = null;
208
222
 
209
223
  columnCounter++;
210
- const rawColName = columnMatch[1].trim();
211
- const wipStr = columnMatch[2];
212
- const { label: colName, color: colColor } = extractColor(
213
- rawColName,
214
- palette
215
- );
224
+ const colName = columnMatch[1].trim();
225
+ const colColor = columnMatch[2]
226
+ ? resolveColor(columnMatch[2].trim(), palette)
227
+ : undefined;
228
+
229
+ // Parse WIP limit from pipe metadata (e.g., "| wip: 3")
230
+ let wipLimit: number | undefined;
231
+ const pipeStr = columnMatch[3];
232
+ if (pipeStr) {
233
+ const wipMatch = pipeStr.match(/\bwip\s*:\s*(\d+)\b/i);
234
+ if (wipMatch) {
235
+ wipLimit = parseInt(wipMatch[1], 10);
236
+ }
237
+ }
238
+
216
239
  currentColumn = {
217
240
  id: `col-${columnCounter}`,
218
241
  name: colName,
219
- wipLimit: wipStr ? parseInt(wipStr, 10) : undefined,
242
+ wipLimit,
220
243
  color: colColor,
221
244
  cards: [],
222
245
  lineNumber,
@@ -226,45 +249,41 @@ export function parseKanban(
226
249
  }
227
250
 
228
251
  // If we hit a non-column, non-header line and haven't started content yet,
229
- // it's invalid (cards without a column)
252
+ // skip silently (blank lines or whitespace between header and columns)
230
253
  if (!contentStarted) {
231
- // Could be the first column, or an error
232
- // For permissiveness, skip these lines silently (they might be blank
233
- // lines or just whitespace between header and columns)
234
254
  continue;
235
255
  }
236
256
 
237
257
  if (!currentColumn) {
238
- // Content line before any column — skip with warning
239
258
  warn(lineNumber, 'Card line found before any column');
240
259
  continue;
241
260
  }
242
261
 
243
- const indent = measureIndent(line);
244
-
245
- // Detail lines: indented under a card
246
- if (indent > 0 && currentCard) {
262
+ // Detail lines: indented deeper than the card
263
+ if (currentCard && indent > cardBaseIndent) {
247
264
  currentCard.details.push(trimmed);
248
265
  currentCard.endLineNumber = lineNumber;
249
266
  continue;
250
267
  }
251
268
 
252
- // New card line (non-indented within a column)
253
- // Finalize previous card
254
- if (currentCard) {
255
- // endLineNumber already tracked
269
+ // Card line: indented under a [Column]
270
+ if (indent > 0) {
271
+ cardCounter++;
272
+ const card = parseCardLine(
273
+ trimmed,
274
+ lineNumber,
275
+ cardCounter,
276
+ aliasMap,
277
+ palette
278
+ );
279
+ cardBaseIndent = indent;
280
+ currentCard = card;
281
+ currentColumn.cards.push(card);
282
+ continue;
256
283
  }
257
284
 
258
- cardCounter++;
259
- const card = parseCardLine(
260
- trimmed,
261
- lineNumber,
262
- cardCounter,
263
- aliasMap,
264
- palette
265
- );
266
- currentCard = card;
267
- currentColumn.cards.push(card);
285
+ // Un-indented non-column line in content phase — could be stray text
286
+ // For permissiveness, skip silently
268
287
  }
269
288
 
270
289
  // Finalize last card's endLineNumber
@@ -310,7 +329,7 @@ export function parseKanban(
310
329
  }
311
330
 
312
331
  if (result.columns.length === 0 && !result.error) {
313
- return fail(1, 'No columns found. Use == Column Name == to define columns');
332
+ return fail(1, 'No columns found. Use [Column Name] to define columns');
314
333
  }
315
334
 
316
335
  return result;
package/src/sharing.ts CHANGED
@@ -8,6 +8,7 @@ const COMPRESSED_SIZE_LIMIT = 8192; // 8 KB
8
8
 
9
9
  export interface DiagramViewState {
10
10
  activeTagGroup?: string;
11
+ collapsedGroups?: string[];
11
12
  }
12
13
 
13
14
  export interface DecodedDiagramUrl {
@@ -47,6 +48,10 @@ export function encodeDiagramUrl(
47
48
  hash += `&tag=${encodeURIComponent(options.viewState.activeTagGroup)}`;
48
49
  }
49
50
 
51
+ if (options?.viewState?.collapsedGroups?.length) {
52
+ hash += `&cg=${encodeURIComponent(options.viewState.collapsedGroups.join(','))}`;
53
+ }
54
+
50
55
  // Encode in both query param AND hash fragment — some share mechanisms
51
56
  // strip one or the other (iOS share sheet strips #, AirDrop strips ?)
52
57
  return { url: `${baseUrl}?${hash}#${hash}` };
@@ -89,6 +94,9 @@ export function decodeDiagramUrl(hash: string): DecodedDiagramUrl {
89
94
  if (key === 'tag' && val) {
90
95
  viewState.activeTagGroup = val;
91
96
  }
97
+ if (key === 'cg' && val) {
98
+ viewState.collapsedGroups = val.split(',').filter(Boolean);
99
+ }
92
100
  }
93
101
 
94
102
  // Strip 'dgmo=' prefix