@diagrammo/dgmo 0.2.27 → 0.2.28
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 +116 -108
- package/dist/index.cjs +543 -351
- 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 +540 -350
- package/dist/index.js.map +1 -1
- package/docs/ai-integration.md +125 -0
- package/docs/language-reference.md +784 -0
- package/package.json +10 -3
- 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/parser.ts +2 -10
- 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/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
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Inline Markdown — shared parsing + SVG rendering for text fields
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
import * as d3Selection from 'd3-selection';
|
|
6
|
+
import type { PaletteColors } from '../palettes';
|
|
7
|
+
|
|
8
|
+
export interface InlineSpan {
|
|
9
|
+
text: string;
|
|
10
|
+
bold?: boolean;
|
|
11
|
+
italic?: boolean;
|
|
12
|
+
code?: boolean;
|
|
13
|
+
href?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function parseInlineMarkdown(text: string): InlineSpan[] {
|
|
17
|
+
const spans: InlineSpan[] = [];
|
|
18
|
+
const regex =
|
|
19
|
+
/\*\*(.+?)\*\*|__(.+?)__|\*(.+?)\*|_(.+?)_|`(.+?)`|\[(.+?)\]\((.+?)\)|(https?:\/\/[^\s)>\]]+|www\.[^\s)>\]]+)|([^*_`[]+?(?=https?:\/\/|www\.|$)|[^*_`[]+)/g;
|
|
20
|
+
let match;
|
|
21
|
+
while ((match = regex.exec(text)) !== null) {
|
|
22
|
+
if (match[1]) spans.push({ text: match[1], bold: true }); // **bold**
|
|
23
|
+
else if (match[2]) spans.push({ text: match[2], bold: true }); // __bold__
|
|
24
|
+
else if (match[3]) spans.push({ text: match[3], italic: true }); // *italic*
|
|
25
|
+
else if (match[4]) spans.push({ text: match[4], italic: true }); // _italic_
|
|
26
|
+
else if (match[5]) spans.push({ text: match[5], code: true }); // `code`
|
|
27
|
+
else if (match[6]) spans.push({ text: match[6], href: match[7] }); // [text](url)
|
|
28
|
+
else if (match[8]) { // bare URL
|
|
29
|
+
const url = match[8];
|
|
30
|
+
const href = url.startsWith('www.') ? `https://${url}` : url;
|
|
31
|
+
spans.push({ text: url, href });
|
|
32
|
+
} else if (match[9]) spans.push({ text: match[9] }); // plain text
|
|
33
|
+
}
|
|
34
|
+
return spans;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const BARE_URL_MAX_DISPLAY = 35;
|
|
38
|
+
|
|
39
|
+
export function truncateBareUrl(url: string): string {
|
|
40
|
+
const stripped = url.replace(/^https?:\/\//, '').replace(/^www\./, '');
|
|
41
|
+
if (stripped.length <= BARE_URL_MAX_DISPLAY) return stripped;
|
|
42
|
+
return stripped.slice(0, BARE_URL_MAX_DISPLAY - 1) + '\u2026';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function renderInlineText(
|
|
46
|
+
textEl: d3Selection.Selection<SVGTextElement, unknown, null, undefined>,
|
|
47
|
+
text: string,
|
|
48
|
+
palette: PaletteColors,
|
|
49
|
+
fontSize?: number
|
|
50
|
+
): void {
|
|
51
|
+
const spans = parseInlineMarkdown(text);
|
|
52
|
+
for (const span of spans) {
|
|
53
|
+
if (span.href) {
|
|
54
|
+
// Bare URLs (text === href or href with https:// prepended) get truncated display;
|
|
55
|
+
// markdown links [text](url) keep their user-chosen text as-is.
|
|
56
|
+
const isBareUrl =
|
|
57
|
+
span.text === span.href ||
|
|
58
|
+
`https://${span.text}` === span.href;
|
|
59
|
+
const display = isBareUrl ? truncateBareUrl(span.text) : span.text;
|
|
60
|
+
const a = textEl.append('a').attr('href', span.href);
|
|
61
|
+
a.append('tspan')
|
|
62
|
+
.text(display)
|
|
63
|
+
.attr('fill', palette.primary)
|
|
64
|
+
.style('text-decoration', 'underline');
|
|
65
|
+
} else {
|
|
66
|
+
const tspan = textEl.append('tspan').text(span.text);
|
|
67
|
+
if (span.bold) tspan.attr('font-weight', 'bold');
|
|
68
|
+
if (span.italic) tspan.attr('font-style', 'italic');
|
|
69
|
+
if (span.code) {
|
|
70
|
+
tspan.attr('font-family', 'monospace');
|
|
71
|
+
if (fontSize) tspan.attr('font-size', fontSize - 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parser utilities — extracted from individual parsers to eliminate
|
|
3
|
+
* duplication of measureIndent, extractColor, header regexes, and
|
|
4
|
+
* pipe-metadata parsing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { resolveColor } from '../colors';
|
|
8
|
+
import type { PaletteColors } from '../palettes';
|
|
9
|
+
|
|
10
|
+
/** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
|
|
11
|
+
export function measureIndent(line: string): number {
|
|
12
|
+
let indent = 0;
|
|
13
|
+
for (const ch of line) {
|
|
14
|
+
if (ch === ' ') indent++;
|
|
15
|
+
else if (ch === '\t') indent += 4;
|
|
16
|
+
else break;
|
|
17
|
+
}
|
|
18
|
+
return indent;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Matches a trailing `(colorName)` suffix on a label. */
|
|
22
|
+
export const COLOR_SUFFIX_RE = /\(([^)]+)\)\s*$/;
|
|
23
|
+
|
|
24
|
+
/** Extract an optional trailing color suffix from a label, resolving via palette. */
|
|
25
|
+
export function extractColor(
|
|
26
|
+
label: string,
|
|
27
|
+
palette?: PaletteColors,
|
|
28
|
+
): { label: string; color?: string } {
|
|
29
|
+
const m = label.match(COLOR_SUFFIX_RE);
|
|
30
|
+
if (!m) return { label };
|
|
31
|
+
const colorName = m[1].trim();
|
|
32
|
+
return {
|
|
33
|
+
label: label.substring(0, m.index!).trim(),
|
|
34
|
+
color: resolveColor(colorName, palette),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Matches `chart: <type>` header lines. */
|
|
39
|
+
export const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
|
|
40
|
+
|
|
41
|
+
/** Matches `title: <text>` header lines. */
|
|
42
|
+
export const TITLE_RE = /^title\s*:\s*(.+)/i;
|
|
43
|
+
|
|
44
|
+
/** Matches `option: value` header lines. */
|
|
45
|
+
export const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
|
|
46
|
+
|
|
47
|
+
/** Parse pipe-delimited metadata from segments after the first (name) segment. */
|
|
48
|
+
export function parsePipeMetadata(
|
|
49
|
+
segments: string[],
|
|
50
|
+
aliasMap: Map<string, string> = new Map(),
|
|
51
|
+
): Record<string, string> {
|
|
52
|
+
const metadata: Record<string, string> = {};
|
|
53
|
+
for (let j = 1; j < segments.length; j++) {
|
|
54
|
+
for (const part of segments[j].split(',')) {
|
|
55
|
+
const trimmedPart = part.trim();
|
|
56
|
+
if (!trimmedPart) continue;
|
|
57
|
+
const colonIdx = trimmedPart.indexOf(':');
|
|
58
|
+
if (colonIdx > 0) {
|
|
59
|
+
const rawKey = trimmedPart.substring(0, colonIdx).trim().toLowerCase();
|
|
60
|
+
const key = aliasMap.get(rawKey) ?? rawKey;
|
|
61
|
+
const value = trimmedPart.substring(colonIdx + 1).trim();
|
|
62
|
+
metadata[key] = value;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return metadata;
|
|
67
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Shared tag-group types, regexes, and matchers
|
|
3
|
+
// ============================================================
|
|
4
|
+
|
|
5
|
+
/** A single entry inside a tag group: `Value(color)` */
|
|
6
|
+
export interface TagEntry {
|
|
7
|
+
value: string;
|
|
8
|
+
color: string;
|
|
9
|
+
lineNumber: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** A tag group block: heading + entries */
|
|
13
|
+
export interface TagGroup {
|
|
14
|
+
name: string;
|
|
15
|
+
alias?: string;
|
|
16
|
+
entries: TagEntry[];
|
|
17
|
+
/** Value of the entry marked `default` (nodes without metadata get this) */
|
|
18
|
+
defaultValue?: string;
|
|
19
|
+
lineNumber: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Result of matching a tag block heading */
|
|
23
|
+
export interface TagBlockMatch {
|
|
24
|
+
name: string;
|
|
25
|
+
alias: string | undefined;
|
|
26
|
+
colorHint: string | undefined;
|
|
27
|
+
/** true when the heading used `## …` (deprecated) */
|
|
28
|
+
deprecated: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// ── Regexes ─────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
/** New canonical syntax: `tag: GroupName [alias X] [(color)]` (case-insensitive) */
|
|
34
|
+
export const TAG_BLOCK_RE =
|
|
35
|
+
/^tag:\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/i;
|
|
36
|
+
|
|
37
|
+
/** Legacy syntax: `## GroupName [alias X] [(color)]` */
|
|
38
|
+
export const GROUP_HEADING_RE =
|
|
39
|
+
/^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
|
|
40
|
+
|
|
41
|
+
// ── Matchers ────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
/** Returns true if `trimmed` is a tag block heading in either syntax. */
|
|
44
|
+
export function isTagBlockHeading(trimmed: string): boolean {
|
|
45
|
+
return TAG_BLOCK_RE.test(trimmed) || GROUP_HEADING_RE.test(trimmed);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a tag block heading line into structured data.
|
|
50
|
+
* Returns `null` if the line is not a tag block heading.
|
|
51
|
+
*/
|
|
52
|
+
export function matchTagBlockHeading(trimmed: string): TagBlockMatch | null {
|
|
53
|
+
// Try new syntax first
|
|
54
|
+
const tagMatch = trimmed.match(TAG_BLOCK_RE);
|
|
55
|
+
if (tagMatch) {
|
|
56
|
+
return {
|
|
57
|
+
name: tagMatch[1].trim(),
|
|
58
|
+
alias: tagMatch[2] || undefined,
|
|
59
|
+
colorHint: tagMatch[3] || undefined,
|
|
60
|
+
deprecated: false,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Fall back to legacy syntax
|
|
65
|
+
const groupMatch = trimmed.match(GROUP_HEADING_RE);
|
|
66
|
+
if (groupMatch) {
|
|
67
|
+
return {
|
|
68
|
+
name: groupMatch[1].trim(),
|
|
69
|
+
alias: groupMatch[2] || undefined,
|
|
70
|
+
colorHint: groupMatch[3] || undefined,
|
|
71
|
+
deprecated: true,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return null;
|
|
76
|
+
}
|