@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/chart.ts
CHANGED
|
@@ -25,6 +25,7 @@ export interface ChartEra {
|
|
|
25
25
|
end: string; // exact category label, e.g. "'81"
|
|
26
26
|
label: string; // display name, e.g. "Carter"
|
|
27
27
|
color: string | null; // resolved CSS color, or null → palette default
|
|
28
|
+
lineNumber: number;
|
|
28
29
|
}
|
|
29
30
|
|
|
30
31
|
import type { DgmoError } from './diagnostics';
|
|
@@ -34,9 +35,13 @@ export interface ParsedChart {
|
|
|
34
35
|
title?: string;
|
|
35
36
|
titleLineNumber?: number;
|
|
36
37
|
series?: string;
|
|
38
|
+
seriesLineNumber?: number;
|
|
37
39
|
xlabel?: string;
|
|
40
|
+
xlabelLineNumber?: number;
|
|
38
41
|
ylabel?: string;
|
|
42
|
+
ylabelLineNumber?: number;
|
|
39
43
|
seriesNames?: string[];
|
|
44
|
+
seriesNameLineNumbers?: number[];
|
|
40
45
|
seriesNameColors?: (string | undefined)[];
|
|
41
46
|
orientation?: 'horizontal' | 'vertical';
|
|
42
47
|
color?: string;
|
|
@@ -55,7 +60,7 @@ export interface ParsedChart {
|
|
|
55
60
|
import { resolveColor } from './colors';
|
|
56
61
|
import type { PaletteColors } from './palettes';
|
|
57
62
|
import { makeDgmoError, formatDgmoError, suggest } from './diagnostics';
|
|
58
|
-
import { extractColor, parseSeriesNames } from './utils/parsing';
|
|
63
|
+
import { extractColor, normalizeDirection, normalizeGroupedNumber, parseFirstLine, parseSeriesNames } from './utils/parsing';
|
|
59
64
|
|
|
60
65
|
// ============================================================
|
|
61
66
|
// Parser
|
|
@@ -76,18 +81,23 @@ const TYPE_ALIASES: Record<string, ChartType> = {
|
|
|
76
81
|
'multi-line': 'line',
|
|
77
82
|
};
|
|
78
83
|
|
|
84
|
+
/** Known option keywords for the simple chart parser. */
|
|
85
|
+
const KNOWN_OPTIONS = new Set([
|
|
86
|
+
'chart', 'title', 'series', 'xlabel', 'ylabel', 'label', 'labels',
|
|
87
|
+
'orientation', 'direction', 'color',
|
|
88
|
+
]);
|
|
89
|
+
|
|
79
90
|
/**
|
|
80
91
|
* Parses the simple chart text format into a structured object.
|
|
81
92
|
*
|
|
82
|
-
* Format:
|
|
93
|
+
* Format (colon-free):
|
|
83
94
|
* ```
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* series: Revenue
|
|
95
|
+
* bar My Chart
|
|
96
|
+
* series Revenue
|
|
87
97
|
*
|
|
88
|
-
* Jan
|
|
89
|
-
* Feb
|
|
90
|
-
* Mar
|
|
98
|
+
* Jan 120
|
|
99
|
+
* Feb 200
|
|
100
|
+
* Mar 150
|
|
91
101
|
* ```
|
|
92
102
|
*/
|
|
93
103
|
export function parseChart(
|
|
@@ -96,6 +106,7 @@ export function parseChart(
|
|
|
96
106
|
): ParsedChart {
|
|
97
107
|
const lines = content.split('\n');
|
|
98
108
|
const parsedEras: ChartEra[] = [];
|
|
109
|
+
const rawEras: { start: string; afterArrow: string; color: string | null; lineNumber: number }[] = [];
|
|
99
110
|
const result: ParsedChart = {
|
|
100
111
|
type: 'bar',
|
|
101
112
|
data: [],
|
|
@@ -111,6 +122,8 @@ export function parseChart(
|
|
|
111
122
|
return result;
|
|
112
123
|
};
|
|
113
124
|
|
|
125
|
+
let firstLineParsed = false;
|
|
126
|
+
|
|
114
127
|
for (let i = 0; i < lines.length; i++) {
|
|
115
128
|
const trimmed = lines[i].trim();
|
|
116
129
|
const lineNumber = i + 1;
|
|
@@ -127,115 +140,198 @@ export function parseChart(
|
|
|
127
140
|
// Skip comments
|
|
128
141
|
if (trimmed.startsWith('//')) continue;
|
|
129
142
|
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
if (
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
let msg = `Unsupported chart type: ${value}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
|
|
157
|
-
const hint = suggest(raw, [...VALID_TYPES]);
|
|
143
|
+
// First non-empty, non-comment line: chart type + optional title
|
|
144
|
+
if (!firstLineParsed) {
|
|
145
|
+
firstLineParsed = true;
|
|
146
|
+
const firstLine = parseFirstLine(trimmed);
|
|
147
|
+
if (firstLine) {
|
|
148
|
+
const raw = firstLine.chartType.toLowerCase();
|
|
149
|
+
const chartType = (TYPE_ALIASES[raw] ?? raw) as ChartType;
|
|
150
|
+
if (VALID_TYPES.has(chartType)) {
|
|
151
|
+
result.type = chartType;
|
|
152
|
+
if (firstLine.title) {
|
|
153
|
+
result.title = firstLine.title;
|
|
154
|
+
result.titleLineNumber = lineNumber;
|
|
155
|
+
}
|
|
156
|
+
continue;
|
|
157
|
+
} else {
|
|
158
|
+
let msg = `Unsupported chart type: ${firstLine.chartType}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
|
|
159
|
+
const hint = suggest(raw, [...VALID_TYPES]);
|
|
160
|
+
if (hint) msg += ` ${hint}`;
|
|
161
|
+
return fail(lineNumber, msg);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
// If the first line is a single word (no spaces, no colon, no numbers),
|
|
165
|
+
// treat it as an unrecognized chart type rather than falling through
|
|
166
|
+
if (!trimmed.includes(' ') && !trimmed.includes(':') && !/\d/.test(trimmed)) {
|
|
167
|
+
let msg = `Unsupported chart type: ${trimmed}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
|
|
168
|
+
const hint = suggest(trimmed.toLowerCase(), [...VALID_TYPES]);
|
|
158
169
|
if (hint) msg += ` ${hint}`;
|
|
159
170
|
return fail(lineNumber, msg);
|
|
160
171
|
}
|
|
161
|
-
|
|
172
|
+
// Fall through — first line might be a data row or option
|
|
162
173
|
}
|
|
163
174
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
175
|
+
// Era line: era Day 1 -> Day 3 Rough Seas (blue) — colon-free
|
|
176
|
+
const eraMatch = trimmed.match(/^era\s+(.+?)\s*->\s*(.+?)(?:\s*\(([^)]+)\))?\s*$/);
|
|
177
|
+
if (eraMatch) {
|
|
178
|
+
// Store start and raw afterArrow — resolved against data labels after parsing
|
|
179
|
+
const afterArrow = eraMatch[2].trim();
|
|
180
|
+
const spaceIdx = afterArrow.indexOf(' ');
|
|
181
|
+
if (spaceIdx >= 0) {
|
|
182
|
+
rawEras.push({
|
|
183
|
+
start: eraMatch[1].trim(),
|
|
184
|
+
afterArrow,
|
|
185
|
+
color: eraMatch[3] ? resolveColor(eraMatch[3].trim(), palette) : null,
|
|
186
|
+
lineNumber,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
167
189
|
continue;
|
|
168
190
|
}
|
|
169
191
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
192
|
+
// Extract first token to check for known options
|
|
193
|
+
const spaceIdx = trimmed.indexOf(' ');
|
|
194
|
+
const firstToken = (spaceIdx >= 0 ? trimmed.substring(0, spaceIdx) : trimmed).toLowerCase();
|
|
195
|
+
|
|
196
|
+
// Known option with a value
|
|
197
|
+
if (KNOWN_OPTIONS.has(firstToken) && spaceIdx >= 0) {
|
|
198
|
+
const value = trimmed.substring(spaceIdx + 1).trim();
|
|
199
|
+
|
|
200
|
+
if (firstToken === 'chart') {
|
|
201
|
+
const raw = value.toLowerCase();
|
|
202
|
+
const chartType = (TYPE_ALIASES[raw] ?? raw) as ChartType;
|
|
203
|
+
if (VALID_TYPES.has(chartType)) {
|
|
204
|
+
result.type = chartType;
|
|
205
|
+
} else {
|
|
206
|
+
let msg = `Unsupported chart type: ${value}. Supported types: ${[...VALID_TYPES].join(', ')}.`;
|
|
207
|
+
const hint = suggest(raw, [...VALID_TYPES]);
|
|
208
|
+
if (hint) msg += ` ${hint}`;
|
|
209
|
+
return fail(lineNumber, msg);
|
|
210
|
+
}
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
174
213
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
214
|
+
if (firstToken === 'title') {
|
|
215
|
+
result.title = value;
|
|
216
|
+
result.titleLineNumber = lineNumber;
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
179
219
|
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
220
|
+
if (firstToken === 'xlabel') {
|
|
221
|
+
result.xlabel = value;
|
|
222
|
+
result.xlabelLineNumber = lineNumber;
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
184
225
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
226
|
+
if (firstToken === 'ylabel') {
|
|
227
|
+
result.ylabel = value;
|
|
228
|
+
result.ylabelLineNumber = lineNumber;
|
|
229
|
+
continue;
|
|
189
230
|
}
|
|
190
|
-
continue;
|
|
191
|
-
}
|
|
192
231
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
232
|
+
if (firstToken === 'label') {
|
|
233
|
+
result.label = value;
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (firstToken === 'labels') {
|
|
196
238
|
const v = value.toLowerCase();
|
|
197
|
-
if (v === '
|
|
198
|
-
result.
|
|
239
|
+
if (v === 'name' || v === 'value' || v === 'percent' || v === 'full') {
|
|
240
|
+
result.labels = v;
|
|
199
241
|
}
|
|
242
|
+
continue;
|
|
200
243
|
}
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
244
|
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
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
|
+
if (firstToken === 'color') {
|
|
261
|
+
result.color = resolveColor(value.trim(), palette) ?? undefined;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (firstToken === 'series') {
|
|
266
|
+
const parsed = parseSeriesNames(value, lines, i, palette);
|
|
267
|
+
i = parsed.newIndex;
|
|
268
|
+
result.series = parsed.series;
|
|
269
|
+
result.seriesLineNumber = lineNumber;
|
|
270
|
+
if (parsed.names.length > 1) {
|
|
271
|
+
result.seriesNames = parsed.names;
|
|
272
|
+
result.seriesNameLineNumbers = parsed.nameLineNumbers;
|
|
273
|
+
}
|
|
274
|
+
if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
|
|
275
|
+
continue;
|
|
276
|
+
}
|
|
207
277
|
}
|
|
208
278
|
|
|
209
|
-
|
|
210
|
-
|
|
279
|
+
// Bare "series" keyword with no value — collect indented names
|
|
280
|
+
if (firstToken === 'series' && spaceIdx === -1) {
|
|
281
|
+
const parsed = parseSeriesNames('', lines, i, palette);
|
|
211
282
|
i = parsed.newIndex;
|
|
212
283
|
result.series = parsed.series;
|
|
284
|
+
result.seriesLineNumber = lineNumber;
|
|
213
285
|
if (parsed.names.length > 1) {
|
|
214
286
|
result.seriesNames = parsed.names;
|
|
287
|
+
result.seriesNameLineNumbers = parsed.nameLineNumbers;
|
|
215
288
|
}
|
|
216
289
|
if (parsed.nameColors.some(Boolean)) result.seriesNameColors = parsed.nameColors;
|
|
217
290
|
continue;
|
|
218
291
|
}
|
|
219
292
|
|
|
220
|
-
// Data
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
const
|
|
226
|
-
|
|
227
|
-
.map((s) => parseFloat(s))
|
|
228
|
-
.filter((n) => !isNaN(n));
|
|
293
|
+
// Data row: parse from the right — rightmost numeric token(s) = value(s), everything left = label
|
|
294
|
+
// Supports comma-separated multi-values: "Jan 100, 200, 300"
|
|
295
|
+
// Supports comma-grouped numbers: "Revenue 1,200, 1,500" → [1200, 1500]
|
|
296
|
+
const dataValues = parseDataRowValues(trimmed);
|
|
297
|
+
if (dataValues) {
|
|
298
|
+
const { label: rawLabel, color: pointColor } = extractColor(dataValues.label, palette);
|
|
299
|
+
const [first, ...rest] = dataValues.values;
|
|
229
300
|
result.data.push({
|
|
230
301
|
label: rawLabel,
|
|
231
|
-
value:
|
|
232
|
-
...(
|
|
302
|
+
value: first,
|
|
303
|
+
...(rest.length > 0 && { extraValues: rest }),
|
|
233
304
|
...(pointColor && { color: pointColor }),
|
|
234
305
|
lineNumber,
|
|
235
306
|
});
|
|
236
307
|
}
|
|
237
308
|
}
|
|
238
309
|
|
|
310
|
+
// Resolve raw eras against known data labels (longest-prefix match for multi-word labels)
|
|
311
|
+
const knownLabels = new Set(result.data.map((d) => d.label));
|
|
312
|
+
for (const raw of rawEras) {
|
|
313
|
+
// Find the longest prefix of afterArrow that matches a known label
|
|
314
|
+
const words = raw.afterArrow.split(' ');
|
|
315
|
+
let end = '';
|
|
316
|
+
let label = '';
|
|
317
|
+
let matched = false;
|
|
318
|
+
for (let w = words.length - 1; w >= 1; w--) {
|
|
319
|
+
const candidateEnd = words.slice(0, w).join(' ');
|
|
320
|
+
if (knownLabels.has(candidateEnd)) {
|
|
321
|
+
end = candidateEnd;
|
|
322
|
+
label = words.slice(w).join(' ');
|
|
323
|
+
matched = true;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
if (!matched) {
|
|
328
|
+
// Fallback: first token = end, rest = label
|
|
329
|
+
end = words[0];
|
|
330
|
+
label = words.slice(1).join(' ');
|
|
331
|
+
}
|
|
332
|
+
parsedEras.push({ start: raw.start, end, label, color: raw.color, lineNumber: raw.lineNumber });
|
|
333
|
+
}
|
|
334
|
+
|
|
239
335
|
// Eras are only valid for line, multi-line (aliased to 'line'), and area chart types
|
|
240
336
|
if (result.type !== 'line' && result.type !== 'area') {
|
|
241
337
|
result.eras = undefined;
|
|
@@ -253,11 +349,11 @@ export function parseChart(
|
|
|
253
349
|
};
|
|
254
350
|
|
|
255
351
|
if (!result.error && result.data.length === 0) {
|
|
256
|
-
warn(1, 'No data points found. Add data in format: Label
|
|
352
|
+
warn(1, 'No data points found. Add data in format: Label 123');
|
|
257
353
|
}
|
|
258
354
|
|
|
259
355
|
if (!result.error && result.type === 'bar-stacked' && !result.seriesNames) {
|
|
260
|
-
setChartError(1, 'Chart type "bar-stacked" requires multiple series names. Use: series
|
|
356
|
+
setChartError(1, 'Chart type "bar-stacked" requires multiple series names. Use: series Name1, Name2, Name3');
|
|
261
357
|
}
|
|
262
358
|
|
|
263
359
|
if (!result.error && result.seriesNames) {
|
|
@@ -277,3 +373,120 @@ export function parseChart(
|
|
|
277
373
|
|
|
278
374
|
return result;
|
|
279
375
|
}
|
|
376
|
+
|
|
377
|
+
// ============================================================
|
|
378
|
+
// Data Row Parser
|
|
379
|
+
// ============================================================
|
|
380
|
+
|
|
381
|
+
/**
|
|
382
|
+
* 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").
|
|
385
|
+
*
|
|
386
|
+
* Examples:
|
|
387
|
+
* "Jan 120" → { label: "Jan", values: [120] }
|
|
388
|
+
* "North America 250" → { label: "North America", values: [250] }
|
|
389
|
+
* "Region 5 300" → { label: "Region 5", values: [300] }
|
|
390
|
+
* "Q1 10, 20, 30" → { label: "Q1", values: [10, 20, 30] }
|
|
391
|
+
* "Revenue 1,200" → { label: "Revenue", values: [1200] }
|
|
392
|
+
*
|
|
393
|
+
* Returns null if the line has no numeric value at the end.
|
|
394
|
+
*/
|
|
395
|
+
export function parseDataRowValues(
|
|
396
|
+
line: string,
|
|
397
|
+
): { label: string; values: number[] } | null {
|
|
398
|
+
// First, normalize comma-grouped numbers: replace patterns like "1,087" with "1087"
|
|
399
|
+
// We need to be careful: commas also separate multi-values.
|
|
400
|
+
// Strategy: tokenize by commas, normalize grouped numbers, then re-parse.
|
|
401
|
+
|
|
402
|
+
// Split by comma to get segments
|
|
403
|
+
const segments = line.split(',');
|
|
404
|
+
|
|
405
|
+
// Normalize each segment: if a segment (trimmed) matches grouped number pattern,
|
|
406
|
+
// merge it with the previous segment
|
|
407
|
+
const normalized: string[] = [];
|
|
408
|
+
for (let i = 0; i < segments.length; i++) {
|
|
409
|
+
const seg = segments[i].trim();
|
|
410
|
+
// Check if this segment is a continuation of a grouped number
|
|
411
|
+
// A continuation looks like exactly 3 digits and follows a segment ending in digits.
|
|
412
|
+
// Grouped numbers have NO space around the comma (e.g., "1,087"), so skip if
|
|
413
|
+
// the raw segment has leading whitespace (e.g., ", 350" is a value separator).
|
|
414
|
+
if (i > 0 && /^\d{3}$/.test(seg) && !/^\s/.test(segments[i])) {
|
|
415
|
+
const prevSeg = normalized[normalized.length - 1].trimEnd();
|
|
416
|
+
// Check if previous segment ends with a number (1-3 digits at the end of the last token)
|
|
417
|
+
if (/\d{1,3}$/.test(prevSeg)) {
|
|
418
|
+
// Check if the combined token would be a valid grouped number
|
|
419
|
+
// Extract the trailing number from prev
|
|
420
|
+
const prevMatch = prevSeg.match(/(\d{1,3})$/);
|
|
421
|
+
if (prevMatch) {
|
|
422
|
+
// Tentatively merge and validate
|
|
423
|
+
const mergedTail = prevMatch[1] + ',' + seg;
|
|
424
|
+
// Build full token by looking at what's left in normalized
|
|
425
|
+
// Simple approach: just merge
|
|
426
|
+
normalized[normalized.length - 1] = prevSeg + seg;
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
normalized.push(segments[i]);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
const rebuilt = normalized.join(',');
|
|
435
|
+
|
|
436
|
+
// Now check for comma-separated values at the end
|
|
437
|
+
// Strategy: find where the label ends and values begin
|
|
438
|
+
// Values are comma-separated numeric tokens at the end of the line
|
|
439
|
+
|
|
440
|
+
// Try splitting by comma first — if the line has commas, the last comma-separated tokens
|
|
441
|
+
// that are all numeric form the values
|
|
442
|
+
const commaParts = rebuilt.split(',');
|
|
443
|
+
if (commaParts.length > 1) {
|
|
444
|
+
// Find how many trailing comma-separated parts are numeric
|
|
445
|
+
let numericCount = 0;
|
|
446
|
+
for (let j = commaParts.length - 1; j >= 0; j--) {
|
|
447
|
+
const part = commaParts[j].trim();
|
|
448
|
+
if (part && !isNaN(parseFloat(part)) && isFinite(Number(part))) {
|
|
449
|
+
numericCount++;
|
|
450
|
+
} else {
|
|
451
|
+
break;
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
if (numericCount > 0) {
|
|
455
|
+
// Pure numeric trailing comma-parts are extra values.
|
|
456
|
+
// Everything before them (joined by comma) contains "label firstValue".
|
|
457
|
+
const splitAt = commaParts.length - numericCount;
|
|
458
|
+
const extraValueParts = commaParts.slice(splitAt);
|
|
459
|
+
const firstPart = commaParts.slice(0, splitAt).join(',').trim();
|
|
460
|
+
|
|
461
|
+
// Split firstPart from the right: last space-separated token must be numeric
|
|
462
|
+
const lastSpaceIdx = firstPart.lastIndexOf(' ');
|
|
463
|
+
if (lastSpaceIdx >= 0) {
|
|
464
|
+
const possibleFirstVal = firstPart.substring(lastSpaceIdx + 1).trim();
|
|
465
|
+
if (possibleFirstVal && !isNaN(parseFloat(possibleFirstVal)) && isFinite(Number(possibleFirstVal))) {
|
|
466
|
+
const label = firstPart.substring(0, lastSpaceIdx).trim();
|
|
467
|
+
if (label) {
|
|
468
|
+
const values = [parseFloat(possibleFirstVal)];
|
|
469
|
+
for (const p of extraValueParts) {
|
|
470
|
+
values.push(parseFloat(p.trim()));
|
|
471
|
+
}
|
|
472
|
+
return { label, values };
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
// No commas or comma parsing didn't work — split by spaces from right
|
|
480
|
+
// Last space-separated token that is numeric = the value
|
|
481
|
+
const lastSpaceIdx = rebuilt.lastIndexOf(' ');
|
|
482
|
+
if (lastSpaceIdx < 0) return null;
|
|
483
|
+
|
|
484
|
+
const possibleValue = rebuilt.substring(lastSpaceIdx + 1).trim();
|
|
485
|
+
const num = parseFloat(possibleValue);
|
|
486
|
+
if (isNaN(num) || !isFinite(Number(possibleValue))) return null;
|
|
487
|
+
|
|
488
|
+
const label = rebuilt.substring(0, lastSpaceIdx).trim();
|
|
489
|
+
if (!label) return null;
|
|
490
|
+
|
|
491
|
+
return { label, values: [num] };
|
|
492
|
+
}
|
package/src/class/parser.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { resolveColor } from '../colors';
|
|
2
2
|
import type { PaletteColors } from '../palettes';
|
|
3
3
|
import { makeDgmoError, formatDgmoError, suggest } from '../diagnostics';
|
|
4
|
-
import { measureIndent } from '../utils/parsing';
|
|
4
|
+
import { measureIndent, parseFirstLine, OPTION_NOCOLON_RE } from '../utils/parsing';
|
|
5
5
|
import type {
|
|
6
6
|
ParsedClassDiagram,
|
|
7
7
|
ClassNode,
|
|
@@ -23,15 +23,19 @@ function classId(name: string): string {
|
|
|
23
23
|
// Regex patterns
|
|
24
24
|
// ============================================================
|
|
25
25
|
|
|
26
|
-
// Class declaration: ClassName [extends|implements ParentClass]
|
|
26
|
+
// Class declaration: [modifier] ClassName [extends|implements ParentClass] (color)
|
|
27
|
+
// Supports both:
|
|
28
|
+
// New: `abstract Animal` or `interface Serializable`
|
|
29
|
+
// Old: `Animal [abstract]` (bracketed suffix, kept for transition)
|
|
27
30
|
const CLASS_DECL_RE =
|
|
28
|
-
/^([A-Z][A-Za-z0-9_]*)(?:\s+(extends|implements)\s+([A-Z][A-Za-z0-9_]*))?(?:\s+\[(abstract|interface|enum)\])?(?:\s+\(([^)]+)\))?\s*$/;
|
|
31
|
+
/^(?:(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*$/;
|
|
29
32
|
|
|
30
33
|
// Relationship — arrow syntax:
|
|
31
|
-
// ClassName --|> TargetClass :
|
|
34
|
+
// ClassName --|> TargetClass label (new: space-separated)
|
|
35
|
+
// ClassName --|> TargetClass : label (old: colon-separated, kept for transition)
|
|
32
36
|
// Arrows: --|> ..|> *-- o-- ..> ->
|
|
33
37
|
const REL_ARROW_RE =
|
|
34
|
-
/^([A-Z][A-Za-z0-9_]*)\s
|
|
38
|
+
/^([A-Z][A-Za-z0-9_]*)\s*(--\|>|\.\.\|>|\*--|o--|\.\.\>|->)\s*([A-Z][A-Za-z0-9_]*)(?:\s+:?\s*(.+))?$/;
|
|
35
39
|
|
|
36
40
|
// Member line patterns
|
|
37
41
|
const VISIBILITY_RE = /^([+\-#])\s*/;
|
|
@@ -195,34 +199,30 @@ export function parseClassDiagram(
|
|
|
195
199
|
// Skip comments
|
|
196
200
|
if (trimmed.startsWith('//')) continue;
|
|
197
201
|
|
|
198
|
-
//
|
|
199
|
-
if (!contentStarted && indent === 0 &&
|
|
200
|
-
const
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (key === 'chart') {
|
|
206
|
-
if (value.toLowerCase() !== 'class') {
|
|
207
|
-
const allTypes = ['class', 'flowchart', 'sequence', 'er', 'org', 'bar', 'line', 'pie', 'scatter', 'sankey', 'venn', 'timeline', 'arc', 'slope'];
|
|
208
|
-
let msg = `Expected chart type "class", got "${value}"`;
|
|
209
|
-
const hint = suggest(value.toLowerCase(), allTypes);
|
|
210
|
-
if (hint) msg += `. ${hint}`;
|
|
211
|
-
return fail(lineNumber, msg);
|
|
202
|
+
// First line: bare chart type + optional title (new syntax)
|
|
203
|
+
if (!contentStarted && indent === 0 && i === 0) {
|
|
204
|
+
const firstLine = parseFirstLine(trimmed);
|
|
205
|
+
if (firstLine && firstLine.chartType === 'class') {
|
|
206
|
+
if (firstLine.title) {
|
|
207
|
+
result.title = firstLine.title;
|
|
208
|
+
result.titleLineNumber = lineNumber;
|
|
212
209
|
}
|
|
213
210
|
continue;
|
|
214
211
|
}
|
|
212
|
+
}
|
|
215
213
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
214
|
+
// Space-separated options before content (new syntax): `color off`
|
|
215
|
+
// Only match lines starting with a lowercase token (options), not uppercase (class names)
|
|
216
|
+
if (!contentStarted && indent === 0 && /^[a-z]/.test(trimmed)) {
|
|
217
|
+
const optMatch = trimmed.match(OPTION_NOCOLON_RE);
|
|
218
|
+
if (optMatch) {
|
|
219
|
+
const key = optMatch[1].toLowerCase();
|
|
220
|
+
const value = optMatch[2].trim();
|
|
221
|
+
// Don't swallow lines that look like class modifier keywords
|
|
222
|
+
if (key !== 'abstract' && key !== 'interface' && key !== 'enum') {
|
|
223
|
+
result.options[key] = value;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
226
|
}
|
|
227
227
|
}
|
|
228
228
|
|
|
@@ -268,11 +268,13 @@ export function parseClassDiagram(
|
|
|
268
268
|
// Try class declaration
|
|
269
269
|
const classDecl = trimmed.match(CLASS_DECL_RE);
|
|
270
270
|
if (classDecl) {
|
|
271
|
-
const
|
|
272
|
-
const
|
|
273
|
-
const
|
|
274
|
-
const
|
|
275
|
-
const
|
|
271
|
+
const prefixModifier = classDecl[1] as ClassModifier | undefined;
|
|
272
|
+
const name = classDecl[2];
|
|
273
|
+
const relKeyword = classDecl[3] as 'extends' | 'implements' | undefined;
|
|
274
|
+
const parentName = classDecl[4];
|
|
275
|
+
const bracketModifier = classDecl[5] as ClassModifier | undefined;
|
|
276
|
+
const modifier = prefixModifier ?? bracketModifier;
|
|
277
|
+
const colorName = classDecl[6]?.trim();
|
|
276
278
|
const color = colorName ? resolveColor(colorName, palette) : undefined;
|
|
277
279
|
|
|
278
280
|
const node = getOrCreateClass(name, lineNumber);
|
|
@@ -344,11 +346,18 @@ export function looksLikeClassDiagram(content: string): boolean {
|
|
|
344
346
|
|
|
345
347
|
// Skip metadata
|
|
346
348
|
if (/^(chart|title)\s*:/i.test(trimmed)) continue;
|
|
349
|
+
// Skip new-style first line (bare chart type)
|
|
350
|
+
if (/^class(\s|$)/i.test(trimmed)) continue;
|
|
347
351
|
|
|
348
352
|
const indent = measureIndent(line);
|
|
349
353
|
|
|
350
354
|
if (indent === 0) {
|
|
351
|
-
// Check for modifier
|
|
355
|
+
// Check for bare modifier keyword: `abstract ClassName`, `interface ClassName`, `enum ClassName`
|
|
356
|
+
if (/^(abstract|interface|enum)\s+[A-Z][A-Za-z0-9_]*/i.test(trimmed)) {
|
|
357
|
+
hasModifier = true;
|
|
358
|
+
hasClassDecl = true;
|
|
359
|
+
}
|
|
360
|
+
// Check for old modifier pattern: ClassName [abstract|interface|enum]
|
|
352
361
|
if (/^[A-Z][A-Za-z0-9_]*\s+\[(abstract|interface|enum)\]/i.test(trimmed)) {
|
|
353
362
|
hasModifier = true;
|
|
354
363
|
hasClassDecl = true;
|
|
@@ -399,11 +408,19 @@ export function extractSymbols(docText: string): DiagramSymbols {
|
|
|
399
408
|
let inMetadata = true;
|
|
400
409
|
for (const rawLine of docText.split('\n')) {
|
|
401
410
|
const line = rawLine.trim();
|
|
402
|
-
|
|
411
|
+
// Skip old-style colon metadata and new-style first line / space-separated options
|
|
412
|
+
if (inMetadata && (/^[a-z-]+\s*:/i.test(line) || /^class(\s|$)/i.test(line))) continue;
|
|
413
|
+
if (inMetadata && /^[a-z]/.test(line) && OPTION_NOCOLON_RE.test(line)) {
|
|
414
|
+
const key = line.match(OPTION_NOCOLON_RE)![1].toLowerCase();
|
|
415
|
+
if (key !== 'abstract' && key !== 'interface' && key !== 'enum') continue;
|
|
416
|
+
}
|
|
403
417
|
inMetadata = false;
|
|
404
418
|
if (line.length === 0 || /^\s/.test(rawLine)) continue;
|
|
405
419
|
const m = CLASS_DECL_RE.exec(line);
|
|
406
|
-
if (m
|
|
420
|
+
if (m) {
|
|
421
|
+
const name = m[2]!; // group 2 is the class name in the new regex
|
|
422
|
+
if (!entities.includes(name)) entities.push(name);
|
|
423
|
+
}
|
|
407
424
|
}
|
|
408
425
|
return {
|
|
409
426
|
kind: 'class',
|