@diagrammo/dgmo 0.6.3 → 0.7.1

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 (44) hide show
  1. package/dist/cli.cjs +180 -178
  2. package/dist/index.cjs +5447 -2229
  3. package/dist/index.cjs.map +1 -1
  4. package/dist/index.d.cts +236 -16
  5. package/dist/index.d.ts +236 -16
  6. package/dist/index.js +5439 -2228
  7. package/dist/index.js.map +1 -1
  8. package/package.json +1 -1
  9. package/src/c4/parser.ts +3 -2
  10. package/src/c4/renderer.ts +6 -6
  11. package/src/class/renderer.ts +183 -7
  12. package/src/cli.ts +3 -11
  13. package/src/colors.ts +3 -3
  14. package/src/d3.ts +132 -29
  15. package/src/dgmo-router.ts +3 -1
  16. package/src/er/parser.ts +5 -3
  17. package/src/er/renderer.ts +11 -5
  18. package/src/gantt/calculator.ts +717 -0
  19. package/src/gantt/parser.ts +767 -0
  20. package/src/gantt/renderer.ts +2251 -0
  21. package/src/gantt/resolver.ts +144 -0
  22. package/src/gantt/types.ts +168 -0
  23. package/src/index.ts +27 -0
  24. package/src/infra/renderer.ts +48 -12
  25. package/src/initiative-status/filter.ts +63 -0
  26. package/src/initiative-status/layout.ts +319 -67
  27. package/src/initiative-status/parser.ts +200 -25
  28. package/src/initiative-status/renderer.ts +293 -10
  29. package/src/initiative-status/types.ts +6 -0
  30. package/src/org/layout.ts +22 -55
  31. package/src/org/parser.ts +7 -5
  32. package/src/org/renderer.ts +4 -8
  33. package/src/palettes/dracula.ts +60 -0
  34. package/src/palettes/index.ts +8 -6
  35. package/src/palettes/monokai.ts +60 -0
  36. package/src/palettes/registry.ts +4 -2
  37. package/src/sequence/parser.ts +10 -9
  38. package/src/sequence/renderer.ts +5 -4
  39. package/src/sharing.ts +8 -0
  40. package/src/sitemap/parser.ts +5 -3
  41. package/src/sitemap/renderer.ts +4 -4
  42. package/src/utils/duration.ts +212 -0
  43. package/src/utils/legend-constants.ts +1 -0
  44. package/src/utils/parsing.ts +23 -12
