@diagrammo/dgmo 0.7.3 → 0.8.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/AGENTS.md +15 -20
- package/README.md +56 -58
- package/dist/cli.cjs +188 -181
- package/dist/index.cjs +3506 -1057
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +196 -43
- package/dist/index.d.ts +196 -43
- package/dist/index.js +3493 -1057
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +629 -289
- package/package.json +1 -1
- package/src/c4/layout.ts +6 -9
- package/src/c4/parser.ts +189 -83
- package/src/c4/renderer.ts +8 -9
- package/src/chart.ts +296 -83
- package/src/class/parser.ts +54 -37
- package/src/class/renderer.ts +8 -8
- package/src/cli.ts +8 -8
- package/src/colors.ts +4 -1
- package/src/completion.ts +757 -10
- package/src/d3.ts +310 -73
- package/src/dgmo-router.ts +63 -8
- package/src/echarts.ts +726 -231
- package/src/er/parser.ts +94 -76
- package/src/er/renderer.ts +6 -5
- package/src/gantt/parser.ts +144 -69
- package/src/gantt/renderer.ts +50 -14
- package/src/gantt/types.ts +3 -3
- package/src/graph/flowchart-parser.ts +97 -37
- package/src/graph/flowchart-renderer.ts +4 -3
- package/src/graph/state-parser.ts +50 -31
- package/src/graph/state-renderer.ts +4 -3
- package/src/index.ts +14 -5
- package/src/infra/compute.ts +1 -0
- package/src/infra/layout.ts +3 -0
- package/src/infra/parser.ts +291 -92
- package/src/infra/renderer.ts +172 -30
- package/src/infra/types.ts +5 -0
- package/src/initiative-status/layout.ts +1 -1
- package/src/initiative-status/parser.ts +121 -47
- package/src/initiative-status/renderer.ts +42 -23
- package/src/initiative-status/types.ts +10 -2
- package/src/kanban/parser.ts +60 -37
- package/src/kanban/renderer.ts +2 -2
- package/src/kanban/types.ts +1 -0
- package/src/org/layout.ts +9 -9
- package/src/org/parser.ts +39 -40
- package/src/org/renderer.ts +5 -6
- package/src/org/resolver.ts +26 -19
- package/src/render.ts +1 -1
- package/src/sequence/parser.ts +304 -95
- package/src/sequence/renderer.ts +9 -9
- package/src/sitemap/layout.ts +3 -4
- package/src/sitemap/parser.ts +57 -49
- package/src/sitemap/renderer.ts +6 -7
- package/src/utils/arrows.ts +25 -6
- package/src/utils/duration.ts +43 -7
- package/src/utils/legend-constants.ts +26 -0
- package/src/utils/legend-svg.ts +167 -0
- package/src/utils/parsing.ts +247 -7
- package/src/utils/tag-groups.ts +160 -15
- package/src/utils/title-constants.ts +9 -0
package/src/sequence/parser.ts
CHANGED
|
@@ -6,10 +6,16 @@ import { inferParticipantType } from './participant-inference';
|
|
|
6
6
|
import type { DgmoError } from '../diagnostics';
|
|
7
7
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
8
8
|
import { parseArrow } from '../utils/arrows';
|
|
9
|
-
import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING } from '../utils/parsing';
|
|
9
|
+
import { measureIndent, extractColor, parsePipeMetadata, MULTIPLE_PIPE_WARNING, parseFirstLine, OPTION_NOCOLON_RE } from '../utils/parsing';
|
|
10
10
|
import type { TagGroup } from '../utils/tag-groups';
|
|
11
11
|
import { matchTagBlockHeading, validateTagValues } from '../utils/tag-groups';
|
|
12
12
|
|
|
13
|
+
/** Known sequence-diagram options that take a value (space-separated). */
|
|
14
|
+
const KNOWN_SEQ_OPTIONS = new Set(['active-tag']);
|
|
15
|
+
|
|
16
|
+
/** Known sequence-diagram boolean options (bare keyword or `no-` prefix). */
|
|
17
|
+
const KNOWN_SEQ_BOOLEANS = new Set(['activations', 'collapse-notes']);
|
|
18
|
+
|
|
13
19
|
/**
|
|
14
20
|
* Participant types that can be declared via "Name is a type" syntax.
|
|
15
21
|
*/
|
|
@@ -165,8 +171,11 @@ const POSITION_ONLY_PATTERN = /^([^:]+?)\s+position\s+(-?\d+)$/i;
|
|
|
165
171
|
// Colored participant declaration — e.g. "Tapin2(green)", "API(blue)"
|
|
166
172
|
const COLORED_PARTICIPANT_PATTERN = /^(\S+?)\(([^)]+)\)\s*$/;
|
|
167
173
|
|
|
168
|
-
// Group heading pattern — "[Backend]", "[
|
|
169
|
-
|
|
174
|
+
// Group heading pattern — "[Backend]", "[Backend] | t: Product"
|
|
175
|
+
// Group 1: name (no ] or | inside brackets), Group 2: color in parens, Group 3: after-bracket text
|
|
176
|
+
const GROUP_HEADING_PATTERN = /^\[([^\]|]+?)(?:\(([^)]+)\))?\]\s*(.*)$/;
|
|
177
|
+
// Fallback: allows anything inside brackets (used to detect pipe-inside-brackets error)
|
|
178
|
+
const GROUP_HEADING_FALLBACK = /^\[([^\]]+)\]\s*(.*)$/;
|
|
170
179
|
// Legacy ## syntax — detect and emit migration error
|
|
171
180
|
const LEGACY_GROUP_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
|
|
172
181
|
|
|
@@ -176,10 +185,169 @@ const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
|
|
|
176
185
|
// Arrow pattern for sequence inference — detects any arrow form
|
|
177
186
|
const ARROW_PATTERN = /\S+\s*(?:<-\S+-|<~\S+~|-\S+->|~\S+~>|->|~>|<-|<~)\s*\S+/;
|
|
178
187
|
|
|
179
|
-
// Note patterns —
|
|
180
|
-
//
|
|
181
|
-
|
|
182
|
-
|
|
188
|
+
// Note patterns — colon-free syntax
|
|
189
|
+
// Single-line: "note text", "note left text", "note right of X text", "note left X text"
|
|
190
|
+
// Multi-line: "note", "note right", "note right of X", "note left X" (body indented below)
|
|
191
|
+
// Also supports legacy colon syntax: "note: text", "note right of X: text"
|
|
192
|
+
//
|
|
193
|
+
// The colon-free positioned form requires participant resolution — the parser
|
|
194
|
+
// already has participant collection infrastructure, so we match the general
|
|
195
|
+
// structure here and resolve participant vs text in the parsing logic.
|
|
196
|
+
const NOTE_SINGLE_COLON = /^note(?:\s+(right|left)(?:\s+(?:of\s+)?(.+?))?)?\s*:\s*(.+)$/i;
|
|
197
|
+
const NOTE_BARE = /^note\s+(.+)$/i;
|
|
198
|
+
const NOTE_MULTI = /^note(?:\s+(right|left)(?:\s+(?:of\s+)?(.+?))?)?\s*:?\s*$/i;
|
|
199
|
+
|
|
200
|
+
/** Result of parseNoteLine — indicates what the parser should do. */
|
|
201
|
+
type NoteParseResult =
|
|
202
|
+
| { kind: 'single'; position: 'right' | 'left'; participantId: string; text: string }
|
|
203
|
+
| { kind: 'multi-head'; position: 'right' | 'left'; participantId: string }
|
|
204
|
+
| { kind: 'skip' }
|
|
205
|
+
| null; // not a note line at all
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Parse a note line, resolving participant names from the known participants list.
|
|
209
|
+
*
|
|
210
|
+
* Supports:
|
|
211
|
+
* - `note: text` / `note text` — default position (right), last msg sender
|
|
212
|
+
* - `note left of X: text` / `note left of X text` / `note left X text`
|
|
213
|
+
* - `note right:` / `note right` — multi-line head
|
|
214
|
+
* - `note right of X:` / `note right of X` / `note left X` — multi-line head
|
|
215
|
+
* - Quoted participant: `note left "Auth Service" text`
|
|
216
|
+
*/
|
|
217
|
+
function parseNoteLine(
|
|
218
|
+
trimmed: string,
|
|
219
|
+
participants: SequenceParticipant[],
|
|
220
|
+
lastMsgFrom: string | null,
|
|
221
|
+
): NoteParseResult {
|
|
222
|
+
const lower = trimmed.toLowerCase();
|
|
223
|
+
if (!lower.startsWith('note')) return null;
|
|
224
|
+
// Must be exactly "note" or "note " — not "notebook" etc.
|
|
225
|
+
if (trimmed.length > 4 && trimmed[4] !== ' ' && trimmed[4] !== ':') return null;
|
|
226
|
+
|
|
227
|
+
// 1. Try legacy colon-based syntax first
|
|
228
|
+
const colonMatch = trimmed.match(NOTE_SINGLE_COLON);
|
|
229
|
+
if (colonMatch) {
|
|
230
|
+
const position = (colonMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
|
|
231
|
+
let participantId = colonMatch[2] || null;
|
|
232
|
+
if (!participantId) {
|
|
233
|
+
if (!lastMsgFrom) return { kind: 'skip' };
|
|
234
|
+
participantId = lastMsgFrom;
|
|
235
|
+
}
|
|
236
|
+
if (!participants.some((p) => p.id === participantId)) return { kind: 'skip' };
|
|
237
|
+
return { kind: 'single', position, participantId, text: colonMatch[3].trim() };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2. Try multi-line head (no text after note): `note`, `note right`, `note right of X`, `note left X`
|
|
241
|
+
// NOTE: NOTE_MULTI's (.+?) can greedily capture "participant text" as one group.
|
|
242
|
+
// Only trust this match if the captured participant actually exists. Otherwise, fall
|
|
243
|
+
// through to the bare-note handler which does proper participant-aware splitting.
|
|
244
|
+
const multiMatch = trimmed.match(NOTE_MULTI);
|
|
245
|
+
if (multiMatch) {
|
|
246
|
+
const position = (multiMatch[1]?.toLowerCase() as 'right' | 'left') || 'right';
|
|
247
|
+
let participantId = multiMatch[2] || null;
|
|
248
|
+
if (!participantId) {
|
|
249
|
+
if (!lastMsgFrom) return { kind: 'skip' };
|
|
250
|
+
participantId = lastMsgFrom;
|
|
251
|
+
}
|
|
252
|
+
if (participants.some((p) => p.id === participantId)) {
|
|
253
|
+
return { kind: 'multi-head', position, participantId };
|
|
254
|
+
}
|
|
255
|
+
// Participant not found — fall through to bare-note handler for proper resolution
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// 3. Bare note (colon-free): `note text` or `note left [of] X text`
|
|
259
|
+
const bareMatch = trimmed.match(NOTE_BARE);
|
|
260
|
+
if (bareMatch) {
|
|
261
|
+
const rest = bareMatch[1].trim();
|
|
262
|
+
const restLower = rest.toLowerCase();
|
|
263
|
+
|
|
264
|
+
// Check for positioned note: `note left/right ...`
|
|
265
|
+
if (restLower.startsWith('left') || restLower.startsWith('right')) {
|
|
266
|
+
const posWord = restLower.startsWith('left') ? 'left' : 'right';
|
|
267
|
+
const position = posWord as 'right' | 'left';
|
|
268
|
+
let afterPos = rest.substring(posWord.length).trim();
|
|
269
|
+
|
|
270
|
+
// Strip optional `of` keyword — track whether it was present
|
|
271
|
+
let hadOf = false;
|
|
272
|
+
if (afterPos.toLowerCase().startsWith('of ')) {
|
|
273
|
+
afterPos = afterPos.substring(3).trim();
|
|
274
|
+
hadOf = true;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (!afterPos) {
|
|
278
|
+
// Just `note left` or `note right` — multi-line head
|
|
279
|
+
if (!lastMsgFrom) return { kind: 'skip' };
|
|
280
|
+
if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
|
|
281
|
+
return { kind: 'multi-head', position, participantId: lastMsgFrom };
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Try to match a known participant at the start of afterPos
|
|
285
|
+
const resolved = resolveParticipantAndText(afterPos, participants);
|
|
286
|
+
if (resolved) {
|
|
287
|
+
if (resolved.text) {
|
|
288
|
+
return { kind: 'single', position, participantId: resolved.participantId, text: resolved.text };
|
|
289
|
+
} else {
|
|
290
|
+
// No text after participant — multi-line head
|
|
291
|
+
return { kind: 'multi-head', position, participantId: resolved.participantId };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// No known participant matched.
|
|
296
|
+
// If `of` was explicit (`note right of Z ...`), the user intended a specific
|
|
297
|
+
// participant — skip when it doesn't exist rather than defaulting.
|
|
298
|
+
if (hadOf) return { kind: 'skip' };
|
|
299
|
+
|
|
300
|
+
// Without `of`, treat remaining text as note content on the last-msg sender
|
|
301
|
+
if (!lastMsgFrom) return { kind: 'skip' };
|
|
302
|
+
if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
|
|
303
|
+
return { kind: 'single', position, participantId: lastMsgFrom, text: afterPos };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Plain `note text` — default position, last msg sender
|
|
307
|
+
if (!lastMsgFrom) return { kind: 'skip' };
|
|
308
|
+
if (!participants.some((p) => p.id === lastMsgFrom)) return { kind: 'skip' };
|
|
309
|
+
return { kind: 'single', position: 'right', participantId: lastMsgFrom, text: rest };
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
return null;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Try to match a known participant name at the start of a string.
|
|
317
|
+
* Returns the matched participant and remaining text, or null if no match.
|
|
318
|
+
* Tries longest match first (multi-word participant names).
|
|
319
|
+
*/
|
|
320
|
+
function resolveParticipantAndText(
|
|
321
|
+
input: string,
|
|
322
|
+
participants: SequenceParticipant[],
|
|
323
|
+
): { participantId: string; text: string } | null {
|
|
324
|
+
// Handle quoted participant: `"Auth Service" text`
|
|
325
|
+
if (input.startsWith('"') || input.startsWith("'")) {
|
|
326
|
+
const quote = input[0];
|
|
327
|
+
const endQuote = input.indexOf(quote, 1);
|
|
328
|
+
if (endQuote > 0) {
|
|
329
|
+
const name = input.substring(1, endQuote);
|
|
330
|
+
if (participants.some((p) => p.id === name)) {
|
|
331
|
+
const text = input.substring(endQuote + 1).trim();
|
|
332
|
+
return { participantId: name, text };
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Sort participants by name length (longest first) for greedy matching
|
|
339
|
+
const sorted = [...participants].sort((a, b) => b.id.length - a.id.length);
|
|
340
|
+
for (const p of sorted) {
|
|
341
|
+
if (input.startsWith(p.id)) {
|
|
342
|
+
const remaining = input.substring(p.id.length);
|
|
343
|
+
// Must be followed by whitespace, end of string, or nothing
|
|
344
|
+
if (remaining === '' || remaining[0] === ' ' || remaining[0] === '\t') {
|
|
345
|
+
return { participantId: p.id, text: remaining.trim() };
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return null;
|
|
350
|
+
}
|
|
183
351
|
|
|
184
352
|
/**
|
|
185
353
|
* Parse a .dgmo file with `chart: sequence` into a structured representation.
|
|
@@ -225,6 +393,23 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
225
393
|
const lines = content.split('\n');
|
|
226
394
|
let hasExplicitChart = false;
|
|
227
395
|
let contentStarted = false;
|
|
396
|
+
let firstLineIndex = -1; // line index of the `sequence [Title]` first line (to skip in main loop)
|
|
397
|
+
|
|
398
|
+
// Handle first non-empty, non-comment line for `sequence Title` syntax
|
|
399
|
+
for (let fi = 0; fi < lines.length; fi++) {
|
|
400
|
+
const fl = lines[fi].trim();
|
|
401
|
+
if (!fl || fl.startsWith('//')) continue;
|
|
402
|
+
const parsed = parseFirstLine(fl);
|
|
403
|
+
if (parsed && parsed.chartType === 'sequence') {
|
|
404
|
+
hasExplicitChart = true;
|
|
405
|
+
firstLineIndex = fi;
|
|
406
|
+
if (parsed.title) {
|
|
407
|
+
result.title = parsed.title;
|
|
408
|
+
result.titleLineNumber = fi + 1;
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
break;
|
|
412
|
+
}
|
|
228
413
|
|
|
229
414
|
// Group parsing state — tracks the active [Group] heading
|
|
230
415
|
let activeGroup: SequenceGroup | null = null;
|
|
@@ -276,28 +461,22 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
276
461
|
continue;
|
|
277
462
|
}
|
|
278
463
|
|
|
279
|
-
//
|
|
464
|
+
// Skip first line already handled as `sequence [Title]`
|
|
465
|
+
if (i === firstLineIndex) continue;
|
|
466
|
+
|
|
467
|
+
// Parse group heading — [Group Name] or [Group Name] | k: v
|
|
280
468
|
const groupMatch = trimmed.match(GROUP_HEADING_PATTERN);
|
|
281
469
|
if (groupMatch) {
|
|
282
|
-
|
|
283
|
-
|
|
470
|
+
const groupName = groupMatch[1].trim();
|
|
471
|
+
const groupColor = groupMatch[2]?.trim();
|
|
284
472
|
let groupMeta: Record<string, string> | undefined;
|
|
285
473
|
|
|
286
|
-
//
|
|
287
|
-
const
|
|
288
|
-
if (
|
|
289
|
-
const
|
|
290
|
-
const segments = groupName.substring(gpipeIdx).split('|');
|
|
474
|
+
// Parse pipe metadata AFTER the closing bracket
|
|
475
|
+
const afterBracket = groupMatch[3]?.trim() || '';
|
|
476
|
+
if (afterBracket.startsWith('|')) {
|
|
477
|
+
const segments = afterBracket.split('|');
|
|
291
478
|
const meta = parsePipeMetadata(segments, aliasMap, () => pushWarning(lineNumber, MULTIPLE_PIPE_WARNING));
|
|
292
479
|
if (Object.keys(meta).length > 0) groupMeta = meta;
|
|
293
|
-
// Re-extract color from name part
|
|
294
|
-
const colorSuffix = nameAndColor.match(/^(.+?)\(([^)]+)\)$/);
|
|
295
|
-
if (colorSuffix) {
|
|
296
|
-
groupName = colorSuffix[1].trim();
|
|
297
|
-
groupColor = colorSuffix[2].trim();
|
|
298
|
-
} else {
|
|
299
|
-
groupName = nameAndColor;
|
|
300
|
-
}
|
|
301
480
|
}
|
|
302
481
|
|
|
303
482
|
if (groupColor) {
|
|
@@ -314,6 +493,19 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
314
493
|
continue;
|
|
315
494
|
}
|
|
316
495
|
|
|
496
|
+
// Detect pipe-inside-brackets error: [Name | meta] → suggest [Name] | meta
|
|
497
|
+
if (trimmed.startsWith('[')) {
|
|
498
|
+
const fallbackMatch = trimmed.match(GROUP_HEADING_FALLBACK);
|
|
499
|
+
if (fallbackMatch && fallbackMatch[1].includes('|')) {
|
|
500
|
+
const rawInside = fallbackMatch[1];
|
|
501
|
+
const pipeIdx = rawInside.indexOf('|');
|
|
502
|
+
const cleanName = rawInside.substring(0, pipeIdx).trim().replace(/\([^)]*\)$/, '').trim();
|
|
503
|
+
const metaPart = rawInside.substring(pipeIdx).trim();
|
|
504
|
+
pushError(lineNumber, `Pipe metadata must go outside brackets — use '[${cleanName}] ${metaPart}' instead of '[${rawInside.trim()}]'`);
|
|
505
|
+
continue;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
317
509
|
// Reject legacy ## group syntax with migration hint
|
|
318
510
|
if (trimmed.match(LEGACY_GROUP_PATTERN)) {
|
|
319
511
|
const legacyMatch = trimmed.match(LEGACY_GROUP_PATTERN)!;
|
|
@@ -359,18 +551,16 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
359
551
|
continue;
|
|
360
552
|
}
|
|
361
553
|
|
|
362
|
-
// Tag group entries (indented Value(color)
|
|
554
|
+
// Tag group entries (indented Value(color) under tag heading)
|
|
555
|
+
// First entry is automatically the default (no `default` keyword needed)
|
|
363
556
|
if (currentTagGroup && !contentStarted && measureIndent(raw) > 0) {
|
|
364
|
-
const
|
|
365
|
-
const entryText = isDefault
|
|
366
|
-
? trimmed.replace(/\s+default\s*$/, '').trim()
|
|
367
|
-
: trimmed;
|
|
368
|
-
const { label, color } = extractColor(entryText);
|
|
557
|
+
const { label, color } = extractColor(trimmed);
|
|
369
558
|
if (!color) {
|
|
370
559
|
pushError(lineNumber, `Expected 'Value(color)' in tag group '${currentTagGroup.name}'`);
|
|
371
560
|
continue;
|
|
372
561
|
}
|
|
373
|
-
|
|
562
|
+
// First entry is the default
|
|
563
|
+
if (currentTagGroup.entries.length === 0) {
|
|
374
564
|
currentTagGroup.defaultValue = label;
|
|
375
565
|
}
|
|
376
566
|
currentTagGroup.entries.push({ value: label, color, lineNumber });
|
|
@@ -444,6 +634,37 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
444
634
|
}
|
|
445
635
|
}
|
|
446
636
|
|
|
637
|
+
// Parse space-separated options (no colon): `activations off`, `no-activations`, `active-tag Priority`
|
|
638
|
+
{
|
|
639
|
+
const optLower = trimmed.toLowerCase();
|
|
640
|
+
// Negated boolean: `no-activations` → options.activations = 'off'
|
|
641
|
+
if (optLower.startsWith('no-')) {
|
|
642
|
+
const base = optLower.substring(3);
|
|
643
|
+
if (KNOWN_SEQ_BOOLEANS.has(base)) {
|
|
644
|
+
if (contentStarted) {
|
|
645
|
+
pushError(lineNumber, `Options like '${trimmed}' must appear before the first message or declaration`);
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
result.options[base] = 'off';
|
|
649
|
+
continue;
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
// Key-value option: `active-tag Priority`
|
|
653
|
+
const spaceMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
654
|
+
if (spaceMatch) {
|
|
655
|
+
const optKey = spaceMatch[1].toLowerCase();
|
|
656
|
+
const optVal = spaceMatch[2].trim();
|
|
657
|
+
if (KNOWN_SEQ_OPTIONS.has(optKey) || KNOWN_SEQ_BOOLEANS.has(optKey)) {
|
|
658
|
+
if (contentStarted) {
|
|
659
|
+
pushError(lineNumber, `Options like '${trimmed}' must appear before the first message or declaration`);
|
|
660
|
+
continue;
|
|
661
|
+
}
|
|
662
|
+
result.options[optKey] = optVal;
|
|
663
|
+
continue;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
447
668
|
// Parse "Name is a type [aka Alias]" declarations (always top-level)
|
|
448
669
|
const { core: isACore, meta: isAMeta } = splitPipe(trimmed, lineNumber);
|
|
449
670
|
const isAMatch = isACore.match(IS_A_PATTERN);
|
|
@@ -529,7 +750,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
529
750
|
if (coloredMatch && !ARROW_PATTERN.test(colorCore)) {
|
|
530
751
|
const id = coloredMatch[1];
|
|
531
752
|
const color = coloredMatch[2].trim();
|
|
532
|
-
|
|
753
|
+
pushError(lineNumber, `'${id}(${color})' syntax is no longer supported — use 'tag:' groups for coloring`);
|
|
533
754
|
contentStarted = true;
|
|
534
755
|
if (!result.participants.some((p) => p.id === id)) {
|
|
535
756
|
result.participants.push({
|
|
@@ -688,8 +909,8 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
688
909
|
}
|
|
689
910
|
|
|
690
911
|
// ---- Deprecated bare return arrows: A <- B, A <~ B ----
|
|
691
|
-
const bareReturnSync = arrowCore.match(/^(.+?)\s
|
|
692
|
-
const bareReturnAsync = arrowCore.match(/^(.+?)\s
|
|
912
|
+
const bareReturnSync = arrowCore.match(/^(.+?)\s*<-\s*(.+)$/);
|
|
913
|
+
const bareReturnAsync = arrowCore.match(/^(.+?)\s*<~\s*(.+)$/);
|
|
693
914
|
const bareReturn = bareReturnSync || bareReturnAsync;
|
|
694
915
|
if (bareReturn) {
|
|
695
916
|
const to = bareReturn[1];
|
|
@@ -828,66 +1049,54 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
828
1049
|
continue;
|
|
829
1050
|
}
|
|
830
1051
|
|
|
831
|
-
//
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
}
|
|
880
|
-
if (noteLines.length === 0) continue; // no body yet — skip during live typing
|
|
881
|
-
const note: SequenceNote = {
|
|
882
|
-
kind: 'note',
|
|
883
|
-
text: noteLines.join('\n'),
|
|
884
|
-
position: notePosition,
|
|
885
|
-
participantId: noteParticipant,
|
|
886
|
-
lineNumber,
|
|
887
|
-
endLineNumber: i + 1, // i has advanced past the body lines (1-based)
|
|
888
|
-
};
|
|
889
|
-
currentContainer().push(note);
|
|
890
|
-
continue;
|
|
1052
|
+
// ---- Note parsing (colon-free + legacy colon syntax) ----
|
|
1053
|
+
// Strategy:
|
|
1054
|
+
// 1. Try colon-based syntax: `note right of X: text` (legacy, still supported)
|
|
1055
|
+
// 2. Try bare note: `note text` — position defaults, text is everything after `note`
|
|
1056
|
+
// 3. For positioned: `note left [of] X text` — needs participant lookup to split name vs text
|
|
1057
|
+
// 4. Multi-line: `note`, `note right`, `note right [of] X` (body indented below)
|
|
1058
|
+
{
|
|
1059
|
+
const noteParsed = parseNoteLine(trimmed, result.participants, lastMsgFrom);
|
|
1060
|
+
if (noteParsed) {
|
|
1061
|
+
if (noteParsed.kind === 'single') {
|
|
1062
|
+
const note: SequenceNote = {
|
|
1063
|
+
kind: 'note',
|
|
1064
|
+
text: noteParsed.text,
|
|
1065
|
+
position: noteParsed.position,
|
|
1066
|
+
participantId: noteParsed.participantId,
|
|
1067
|
+
lineNumber,
|
|
1068
|
+
endLineNumber: lineNumber,
|
|
1069
|
+
};
|
|
1070
|
+
currentContainer().push(note);
|
|
1071
|
+
continue;
|
|
1072
|
+
}
|
|
1073
|
+
if (noteParsed.kind === 'multi-head') {
|
|
1074
|
+
// Collect indented body lines
|
|
1075
|
+
const noteLines: string[] = [];
|
|
1076
|
+
while (i + 1 < lines.length) {
|
|
1077
|
+
const nextRaw = lines[i + 1];
|
|
1078
|
+
const nextTrimmed = nextRaw.trim();
|
|
1079
|
+
if (!nextTrimmed) break;
|
|
1080
|
+
const nextIndent = measureIndent(nextRaw);
|
|
1081
|
+
if (nextIndent <= indent) break;
|
|
1082
|
+
noteLines.push(nextTrimmed);
|
|
1083
|
+
i++;
|
|
1084
|
+
}
|
|
1085
|
+
if (noteLines.length === 0) continue; // no body yet — skip during live typing
|
|
1086
|
+
const note: SequenceNote = {
|
|
1087
|
+
kind: 'note',
|
|
1088
|
+
text: noteLines.join('\n'),
|
|
1089
|
+
position: noteParsed.position,
|
|
1090
|
+
participantId: noteParsed.participantId,
|
|
1091
|
+
lineNumber,
|
|
1092
|
+
endLineNumber: i + 1, // i has advanced past the body lines (1-based)
|
|
1093
|
+
};
|
|
1094
|
+
currentContainer().push(note);
|
|
1095
|
+
continue;
|
|
1096
|
+
}
|
|
1097
|
+
// 'skip' — note was incomplete (no preceding message, unknown participant)
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
891
1100
|
}
|
|
892
1101
|
}
|
|
893
1102
|
|
|
@@ -896,7 +1105,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
896
1105
|
// Check if raw content has arrow patterns for inference
|
|
897
1106
|
const hasArrows = lines.some((line) => ARROW_PATTERN.test(line.trim()));
|
|
898
1107
|
if (!hasArrows) {
|
|
899
|
-
return fail(1, 'No "
|
|
1108
|
+
return fail(1, 'No "sequence" header and no sequence content detected');
|
|
900
1109
|
}
|
|
901
1110
|
}
|
|
902
1111
|
|
|
@@ -935,7 +1144,7 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
935
1144
|
// Warn about empty groups
|
|
936
1145
|
for (const group of result.groups) {
|
|
937
1146
|
if (group.participantIds.length === 0) {
|
|
938
|
-
pushWarning(group.lineNumber, `
|
|
1147
|
+
pushWarning(group.lineNumber, `Empty group '${group.name}' — did you mean '== ${group.name} ==' for a section divider?`);
|
|
939
1148
|
}
|
|
940
1149
|
}
|
|
941
1150
|
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -28,15 +28,15 @@ import {
|
|
|
28
28
|
LEGEND_HEIGHT,
|
|
29
29
|
LEGEND_PILL_PAD,
|
|
30
30
|
LEGEND_PILL_FONT_SIZE,
|
|
31
|
-
LEGEND_PILL_FONT_W,
|
|
32
31
|
LEGEND_CAPSULE_PAD,
|
|
33
32
|
LEGEND_DOT_R,
|
|
34
33
|
LEGEND_ENTRY_FONT_SIZE,
|
|
35
|
-
LEGEND_ENTRY_FONT_W,
|
|
36
34
|
LEGEND_ENTRY_DOT_GAP,
|
|
37
35
|
LEGEND_ENTRY_TRAIL,
|
|
38
36
|
LEGEND_GROUP_GAP,
|
|
37
|
+
measureLegendText,
|
|
39
38
|
} from '../utils/legend-constants';
|
|
39
|
+
import { TITLE_FONT_SIZE, TITLE_FONT_WEIGHT } from '../utils/title-constants';
|
|
40
40
|
|
|
41
41
|
// ============================================================
|
|
42
42
|
// Layout Constants
|
|
@@ -932,7 +932,7 @@ export function renderSequenceDiagram(
|
|
|
932
932
|
);
|
|
933
933
|
if (tg) {
|
|
934
934
|
for (const entry of tg.entries) {
|
|
935
|
-
tagValueToColor.set(entry.value.toLowerCase(), resolveColor(entry.color));
|
|
935
|
+
tagValueToColor.set(entry.value.toLowerCase(), resolveColor(entry.color) ?? entry.color);
|
|
936
936
|
}
|
|
937
937
|
}
|
|
938
938
|
}
|
|
@@ -1560,8 +1560,8 @@ export function renderSequenceDiagram(
|
|
|
1560
1560
|
.attr('y', 30)
|
|
1561
1561
|
.attr('text-anchor', 'middle')
|
|
1562
1562
|
.attr('fill', palette.text)
|
|
1563
|
-
.attr('font-size',
|
|
1564
|
-
.attr('font-weight',
|
|
1563
|
+
.attr('font-size', TITLE_FONT_SIZE)
|
|
1564
|
+
.attr('font-weight', TITLE_FONT_WEIGHT)
|
|
1565
1565
|
.text(title);
|
|
1566
1566
|
|
|
1567
1567
|
if (parsed.titleLineNumber) {
|
|
@@ -1589,10 +1589,10 @@ export function renderSequenceDiagram(
|
|
|
1589
1589
|
const isActive =
|
|
1590
1590
|
!!activeTagGroup &&
|
|
1591
1591
|
tg.name.toLowerCase() === activeTagGroup.toLowerCase();
|
|
1592
|
-
const pillWidth = tg.name
|
|
1592
|
+
const pillWidth = measureLegendText(tg.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
1593
1593
|
const entries = tg.entries.map((e) => ({
|
|
1594
1594
|
value: e.value,
|
|
1595
|
-
color: resolveColor(e.color),
|
|
1595
|
+
color: resolveColor(e.color) ?? e.color,
|
|
1596
1596
|
}));
|
|
1597
1597
|
let totalWidth = pillWidth;
|
|
1598
1598
|
if (isActive) {
|
|
@@ -1601,7 +1601,7 @@ export function renderSequenceDiagram(
|
|
|
1601
1601
|
entriesWidth +=
|
|
1602
1602
|
LEGEND_DOT_R * 2 +
|
|
1603
1603
|
LEGEND_ENTRY_DOT_GAP +
|
|
1604
|
-
entry.value
|
|
1604
|
+
measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
1605
1605
|
LEGEND_ENTRY_TRAIL;
|
|
1606
1606
|
}
|
|
1607
1607
|
totalWidth = LEGEND_CAPSULE_PAD * 2 + pillWidth + 4 + entriesWidth;
|
|
@@ -1702,7 +1702,7 @@ export function renderSequenceDiagram(
|
|
|
1702
1702
|
.attr('fill', palette.textMuted)
|
|
1703
1703
|
.text(entry.value);
|
|
1704
1704
|
|
|
1705
|
-
entryX = textX + entry.value
|
|
1705
|
+
entryX = textX + measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) + LEGEND_ENTRY_TRAIL;
|
|
1706
1706
|
}
|
|
1707
1707
|
}
|
|
1708
1708
|
|
package/src/sitemap/layout.ts
CHANGED
|
@@ -6,6 +6,7 @@ import dagre from '@dagrejs/dagre';
|
|
|
6
6
|
import type { ParsedSitemap, SitemapNode } from './types';
|
|
7
7
|
import type { TagGroup } from '../utils/tag-groups';
|
|
8
8
|
import { resolveTagColor, injectDefaultTagMetadata } from '../utils/tag-groups';
|
|
9
|
+
import { LEGEND_PILL_FONT_SIZE, LEGEND_ENTRY_FONT_SIZE, measureLegendText } from '../utils/legend-constants';
|
|
9
10
|
|
|
10
11
|
// ============================================================
|
|
11
12
|
// Types
|
|
@@ -105,10 +106,8 @@ const CONTAINER_META_LINE_HEIGHT = 16;
|
|
|
105
106
|
// Legend (kanban-style pills)
|
|
106
107
|
const LEGEND_HEIGHT = 28;
|
|
107
108
|
const LEGEND_PILL_PAD = 16;
|
|
108
|
-
const LEGEND_PILL_FONT_W = 11 * 0.6;
|
|
109
109
|
const LEGEND_CAPSULE_PAD = 4;
|
|
110
110
|
const LEGEND_DOT_R = 4;
|
|
111
|
-
const LEGEND_ENTRY_FONT_W = 10 * 0.6;
|
|
112
111
|
const LEGEND_ENTRY_DOT_GAP = 4;
|
|
113
112
|
const LEGEND_ENTRY_TRAIL = 8;
|
|
114
113
|
const LEGEND_GROUP_GAP = 12;
|
|
@@ -178,7 +177,7 @@ function computeLegendGroups(
|
|
|
178
177
|
: group.entries;
|
|
179
178
|
if (visibleEntries.length === 0) continue;
|
|
180
179
|
|
|
181
|
-
const pillWidth = group.name
|
|
180
|
+
const pillWidth = measureLegendText(group.name, LEGEND_PILL_FONT_SIZE) + LEGEND_PILL_PAD;
|
|
182
181
|
const minPillWidth = pillWidth;
|
|
183
182
|
|
|
184
183
|
let entriesWidth = 0;
|
|
@@ -186,7 +185,7 @@ function computeLegendGroups(
|
|
|
186
185
|
entriesWidth +=
|
|
187
186
|
LEGEND_DOT_R * 2 +
|
|
188
187
|
LEGEND_ENTRY_DOT_GAP +
|
|
189
|
-
entry.value
|
|
188
|
+
measureLegendText(entry.value, LEGEND_ENTRY_FONT_SIZE) +
|
|
190
189
|
LEGEND_ENTRY_TRAIL;
|
|
191
190
|
}
|
|
192
191
|
const eyeSpace = LEGEND_EYE_SIZE + LEGEND_EYE_GAP;
|