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