@diagrammo/dgmo 0.8.2 → 0.8.4

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.
Files changed (120) hide show
  1. package/.claude/commands/dgmo-diagram-this.md +60 -0
  2. package/.claude/commands/dgmo-document-project.md +128 -0
  3. package/.claude/commands/dgmo.md +185 -50
  4. package/.cursorrules +32 -37
  5. package/.github/copilot-instructions.md +35 -44
  6. package/.windsurfrules +32 -37
  7. package/README.md +4 -4
  8. package/dist/cli.cjs +189 -194
  9. package/dist/editor.cjs +336 -0
  10. package/dist/editor.cjs.map +1 -0
  11. package/dist/editor.d.cts +27 -0
  12. package/dist/editor.d.ts +27 -0
  13. package/dist/editor.js +305 -0
  14. package/dist/editor.js.map +1 -0
  15. package/dist/index.cjs +3699 -1564
  16. package/dist/index.cjs.map +1 -1
  17. package/dist/index.d.cts +7 -6
  18. package/dist/index.d.ts +7 -6
  19. package/dist/index.js +3699 -1564
  20. package/dist/index.js.map +1 -1
  21. package/docs/language-reference.md +822 -1060
  22. package/gallery/fixtures/arc.dgmo +18 -0
  23. package/gallery/fixtures/area.dgmo +19 -0
  24. package/gallery/fixtures/bar-stacked.dgmo +10 -0
  25. package/gallery/fixtures/bar.dgmo +10 -0
  26. package/gallery/fixtures/c4-full.dgmo +52 -0
  27. package/gallery/fixtures/c4.dgmo +17 -0
  28. package/gallery/fixtures/chord.dgmo +12 -0
  29. package/gallery/fixtures/class-basic.dgmo +14 -0
  30. package/gallery/fixtures/class-full.dgmo +43 -0
  31. package/gallery/fixtures/doughnut.dgmo +8 -0
  32. package/gallery/fixtures/flowchart-basic.dgmo +3 -0
  33. package/gallery/fixtures/flowchart-colors.dgmo +5 -0
  34. package/gallery/fixtures/flowchart-complex.dgmo +17 -0
  35. package/gallery/fixtures/flowchart-decision.dgmo +5 -0
  36. package/gallery/fixtures/flowchart-full.dgmo +13 -0
  37. package/gallery/fixtures/flowchart-groups.dgmo +10 -0
  38. package/gallery/fixtures/flowchart-loop.dgmo +7 -0
  39. package/gallery/fixtures/flowchart-nested.dgmo +7 -0
  40. package/gallery/fixtures/flowchart-shapes.dgmo +5 -0
  41. package/gallery/fixtures/function.dgmo +8 -0
  42. package/gallery/fixtures/funnel.dgmo +7 -0
  43. package/gallery/fixtures/gantt-full.dgmo +49 -0
  44. package/gallery/fixtures/gantt.dgmo +42 -0
  45. package/gallery/fixtures/heatmap.dgmo +8 -0
  46. package/gallery/fixtures/infra-full.dgmo +78 -0
  47. package/gallery/fixtures/infra-overload.dgmo +25 -0
  48. package/gallery/fixtures/infra.dgmo +47 -0
  49. package/gallery/fixtures/initiative-status-full.dgmo +46 -0
  50. package/gallery/fixtures/initiative-status-phases.dgmo +29 -0
  51. package/gallery/fixtures/initiative-status.dgmo +9 -0
  52. package/gallery/fixtures/line.dgmo +19 -0
  53. package/gallery/fixtures/multi-line.dgmo +11 -0
  54. package/gallery/fixtures/org-basic.dgmo +16 -0
  55. package/gallery/fixtures/org-full.dgmo +69 -0
  56. package/gallery/fixtures/org-teams.dgmo +25 -0
  57. package/gallery/fixtures/pie.dgmo +9 -0
  58. package/gallery/fixtures/polar-area.dgmo +8 -0
  59. package/gallery/fixtures/quadrant.dgmo +18 -0
  60. package/gallery/fixtures/radar.dgmo +8 -0
  61. package/gallery/fixtures/sankey.dgmo +31 -0
  62. package/gallery/fixtures/scatter.dgmo +21 -0
  63. package/gallery/fixtures/sequence-tags-protocols.dgmo +45 -0
  64. package/gallery/fixtures/sequence-tags.dgmo +41 -0
  65. package/gallery/fixtures/sequence.dgmo +35 -0
  66. package/gallery/fixtures/sitemap-basic.dgmo +12 -0
  67. package/gallery/fixtures/sitemap-full.dgmo +156 -0
  68. package/gallery/fixtures/slope.dgmo +8 -0
  69. package/gallery/fixtures/spr-eras.dgmo +62 -0
  70. package/gallery/fixtures/state.dgmo +30 -0
  71. package/gallery/fixtures/timeline-intraday.dgmo +14 -0
  72. package/gallery/fixtures/timeline.dgmo +32 -0
  73. package/gallery/fixtures/venn.dgmo +10 -0
  74. package/gallery/fixtures/wordcloud.dgmo +24 -0
  75. package/package.json +51 -2
  76. package/src/c4/layout.ts +372 -90
  77. package/src/c4/parser.ts +113 -62
  78. package/src/chart.ts +149 -64
  79. package/src/class/parser.ts +84 -28
  80. package/src/class/renderer.ts +2 -2
  81. package/src/cli.ts +179 -77
  82. package/src/completion.ts +381 -182
  83. package/src/d3.ts +1026 -428
  84. package/src/dgmo-mermaid.ts +16 -13
  85. package/src/dgmo-router.ts +70 -24
  86. package/src/echarts.ts +682 -169
  87. package/src/editor/dgmo.grammar +69 -0
  88. package/src/editor/dgmo.grammar.d.ts +2 -0
  89. package/src/editor/dgmo.grammar.js +18 -0
  90. package/src/editor/dgmo.grammar.terms.d.ts +5 -0
  91. package/src/editor/dgmo.grammar.terms.js +35 -0
  92. package/src/editor/highlight.ts +36 -0
  93. package/src/editor/index.ts +28 -0
  94. package/src/editor/keywords.ts +220 -0
  95. package/src/editor/tokens.ts +30 -0
  96. package/src/er/parser.ts +55 -29
  97. package/src/er/renderer.ts +112 -53
  98. package/src/gantt/calculator.ts +91 -29
  99. package/src/gantt/parser.ts +291 -97
  100. package/src/gantt/renderer.ts +1120 -350
  101. package/src/graph/flowchart-parser.ts +48 -75
  102. package/src/graph/state-parser.ts +54 -27
  103. package/src/infra/parser.ts +161 -177
  104. package/src/infra/renderer.ts +723 -271
  105. package/src/infra/types.ts +0 -1
  106. package/src/initiative-status/parser.ts +144 -56
  107. package/src/kanban/parser.ts +27 -19
  108. package/src/org/layout.ts +111 -44
  109. package/src/org/parser.ts +71 -27
  110. package/src/org/resolver.ts +3 -3
  111. package/src/palettes/index.ts +3 -2
  112. package/src/render.ts +1 -2
  113. package/src/sequence/parser.ts +209 -100
  114. package/src/sitemap/parser.ts +73 -44
  115. package/src/utils/arrows.ts +2 -22
  116. package/src/utils/duration.ts +39 -21
  117. package/src/utils/legend-constants.ts +0 -2
  118. package/src/utils/parsing.ts +82 -72
  119. package/src/utils/tag-groups.ts +4 -41
  120. package/src/infra/serialize.ts +0 -67
