@diagrammo/dgmo 0.39.0 → 0.41.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/dist/advanced.cjs +372 -162
- package/dist/advanced.js +372 -162
- package/dist/auto.cjs +373 -163
- package/dist/auto.js +92 -92
- package/dist/auto.mjs +373 -163
- package/dist/cli.cjs +115 -115
- package/dist/index.cjs +550 -217
- package/dist/index.js +550 -217
- package/docs/language-reference.md +17 -0
- package/gallery/fixtures/block.dgmo +16 -0
- package/gallery/fixtures/event-line.dgmo +26 -0
- package/package.json +1 -1
- package/src/diagnostics.ts +0 -0
- package/src/echarts.ts +5 -0
- package/src/error-card.ts +200 -0
- package/src/event-line/parser.ts +155 -55
- package/src/event-line/renderer.ts +350 -139
- package/src/index.ts +7 -30
- package/src/journey-map/renderer.ts +50 -18
|
@@ -2399,6 +2399,23 @@ tag Genre as g
|
|
|
2399
2399
|
- **Tag** — trailing same-line metadata (`g: Pop`), like timeline; colors the dot, leader, and card.
|
|
2400
2400
|
- **Description** — bare indented body lines (like `pyramid`/`ring`); `- ` makes a bullet; inline markdown (`**bold**`, `*italic*`, `` `code` ``) supported.
|
|
2401
2401
|
|
|
2402
|
+
#### Eras
|
|
2403
|
+
|
|
2404
|
+
Group a run of events into a labeled section with a `[Name]` bracket, then **indent the events beneath it** (the org / version-control nesting idiom). An event belongs to the era it is indented under; its description sits one level deeper still. An event at indent 0 sits **outside** any era.
|
|
2405
|
+
|
|
2406
|
+
```
|
|
2407
|
+
[The Early Web]
|
|
2408
|
+
1991 WorldWideWeb t: Protocol
|
|
2409
|
+
Tim Berners-Lee publishes the first site.
|
|
2410
|
+
1993 Mosaic t: Browser
|
|
2411
|
+
|
|
2412
|
+
[The App Era] collapsed: true
|
|
2413
|
+
2005 Ajax t: Platform
|
|
2414
|
+
```
|
|
2415
|
+
|
|
2416
|
+
- **Trailing metadata** after `]` — optional `collapsed: true` and/or a named color (`[The 1960s] collapsed: true blue`).
|
|
2417
|
+
- **`collapsed: true`** folds the era into a single summary card (its name + a bulleted list of member events) while a bracket stays on the spine; in the app a reader toggles it live.
|
|
2418
|
+
|
|
2402
2419
|
#### Directives
|
|
2403
2420
|
|
|
2404
2421
|
- `no-scale` — space events evenly instead of by date (dates become captions).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
block Web Service Architecture
|
|
2
|
+
|
|
3
|
+
tag Layer as l
|
|
4
|
+
Edge blue
|
|
5
|
+
Service green
|
|
6
|
+
Data orange
|
|
7
|
+
|
|
8
|
+
[Clients] l: Edge
|
|
9
|
+
[Browser] [Mobile] [CLI]
|
|
10
|
+
|
|
11
|
+
[Backend] l: Service
|
|
12
|
+
[Auth] [Orders]
|
|
13
|
+
[Inventory] [Billing]
|
|
14
|
+
|
|
15
|
+
[Data] l: Data collapsed
|
|
16
|
+
[Postgres] [Redis]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
event-line A Short History of the Web
|
|
2
|
+
no-scale
|
|
3
|
+
|
|
4
|
+
tag Theme as t
|
|
5
|
+
Protocol blue
|
|
6
|
+
Browser green
|
|
7
|
+
Platform purple
|
|
8
|
+
Standard orange
|
|
9
|
+
|
|
10
|
+
[The Early Web]
|
|
11
|
+
1991 WorldWideWeb t: Protocol
|
|
12
|
+
Tim Berners-Lee publishes the first website at **CERN**.
|
|
13
|
+
1993 Mosaic t: Browser
|
|
14
|
+
The first popular *graphical* browser.
|
|
15
|
+
|
|
16
|
+
[The Standards Era] collapsed: true
|
|
17
|
+
1995 JavaScript t: Platform
|
|
18
|
+
Brendan Eich writes the language in ten days.
|
|
19
|
+
1996 CSS t: Standard
|
|
20
|
+
Styling splits from structure.
|
|
21
|
+
|
|
22
|
+
[The App Era]
|
|
23
|
+
2005 Ajax t: Platform
|
|
24
|
+
`XMLHttpRequest` updates pages without a reload.
|
|
25
|
+
2014 HTML5 t: Standard
|
|
26
|
+
The living standard — video, canvas, semantics.
|
package/package.json
CHANGED
package/src/diagnostics.ts
CHANGED
|
Binary file
|
package/src/echarts.ts
CHANGED
|
@@ -283,6 +283,9 @@ const CHART_BASE: Pick<
|
|
|
283
283
|
tooltip: { show: false },
|
|
284
284
|
};
|
|
285
285
|
const CHART_BORDER_WIDTH = 2;
|
|
286
|
+
// Scatter bubbles render semi-transparent so overlapping points stay visible.
|
|
287
|
+
// Emphasis bumps back to full opacity on hover (see emphasisConfig).
|
|
288
|
+
const SCATTER_FILL_OPACITY = 0.65;
|
|
286
289
|
|
|
287
290
|
// ============================================================
|
|
288
291
|
// Parser
|
|
@@ -1930,6 +1933,7 @@ function buildScatterOption(
|
|
|
1930
1933
|
}),
|
|
1931
1934
|
borderColor: catColor,
|
|
1932
1935
|
borderWidth: CHART_BORDER_WIDTH,
|
|
1936
|
+
opacity: SCATTER_FILL_OPACITY,
|
|
1933
1937
|
},
|
|
1934
1938
|
label: labelConfig,
|
|
1935
1939
|
emphasis: emphasisConfig,
|
|
@@ -1961,6 +1965,7 @@ function buildScatterOption(
|
|
|
1961
1965
|
{
|
|
1962
1966
|
type: 'scatter' as const,
|
|
1963
1967
|
data,
|
|
1968
|
+
itemStyle: { opacity: SCATTER_FILL_OPACITY },
|
|
1964
1969
|
label: labelConfig,
|
|
1965
1970
|
emphasis: emphasisConfig,
|
|
1966
1971
|
blur: blurConfig,
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// Error Card — fallback image rendered when a diagram can't parse
|
|
3
|
+
// ============================================================
|
|
4
|
+
//
|
|
5
|
+
// When `render(text, { onError: 'svg' })` (the default) hits parse errors,
|
|
6
|
+
// hosts that embed dgmo — remark/astro/docusaurus/fumadocs, Obsidian, the
|
|
7
|
+
// web editor — show whatever SVG we return in place of the diagram. A blank
|
|
8
|
+
// or cryptic box is a dead end for the author, so we render a friendly card:
|
|
9
|
+
// a frown mark, the dgmo wordmark, and per-error the line number, the
|
|
10
|
+
// message, and the actual offending source line with a caret at the column.
|
|
11
|
+
//
|
|
12
|
+
// Pure string builder — no DOM, so it works in every consumer (SSR, browser,
|
|
13
|
+
// resvg→PNG in the CLI). resvg has no color-mix(); all colors are pre-resolved.
|
|
14
|
+
|
|
15
|
+
import { truncateText, measureText } from './utils/text-measure';
|
|
16
|
+
import { FONT_FAMILY } from './fonts';
|
|
17
|
+
import type { PaletteConfig } from './palettes/types';
|
|
18
|
+
import type { Theme } from './themes';
|
|
19
|
+
import type { DgmoError } from './diagnostics';
|
|
20
|
+
|
|
21
|
+
const MONO_FAMILY =
|
|
22
|
+
"ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, monospace";
|
|
23
|
+
|
|
24
|
+
const W = 660;
|
|
25
|
+
const PAD = 24;
|
|
26
|
+
const CONTENT_W = W - PAD * 2;
|
|
27
|
+
const MAX_ERRORS = 4;
|
|
28
|
+
// Monospace advance ≈ 0.6em — used to place the caret under the bad column.
|
|
29
|
+
const MONO_CH = 0.6;
|
|
30
|
+
const CODE_FS = 12.5;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Render a self-contained SVG error card for a failed diagram parse.
|
|
34
|
+
*
|
|
35
|
+
* @param errors Error-severity diagnostics (line-numbered).
|
|
36
|
+
* @param source Original DGMO source, used to quote the offending lines.
|
|
37
|
+
*/
|
|
38
|
+
export function renderErrorCard(
|
|
39
|
+
errors: DgmoError[],
|
|
40
|
+
source: string,
|
|
41
|
+
palette: PaletteConfig,
|
|
42
|
+
theme?: Theme
|
|
43
|
+
): string {
|
|
44
|
+
const c = palette[theme === 'dark' ? 'dark' : 'light'];
|
|
45
|
+
const isTransparent = theme === 'transparent';
|
|
46
|
+
const card = c.surface;
|
|
47
|
+
const codeBg = c.bg;
|
|
48
|
+
const border = c.border;
|
|
49
|
+
const danger = c.destructive ?? c.colors.red;
|
|
50
|
+
const text = c.text;
|
|
51
|
+
const muted = c.textMuted;
|
|
52
|
+
const srcLines = source.split('\n');
|
|
53
|
+
|
|
54
|
+
const shown = errors.slice(0, MAX_ERRORS);
|
|
55
|
+
const overflow = errors.length - shown.length;
|
|
56
|
+
|
|
57
|
+
const parts: string[] = [];
|
|
58
|
+
let y = PAD + 8;
|
|
59
|
+
|
|
60
|
+
// ── Header: frown mark + title, dgmo wordmark right ────────
|
|
61
|
+
parts.push(frownMark(PAD + 14, y + 12, danger));
|
|
62
|
+
parts.push(
|
|
63
|
+
`<text x="${PAD + 38}" y="${y + 11}" fill="${text}" font-family="${FONT_FAMILY}" font-size="16" font-weight="700">Couldn't render this diagram</text>`,
|
|
64
|
+
`<text x="${W - PAD}" y="${y + 10}" text-anchor="end" fill="${muted}" font-family="${FONT_FAMILY}" font-size="12" font-weight="600" letter-spacing="0.5">dgmo</text>`
|
|
65
|
+
);
|
|
66
|
+
y += 24;
|
|
67
|
+
const errLabel = errors.length === 1 ? '1 error' : `${errors.length} errors`;
|
|
68
|
+
parts.push(
|
|
69
|
+
`<text x="${PAD + 38}" y="${y + 4}" fill="${muted}" font-family="${FONT_FAMILY}" font-size="12.5">${errLabel} — fix the lines below and re-render</text>`
|
|
70
|
+
);
|
|
71
|
+
y += 22;
|
|
72
|
+
parts.push(
|
|
73
|
+
`<line x1="${PAD}" y1="${y}" x2="${W - PAD}" y2="${y}" stroke="${border}" stroke-width="1"/>`
|
|
74
|
+
);
|
|
75
|
+
y += 24;
|
|
76
|
+
|
|
77
|
+
// ── Per-error blocks ───────────────────────────────────────
|
|
78
|
+
for (const e of shown) {
|
|
79
|
+
const hasLine = e.line > 0;
|
|
80
|
+
|
|
81
|
+
// Line badge (skip when line is unknown / 0), with the message inline.
|
|
82
|
+
let msgX = PAD;
|
|
83
|
+
if (hasLine) {
|
|
84
|
+
const label = e.column ? `Line ${e.line}:${e.column}` : `Line ${e.line}`;
|
|
85
|
+
const badgeW = 14 + label.length * 7;
|
|
86
|
+
parts.push(
|
|
87
|
+
`<rect x="${PAD}" y="${y - 13}" width="${badgeW}" height="19" rx="4" fill="${danger}"/>`,
|
|
88
|
+
`<text x="${PAD + badgeW / 2}" y="${y + 1}" text-anchor="middle" fill="${c.bg}" font-family="${FONT_FAMILY}" font-size="11.5" font-weight="700">${escapeXml(label)}</text>`
|
|
89
|
+
);
|
|
90
|
+
msgX = PAD + badgeW + 12;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// Message: first line sits to the right of the badge, continuation lines
|
|
94
|
+
// wrap to the full content width on the rows below.
|
|
95
|
+
const lines = wrapMessage(e.message, CONTENT_W - (msgX - PAD), CONTENT_W);
|
|
96
|
+
lines.forEach((line, i) => {
|
|
97
|
+
parts.push(
|
|
98
|
+
`<text x="${i === 0 ? msgX : PAD}" y="${y + 1}" fill="${text}" font-family="${FONT_FAMILY}" font-size="13">${escapeXml(line)}</text>`
|
|
99
|
+
);
|
|
100
|
+
if (i < lines.length - 1) y += 18;
|
|
101
|
+
});
|
|
102
|
+
y += 24;
|
|
103
|
+
|
|
104
|
+
// Offending source line, monospace, with a caret under the column.
|
|
105
|
+
const raw = hasLine ? srcLines[e.line - 1] : undefined;
|
|
106
|
+
if (raw !== undefined && raw.trim().length > 0) {
|
|
107
|
+
const gutter = `${e.line}`;
|
|
108
|
+
const gutterW = gutter.length * CODE_FS * MONO_CH + 16;
|
|
109
|
+
const codeX = PAD + gutterW;
|
|
110
|
+
const codeMaxW = CONTENT_W - gutterW - 12;
|
|
111
|
+
const codeText = truncateText(
|
|
112
|
+
raw.replace(/\t/g, ' '),
|
|
113
|
+
CODE_FS,
|
|
114
|
+
codeMaxW
|
|
115
|
+
);
|
|
116
|
+
const hasCaret = e.column !== undefined && e.column > 0;
|
|
117
|
+
const blockH = hasCaret ? 40 : 26;
|
|
118
|
+
parts.push(
|
|
119
|
+
`<rect x="${PAD}" y="${y - 14}" width="${CONTENT_W}" height="${blockH}" rx="5" fill="${codeBg}" stroke="${border}" stroke-width="1"/>`,
|
|
120
|
+
`<line x1="${codeX - 8}" y1="${y - 14}" x2="${codeX - 8}" y2="${y - 14 + blockH}" stroke="${border}" stroke-width="1"/>`,
|
|
121
|
+
`<text x="${PAD + 8}" y="${y + 3}" fill="${muted}" font-family="${MONO_FAMILY}" font-size="${CODE_FS}">${escapeXml(gutter)}</text>`,
|
|
122
|
+
`<text x="${codeX}" y="${y + 3}" fill="${text}" font-family="${MONO_FAMILY}" font-size="${CODE_FS}" xml:space="preserve">${escapeXml(codeText)}</text>`
|
|
123
|
+
);
|
|
124
|
+
if (hasCaret) {
|
|
125
|
+
const caretX = codeX + (e.column! - 1) * CODE_FS * MONO_CH;
|
|
126
|
+
parts.push(
|
|
127
|
+
`<text x="${caretX}" y="${y + 17}" fill="${danger}" font-family="${MONO_FAMILY}" font-size="${CODE_FS}" font-weight="700">^</text>`
|
|
128
|
+
);
|
|
129
|
+
y += 14;
|
|
130
|
+
}
|
|
131
|
+
y += 24;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
y += 14;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if (overflow > 0) {
|
|
138
|
+
parts.push(
|
|
139
|
+
`<text x="${PAD}" y="${y}" fill="${muted}" font-family="${FONT_FAMILY}" font-size="12.5" font-style="italic">+ ${overflow} more error${overflow === 1 ? '' : 's'}</text>`
|
|
140
|
+
);
|
|
141
|
+
y += 22;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const height = Math.round(y + PAD - 14);
|
|
145
|
+
const pageBg = isTransparent
|
|
146
|
+
? ''
|
|
147
|
+
: `<rect width="100%" height="100%" fill="${c.bg}"/>`;
|
|
148
|
+
|
|
149
|
+
return [
|
|
150
|
+
`<svg xmlns="http://www.w3.org/2000/svg" width="${W}" height="${height}" viewBox="0 0 ${W} ${height}" font-family="${FONT_FAMILY}">`,
|
|
151
|
+
pageBg,
|
|
152
|
+
`<rect x="0.5" y="0.5" width="${W - 1}" height="${height - 1}" rx="10" fill="${card}" stroke="${border}" stroke-width="1"/>`,
|
|
153
|
+
parts.join(''),
|
|
154
|
+
`</svg>`,
|
|
155
|
+
].join('');
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Greedy word-wrap where the first line is narrower (it shares its row with
|
|
159
|
+
// the line badge) and continuation lines use the full content width.
|
|
160
|
+
function wrapMessage(
|
|
161
|
+
message: string,
|
|
162
|
+
firstWidth: number,
|
|
163
|
+
fullWidth: number
|
|
164
|
+
): string[] {
|
|
165
|
+
const words = message.split(/\s+/).filter(Boolean);
|
|
166
|
+
if (words.length === 0) return [''];
|
|
167
|
+
const lines: string[] = [];
|
|
168
|
+
let cur = '';
|
|
169
|
+
for (const word of words) {
|
|
170
|
+
const limit = lines.length === 0 ? firstWidth : fullWidth;
|
|
171
|
+
const test = cur ? `${cur} ${word}` : word;
|
|
172
|
+
if (measureText(test, 13) <= limit || !cur) {
|
|
173
|
+
cur = test;
|
|
174
|
+
} else {
|
|
175
|
+
lines.push(cur);
|
|
176
|
+
cur = word;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (cur) lines.push(cur);
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Frown face: ringed circle, two eyes, a downturned mouth.
|
|
184
|
+
function frownMark(cx: number, cy: number, color: string): string {
|
|
185
|
+
const r = 12;
|
|
186
|
+
return [
|
|
187
|
+
`<circle cx="${cx}" cy="${cy}" r="${r}" fill="none" stroke="${color}" stroke-width="2"/>`,
|
|
188
|
+
`<circle cx="${cx - 4.5}" cy="${cy - 3}" r="1.6" fill="${color}"/>`,
|
|
189
|
+
`<circle cx="${cx + 4.5}" cy="${cy - 3}" r="1.6" fill="${color}"/>`,
|
|
190
|
+
`<path d="M ${cx - 5} ${cy + 6} Q ${cx} ${cy + 1} ${cx + 5} ${cy + 6}" fill="none" stroke="${color}" stroke-width="2" stroke-linecap="round"/>`,
|
|
191
|
+
].join('');
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function escapeXml(s: string): string {
|
|
195
|
+
return s
|
|
196
|
+
.replace(/&/g, '&')
|
|
197
|
+
.replace(/</g, '<')
|
|
198
|
+
.replace(/>/g, '>')
|
|
199
|
+
.replace(/"/g, '"');
|
|
200
|
+
}
|
package/src/event-line/parser.ts
CHANGED
|
@@ -55,6 +55,7 @@ export const EVENT_LINE_DIAGNOSTIC_CODES = {
|
|
|
55
55
|
NO_EVENTS: 'E_EVENT_LINE_NO_EVENTS',
|
|
56
56
|
BAD_DATE: 'E_EVENT_LINE_BAD_DATE',
|
|
57
57
|
UNSUPPORTED: 'E_EVENT_LINE_UNSUPPORTED',
|
|
58
|
+
ERA_DATE_ORDER: 'E_EVENT_LINE_ERA_DATE_ORDER',
|
|
58
59
|
} as const;
|
|
59
60
|
|
|
60
61
|
/** A non-ISO date attempt: leading digits with a slash or dot separator. */
|
|
@@ -113,6 +114,11 @@ export function parseEventLine(
|
|
|
113
114
|
let currentTagGroup: Writable<TagGroup> | null = null;
|
|
114
115
|
let currentEvent: Writable<EventLineEvent> | null = null;
|
|
115
116
|
let currentEra: string | null = null;
|
|
117
|
+
// Indent of the open era's `[Name]` bracket; events must be indented deeper
|
|
118
|
+
// to belong to it. -1 ⇒ no era open (an indent-0 event is then era-less).
|
|
119
|
+
let eraIndent = -1;
|
|
120
|
+
// Indent of the current event; a line indented deeper is its description.
|
|
121
|
+
let currentEventIndent = 0;
|
|
116
122
|
const aliasMap = new Map<string, string>();
|
|
117
123
|
|
|
118
124
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -197,15 +203,17 @@ export function parseEventLine(
|
|
|
197
203
|
continue;
|
|
198
204
|
}
|
|
199
205
|
|
|
200
|
-
// ──
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
// ── Content phase: indentation classifies each line. ──
|
|
207
|
+
//
|
|
208
|
+
// Eras are top-level `[Name]` section headers whose member events are
|
|
209
|
+
// INDENTED beneath them (the org §7 / version-control §29 idiom); an event
|
|
210
|
+
// at indent 0 sits OUTSIDE any era. A line indented deeper than its event
|
|
211
|
+
// is that event's description body (§28.4).
|
|
212
|
+
|
|
213
|
+
// Description body — any line indented deeper than its event. Checked
|
|
214
|
+
// before the era bracket so a bracketed prose line (` [aside]`) under an
|
|
215
|
+
// event stays a description rather than opening a spurious era.
|
|
216
|
+
if (currentEvent && indent > currentEventIndent) {
|
|
209
217
|
const descLine = trimmed.startsWith('- ')
|
|
210
218
|
? `• ${trimmed.substring(2)}`
|
|
211
219
|
: trimmed;
|
|
@@ -213,47 +221,14 @@ export function parseEventLine(
|
|
|
213
221
|
continue;
|
|
214
222
|
}
|
|
215
223
|
|
|
216
|
-
// ──
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
options.scale = false;
|
|
221
|
-
continue;
|
|
222
|
-
}
|
|
223
|
-
const sideMatch = trimmed.match(SIDE_RE);
|
|
224
|
-
if (sideMatch) {
|
|
225
|
-
options.side = sideMatch[1]!.toLowerCase() as EventLineOptions['side'];
|
|
226
|
-
continue;
|
|
227
|
-
}
|
|
228
|
-
if (trimmed.toLowerCase() === 'no-box') {
|
|
229
|
-
options.noBox = true;
|
|
230
|
-
continue;
|
|
231
|
-
}
|
|
232
|
-
if (trimmed.toLowerCase() === 'no-legend') {
|
|
233
|
-
options.noLegend = true;
|
|
234
|
-
continue;
|
|
235
|
-
}
|
|
236
|
-
const dirMatch = trimmed.match(DIRECTION_RE);
|
|
237
|
-
if (dirMatch) {
|
|
238
|
-
const dir = dirMatch[1]!.toUpperCase();
|
|
239
|
-
if (dir !== 'LR') {
|
|
240
|
-
pushWarning(
|
|
241
|
-
lineNumber,
|
|
242
|
-
`event-line is horizontal-only in v1; \`direction ${dir}\` (vertical orientation) is a fast-follow.`,
|
|
243
|
-
EVENT_LINE_DIAGNOSTIC_CODES.UNSUPPORTED
|
|
244
|
-
);
|
|
245
|
-
}
|
|
246
|
-
continue;
|
|
247
|
-
}
|
|
248
|
-
if (tryParseSharedOption(trimmed, sharedOptions)) continue;
|
|
249
|
-
|
|
250
|
-
// ── Era run delimiter: `[Name]` opens a section that runs to the next
|
|
251
|
-
// `[Name]` or EOF (§28.6a). NOT an indentation container — events stay at
|
|
252
|
-
// indent 0, so flat bare-body descriptions are preserved. Optional trailing
|
|
253
|
-
// `collapsed: true` and/or a color name tint/fold the era.
|
|
224
|
+
// ── Era run delimiter: `[Name]` (§28.6a) — a section header whose member
|
|
225
|
+
// events are indented beneath it. Dedenting to (or past) the bracket's
|
|
226
|
+
// own indent leaves the era. Optional trailing `collapsed: true` and/or
|
|
227
|
+
// a color name tint/fold the era.
|
|
254
228
|
const eraMatch = trimmed.match(ERA_RE);
|
|
255
229
|
if (eraMatch) {
|
|
256
230
|
contentStarted = true;
|
|
231
|
+
currentTagGroup = null;
|
|
257
232
|
currentEvent = null;
|
|
258
233
|
const name = eraMatch[1]!.trim();
|
|
259
234
|
let rest = (eraMatch[2] ?? '').trim();
|
|
@@ -261,7 +236,9 @@ export function parseEventLine(
|
|
|
261
236
|
const cm = rest.match(ERA_COLLAPSED_RE);
|
|
262
237
|
if (cm) {
|
|
263
238
|
collapsed = cm[1]!.toLowerCase() === 'true';
|
|
264
|
-
rest = (
|
|
239
|
+
rest = (
|
|
240
|
+
rest.slice(0, cm.index) + rest.slice(cm.index! + cm[0].length)
|
|
241
|
+
).trim();
|
|
265
242
|
}
|
|
266
243
|
let color: string | null = null;
|
|
267
244
|
if (rest) {
|
|
@@ -280,19 +257,59 @@ export function parseEventLine(
|
|
|
280
257
|
const era: EventLineEra = { name, color, collapsed, lineNumber };
|
|
281
258
|
result.eras.push(era);
|
|
282
259
|
currentEra = name;
|
|
260
|
+
eraIndent = indent;
|
|
283
261
|
continue;
|
|
284
262
|
}
|
|
285
263
|
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
264
|
+
// ── Top-level (indent 0) directives and reserved seams. ──
|
|
265
|
+
if (indent === 0) {
|
|
266
|
+
currentTagGroup = null;
|
|
267
|
+
if (trimmed.toLowerCase() === 'no-scale') {
|
|
268
|
+
options.scale = false;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const sideMatch = trimmed.match(SIDE_RE);
|
|
272
|
+
if (sideMatch) {
|
|
273
|
+
options.side = sideMatch[1]!.toLowerCase() as EventLineOptions['side'];
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
if (trimmed.toLowerCase() === 'no-box') {
|
|
277
|
+
options.noBox = true;
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
if (trimmed.toLowerCase() === 'no-legend') {
|
|
281
|
+
options.noLegend = true;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const dirMatch = trimmed.match(DIRECTION_RE);
|
|
285
|
+
if (dirMatch) {
|
|
286
|
+
const dir = dirMatch[1]!.toUpperCase();
|
|
287
|
+
if (dir !== 'LR') {
|
|
288
|
+
pushWarning(
|
|
289
|
+
lineNumber,
|
|
290
|
+
`event-line is horizontal-only in v1; \`direction ${dir}\` (vertical orientation) is a fast-follow.`,
|
|
291
|
+
EVENT_LINE_DIAGNOSTIC_CODES.UNSUPPORTED
|
|
292
|
+
);
|
|
293
|
+
}
|
|
294
|
+
continue;
|
|
295
|
+
}
|
|
296
|
+
if (tryParseSharedOption(trimmed, sharedOptions)) continue;
|
|
297
|
+
if (SECTION_SEAM_RE.test(trimmed)) {
|
|
298
|
+
pushWarning(
|
|
299
|
+
lineNumber,
|
|
300
|
+
'Group events with `[Name]` era brackets (§28.6a), not `section`.',
|
|
301
|
+
EVENT_LINE_DIAGNOSTIC_CODES.UNSUPPORTED
|
|
302
|
+
);
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
293
305
|
}
|
|
294
306
|
|
|
295
307
|
// ── Event line ──
|
|
308
|
+
// An event belongs to the open era only when indented beneath that era's
|
|
309
|
+
// bracket; an indent-0 event sits outside any era and closes the run
|
|
310
|
+
// (events outside eras are fully supported — they simply have `era: null`).
|
|
311
|
+
const inEra = currentEra !== null && indent > eraIndent;
|
|
312
|
+
if (!inEra) currentEra = null;
|
|
296
313
|
contentStarted = true;
|
|
297
314
|
const event = parseEventHeader(
|
|
298
315
|
trimmed,
|
|
@@ -304,6 +321,7 @@ export function parseEventLine(
|
|
|
304
321
|
);
|
|
305
322
|
result.events.push(event);
|
|
306
323
|
currentEvent = event;
|
|
324
|
+
currentEventIndent = indent;
|
|
307
325
|
}
|
|
308
326
|
|
|
309
327
|
options.noTitle = sharedOptions['no-title'] === 'on';
|
|
@@ -323,9 +341,91 @@ export function parseEventLine(
|
|
|
323
341
|
);
|
|
324
342
|
}
|
|
325
343
|
|
|
344
|
+
// Eras render as date-spanning brackets, so an event dated outside its era's
|
|
345
|
+
// chronological position makes adjacent era brackets overlap (§28.6a). Only
|
|
346
|
+
// meaningful when the date scale drives x-position.
|
|
347
|
+
if (options.scale) {
|
|
348
|
+
validateEraDateOrder(result.events, result.eras, pushWarning);
|
|
349
|
+
}
|
|
350
|
+
|
|
326
351
|
return result;
|
|
327
352
|
}
|
|
328
353
|
|
|
354
|
+
/**
|
|
355
|
+
* Warn when a dated event sits in an era whose chronological run it breaks —
|
|
356
|
+
* i.e. it is dated after a later era begins, or before an earlier era ends.
|
|
357
|
+
* Eras are left-to-right date bands; a straggler makes their brackets overlap.
|
|
358
|
+
*/
|
|
359
|
+
function validateEraDateOrder(
|
|
360
|
+
events: readonly EventLineEvent[],
|
|
361
|
+
eras: readonly EventLineEra[],
|
|
362
|
+
pushWarning: (line: number, message: string, code?: string) => void
|
|
363
|
+
): void {
|
|
364
|
+
if (eras.length < 2) return;
|
|
365
|
+
const order = new Map(eras.map((e, i) => [e.name, i]));
|
|
366
|
+
const dated = events.filter(
|
|
367
|
+
(e) => e.dateValue !== null && e.era !== null && order.has(e.era)
|
|
368
|
+
);
|
|
369
|
+
if (dated.length === 0) return;
|
|
370
|
+
|
|
371
|
+
// Earliest and latest dated event per era index.
|
|
372
|
+
const perEra = new Map<
|
|
373
|
+
number,
|
|
374
|
+
{ min: EventLineEvent; max: EventLineEvent }
|
|
375
|
+
>();
|
|
376
|
+
for (const ev of dated) {
|
|
377
|
+
const idx = order.get(ev.era!)!;
|
|
378
|
+
const cur = perEra.get(idx);
|
|
379
|
+
if (!cur) {
|
|
380
|
+
perEra.set(idx, { min: ev, max: ev });
|
|
381
|
+
} else {
|
|
382
|
+
if (ev.dateValue! < cur.min.dateValue!) cur.min = ev;
|
|
383
|
+
if (ev.dateValue! > cur.max.dateValue!) cur.max = ev;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const n = eras.length;
|
|
388
|
+
// prefixMax[k] = the latest-dated event across eras with index < k.
|
|
389
|
+
// suffixMin[k] = the earliest-dated event across eras with index > k.
|
|
390
|
+
const prefixMax: (EventLineEvent | null)[] = new Array(n).fill(null);
|
|
391
|
+
const suffixMin: (EventLineEvent | null)[] = new Array(n).fill(null);
|
|
392
|
+
for (let k = 1; k < n; k++) {
|
|
393
|
+
const prev = perEra.get(k - 1)?.max ?? null;
|
|
394
|
+
const carried = prefixMax[k - 1] ?? null;
|
|
395
|
+
prefixMax[k] =
|
|
396
|
+
prev && (!carried || prev.dateValue! > carried.dateValue!)
|
|
397
|
+
? prev
|
|
398
|
+
: carried;
|
|
399
|
+
}
|
|
400
|
+
for (let k = n - 2; k >= 0; k--) {
|
|
401
|
+
const next = perEra.get(k + 1)?.min ?? null;
|
|
402
|
+
const carried = suffixMin[k + 1] ?? null;
|
|
403
|
+
suffixMin[k] =
|
|
404
|
+
next && (!carried || next.dateValue! < carried.dateValue!)
|
|
405
|
+
? next
|
|
406
|
+
: carried;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
for (const ev of dated) {
|
|
410
|
+
const k = order.get(ev.era!)!;
|
|
411
|
+
const ahead = suffixMin[k];
|
|
412
|
+
const behind = prefixMax[k];
|
|
413
|
+
if (ahead && ev.dateValue! > ahead.dateValue!) {
|
|
414
|
+
pushWarning(
|
|
415
|
+
ev.lineNumber,
|
|
416
|
+
`"${ev.label}" (${ev.date}) is in era "${ev.era}" but dated after era "${ahead.era}" begins (${ahead.date}). event-line eras run left-to-right by date — fix the date or move it to the right era, or their brackets will overlap.`,
|
|
417
|
+
EVENT_LINE_DIAGNOSTIC_CODES.ERA_DATE_ORDER
|
|
418
|
+
);
|
|
419
|
+
} else if (behind && ev.dateValue! < behind.dateValue!) {
|
|
420
|
+
pushWarning(
|
|
421
|
+
ev.lineNumber,
|
|
422
|
+
`"${ev.label}" (${ev.date}) is in era "${ev.era}" but dated before era "${behind.era}" ends (${behind.date}). event-line eras run left-to-right by date — fix the date or move it to the right era, or their brackets will overlap.`,
|
|
423
|
+
EVENT_LINE_DIAGNOSTIC_CODES.ERA_DATE_ORDER
|
|
424
|
+
);
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
329
429
|
function parseEventHeader(
|
|
330
430
|
trimmed: string,
|
|
331
431
|
lineNumber: number,
|