@diagrammo/dgmo 0.8.2 → 0.8.4
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/commands/dgmo-diagram-this.md +60 -0
- package/.claude/commands/dgmo-document-project.md +128 -0
- package/.claude/commands/dgmo.md +185 -50
- package/.cursorrules +32 -37
- package/.github/copilot-instructions.md +35 -44
- package/.windsurfrules +32 -37
- package/README.md +4 -4
- package/dist/cli.cjs +189 -194
- package/dist/editor.cjs +336 -0
- package/dist/editor.cjs.map +1 -0
- package/dist/editor.d.cts +27 -0
- package/dist/editor.d.ts +27 -0
- package/dist/editor.js +305 -0
- package/dist/editor.js.map +1 -0
- package/dist/index.cjs +3699 -1564
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +7 -6
- package/dist/index.d.ts +7 -6
- package/dist/index.js +3699 -1564
- package/dist/index.js.map +1 -1
- package/docs/language-reference.md +822 -1060
- package/gallery/fixtures/arc.dgmo +18 -0
- package/gallery/fixtures/area.dgmo +19 -0
- package/gallery/fixtures/bar-stacked.dgmo +10 -0
- package/gallery/fixtures/bar.dgmo +10 -0
- package/gallery/fixtures/c4-full.dgmo +52 -0
- package/gallery/fixtures/c4.dgmo +17 -0
- package/gallery/fixtures/chord.dgmo +12 -0
- package/gallery/fixtures/class-basic.dgmo +14 -0
- package/gallery/fixtures/class-full.dgmo +43 -0
- package/gallery/fixtures/doughnut.dgmo +8 -0
- package/gallery/fixtures/flowchart-basic.dgmo +3 -0
- package/gallery/fixtures/flowchart-colors.dgmo +5 -0
- package/gallery/fixtures/flowchart-complex.dgmo +17 -0
- package/gallery/fixtures/flowchart-decision.dgmo +5 -0
- package/gallery/fixtures/flowchart-full.dgmo +13 -0
- package/gallery/fixtures/flowchart-groups.dgmo +10 -0
- package/gallery/fixtures/flowchart-loop.dgmo +7 -0
- package/gallery/fixtures/flowchart-nested.dgmo +7 -0
- package/gallery/fixtures/flowchart-shapes.dgmo +5 -0
- package/gallery/fixtures/function.dgmo +8 -0
- package/gallery/fixtures/funnel.dgmo +7 -0
- package/gallery/fixtures/gantt-full.dgmo +49 -0
- package/gallery/fixtures/gantt.dgmo +42 -0
- package/gallery/fixtures/heatmap.dgmo +8 -0
- package/gallery/fixtures/infra-full.dgmo +78 -0
- package/gallery/fixtures/infra-overload.dgmo +25 -0
- package/gallery/fixtures/infra.dgmo +47 -0
- package/gallery/fixtures/initiative-status-full.dgmo +46 -0
- package/gallery/fixtures/initiative-status-phases.dgmo +29 -0
- package/gallery/fixtures/initiative-status.dgmo +9 -0
- package/gallery/fixtures/line.dgmo +19 -0
- package/gallery/fixtures/multi-line.dgmo +11 -0
- package/gallery/fixtures/org-basic.dgmo +16 -0
- package/gallery/fixtures/org-full.dgmo +69 -0
- package/gallery/fixtures/org-teams.dgmo +25 -0
- package/gallery/fixtures/pie.dgmo +9 -0
- package/gallery/fixtures/polar-area.dgmo +8 -0
- package/gallery/fixtures/quadrant.dgmo +18 -0
- package/gallery/fixtures/radar.dgmo +8 -0
- package/gallery/fixtures/sankey.dgmo +31 -0
- package/gallery/fixtures/scatter.dgmo +21 -0
- package/gallery/fixtures/sequence-tags-protocols.dgmo +45 -0
- package/gallery/fixtures/sequence-tags.dgmo +41 -0
- package/gallery/fixtures/sequence.dgmo +35 -0
- package/gallery/fixtures/sitemap-basic.dgmo +12 -0
- package/gallery/fixtures/sitemap-full.dgmo +156 -0
- package/gallery/fixtures/slope.dgmo +8 -0
- package/gallery/fixtures/spr-eras.dgmo +62 -0
- package/gallery/fixtures/state.dgmo +30 -0
- package/gallery/fixtures/timeline-intraday.dgmo +14 -0
- package/gallery/fixtures/timeline.dgmo +32 -0
- package/gallery/fixtures/venn.dgmo +10 -0
- package/gallery/fixtures/wordcloud.dgmo +24 -0
- package/package.json +51 -2
- package/src/c4/layout.ts +372 -90
- package/src/c4/parser.ts +113 -62
- package/src/chart.ts +149 -64
- package/src/class/parser.ts +84 -28
- package/src/class/renderer.ts +2 -2
- package/src/cli.ts +179 -77
- package/src/completion.ts +381 -182
- package/src/d3.ts +1026 -428
- package/src/dgmo-mermaid.ts +16 -13
- package/src/dgmo-router.ts +70 -24
- package/src/echarts.ts +682 -169
- package/src/editor/dgmo.grammar +69 -0
- package/src/editor/dgmo.grammar.d.ts +2 -0
- package/src/editor/dgmo.grammar.js +18 -0
- package/src/editor/dgmo.grammar.terms.d.ts +5 -0
- package/src/editor/dgmo.grammar.terms.js +35 -0
- package/src/editor/highlight.ts +36 -0
- package/src/editor/index.ts +28 -0
- package/src/editor/keywords.ts +220 -0
- package/src/editor/tokens.ts +30 -0
- package/src/er/parser.ts +55 -29
- package/src/er/renderer.ts +112 -53
- package/src/gantt/calculator.ts +91 -29
- package/src/gantt/parser.ts +291 -97
- package/src/gantt/renderer.ts +1120 -350
- package/src/graph/flowchart-parser.ts +48 -75
- package/src/graph/state-parser.ts +54 -27
- package/src/infra/parser.ts +161 -177
- package/src/infra/renderer.ts +723 -271
- package/src/infra/types.ts +0 -1
- package/src/initiative-status/parser.ts +144 -56
- package/src/kanban/parser.ts +27 -19
- package/src/org/layout.ts +111 -44
- package/src/org/parser.ts +71 -27
- package/src/org/resolver.ts +3 -3
- package/src/palettes/index.ts +3 -2
- package/src/render.ts +1 -2
- package/src/sequence/parser.ts +209 -100
- package/src/sitemap/parser.ts +73 -44
- package/src/utils/arrows.ts +2 -22
- package/src/utils/duration.ts +39 -21
- package/src/utils/legend-constants.ts +0 -2
- package/src/utils/parsing.ts +82 -72
- package/src/utils/tag-groups.ts +4 -41
- package/src/infra/serialize.ts +0 -67
package/src/chart.ts
CHANGED
|
@@ -21,9 +21,9 @@ export interface ChartDataPoint {
|
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
export interface ChartEra {
|
|
24
|
-
start: string;
|
|
25
|
-
end: string;
|
|
26
|
-
label: string;
|
|
24
|
+
start: string; // exact category label, e.g. "'77"
|
|
25
|
+
end: string; // exact category label, e.g. "'81"
|
|
26
|
+
label: string; // display name, e.g. "Carter"
|
|
27
27
|
color: string | null; // resolved CSS color, or null → palette default
|
|
28
28
|
lineNumber: number;
|
|
29
29
|
}
|
|
@@ -46,7 +46,9 @@ export interface ParsedChart {
|
|
|
46
46
|
orientation?: 'horizontal' | 'vertical';
|
|
47
47
|
color?: string;
|
|
48
48
|
label?: string;
|
|
49
|
-
|
|
49
|
+
noLabelName?: boolean;
|
|
50
|
+
noLabelValue?: boolean;
|
|
51
|
+
noLabelPercent?: boolean;
|
|
50
52
|
data: ChartDataPoint[];
|
|
51
53
|
eras?: ChartEra[];
|
|
52
54
|
diagnostics: DgmoError[];
|
|
@@ -60,7 +62,11 @@ export interface ParsedChart {
|
|
|
60
62
|
import { resolveColor } from './colors';
|
|
61
63
|
import type { PaletteColors } from './palettes';
|
|
62
64
|
import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
|
|
63
|
-
import {
|
|
65
|
+
import {
|
|
66
|
+
extractColor,
|
|
67
|
+
parseFirstLine,
|
|
68
|
+
parseSeriesNames,
|
|
69
|
+
} from './utils/parsing';
|
|
64
70
|
|
|
65
71
|
// ============================================================
|
|
66
72
|
// Parser
|
|
@@ -83,10 +89,21 @@ const TYPE_ALIASES: Record<string, ChartType> = {
|
|
|
83
89
|
|
|
84
90
|
/** Known option keywords for the simple chart parser. */
|
|
85
91
|
const KNOWN_OPTIONS = new Set([
|
|
86
|
-
'chart',
|
|
87
|
-
'
|
|
92
|
+
'chart',
|
|
93
|
+
'title',
|
|
94
|
+
'series',
|
|
95
|
+
'x-label',
|
|
96
|
+
'y-label',
|
|
97
|
+
'label',
|
|
98
|
+
'no-label-name',
|
|
99
|
+
'no-label-value',
|
|
100
|
+
'no-label-percent',
|
|
101
|
+
'color',
|
|
88
102
|
]);
|
|
89
103
|
|
|
104
|
+
/** Known boolean options for the simple chart parser. */
|
|
105
|
+
const KNOWN_BOOLEANS = new Set(['orientation-horizontal']);
|
|
106
|
+
|
|
90
107
|
/**
|
|
91
108
|
* Parses the simple chart text format into a structured object.
|
|
92
109
|
*
|
|
@@ -106,7 +123,12 @@ export function parseChart(
|
|
|
106
123
|
): ParsedChart {
|
|
107
124
|
const lines = content.split('\n');
|
|
108
125
|
const parsedEras: ChartEra[] = [];
|
|
109
|
-
const rawEras: {
|
|
126
|
+
const rawEras: {
|
|
127
|
+
start: string;
|
|
128
|
+
afterArrow: string;
|
|
129
|
+
color: string | null;
|
|
130
|
+
lineNumber: number;
|
|
131
|
+
}[] = [];
|
|
110
132
|
const result: ParsedChart = {
|
|
111
133
|
type: 'bar',
|
|
112
134
|
data: [],
|
|
@@ -133,7 +155,12 @@ export function parseChart(
|
|
|
133
155
|
|
|
134
156
|
// Reject legacy ## section headers
|
|
135
157
|
if (/^#{2,}\s+/.test(trimmed)) {
|
|
136
|
-
result.diagnostics.push(
|
|
158
|
+
result.diagnostics.push(
|
|
159
|
+
makeDgmoError(
|
|
160
|
+
lineNumber,
|
|
161
|
+
`'${trimmed}' — ## syntax is no longer supported. Use [Group] containers instead`
|
|
162
|
+
)
|
|
163
|
+
);
|
|
137
164
|
continue;
|
|
138
165
|
}
|
|
139
166
|
|
|
@@ -163,7 +190,11 @@ export function parseChart(
|
|
|
163
190
|
}
|
|
164
191
|
// If the first line is a single word (no spaces, no colon, no numbers),
|
|
165
192
|
// treat it as an unrecognized chart type rather than falling through
|
|
166
|
-
if (
|
|
193
|
+
if (
|
|
194
|
+
!trimmed.includes(' ') &&
|
|
195
|
+
!trimmed.includes(':') &&
|
|
196
|
+
!/\d/.test(trimmed)
|
|
197
|
+
) {
|
|
167
198
|
let msg = `Unsupported chart type: ${trimmed}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
|
|
168
199
|
const hint = suggest(trimmed.toLowerCase(), [...VALID_TYPES]);
|
|
169
200
|
if (hint) msg += ` ${hint}`;
|
|
@@ -173,7 +204,9 @@ export function parseChart(
|
|
|
173
204
|
}
|
|
174
205
|
|
|
175
206
|
// Era line: era Day 1 -> Day 3 Rough Seas (blue) — colon-free
|
|
176
|
-
const eraMatch = trimmed.match(
|
|
207
|
+
const eraMatch = trimmed.match(
|
|
208
|
+
/^era\s+(.+?)\s*->\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/
|
|
209
|
+
);
|
|
177
210
|
if (eraMatch) {
|
|
178
211
|
// Store start and raw afterArrow — resolved against data labels after parsing
|
|
179
212
|
const afterArrow = eraMatch[2].trim();
|
|
@@ -191,7 +224,17 @@ export function parseChart(
|
|
|
191
224
|
|
|
192
225
|
// Extract first token to check for known options
|
|
193
226
|
const spaceIdx = trimmed.indexOf(' ');
|
|
194
|
-
const firstToken = (
|
|
227
|
+
const firstToken = (
|
|
228
|
+
spaceIdx >= 0 ? trimmed.substring(0, spaceIdx) : trimmed
|
|
229
|
+
).toLowerCase();
|
|
230
|
+
|
|
231
|
+
// Bare boolean options (e.g. orientation-horizontal)
|
|
232
|
+
if (KNOWN_BOOLEANS.has(firstToken) && spaceIdx < 0) {
|
|
233
|
+
if (firstToken === 'orientation-horizontal') {
|
|
234
|
+
result.orientation = 'horizontal';
|
|
235
|
+
}
|
|
236
|
+
continue;
|
|
237
|
+
}
|
|
195
238
|
|
|
196
239
|
// Known option with a value
|
|
197
240
|
if (KNOWN_OPTIONS.has(firstToken) && spaceIdx >= 0) {
|
|
@@ -217,13 +260,13 @@ export function parseChart(
|
|
|
217
260
|
continue;
|
|
218
261
|
}
|
|
219
262
|
|
|
220
|
-
if (firstToken === '
|
|
263
|
+
if (firstToken === 'x-label') {
|
|
221
264
|
result.xlabel = value;
|
|
222
265
|
result.xlabelLineNumber = lineNumber;
|
|
223
266
|
continue;
|
|
224
267
|
}
|
|
225
268
|
|
|
226
|
-
if (firstToken === '
|
|
269
|
+
if (firstToken === 'y-label') {
|
|
227
270
|
result.ylabel = value;
|
|
228
271
|
result.ylabelLineNumber = lineNumber;
|
|
229
272
|
continue;
|
|
@@ -234,29 +277,6 @@ export function parseChart(
|
|
|
234
277
|
continue;
|
|
235
278
|
}
|
|
236
279
|
|
|
237
|
-
if (firstToken === 'labels') {
|
|
238
|
-
const v = value.toLowerCase();
|
|
239
|
-
if (v === 'name' || v === 'value' || v === 'percent' || v === 'full') {
|
|
240
|
-
result.labels = v;
|
|
241
|
-
}
|
|
242
|
-
continue;
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
if (firstToken === 'orientation' || firstToken === 'direction') {
|
|
246
|
-
// Only bar and bar-stacked support orientation (axis swapping)
|
|
247
|
-
if (result.type === 'bar' || result.type === 'bar-stacked') {
|
|
248
|
-
const vLower = value.toLowerCase();
|
|
249
|
-
if (vLower === 'horizontal' || vLower === 'vertical') {
|
|
250
|
-
result.orientation = vLower;
|
|
251
|
-
} else {
|
|
252
|
-
const dir = normalizeDirection(value);
|
|
253
|
-
if (dir === 'LR') result.orientation = 'horizontal';
|
|
254
|
-
else if (dir === 'TB') result.orientation = 'vertical';
|
|
255
|
-
}
|
|
256
|
-
}
|
|
257
|
-
continue;
|
|
258
|
-
}
|
|
259
|
-
|
|
260
280
|
if (firstToken === 'color') {
|
|
261
281
|
result.color = resolveColor(value.trim(), palette) ?? undefined;
|
|
262
282
|
continue;
|
|
@@ -271,11 +291,26 @@ export function parseChart(
|
|
|
271
291
|
result.seriesNames = parsed.names;
|
|
272
292
|
result.seriesNameLineNumbers = parsed.nameLineNumbers;
|
|
273
293
|
}
|
|
274
|
-
if (parsed.nameColors.some(Boolean))
|
|
294
|
+
if (parsed.nameColors.some(Boolean))
|
|
295
|
+
result.seriesNameColors = parsed.nameColors;
|
|
275
296
|
continue;
|
|
276
297
|
}
|
|
277
298
|
}
|
|
278
299
|
|
|
300
|
+
// Bare boolean options: no-label-name, no-label-value, no-label-percent
|
|
301
|
+
if (firstToken === 'no-label-name') {
|
|
302
|
+
result.noLabelName = true;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
if (firstToken === 'no-label-value') {
|
|
306
|
+
result.noLabelValue = true;
|
|
307
|
+
continue;
|
|
308
|
+
}
|
|
309
|
+
if (firstToken === 'no-label-percent') {
|
|
310
|
+
result.noLabelPercent = true;
|
|
311
|
+
continue;
|
|
312
|
+
}
|
|
313
|
+
|
|
279
314
|
// Bare "series" keyword with no value — collect indented names
|
|
280
315
|
if (firstToken === 'series' && spaceIdx === -1) {
|
|
281
316
|
const parsed = parseSeriesNames('', lines, i, palette);
|
|
@@ -286,16 +321,22 @@ export function parseChart(
|
|
|
286
321
|
result.seriesNames = parsed.names;
|
|
287
322
|
result.seriesNameLineNumbers = parsed.nameLineNumbers;
|
|
288
323
|
}
|
|
289
|
-
if (parsed.nameColors.some(Boolean))
|
|
324
|
+
if (parsed.nameColors.some(Boolean))
|
|
325
|
+
result.seriesNameColors = parsed.nameColors;
|
|
290
326
|
continue;
|
|
291
327
|
}
|
|
292
328
|
|
|
293
329
|
// Data row: parse from the right — rightmost numeric token(s) = value(s), everything left = label
|
|
294
330
|
// Supports comma-separated multi-values: "Jan 100, 200, 300"
|
|
331
|
+
// Supports space-separated multi-values when series are defined: "Jan 100 200 300"
|
|
295
332
|
// Supports comma-grouped numbers: "Revenue 1,200, 1,500" → [1200, 1500]
|
|
296
|
-
const
|
|
333
|
+
const multiValue = (result.seriesNames?.length ?? 0) >= 2;
|
|
334
|
+
const dataValues = parseDataRowValues(trimmed, { multiValue });
|
|
297
335
|
if (dataValues) {
|
|
298
|
-
const { label: rawLabel, color: pointColor } = extractColor(
|
|
336
|
+
const { label: rawLabel, color: pointColor } = extractColor(
|
|
337
|
+
dataValues.label,
|
|
338
|
+
palette
|
|
339
|
+
);
|
|
299
340
|
const [first, ...rest] = dataValues.values;
|
|
300
341
|
result.data.push({
|
|
301
342
|
label: rawLabel,
|
|
@@ -304,7 +345,14 @@ export function parseChart(
|
|
|
304
345
|
...(pointColor && { color: pointColor }),
|
|
305
346
|
lineNumber,
|
|
306
347
|
});
|
|
348
|
+
continue;
|
|
307
349
|
}
|
|
350
|
+
|
|
351
|
+
// Catch-all: nothing matched this line
|
|
352
|
+
let msg = `Unexpected line: '${trimmed}'.`;
|
|
353
|
+
const hint = suggest(firstToken, [...KNOWN_OPTIONS, ...KNOWN_BOOLEANS]);
|
|
354
|
+
if (hint) msg += ` ${hint}`;
|
|
355
|
+
result.diagnostics.push(makeDgmoError(lineNumber, msg, 'warning'));
|
|
308
356
|
}
|
|
309
357
|
|
|
310
358
|
// Resolve raw eras against known data labels (longest-prefix match for multi-word labels)
|
|
@@ -329,7 +377,13 @@ export function parseChart(
|
|
|
329
377
|
end = words[0];
|
|
330
378
|
label = words.slice(1).join(' ');
|
|
331
379
|
}
|
|
332
|
-
parsedEras.push({
|
|
380
|
+
parsedEras.push({
|
|
381
|
+
start: raw.start,
|
|
382
|
+
end,
|
|
383
|
+
label,
|
|
384
|
+
color: raw.color,
|
|
385
|
+
lineNumber: raw.lineNumber,
|
|
386
|
+
});
|
|
333
387
|
}
|
|
334
388
|
|
|
335
389
|
// Eras are only valid for line, multi-line (aliased to 'line'), and area chart types
|
|
@@ -353,7 +407,10 @@ export function parseChart(
|
|
|
353
407
|
}
|
|
354
408
|
|
|
355
409
|
if (!result.error && result.type === 'bar-stacked' && !result.seriesNames) {
|
|
356
|
-
setChartError(
|
|
410
|
+
setChartError(
|
|
411
|
+
1,
|
|
412
|
+
'Chart type "bar-stacked" requires multiple series names. Use: series Name1, Name2, Name3'
|
|
413
|
+
);
|
|
357
414
|
}
|
|
358
415
|
|
|
359
416
|
if (!result.error && result.seriesNames) {
|
|
@@ -361,7 +418,10 @@ export function parseChart(
|
|
|
361
418
|
for (const dp of result.data) {
|
|
362
419
|
const actualCount = 1 + (dp.extraValues?.length ?? 0);
|
|
363
420
|
if (actualCount !== expectedCount) {
|
|
364
|
-
warn(
|
|
421
|
+
warn(
|
|
422
|
+
dp.lineNumber,
|
|
423
|
+
`Data point "${dp.label}" has ${actualCount} value(s), but ${expectedCount} series defined. Each row must have ${expectedCount} values.`
|
|
424
|
+
);
|
|
365
425
|
}
|
|
366
426
|
}
|
|
367
427
|
// Filter out mismatched data points so renderers get clean data
|
|
@@ -380,20 +440,22 @@ export function parseChart(
|
|
|
380
440
|
|
|
381
441
|
/**
|
|
382
442
|
* Parse a data row line: everything before the last numeric token(s) is the label,
|
|
383
|
-
* numeric tokens at the end are the values. Supports comma-separated multi-values
|
|
384
|
-
* and comma-grouped numbers (e.g., "1,087").
|
|
443
|
+
* numeric tokens at the end are the values. Supports comma-separated multi-values,
|
|
444
|
+
* space-separated multi-values, and comma-grouped numbers (e.g., "1,087").
|
|
385
445
|
*
|
|
386
446
|
* Examples:
|
|
387
|
-
* "Jan 120"
|
|
388
|
-
* "North America 250"
|
|
389
|
-
* "
|
|
390
|
-
* "Q1 10
|
|
391
|
-
* "Revenue 1,200"
|
|
447
|
+
* "Jan 120" → { label: "Jan", values: [120] }
|
|
448
|
+
* "North America 250" → { label: "North America", values: [250] }
|
|
449
|
+
* "Q1 10, 20, 30" → { label: "Q1", values: [10, 20, 30] }
|
|
450
|
+
* "Q1 10 20 30" → { label: "Q1", values: [10, 20, 30] }
|
|
451
|
+
* "Revenue 1,200" → { label: "Revenue", values: [1200] }
|
|
452
|
+
* "Revenue 3,984,078.65"→ { label: "Revenue", values: [3984078.65] }
|
|
392
453
|
*
|
|
393
454
|
* Returns null if the line has no numeric value at the end.
|
|
394
455
|
*/
|
|
395
456
|
export function parseDataRowValues(
|
|
396
457
|
line: string,
|
|
458
|
+
options?: { multiValue?: boolean }
|
|
397
459
|
): { label: string; values: number[] } | null {
|
|
398
460
|
// First, normalize comma-grouped numbers: replace patterns like "1,087" with "1087"
|
|
399
461
|
// We need to be careful: commas also separate multi-values.
|
|
@@ -407,11 +469,12 @@ export function parseDataRowValues(
|
|
|
407
469
|
const normalized: string[] = [];
|
|
408
470
|
for (let i = 0; i < segments.length; i++) {
|
|
409
471
|
const seg = segments[i].trim();
|
|
410
|
-
// Check if this segment is a continuation of a grouped number
|
|
411
|
-
// A continuation
|
|
472
|
+
// Check if this segment is a continuation of a grouped number.
|
|
473
|
+
// A continuation starts with exactly 3 digits (possibly followed by a decimal like ".65")
|
|
474
|
+
// and follows a segment ending in digits.
|
|
412
475
|
// Grouped numbers have NO space around the comma (e.g., "1,087"), so skip if
|
|
413
476
|
// the raw segment has leading whitespace (e.g., ", 350" is a value separator).
|
|
414
|
-
if (i > 0 && /^\d{3}
|
|
477
|
+
if (i > 0 && /^\d{3}(\.\d+)?$/.test(seg) && !/^\s/.test(segments[i])) {
|
|
415
478
|
const prevSeg = normalized[normalized.length - 1].trimEnd();
|
|
416
479
|
// Check if previous segment ends with a number (1-3 digits at the end of the last token)
|
|
417
480
|
if (/\d{1,3}$/.test(prevSeg)) {
|
|
@@ -420,7 +483,6 @@ export function parseDataRowValues(
|
|
|
420
483
|
const prevMatch = prevSeg.match(/(\d{1,3})$/);
|
|
421
484
|
if (prevMatch) {
|
|
422
485
|
// Tentatively merge and validate
|
|
423
|
-
const mergedTail = prevMatch[1] + ',' + seg;
|
|
424
486
|
// Build full token by looking at what's left in normalized
|
|
425
487
|
// Simple approach: just merge
|
|
426
488
|
normalized[normalized.length - 1] = prevSeg + seg;
|
|
@@ -462,7 +524,11 @@ export function parseDataRowValues(
|
|
|
462
524
|
const lastSpaceIdx = firstPart.lastIndexOf(' ');
|
|
463
525
|
if (lastSpaceIdx >= 0) {
|
|
464
526
|
const possibleFirstVal = firstPart.substring(lastSpaceIdx + 1).trim();
|
|
465
|
-
if (
|
|
527
|
+
if (
|
|
528
|
+
possibleFirstVal &&
|
|
529
|
+
!isNaN(parseFloat(possibleFirstVal)) &&
|
|
530
|
+
isFinite(Number(possibleFirstVal))
|
|
531
|
+
) {
|
|
466
532
|
const label = firstPart.substring(0, lastSpaceIdx).trim();
|
|
467
533
|
if (label) {
|
|
468
534
|
const values = [parseFloat(possibleFirstVal)];
|
|
@@ -476,16 +542,35 @@ export function parseDataRowValues(
|
|
|
476
542
|
}
|
|
477
543
|
}
|
|
478
544
|
|
|
479
|
-
// No commas or comma parsing didn't work — split by spaces from right
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
545
|
+
// No commas or comma parsing didn't work — split by spaces from right.
|
|
546
|
+
// When multiValue is enabled, walk backward collecting consecutive numeric tokens.
|
|
547
|
+
// Otherwise (default), take only the last token — preserving labels that contain
|
|
548
|
+
// numbers (e.g., "Region 5 300" → label "Region 5", value 300).
|
|
549
|
+
const tokens = rebuilt.split(/\s+/);
|
|
550
|
+
if (tokens.length < 2) return null;
|
|
551
|
+
|
|
552
|
+
if (options?.multiValue) {
|
|
553
|
+
const values: number[] = [];
|
|
554
|
+
let idx = tokens.length - 1;
|
|
555
|
+
while (idx >= 1) {
|
|
556
|
+
const tok = tokens[idx];
|
|
557
|
+
const num = parseFloat(tok);
|
|
558
|
+
if (isNaN(num) || !isFinite(Number(tok))) break;
|
|
559
|
+
values.unshift(num);
|
|
560
|
+
idx--;
|
|
561
|
+
}
|
|
562
|
+
if (values.length === 0) return null;
|
|
563
|
+
const label = tokens.slice(0, idx + 1).join(' ');
|
|
564
|
+
if (!label) return null;
|
|
565
|
+
return { label, values };
|
|
566
|
+
}
|
|
483
567
|
|
|
484
|
-
|
|
485
|
-
const
|
|
486
|
-
|
|
568
|
+
// Single-value mode: only the last space-separated token
|
|
569
|
+
const lastToken = tokens[tokens.length - 1];
|
|
570
|
+
const num = parseFloat(lastToken);
|
|
571
|
+
if (isNaN(num) || !isFinite(Number(lastToken))) return null;
|
|
487
572
|
|
|
488
|
-
const label =
|
|
573
|
+
const label = tokens.slice(0, -1).join(' ');
|
|
489
574
|
if (!label) return null;
|
|
490
575
|
|
|
491
576
|
return { label, values: [num] };
|
package/src/class/parser.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
import { resolveColor } from '../colors';
|
|
2
2
|
import type { PaletteColors } from '../palettes';
|
|
3
|
-
import { makeDgmoError, formatDgmoError
|
|
4
|
-
import {
|
|
3
|
+
import { makeDgmoError, formatDgmoError } from '../diagnostics';
|
|
4
|
+
import {
|
|
5
|
+
measureIndent,
|
|
6
|
+
parseFirstLine,
|
|
7
|
+
OPTION_NOCOLON_RE,
|
|
8
|
+
} from '../utils/parsing';
|
|
5
9
|
import type {
|
|
6
10
|
ParsedClassDiagram,
|
|
7
11
|
ClassNode,
|
|
@@ -30,12 +34,16 @@ function classId(name: string): string {
|
|
|
30
34
|
const CLASS_DECL_RE =
|
|
31
35
|
/^(?:(abstract|interface|enum)\s+)?([A-Z][A-Za-z0-9_]*)(?:\s+(extends|implements)\s+([A-Z][A-Za-z0-9_]*))?(?:\s+\[(abstract|interface|enum)\])?(?:\s+\(([^)]+)\))?\s*$/;
|
|
32
36
|
|
|
33
|
-
// Relationship — arrow syntax:
|
|
34
|
-
//
|
|
35
|
-
//
|
|
37
|
+
// Relationship — arrow syntax (indented under source class):
|
|
38
|
+
// --|> TargetClass label (space-separated)
|
|
39
|
+
// --|> TargetClass : label (colon-separated, kept for transition)
|
|
36
40
|
// Arrows: --|> ..|> *-- o-- ..> ->
|
|
41
|
+
const INDENT_REL_ARROW_RE =
|
|
42
|
+
/^(--\|>|\.\.\|>|\*--|o--|\.\.>|->)\s*([A-Z][A-Za-z0-9_]*)(?:\s+:?\s*(.+))?$/;
|
|
43
|
+
|
|
44
|
+
// Legacy top-level relationship regex (used only for detection/rejection)
|
|
37
45
|
const REL_ARROW_RE =
|
|
38
|
-
/^([A-Z][A-Za-z0-9_]*)\s*(--\|>|\.\.\|>|\*--|o
|
|
46
|
+
/^([A-Z][A-Za-z0-9_]*)\s*(--\|>|\.\.\|>|\*--|o--|\.\.>|->)\s*([A-Z][A-Za-z0-9_]*)(?:\s+:?\s*(.+))?$/;
|
|
39
47
|
|
|
40
48
|
// Member line patterns
|
|
41
49
|
const VISIBILITY_RE = /^([+\-#])\s*/;
|
|
@@ -156,7 +164,8 @@ export function parseClassDiagram(
|
|
|
156
164
|
error: null,
|
|
157
165
|
};
|
|
158
166
|
|
|
159
|
-
|
|
167
|
+
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
168
|
+
const _fail = (line: number, message: string): ParsedClassDiagram => {
|
|
160
169
|
const diag = makeDgmoError(line, message);
|
|
161
170
|
result.diagnostics.push(diag);
|
|
162
171
|
result.error = formatDgmoError(diag);
|
|
@@ -211,9 +220,14 @@ export function parseClassDiagram(
|
|
|
211
220
|
}
|
|
212
221
|
}
|
|
213
222
|
|
|
214
|
-
// Space-separated options before content (new syntax): `color
|
|
223
|
+
// Space-separated options before content (new syntax): `no-auto-color`
|
|
215
224
|
// Only match lines starting with a lowercase token (options), not uppercase (class names)
|
|
216
225
|
if (!contentStarted && indent === 0 && /^[a-z]/.test(trimmed)) {
|
|
226
|
+
// Bare boolean option (single keyword, no value)
|
|
227
|
+
if (trimmed.toLowerCase() === 'no-auto-color') {
|
|
228
|
+
result.options['no-auto-color'] = 'on';
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
217
231
|
const optMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
218
232
|
if (optMatch) {
|
|
219
233
|
const key = optMatch[1].toLowerCase();
|
|
@@ -226,8 +240,27 @@ export function parseClassDiagram(
|
|
|
226
240
|
}
|
|
227
241
|
}
|
|
228
242
|
|
|
229
|
-
// Indented lines = members of current class
|
|
243
|
+
// Indented lines = relationships or members of current class
|
|
230
244
|
if (indent > 0 && currentClass) {
|
|
245
|
+
// Try indented relationship arrow: --|> TargetClass [label]
|
|
246
|
+
const indentRel = trimmed.match(INDENT_REL_ARROW_RE);
|
|
247
|
+
if (indentRel) {
|
|
248
|
+
const arrow = indentRel[1];
|
|
249
|
+
const targetName = indentRel[2];
|
|
250
|
+
const label = indentRel[3]?.trim();
|
|
251
|
+
|
|
252
|
+
getOrCreateClass(targetName, lineNumber);
|
|
253
|
+
|
|
254
|
+
result.relationships.push({
|
|
255
|
+
source: currentClass.id,
|
|
256
|
+
target: classId(targetName),
|
|
257
|
+
type: ARROW_TO_TYPE[arrow],
|
|
258
|
+
...(label && { label }),
|
|
259
|
+
lineNumber,
|
|
260
|
+
});
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
231
264
|
const member = parseMember(
|
|
232
265
|
trimmed,
|
|
233
266
|
lineNumber,
|
|
@@ -243,25 +276,19 @@ export function parseClassDiagram(
|
|
|
243
276
|
currentClass = null;
|
|
244
277
|
contentStarted = true;
|
|
245
278
|
|
|
246
|
-
//
|
|
279
|
+
// Reject top-level relationship arrows — must be indented under source class
|
|
247
280
|
const relArrow = trimmed.match(REL_ARROW_RE);
|
|
248
281
|
if (relArrow) {
|
|
249
282
|
const sourceName = relArrow[1];
|
|
250
283
|
const arrow = relArrow[2];
|
|
251
284
|
const targetName = relArrow[3];
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
source: classId(sourceName),
|
|
260
|
-
target: classId(targetName),
|
|
261
|
-
type: ARROW_TO_TYPE[arrow],
|
|
262
|
-
...(label && { label }),
|
|
263
|
-
lineNumber,
|
|
264
|
-
});
|
|
285
|
+
result.diagnostics.push(
|
|
286
|
+
makeDgmoError(
|
|
287
|
+
lineNumber,
|
|
288
|
+
`Relationship "${sourceName} ${arrow} ${targetName}" must be indented under the source class "${sourceName}"`,
|
|
289
|
+
'warning'
|
|
290
|
+
)
|
|
291
|
+
);
|
|
265
292
|
continue;
|
|
266
293
|
}
|
|
267
294
|
|
|
@@ -297,17 +324,29 @@ export function parseClassDiagram(
|
|
|
297
324
|
currentClass = node;
|
|
298
325
|
continue;
|
|
299
326
|
}
|
|
327
|
+
|
|
328
|
+
// Catch-all: nothing matched this line
|
|
329
|
+
result.diagnostics.push(
|
|
330
|
+
makeDgmoError(lineNumber, `Unexpected line: '${trimmed}'.`, 'warning')
|
|
331
|
+
);
|
|
300
332
|
}
|
|
301
333
|
|
|
302
334
|
// Validation
|
|
303
335
|
if (result.classes.length === 0 && !result.error) {
|
|
304
|
-
const diag = makeDgmoError(
|
|
336
|
+
const diag = makeDgmoError(
|
|
337
|
+
1,
|
|
338
|
+
'No classes found. Add class declarations like "ClassName" or "ClassName [interface]".'
|
|
339
|
+
);
|
|
305
340
|
result.diagnostics.push(diag);
|
|
306
341
|
result.error = formatDgmoError(diag);
|
|
307
342
|
}
|
|
308
343
|
|
|
309
344
|
// Warn about isolated classes (not in any relationship)
|
|
310
|
-
if (
|
|
345
|
+
if (
|
|
346
|
+
result.classes.length >= 2 &&
|
|
347
|
+
result.relationships.length >= 1 &&
|
|
348
|
+
!result.error
|
|
349
|
+
) {
|
|
311
350
|
const connectedIds = new Set<string>();
|
|
312
351
|
for (const rel of result.relationships) {
|
|
313
352
|
connectedIds.add(rel.source);
|
|
@@ -315,7 +354,13 @@ export function parseClassDiagram(
|
|
|
315
354
|
}
|
|
316
355
|
for (const cls of result.classes) {
|
|
317
356
|
if (!connectedIds.has(cls.id)) {
|
|
318
|
-
result.diagnostics.push(
|
|
357
|
+
result.diagnostics.push(
|
|
358
|
+
makeDgmoError(
|
|
359
|
+
cls.lineNumber,
|
|
360
|
+
`Class "${cls.name}" is not connected to any other class`,
|
|
361
|
+
'warning'
|
|
362
|
+
)
|
|
363
|
+
);
|
|
319
364
|
}
|
|
320
365
|
}
|
|
321
366
|
}
|
|
@@ -358,7 +403,9 @@ export function looksLikeClassDiagram(content: string): boolean {
|
|
|
358
403
|
hasClassDecl = true;
|
|
359
404
|
}
|
|
360
405
|
// Check for old modifier pattern: ClassName [abstract|interface|enum]
|
|
361
|
-
if (
|
|
406
|
+
if (
|
|
407
|
+
/^[A-Z][A-Za-z0-9_]*\s+\[(abstract|interface|enum)\]/i.test(trimmed)
|
|
408
|
+
) {
|
|
362
409
|
hasModifier = true;
|
|
363
410
|
hasClassDecl = true;
|
|
364
411
|
}
|
|
@@ -380,6 +427,10 @@ export function looksLikeClassDiagram(content: string): boolean {
|
|
|
380
427
|
if (/^[+\-#]?\s*\w+.*[:(]/.test(trimmed)) {
|
|
381
428
|
hasIndentedMember = true;
|
|
382
429
|
}
|
|
430
|
+
// Indented relationship arrows
|
|
431
|
+
if (INDENT_REL_ARROW_RE.test(trimmed)) {
|
|
432
|
+
hasRelationship = true;
|
|
433
|
+
}
|
|
383
434
|
}
|
|
384
435
|
}
|
|
385
436
|
|
|
@@ -409,7 +460,12 @@ export function extractSymbols(docText: string): DiagramSymbols {
|
|
|
409
460
|
for (const rawLine of docText.split('\n')) {
|
|
410
461
|
const line = rawLine.trim();
|
|
411
462
|
// Skip old-style colon metadata and new-style first line / space-separated options
|
|
412
|
-
if (
|
|
463
|
+
if (
|
|
464
|
+
inMetadata &&
|
|
465
|
+
(/^[a-z-]+\s*:/i.test(line) || /^class(\s|$)/i.test(line))
|
|
466
|
+
)
|
|
467
|
+
continue;
|
|
468
|
+
if (inMetadata && line.toLowerCase() === 'no-auto-color') continue;
|
|
413
469
|
if (inMetadata && /^[a-z]/.test(line) && OPTION_NOCOLON_RE.test(line)) {
|
|
414
470
|
const key = line.match(OPTION_NOCOLON_RE)![1].toLowerCase();
|
|
415
471
|
if (key !== 'abstract' && key !== 'interface' && key !== 'enum') continue;
|
package/src/class/renderer.ts
CHANGED
|
@@ -82,7 +82,7 @@ const CLASS_TYPE_MAP: Record<string, ClassLegendEntry> = {
|
|
|
82
82
|
const CLASS_TYPE_ORDER = ['class', 'abstract', 'interface', 'enum'];
|
|
83
83
|
|
|
84
84
|
function collectClassTypes(parsed: ParsedClassDiagram): ClassLegendEntry[] {
|
|
85
|
-
if (parsed.options?.color
|
|
85
|
+
if (parsed.options?.['no-auto-color']) return [];
|
|
86
86
|
|
|
87
87
|
const present = new Set<string>();
|
|
88
88
|
for (const c of parsed.classes) {
|
|
@@ -500,7 +500,7 @@ export function renderClassDiagram(
|
|
|
500
500
|
|
|
501
501
|
const w = node.width;
|
|
502
502
|
const h = node.height;
|
|
503
|
-
const colorOff = parsed.options?.color
|
|
503
|
+
const colorOff = !!parsed.options?.['no-auto-color'];
|
|
504
504
|
// When legend is collapsed, use neutral color for nodes without explicit color
|
|
505
505
|
const neutralize = hasLegend && !isLegendExpanded && !node.color;
|
|
506
506
|
const effectiveColor = neutralize ? palette.primary : node.color;
|