@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.
- package/AGENTS.md +2 -1
- package/README.md +1 -0
- package/dist/cli.cjs +142 -90
- package/dist/editor.cjs +30 -4
- package/dist/editor.cjs.map +1 -1
- package/dist/editor.js +30 -4
- package/dist/editor.js.map +1 -1
- package/dist/highlight.cjs +25 -3
- package/dist/highlight.cjs.map +1 -1
- package/dist/highlight.js +25 -3
- package/dist/highlight.js.map +1 -1
- package/dist/index.cjs +21201 -12886
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +646 -89
- package/dist/index.d.ts +646 -89
- package/dist/index.js +21178 -12889
- package/dist/index.js.map +1 -1
- package/docs/guide/chart-mindmap.md +198 -0
- package/docs/guide/chart-sequence.md +23 -1
- package/docs/guide/chart-sitemap.md +18 -1
- package/docs/guide/chart-tech-radar.md +219 -0
- package/docs/guide/chart-wireframe.md +100 -0
- package/docs/guide/index.md +8 -0
- package/docs/guide/registry.json +1 -0
- package/docs/language-reference.md +249 -4
- package/gallery/fixtures/boxes-and-lines.dgmo +10 -3
- package/gallery/fixtures/c4-full.dgmo +2 -2
- package/gallery/fixtures/cycle/ooda-loop.dgmo +25 -0
- package/gallery/fixtures/cycle/pdca-circle-nodes.dgmo +12 -0
- package/gallery/fixtures/cycle/pdca-minimal.dgmo +6 -0
- package/gallery/fixtures/cycle/sprint-cycle-span.dgmo +17 -0
- package/gallery/fixtures/gantt-full.dgmo +2 -2
- package/gallery/fixtures/gantt.dgmo +2 -2
- package/gallery/fixtures/infra-full.dgmo +2 -2
- package/gallery/fixtures/infra.dgmo +1 -1
- package/gallery/fixtures/sequence-tags-protocols.dgmo +2 -2
- package/gallery/fixtures/sequence-tags.dgmo +2 -2
- package/gallery/fixtures/tech-radar-dense.dgmo +77 -0
- package/gallery/fixtures/tech-radar.dgmo +36 -0
- package/gallery/fixtures/timeline.dgmo +1 -1
- package/package.json +1 -1
- package/src/boxes-and-lines/collapse.ts +21 -3
- package/src/boxes-and-lines/layout.ts +360 -42
- package/src/boxes-and-lines/parser.ts +94 -11
- package/src/boxes-and-lines/renderer.ts +371 -114
- package/src/boxes-and-lines/types.ts +2 -1
- package/src/c4/layout.ts +8 -8
- package/src/c4/parser.ts +35 -2
- package/src/c4/renderer.ts +19 -3
- package/src/c4/types.ts +1 -0
- package/src/chart.ts +14 -7
- package/src/completion.ts +253 -0
- package/src/cycle/layout.ts +732 -0
- package/src/cycle/parser.ts +352 -0
- package/src/cycle/renderer.ts +539 -0
- package/src/cycle/types.ts +77 -0
- package/src/d3.ts +240 -40
- package/src/dgmo-router.ts +15 -0
- package/src/echarts.ts +7 -4
- package/src/editor/dgmo.grammar +5 -1
- package/src/editor/dgmo.grammar.js +1 -1
- package/src/editor/keywords.ts +26 -0
- package/src/gantt/parser.ts +2 -8
- package/src/graph/flowchart-parser.ts +15 -21
- package/src/graph/layout.ts +73 -9
- package/src/graph/state-collapse.ts +78 -0
- package/src/graph/state-parser.ts +5 -10
- package/src/graph/state-renderer.ts +139 -34
- package/src/index.ts +78 -0
- package/src/infra/layout.ts +218 -74
- package/src/infra/parser.ts +30 -6
- package/src/infra/renderer.ts +14 -8
- package/src/infra/types.ts +10 -3
- package/src/journey-map/layout.ts +386 -0
- package/src/journey-map/parser.ts +540 -0
- package/src/journey-map/renderer.ts +1456 -0
- package/src/journey-map/types.ts +47 -0
- package/src/kanban/parser.ts +3 -10
- package/src/kanban/renderer.ts +325 -63
- package/src/mindmap/collapse.ts +88 -0
- package/src/mindmap/layout.ts +605 -0
- package/src/mindmap/parser.ts +373 -0
- package/src/mindmap/renderer.ts +544 -0
- package/src/mindmap/text-wrap.ts +217 -0
- package/src/mindmap/types.ts +55 -0
- package/src/org/parser.ts +2 -6
- package/src/render.ts +18 -21
- package/src/sequence/renderer.ts +273 -56
- package/src/sharing.ts +3 -0
- package/src/sitemap/layout.ts +56 -18
- package/src/sitemap/parser.ts +26 -17
- package/src/sitemap/renderer.ts +34 -0
- package/src/sitemap/types.ts +1 -0
- package/src/tech-radar/index.ts +14 -0
- package/src/tech-radar/interactive.ts +1058 -0
- package/src/tech-radar/layout.ts +190 -0
- package/src/tech-radar/parser.ts +385 -0
- package/src/tech-radar/renderer.ts +1159 -0
- package/src/tech-radar/shared.ts +187 -0
- package/src/tech-radar/types.ts +81 -0
- package/src/utils/description-helpers.ts +33 -0
- package/src/utils/export-container.ts +3 -2
- package/src/utils/legend-d3.ts +1 -0
- package/src/utils/legend-layout.ts +5 -3
- package/src/utils/parsing.ts +48 -7
- package/src/utils/tag-groups.ts +46 -60
- package/src/wireframe/layout.ts +460 -0
- package/src/wireframe/parser.ts +956 -0
- package/src/wireframe/renderer.ts +1293 -0
- 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
|
+
}
|