@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/utils/parsing.ts
CHANGED
|
@@ -7,6 +7,20 @@
|
|
|
7
7
|
import { resolveColor } from '../colors';
|
|
8
8
|
import type { PaletteColors } from '../palettes';
|
|
9
9
|
|
|
10
|
+
// ── All known chart types ────────────────────────────────────
|
|
11
|
+
/** Complete set of recognized chart type identifiers. */
|
|
12
|
+
export const ALL_CHART_TYPES = new Set([
|
|
13
|
+
// data charts
|
|
14
|
+
'bar', 'line', 'pie', 'doughnut', 'area', 'polar-area', 'radar',
|
|
15
|
+
'bar-stacked', 'multi-line', 'scatter', 'sankey', 'chord', 'function',
|
|
16
|
+
'heatmap', 'funnel',
|
|
17
|
+
// visualizations
|
|
18
|
+
'slope', 'wordcloud', 'arc', 'timeline', 'venn', 'quadrant',
|
|
19
|
+
// diagrams
|
|
20
|
+
'sequence', 'flowchart', 'class', 'er', 'org', 'kanban', 'c4',
|
|
21
|
+
'initiative-status', 'state', 'sitemap', 'infra', 'gantt',
|
|
22
|
+
]);
|
|
23
|
+
|
|
10
24
|
/** Measure leading whitespace of a line, normalizing tabs to 4 spaces. */
|
|
11
25
|
export function measureIndent(line: string): number {
|
|
12
26
|
let indent = 0;
|
|
@@ -31,19 +45,210 @@ export function extractColor(
|
|
|
31
45
|
const colorName = m[1].trim();
|
|
32
46
|
return {
|
|
33
47
|
label: label.substring(0, m.index!).trim(),
|
|
34
|
-
color: resolveColor(colorName, palette),
|
|
48
|
+
color: resolveColor(colorName, palette) ?? undefined,
|
|
35
49
|
};
|
|
36
50
|
}
|
|
37
51
|
|
|
38
|
-
/** Matches `chart: <type>` header lines. */
|
|
52
|
+
/** @deprecated Matches `chart: <type>` header lines. Remove after all parsers migrate. */
|
|
39
53
|
export const CHART_TYPE_RE = /^chart\s*:\s*(.+)/i;
|
|
40
54
|
|
|
41
|
-
/** Matches `title: <text>` header lines. */
|
|
55
|
+
/** @deprecated Matches `title: <text>` header lines. Remove after all parsers migrate. */
|
|
42
56
|
export const TITLE_RE = /^title\s*:\s*(.+)/i;
|
|
43
57
|
|
|
44
|
-
/** Matches `option: value` header lines. */
|
|
58
|
+
/** @deprecated Matches `option: value` header lines. Remove after all parsers migrate. */
|
|
45
59
|
export const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
|
|
46
60
|
|
|
61
|
+
/** Matches `option value` header lines (space-separated, no colon). */
|
|
62
|
+
export const OPTION_NOCOLON_RE = /^([a-z][a-z0-9-]*)\s+(.+)$/i;
|
|
63
|
+
|
|
64
|
+
/** Matches `# GroupName` lines — alternate group notation. */
|
|
65
|
+
export const GROUP_HASH_RE = /^#\s+(.+)$/;
|
|
66
|
+
|
|
67
|
+
/** Matches `## ...` lines — parse error with helpful hint. */
|
|
68
|
+
export const DOUBLE_HASH_RE = /^##\s/;
|
|
69
|
+
|
|
70
|
+
// ── New shared utilities ─────────────────────────────────────
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Parse the first non-empty, non-comment line to extract chart type and optional title.
|
|
74
|
+
* The first token is matched against `ALL_CHART_TYPES`; the remainder is the title.
|
|
75
|
+
*
|
|
76
|
+
* Returns `null` if the first token is not a recognized chart type.
|
|
77
|
+
*/
|
|
78
|
+
export function parseFirstLine(
|
|
79
|
+
line: string,
|
|
80
|
+
): { chartType: string; title: string | undefined } | null {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (!trimmed || trimmed.startsWith('//')) return null;
|
|
83
|
+
|
|
84
|
+
// Try old-style `chart: type` first (for transition)
|
|
85
|
+
const oldMatch = trimmed.match(CHART_TYPE_RE);
|
|
86
|
+
if (oldMatch) {
|
|
87
|
+
const parts = oldMatch[1].trim();
|
|
88
|
+
// Could be `chart: gantt My Title` — first token is type
|
|
89
|
+
const spaceIdx = parts.indexOf(' ');
|
|
90
|
+
if (spaceIdx === -1) {
|
|
91
|
+
const ct = parts.toLowerCase();
|
|
92
|
+
return ALL_CHART_TYPES.has(ct) ? { chartType: ct, title: undefined } : null;
|
|
93
|
+
}
|
|
94
|
+
const ct = parts.substring(0, spaceIdx).toLowerCase();
|
|
95
|
+
if (ALL_CHART_TYPES.has(ct)) {
|
|
96
|
+
return { chartType: ct, title: parts.substring(spaceIdx + 1).trim() || undefined };
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// New-style: first token is chart type, rest is title
|
|
102
|
+
const spaceIdx = trimmed.indexOf(' ');
|
|
103
|
+
if (spaceIdx === -1) {
|
|
104
|
+
const ct = trimmed.toLowerCase();
|
|
105
|
+
return ALL_CHART_TYPES.has(ct) ? { chartType: ct, title: undefined } : null;
|
|
106
|
+
}
|
|
107
|
+
const firstToken = trimmed.substring(0, spaceIdx).toLowerCase();
|
|
108
|
+
if (!ALL_CHART_TYPES.has(firstToken)) return null;
|
|
109
|
+
return { chartType: firstToken, title: trimmed.substring(spaceIdx + 1).trim() || undefined };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Result of `prescanOptions()` — options collected from a two-pass scan. */
|
|
113
|
+
export interface PrescanResult {
|
|
114
|
+
/** Key-value options, e.g., `direction LR` → `{ direction: 'LR' }` */
|
|
115
|
+
options: Record<string, string>;
|
|
116
|
+
/** Presence-based boolean options, e.g., `critical-path` → Set('critical-path') */
|
|
117
|
+
booleans: Set<string>;
|
|
118
|
+
/** Negated booleans, e.g., `no-dependencies` → Set('dependencies') */
|
|
119
|
+
negated: Set<string>;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Pre-scan all lines to collect options that can appear anywhere in the file.
|
|
124
|
+
*
|
|
125
|
+
* For each non-indented, non-comment line:
|
|
126
|
+
* - If the first token is a known option key and the line has more tokens → key-value option
|
|
127
|
+
* - If the first token is a known boolean key (bare keyword) → boolean enabled
|
|
128
|
+
* - If the first token starts with `no-` and the rest is a known boolean → negated
|
|
129
|
+
*
|
|
130
|
+
* Comment handling: full comment lines (`// ...`) are skipped. Inline comments
|
|
131
|
+
* are stripped before extraction (`direction LR // override` → option `direction: LR`).
|
|
132
|
+
*
|
|
133
|
+
* @param lines All lines of the document
|
|
134
|
+
* @param knownOptions Set of recognized option key names (e.g., `direction`, `start`, `notation`)
|
|
135
|
+
* @param knownBooleans Set of recognized boolean option names (e.g., `critical-path`, `animate`)
|
|
136
|
+
*/
|
|
137
|
+
export function prescanOptions(
|
|
138
|
+
lines: string[],
|
|
139
|
+
knownOptions: Set<string>,
|
|
140
|
+
knownBooleans: Set<string> = new Set(),
|
|
141
|
+
): PrescanResult {
|
|
142
|
+
const options: Record<string, string> = {};
|
|
143
|
+
const booleans = new Set<string>();
|
|
144
|
+
const negated = new Set<string>();
|
|
145
|
+
|
|
146
|
+
for (const raw of lines) {
|
|
147
|
+
// Skip indented lines — these are content, not top-level options
|
|
148
|
+
if (raw.length > 0 && (raw[0] === ' ' || raw[0] === '\t')) continue;
|
|
149
|
+
|
|
150
|
+
const trimmed = raw.trim();
|
|
151
|
+
if (!trimmed || trimmed.startsWith('//')) continue;
|
|
152
|
+
|
|
153
|
+
// Strip inline comments
|
|
154
|
+
const commentIdx = trimmed.indexOf(' //');
|
|
155
|
+
const effective = commentIdx >= 0 ? trimmed.substring(0, commentIdx).trim() : trimmed;
|
|
156
|
+
if (!effective) continue;
|
|
157
|
+
|
|
158
|
+
// Extract first token
|
|
159
|
+
const spaceIdx = effective.indexOf(' ');
|
|
160
|
+
const firstToken = (spaceIdx === -1 ? effective : effective.substring(0, spaceIdx)).toLowerCase();
|
|
161
|
+
|
|
162
|
+
// Check for bare boolean (presence = on)
|
|
163
|
+
if (spaceIdx === -1 && knownBooleans.has(firstToken)) {
|
|
164
|
+
booleans.add(firstToken);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check for negated boolean: `no-X` where X is a known boolean
|
|
169
|
+
if (spaceIdx === -1 && firstToken.startsWith('no-')) {
|
|
170
|
+
const base = firstToken.substring(3);
|
|
171
|
+
if (knownBooleans.has(base)) {
|
|
172
|
+
negated.add(base);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check for boolean with a value (e.g., `today-marker 2026-03-26`) —
|
|
178
|
+
// must come before pure key-value check so booleans flag is also set
|
|
179
|
+
if (spaceIdx !== -1 && knownBooleans.has(firstToken)) {
|
|
180
|
+
booleans.add(firstToken);
|
|
181
|
+
options[firstToken] = effective.substring(spaceIdx + 1).trim();
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Check for key-value option
|
|
186
|
+
if (spaceIdx !== -1 && knownOptions.has(firstToken)) {
|
|
187
|
+
options[firstToken] = effective.substring(spaceIdx + 1).trim();
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
return { options, booleans, negated };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Normalize a comma-grouped number string to a plain integer string.
|
|
197
|
+
* Validates the strict pattern: leftmost group 1-3 digits, then groups of exactly 3.
|
|
198
|
+
*
|
|
199
|
+
* Examples: `1,087` → `'1087'`, `1,250,000` → `'1250000'`
|
|
200
|
+
* Returns `null` if the string is not a valid comma-grouped number.
|
|
201
|
+
*/
|
|
202
|
+
export function normalizeGroupedNumber(token: string): string | null {
|
|
203
|
+
if (!/^\d{1,3}(,\d{3})+$/.test(token)) return null;
|
|
204
|
+
return token.replace(/,/g, '');
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Strip surrounding quotes (`"` or `'`) from a token.
|
|
209
|
+
* Returns the unquoted content, or the original string if not quoted.
|
|
210
|
+
*/
|
|
211
|
+
export function stripQuotes(token: string): string {
|
|
212
|
+
if (token.length >= 2) {
|
|
213
|
+
if ((token[0] === '"' && token[token.length - 1] === '"') ||
|
|
214
|
+
(token[0] === "'" && token[token.length - 1] === "'")) {
|
|
215
|
+
return token.substring(1, token.length - 1);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return token;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Quote-aware tokenizer — splits a string by whitespace but keeps quoted
|
|
223
|
+
* substrings (`"double"` or `'single'`) as single tokens.
|
|
224
|
+
* Quotes are preserved in the output tokens — call `stripQuotes()` to remove them.
|
|
225
|
+
*/
|
|
226
|
+
export function tokenizeQuoteAware(input: string): string[] {
|
|
227
|
+
const tokens: string[] = [];
|
|
228
|
+
let i = 0;
|
|
229
|
+
while (i < input.length) {
|
|
230
|
+
// Skip whitespace
|
|
231
|
+
if (input[i] === ' ' || input[i] === '\t') { i++; continue; }
|
|
232
|
+
|
|
233
|
+
// Quoted token
|
|
234
|
+
if (input[i] === '"' || input[i] === "'") {
|
|
235
|
+
const quote = input[i];
|
|
236
|
+
const start = i;
|
|
237
|
+
i++; // skip opening quote
|
|
238
|
+
while (i < input.length && input[i] !== quote) i++;
|
|
239
|
+
if (i < input.length) i++; // skip closing quote
|
|
240
|
+
tokens.push(input.substring(start, i));
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Unquoted token
|
|
245
|
+
const start = i;
|
|
246
|
+
while (i < input.length && input[i] !== ' ' && input[i] !== '\t') i++;
|
|
247
|
+
tokens.push(input.substring(start, i));
|
|
248
|
+
}
|
|
249
|
+
return tokens;
|
|
250
|
+
}
|
|
251
|
+
|
|
47
252
|
/**
|
|
48
253
|
* Collect indented continuation lines as individual values.
|
|
49
254
|
* Used when a property like `series:` has an empty value — subsequent
|
|
@@ -57,8 +262,9 @@ export const OPTION_RE = /^([a-z][a-z0-9-]*)\s*:\s*(.+)$/i;
|
|
|
57
262
|
export function collectIndentedValues(
|
|
58
263
|
lines: string[],
|
|
59
264
|
startIndex: number,
|
|
60
|
-
): { values: string[]; newIndex: number } {
|
|
265
|
+
): { values: string[]; lineNumbers: number[]; newIndex: number } {
|
|
61
266
|
const values: string[] = [];
|
|
267
|
+
const lineNumbers: number[] = [];
|
|
62
268
|
let j = startIndex + 1;
|
|
63
269
|
for (; j < lines.length; j++) {
|
|
64
270
|
const raw = lines[j];
|
|
@@ -71,8 +277,9 @@ export function collectIndentedValues(
|
|
|
71
277
|
if (raw[0] !== ' ' && raw[0] !== '\t') break;
|
|
72
278
|
// Strip trailing comma and collect
|
|
73
279
|
values.push(trimmed.replace(/,\s*$/, ''));
|
|
280
|
+
lineNumbers.push(j + 1); // 1-based
|
|
74
281
|
}
|
|
75
|
-
return { values, newIndex: j - 1 };
|
|
282
|
+
return { values, lineNumbers, newIndex: j - 1 };
|
|
76
283
|
}
|
|
77
284
|
|
|
78
285
|
/**
|
|
@@ -91,18 +298,23 @@ export function parseSeriesNames(
|
|
|
91
298
|
series: string;
|
|
92
299
|
names: string[];
|
|
93
300
|
nameColors: (string | undefined)[];
|
|
301
|
+
nameLineNumbers: number[];
|
|
94
302
|
newIndex: number;
|
|
95
303
|
} {
|
|
96
304
|
let rawNames: string[];
|
|
97
305
|
let series: string;
|
|
98
306
|
let newIndex = lineIndex;
|
|
307
|
+
let nameLineNumbers: number[] = [];
|
|
99
308
|
if (value) {
|
|
100
309
|
series = value;
|
|
101
310
|
rawNames = value.split(',').map((s) => s.trim()).filter(Boolean);
|
|
311
|
+
// Inline series names all share the same line number
|
|
312
|
+
nameLineNumbers = rawNames.map(() => lineIndex + 1);
|
|
102
313
|
} else {
|
|
103
314
|
const collected = collectIndentedValues(lines, lineIndex);
|
|
104
315
|
newIndex = collected.newIndex;
|
|
105
316
|
rawNames = collected.values;
|
|
317
|
+
nameLineNumbers = collected.lineNumbers;
|
|
106
318
|
series = rawNames.join(', ');
|
|
107
319
|
}
|
|
108
320
|
const names: string[] = [];
|
|
@@ -115,7 +327,35 @@ export function parseSeriesNames(
|
|
|
115
327
|
if (names.length === 1) {
|
|
116
328
|
series = names[0];
|
|
117
329
|
}
|
|
118
|
-
return { series, names, nameColors, newIndex };
|
|
330
|
+
return { series, names, nameColors, nameLineNumbers, newIndex };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Normalize a direction/orientation value to canonical form ('LR' | 'TB').
|
|
335
|
+
* Accepts 'lr', 'tb', 'horizontal', 'vertical' (case-insensitive).
|
|
336
|
+
* Returns null if the value is not recognized.
|
|
337
|
+
*/
|
|
338
|
+
export function normalizeDirection(value: string): 'LR' | 'TB' | null {
|
|
339
|
+
const v = value.trim().toLowerCase();
|
|
340
|
+
if (v === 'lr' || v === 'horizontal') return 'LR';
|
|
341
|
+
if (v === 'tb' || v === 'vertical') return 'TB';
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Infer arrow color from label text.
|
|
347
|
+
* Returns a named palette color or undefined if no inference applies.
|
|
348
|
+
* Case-insensitive, exact match only (not prefix/substring).
|
|
349
|
+
*/
|
|
350
|
+
export function inferArrowColor(label: string): string | undefined {
|
|
351
|
+
const lower = label.toLowerCase();
|
|
352
|
+
// Green: positive/affirmative
|
|
353
|
+
if (lower === 'yes' || lower === 'success' || lower === 'ok' || lower === 'true') return 'green';
|
|
354
|
+
// Red: negative/failure
|
|
355
|
+
if (lower === 'no' || lower === 'fail' || lower === 'error' || lower === 'false') return 'red';
|
|
356
|
+
// Orange: uncertain/warning
|
|
357
|
+
if (lower === 'maybe' || lower === 'warning') return 'orange';
|
|
358
|
+
return undefined;
|
|
119
359
|
}
|
|
120
360
|
|
|
121
361
|
/** Warning message for multiple pipes on a single line. */
|
package/src/utils/tag-groups.ts
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
// Shared tag-group types, regexes, and matchers
|
|
3
3
|
// ============================================================
|
|
4
4
|
|
|
5
|
+
import { stripQuotes, tokenizeQuoteAware } from './parsing';
|
|
6
|
+
|
|
5
7
|
/** A single entry inside a tag group: `Value(color)` */
|
|
6
8
|
export interface TagEntry {
|
|
7
9
|
value: string;
|
|
@@ -14,7 +16,7 @@ export interface TagGroup {
|
|
|
14
16
|
name: string;
|
|
15
17
|
alias?: string;
|
|
16
18
|
entries: TagEntry[];
|
|
17
|
-
/**
|
|
19
|
+
/** First value in the tag declaration is the default (nodes without metadata get this) */
|
|
18
20
|
defaultValue?: string;
|
|
19
21
|
lineNumber: number;
|
|
20
22
|
}
|
|
@@ -26,23 +28,154 @@ export interface TagBlockMatch {
|
|
|
26
28
|
colorHint: string | undefined;
|
|
27
29
|
/** true when the heading used `## …` (deprecated) */
|
|
28
30
|
deprecated: boolean;
|
|
31
|
+
/** Inline tag values parsed from single-line form (e.g., `tag Priority p High(red), Low(blue)`) */
|
|
32
|
+
inlineValues?: string[];
|
|
29
33
|
}
|
|
30
34
|
|
|
31
35
|
// ── Regexes ─────────────────────────────────────────────────
|
|
32
36
|
|
|
33
|
-
/**
|
|
37
|
+
/** @deprecated Old syntax: `tag: GroupName [alias X] [(color)]` — remove after migration. */
|
|
34
38
|
export const TAG_BLOCK_RE =
|
|
35
39
|
/^tag:\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/i;
|
|
36
40
|
|
|
37
|
-
/**
|
|
41
|
+
/** New canonical syntax: line starting with `tag` keyword (no colon). */
|
|
42
|
+
export const TAG_BLOCK_NOCOLON_RE = /^tag\s+/i;
|
|
43
|
+
|
|
44
|
+
/** @deprecated Legacy syntax: `## GroupName [alias X] [(color)]` */
|
|
38
45
|
export const GROUP_HEADING_RE =
|
|
39
46
|
/^##\s+(.+?)(?:\s+alias\s+(\w+))?(?:\s*\(([^)]+)\))?\s*$/;
|
|
40
47
|
|
|
48
|
+
// ── Alias Inference ─────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
/** Returns true if the token looks like an alias: 1-4 lowercase ASCII characters. */
|
|
51
|
+
function isAliasToken(token: string): boolean {
|
|
52
|
+
return /^[a-z]{1,4}$/.test(token);
|
|
53
|
+
}
|
|
54
|
+
|
|
41
55
|
// ── Matchers ────────────────────────────────────────────────
|
|
42
56
|
|
|
43
|
-
/** Returns true if `trimmed` is a tag block heading in
|
|
57
|
+
/** Returns true if `trimmed` is a tag block heading in any syntax. */
|
|
44
58
|
export function isTagBlockHeading(trimmed: string): boolean {
|
|
45
|
-
return TAG_BLOCK_RE.test(trimmed) || GROUP_HEADING_RE.test(trimmed);
|
|
59
|
+
return TAG_BLOCK_NOCOLON_RE.test(trimmed) || TAG_BLOCK_RE.test(trimmed) || GROUP_HEADING_RE.test(trimmed);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Parse a new-syntax tag declaration line: `tag Name [alias] [Values...]`
|
|
64
|
+
*
|
|
65
|
+
* Alias inference: the token immediately after the name is the alias if it's 1-4 lowercase chars.
|
|
66
|
+
* Everything after the alias (or name, if no alias) that contains a comma or `(` is treated as inline values.
|
|
67
|
+
*
|
|
68
|
+
* Supports quoted names: `tag "Marketing mktg"` → name="Marketing mktg", no alias.
|
|
69
|
+
*/
|
|
70
|
+
export function parseTagDeclaration(line: string): TagBlockMatch | null {
|
|
71
|
+
// Must start with `tag ` (case-insensitive)
|
|
72
|
+
if (!TAG_BLOCK_NOCOLON_RE.test(line)) return null;
|
|
73
|
+
|
|
74
|
+
// Strip the `tag ` prefix
|
|
75
|
+
const afterTag = line.replace(/^tag\s+/i, '');
|
|
76
|
+
if (!afterTag.trim()) return null;
|
|
77
|
+
|
|
78
|
+
// Check if there are inline values (indicated by presence of `(` for colors after the name part)
|
|
79
|
+
// Strategy: tokenize, identify name + optional alias, rest is inline values
|
|
80
|
+
const tokens = tokenizeQuoteAware(afterTag);
|
|
81
|
+
if (tokens.length === 0) return null;
|
|
82
|
+
|
|
83
|
+
// First token (or quoted token) is the tag name
|
|
84
|
+
let name = stripQuotes(tokens[0]);
|
|
85
|
+
let alias: string | undefined;
|
|
86
|
+
let inlineValues: string[] | undefined;
|
|
87
|
+
let colorHint: string | undefined;
|
|
88
|
+
let restStartIdx = 1;
|
|
89
|
+
|
|
90
|
+
// If the first token is quoted, name is the quoted content — check for alias next
|
|
91
|
+
if (tokens[0][0] === '"' || tokens[0][0] === "'") {
|
|
92
|
+
// Quoted name — check if next token is alias
|
|
93
|
+
if (tokens.length > 1 && isAliasToken(tokens[1])) {
|
|
94
|
+
alias = tokens[1];
|
|
95
|
+
restStartIdx = 2;
|
|
96
|
+
}
|
|
97
|
+
} else {
|
|
98
|
+
// Unquoted — collect multi-word name. The alias is the last token that's 1-4 lowercase
|
|
99
|
+
// BEFORE any value tokens (values have `(color)` suffixes or appear after we see a comma).
|
|
100
|
+
|
|
101
|
+
// First check for explicit `alias` keyword: `tag Name alias X`
|
|
102
|
+
const aliasKeywordIdx = tokens.findIndex((t, i) => i > 0 && t.toLowerCase() === 'alias');
|
|
103
|
+
if (aliasKeywordIdx > 0 && aliasKeywordIdx + 1 < tokens.length) {
|
|
104
|
+
// Everything before `alias` is the name, the token after `alias` is the alias
|
|
105
|
+
name = tokens.slice(0, aliasKeywordIdx).map(t => stripQuotes(t)).join(' ');
|
|
106
|
+
alias = tokens[aliasKeywordIdx + 1];
|
|
107
|
+
restStartIdx = aliasKeywordIdx + 2;
|
|
108
|
+
} else {
|
|
109
|
+
// Find where inline values start — look for a token with `(` in it (color suffix)
|
|
110
|
+
// or the presence of a comma in the remaining text
|
|
111
|
+
const remainingText = tokens.slice(1).join(' ');
|
|
112
|
+
const commaInRemaining = remainingText.includes(',');
|
|
113
|
+
|
|
114
|
+
if (tokens.length === 1) {
|
|
115
|
+
// Just `tag Name` — no alias, no values
|
|
116
|
+
} else if (tokens.length === 2 && isAliasToken(tokens[1]) && !commaInRemaining) {
|
|
117
|
+
// `tag Priority p` — alias only, no values
|
|
118
|
+
alias = tokens[1];
|
|
119
|
+
restStartIdx = 2;
|
|
120
|
+
} else if (tokens.length >= 2) {
|
|
121
|
+
// Check if token[1] is an alias
|
|
122
|
+
if (isAliasToken(tokens[1])) {
|
|
123
|
+
alias = tokens[1];
|
|
124
|
+
restStartIdx = 2;
|
|
125
|
+
// Multi-word name not applicable when alias is right after first token
|
|
126
|
+
} else {
|
|
127
|
+
// Could be multi-word name: `tag Risk Level lo`
|
|
128
|
+
// Walk tokens to find the alias at the end (before inline values)
|
|
129
|
+
// Find where inline values begin — first token containing `(` or after comma
|
|
130
|
+
let valueStart = tokens.length; // default: no values
|
|
131
|
+
for (let i = 1; i < tokens.length; i++) {
|
|
132
|
+
// A token containing `(` suggests a value with color: `High(red)`
|
|
133
|
+
if (tokens[i].includes('(')) {
|
|
134
|
+
valueStart = i;
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Check if the token just before valueStart is an alias
|
|
140
|
+
if (valueStart > 1 && isAliasToken(tokens[valueStart - 1])) {
|
|
141
|
+
alias = tokens[valueStart - 1];
|
|
142
|
+
// Name is everything from token[0] to token[valueStart-2]
|
|
143
|
+
name = tokens.slice(0, valueStart - 1).map(t => stripQuotes(t)).join(' ');
|
|
144
|
+
restStartIdx = valueStart;
|
|
145
|
+
} else {
|
|
146
|
+
// No alias — name is everything before values
|
|
147
|
+
name = tokens.slice(0, valueStart).map(t => stripQuotes(t)).join(' ');
|
|
148
|
+
restStartIdx = valueStart;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Parse remaining tokens as inline values (if any)
|
|
156
|
+
if (restStartIdx < tokens.length) {
|
|
157
|
+
// Rejoin and split by comma for inline values
|
|
158
|
+
const valueStr = tokens.slice(restStartIdx).join(' ');
|
|
159
|
+
inlineValues = valueStr.split(',').map(v => v.trim()).filter(Boolean);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check for trailing color hint on name (without inline values)
|
|
163
|
+
// e.g., `tag Location(blue)` — colorHint on the tag group itself
|
|
164
|
+
if (!inlineValues || inlineValues.length === 0) {
|
|
165
|
+
const colorMatch = name.match(/\(([^)]+)\)\s*$/);
|
|
166
|
+
if (colorMatch) {
|
|
167
|
+
colorHint = colorMatch[1];
|
|
168
|
+
name = name.substring(0, colorMatch.index!).trim();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return {
|
|
173
|
+
name,
|
|
174
|
+
alias,
|
|
175
|
+
colorHint,
|
|
176
|
+
deprecated: false,
|
|
177
|
+
inlineValues: inlineValues && inlineValues.length > 0 ? inlineValues : undefined,
|
|
178
|
+
};
|
|
46
179
|
}
|
|
47
180
|
|
|
48
181
|
/**
|
|
@@ -119,15 +252,23 @@ export function validateTagValues(
|
|
|
119
252
|
(e) => e.value.toLowerCase() === value.toLowerCase()
|
|
120
253
|
);
|
|
121
254
|
if (!match) {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
255
|
+
// Suppress warning if the value is a prefix of any valid entry —
|
|
256
|
+
// the user is likely still typing (live parse during editing).
|
|
257
|
+
const valueLower = value.toLowerCase();
|
|
258
|
+
const isPrefix = group.entries.some(
|
|
259
|
+
(e) => e.value.toLowerCase().startsWith(valueLower)
|
|
260
|
+
);
|
|
261
|
+
if (!isPrefix) {
|
|
262
|
+
const defined = group.entries.map((e) => e.value);
|
|
263
|
+
let msg = `Unknown value '${value}' for tag group '${group.name}'`;
|
|
264
|
+
const hint = suggestFn?.(value, defined);
|
|
265
|
+
if (hint) {
|
|
266
|
+
msg += `. ${hint}`;
|
|
267
|
+
} else {
|
|
268
|
+
msg += ` — defined values: ${defined.join(', ')}`;
|
|
269
|
+
}
|
|
270
|
+
pushWarning(entity.lineNumber, msg);
|
|
129
271
|
}
|
|
130
|
-
pushWarning(entity.lineNumber, msg);
|
|
131
272
|
}
|
|
132
273
|
}
|
|
133
274
|
}
|
|
@@ -169,7 +310,11 @@ export function injectDefaultTagMetadata(
|
|
|
169
310
|
// ── Matchers ────────────────────────────────────────────────
|
|
170
311
|
|
|
171
312
|
export function matchTagBlockHeading(trimmed: string): TagBlockMatch | null {
|
|
172
|
-
// Try new syntax first
|
|
313
|
+
// Try new no-colon syntax first: `tag Name [alias] [Values...]`
|
|
314
|
+
const nocolonResult = parseTagDeclaration(trimmed);
|
|
315
|
+
if (nocolonResult) return nocolonResult;
|
|
316
|
+
|
|
317
|
+
// Try old colon syntax: `tag: GroupName [alias X] [(color)]`
|
|
173
318
|
const tagMatch = trimmed.match(TAG_BLOCK_RE);
|
|
174
319
|
if (tagMatch) {
|
|
175
320
|
return {
|
|
@@ -180,7 +325,7 @@ export function matchTagBlockHeading(trimmed: string): TagBlockMatch | null {
|
|
|
180
325
|
};
|
|
181
326
|
}
|
|
182
327
|
|
|
183
|
-
// Fall back to legacy syntax
|
|
328
|
+
// Fall back to legacy ## syntax
|
|
184
329
|
const groupMatch = trimmed.match(GROUP_HEADING_RE);
|
|
185
330
|
if (groupMatch) {
|
|
186
331
|
return {
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Shared chart title constants
|
|
3
|
+
// All renderers import from here to stay in sync.
|
|
4
|
+
// ============================================================
|
|
5
|
+
|
|
6
|
+
export const TITLE_FONT_SIZE = 20;
|
|
7
|
+
export const TITLE_FONT_WEIGHT = '700';
|
|
8
|
+
export const TITLE_Y = 30;
|
|
9
|
+
export const TITLE_OFFSET = 40;
|