@diagrammo/dgmo 0.7.2 → 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.
Files changed (62) hide show
  1. package/AGENTS.md +15 -20
  2. package/README.md +56 -58
  3. package/dist/cli.cjs +188 -181
  4. package/dist/index.cjs +3529 -1061
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +196 -43
  7. package/dist/index.d.ts +196 -43
  8. package/dist/index.js +3516 -1061
  9. package/dist/index.js.map +1 -1
  10. package/docs/language-reference.md +629 -289
  11. package/package.json +1 -1
  12. package/src/c4/layout.ts +6 -9
  13. package/src/c4/parser.ts +189 -83
  14. package/src/c4/renderer.ts +8 -9
  15. package/src/chart.ts +296 -83
  16. package/src/class/parser.ts +54 -37
  17. package/src/class/renderer.ts +8 -8
  18. package/src/cli.ts +8 -8
  19. package/src/colors.ts +4 -1
  20. package/src/completion.ts +757 -10
  21. package/src/d3.ts +312 -73
  22. package/src/dgmo-router.ts +63 -8
  23. package/src/echarts.ts +726 -231
  24. package/src/er/parser.ts +94 -76
  25. package/src/er/renderer.ts +6 -5
  26. package/src/gantt/parser.ts +144 -69
  27. package/src/gantt/renderer.ts +50 -14
  28. package/src/gantt/types.ts +3 -3
  29. package/src/graph/flowchart-parser.ts +97 -37
  30. package/src/graph/flowchart-renderer.ts +4 -3
  31. package/src/graph/state-parser.ts +50 -31
  32. package/src/graph/state-renderer.ts +4 -3
  33. package/src/index.ts +14 -5
  34. package/src/infra/compute.ts +1 -0
  35. package/src/infra/layout.ts +3 -0
  36. package/src/infra/parser.ts +291 -92
  37. package/src/infra/renderer.ts +172 -30
  38. package/src/infra/types.ts +5 -0
  39. package/src/initiative-status/layout.ts +1 -1
  40. package/src/initiative-status/parser.ts +121 -47
  41. package/src/initiative-status/renderer.ts +82 -31
  42. package/src/initiative-status/types.ts +10 -2
  43. package/src/kanban/parser.ts +60 -37
  44. package/src/kanban/renderer.ts +2 -2
  45. package/src/kanban/types.ts +1 -0
  46. package/src/org/layout.ts +9 -9
  47. package/src/org/parser.ts +39 -40
  48. package/src/org/renderer.ts +5 -6
  49. package/src/org/resolver.ts +26 -19
  50. package/src/render.ts +1 -1
  51. package/src/sequence/parser.ts +304 -95
  52. package/src/sequence/renderer.ts +9 -9
  53. package/src/sitemap/layout.ts +3 -4
  54. package/src/sitemap/parser.ts +57 -49
  55. package/src/sitemap/renderer.ts +6 -7
  56. package/src/utils/arrows.ts +25 -6
  57. package/src/utils/duration.ts +43 -7
  58. package/src/utils/legend-constants.ts +26 -0
  59. package/src/utils/legend-svg.ts +167 -0
  60. package/src/utils/parsing.ts +247 -7
  61. package/src/utils/tag-groups.ts +160 -15
  62. package/src/utils/title-constants.ts +9 -0
@@ -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. */
@@ -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
- /** Value of the entry marked `default` (nodes without metadata get this) */
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
- /** New canonical syntax: `tag: GroupName [alias X] [(color)]` (case-insensitive) */
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
- /** Legacy syntax: `## GroupName [alias X] [(color)]` */
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 either syntax. */
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
- const defined = group.entries.map((e) => e.value);
123
- let msg = `Unknown value '${value}' for tag group '${group.name}'`;
124
- const hint = suggestFn?.(value, defined);
125
- if (hint) {
126
- msg += `. ${hint}`;
127
- } else {
128
- msg += ` — defined values: ${defined.join(', ')}`;
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;