yatt-md 0.1.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.
@@ -0,0 +1,13 @@
1
+ export { parse } from './parser.js';
2
+ export { validate } from './validator.js';
3
+ export { schedule } from './scheduler.js';
4
+ export { renderGanttSVG } from './renderer/gantt-svg.js';
5
+ export { renderListHTML } from './renderer/list-html.js';
6
+ export type { GanttOptions } from './renderer/gantt-svg.js';
7
+ export type { ListOptions } from './renderer/list-html.js';
8
+ export type { Status, Priority, DurationUnit, Duration, DateRange, Dependency, Task, Milestone, ParallelBlock, Section, Comment, DocumentItem, YattHeader, YattDocument, ParseError, } from './types.js';
9
+ import type { ParseError } from './types.js';
10
+ export declare function render(source: string, format?: 'gantt' | 'list'): {
11
+ html: string;
12
+ errors: ParseError[];
13
+ };
package/dist/index.js ADDED
@@ -0,0 +1,21 @@
1
+ export { parse } from './parser.js';
2
+ export { validate } from './validator.js';
3
+ export { schedule } from './scheduler.js';
4
+ export { renderGanttSVG } from './renderer/gantt-svg.js';
5
+ export { renderListHTML } from './renderer/list-html.js';
6
+ import { parse } from './parser.js';
7
+ import { validate } from './validator.js';
8
+ import { schedule } from './scheduler.js';
9
+ import { renderGanttSVG } from './renderer/gantt-svg.js';
10
+ import { renderListHTML } from './renderer/list-html.js';
11
+ export function render(source, format) {
12
+ const { doc, errors: parseErrors } = parse(source);
13
+ const validationErrors = validate(doc);
14
+ const allErrors = [...parseErrors, ...validationErrors];
15
+ const scheduled = schedule(doc);
16
+ if (format === 'list') {
17
+ return { html: renderListHTML(scheduled), errors: allErrors };
18
+ }
19
+ // Default: gantt
20
+ return { html: renderGanttSVG(scheduled), errors: allErrors };
21
+ }
@@ -0,0 +1,12 @@
1
+ export type TokenType = 'comment' | 'section' | 'parallel-open' | 'parallel-close' | 'milestone' | 'task' | 'subtask' | 'blank' | 'header-field';
2
+ export interface Token {
3
+ type: TokenType;
4
+ raw: string;
5
+ line: number;
6
+ depth?: number;
7
+ content?: string;
8
+ key?: string;
9
+ value?: string;
10
+ level?: number;
11
+ }
12
+ export declare function tokenize(source: string): Token[];
package/dist/lexer.js ADDED
@@ -0,0 +1,100 @@
1
+ const HEADER_KEYS = new Set([
2
+ 'title', 'owner', 'start', 'schedule', 'timezone', 'week-start',
3
+ ]);
4
+ export function tokenize(source) {
5
+ const lines = source.split(/\r?\n/);
6
+ const tokens = [];
7
+ let headerMode = true;
8
+ for (let i = 0; i < lines.length; i++) {
9
+ const lineNum = i + 1;
10
+ const raw = lines[i];
11
+ const trimmed = raw.trim();
12
+ if (trimmed === '') {
13
+ tokens.push({ type: 'blank', raw, line: lineNum });
14
+ continue;
15
+ }
16
+ // Comment
17
+ if (trimmed.startsWith('//')) {
18
+ tokens.push({ type: 'comment', raw, line: lineNum, content: trimmed.slice(2).trim() });
19
+ continue;
20
+ }
21
+ // Section header (## or #)
22
+ if (trimmed.startsWith('##')) {
23
+ headerMode = false;
24
+ tokens.push({ type: 'section', raw, line: lineNum, level: 2, content: trimmed.slice(2).trim() });
25
+ continue;
26
+ }
27
+ if (trimmed.startsWith('#')) {
28
+ headerMode = false;
29
+ tokens.push({ type: 'section', raw, line: lineNum, level: 1, content: trimmed.slice(1).trim() });
30
+ continue;
31
+ }
32
+ // Parallel open: "parallel: name" or "parallel:"
33
+ if (/^parallel\s*:/i.test(trimmed)) {
34
+ headerMode = false;
35
+ const rest = trimmed.replace(/^parallel\s*:\s*/i, '');
36
+ tokens.push({ type: 'parallel-open', raw, line: lineNum, content: rest });
37
+ continue;
38
+ }
39
+ // Parallel close: "end: name" or "end:"
40
+ if (/^end\s*:/i.test(trimmed)) {
41
+ headerMode = false;
42
+ const rest = trimmed.replace(/^end\s*:\s*/i, '');
43
+ tokens.push({ type: 'parallel-close', raw, line: lineNum, content: rest });
44
+ continue;
45
+ }
46
+ // Milestone: ">> text | fields"
47
+ if (trimmed.startsWith('>>')) {
48
+ headerMode = false;
49
+ tokens.push({ type: 'milestone', raw, line: lineNum, content: trimmed.slice(2).trim() });
50
+ continue;
51
+ }
52
+ // Subtask level 3: "... [status] text"
53
+ if (trimmed.startsWith('...')) {
54
+ headerMode = false;
55
+ tokens.push({ type: 'subtask', raw, line: lineNum, depth: 3, content: trimmed.slice(3).trim() });
56
+ continue;
57
+ }
58
+ // Subtask level 2: ".. [status] text"
59
+ if (trimmed.startsWith('..') && !trimmed.startsWith('...')) {
60
+ headerMode = false;
61
+ tokens.push({ type: 'subtask', raw, line: lineNum, depth: 2, content: trimmed.slice(2).trim() });
62
+ continue;
63
+ }
64
+ // Subtask level 1: ". [status] text"
65
+ if (trimmed.startsWith('.') && !trimmed.startsWith('..')) {
66
+ headerMode = false;
67
+ tokens.push({ type: 'subtask', raw, line: lineNum, depth: 1, content: trimmed.slice(1).trim() });
68
+ continue;
69
+ }
70
+ // Task: "[status] text | fields"
71
+ if (trimmed.startsWith('[')) {
72
+ headerMode = false;
73
+ tokens.push({ type: 'task', raw, line: lineNum, content: trimmed });
74
+ continue;
75
+ }
76
+ // Header field: "key: value" lines before any non-header content
77
+ if (headerMode) {
78
+ const headerMatch = trimmed.match(/^([\w-]+)\s*:\s*(.*)$/);
79
+ if (headerMatch && HEADER_KEYS.has(headerMatch[1].toLowerCase())) {
80
+ tokens.push({
81
+ type: 'header-field',
82
+ raw,
83
+ line: lineNum,
84
+ key: headerMatch[1].toLowerCase(),
85
+ value: headerMatch[2].trim(),
86
+ });
87
+ continue;
88
+ }
89
+ }
90
+ // Plain text line or markdown list item — treat as task with 'new' status
91
+ headerMode = false;
92
+ let plainContent = trimmed;
93
+ const listMarkerMatch = trimmed.match(/^(?:[-*]\s+|\d+[.)]\s+)/);
94
+ if (listMarkerMatch) {
95
+ plainContent = trimmed.slice(listMarkerMatch[0].length);
96
+ }
97
+ tokens.push({ type: 'task', raw, line: lineNum, content: plainContent });
98
+ }
99
+ return tokens;
100
+ }
@@ -0,0 +1,5 @@
1
+ import { YattDocument, ParseError } from './types.js';
2
+ export declare function parse(source: string): {
3
+ doc: YattDocument;
4
+ errors: ParseError[];
5
+ };
package/dist/parser.js ADDED
@@ -0,0 +1,473 @@
1
+ import { tokenize } from './lexer.js';
2
+ // ── Status maps ──────────────────────────────────────────────────────────────
3
+ const SIGIL_TO_STATUS = {
4
+ ' ': 'new',
5
+ '~': 'active',
6
+ 'x': 'done',
7
+ '!': 'blocked',
8
+ '?': 'at-risk',
9
+ '>': 'deferred',
10
+ '_': 'cancelled',
11
+ '-': 'cancelled',
12
+ '=': 'review',
13
+ 'o': 'paused',
14
+ };
15
+ const WORD_TO_STATUS = {
16
+ new: 'new',
17
+ active: 'active',
18
+ done: 'done',
19
+ blocked: 'blocked',
20
+ 'at-risk': 'at-risk',
21
+ deferred: 'deferred',
22
+ cancelled: 'cancelled',
23
+ review: 'review',
24
+ paused: 'paused',
25
+ };
26
+ // ── Regex patterns ────────────────────────────────────────────────────────────
27
+ const RE_DURATION = /^(\d+(?:\.\d+)?)(h|bd|d|w|m|q)$/;
28
+ const RE_ASSIGNEE = /^@[\w-]+/g;
29
+ const RE_PRIORITY = /^!(high|critical|low|normal)$/;
30
+ const RE_PROGRESS = /^%(\d+)$/;
31
+ const RE_ID = /^id:([\w-]+)$/;
32
+ const RE_HASH_ID = /^#([\w-]+)$/;
33
+ const RE_AFTER = /^after:([\w|,-]+)$/;
34
+ const RE_MODIFIER = /^\+[\w-]+(:\S+)?$/; // +key or +key:value e.g. +delayed:1w
35
+ const RE_START_DATE = /^>(\d{4}-\d{2}-\d{2})$/;
36
+ const RE_DUE_DATE = /^<(\d{4}-\d{2}-\d{2})$/;
37
+ const RE_RECURRENCE = /^\*(daily|weekday|weekly|biweekly|monthly|quarterly|yearly)$/;
38
+ const RE_EXTERNAL_REF = /^\$[\w-]+$/;
39
+ // Keywords that can appear as bare modifiers (without + prefix) in pipe fields
40
+ const MODIFIER_KEYWORDS = new Set([
41
+ 'deadline', 'fixed', 'external', 'waiting', 'at-risk', 'blocked',
42
+ 'critical', 'tentative', 'recurring', 'milestone', 'delayed', 'hard-block',
43
+ ]);
44
+ // ── Status parsing ────────────────────────────────────────────────────────────
45
+ function parseStatus(content) {
46
+ // Word form: [new], [active], etc.
47
+ const wordMatch = content.match(/^\[([\w-]+)\]\s*/);
48
+ if (wordMatch) {
49
+ const word = wordMatch[1].toLowerCase();
50
+ if (WORD_TO_STATUS[word]) {
51
+ return { status: WORD_TO_STATUS[word], rest: content.slice(wordMatch[0].length) };
52
+ }
53
+ }
54
+ // Sigil form: [ ], [~], [x], etc.
55
+ const sigilMatch = content.match(/^\[(.)\]\s*/);
56
+ if (sigilMatch) {
57
+ const sigil = sigilMatch[1];
58
+ if (SIGIL_TO_STATUS[sigil] !== undefined) {
59
+ return { status: SIGIL_TO_STATUS[sigil], rest: content.slice(sigilMatch[0].length) };
60
+ }
61
+ }
62
+ return null;
63
+ }
64
+ function parseFields(segments) {
65
+ const result = { assignees: [], tags: [], after: [], modifiers: [] };
66
+ for (const seg of segments) {
67
+ const s = seg.trim();
68
+ if (!s)
69
+ continue;
70
+ // Duration
71
+ const durMatch = s.match(RE_DURATION);
72
+ if (durMatch && !result.duration) {
73
+ result.duration = { value: parseFloat(durMatch[1]), unit: durMatch[2] };
74
+ continue;
75
+ }
76
+ // Start date
77
+ const startMatch = s.match(RE_START_DATE);
78
+ if (startMatch) {
79
+ result.startDate = startMatch[1];
80
+ continue;
81
+ }
82
+ // Due date
83
+ const dueMatch = s.match(RE_DUE_DATE);
84
+ if (dueMatch) {
85
+ result.dueDate = dueMatch[1];
86
+ continue;
87
+ }
88
+ // ID
89
+ const idMatch = s.match(RE_ID);
90
+ if (idMatch) {
91
+ result.id = idMatch[1];
92
+ continue;
93
+ }
94
+ // After dependency
95
+ const afterMatch = s.match(RE_AFTER);
96
+ if (afterMatch) {
97
+ const raw = afterMatch[1];
98
+ if (raw.includes('|')) {
99
+ result.after.push({ ids: raw.split('|').filter(Boolean), logic: 'or' });
100
+ }
101
+ else {
102
+ result.after.push({ ids: raw.split(',').filter(Boolean), logic: 'and' });
103
+ }
104
+ continue;
105
+ }
106
+ // Priority
107
+ const prioMatch = s.match(RE_PRIORITY);
108
+ if (prioMatch) {
109
+ result.priority = prioMatch[1];
110
+ continue;
111
+ }
112
+ // Progress
113
+ const progressMatch = s.match(RE_PROGRESS);
114
+ if (progressMatch) {
115
+ result.progress = Math.min(100, Math.max(0, parseInt(progressMatch[1], 10)));
116
+ continue;
117
+ }
118
+ // Recurrence
119
+ const recurMatch = s.match(RE_RECURRENCE);
120
+ if (recurMatch) {
121
+ result.recurrence = recurMatch[1];
122
+ continue;
123
+ }
124
+ // Time-shift modifiers: "delayed 3d" or "blocked 2w" (canonical space-separated form)
125
+ const shiftMatch = s.match(/^(delayed|blocked)\s+(\S+)$/);
126
+ if (shiftMatch) {
127
+ result.modifiers.push(shiftMatch[1] + ':' + shiftMatch[2]);
128
+ continue;
129
+ }
130
+ // Modifier with + prefix (legacy / boolean modifiers: +deadline, +fixed, +delayed:3d, etc.)
131
+ if (RE_MODIFIER.test(s)) {
132
+ result.modifiers.push(s.slice(1));
133
+ continue;
134
+ }
135
+ // Bare modifier keyword (without + prefix): e.g. blocked, delayed:1w
136
+ const bareModMatch = s.match(/^([\w-]+)(:\S+)?$/);
137
+ if (bareModMatch && MODIFIER_KEYWORDS.has(bareModMatch[1])) {
138
+ result.modifiers.push(s);
139
+ continue;
140
+ }
141
+ // External ref
142
+ if (RE_EXTERNAL_REF.test(s)) {
143
+ result.externalRef = s;
144
+ continue;
145
+ }
146
+ // Assignees (may have multiple @mentions in one segment)
147
+ const assigneeMatches = s.match(/(@[\w-]+)/g);
148
+ if (assigneeMatches && s.replace(/(@[\w-]+)/g, '').trim() === '') {
149
+ result.assignees.push(...assigneeMatches.map(a => a.slice(1)));
150
+ continue;
151
+ }
152
+ // Hash ID shortcut: #slug = id:slug
153
+ const hashIdMatch = s.match(RE_HASH_ID);
154
+ if (hashIdMatch) {
155
+ result.id = hashIdMatch[1];
156
+ continue;
157
+ }
158
+ }
159
+ return result;
160
+ }
161
+ // Split content on '|' but re-join segments that belong to an after:id1|id2 OR dep.
162
+ // An after: segment followed by a token that looks like a bare id (no keyword prefix)
163
+ // is part of an OR dependency list.
164
+ function splitPipeFields(content) {
165
+ const raw = content.split('|');
166
+ const merged = [];
167
+ for (let i = 0; i < raw.length; i++) {
168
+ const seg = raw[i].trim();
169
+ // If the previous merged segment ends with an after: chain, a bare id continues it
170
+ if (merged.length > 0 &&
171
+ /^after:/.test(merged[merged.length - 1].trim()) &&
172
+ /^[\w-]+$/.test(seg)) {
173
+ merged[merged.length - 1] += '|' + seg;
174
+ }
175
+ else {
176
+ merged.push(raw[i]);
177
+ }
178
+ }
179
+ return merged;
180
+ }
181
+ // Parse "name | field | field ..." returning name and fields
182
+ function parseNameAndFields(content) {
183
+ const parts = splitPipeFields(content);
184
+ const name = (parts[0] ?? '').trim();
185
+ const fields = parseFields(parts.slice(1));
186
+ return { name, fields };
187
+ }
188
+ // ── Task / Milestone constructors ─────────────────────────────────────────────
189
+ function buildTask(content, line, depth = 0) {
190
+ const statusResult = parseStatus(content);
191
+ const status = statusResult?.status ?? 'new';
192
+ const rest = statusResult?.rest ?? content;
193
+ const { name, fields } = parseNameAndFields(rest);
194
+ const task = {
195
+ type: 'task',
196
+ status,
197
+ name,
198
+ assignees: fields.assignees,
199
+ tags: fields.tags,
200
+ after: fields.after,
201
+ modifiers: fields.modifiers,
202
+ subtasks: [],
203
+ line,
204
+ };
205
+ if (fields.id)
206
+ task.id = fields.id;
207
+ if (fields.priority)
208
+ task.priority = fields.priority;
209
+ if (fields.progress !== undefined)
210
+ task.progress = fields.progress;
211
+ if (fields.duration)
212
+ task.duration = fields.duration;
213
+ if (fields.startDate)
214
+ task.startDate = fields.startDate;
215
+ if (fields.dueDate)
216
+ task.dueDate = fields.dueDate;
217
+ if (fields.recurrence)
218
+ task.recurrence = fields.recurrence;
219
+ if (fields.externalRef)
220
+ task.externalRef = fields.externalRef;
221
+ return task;
222
+ }
223
+ function buildMilestone(content, line) {
224
+ const { name, fields } = parseNameAndFields(content);
225
+ const ms = {
226
+ type: 'milestone',
227
+ name,
228
+ after: fields.after,
229
+ modifiers: fields.modifiers,
230
+ line,
231
+ };
232
+ if (fields.id)
233
+ ms.id = fields.id;
234
+ if (fields.startDate)
235
+ ms.date = fields.startDate;
236
+ if (fields.dueDate)
237
+ ms.date = fields.dueDate;
238
+ return ms;
239
+ }
240
+ // ── Main parser ───────────────────────────────────────────────────────────────
241
+ export function parse(source) {
242
+ const tokens = tokenize(source);
243
+ const errors = [];
244
+ const header = {};
245
+ const items = [];
246
+ const idMap = new Map();
247
+ function registerId(id, item, line) {
248
+ if (idMap.has(id)) {
249
+ errors.push({ message: `Duplicate ID: "${id}"`, line, severity: 'error' });
250
+ }
251
+ else {
252
+ idMap.set(id, item);
253
+ }
254
+ }
255
+ // ── Pass 1: Build AST ──────────────────────────────────────────────────────
256
+ let i = 0;
257
+ // Collect header fields first
258
+ while (i < tokens.length && tokens[i].type === 'header-field') {
259
+ const tok = tokens[i++];
260
+ switch (tok.key) {
261
+ case 'title':
262
+ header.title = tok.value;
263
+ break;
264
+ case 'owner':
265
+ header.owner = tok.value;
266
+ break;
267
+ case 'start':
268
+ header.start = tok.value;
269
+ break;
270
+ case 'schedule':
271
+ if (tok.value === 'business-days' || tok.value === 'calendar-days') {
272
+ header.schedule = tok.value;
273
+ }
274
+ break;
275
+ case 'timezone':
276
+ header.timezone = tok.value;
277
+ break;
278
+ case 'week-start':
279
+ if (tok.value === 'mon' || tok.value === 'sun')
280
+ header.weekStart = tok.value;
281
+ break;
282
+ }
283
+ }
284
+ // Parse body tokens
285
+ // Parallel blocks can nest (though spec shows single-level usage)
286
+ const parallelStack = [];
287
+ function currentContainer() {
288
+ if (parallelStack.length > 0) {
289
+ return parallelStack[parallelStack.length - 1];
290
+ }
291
+ return { items };
292
+ }
293
+ // Track last task at each depth for subtask attachment
294
+ // depth 0 = top-level task, 1 = first subtask level, etc.
295
+ const taskStack = [null, null, null, null];
296
+ function resetTaskStack() {
297
+ taskStack[0] = taskStack[1] = taskStack[2] = taskStack[3] = null;
298
+ }
299
+ while (i < tokens.length) {
300
+ const tok = tokens[i++];
301
+ if (tok.type === 'blank' || tok.type === 'header-field')
302
+ continue;
303
+ if (tok.type === 'comment') {
304
+ const comment = { type: 'comment', text: tok.content ?? '', line: tok.line };
305
+ currentContainer().items.push(comment);
306
+ continue;
307
+ }
308
+ if (tok.type === 'section') {
309
+ resetTaskStack();
310
+ const section = {
311
+ type: 'section',
312
+ title: tok.content ?? '',
313
+ level: (tok.level === 2 ? 2 : 1),
314
+ line: tok.line,
315
+ };
316
+ currentContainer().items.push(section);
317
+ continue;
318
+ }
319
+ if (tok.type === 'parallel-open') {
320
+ resetTaskStack();
321
+ // content may be "name | field | field" or just "name" or empty
322
+ const content = tok.content ?? '';
323
+ const pipeIdx = content.indexOf('|');
324
+ let blockName;
325
+ let blockFields;
326
+ if (pipeIdx !== -1) {
327
+ blockName = content.slice(0, pipeIdx).trim() || undefined;
328
+ blockFields = parseFields(content.slice(pipeIdx + 1).split('|'));
329
+ }
330
+ else {
331
+ blockName = content.trim() || undefined;
332
+ blockFields = parseFields([]);
333
+ }
334
+ const block = {
335
+ type: 'parallel',
336
+ name: blockName,
337
+ id: blockName ?? undefined,
338
+ after: blockFields.after,
339
+ items: [],
340
+ line: tok.line,
341
+ };
342
+ if (block.id)
343
+ registerId(block.id, block, tok.line);
344
+ currentContainer().items.push(block);
345
+ parallelStack.push(block);
346
+ continue;
347
+ }
348
+ if (tok.type === 'parallel-close') {
349
+ resetTaskStack();
350
+ if (parallelStack.length === 0) {
351
+ errors.push({ message: 'Unexpected end: without matching parallel:', line: tok.line, severity: 'error' });
352
+ }
353
+ else {
354
+ parallelStack.pop();
355
+ }
356
+ continue;
357
+ }
358
+ if (tok.type === 'milestone') {
359
+ resetTaskStack();
360
+ const ms = buildMilestone(tok.content ?? '', tok.line);
361
+ if (ms.id)
362
+ registerId(ms.id, ms, tok.line);
363
+ const descLines = [];
364
+ while (i < tokens.length && tokens[i].type === 'comment') {
365
+ descLines.push(tokens[i].content ?? '');
366
+ i++;
367
+ }
368
+ if (descLines.length > 0)
369
+ ms.description = descLines.join('\n');
370
+ currentContainer().items.push(ms);
371
+ continue;
372
+ }
373
+ if (tok.type === 'task') {
374
+ const task = buildTask(tok.content ?? '', tok.line, 0);
375
+ if (task.id)
376
+ registerId(task.id, task, tok.line);
377
+ const descLines = [];
378
+ while (i < tokens.length && tokens[i].type === 'comment') {
379
+ descLines.push(tokens[i].content ?? '');
380
+ i++;
381
+ }
382
+ if (descLines.length > 0)
383
+ task.description = descLines.join('\n');
384
+ currentContainer().items.push(task);
385
+ taskStack[0] = task;
386
+ taskStack[1] = taskStack[2] = taskStack[3] = null;
387
+ continue;
388
+ }
389
+ if (tok.type === 'subtask') {
390
+ const depth = tok.depth ?? 1;
391
+ const task = buildTask(tok.content ?? '', tok.line, depth);
392
+ if (task.id)
393
+ registerId(task.id, task, tok.line);
394
+ const descLines = [];
395
+ while (i < tokens.length && tokens[i].type === 'comment') {
396
+ descLines.push(tokens[i].content ?? '');
397
+ i++;
398
+ }
399
+ if (descLines.length > 0)
400
+ task.description = descLines.join('\n');
401
+ taskStack[depth] = task;
402
+ // Clear deeper levels
403
+ for (let d = depth + 1; d <= 3; d++)
404
+ taskStack[d] = null;
405
+ // Find parent
406
+ let attached = false;
407
+ for (let pd = depth - 1; pd >= 0; pd--) {
408
+ if (taskStack[pd]) {
409
+ taskStack[pd].subtasks.push(task);
410
+ attached = true;
411
+ break;
412
+ }
413
+ }
414
+ if (!attached) {
415
+ // No parent task found — attach to current container as top-level
416
+ currentContainer().items.push(task);
417
+ }
418
+ continue;
419
+ }
420
+ }
421
+ if (parallelStack.length > 0) {
422
+ errors.push({
423
+ message: `Unclosed parallel block: "${parallelStack[parallelStack.length - 1].name ?? '(anonymous)'}"`,
424
+ line: parallelStack[parallelStack.length - 1].line,
425
+ severity: 'error',
426
+ });
427
+ }
428
+ const doc = { header, items, idMap };
429
+ // ── Pass 2: Resolve after: references ────────────────────────────────────
430
+ function resolveItem(item) {
431
+ for (const dep of item.after) {
432
+ for (const id of dep.ids) {
433
+ if (!idMap.has(id)) {
434
+ errors.push({
435
+ message: `Unknown dependency ID: "${id}"`,
436
+ line: item.line,
437
+ severity: 'error',
438
+ });
439
+ }
440
+ }
441
+ }
442
+ if (item.type === 'task') {
443
+ for (const sub of item.subtasks)
444
+ resolveItem(sub);
445
+ }
446
+ }
447
+ function resolveBlock(block) {
448
+ for (const dep of block.after) {
449
+ for (const id of dep.ids) {
450
+ if (!idMap.has(id)) {
451
+ errors.push({
452
+ message: `Unknown dependency ID: "${id}" in parallel block`,
453
+ line: block.line,
454
+ severity: 'error',
455
+ });
456
+ }
457
+ }
458
+ }
459
+ for (const it of block.items) {
460
+ if (it.type === 'task' || it.type === 'milestone')
461
+ resolveItem(it);
462
+ else if (it.type === 'parallel')
463
+ resolveBlock(it);
464
+ }
465
+ }
466
+ for (const item of items) {
467
+ if (item.type === 'task' || item.type === 'milestone')
468
+ resolveItem(item);
469
+ else if (item.type === 'parallel')
470
+ resolveBlock(item);
471
+ }
472
+ return { doc, errors };
473
+ }
@@ -0,0 +1,10 @@
1
+ import { YattDocument } from '../types.js';
2
+ export interface GanttOptions {
3
+ width?: number;
4
+ rowHeight?: number;
5
+ headerHeight?: number;
6
+ padding?: number;
7
+ fontFamily?: string;
8
+ theme?: 'light' | 'dark';
9
+ }
10
+ export declare function renderGanttSVG(doc: YattDocument, options?: GanttOptions): string;