@diagrammo/dgmo 0.2.27 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/dgmo-chart/SKILL.md +107 -0
- package/.claude/skills/dgmo-flowchart/SKILL.md +61 -0
- package/.claude/skills/dgmo-generate/SKILL.md +58 -0
- package/.claude/skills/dgmo-sequence/SKILL.md +83 -0
- package/.cursorrules +117 -0
- package/.github/copilot-instructions.md +117 -0
- package/.windsurfrules +117 -0
- package/README.md +10 -3
- package/dist/cli.cjs +366 -918
- package/dist/index.cjs +581 -396
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +39 -24
- package/dist/index.d.ts +39 -24
- package/dist/index.js +578 -395
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +125 -0
- package/docs/language-reference.md +786 -0
- package/package.json +15 -8
- package/src/c4/parser.ts +90 -74
- package/src/c4/renderer.ts +13 -12
- package/src/c4/types.ts +6 -4
- package/src/chart.ts +3 -2
- package/src/class/layout.ts +17 -12
- package/src/class/parser.ts +22 -52
- package/src/class/renderer.ts +44 -46
- package/src/class/types.ts +1 -1
- package/src/cli.ts +130 -19
- package/src/d3.ts +1 -1
- package/src/dgmo-mermaid.ts +1 -1
- package/src/dgmo-router.ts +1 -1
- package/src/echarts.ts +33 -13
- package/src/er/parser.ts +34 -43
- package/src/er/types.ts +1 -1
- package/src/graph/flowchart-parser.ts +2 -25
- package/src/graph/types.ts +1 -1
- package/src/index.ts +5 -0
- package/src/initiative-status/parser.ts +36 -7
- package/src/initiative-status/types.ts +1 -1
- package/src/kanban/parser.ts +32 -53
- package/src/kanban/renderer.ts +9 -8
- package/src/kanban/types.ts +6 -14
- package/src/org/parser.ts +47 -87
- package/src/org/resolver.ts +11 -12
- package/src/sequence/parser.ts +97 -15
- package/src/sequence/renderer.ts +62 -69
- package/src/utils/arrows.ts +75 -0
- package/src/utils/inline-markdown.ts +75 -0
- package/src/utils/parsing.ts +67 -0
- package/src/utils/tag-groups.ts +76 -0
package/src/org/parser.ts
CHANGED
|
@@ -1,26 +1,25 @@
|
|
|
1
|
-
import { resolveColor } from '../colors';
|
|
2
1
|
import type { PaletteColors } from '../palettes';
|
|
3
2
|
import type { DgmoError } from '../diagnostics';
|
|
4
3
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
4
|
+
import type { TagGroup, TagEntry } from '../utils/tag-groups';
|
|
5
|
+
import { isTagBlockHeading, matchTagBlockHeading } from '../utils/tag-groups';
|
|
6
|
+
import {
|
|
7
|
+
measureIndent,
|
|
8
|
+
extractColor,
|
|
9
|
+
parsePipeMetadata,
|
|
10
|
+
CHART_TYPE_RE,
|
|
11
|
+
TITLE_RE,
|
|
12
|
+
OPTION_RE,
|
|
13
|
+
} from '../utils/parsing';
|
|
5
14
|
|
|
6
15
|
// ============================================================
|
|
7
16
|
// Types
|
|
8
17
|
// ============================================================
|
|
9
18
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface OrgTagGroup {
|
|
17
|
-
name: string;
|
|
18
|
-
alias?: string;
|
|
19
|
-
entries: OrgTagEntry[];
|
|
20
|
-
/** Value of the entry marked `default` (nodes without metadata get this) */
|
|
21
|
-
defaultValue?: string;
|
|
22
|
-
lineNumber: number;
|
|
23
|
-
}
|
|
19
|
+
/** @deprecated Use `TagEntry` from `utils/tag-groups` */
|
|
20
|
+
export type OrgTagEntry = TagEntry;
|
|
21
|
+
/** @deprecated Use `TagGroup` from `utils/tag-groups` */
|
|
22
|
+
export type OrgTagGroup = TagGroup;
|
|
24
23
|
|
|
25
24
|
export interface OrgNode {
|
|
26
25
|
id: string;
|
|
@@ -47,48 +46,19 @@ export interface ParsedOrg {
|
|
|
47
46
|
// Helpers
|
|
48
47
|
// ============================================================
|
|
49
48
|
|
|
50
|
-
function measureIndent(line: string): number {
|
|
51
|
-
let indent = 0;
|
|
52
|
-
for (const ch of line) {
|
|
53
|
-
if (ch === ' ') indent++;
|
|
54
|
-
else if (ch === '\t') indent += 4;
|
|
55
|
-
else break;
|
|
56
|
-
}
|
|
57
|
-
return indent;
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
|
|
61
|
-
|
|
62
|
-
function extractColor(
|
|
63
|
-
label: string,
|
|
64
|
-
palette?: PaletteColors
|
|
65
|
-
): { label: string; color?: string } {
|
|
66
|
-
const m = label.match(COLOR_SUFFIX_RE);
|
|
67
|
-
if (!m) return { label };
|
|
68
|
-
const colorName = m[1].trim();
|
|
69
|
-
return {
|
|
70
|
-
label: label.substring(0, m.index!).trim(),
|
|
71
|
-
color: resolveColor(colorName, palette),
|
|
72
|
-
};
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
const GROUP_HEADING_RE = /^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
|
|
76
49
|
const CONTAINER_RE = /^\[([^\]]+)\]$/;
|
|
77
50
|
const METADATA_RE = /^([^:]+):\s*(.+)$/;
|
|
78
|
-
const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
|
|
79
|
-
const TITLE_RE = /^title\s*:\s*(.+)/i;
|
|
80
|
-
const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
|
|
81
51
|
|
|
82
52
|
// ============================================================
|
|
83
53
|
// Inference
|
|
84
54
|
// ============================================================
|
|
85
55
|
|
|
86
|
-
/** Returns true if content contains tag group headings (`##
|
|
56
|
+
/** Returns true if content contains tag group headings (`tag: …` or `## …`), suggesting an org chart. */
|
|
87
57
|
export function looksLikeOrg(content: string): boolean {
|
|
88
58
|
for (const line of content.split('\n')) {
|
|
89
59
|
const trimmed = line.trim();
|
|
90
60
|
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
91
|
-
if (
|
|
61
|
+
if (isTagBlockHeading(trimmed)) return true;
|
|
92
62
|
}
|
|
93
63
|
return false;
|
|
94
64
|
}
|
|
@@ -125,6 +95,11 @@ export function parseOrg(
|
|
|
125
95
|
if (!result.error) result.error = formatDgmoError(diag);
|
|
126
96
|
};
|
|
127
97
|
|
|
98
|
+
/** Push a non-fatal warning (does not set result.error). */
|
|
99
|
+
const pushWarning = (line: number, message: string): void => {
|
|
100
|
+
result.diagnostics.push(makeDgmoError(line, message, 'warning'));
|
|
101
|
+
};
|
|
102
|
+
|
|
128
103
|
if (!content || !content.trim()) {
|
|
129
104
|
return fail(0, 'No content provided');
|
|
130
105
|
}
|
|
@@ -189,41 +164,43 @@ export function parseOrg(
|
|
|
189
164
|
}
|
|
190
165
|
}
|
|
191
166
|
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if (optMatch && !trimmed.startsWith('##')) {
|
|
197
|
-
const key = optMatch[1].trim().toLowerCase();
|
|
198
|
-
if (key !== 'chart' && key !== 'title') {
|
|
199
|
-
result.options[key] = optMatch[2].trim();
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
// ## Tag group heading
|
|
206
|
-
const groupMatch = trimmed.match(GROUP_HEADING_RE);
|
|
207
|
-
if (groupMatch) {
|
|
167
|
+
// Tag group heading — `tag: Name` (new) or `## Name` (deprecated)
|
|
168
|
+
// Must be checked BEFORE OPTION_RE to prevent `tag: Rank` being swallowed as option `tag=Rank`
|
|
169
|
+
const tagBlockMatch = matchTagBlockHeading(trimmed);
|
|
170
|
+
if (tagBlockMatch) {
|
|
208
171
|
if (contentStarted) {
|
|
209
|
-
pushError(lineNumber, 'Tag groups
|
|
172
|
+
pushError(lineNumber, 'Tag groups must appear before org content');
|
|
210
173
|
continue;
|
|
211
174
|
}
|
|
212
|
-
|
|
213
|
-
|
|
175
|
+
if (tagBlockMatch.deprecated) {
|
|
176
|
+
pushWarning(lineNumber, `'## ${tagBlockMatch.name}' is deprecated for tag groups — use 'tag: ${tagBlockMatch.name}' instead`);
|
|
177
|
+
}
|
|
214
178
|
currentTagGroup = {
|
|
215
|
-
name:
|
|
216
|
-
alias,
|
|
179
|
+
name: tagBlockMatch.name,
|
|
180
|
+
alias: tagBlockMatch.alias,
|
|
217
181
|
entries: [],
|
|
218
182
|
lineNumber,
|
|
219
183
|
};
|
|
220
|
-
if (alias) {
|
|
221
|
-
aliasMap.set(alias.toLowerCase(),
|
|
184
|
+
if (tagBlockMatch.alias) {
|
|
185
|
+
aliasMap.set(tagBlockMatch.alias.toLowerCase(), tagBlockMatch.name.toLowerCase());
|
|
222
186
|
}
|
|
223
187
|
result.tagGroups.push(currentTagGroup);
|
|
224
188
|
continue;
|
|
225
189
|
}
|
|
226
190
|
|
|
191
|
+
// Generic header options (key: value lines before content/tag groups)
|
|
192
|
+
// Only match non-indented lines with simple hyphenated keys
|
|
193
|
+
if (!contentStarted && !currentTagGroup && measureIndent(line) === 0) {
|
|
194
|
+
const optMatch = trimmed.match(OPTION_RE);
|
|
195
|
+
if (optMatch) {
|
|
196
|
+
const key = optMatch[1].trim().toLowerCase();
|
|
197
|
+
if (key !== 'chart' && key !== 'title') {
|
|
198
|
+
result.options[key] = optMatch[2].trim();
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
227
204
|
// Tag group entries (indented Value(color) [default] under ## heading)
|
|
228
205
|
if (currentTagGroup && !contentStarted) {
|
|
229
206
|
const indent = measureIndent(line);
|
|
@@ -342,24 +319,7 @@ function parseNodeLabel(
|
|
|
342
319
|
let rawLabel = segments[0];
|
|
343
320
|
const { label, color } = extractColor(rawLabel, palette);
|
|
344
321
|
|
|
345
|
-
const metadata
|
|
346
|
-
// Collect all metadata parts: split pipe segments further on commas
|
|
347
|
-
const metaParts: string[] = [];
|
|
348
|
-
for (let j = 1; j < segments.length; j++) {
|
|
349
|
-
for (const part of segments[j].split(',')) {
|
|
350
|
-
const trimmedPart = part.trim();
|
|
351
|
-
if (trimmedPart) metaParts.push(trimmedPart);
|
|
352
|
-
}
|
|
353
|
-
}
|
|
354
|
-
for (const part of metaParts) {
|
|
355
|
-
const colonIdx = part.indexOf(':');
|
|
356
|
-
if (colonIdx > 0) {
|
|
357
|
-
const rawKey = part.substring(0, colonIdx).trim().toLowerCase();
|
|
358
|
-
const key = aliasMap.get(rawKey) ?? rawKey;
|
|
359
|
-
const value = part.substring(colonIdx + 1).trim();
|
|
360
|
-
metadata[key] = value;
|
|
361
|
-
}
|
|
362
|
-
}
|
|
322
|
+
const metadata = parsePipeMetadata(segments, aliasMap);
|
|
363
323
|
|
|
364
324
|
return {
|
|
365
325
|
id: `node-${counter}`,
|
package/src/org/resolver.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { DgmoError } from '../diagnostics';
|
|
2
2
|
import { makeDgmoError } from '../diagnostics';
|
|
3
|
+
import { isTagBlockHeading, matchTagBlockHeading } from '../utils/tag-groups';
|
|
3
4
|
|
|
4
5
|
// ============================================================
|
|
5
6
|
// Types
|
|
@@ -25,7 +26,6 @@ const IMPORT_RE = /^(\s+)import:\s+(.+\.dgmo)\s*$/i;
|
|
|
25
26
|
const TAGS_RE = /^tags:\s+(.+\.dgmo)\s*$/i;
|
|
26
27
|
const HEADER_RE = /^(chart|title)\s*:/i;
|
|
27
28
|
const OPTION_RE = /^[a-z][a-z0-9-]*\s*:/i;
|
|
28
|
-
const GROUP_HEADING_RE = /^##\s+/;
|
|
29
29
|
|
|
30
30
|
// ============================================================
|
|
31
31
|
// Path Helpers (pure string ops — no Node `path` dependency)
|
|
@@ -67,10 +67,9 @@ function extractTagGroups(lines: string[]): TagGroupBlock[] {
|
|
|
67
67
|
|
|
68
68
|
for (const line of lines) {
|
|
69
69
|
const trimmed = line.trim();
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
const
|
|
73
|
-
const name = nameMatch ? nameMatch[1].trim().toLowerCase() : trimmed.substring(3).trim().toLowerCase();
|
|
70
|
+
const headingMatch = matchTagBlockHeading(trimmed);
|
|
71
|
+
if (headingMatch) {
|
|
72
|
+
const name = headingMatch.name.toLowerCase();
|
|
74
73
|
current = { name, lines: [line] };
|
|
75
74
|
blocks.push(current);
|
|
76
75
|
} else if (current) {
|
|
@@ -144,7 +143,7 @@ function parseFileHeader(lines: string[]): ParsedHeader {
|
|
|
144
143
|
}
|
|
145
144
|
|
|
146
145
|
// Other option-like header lines (non-indented key: value)
|
|
147
|
-
if (OPTION_RE.test(trimmed) && !trimmed
|
|
146
|
+
if (OPTION_RE.test(trimmed) && !isTagBlockHeading(trimmed) && !lines[i].match(/^\s/)) {
|
|
148
147
|
// Check it's not a content line (node with metadata)
|
|
149
148
|
const key = trimmed.split(':')[0].trim().toLowerCase();
|
|
150
149
|
if (key !== 'chart' && key !== 'title' && !trimmed.includes('|')) {
|
|
@@ -207,7 +206,7 @@ async function resolveFile(
|
|
|
207
206
|
headerLines.push(lines[i]);
|
|
208
207
|
continue;
|
|
209
208
|
}
|
|
210
|
-
if (
|
|
209
|
+
if (isTagBlockHeading(trimmed)) continue; // skip inline tag group headings
|
|
211
210
|
if (lines[i] !== trimmed) continue; // skip tag group entries (indented lines)
|
|
212
211
|
|
|
213
212
|
const tagsMatch = trimmed.match(TAGS_RE);
|
|
@@ -248,7 +247,7 @@ async function resolveFile(
|
|
|
248
247
|
if (!importMatch) {
|
|
249
248
|
// Pass through — skip inline tag group lines (already extracted above)
|
|
250
249
|
const trimmed = line.trim();
|
|
251
|
-
if (
|
|
250
|
+
if (isTagBlockHeading(trimmed) || (inlineTagGroups.length > 0 && isTagGroupEntry(line, bodyLines, i))) {
|
|
252
251
|
continue;
|
|
253
252
|
}
|
|
254
253
|
resolvedBodyLines.push(line);
|
|
@@ -386,7 +385,7 @@ function findBodyStart(lines: string[]): number {
|
|
|
386
385
|
}
|
|
387
386
|
|
|
388
387
|
// Tag group heading
|
|
389
|
-
if (
|
|
388
|
+
if (isTagBlockHeading(trimmed)) {
|
|
390
389
|
inTagGroup = true;
|
|
391
390
|
continue;
|
|
392
391
|
}
|
|
@@ -405,7 +404,7 @@ function findBodyStart(lines: string[]): number {
|
|
|
405
404
|
if (TAGS_RE.test(trimmed)) continue;
|
|
406
405
|
|
|
407
406
|
// Option-like lines (non-indented key: value before content)
|
|
408
|
-
if (OPTION_RE.test(trimmed) && !lines[i].match(/^\s/) && !trimmed.includes('|')) {
|
|
407
|
+
if (OPTION_RE.test(trimmed) && !isTagBlockHeading(trimmed) && !lines[i].match(/^\s/) && !trimmed.includes('|')) {
|
|
409
408
|
const key = trimmed.split(':')[0].trim().toLowerCase();
|
|
410
409
|
if (key !== 'chart' && key !== 'title') {
|
|
411
410
|
continue;
|
|
@@ -420,7 +419,7 @@ function findBodyStart(lines: string[]): number {
|
|
|
420
419
|
}
|
|
421
420
|
|
|
422
421
|
/**
|
|
423
|
-
* Check if a line is a tag group entry (indented line under a
|
|
422
|
+
* Check if a line is a tag group entry (indented line under a tag block heading).
|
|
424
423
|
*/
|
|
425
424
|
function isTagGroupEntry(line: string, allLines: string[], index: number): boolean {
|
|
426
425
|
if (!line.match(/^\s+/)) return false;
|
|
@@ -428,7 +427,7 @@ function isTagGroupEntry(line: string, allLines: string[], index: number): boole
|
|
|
428
427
|
for (let i = index - 1; i >= 0; i--) {
|
|
429
428
|
const prev = allLines[i].trim();
|
|
430
429
|
if (prev === '' || prev.startsWith('//')) continue;
|
|
431
|
-
if (
|
|
430
|
+
if (isTagBlockHeading(prev)) return true;
|
|
432
431
|
if (allLines[i].match(/^\s+/)) continue; // another entry
|
|
433
432
|
return false;
|
|
434
433
|
}
|
package/src/sequence/parser.ts
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
import { inferParticipantType } from './participant-inference';
|
|
6
6
|
import type { DgmoError } from '../diagnostics';
|
|
7
7
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
8
|
+
import { parseArrow } from '../utils/arrows';
|
|
9
|
+
import { measureIndent } from '../utils/parsing';
|
|
8
10
|
|
|
9
11
|
/**
|
|
10
12
|
* Participant types that can be declared via "Name is a type" syntax.
|
|
@@ -60,6 +62,7 @@ export interface SequenceMessage {
|
|
|
60
62
|
returnLabel?: string;
|
|
61
63
|
lineNumber: number;
|
|
62
64
|
async?: boolean;
|
|
65
|
+
bidirectional?: boolean;
|
|
63
66
|
}
|
|
64
67
|
|
|
65
68
|
/**
|
|
@@ -159,8 +162,9 @@ const GROUP_HEADING_PATTERN = /^##\s+(.+?)(?:\(([^)]+)\))?\s*$/;
|
|
|
159
162
|
// Section divider pattern — "== Label ==", "== Label(color) ==", or "== Label" (trailing == optional)
|
|
160
163
|
const SECTION_PATTERN = /^==\s+(.+?)(?:\s*==)?\s*$/;
|
|
161
164
|
|
|
162
|
-
// Arrow pattern for sequence inference — "A -> B: message"
|
|
163
|
-
|
|
165
|
+
// Arrow pattern for sequence inference — "A -> B: message", "A ~> B: message",
|
|
166
|
+
// "A -label-> B", "A ~label~> B", "A <-> B", "A <~> B"
|
|
167
|
+
const ARROW_PATTERN = /\S+\s*(?:<->|<~>|->|~>|-\S+->|~\S+~>|<-\S+->|<~\S+~>)\s*\S+/;
|
|
164
168
|
|
|
165
169
|
// <- return syntax: "Login <- 200 OK"
|
|
166
170
|
const ARROW_RETURN_PATTERN = /^(.+?)\s*<-\s*(.+)$/;
|
|
@@ -212,19 +216,6 @@ function parseReturnLabel(rawLabel: string): {
|
|
|
212
216
|
return { label: rawLabel };
|
|
213
217
|
}
|
|
214
218
|
|
|
215
|
-
/**
|
|
216
|
-
* Measure leading whitespace of a line, normalizing tabs to 4 spaces.
|
|
217
|
-
*/
|
|
218
|
-
function measureIndent(line: string): number {
|
|
219
|
-
let indent = 0;
|
|
220
|
-
for (const ch of line) {
|
|
221
|
-
if (ch === ' ') indent++;
|
|
222
|
-
else if (ch === '\t') indent += 4;
|
|
223
|
-
else break;
|
|
224
|
-
}
|
|
225
|
-
return indent;
|
|
226
|
-
}
|
|
227
|
-
|
|
228
219
|
/**
|
|
229
220
|
* Parse a .dgmo file with `chart: sequence` into a structured representation.
|
|
230
221
|
*/
|
|
@@ -519,6 +510,97 @@ export function parseSequenceDgmo(content: string): ParsedSequenceDgmo {
|
|
|
519
510
|
continue;
|
|
520
511
|
}
|
|
521
512
|
|
|
513
|
+
// ---- Labeled arrows: -label->, ~label~>, <-label->, <~label~> ----
|
|
514
|
+
// Must be checked BEFORE plain arrow patterns to avoid partial matches
|
|
515
|
+
const labeledArrow = parseArrow(trimmed);
|
|
516
|
+
if (labeledArrow && 'error' in labeledArrow) {
|
|
517
|
+
pushError(lineNumber, labeledArrow.error);
|
|
518
|
+
continue;
|
|
519
|
+
}
|
|
520
|
+
if (labeledArrow) {
|
|
521
|
+
contentStarted = true;
|
|
522
|
+
const { from, to, label, async: isAsync, bidirectional } = labeledArrow;
|
|
523
|
+
lastMsgFrom = from;
|
|
524
|
+
|
|
525
|
+
const msg: SequenceMessage = {
|
|
526
|
+
from,
|
|
527
|
+
to,
|
|
528
|
+
label,
|
|
529
|
+
returnLabel: undefined,
|
|
530
|
+
lineNumber,
|
|
531
|
+
...(isAsync ? { async: true } : {}),
|
|
532
|
+
...(bidirectional ? { bidirectional: true } : {}),
|
|
533
|
+
};
|
|
534
|
+
result.messages.push(msg);
|
|
535
|
+
currentContainer().push(msg);
|
|
536
|
+
|
|
537
|
+
// Auto-register participants
|
|
538
|
+
if (!result.participants.some((p) => p.id === from)) {
|
|
539
|
+
result.participants.push({
|
|
540
|
+
id: from,
|
|
541
|
+
label: from,
|
|
542
|
+
type: inferParticipantType(from),
|
|
543
|
+
lineNumber,
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
if (!result.participants.some((p) => p.id === to)) {
|
|
547
|
+
result.participants.push({
|
|
548
|
+
id: to,
|
|
549
|
+
label: to,
|
|
550
|
+
type: inferParticipantType(to),
|
|
551
|
+
lineNumber,
|
|
552
|
+
});
|
|
553
|
+
}
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// ---- Plain bidi arrows: <-> and <~> ----
|
|
558
|
+
// Must be checked BEFORE unidirectional plain arrows
|
|
559
|
+
const bidiSyncMatch = trimmed.match(
|
|
560
|
+
/^(\S+)\s*<->\s*([^\s:]+)\s*(?::\s*(.+))?$/
|
|
561
|
+
);
|
|
562
|
+
const bidiAsyncMatch = trimmed.match(
|
|
563
|
+
/^(\S+)\s*<~>\s*([^\s:]+)\s*(?::\s*(.+))?$/
|
|
564
|
+
);
|
|
565
|
+
const bidiMatch = bidiSyncMatch || bidiAsyncMatch;
|
|
566
|
+
if (bidiMatch) {
|
|
567
|
+
contentStarted = true;
|
|
568
|
+
const from = bidiMatch[1];
|
|
569
|
+
const to = bidiMatch[2];
|
|
570
|
+
lastMsgFrom = from;
|
|
571
|
+
const rawLabel = bidiMatch[3]?.trim() || '';
|
|
572
|
+
const isBidiAsync = !!bidiAsyncMatch;
|
|
573
|
+
|
|
574
|
+
const msg: SequenceMessage = {
|
|
575
|
+
from,
|
|
576
|
+
to,
|
|
577
|
+
label: rawLabel,
|
|
578
|
+
lineNumber,
|
|
579
|
+
bidirectional: true,
|
|
580
|
+
...(isBidiAsync ? { async: true } : {}),
|
|
581
|
+
};
|
|
582
|
+
result.messages.push(msg);
|
|
583
|
+
currentContainer().push(msg);
|
|
584
|
+
|
|
585
|
+
if (!result.participants.some((p) => p.id === from)) {
|
|
586
|
+
result.participants.push({
|
|
587
|
+
id: from,
|
|
588
|
+
label: from,
|
|
589
|
+
type: inferParticipantType(from),
|
|
590
|
+
lineNumber,
|
|
591
|
+
});
|
|
592
|
+
}
|
|
593
|
+
if (!result.participants.some((p) => p.id === to)) {
|
|
594
|
+
result.participants.push({
|
|
595
|
+
id: to,
|
|
596
|
+
label: to,
|
|
597
|
+
type: inferParticipantType(to),
|
|
598
|
+
lineNumber,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
|
|
522
604
|
// Match ~> (async arrow) or -> (sync arrow)
|
|
523
605
|
let isAsync = false;
|
|
524
606
|
const asyncArrowMatch = trimmed.match(
|
package/src/sequence/renderer.ts
CHANGED
|
@@ -5,6 +5,13 @@
|
|
|
5
5
|
import * as d3Selection from 'd3-selection';
|
|
6
6
|
import type { PaletteColors } from '../palettes';
|
|
7
7
|
import { resolveColor } from '../colors';
|
|
8
|
+
import {
|
|
9
|
+
parseInlineMarkdown,
|
|
10
|
+
truncateBareUrl,
|
|
11
|
+
renderInlineText,
|
|
12
|
+
} from '../utils/inline-markdown';
|
|
13
|
+
export type { InlineSpan } from '../utils/inline-markdown';
|
|
14
|
+
export { parseInlineMarkdown, truncateBareUrl };
|
|
8
15
|
import { FONT_FAMILY } from '../fonts';
|
|
9
16
|
import type {
|
|
10
17
|
ParsedSequenceDgmo,
|
|
@@ -44,74 +51,6 @@ const NOTE_CHARS_PER_LINE = Math.floor((NOTE_MAX_W - NOTE_PAD_H * 2 - NOTE_FOLD)
|
|
|
44
51
|
const COLLAPSED_NOTE_H = 20;
|
|
45
52
|
const COLLAPSED_NOTE_W = 40;
|
|
46
53
|
|
|
47
|
-
interface InlineSpan {
|
|
48
|
-
text: string;
|
|
49
|
-
bold?: boolean;
|
|
50
|
-
italic?: boolean;
|
|
51
|
-
code?: boolean;
|
|
52
|
-
href?: string;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export function parseInlineMarkdown(text: string): InlineSpan[] {
|
|
56
|
-
const spans: InlineSpan[] = [];
|
|
57
|
-
const regex =
|
|
58
|
-
/\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)|(https?:\/\/[^\s)>\]]+|www\.[^\s)>\]]+)|([^*_`[]+?(?=https?:\/\/|www\.|$)|[^*_`[]+)/g;
|
|
59
|
-
let match;
|
|
60
|
-
while ((match = regex.exec(text)) !== null) {
|
|
61
|
-
if (match[1]) spans.push({ text: match[1], bold: true }); // **bold**
|
|
62
|
-
else if (match[2]) spans.push({ text: match[2], bold: true }); // __bold__
|
|
63
|
-
else if (match[3]) spans.push({ text: match[3], italic: true }); // *italic*
|
|
64
|
-
else if (match[4]) spans.push({ text: match[4], italic: true }); // _italic_
|
|
65
|
-
else if (match[5]) spans.push({ text: match[5], code: true }); // `code`
|
|
66
|
-
else if (match[6]) spans.push({ text: match[6], href: match[7] }); // [text](url)
|
|
67
|
-
else if (match[8]) { // bare URL
|
|
68
|
-
const url = match[8];
|
|
69
|
-
const href = url.startsWith('www.') ? `https://${url}` : url;
|
|
70
|
-
spans.push({ text: url, href });
|
|
71
|
-
} else if (match[9]) spans.push({ text: match[9] }); // plain text
|
|
72
|
-
}
|
|
73
|
-
return spans;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
const BARE_URL_MAX_DISPLAY = 35;
|
|
77
|
-
|
|
78
|
-
export function truncateBareUrl(url: string): string {
|
|
79
|
-
const stripped = url.replace(/^https?:\/\//, '').replace(/^www\./, '');
|
|
80
|
-
if (stripped.length <= BARE_URL_MAX_DISPLAY) return stripped;
|
|
81
|
-
return stripped.slice(0, BARE_URL_MAX_DISPLAY - 1) + '\u2026';
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function renderInlineText(
|
|
85
|
-
textEl: d3Selection.Selection<SVGTextElement, unknown, null, undefined>,
|
|
86
|
-
text: string,
|
|
87
|
-
palette: PaletteColors,
|
|
88
|
-
fontSize?: number
|
|
89
|
-
): void {
|
|
90
|
-
const spans = parseInlineMarkdown(text);
|
|
91
|
-
for (const span of spans) {
|
|
92
|
-
if (span.href) {
|
|
93
|
-
// Bare URLs (text === href or href with https:// prepended) get truncated display;
|
|
94
|
-
// markdown links [text](url) keep their user-chosen text as-is.
|
|
95
|
-
const isBareUrl =
|
|
96
|
-
span.text === span.href ||
|
|
97
|
-
`https://${span.text}` === span.href;
|
|
98
|
-
const display = isBareUrl ? truncateBareUrl(span.text) : span.text;
|
|
99
|
-
const a = textEl.append('a').attr('href', span.href);
|
|
100
|
-
a.append('tspan')
|
|
101
|
-
.text(display)
|
|
102
|
-
.attr('fill', palette.primary)
|
|
103
|
-
.style('text-decoration', 'underline');
|
|
104
|
-
} else {
|
|
105
|
-
const tspan = textEl.append('tspan').text(span.text);
|
|
106
|
-
if (span.bold) tspan.attr('font-weight', 'bold');
|
|
107
|
-
if (span.italic) tspan.attr('font-style', 'italic');
|
|
108
|
-
if (span.code) {
|
|
109
|
-
tspan.attr('font-family', 'monospace');
|
|
110
|
-
if (fontSize) tspan.attr('font-size', fontSize - 1);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
54
|
|
|
116
55
|
function wrapTextLines(text: string, maxChars: number): string[] {
|
|
117
56
|
const rawLines = text.split('\n');
|
|
@@ -599,6 +538,7 @@ export interface RenderStep {
|
|
|
599
538
|
label: string;
|
|
600
539
|
messageIndex: number;
|
|
601
540
|
async?: boolean;
|
|
541
|
+
bidirectional?: boolean;
|
|
602
542
|
}
|
|
603
543
|
|
|
604
544
|
/**
|
|
@@ -639,8 +579,14 @@ export function buildRenderSequence(messages: SequenceMessage[]): RenderStep[] {
|
|
|
639
579
|
label: msg.label,
|
|
640
580
|
messageIndex: mi,
|
|
641
581
|
...(msg.async ? { async: true } : {}),
|
|
582
|
+
...(msg.bidirectional ? { bidirectional: true } : {}),
|
|
642
583
|
});
|
|
643
584
|
|
|
585
|
+
// Bidirectional messages: no activation bar, no return
|
|
586
|
+
if (msg.bidirectional) {
|
|
587
|
+
continue;
|
|
588
|
+
}
|
|
589
|
+
|
|
644
590
|
// Async messages: no return arrow, no activation on target
|
|
645
591
|
if (msg.async) {
|
|
646
592
|
continue;
|
|
@@ -1400,6 +1346,42 @@ export function renderSequenceDiagram(
|
|
|
1400
1346
|
.attr('stroke', palette.text)
|
|
1401
1347
|
.attr('stroke-width', 1.2);
|
|
1402
1348
|
|
|
1349
|
+
// Filled reverse arrowhead for bidirectional sync arrows (marker-start)
|
|
1350
|
+
defs
|
|
1351
|
+
.append('marker')
|
|
1352
|
+
.attr('id', 'seq-arrowhead-reverse')
|
|
1353
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
|
|
1354
|
+
.attr('refX', 0)
|
|
1355
|
+
.attr('refY', ARROWHEAD_SIZE / 2)
|
|
1356
|
+
.attr('markerWidth', ARROWHEAD_SIZE)
|
|
1357
|
+
.attr('markerHeight', ARROWHEAD_SIZE)
|
|
1358
|
+
.attr('orient', 'auto')
|
|
1359
|
+
.append('polygon')
|
|
1360
|
+
.attr(
|
|
1361
|
+
'points',
|
|
1362
|
+
`${ARROWHEAD_SIZE},0 0,${ARROWHEAD_SIZE / 2} ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE}`
|
|
1363
|
+
)
|
|
1364
|
+
.attr('fill', palette.text);
|
|
1365
|
+
|
|
1366
|
+
// Open reverse arrowhead for bidirectional async arrows (marker-start)
|
|
1367
|
+
defs
|
|
1368
|
+
.append('marker')
|
|
1369
|
+
.attr('id', 'seq-arrowhead-async-reverse')
|
|
1370
|
+
.attr('viewBox', `0 0 ${ARROWHEAD_SIZE} ${ARROWHEAD_SIZE}`)
|
|
1371
|
+
.attr('refX', 0)
|
|
1372
|
+
.attr('refY', ARROWHEAD_SIZE / 2)
|
|
1373
|
+
.attr('markerWidth', ARROWHEAD_SIZE)
|
|
1374
|
+
.attr('markerHeight', ARROWHEAD_SIZE)
|
|
1375
|
+
.attr('orient', 'auto')
|
|
1376
|
+
.append('polyline')
|
|
1377
|
+
.attr(
|
|
1378
|
+
'points',
|
|
1379
|
+
`${ARROWHEAD_SIZE},0 0,${ARROWHEAD_SIZE / 2} ${ARROWHEAD_SIZE},${ARROWHEAD_SIZE}`
|
|
1380
|
+
)
|
|
1381
|
+
.attr('fill', 'none')
|
|
1382
|
+
.attr('stroke', palette.text)
|
|
1383
|
+
.attr('stroke-width', 1.2);
|
|
1384
|
+
|
|
1403
1385
|
// Render title
|
|
1404
1386
|
if (title) {
|
|
1405
1387
|
const titleEl = svg
|
|
@@ -1954,7 +1936,12 @@ export function renderSequenceDiagram(
|
|
|
1954
1936
|
const markerRef = step.async
|
|
1955
1937
|
? 'url(#seq-arrowhead-async)'
|
|
1956
1938
|
: 'url(#seq-arrowhead)';
|
|
1957
|
-
|
|
1939
|
+
const markerStartRef = step.bidirectional
|
|
1940
|
+
? step.async
|
|
1941
|
+
? 'url(#seq-arrowhead-async-reverse)'
|
|
1942
|
+
: 'url(#seq-arrowhead-reverse)'
|
|
1943
|
+
: null;
|
|
1944
|
+
const line = svg
|
|
1958
1945
|
.append('line')
|
|
1959
1946
|
.attr('x1', x1)
|
|
1960
1947
|
.attr('y1', y)
|
|
@@ -1970,6 +1957,12 @@ export function renderSequenceDiagram(
|
|
|
1970
1957
|
)
|
|
1971
1958
|
.attr('data-msg-index', String(step.messageIndex))
|
|
1972
1959
|
.attr('data-step-index', String(i));
|
|
1960
|
+
if (markerStartRef) {
|
|
1961
|
+
line.attr('marker-start', markerStartRef);
|
|
1962
|
+
}
|
|
1963
|
+
if (step.bidirectional && step.async) {
|
|
1964
|
+
line.attr('stroke-dasharray', '6 4');
|
|
1965
|
+
}
|
|
1973
1966
|
|
|
1974
1967
|
if (step.label) {
|
|
1975
1968
|
const midX = (x1 + x2) / 2;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Shared Arrow Parsing Utility
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// Labeled arrow syntax: `-label->`, `~label~>`, `<-label->`, `<~label~>`
|
|
6
|
+
// Used by sequence, C4, and init-status parsers.
|
|
7
|
+
|
|
8
|
+
export interface ParsedArrow {
|
|
9
|
+
from: string;
|
|
10
|
+
to: string;
|
|
11
|
+
label: string;
|
|
12
|
+
async: boolean;
|
|
13
|
+
bidirectional: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Bidi patterns checked FIRST — longer prefix avoids partial match
|
|
17
|
+
const BIDI_SYNC_LABELED_RE = /^(\S+)\s+<-(.+)->\s+(\S+)$/;
|
|
18
|
+
const BIDI_ASYNC_LABELED_RE = /^(\S+)\s+<~(.+)~>\s+(\S+)$/;
|
|
19
|
+
const SYNC_LABELED_RE = /^(\S+)\s+-(.+)->\s+(\S+)$/;
|
|
20
|
+
const ASYNC_LABELED_RE = /^(\S+)\s+~(.+)~>\s+(\S+)$/;
|
|
21
|
+
|
|
22
|
+
const ARROW_CHARS = ['->', '~>', '<->', '<~>'];
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Try to parse a labeled arrow from a trimmed line.
|
|
26
|
+
*
|
|
27
|
+
* Returns:
|
|
28
|
+
* - `ParsedArrow` if matched and valid
|
|
29
|
+
* - `{ error: string }` if matched but label contains arrow chars
|
|
30
|
+
* - `null` if not a labeled arrow (caller should fall through to plain patterns)
|
|
31
|
+
*/
|
|
32
|
+
export function parseArrow(
|
|
33
|
+
line: string,
|
|
34
|
+
): ParsedArrow | { error: string } | null {
|
|
35
|
+
// Order: bidi first (longer prefix), then unidirectional
|
|
36
|
+
const patterns: {
|
|
37
|
+
re: RegExp;
|
|
38
|
+
async: boolean;
|
|
39
|
+
bidirectional: boolean;
|
|
40
|
+
}[] = [
|
|
41
|
+
{ re: BIDI_SYNC_LABELED_RE, async: false, bidirectional: true },
|
|
42
|
+
{ re: BIDI_ASYNC_LABELED_RE, async: true, bidirectional: true },
|
|
43
|
+
{ re: SYNC_LABELED_RE, async: false, bidirectional: false },
|
|
44
|
+
{ re: ASYNC_LABELED_RE, async: true, bidirectional: false },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
for (const { re, async: isAsync, bidirectional } of patterns) {
|
|
48
|
+
const m = line.match(re);
|
|
49
|
+
if (!m) continue;
|
|
50
|
+
|
|
51
|
+
const label = m[2].trim();
|
|
52
|
+
|
|
53
|
+
// Empty label (e.g. `--> B`) — fall through to plain arrow handling
|
|
54
|
+
if (!label) return null;
|
|
55
|
+
|
|
56
|
+
// Validate: no arrow chars inside label
|
|
57
|
+
for (const arrow of ARROW_CHARS) {
|
|
58
|
+
if (label.includes(arrow)) {
|
|
59
|
+
return {
|
|
60
|
+
error: 'Arrow characters (->, ~>) are not allowed inside labels',
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
from: m[1],
|
|
67
|
+
to: m[3],
|
|
68
|
+
label,
|
|
69
|
+
async: isAsync,
|
|
70
|
+
bidirectional,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return null;
|
|
75
|
+
}
|