@@ -2,14 +2,19 @@
2
2
  // Gantt Chart Parser
3
3
  // ============================================================
4
4
 
5
- import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
5
+ import { makeDgmoError, formatDgmoError } from '../diagnostics';
6
6
  import type { DgmoError } from '../diagnostics';
7
- import type { TagGroup, TagEntry } from '../utils/tag-groups';
7
+ import type { TagGroup } from '../utils/tag-groups';
8
8
  import { matchTagBlockHeading } from '../utils/tag-groups';
9
- import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING, parseFirstLine, prescanOptions, GROUP_HASH_RE } from '../utils/parsing';
9
+ import {
10
+ measureIndent,
11
+ extractColor,
12
+ parsePipeMetadata,
13
+ MULTIPLE_PIPE_ERROR,
14
+ parseFirstLine,
15
+ } from '../utils/parsing';
10
16
  import { parseOffset } from '../utils/duration';
11
17
  import type { PaletteColors } from '../palettes';
12
- import { resolveColor } from '../colors';
13
18
  import { getSeriesColors } from '../palettes';
14
19
  import type {
15
20
  ParsedGantt,
@@ -17,11 +22,6 @@ import type {
17
22
  GanttTask,
18
23
  GanttGroup,
19
24
  GanttParallelBlock,
20
- GanttDependency,
21
- GanttHolidays,
22
- GanttEra,
23
- GanttMarker,
24
- GanttOptions,
25
25
  Duration,
26
26
  DurationUnit,
27
27
  Offset,
@@ -37,7 +37,8 @@ const DURATION_RE = /^(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
37
37
  const EXPLICIT_DATE_RE = /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s+(.+)$/;
38
38
 
39
39
  /** Timeline migration syntax: `2024-01-15 -> 30d Label` or `2024-01-15 14:30 -> 2h Label` */
40
- const TIMELINE_DURATION_RE = /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*->\s*(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
40
+ const TIMELINE_DURATION_RE =
41
+ /^(\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?)\s*(?:->|\u2013>)\s*(\d+(?:\.\d+)?)(min|bd|d|w|m|q|y|h)(\?)?\s+(.+)$/;
41
42
 
42
43
  /** Group container: `[GroupName]` with optional pipe metadata */
43
44
  const GROUP_RE = /^\[(.+?)\]\s*(.*)$/;
@@ -49,24 +50,47 @@ const DEPENDENCY_RE = /^(?:-(.+?))?->\s*(.+)$/;
49
50
  const COMMENT_RE = /^\/\//;
50
51
 
51
52
  /** Era: `era YYYY[-MM[-DD[ HH:MM]]] -> YYYY[-MM[-DD[ HH:MM]]] Label (color?)` */
52
- const ERA_RE = /^era\s+(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s*->\s*(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s+(.+)$/i;
53
+ const ERA_RE =
54
+ /^era\s+(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s+(.+)$/i;
53
55
 
54
56
  /** Marker: `marker YYYY[-MM[-DD[ HH:MM]]] Label (color?)` */
55
- const MARKER_RE = /^marker\s+(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s+(.+)$/i;
57
+ const MARKER_RE =
58
+ /^marker\s+(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s+(.+)$/i;
56
59
 
57
60
  /** Holiday date: `2024-01-15 Label` */
58
61
  const HOLIDAY_DATE_RE = /^(\d{4}-\d{2}-\d{2})\s+(.+)$/;
59
62
 
60
63
  /** Holiday range: `2024-12-24 -> 2024-12-31 Label` */
61
- const HOLIDAY_RANGE_RE = /^(\d{4}-\d{2}-\d{2})\s*->\s*(\d{4}-\d{2}-\d{2})\s+(.+)$/;
64
+ const HOLIDAY_RANGE_RE =
65
+ /^(\d{4}-\d{2}-\d{2})\s*(?:->|\u2013>)\s*(\d{4}-\d{2}-\d{2})\s+(.+)$/;
62
66
 
63
67
  /** Workweek override: `workweek sun-thu` */
64
68
  const WORKWEEK_RE = /^workweek\s+(.+)$/i;
65
69
 
70
+ /** Era entry (inside era block, no `era` prefix): `YYYY[-MM[-DD[ HH:MM]]] -> YYYY[-MM[-DD[ HH:MM]]] Label` */
71
+ const ERA_ENTRY_RE =
72
+ /^(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s*(?:->|\u2013>)\s*(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s+(.+)$/;
73
+
74
+ /** Marker entry (inside marker block, no `marker` prefix): `YYYY[-MM[-DD[ HH:MM]]] Label` */
75
+ const MARKER_ENTRY_RE =
76
+ /^(\d{4}(?:-\d{2}(?:-\d{2}(?: \d{2}:\d{2})?)?)?)\s+(.+)$/;
77
+
66
78
  // Valid weekday names
67
79
  const WEEKDAY_MAP: Record<string, Weekday> = {
68
- mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun',
69
- monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun',
80
+ mon: 'mon',
81
+ tue: 'tue',
82
+ wed: 'wed',
83
+ thu: 'thu',
84
+ fri: 'fri',
85
+ sat: 'sat',
86
+ sun: 'sun',
87
+ monday: 'mon',
88
+ tuesday: 'tue',
89
+ wednesday: 'wed',
90
+ thursday: 'thu',
91
+ friday: 'fri',
92
+ saturday: 'sat',
93
+ sunday: 'sun',
70
94
  };
71
95
 
72
96
  // ── Block Stack ─────────────────────────────────────────────
@@ -81,13 +105,20 @@ interface BlockEntry {
81
105
 
82
106
  // ── Parser ──────────────────────────────────────────────────
83
107
 
84
- export function parseGantt(content: string, palette?: PaletteColors): ParsedGantt {
108
+ export function parseGantt(
109
+ content: string,
110
+ palette?: PaletteColors
111
+ ): ParsedGantt {
85
112
  const lines = content.split('\n');
86
113
  const diagnostics: DgmoError[] = [];
87
114
 
88
115
  const result: ParsedGantt = {
89
116
  nodes: [],
90
- holidays: { dates: [], ranges: [], workweek: ['mon', 'tue', 'wed', 'thu', 'fri'] },
117
+ holidays: {
118
+ dates: [],
119
+ ranges: [],
120
+ workweek: ['mon', 'tue', 'wed', 'thu', 'fri'],
121
+ },
91
122
  tagGroups: [],
92
123
  eras: [],
93
124
  markers: [],
@@ -150,12 +181,15 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
150
181
  // ── State ───────────────────────────────────────────────
151
182
 
152
183
  let seenChartType = false;
153
- let inHeaderBlock = true; // options must come before content
154
184
  let inHolidaysBlock = false;
155
185
  let holidaysBlockIndent = 0;
156
186
  let inTagBlock = false;
157
187
  let currentTagGroup: TagGroup | null = null;
158
188
  let tagBlockIndent = 0;
189
+ let inEraBlock = false;
190
+ let eraBlockIndent = 0;
191
+ let inMarkerBlock = false;
192
+ let markerBlockIndent = 0;
159
193
  let lastTaskNode: (GanttNode & { kind: 'task' }) | null = null;
160
194
  let taskIdCounter = 0;
161
195
  const seriesColors = palette ? getSeriesColors(palette) : [];
@@ -170,10 +204,16 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
170
204
 
171
205
  // Skip empty lines
172
206
  if (!line) {
173
- // Empty line ends holidays/tag blocks only if at root indent
207
+ // Empty line ends holidays/tag/era/marker blocks only if at root indent
174
208
  if (inHolidaysBlock && indent <= holidaysBlockIndent) {
175
209
  inHolidaysBlock = false;
176
210
  }
211
+ if (inEraBlock && indent <= eraBlockIndent) {
212
+ inEraBlock = false;
213
+ }
214
+ if (inMarkerBlock && indent <= markerBlockIndent) {
215
+ inMarkerBlock = false;
216
+ }
177
217
  if (inTagBlock && indent <= tagBlockIndent) {
178
218
  inTagBlock = false;
179
219
  if (currentTagGroup) {
@@ -190,7 +230,10 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
190
230
  const firstLineResult = parseFirstLine(line);
191
231
  if (firstLineResult) {
192
232
  if (firstLineResult.chartType !== 'gantt') {
193
- return fail(lineNumber, `Expected chart type "gantt", got "${firstLineResult.chartType}"`);
233
+ return fail(
234
+ lineNumber,
235
+ `Expected chart type "gantt", got "${firstLineResult.chartType}"`
236
+ );
194
237
  }
195
238
  seenChartType = true;
196
239
  if (firstLineResult.title) {
@@ -236,7 +279,10 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
236
279
  if (days) {
237
280
  result.holidays.workweek = days;
238
281
  } else {
239
- warn(lineNumber, `Invalid workweek format: "${workweekMatch[1]}". Use day range like "sun-thu" or comma-separated days.`);
282
+ warn(
283
+ lineNumber,
284
+ `Invalid workweek format: "${workweekMatch[1]}". Use day range like "sun-thu" or comma-separated days.`
285
+ );
240
286
  }
241
287
  continue;
242
288
  }
@@ -249,6 +295,57 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
249
295
  }
250
296
  }
251
297
 
298
+ // ── Era block entries ─────────────────────────────────
299
+
300
+ if (inEraBlock) {
301
+ if (indent <= eraBlockIndent) {
302
+ inEraBlock = false;
303
+ // fall through to process this line normally
304
+ } else {
305
+ if (COMMENT_RE.test(line)) continue;
306
+ const eraEntryMatch = line.match(ERA_ENTRY_RE);
307
+ if (eraEntryMatch) {
308
+ const eraLabelRaw = eraEntryMatch[3].trim();
309
+ const eraExtracted = extractColor(eraLabelRaw, palette);
310
+ result.eras.push({
311
+ startDate: eraEntryMatch[1],
312
+ endDate: eraEntryMatch[2],
313
+ label: eraExtracted.label,
314
+ color: eraExtracted.color || null,
315
+ lineNumber,
316
+ });
317
+ } else {
318
+ warn(lineNumber, `Unrecognized era entry: "${line}"`);
319
+ }
320
+ continue;
321
+ }
322
+ }
323
+
324
+ // ── Marker block entries ─────────────────────────────
325
+
326
+ if (inMarkerBlock) {
327
+ if (indent <= markerBlockIndent) {
328
+ inMarkerBlock = false;
329
+ // fall through to process this line normally
330
+ } else {
331
+ if (COMMENT_RE.test(line)) continue;
332
+ const markerEntryMatch = line.match(MARKER_ENTRY_RE);
333
+ if (markerEntryMatch) {
334
+ const markerLabelRaw = markerEntryMatch[2].trim();
335
+ const markerExtracted = extractColor(markerLabelRaw, palette);
336
+ result.markers.push({
337
+ date: markerEntryMatch[1],
338
+ label: markerExtracted.label,
339
+ color: markerExtracted.color || null,
340
+ lineNumber,
341
+ });
342
+ } else {
343
+ warn(lineNumber, `Unrecognized marker entry: "${line}"`);
344
+ }
345
+ continue;
346
+ }
347
+ }
348
+
252
349
  // ── Tag block entries ─────────────────────────────────
253
350
 
254
351
  if (inTagBlock && currentTagGroup) {
@@ -263,7 +360,10 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
263
360
  // First entry is the default (no `default` keyword needed)
264
361
  if (COMMENT_RE.test(line)) continue;
265
362
  const extracted = extractColor(line, palette);
266
- const color = extracted.color || seriesColors[currentTagGroup.entries.length % seriesColors.length] || '#888888';
363
+ const color =
364
+ extracted.color ||
365
+ seriesColors[currentTagGroup.entries.length % seriesColors.length] ||
366
+ '#888888';
267
367
  const isFirstEntry = currentTagGroup.entries.length === 0;
268
368
  currentTagGroup.entries.push({
269
369
  value: extracted.label,
@@ -302,19 +402,32 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
302
402
  let offset: Offset | undefined;
303
403
 
304
404
  if (depParts.length > 1) {
305
- const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
405
+ const meta = parsePipeMetadata(
406
+ ['', ...depParts.slice(1)],
407
+ aliasMap,
408
+ () => warn(lineNumber, MULTIPLE_PIPE_ERROR)
409
+ );
306
410
  if (meta.lag || meta.lead) {
307
411
  const key = meta.lag ? 'lag' : 'lead';
308
- softError(lineNumber, `"${key}" is no longer supported — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
412
+ softError(
413
+ lineNumber,
414
+ `"${key}" is no longer supported — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`
415
+ );
309
416
  }
310
417
  if (meta.offset) {
311
418
  const raw = meta.offset;
312
419
  if (raw.trim().startsWith('+')) {
313
- warn(lineNumber, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
420
+ warn(
421
+ lineNumber,
422
+ `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`
423
+ );
314
424
  } else {
315
425
  offset = parseOffset(raw) ?? undefined;
316
426
  if (!offset) {
317
- warn(lineNumber, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
427
+ warn(
428
+ lineNumber,
429
+ `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`
430
+ );
318
431
  }
319
432
  }
320
433
  }
@@ -348,13 +461,14 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
348
461
  if (line.toLowerCase() === 'holiday' || line.toLowerCase() === 'holidays') {
349
462
  inHolidaysBlock = true;
350
463
  holidaysBlockIndent = indent;
351
- inHeaderBlock = false;
352
464
  result.options.holidaysLineNumber = lineNumber;
353
465
  continue;
354
466
  }
355
467
 
356
468
  // Single-line holiday: `holiday 2024-12-25 Christmas`
357
- const holidayInlineMatch = line.match(/^holiday\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/i);
469
+ const holidayInlineMatch = line.match(
470
+ /^holiday\s+(\d{4}-\d{2}-\d{2})\s+(.+)$/i
471
+ );
358
472
  if (holidayInlineMatch) {
359
473
  result.holidays.dates.push({
360
474
  date: holidayInlineMatch[1],
@@ -362,20 +476,14 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
362
476
  lineNumber,
363
477
  });
364
478
  result.options.holidaysLineNumber ??= lineNumber;
365
- inHeaderBlock = false;
366
479
  continue;
367
480
  }
368
481
 
369
482
  // Tag block heading
370
483
  const tagMatch = matchTagBlockHeading(line);
371
484
  if (tagMatch) {
372
- if (tagMatch.deprecated) {
373
- softError(lineNumber, `'## ${tagMatch.name}' is no longer supported — use 'tag ${tagMatch.name}' instead`);
374
- continue;
375
- }
376
485
  inTagBlock = true;
377
486
  tagBlockIndent = indent;
378
- inHeaderBlock = false;
379
487
  currentTagGroup = {
380
488
  name: tagMatch.name,
381
489
  alias: tagMatch.alias,
@@ -388,7 +496,29 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
388
496
  continue;
389
497
  }
390
498
 
391
- // Era
499
+ // Top-level workweek (outside holiday block)
500
+ const topWorkweekMatch = line.match(WORKWEEK_RE);
501
+ if (topWorkweekMatch) {
502
+ const days = parseWorkweek(topWorkweekMatch[1].trim());
503
+ if (days) {
504
+ result.holidays.workweek = days;
505
+ } else {
506
+ warn(
507
+ lineNumber,
508
+ `Invalid workweek format: "${topWorkweekMatch[1]}". Use day range like "sun-thu" or comma-separated days.`
509
+ );
510
+ }
511
+ continue;
512
+ }
513
+
514
+ // Era block: bare `era` keyword starts a block
515
+ if (line.toLowerCase() === 'era') {
516
+ inEraBlock = true;
517
+ eraBlockIndent = indent;
518
+ continue;
519
+ }
520
+
521
+ // Era (inline)
392
522
  const eraMatch = line.match(ERA_RE);
393
523
  if (eraMatch) {
394
524
  const eraLabelRaw = eraMatch[3].trim();
@@ -400,11 +530,17 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
400
530
  color: eraExtracted.color || null,
401
531
  lineNumber,
402
532
  });
403
- inHeaderBlock = false;
404
533
  continue;
405
534
  }
406
535
 
407
- // Marker
536
+ // Marker block: bare `marker` keyword starts a block
537
+ if (line.toLowerCase() === 'marker') {
538
+ inMarkerBlock = true;
539
+ markerBlockIndent = indent;
540
+ continue;
541
+ }
542
+
543
+ // Marker (inline)
408
544
  const markerMatch = line.match(MARKER_RE);
409
545
  if (markerMatch) {
410
546
  const markerLabelRaw = markerMatch[2].trim();
@@ -415,7 +551,6 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
415
551
  color: markerExtracted.color || null,
416
552
  lineNumber,
417
553
  });
418
- inHeaderBlock = false;
419
554
  continue;
420
555
  }
421
556
 
@@ -472,14 +607,14 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
472
607
  result.options.title = value;
473
608
  result.options.titleLineNumber = lineNumber;
474
609
  break;
475
- case 'orientation':
476
- warn(lineNumber, `'orientation' is not supported for gantt charts`);
477
- break;
478
610
  case 'today-marker':
479
611
  if (/^\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2})?$/.test(value)) {
480
612
  result.options.todayMarker = value;
481
613
  } else {
482
- warn(lineNumber, `Invalid today-marker value: "${value}". Expected YYYY-MM-DD.`);
614
+ warn(
615
+ lineNumber,
616
+ `Invalid today-marker value: "${value}". Expected YYYY-MM-DD.`
617
+ );
483
618
  }
484
619
  break;
485
620
  case 'critical-path':
@@ -494,41 +629,20 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
494
629
  result.options.sort = 'tag';
495
630
  const colonIdx = value.indexOf(':');
496
631
  if (colonIdx !== -1) {
497
- result.options.defaultSwimlaneGroup = value.slice(colonIdx + 1).trim() || null;
632
+ result.options.defaultSwimlaneGroup =
633
+ value.slice(colonIdx + 1).trim() || null;
498
634
  }
499
635
  } else {
500
- warn(lineNumber, `Invalid sort value: "${value}". Expected "tag" or "tag:GroupName".`);
636
+ warn(
637
+ lineNumber,
638
+ `Invalid sort value: "${value}". Expected "tag" or "tag:GroupName".`
639
+ );
501
640
  }
502
641
  break;
503
642
  }
504
643
  continue;
505
644
  }
506
645
 
507
- inHeaderBlock = false;
508
-
509
- // ── `# Group` alternate syntax ──────────────────────────
510
-
511
- const hashGroupMatch = line.match(GROUP_HASH_RE);
512
- if (hashGroupMatch) {
513
- const nameExtracted = extractColor(hashGroupMatch[1], palette);
514
- const group: GanttGroup = {
515
- name: nameExtracted.label,
516
- color: nameExtracted.color || null,
517
- metadata: {},
518
- lineNumber,
519
- children: [],
520
- };
521
- const groupNode: GanttNode = { kind: 'group', ...group };
522
- currentContainer().push(groupNode);
523
- blockStack.push({
524
- node: groupNode as GanttGroup,
525
- indent,
526
- containerType: 'group',
527
- });
528
- lastTaskNode = null;
529
- continue;
530
- }
531
-
532
646
  // ── Parallel block ────────────────────────────────────
533
647
 
534
648
  if (line === 'parallel') {
@@ -548,8 +662,14 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
548
662
  const groupMatch = line.match(GROUP_RE);
549
663
  if (groupMatch) {
550
664
  // Validate nesting: group under a task is invalid
551
- if (blockStack.length > 0 && blockStack[blockStack.length - 1].containerType === 'task') {
552
- softError(lineNumber, `Cannot nest a group inside a task. Groups must be inside other groups or parallel blocks.`);
665
+ if (
666
+ blockStack.length > 0 &&
667
+ blockStack[blockStack.length - 1].containerType === 'task'
668
+ ) {
669
+ softError(
670
+ lineNumber,
671
+ `Cannot nest a group inside a task. Groups must be inside other groups or parallel blocks.`
672
+ );
553
673
  continue;
554
674
  }
555
675
 
@@ -560,12 +680,16 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
560
680
  let metadata: Record<string, string> = {};
561
681
  let color: string | null = null;
562
682
 
563
- const pipeWarn = () => warn(lineNumber, MULTIPLE_PIPE_WARNING);
683
+ const pipeWarn = () => warn(lineNumber, MULTIPLE_PIPE_ERROR);
564
684
  if (segments.length > 0 && segments[0].trim()) {
565
685
  // Check if first segment after brackets is pipe metadata
566
686
  metadata = parsePipeMetadata(['', ...segments], aliasMap, pipeWarn);
567
687
  } else if (segments.length > 1) {
568
- metadata = parsePipeMetadata(['', ...segments.slice(1)], aliasMap, pipeWarn);
688
+ metadata = parsePipeMetadata(
689
+ ['', ...segments.slice(1)],
690
+ aliasMap,
691
+ pipeWarn
692
+ );
569
693
  }
570
694
 
571
695
  // Extract color from group name if present
@@ -602,11 +726,21 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
602
726
  const uncertain = !!timelineDurMatch[4];
603
727
  const labelRaw = timelineDurMatch[5];
604
728
 
605
- const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber, startDate);
729
+ const task = makeTask(
730
+ labelRaw,
731
+ { amount, unit },
732
+ uncertain,
733
+ lineNumber,
734
+ startDate
735
+ );
606
736
  const taskNode: GanttNode = { kind: 'task', ...task };
607
737
  currentContainer().push(taskNode);
608
738
  lastTaskNode = taskNode as GanttNode & { kind: 'task' };
609
- blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
739
+ blockStack.push({
740
+ node: taskNode as unknown as GanttGroup,
741
+ indent,
742
+ containerType: 'task',
743
+ });
610
744
  continue;
611
745
  }
612
746
 
@@ -623,7 +757,11 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
623
757
  const taskNode: GanttNode = { kind: 'task', ...task };
624
758
  currentContainer().push(taskNode);
625
759
  lastTaskNode = taskNode as GanttNode & { kind: 'task' };
626
- blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
760
+ blockStack.push({
761
+ node: taskNode as unknown as GanttGroup,
762
+ indent,
763
+ containerType: 'task',
764
+ });
627
765
  continue;
628
766
  }
629
767
 
@@ -636,13 +774,17 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
636
774
  null, // no duration — it's a date anchor / milestone
637
775
  false,
638
776
  lineNumber,
639
- explicitDateMatch[1],
777
+ explicitDateMatch[1]
640
778
  );
641
779
  // Explicit date tasks with no duration are milestones
642
780
  const taskNode: GanttNode = { kind: 'task', ...task };
643
781
  currentContainer().push(taskNode);
644
782
  lastTaskNode = taskNode as GanttNode & { kind: 'task' };
645
- blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
783
+ blockStack.push({
784
+ node: taskNode as unknown as GanttGroup,
785
+ indent,
786
+ containerType: 'task',
787
+ });
646
788
  continue;
647
789
  }
648
790
 
@@ -652,7 +794,10 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
652
794
  if (depMatch) {
653
795
  // Dependency without a task context is an error
654
796
  if (!lastTaskNode) {
655
- softError(lineNumber, `Dependency "-> ${depMatch[2]}" must be indented under a task.`);
797
+ softError(
798
+ lineNumber,
799
+ `Dependency "-> ${depMatch[2]}" must be indented under a task.`
800
+ );
656
801
  continue;
657
802
  }
658
803
  // This happens when the dep is at the same indent as the task
@@ -662,19 +807,32 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
662
807
  let offset: Offset | undefined;
663
808
 
664
809
  if (depParts.length > 1) {
665
- const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
810
+ const meta = parsePipeMetadata(
811
+ ['', ...depParts.slice(1)],
812
+ aliasMap,
813
+ () => warn(lineNumber, MULTIPLE_PIPE_ERROR)
814
+ );
666
815
  if (meta.lag || meta.lead) {
667
816
  const key = meta.lag ? 'lag' : 'lead';
668
- softError(lineNumber, `"${key}" is no longer supported — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
817
+ softError(
818
+ lineNumber,
819
+ `"${key}" is no longer supported — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`
820
+ );
669
821
  }
670
822
  if (meta.offset) {
671
823
  const raw = meta.offset;
672
824
  if (raw.trim().startsWith('+')) {
673
- warn(lineNumber, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
825
+ warn(
826
+ lineNumber,
827
+ `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`
828
+ );
674
829
  } else {
675
830
  offset = parseOffset(raw) ?? undefined;
676
831
  if (!offset) {
677
- warn(lineNumber, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
832
+ warn(
833
+ lineNumber,
834
+ `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`
835
+ );
678
836
  }
679
837
  }
680
838
  }
@@ -686,7 +844,10 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
686
844
 
687
845
  // ── Bare label = parse error ──────────────────────────
688
846
 
689
- softError(lineNumber, `Expected duration (e.g., "10d Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
847
+ softError(
848
+ lineNumber,
849
+ `Expected duration (e.g., "10d Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`
850
+ );
690
851
  continue;
691
852
  }
692
853
 
@@ -714,20 +875,26 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
714
875
  duration: Duration | null,
715
876
  uncertain: boolean,
716
877
  ln: number,
717
- explicitStart?: string,
878
+ explicitStart?: string
718
879
  ): GanttTask {
719
880
  const segments = labelRaw.split('|');
720
881
  const label = segments[0].trim();
721
882
 
722
883
  // Check for reserved keyword
723
884
  if (label.toLowerCase() === 'parallel') {
724
- softError(ln, `"parallel" is a reserved keyword and cannot be used as a task name.`);
885
+ softError(
886
+ ln,
887
+ `"parallel" is a reserved keyword and cannot be used as a task name.`
888
+ );
725
889
  }
726
890
 
727
891
  // Parse pipe metadata
728
- const metadata = segments.length > 1
729
- ? parsePipeMetadata(segments, aliasMap, () => warn(ln, MULTIPLE_PIPE_WARNING))
730
- : {};
892
+ const metadata =
893
+ segments.length > 1
894
+ ? parsePipeMetadata(segments, aliasMap, () =>
895
+ warn(ln, MULTIPLE_PIPE_ERROR)
896
+ )
897
+ : {};
731
898
 
732
899
  // Extract progress from metadata or shorthand
733
900
  let progress: number | null = null;
@@ -747,7 +914,10 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
747
914
  // Reject lag/lead — use offset instead
748
915
  if (metadata.lag || metadata.lead) {
749
916
  const key = metadata.lag ? 'lag' : 'lead';
750
- softError(ln, `"${key}" is no longer supported — use "offset: ${metadata[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
917
+ softError(
918
+ ln,
919
+ `"${key}" is no longer supported — use "offset: ${metadata[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`
920
+ );
751
921
  }
752
922
 
753
923
  // Extract task-level offset from metadata
@@ -755,11 +925,17 @@ export function parseGantt(content: string, palette?: PaletteColors): ParsedGant
755
925
  if (metadata.offset) {
756
926
  const raw = metadata.offset;
757
927
  if (raw.trim().startsWith('+')) {
758
- warn(ln, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
928
+ warn(
929
+ ln,
930
+ `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`
931
+ );
759
932
  } else {
760
933
  taskOffset = parseOffset(raw) ?? undefined;
761
934
  if (!taskOffset) {
762
- warn(ln, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
935
+ warn(
936
+ ln,
937
+ `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`
938
+ );
763
939
  }
764
940
  }
765
941
  delete metadata.offset;
@@ -805,7 +981,15 @@ function parseWorkweek(s: string): Weekday[] | null {
805
981
  const start = WEEKDAY_MAP[rangeParts[0].trim()];
806
982
  const end = WEEKDAY_MAP[rangeParts[1].trim()];
807
983
  if (start && end) {
808
- const allDays: Weekday[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
984
+ const allDays: Weekday[] = [
985
+ 'mon',
986
+ 'tue',
987
+ 'wed',
988
+ 'thu',
989
+ 'fri',
990
+ 'sat',
991
+ 'sun',
992
+ ];
809
993
  const startIdx = allDays.indexOf(start);
810
994
  const endIdx = allDays.indexOf(end);
811
995
  const days: Weekday[] = [];
@@ -820,7 +1004,10 @@ function parseWorkweek(s: string): Weekday[] | null {
820
1004
  }
821
1005
 
822
1006
  // Try comma-separated: "mon, tue, wed, thu, fri"
823
- const parts = s.toLowerCase().split(',').map(p => p.trim());
1007
+ const parts = s
1008
+ .toLowerCase()
1009
+ .split(',')
1010
+ .map((p) => p.trim());
824
1011
  const days: Weekday[] = [];
825
1012
  for (const part of parts) {
826
1013
  const day = WEEKDAY_MAP[part];
@@ -833,13 +1020,20 @@ function parseWorkweek(s: string): Weekday[] | null {
833
1020
  // ── Known option keys ─────────────────────────────────────
834
1021
 
835
1022
  const KNOWN_OPTIONS = new Set([
836
- 'start', 'title', 'orientation', 'today-marker',
837
- 'critical-path', 'dependencies', 'chart', 'sort',
1023
+ 'start',
1024
+ 'title',
1025
+ 'today-marker',
1026
+ 'critical-path',
1027
+ 'dependencies',
1028
+ 'chart',
1029
+ 'sort',
838
1030
  ]);
839
1031
 
840
1032
  /** Boolean options that can appear as bare keywords or with `no-` prefix. */
841
1033
  const KNOWN_BOOLEANS = new Set([
842
- 'critical-path', 'today-marker', 'dependencies',
1034
+ 'critical-path',
1035
+ 'today-marker',
1036
+ 'dependencies',
843
1037
  ]);
844
1038
 
845
1039
  function isKnownOption(key: string): boolean {