@@ -0,0 +1,767 @@
1
+ // ============================================================
2
+ // Gantt Chart Parser
3
+ // ============================================================
4
+
5
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
6
+ import type { DgmoError } from '../diagnostics';
7
+ import type { TagGroup, TagEntry } from '../utils/tag-groups';
8
+ import { matchTagBlockHeading } from '../utils/tag-groups';
9
+ import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
10
+ import { parseOffset } from '../utils/duration';
11
+ import type { PaletteColors } from '../palettes';
12
+ import { resolveColor } from '../colors';
13
+ import { getSeriesColors } from '../palettes';
14
+ import type {
15
+ ParsedGantt,
16
+ GanttNode,
17
+ GanttTask,
18
+ GanttGroup,
19
+ GanttParallelBlock,
20
+ GanttDependency,
21
+ GanttHolidays,
22
+ GanttEra,
23
+ GanttMarker,
24
+ GanttOptions,
25
+ Duration,
26
+ DurationUnit,
27
+ Offset,
28
+ Weekday,
29
+ } from './types';
30
+
31
+ // ── Regexes ─────────────────────────────────────────────────
32
+
33
+ /** Duration task: `30d: Label`, `1.5w: Label`, `10bd?: Label` */
34
+ const DURATION_RE = /^(\d+(?:\.\d+)?)(d|bd|w|m|q|y)(\?)?:\s*(.+)$/;
35
+
36
+ /** Explicit date task: `2024-01-15: Label` */
37
+ const EXPLICIT_DATE_RE = /^(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
38
+
39
+ /** Timeline migration syntax: `2024-01-15 -> 30d: Label` */
40
+ const TIMELINE_DURATION_RE = /^(\d{4}-\d{2}-\d{2})\s*->\s*(\d+(?:\.\d+)?)(d|bd|w|m|q|y)(\?)?:\s*(.+)$/;
41
+
42
+ /** Group container: `[GroupName]` with optional pipe metadata */
43
+ const GROUP_RE = /^\[(.+?)\]\s*(.*)$/;
44
+
45
+ /** Dependency: `-> TargetName` with optional pipe metadata */
46
+ const DEPENDENCY_RE = /^->\s*(.+)$/;
47
+
48
+ /** Comment line */
49
+ const COMMENT_RE = /^\/\//;
50
+
51
+ /** Era: `era YYYY[-MM[-DD]] -> YYYY[-MM[-DD]]: Label (color?)` */
52
+ const ERA_RE = /^era\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*->\s*(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s*:\s*(.+)$/i;
53
+
54
+ /** Marker: `marker: YYYY[-MM[-DD]] Label (color?)` */
55
+ const MARKER_RE = /^marker:\s+(\d{4}(?:-\d{2}(?:-\d{2})?)?)\s+(.+)$/i;
56
+
57
+ /** Holiday date: `2024-01-15: Label` */
58
+ const HOLIDAY_DATE_RE = /^(\d{4}-\d{2}-\d{2}):\s*(.+)$/;
59
+
60
+ /** 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*(.+)$/;
62
+
63
+ /** Workweek override: `workweek: sun-thu` */
64
+ const WORKWEEK_RE = /^workweek:\s*(.+)$/i;
65
+
66
+ /** chart: gantt */
67
+ const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
68
+
69
+ /** Option lines */
70
+ const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
71
+
72
+ // Valid weekday names
73
+ const WEEKDAY_MAP: Record<string, Weekday> = {
74
+ mon: 'mon', tue: 'tue', wed: 'wed', thu: 'thu', fri: 'fri', sat: 'sat', sun: 'sun',
75
+ monday: 'mon', tuesday: 'tue', wednesday: 'wed', thursday: 'thu', friday: 'fri', saturday: 'sat', sunday: 'sun',
76
+ };
77
+
78
+ // ── Block Stack ─────────────────────────────────────────────
79
+
80
+ type ContainerType = 'group' | 'parallel' | 'task';
81
+
82
+ interface BlockEntry {
83
+ node: GanttGroup | GanttParallelBlock;
84
+ indent: number;
85
+ containerType: ContainerType;
86
+ }
87
+
88
+ // ── Parser ──────────────────────────────────────────────────
89
+
90
+ export function parseGantt(content: string, palette?: PaletteColors): ParsedGantt {
91
+ const lines = content.split('\n');
92
+ const diagnostics: DgmoError[] = [];
93
+
94
+ const result: ParsedGantt = {
95
+ nodes: [],
96
+ holidays: { dates: [], ranges: [], workweek: ['mon', 'tue', 'wed', 'thu', 'fri'] },
97
+ tagGroups: [],
98
+ eras: [],
99
+ markers: [],
100
+ options: {
101
+ start: null,
102
+ title: null,
103
+ titleLineNumber: null,
104
+ orientation: 'horizontal',
105
+ todayMarker: 'off',
106
+ criticalPath: false,
107
+ dependencies: false,
108
+ sort: 'default',
109
+ defaultSwimlaneGroup: null,
110
+ },
111
+ diagnostics,
112
+ error: null,
113
+ };
114
+
115
+ const fail = (line: number, message: string): ParsedGantt => {
116
+ const diag = makeDgmoError(line, message);
117
+ diagnostics.push(diag);
118
+ result.error = formatDgmoError(diag);
119
+ return result;
120
+ };
121
+
122
+ const warn = (line: number, message: string): void => {
123
+ diagnostics.push(makeDgmoError(line, message, 'warning'));
124
+ };
125
+
126
+ /** Red squiggly but parsing continues — line is wrong, rest of chart is fine */
127
+ const softError = (line: number, message: string): void => {
128
+ diagnostics.push(makeDgmoError(line, message, 'error'));
129
+ };
130
+
131
+ // ── Alias map for pipe metadata ─────────────────────────
132
+
133
+ const aliasMap = new Map<string, string>();
134
+
135
+ // ── Block stack ─────────────────────────────────────────
136
+
137
+ const blockStack: BlockEntry[] = [];
138
+
139
+ const currentContainer = (): GanttNode[] => {
140
+ if (blockStack.length === 0) return result.nodes;
141
+ const top = blockStack[blockStack.length - 1];
142
+ return top.node.children;
143
+ };
144
+
145
+ const currentGroupPath = (): string[] => {
146
+ const path: string[] = [];
147
+ for (const entry of blockStack) {
148
+ if (entry.containerType === 'group') {
149
+ path.push((entry.node as GanttGroup).name);
150
+ }
151
+ }
152
+ return path;
153
+ };
154
+
155
+ // ── State ───────────────────────────────────────────────
156
+
157
+ let seenChartType = false;
158
+ let inHeaderBlock = true; // options must come before content
159
+ let inHolidaysBlock = false;
160
+ let holidaysBlockIndent = 0;
161
+ let inTagBlock = false;
162
+ let currentTagGroup: TagGroup | null = null;
163
+ let tagBlockIndent = 0;
164
+ let lastTaskNode: (GanttNode & { kind: 'task' }) | null = null;
165
+ let taskIdCounter = 0;
166
+ const seriesColors = palette ? getSeriesColors(palette) : [];
167
+
168
+ // ── Main Parse Loop ─────────────────────────────────────
169
+
170
+ for (let i = 0; i < lines.length; i++) {
171
+ const rawLine = lines[i];
172
+ const line = rawLine.trim();
173
+ const indent = measureIndent(rawLine);
174
+ const lineNumber = i + 1;
175
+
176
+ // Skip empty lines
177
+ if (!line) {
178
+ // Empty line ends holidays/tag blocks only if at root indent
179
+ if (inHolidaysBlock && indent <= holidaysBlockIndent) {
180
+ inHolidaysBlock = false;
181
+ }
182
+ if (inTagBlock && indent <= tagBlockIndent) {
183
+ inTagBlock = false;
184
+ if (currentTagGroup) {
185
+ result.tagGroups.push(currentTagGroup);
186
+ currentTagGroup = null;
187
+ }
188
+ }
189
+ continue;
190
+ }
191
+
192
+ // ── Chart type ────────────────────────────────────────
193
+
194
+ const chartTypeMatch = line.match(CHART_TYPE_RE);
195
+ if (chartTypeMatch) {
196
+ const type = chartTypeMatch[1].trim().toLowerCase();
197
+ if (type !== 'gantt') {
198
+ return fail(lineNumber, `Expected chart type "gantt", got "${type}"`);
199
+ }
200
+ seenChartType = true;
201
+ continue;
202
+ }
203
+
204
+ // ── Holidays block ────────────────────────────────────
205
+
206
+ if (inHolidaysBlock) {
207
+ if (indent <= holidaysBlockIndent) {
208
+ inHolidaysBlock = false;
209
+ // fall through to process this line normally
210
+ } else {
211
+ // Parse holiday entries
212
+ const rangeMatch = line.match(HOLIDAY_RANGE_RE);
213
+ if (rangeMatch) {
214
+ result.holidays.ranges.push({
215
+ startDate: rangeMatch[1],
216
+ endDate: rangeMatch[2],
217
+ label: rangeMatch[3].trim(),
218
+ lineNumber,
219
+ });
220
+ continue;
221
+ }
222
+
223
+ const dateMatch = line.match(HOLIDAY_DATE_RE);
224
+ if (dateMatch) {
225
+ result.holidays.dates.push({
226
+ date: dateMatch[1],
227
+ label: dateMatch[2].trim(),
228
+ lineNumber,
229
+ });
230
+ continue;
231
+ }
232
+
233
+ const workweekMatch = line.match(WORKWEEK_RE);
234
+ if (workweekMatch) {
235
+ const days = parseWorkweek(workweekMatch[1].trim());
236
+ if (days) {
237
+ result.holidays.workweek = days;
238
+ } else {
239
+ warn(lineNumber, `Invalid workweek format: "${workweekMatch[1]}". Use day range like "sun-thu" or comma-separated days.`);
240
+ }
241
+ continue;
242
+ }
243
+
244
+ // Skip comments inside holidays
245
+ if (COMMENT_RE.test(line)) continue;
246
+
247
+ warn(lineNumber, `Unrecognized holiday entry: "${line}"`);
248
+ continue;
249
+ }
250
+ }
251
+
252
+ // ── Tag block entries ─────────────────────────────────
253
+
254
+ if (inTagBlock && currentTagGroup) {
255
+ if (indent <= tagBlockIndent) {
256
+ // End of tag block
257
+ inTagBlock = false;
258
+ result.tagGroups.push(currentTagGroup);
259
+ currentTagGroup = null;
260
+ // fall through to process this line normally
261
+ } else {
262
+ // Parse tag entry: `Value(color)` or `Value` with optional `default` suffix
263
+ if (COMMENT_RE.test(line)) continue;
264
+ let entryLine = line;
265
+ let isDefault = false;
266
+ if (entryLine.endsWith(' default') || entryLine.endsWith('\tdefault')) {
267
+ isDefault = true;
268
+ entryLine = entryLine.replace(/\s+default$/, '').trim();
269
+ }
270
+ const extracted = extractColor(entryLine, palette);
271
+ const color = extracted.color || seriesColors[currentTagGroup.entries.length % seriesColors.length] || '#888888';
272
+ currentTagGroup.entries.push({
273
+ value: extracted.label,
274
+ color,
275
+ lineNumber,
276
+ });
277
+ if (isDefault) {
278
+ currentTagGroup.defaultValue = extracted.label;
279
+ }
280
+ continue;
281
+ }
282
+ }
283
+
284
+ // ── Close blocks when indent decreases ────────────────
285
+ // CRITICAL: close blocks BEFORE matching new elements
286
+
287
+ while (blockStack.length > 0) {
288
+ const top = blockStack[blockStack.length - 1];
289
+ if (indent <= top.indent) {
290
+ blockStack.pop();
291
+ lastTaskNode = null;
292
+ } else {
293
+ break;
294
+ }
295
+ }
296
+
297
+ // ── Check if we're inside a task (for deps/comments) ──
298
+
299
+ if (lastTaskNode && indent > 0) {
300
+ // Dependency under a task
301
+ const depMatch = line.match(DEPENDENCY_RE);
302
+ if (depMatch) {
303
+ const depParts = depMatch[1].split('|');
304
+ const targetName = depParts[0].trim();
305
+ let offset: Offset | undefined;
306
+
307
+ if (depParts.length > 1) {
308
+ const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
309
+ if (meta.lag || meta.lead) {
310
+ const key = meta.lag ? 'lag' : 'lead';
311
+ softError(lineNumber, `Unknown keyword "${key}". Use "offset: ${meta[key]}" instead.`);
312
+ }
313
+ if (meta.offset) {
314
+ const raw = meta.offset;
315
+ if (raw.trim().startsWith('+')) {
316
+ warn(lineNumber, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
317
+ } else {
318
+ offset = parseOffset(raw) ?? undefined;
319
+ if (!offset) {
320
+ warn(lineNumber, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
321
+ }
322
+ }
323
+ }
324
+ }
325
+
326
+ lastTaskNode.dependencies.push({
327
+ targetName,
328
+ offset,
329
+ lineNumber,
330
+ });
331
+ continue;
332
+ }
333
+
334
+ // Comment under a task
335
+ if (COMMENT_RE.test(line)) {
336
+ const commentText = line.replace(/^\/\/\s?/, '');
337
+ lastTaskNode.comment = lastTaskNode.comment
338
+ ? lastTaskNode.comment + '\n' + commentText
339
+ : commentText;
340
+ continue;
341
+ }
342
+ }
343
+
344
+ // ── Top-level comment ─────────────────────────────────
345
+
346
+ if (COMMENT_RE.test(line)) continue;
347
+
348
+ // ── Header options ────────────────────────────────────
349
+
350
+ if (line.toLowerCase() === 'holidays') {
351
+ inHolidaysBlock = true;
352
+ holidaysBlockIndent = indent;
353
+ inHeaderBlock = false;
354
+ continue;
355
+ }
356
+
357
+ // Tag block heading
358
+ const tagMatch = matchTagBlockHeading(line);
359
+ if (tagMatch) {
360
+ inTagBlock = true;
361
+ tagBlockIndent = indent;
362
+ inHeaderBlock = false;
363
+ currentTagGroup = {
364
+ name: tagMatch.name,
365
+ alias: tagMatch.alias,
366
+ entries: [],
367
+ lineNumber,
368
+ };
369
+ if (tagMatch.alias) {
370
+ aliasMap.set(tagMatch.alias.toLowerCase(), tagMatch.name.toLowerCase());
371
+ }
372
+ continue;
373
+ }
374
+
375
+ // Era
376
+ const eraMatch = line.match(ERA_RE);
377
+ if (eraMatch) {
378
+ const eraLabelRaw = eraMatch[3].trim();
379
+ const eraExtracted = extractColor(eraLabelRaw, palette);
380
+ result.eras.push({
381
+ startDate: eraMatch[1],
382
+ endDate: eraMatch[2],
383
+ label: eraExtracted.label,
384
+ color: eraExtracted.color || null,
385
+ });
386
+ inHeaderBlock = false;
387
+ continue;
388
+ }
389
+
390
+ // Marker
391
+ const markerMatch = line.match(MARKER_RE);
392
+ if (markerMatch) {
393
+ const markerLabelRaw = markerMatch[2].trim();
394
+ const markerExtracted = extractColor(markerLabelRaw, palette);
395
+ result.markers.push({
396
+ date: markerMatch[1],
397
+ label: markerExtracted.label,
398
+ color: markerExtracted.color || null,
399
+ lineNumber,
400
+ });
401
+ inHeaderBlock = false;
402
+ continue;
403
+ }
404
+
405
+ // Options (start, title, orientation, etc.)
406
+ const optMatch = line.match(OPTION_RE);
407
+ if (optMatch && isKnownOption(optMatch[1].toLowerCase())) {
408
+ const key = optMatch[1].toLowerCase();
409
+ const value = optMatch[2].trim();
410
+
411
+ switch (key) {
412
+ case 'start':
413
+ result.options.start = value;
414
+ break;
415
+ case 'title':
416
+ result.options.title = value;
417
+ result.options.titleLineNumber = lineNumber;
418
+ break;
419
+ case 'orientation':
420
+ if (value === 'horizontal' || value === 'vertical') {
421
+ result.options.orientation = value;
422
+ } else {
423
+ warn(lineNumber, `Invalid orientation: "${value}". Expected "horizontal" or "vertical".`);
424
+ }
425
+ break;
426
+ case 'today-marker':
427
+ if (value === 'on' || value === 'off') {
428
+ result.options.todayMarker = value;
429
+ } else if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
430
+ result.options.todayMarker = value;
431
+ } else {
432
+ warn(lineNumber, `Invalid today-marker value: "${value}". Expected "on", "off", or YYYY-MM-DD.`);
433
+ }
434
+ break;
435
+ case 'critical-path':
436
+ result.options.criticalPath = value === 'on';
437
+ break;
438
+ case 'dependencies':
439
+ result.options.dependencies = value === 'on';
440
+ break;
441
+ case 'sort':
442
+ if (value === 'tag' || value.startsWith('tag:')) {
443
+ result.options.sort = 'tag';
444
+ const colonIdx = value.indexOf(':');
445
+ if (colonIdx !== -1) {
446
+ result.options.defaultSwimlaneGroup = value.slice(colonIdx + 1).trim() || null;
447
+ }
448
+ } else {
449
+ warn(lineNumber, `Invalid sort value: "${value}". Expected "tag" or "tag:GroupName".`);
450
+ }
451
+ break;
452
+ }
453
+ continue;
454
+ }
455
+
456
+ inHeaderBlock = false;
457
+
458
+ // ── Parallel block ────────────────────────────────────
459
+
460
+ if (line === 'parallel') {
461
+ const parallel: GanttParallelBlock = {
462
+ kind: 'parallel',
463
+ lineNumber,
464
+ children: [],
465
+ };
466
+ currentContainer().push(parallel);
467
+ blockStack.push({ node: parallel, indent, containerType: 'parallel' });
468
+ lastTaskNode = null;
469
+ continue;
470
+ }
471
+
472
+ // ── Group container ───────────────────────────────────
473
+
474
+ const groupMatch = line.match(GROUP_RE);
475
+ if (groupMatch) {
476
+ // Validate nesting: group under a task is invalid
477
+ if (blockStack.length > 0 && blockStack[blockStack.length - 1].containerType === 'task') {
478
+ softError(lineNumber, `Cannot nest a group inside a task. Groups must be inside other groups or parallel blocks.`);
479
+ continue;
480
+ }
481
+
482
+ const afterBrackets = groupMatch[2].trim();
483
+ const segments = afterBrackets ? afterBrackets.split('|') : [];
484
+
485
+ // First segment could be empty (just `[Group]`) or have metadata
486
+ let metadata: Record<string, string> = {};
487
+ let color: string | null = null;
488
+
489
+ const pipeWarn = () => warn(lineNumber, MULTIPLE_PIPE_WARNING);
490
+ if (segments.length > 0 && segments[0].trim()) {
491
+ // Check if first segment after brackets is pipe metadata
492
+ metadata = parsePipeMetadata(['', ...segments], aliasMap, pipeWarn);
493
+ } else if (segments.length > 1) {
494
+ metadata = parsePipeMetadata(['', ...segments.slice(1)], aliasMap, pipeWarn);
495
+ }
496
+
497
+ // Extract color from group name if present
498
+ const nameExtracted = extractColor(groupMatch[1], palette);
499
+ if (nameExtracted.color) {
500
+ color = nameExtracted.color;
501
+ }
502
+
503
+ const group: GanttGroup = {
504
+ name: nameExtracted.label,
505
+ color,
506
+ metadata,
507
+ lineNumber,
508
+ children: [],
509
+ };
510
+ const groupNode: GanttNode = { kind: 'group', ...group };
511
+ currentContainer().push(groupNode);
512
+ blockStack.push({
513
+ node: groupNode as GanttGroup,
514
+ indent,
515
+ containerType: 'group',
516
+ });
517
+ lastTaskNode = null;
518
+ continue;
519
+ }
520
+
521
+ // ── Timeline migration syntax: 2024-01-15 -> 30d: Label ─
522
+
523
+ const timelineDurMatch = line.match(TIMELINE_DURATION_RE);
524
+ if (timelineDurMatch) {
525
+ const startDate = timelineDurMatch[1];
526
+ const amount = parseFloat(timelineDurMatch[2]);
527
+ const unit = timelineDurMatch[3] as DurationUnit;
528
+ const uncertain = !!timelineDurMatch[4];
529
+ const labelRaw = timelineDurMatch[5];
530
+
531
+ const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber, startDate);
532
+ const taskNode: GanttNode = { kind: 'task', ...task };
533
+ currentContainer().push(taskNode);
534
+ lastTaskNode = taskNode as GanttNode & { kind: 'task' };
535
+ blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
536
+ continue;
537
+ }
538
+
539
+ // ── Duration task: 30d: Label ─────────────────────────
540
+
541
+ const durMatch = line.match(DURATION_RE);
542
+ if (durMatch) {
543
+ const amount = parseFloat(durMatch[1]);
544
+ const unit = durMatch[2] as DurationUnit;
545
+ const uncertain = !!durMatch[3];
546
+ const labelRaw = durMatch[4];
547
+
548
+ const task = makeTask(labelRaw, { amount, unit }, uncertain, lineNumber);
549
+ const taskNode: GanttNode = { kind: 'task', ...task };
550
+ currentContainer().push(taskNode);
551
+ lastTaskNode = taskNode as GanttNode & { kind: 'task' };
552
+ blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
553
+ continue;
554
+ }
555
+
556
+ // ── Explicit date task: 2024-01-15: Label ─────────────
557
+
558
+ const explicitDateMatch = line.match(EXPLICIT_DATE_RE);
559
+ if (explicitDateMatch) {
560
+ const task = makeTask(
561
+ explicitDateMatch[2],
562
+ null, // no duration — it's a date anchor / milestone
563
+ false,
564
+ lineNumber,
565
+ explicitDateMatch[1],
566
+ );
567
+ // Explicit date tasks with no duration are milestones
568
+ const taskNode: GanttNode = { kind: 'task', ...task };
569
+ currentContainer().push(taskNode);
570
+ lastTaskNode = taskNode as GanttNode & { kind: 'task' };
571
+ blockStack.push({ node: taskNode as unknown as GanttGroup, indent, containerType: 'task' });
572
+ continue;
573
+ }
574
+
575
+ // ── Dependency at root level (under a task context) ───
576
+
577
+ const depMatch = line.match(DEPENDENCY_RE);
578
+ if (depMatch) {
579
+ // Dependency without a task context is an error
580
+ if (!lastTaskNode) {
581
+ softError(lineNumber, `Dependency "-> ${depMatch[1]}" must be indented under a task.`);
582
+ continue;
583
+ }
584
+ // This happens when the dep is at the same indent as the task
585
+ const depParts = depMatch[1].split('|');
586
+ const targetName = depParts[0].trim();
587
+ let offset: Offset | undefined;
588
+
589
+ if (depParts.length > 1) {
590
+ const meta = parsePipeMetadata(['', ...depParts.slice(1)], aliasMap, () => warn(lineNumber, MULTIPLE_PIPE_WARNING));
591
+ if (meta.lag || meta.lead) {
592
+ const key = meta.lag ? 'lag' : 'lead';
593
+ warn(lineNumber, `"${key}" is deprecated — use "offset: ${meta[key]}" instead.${key === 'lead' ? ' Negate the value for lead behavior: "offset: -...".' : ''}`);
594
+ }
595
+ if (meta.offset) {
596
+ const raw = meta.offset;
597
+ if (raw.trim().startsWith('+')) {
598
+ warn(lineNumber, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
599
+ } else {
600
+ offset = parseOffset(raw) ?? undefined;
601
+ if (!offset) {
602
+ warn(lineNumber, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
603
+ }
604
+ }
605
+ }
606
+ }
607
+
608
+ lastTaskNode.dependencies.push({ targetName, offset, lineNumber });
609
+ continue;
610
+ }
611
+
612
+ // ── Bare label = parse error ──────────────────────────
613
+
614
+ softError(lineNumber, `Expected duration (e.g., "10d: Task"), group brackets (e.g., "[Group]"), or keyword. Got: "${line}"`);
615
+ continue;
616
+ }
617
+
618
+ // ── Finalize ────────────────────────────────────────────
619
+
620
+ // Push final tag group if still open
621
+ if (currentTagGroup) {
622
+ result.tagGroups.push(currentTagGroup);
623
+ }
624
+
625
+ // If no chart type was declared, that's acceptable (inferred from context)
626
+
627
+ // Validate sort: tag requires tag groups
628
+ if (result.options.sort === 'tag' && result.tagGroups.length === 0) {
629
+ warn(0, 'sort: tag has no effect — no tag groups defined.');
630
+ result.options.sort = 'default';
631
+ }
632
+
633
+ return result;
634
+
635
+ // ── Helper: create a task ───────────────────────────────
636
+
637
+ function makeTask(
638
+ labelRaw: string,
639
+ duration: Duration | null,
640
+ uncertain: boolean,
641
+ ln: number,
642
+ explicitStart?: string,
643
+ ): GanttTask {
644
+ const segments = labelRaw.split('|');
645
+ const label = segments[0].trim();
646
+
647
+ // Check for reserved keyword
648
+ if (label.toLowerCase() === 'parallel') {
649
+ softError(ln, `"parallel" is a reserved keyword and cannot be used as a task name.`);
650
+ }
651
+
652
+ // Parse pipe metadata
653
+ const metadata = segments.length > 1
654
+ ? parsePipeMetadata(segments, aliasMap, () => warn(ln, MULTIPLE_PIPE_WARNING))
655
+ : {};
656
+
657
+ // Extract progress from metadata or shorthand
658
+ let progress: number | null = null;
659
+ if (metadata.progress) {
660
+ progress = parseFloat(metadata.progress);
661
+ delete metadata.progress;
662
+ }
663
+ // Check for progress shorthand: `| 80%` or `| t:X, 80%`
664
+ for (const part of segments.slice(1).join(',').split(',')) {
665
+ const seg = part.trim();
666
+ const progressMatch = seg.match(/^(\d+)%$/);
667
+ if (progressMatch) {
668
+ progress = parseInt(progressMatch[1], 10);
669
+ }
670
+ }
671
+
672
+ // Reject lag/lead — use offset instead
673
+ if (metadata.lag || metadata.lead) {
674
+ const key = metadata.lag ? 'lag' : 'lead';
675
+ softError(ln, `Unknown keyword "${key}". Use "offset: ${metadata[key]}" instead.`);
676
+ }
677
+
678
+ // Extract task-level offset from metadata
679
+ let taskOffset: Offset | undefined;
680
+ if (metadata.offset) {
681
+ const raw = metadata.offset;
682
+ if (raw.trim().startsWith('+')) {
683
+ warn(ln, `Invalid offset: "${raw}". Explicit "+" is not supported — use "${raw.trim().slice(1)}" instead.`);
684
+ } else {
685
+ taskOffset = parseOffset(raw) ?? undefined;
686
+ if (!taskOffset) {
687
+ warn(ln, `Invalid offset: "${raw}". Expected format like "3bd", "-5d", or "0bd".`);
688
+ }
689
+ }
690
+ delete metadata.offset;
691
+ }
692
+
693
+ // Inherit metadata from parent groups (tag inheritance)
694
+ const groupPath = currentGroupPath();
695
+ const inheritedMeta: Record<string, string> = {};
696
+ for (const entry of blockStack) {
697
+ if (entry.containerType === 'group') {
698
+ const groupNode = entry.node as GanttGroup;
699
+ Object.assign(inheritedMeta, groupNode.metadata);
700
+ }
701
+ // parallel blocks are transparent for tags — skip
702
+ }
703
+ // Task's own metadata overrides inherited
704
+ const effectiveMetadata = { ...inheritedMeta, ...metadata };
705
+
706
+ const id = `task_${taskIdCounter++}`;
707
+
708
+ return {
709
+ id,
710
+ label,
711
+ duration,
712
+ explicitStart,
713
+ uncertain,
714
+ progress,
715
+ offset: taskOffset,
716
+ dependencies: [],
717
+ metadata: effectiveMetadata,
718
+ lineNumber: ln,
719
+ groupPath,
720
+ };
721
+ }
722
+ }
723
+
724
+ // ── Utility: parse workweek string ────────────────────────
725
+
726
+ function parseWorkweek(s: string): Weekday[] | null {
727
+ // Try range format: "sun-thu"
728
+ const rangeParts = s.toLowerCase().split('-');
729
+ if (rangeParts.length === 2) {
730
+ const start = WEEKDAY_MAP[rangeParts[0].trim()];
731
+ const end = WEEKDAY_MAP[rangeParts[1].trim()];
732
+ if (start && end) {
733
+ const allDays: Weekday[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
734
+ const startIdx = allDays.indexOf(start);
735
+ const endIdx = allDays.indexOf(end);
736
+ const days: Weekday[] = [];
737
+ let idx = startIdx;
738
+ while (true) {
739
+ days.push(allDays[idx]);
740
+ if (idx === endIdx) break;
741
+ idx = (idx + 1) % 7;
742
+ }
743
+ return days;
744
+ }
745
+ }
746
+
747
+ // Try comma-separated: "mon, tue, wed, thu, fri"
748
+ const parts = s.toLowerCase().split(',').map(p => p.trim());
749
+ const days: Weekday[] = [];
750
+ for (const part of parts) {
751
+ const day = WEEKDAY_MAP[part];
752
+ if (!day) return null;
753
+ days.push(day);
754
+ }
755
+ return days.length > 0 ? days : null;
756
+ }
757
+
758
+ // ── Known option keys ─────────────────────────────────────
759
+
760
+ const KNOWN_OPTIONS = new Set([
761
+ 'start', 'title', 'orientation', 'today-marker',
762
+ 'critical-path', 'dependencies', 'chart', 'sort',
763
+ ]);
764
+
765
+ function isKnownOption(key: string): boolean {
766
+ return KNOWN_OPTIONS.has(key);
767
+ }