@diagrammo/dgmo 0.8.20 → 0.8.22

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 (110) hide show
  1. package/AGENTS.md +2 -1
  2. package/README.md +1 -0
  3. package/dist/cli.cjs +142 -90
  4. package/dist/editor.cjs +30 -4
  5. package/dist/editor.cjs.map +1 -1
  6. package/dist/editor.js +30 -4
  7. package/dist/editor.js.map +1 -1
  8. package/dist/highlight.cjs +25 -3
  9. package/dist/highlight.cjs.map +1 -1
  10. package/dist/highlight.js +25 -3
  11. package/dist/highlight.js.map +1 -1
  12. package/dist/index.cjs +21201 -12886
  13. package/dist/index.cjs.map +1 -1
  14. package/dist/index.d.cts +646 -89
  15. package/dist/index.d.ts +646 -89
  16. package/dist/index.js +21178 -12889
  17. package/dist/index.js.map +1 -1
  18. package/docs/guide/chart-mindmap.md +198 -0
  19. package/docs/guide/chart-sequence.md +23 -1
  20. package/docs/guide/chart-sitemap.md +18 -1
  21. package/docs/guide/chart-tech-radar.md +219 -0
  22. package/docs/guide/chart-wireframe.md +100 -0
  23. package/docs/guide/index.md +8 -0
  24. package/docs/guide/registry.json +1 -0
  25. package/docs/language-reference.md +249 -4
  26. package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
  27. package/gallery/fixtures/c4-full.dgmo +2 -2
  28. package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
  29. package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
  30. package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
  31. package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
  32. package/gallery/fixtures/gantt-full.dgmo +2 -2
  33. package/gallery/fixtures/gantt.dgmo +2 -2
  34. package/gallery/fixtures/infra-full.dgmo +2 -2
  35. package/gallery/fixtures/infra.dgmo +1 -1
  36. package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
  37. package/gallery/fixtures/sequence-tags.dgmo +2 -2
  38. package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
  39. package/gallery/fixtures/tech-radar.dgmo +36 -0
  40. package/gallery/fixtures/timeline.dgmo +1 -1
  41. package/package.json +1 -1
  42. package/src/boxes-and-lines/collapse.ts +21 -3
  43. package/src/boxes-and-lines/layout.ts +360 -42
  44. package/src/boxes-and-lines/parser.ts +94 -11
  45. package/src/boxes-and-lines/renderer.ts +371 -114
  46. package/src/boxes-and-lines/types.ts +2 -1
  47. package/src/c4/layout.ts +8 -8
  48. package/src/c4/parser.ts +35 -2
  49. package/src/c4/renderer.ts +19 -3
  50. package/src/c4/types.ts +1 -0
  51. package/src/chart.ts +14 -7
  52. package/src/completion.ts +253 -0
  53. package/src/cycle/layout.ts +732 -0
  54. package/src/cycle/parser.ts +352 -0
  55. package/src/cycle/renderer.ts +539 -0
  56. package/src/cycle/types.ts +77 -0
  57. package/src/d3.ts +240 -40
  58. package/src/dgmo-router.ts +15 -0
  59. package/src/echarts.ts +7 -4
  60. package/src/editor/dgmo.grammar +5 -1
  61. package/src/editor/dgmo.grammar.js +1 -1
  62. package/src/editor/keywords.ts +26 -0
  63. package/src/gantt/parser.ts +2 -8
  64. package/src/graph/flowchart-parser.ts +15 -21
  65. package/src/graph/layout.ts +73 -9
  66. package/src/graph/state-collapse.ts +78 -0
  67. package/src/graph/state-parser.ts +5 -10
  68. package/src/graph/state-renderer.ts +139 -34
  69. package/src/index.ts +78 -0
  70. package/src/infra/layout.ts +218 -74
  71. package/src/infra/parser.ts +30 -6
  72. package/src/infra/renderer.ts +14 -8
  73. package/src/infra/types.ts +10 -3
  74. package/src/journey-map/layout.ts +386 -0
  75. package/src/journey-map/parser.ts +540 -0
  76. package/src/journey-map/renderer.ts +1456 -0
  77. package/src/journey-map/types.ts +47 -0
  78. package/src/kanban/parser.ts +3 -10
  79. package/src/kanban/renderer.ts +325 -63
  80. package/src/mindmap/collapse.ts +88 -0
  81. package/src/mindmap/layout.ts +605 -0
  82. package/src/mindmap/parser.ts +373 -0
  83. package/src/mindmap/renderer.ts +544 -0
  84. package/src/mindmap/text-wrap.ts +217 -0
  85. package/src/mindmap/types.ts +55 -0
  86. package/src/org/parser.ts +2 -6
  87. package/src/render.ts +18 -21
  88. package/src/sequence/renderer.ts +273 -56
  89. package/src/sharing.ts +3 -0
  90. package/src/sitemap/layout.ts +56 -18
  91. package/src/sitemap/parser.ts +26 -17
  92. package/src/sitemap/renderer.ts +34 -0
  93. package/src/sitemap/types.ts +1 -0
  94. package/src/tech-radar/index.ts +14 -0
  95. package/src/tech-radar/interactive.ts +1058 -0
  96. package/src/tech-radar/layout.ts +190 -0
  97. package/src/tech-radar/parser.ts +385 -0
  98. package/src/tech-radar/renderer.ts +1159 -0
  99. package/src/tech-radar/shared.ts +187 -0
  100. package/src/tech-radar/types.ts +81 -0
  101. package/src/utils/description-helpers.ts +33 -0
  102. package/src/utils/export-container.ts +3 -2
  103. package/src/utils/legend-d3.ts +1 -0
  104. package/src/utils/legend-layout.ts +5 -3
  105. package/src/utils/parsing.ts +48 -7
  106. package/src/utils/tag-groups.ts +46 -60
  107. package/src/wireframe/layout.ts +460 -0
  108. package/src/wireframe/parser.ts +956 -0
  109. package/src/wireframe/renderer.ts +1293 -0
  110. package/src/wireframe/types.ts +110 -0
@@ -0,0 +1,540 @@
1
+ import type { PaletteColors } from '../palettes';
2
+ import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
3
+ import {
4
+ matchTagBlockHeading,
5
+ stripDefaultModifier,
6
+ validateTagGroupNames,
7
+ } from '../utils/tag-groups';
8
+ import {
9
+ measureIndent,
10
+ extractColor,
11
+ parseFirstLine,
12
+ OPTION_NOCOLON_RE,
13
+ } from '../utils/parsing';
14
+ import { tryStripDescriptionKeyword } from '../utils/description-helpers';
15
+ import type {
16
+ ParsedJourneyMap,
17
+ JourneyMapPhase,
18
+ JourneyMapStep,
19
+ JourneyMapAnnotation,
20
+ } from './types';
21
+ import type { TagGroup } from '../utils/tag-groups';
22
+
23
+ // ============================================================
24
+ // Regex patterns
25
+ // ============================================================
26
+
27
+ const PHASE_RE = /^\[(.+?)\]$/;
28
+ const SCORE_RE = /^(\d+(?:\.\d+)?)(?:\s+([A-Za-z]\w*))?$/;
29
+ const ANNOTATION_RE = /^(pain|opportunity|thought)\s*:\s*(.+)$/i;
30
+
31
+ /** Known journey-map options (key-value). */
32
+ const KNOWN_OPTIONS = new Set(['active-tag']);
33
+ /** Known journey-map boolean options (bare keyword = on). */
34
+ const KNOWN_BOOLEANS = new Set(['no-legend']);
35
+
36
+ // ============================================================
37
+ // Parser
38
+ // ============================================================
39
+
40
+ export function parseJourneyMap(
41
+ content: string,
42
+ palette?: PaletteColors
43
+ ): ParsedJourneyMap {
44
+ const result: ParsedJourneyMap = {
45
+ type: 'journey-map',
46
+ phases: [],
47
+ steps: [],
48
+ tagGroups: [],
49
+ options: {},
50
+ diagnostics: [],
51
+ error: null,
52
+ };
53
+
54
+ const fail = (line: number, message: string): ParsedJourneyMap => {
55
+ const diag = makeDgmoError(line, message);
56
+ result.diagnostics.push(diag);
57
+ result.error = formatDgmoError(diag);
58
+ return result;
59
+ };
60
+
61
+ const warn = (line: number, message: string): void => {
62
+ result.diagnostics.push(makeDgmoError(line, message, 'warning'));
63
+ };
64
+
65
+ if (!content || !content.trim()) {
66
+ return fail(0, 'No content provided');
67
+ }
68
+
69
+ const lines = content.split('\n');
70
+ let contentStarted = false;
71
+ let currentTagGroup: TagGroup | null = null;
72
+ let inPersona = false;
73
+ let currentPhase: JourneyMapPhase | null = null;
74
+ let currentStep: JourneyMapStep | null = null;
75
+ let stepBaseIndent = 0;
76
+ let phaseCounter = 0;
77
+ let stepCounter = 0;
78
+ let hasPhases = false;
79
+
80
+ const aliasMap = new Map<string, string>();
81
+ const tagValueSets = new Map<string, Set<string>>();
82
+
83
+ for (let i = 0; i < lines.length; i++) {
84
+ const line = lines[i];
85
+ const lineNumber = i + 1;
86
+ const trimmed = line.trim();
87
+
88
+ // Skip empty lines
89
+ if (!trimmed) {
90
+ if (currentTagGroup) currentTagGroup = null;
91
+ if (inPersona) inPersona = false;
92
+ continue;
93
+ }
94
+
95
+ // Skip comments
96
+ if (trimmed.startsWith('//')) continue;
97
+
98
+ const indent = measureIndent(line);
99
+
100
+ // --- Header phase ---
101
+
102
+ // Extract chart type + title from first line
103
+ if (!contentStarted && !currentTagGroup && !inPersona) {
104
+ const firstLine = parseFirstLine(trimmed);
105
+ if (firstLine) {
106
+ if (firstLine.chartType !== 'journey-map') {
107
+ const allTypes = ['journey-map', 'kanban', 'sequence', 'flowchart'];
108
+ let msg = `Expected chart type "journey-map", got "${firstLine.chartType}"`;
109
+ const hint = suggest(firstLine.chartType, allTypes);
110
+ if (hint) msg += `. ${hint}`;
111
+ return fail(lineNumber, msg);
112
+ }
113
+ if (firstLine.title) {
114
+ result.title = firstLine.title;
115
+ result.titleLineNumber = lineNumber;
116
+ }
117
+ continue;
118
+ }
119
+ }
120
+
121
+ // Persona keyword
122
+ if (!contentStarted && !currentTagGroup && indent === 0) {
123
+ const personaMatch = trimmed.match(/^persona\s+(.+)$/i);
124
+ if (personaMatch) {
125
+ const afterKeyword = personaMatch[1].trim();
126
+ const pipeIdx = afterKeyword.indexOf('|');
127
+ let personaName: string;
128
+ let personaColor: string | undefined;
129
+
130
+ if (pipeIdx >= 0) {
131
+ personaName = afterKeyword.substring(0, pipeIdx).trim();
132
+ const metaStr = afterKeyword.substring(pipeIdx + 1).trim();
133
+ // Parse comma-separated key: value pairs
134
+ for (const part of metaStr.split(',')) {
135
+ const colonIdx = part.indexOf(':');
136
+ if (colonIdx > 0) {
137
+ const key = part.substring(0, colonIdx).trim().toLowerCase();
138
+ const value = part.substring(colonIdx + 1).trim();
139
+ if (key === 'color') {
140
+ const resolved = extractColor(`x(${value})`, palette);
141
+ personaColor = resolved.color;
142
+ }
143
+ }
144
+ }
145
+ } else {
146
+ personaName = afterKeyword;
147
+ }
148
+
149
+ if (!personaName) {
150
+ return fail(lineNumber, 'persona requires a name');
151
+ }
152
+
153
+ result.persona = {
154
+ name: personaName,
155
+ color: personaColor,
156
+ lineNumber,
157
+ };
158
+ inPersona = true;
159
+ continue;
160
+ }
161
+ if (/^persona\s*$/i.test(trimmed)) {
162
+ return fail(lineNumber, 'persona requires a name');
163
+ }
164
+ }
165
+
166
+ // Persona description (indented lines while inPersona)
167
+ if (inPersona && indent > 0) {
168
+ if (result.persona) {
169
+ result.persona.description = result.persona.description
170
+ ? result.persona.description + '\n' + trimmed
171
+ : trimmed;
172
+ }
173
+ continue;
174
+ }
175
+
176
+ // End persona on non-indented line
177
+ if (inPersona && indent === 0) {
178
+ inPersona = false;
179
+ }
180
+
181
+ // Tag group heading
182
+ if (!contentStarted) {
183
+ const tagBlockMatch = matchTagBlockHeading(trimmed);
184
+ if (tagBlockMatch) {
185
+ currentTagGroup = {
186
+ name: tagBlockMatch.name,
187
+ alias: tagBlockMatch.alias,
188
+ entries: [],
189
+ lineNumber,
190
+ };
191
+ if (tagBlockMatch.alias) {
192
+ aliasMap.set(
193
+ tagBlockMatch.alias.toLowerCase(),
194
+ tagBlockMatch.name.toLowerCase()
195
+ );
196
+ }
197
+ result.tagGroups.push(currentTagGroup);
198
+ continue;
199
+ }
200
+ }
201
+
202
+ // Tag group entries (indented under tag heading)
203
+ if (currentTagGroup && !contentStarted) {
204
+ if (indent > 0) {
205
+ const { text: cleanEntry, isDefault } = stripDefaultModifier(trimmed);
206
+ const { label, color } = extractColor(cleanEntry, palette);
207
+ if (!color) {
208
+ warn(
209
+ lineNumber,
210
+ `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`
211
+ );
212
+ continue;
213
+ }
214
+ if (isDefault) {
215
+ currentTagGroup.defaultValue = label;
216
+ } else if (currentTagGroup.entries.length === 0) {
217
+ currentTagGroup.defaultValue = label;
218
+ }
219
+ currentTagGroup.entries.push({ value: label, color, lineNumber });
220
+ continue;
221
+ }
222
+ currentTagGroup = null;
223
+ }
224
+
225
+ // Generic header options
226
+ if (!contentStarted && !currentTagGroup && indent === 0) {
227
+ const optMatch = trimmed.match(OPTION_NOCOLON_RE);
228
+ if (optMatch && !PHASE_RE.test(trimmed)) {
229
+ const key = optMatch[1].trim().toLowerCase();
230
+ if (KNOWN_OPTIONS.has(key)) {
231
+ result.options[key] = optMatch[2].trim();
232
+ continue;
233
+ }
234
+ }
235
+ if (
236
+ KNOWN_BOOLEANS.has(trimmed.toLowerCase()) &&
237
+ !PHASE_RE.test(trimmed)
238
+ ) {
239
+ result.options[trimmed.toLowerCase()] = 'on';
240
+ continue;
241
+ }
242
+ }
243
+
244
+ // --- Content phase detection ---
245
+
246
+ // Phase header at indent 0
247
+ const phaseMatch = indent === 0 ? trimmed.match(PHASE_RE) : null;
248
+ if (phaseMatch) {
249
+ contentStarted = true;
250
+ currentTagGroup = null;
251
+ inPersona = false;
252
+ hasPhases = true;
253
+
254
+ // Finalize previous step
255
+ if (currentStep) {
256
+ currentStep.endLineNumber = lineNumber - 1;
257
+ }
258
+ currentStep = null;
259
+
260
+ phaseCounter++;
261
+ currentPhase = {
262
+ id: `phase-${phaseCounter}`,
263
+ name: phaseMatch[1].trim(),
264
+ steps: [],
265
+ lineNumber,
266
+ };
267
+ result.phases.push(currentPhase);
268
+ continue;
269
+ }
270
+
271
+ // Flat mode: indent-0 line that is not a phase/keyword — check if step
272
+ if (indent === 0 && !contentStarted) {
273
+ // Check if this looks like a step (contains pipe or is a bare step name)
274
+ if (
275
+ trimmed.includes('|') ||
276
+ (!KNOWN_OPTIONS.has(trimmed.toLowerCase()) &&
277
+ !KNOWN_BOOLEANS.has(trimmed.toLowerCase()))
278
+ ) {
279
+ contentStarted = true;
280
+ }
281
+ }
282
+
283
+ // --- Content phase ---
284
+
285
+ if (!contentStarted) continue;
286
+
287
+ // Mixed mode check: indent-0 non-phase line when phases exist
288
+ if (indent === 0 && hasPhases && !phaseMatch) {
289
+ // Stray line between phases
290
+ if (trimmed.includes('|') || !PHASE_RE.test(trimmed)) {
291
+ warn(
292
+ lineNumber,
293
+ 'Steps outside phases will be ignored when phases are present'
294
+ );
295
+ continue;
296
+ }
297
+ }
298
+
299
+ // Annotations/description on current step (deeper indent)
300
+ if (currentStep && indent > stepBaseIndent) {
301
+ // Check for annotation keywords
302
+ const annoMatch = trimmed.match(ANNOTATION_RE);
303
+ if (annoMatch) {
304
+ currentStep.annotations.push({
305
+ type: annoMatch[1].toLowerCase() as JourneyMapAnnotation['type'],
306
+ text: annoMatch[2].trim(),
307
+ });
308
+ currentStep.endLineNumber = lineNumber;
309
+ continue;
310
+ }
311
+
312
+ // Check for description keyword
313
+ const descResult = tryStripDescriptionKeyword(trimmed);
314
+ if (descResult.isKeyword) {
315
+ currentStep.description = descResult.text;
316
+ currentStep.endLineNumber = lineNumber;
317
+ continue;
318
+ }
319
+
320
+ // Unknown deeper-indented line — treat as detail, skip silently
321
+ currentStep.endLineNumber = lineNumber;
322
+ continue;
323
+ }
324
+
325
+ // Step line (indented under phase, or indent-0 in flat mode)
326
+ if ((currentPhase && indent > 0) || (!hasPhases && indent === 0)) {
327
+ // Finalize previous step
328
+ if (currentStep) {
329
+ currentStep.endLineNumber = lineNumber - 1;
330
+ }
331
+
332
+ stepCounter++;
333
+ const step = parseStepLine(
334
+ trimmed,
335
+ lineNumber,
336
+ stepCounter,
337
+ aliasMap,
338
+ warn
339
+ );
340
+ stepBaseIndent = indent;
341
+ currentStep = step;
342
+
343
+ if (currentPhase) {
344
+ currentPhase.steps.push(step);
345
+ } else {
346
+ result.steps.push(step);
347
+ }
348
+ continue;
349
+ }
350
+
351
+ // Unrecognized line
352
+ if (indent > 0 && !currentPhase && hasPhases) {
353
+ warn(lineNumber, `Unexpected indented line outside of a phase`);
354
+ }
355
+ }
356
+
357
+ // Finalize last step
358
+ if (currentStep) {
359
+ currentStep.endLineNumber = lines.length;
360
+ }
361
+
362
+ // --- Post-parse validation ---
363
+
364
+ // Build tag value sets
365
+ for (const group of result.tagGroups) {
366
+ const values = new Set(group.entries.map((e) => e.value.toLowerCase()));
367
+ tagValueSets.set(group.name.toLowerCase(), values);
368
+ }
369
+
370
+ // Validate tag values on all steps
371
+ const allSteps = hasPhases
372
+ ? result.phases.flatMap((p) => p.steps)
373
+ : result.steps;
374
+
375
+ for (const step of allSteps) {
376
+ for (const [tagKey, tagValue] of Object.entries(step.tags)) {
377
+ const groupKey =
378
+ aliasMap.get(tagKey.toLowerCase()) ?? tagKey.toLowerCase();
379
+ const validValues = tagValueSets.get(groupKey);
380
+ if (validValues && !validValues.has(tagValue.toLowerCase())) {
381
+ const entries = result.tagGroups
382
+ .find((g) => g.name.toLowerCase() === groupKey)
383
+ ?.entries.map((e) => e.value);
384
+ let msg = `Unknown tag value "${tagValue}" for group "${groupKey}"`;
385
+ if (entries) {
386
+ const hint = suggest(tagValue, entries);
387
+ if (hint) msg += `. ${hint}`;
388
+ }
389
+ warn(step.lineNumber, msg);
390
+ }
391
+ }
392
+
393
+ // Hint for missing scores
394
+ if (step.score === undefined) {
395
+ warn(
396
+ step.lineNumber,
397
+ `Step "${step.title}" has no score — it will not appear on the emotion curve`
398
+ );
399
+ }
400
+ }
401
+
402
+ // No content check
403
+ if (
404
+ result.phases.length === 0 &&
405
+ result.steps.length === 0 &&
406
+ !result.error
407
+ ) {
408
+ return fail(1, 'No phases or steps found');
409
+ }
410
+
411
+ validateTagGroupNames(result.tagGroups, warn);
412
+
413
+ return result;
414
+ }
415
+
416
+ // ============================================================
417
+ // Step line parser
418
+ // ============================================================
419
+
420
+ function parseStepLine(
421
+ trimmed: string,
422
+ lineNumber: number,
423
+ counter: number,
424
+ aliasMap: Map<string, string>,
425
+ warn: (line: number, message: string) => void
426
+ ): JourneyMapStep {
427
+ const pipeIdx = trimmed.indexOf('|');
428
+ let title: string;
429
+ let score: number | undefined;
430
+ let emotionLabel: string | undefined;
431
+ const tags: Record<string, string> = {};
432
+
433
+ if (pipeIdx >= 0) {
434
+ title = trimmed.substring(0, pipeIdx).trim();
435
+ const pipeContent = trimmed.substring(pipeIdx + 1).trim();
436
+
437
+ if (pipeContent) {
438
+ // Split on first comma to isolate potential score segment
439
+ const commaIdx = pipeContent.indexOf(',');
440
+ const firstSegment =
441
+ commaIdx >= 0
442
+ ? pipeContent.substring(0, commaIdx).trim()
443
+ : pipeContent.trim();
444
+ const restSegments =
445
+ commaIdx >= 0 ? pipeContent.substring(commaIdx + 1).trim() : '';
446
+
447
+ const scoreMatch = firstSegment.match(SCORE_RE);
448
+
449
+ if (scoreMatch) {
450
+ const rawScore = scoreMatch[1];
451
+ const label = scoreMatch[2];
452
+
453
+ if (rawScore.includes('.')) {
454
+ // Float — reject
455
+ warn(lineNumber, `Score must be an integer 1-5, got ${rawScore}`);
456
+ } else {
457
+ const intScore = parseInt(rawScore, 10);
458
+ if (intScore < 1 || intScore > 5) {
459
+ warn(lineNumber, `Score out of range: ${intScore} (must be 1-5)`);
460
+ } else {
461
+ score = intScore;
462
+ emotionLabel = label;
463
+ }
464
+ }
465
+
466
+ // Parse remaining metadata
467
+ if (restSegments) {
468
+ const metaParts = restSegments.split(',');
469
+ for (const part of metaParts) {
470
+ const colonIdx = part.indexOf(':');
471
+ if (colonIdx > 0) {
472
+ const rawKey = part.substring(0, colonIdx).trim().toLowerCase();
473
+ const key = aliasMap.get(rawKey) ?? rawKey;
474
+ const value = part.substring(colonIdx + 1).trim();
475
+ tags[key] = value;
476
+ }
477
+ }
478
+ }
479
+ } else {
480
+ // First segment didn't match score regex
481
+ // Check if it's a multi-word emotion label attempt (number followed by multiple words)
482
+ const multiWordCheck = firstSegment.match(/^(\d+)\s+(.+)$/);
483
+ if (multiWordCheck && multiWordCheck[2].includes(' ')) {
484
+ // Preserve the score but warn about the multi-word label
485
+ const mwScore = parseInt(multiWordCheck[1], 10);
486
+ if (mwScore >= 1 && mwScore <= 5) {
487
+ score = mwScore;
488
+ }
489
+ warn(
490
+ lineNumber,
491
+ `Emotion label must be a single word — got "${multiWordCheck[2]}"`
492
+ );
493
+ }
494
+
495
+ // Treat entire pipe content as standard metadata
496
+ const allParts = pipeContent.split(',');
497
+ for (const part of allParts) {
498
+ const colonIdx = part.indexOf(':');
499
+ if (colonIdx > 0) {
500
+ const rawKey = part.substring(0, colonIdx).trim().toLowerCase();
501
+ const key = aliasMap.get(rawKey) ?? rawKey;
502
+ const value = part.substring(colonIdx + 1).trim();
503
+ tags[key] = value;
504
+ }
505
+ }
506
+
507
+ // Check for explicit score: key
508
+ if ('score' in tags) {
509
+ const scoreVal = tags['score'];
510
+ delete tags['score'];
511
+ const parsed = parseInt(scoreVal, 10);
512
+ if (isNaN(parsed) || scoreVal !== String(parsed)) {
513
+ warn(
514
+ lineNumber,
515
+ `Invalid score value: "${scoreVal}" (must be an integer 1-5)`
516
+ );
517
+ } else if (parsed < 1 || parsed > 5) {
518
+ warn(lineNumber, `Score out of range: ${parsed} (must be 1-5)`);
519
+ } else {
520
+ score = parsed;
521
+ }
522
+ }
523
+ }
524
+ }
525
+ } else {
526
+ title = trimmed;
527
+ // No pipe — scoreless step
528
+ }
529
+
530
+ return {
531
+ id: `step-${counter}`,
532
+ title,
533
+ score,
534
+ emotionLabel,
535
+ tags,
536
+ annotations: [],
537
+ lineNumber,
538
+ endLineNumber: lineNumber,
539
+ };
540
+ }