@cardenelabs/dragon 0.34.0 → 0.35.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 (127) hide show
  1. package/README.md +39 -720
  2. package/dist/cli.cjs +4529 -0
  3. package/dist/cli.cjs.map +1 -0
  4. package/dist/cli.js +4523 -0
  5. package/dist/cli.js.map +1 -0
  6. package/dist/export.cjs +3912 -0
  7. package/dist/export.cjs.map +1 -0
  8. package/dist/export.d.cts +36 -0
  9. package/dist/export.d.ts +36 -0
  10. package/dist/export.js +3907 -0
  11. package/dist/export.js.map +1 -0
  12. package/dist/fonts.cjs +7 -0
  13. package/dist/fonts.cjs.map +1 -0
  14. package/dist/fonts.js +4 -0
  15. package/dist/fonts.js.map +1 -0
  16. package/dist/index.cjs +4105 -20356
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.d.cts +227 -5522
  19. package/dist/index.d.ts +227 -5522
  20. package/dist/index.js +4091 -20283
  21. package/dist/index.js.map +1 -1
  22. package/dist/player.global.js +2 -0
  23. package/dist/schema-BBWfniwH.d.cts +112 -0
  24. package/dist/schema-BBWfniwH.d.ts +112 -0
  25. package/package.json +39 -24
  26. package/src/cli.ts +237 -0
  27. package/src/export/capture.ts +277 -0
  28. package/src/export/fonts.ts +68 -0
  29. package/src/export/html.ts +32 -0
  30. package/src/export/index.ts +146 -0
  31. package/src/field-messages.ts +104 -0
  32. package/src/figure-text.ts +42 -0
  33. package/src/index.ts +30 -283
  34. package/src/layout/architecture.ts +381 -0
  35. package/src/layout/measure.ts +21 -0
  36. package/src/layout/scene.ts +353 -0
  37. package/src/layout/sequence.ts +189 -0
  38. package/src/load-fonts.ts +8 -0
  39. package/src/look/fonts.ts +33 -0
  40. package/src/look/palettes.json +209 -0
  41. package/src/look/values.ts +155 -0
  42. package/src/markdown.ts +35 -0
  43. package/src/messages.ts +122 -0
  44. package/src/parser.ts +342 -310
  45. package/src/player-global.ts +4 -0
  46. package/src/player.ts +271 -0
  47. package/src/printer.ts +76 -0
  48. package/src/render/icons.ts +41 -0
  49. package/src/render/motion.ts +106 -0
  50. package/src/render/parts.ts +279 -0
  51. package/src/render/svg.ts +341 -0
  52. package/src/schema.ts +161 -16
  53. package/src/schemas/diagram.schema.json +150 -0
  54. package/src/schemas/validate-diagram.d.ts +12 -0
  55. package/src/schemas/validate-diagram.js +8 -0
  56. package/src/timeline.ts +166 -0
  57. package/src/tokenize.ts +20 -281
  58. package/src/version.ts +55 -0
  59. package/src/view.ts +16 -0
  60. package/src/words.ts +79 -0
  61. package/examples/quick-start.md +0 -142
  62. package/src/canvas-bounds.ts +0 -52
  63. package/src/color.ts +0 -197
  64. package/src/compile/actor-ids.ts +0 -297
  65. package/src/compile/actor-option-notice.ts +0 -379
  66. package/src/compile/actors.ts +0 -103
  67. package/src/compile/c4.ts +0 -74
  68. package/src/compile/chain.ts +0 -93
  69. package/src/compile/chart-fields.ts +0 -193
  70. package/src/compile/deep-rewrite.ts +0 -30
  71. package/src/compile/derived-source.ts +0 -18
  72. package/src/compile/direction.ts +0 -102
  73. package/src/compile/edge-option-notice.ts +0 -112
  74. package/src/compile/er-relation.ts +0 -200
  75. package/src/compile/flow.ts +0 -54
  76. package/src/compile/flowchart.ts +0 -344
  77. package/src/compile/funnel.ts +0 -87
  78. package/src/compile/gantt.ts +0 -337
  79. package/src/compile/generic.ts +0 -696
  80. package/src/compile/hierarchy.ts +0 -92
  81. package/src/compile/journey.ts +0 -93
  82. package/src/compile/kinds.ts +0 -139
  83. package/src/compile/lanes.ts +0 -138
  84. package/src/compile/metro.ts +0 -271
  85. package/src/compile/mind.ts +0 -393
  86. package/src/compile/node-title.ts +0 -24
  87. package/src/compile/notice.ts +0 -141
  88. package/src/compile/order.ts +0 -57
  89. package/src/compile/parts-lookup.ts +0 -17
  90. package/src/compile/parts.ts +0 -2709
  91. package/src/compile/placement-apply.ts +0 -689
  92. package/src/compile/placement.ts +0 -15
  93. package/src/compile/quadrant.ts +0 -265
  94. package/src/compile/record.ts +0 -75
  95. package/src/compile/row-marks.ts +0 -128
  96. package/src/compile/rows.ts +0 -40
  97. package/src/compile/sequence.ts +0 -305
  98. package/src/compile/slug.ts +0 -37
  99. package/src/compile/stages.ts +0 -247
  100. package/src/compile/subtitle.ts +0 -35
  101. package/src/compile/swimlane-marks.ts +0 -87
  102. package/src/compile/swimlane.ts +0 -82
  103. package/src/compile/timeline.ts +0 -213
  104. package/src/compile/topology.ts +0 -44
  105. package/src/compile/tree.ts +0 -69
  106. package/src/compile/value-chart.ts +0 -337
  107. package/src/compile/values.ts +0 -432
  108. package/src/compile/word-state.ts +0 -82
  109. package/src/compile.ts +0 -3168
  110. package/src/focus.ts +0 -46
  111. package/src/input-size.ts +0 -181
  112. package/src/json-line-map.ts +0 -30
  113. package/src/json-parser.ts +0 -4306
  114. package/src/keywords.ts +0 -417
  115. package/src/near-name.ts +0 -42
  116. package/src/notation-lint.ts +0 -342
  117. package/src/relative-pos.ts +0 -251
  118. package/src/schemas/diagram.json +0 -3088
  119. package/src/types.ts +0 -1009
  120. package/src/v05/index.ts +0 -9
  121. package/src/v05/input-table.generated.ts +0 -138
  122. package/src/v05/parser-types.ts +0 -32
  123. package/src/v05/parser.ts +0 -6668
  124. package/src/v05/readout-table.generated.ts +0 -1088
  125. package/src/v05/removed-syntax.ts +0 -115
  126. package/src/value-syntax.ts +0 -313
  127. package/src/write-position.ts +0 -270
package/src/parser.ts CHANGED
@@ -1,354 +1,386 @@
1
1
  /**
2
- * Text DSL parser
3
- * 入力: docs/cdl/text-dsl-spec.md 準拠の箇条書きテキスト
4
- * 出力: DslDocument AST or DslError[]
2
+ * 人が書く記法を、図の原本の JSON へ変える。
3
+ *
4
+ * 記法は 1 行が 1 本の線になる行ごとの文で、最初の段の見出しより前に図の種類と題と箱を宣言し、
5
+ * 段ごとに見出し (`#`) と本文 (`>`) の下へ、その段で起きることを起きる順に 1 行ずつ並べる。
6
+ *
7
+ * 時系列図 <題>
8
+ * 見せ方 <止める か 寄る> 書かなければ字の大きさで選ぶ
9
+ * 区画 <名札> = <名前>「<添え書き>」 [色 <色>] [印 <印>]
10
+ * <名札> = <名前>「<下の小さな字>」 [色 <色>] [印 <印>] [区画 <名札>]
11
+ * 帯 <題> = <欄>, <欄>
12
+ * # <段の題>
13
+ * > <段の本文>
14
+ * `<段で打つ命令>`
15
+ * <名札> -> <名札> : <線の名前> [札 <値>] [名前 上] 線。運ぶ物を書かなければ封筒が走る
16
+ * <名札> : <処理の名前> 箱の中の処理
17
+ * <名札>「<字>」 箱の下の小さな字の書き換え
18
+ * 帯 <欄> = <値> [<札>] 中身の帯の値
19
+ *
20
+ * 語は英語でも書ける (#2997)。`sequence` / `architecture` で始めた file は、語を `words.ts` の表の
21
+ * 英語の語で書き、`「…」` の代わりに `"…"` でも囲める。1 つの file の言葉は最初の行の図の種類の語で
22
+ * 決まり、違う言葉の語を書いた行は誤りにする。誤りの文も file の言葉で出す。
23
+ *
24
+ * 行の頭の語と同じ綴りの名札 (`帯` や `strip`) も書ける。行の頭の語と名札は行の形で見分け、
25
+ * 名札の次に `->` か `:` か `=` か囲みが来る行は箱の行と読む。
26
+ *
27
+ * 誤りは最初の 1 つで止め、行と列を示す。
5
28
  */
29
+ import { type Messages, messagesFor } from "./messages";
30
+ import { type Box, type Carry, type Color, COLORS, type Diagram, type DiagramEvent, type LabelSide, type Region, type Step, type ViewMode } from "./schema";
31
+ import { columnOf, type SourceLine, tokenize } from "./tokenize";
32
+ import { FORMAT_VERSION } from "./version";
33
+ import { languageOf, type NotationLang, otherLang, valueOf, wordOf, type WORDS } from "./words";
6
34
 
7
- import type {
8
- DslDocument,
9
- DslActor,
10
- DslStep,
11
- DslAnimate,
12
- DslState,
13
- DslPhase,
14
- DslError,
15
- PresetType,
16
- } from "./types";
17
- import type { NodeKind, Tone } from "@cardenelabs/cdl";
18
- import {
19
- PRESET_NAMES,
20
- NODE_KIND_ALIAS,
21
- TONE_ALIAS,
22
- normalizeArrow,
23
- resolveHeader,
24
- resolveAnimSubkey,
25
- parseDuration,
26
- } from "./keywords";
35
+ export interface LineError {
36
+ line: number;
37
+ column: number;
38
+ message: string;
39
+ }
27
40
 
28
- type Line = { raw: string; trimmed: string; indent: number; lineNo: number };
41
+ export type Parsed = { ok: true; diagram: Diagram } | { ok: false; errors: LineError[] };
29
42
 
30
- function indentOf(s: string): number {
31
- let n = 0;
32
- for (const c of s) {
33
- if (c === " ") n += 1;
34
- else if (c === "\t") n += 2;
35
- else break;
36
- }
37
- return n;
43
+ export interface ParseOptions {
44
+ /** 最初の行が読めず file の言葉が決まらない時に、誤りの文に使う言葉。既定は日本語 */
45
+ lang?: NotationLang;
38
46
  }
39
47
 
40
- function splitLines(src: string): Line[] {
41
- return src.split("\n").map((raw, i) => {
42
- // # コメントを除去 (行末まで)
43
- const noComment = raw.replace(/\s*#.*$/, "");
44
- return {
45
- raw,
46
- trimmed: noComment.trim(),
47
- indent: indentOf(noComment),
48
- lineNo: i + 1,
49
- };
50
- });
48
+ class NotationError extends Error {
49
+ constructor(
50
+ readonly line: number,
51
+ readonly column: number,
52
+ message: string,
53
+ ) {
54
+ super(message);
55
+ }
51
56
  }
52
57
 
53
- function isBlank(l: Line): boolean {
54
- return l.trimmed === "";
55
- }
58
+ const fail = (source: SourceLine, index: number, message: string): NotationError =>
59
+ new NotationError(source.line, columnOf(source, index), message);
56
60
 
57
- /** "key: value" を split */
58
- function splitKv(s: string): [string, string] | null {
59
- const idx = s.indexOf(":");
60
- if (idx < 0) return null;
61
- return [s.slice(0, idx).trim(), s.slice(idx + 1).trim()];
61
+ /** `[鍵 値]` の指定。`raw` は括弧の中身そのまま、`at` は `[` の位置。 */
62
+ interface Attr {
63
+ raw: string;
64
+ key: string;
65
+ value: string;
66
+ at: number;
62
67
  }
63
68
 
64
- /** 行の "- item (kind)" or "- item" を parse */
65
- function parseListItem(s: string): { name: string; paren?: string } | null {
66
- const m = s.match(/^-\s*(.+?)(?:\s*\(\s*(.+?)\s*\))?\s*$/);
67
- if (!m) return null;
68
- return { name: m[1]!.trim(), paren: m[2]?.trim() };
69
+ /** 行の `start` から後ろの終わりに並ぶ `[...]` を外す。`end` は残った字の終わり。 */
70
+ function trailingAttrs(source: SourceLine, start: number): { end: number; attrs: Attr[] } {
71
+ const attrs: Attr[] = [];
72
+ let end = source.text.length;
73
+ for (;;) {
74
+ const found = /\s*\[([^[\]]*)\]$/.exec(source.text.slice(start, end));
75
+ if (!found) break;
76
+ const raw = (found[1] ?? "").trim();
77
+ const [key = "", ...words] = raw.split(/\s+/);
78
+ attrs.unshift({ raw, key, value: words.join(" "), at: start + found.index + found[0].indexOf("[") });
79
+ end = start + found.index;
80
+ }
81
+ return { end, attrs };
69
82
  }
70
83
 
71
- /**
72
- * Parser 本体
73
- */
74
- export type ParseResult = { ok: true; doc: DslDocument } | { ok: false; errors: DslError[] };
75
-
76
- export function parseTextDsl(src: string): ParseResult {
77
- const lines = splitLines(src);
78
- const errors: DslError[] = [];
79
-
80
- let title: string | undefined;
81
- let type: PresetType | undefined;
82
- const actors: DslActor[] = [];
83
- const steps: DslStep[] = [];
84
- let animate: DslAnimate | undefined;
84
+ const escape = (word: string): string => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
85
85
 
86
- let i = 0;
87
- while (i < lines.length) {
88
- const l = lines[i]!;
89
- if (isBlank(l)) { i += 1; continue; }
90
-
91
- // top-level header の検出 (`タイトル:` 等)
92
- const kv = splitKv(l.trimmed);
93
- if (kv && l.indent === 0) {
94
- const header = resolveHeader(kv[0]);
95
- if (header === "title") {
96
- title = kv[1];
97
- i += 1; continue;
98
- }
99
- if (header === "type") {
100
- if (!(PRESET_NAMES as readonly string[]).includes(kv[1].toLowerCase())) {
101
- errors.push({
102
- line: l.lineNo,
103
- message: `未知の種類 "${kv[1]}"`,
104
- hint: `次から選んでください: ${PRESET_NAMES.join(" / ")}`,
105
- });
106
- }
107
- type = kv[1].toLowerCase() as PresetType;
108
- i += 1; continue;
109
- }
110
- if (header === "actors") {
111
- // 次のブロック (indent > 0) を全部読む
112
- i += 1;
113
- while (i < lines.length && (lines[i]!.indent > 0 || isBlank(lines[i]!))) {
114
- if (!isBlank(lines[i]!)) {
115
- const a = parseActor(lines[i]!, errors);
116
- if (a) actors.push(a);
117
- }
118
- i += 1;
119
- }
120
- continue;
121
- }
122
- if (header === "flow") {
123
- i += 1;
124
- while (i < lines.length && (lines[i]!.indent > 0 || isBlank(lines[i]!))) {
125
- if (!isBlank(lines[i]!)) {
126
- const s = parseStep(lines[i]!, errors, actors);
127
- if (s) steps.push(s);
128
- }
129
- i += 1;
130
- }
131
- continue;
132
- }
133
- if (header === "animate") {
134
- const r = parseAnimate(lines, i + 1, errors);
135
- animate = r.anim;
136
- i = r.nextIndex;
137
- continue;
138
- }
139
- }
86
+ /** 字を囲む形。日本語の語の file は「」だけ、英語の語の file は「」と "" を読む */
87
+ const QUOTES: Record<NotationLang, readonly (readonly [string, string])[]> = {
88
+ ja: [["「", "」"]],
89
+ en: [
90
+ ["「", "」"],
91
+ ['"', '"'],
92
+ ],
93
+ };
140
94
 
141
- // 知らない top-level 行はスキップ (warning に降格、 ignore)
142
- i += 1;
95
+ /** `<名前>「<添え書き>」` を名前と添え書きに分ける。 */
96
+ function nameAndNote(text: string, lang: NotationLang): { name: string; note?: string } {
97
+ for (const [open, close] of QUOTES[lang]) {
98
+ const found = new RegExp(`^(.*?)\\s*${escape(open)}(.*)${escape(close)}$`).exec(text);
99
+ if (found) return { name: (found[1] ?? "").trim(), note: found[2] ?? "" };
143
100
  }
101
+ return { name: text.trim() };
102
+ }
144
103
 
145
- // 必須項目チェック
146
- if (!title) errors.push({ line: 1, message: "タイトル: が見つかりません", hint: "ファイル先頭に `タイトル: <名前>` を追加" });
147
- if (!type) errors.push({ line: 1, message: "種類: が見つかりません", hint: "ファイルに `種類: sequence` 等を追加" });
148
- if (actors.length === 0) errors.push({ line: 1, message: "登場人物: ブロックが空または見つかりません", hint: "`登場人物:` の下に `- 名前 (種類)` を追加" });
149
-
150
- if (errors.length > 0) return { ok: false, errors };
104
+ /** `<名札>「<字>」` の書き換えの行。名札と字の位置を返す */
105
+ function swapLine(text: string, lang: NotationLang): RegExpExecArray | null {
106
+ const opens = QUOTES[lang].map(([o]) => escape(o)).join("");
107
+ for (const [open, close] of QUOTES[lang]) {
108
+ const found = new RegExp(`^([^\\s${opens}]+)\\s*${escape(open)}(.*)${escape(close)}$`, "d").exec(text);
109
+ if (found) return found;
110
+ }
111
+ return null;
112
+ }
151
113
 
114
+ /**
115
+ * 1 つの言葉の、行の頭の語で始まる行の形。
116
+ * `reading` は読んでいる file の言葉で、帯の値の行から外す箱の行 (名札の次に囲みが来る行) の形を決める。
117
+ */
118
+ function headShapes(lang: NotationLang, reading: NotationLang) {
119
+ const head = (key: keyof typeof WORDS.head): string => escape(wordOf("head", key, lang));
120
+ const opens = QUOTES[reading].map(([open]) => `|${escape(open)}`).join("");
152
121
  return {
153
- ok: true,
154
- doc: {
155
- title: title!,
156
- type: type!,
157
- actors,
158
- flow: steps,
159
- animate,
160
- pos: { line: 1 },
161
- },
122
+ view: new RegExp(`^${head("view")}\\s+(\\S+)$`),
123
+ strip: new RegExp(`^${head("strip")}\\s+(.+?)\\s*=\\s*(.+)$`),
124
+ region: new RegExp(`^${head("region")}\\s+([^\\s=]+)\\s*=\\s*`, "d"),
125
+ // 空白を全部読んでから次の字を見る (`\s` を外すと、空白 2 つの `帯 -> 受け手` を帯の値と読む)
126
+ stripValue: new RegExp(`^${head("strip")}\\s+(?!\\s|->|:|=${opens})`),
162
127
  };
163
128
  }
164
129
 
165
- function parseActor(l: Line, errors: DslError[]): DslActor | null {
166
- const item = parseListItem(l.trimmed);
167
- if (!item) {
168
- errors.push({ line: l.lineNo, message: `登場人物の書式エラー: "${l.trimmed}"`, hint: "`- 名前 (種類)` 形式で書いてください" });
169
- return null;
170
- }
171
- let kind: NodeKind = "actor";
172
- let kindWritten = false;
173
- if (item.paren) {
174
- const resolved = NODE_KIND_ALIAS[item.paren] ?? (item.paren as NodeKind);
175
- kind = resolved;
176
- kindWritten = true;
177
- }
178
- return { name: item.name, kind, kindWritten, pos: { line: l.lineNo } };
130
+ interface Building {
131
+ source: SourceLine;
132
+ title: string;
133
+ body: string[];
134
+ code?: string;
135
+ events: DiagramEvent[];
179
136
  }
180
137
 
181
- function parseStep(l: Line, errors: DslError[], actors: DslActor[]): DslStep | null {
182
- // "1. ユーザー → API: ログイン情報 (成功)"
183
- const m = l.trimmed.match(/^(\d+)\.\s*(.+)$/);
184
- if (!m) {
185
- errors.push({ line: l.lineNo, message: `流れの書式エラー: "${l.trimmed}"`, hint: "`番号. <from> → <to>: <ラベル>` 形式" });
186
- return null;
187
- }
188
- const no = parseInt(m[1]!, 10);
189
- const rest = normalizeArrow(m[2]!);
190
- // "A → B: label (tone)"
191
- const m2 = rest.match(/^(.+?)\s*→\s*(.+?)\s*:\s*(.+)$/);
192
- if (!m2) {
193
- errors.push({ line: l.lineNo, message: `矢印または ラベル なし: "${l.trimmed}"`, hint: "`<from> → <to>: <ラベル>` 形式" });
194
- return null;
195
- }
196
- const from = m2[1]!.trim();
197
- const to = m2[2]!.trim();
198
- let labelPart = m2[3]!.trim();
199
-
200
- // tone in () at end
201
- let tone: Tone | undefined;
202
- const toneMatch = labelPart.match(/^(.+?)\s*\(\s*(.+?)\s*\)\s*$/);
203
- if (toneMatch) {
204
- const resolved = TONE_ALIAS[toneMatch[2]!];
205
- if (resolved) {
206
- tone = resolved;
207
- labelPart = toneMatch[1]!.trim();
208
- }
209
- }
138
+ function parseLines(lines: SourceLine[], fallback: NotationLang): Diagram {
139
+ const [head, ...rest] = lines;
140
+ if (!head) throw new NotationError(1, 1, messagesFor(fallback).noHead);
141
+ const found = languageOf(head.text);
142
+ const m: Messages = messagesFor(found ?? fallback);
143
+ const header = /^(\S+)\s+(.+)$/.exec(head.text);
144
+ const kind = found && valueOf("kind", header?.[1] ?? "", found);
145
+ if (!found || !header || !kind) throw fail(head, 0, m.badHead);
146
+ const lang: NotationLang = found;
147
+ const title = header[2] ?? "";
210
148
 
211
- // sub label syntax: "label (sub)" → label, sub の使い分けは tone と曖昧。
212
- // 仕様 ... 末尾 (...) は tone を優先解釈、 tone エイリアスに一致しなければ sub。
213
- let sub: string | undefined;
214
- if (!tone && toneMatch) {
215
- sub = toneMatch[2];
216
- labelPart = toneMatch[1]!.trim();
217
- }
149
+ const other = otherLang(lang);
150
+ const own = headShapes(lang, lang);
151
+ const foreign = headShapes(other, lang);
152
+ /** 違う言葉の語を書いた行の誤り。`group` の役割の、この file の言葉の語を示す */
153
+ const foreignWord = <G extends keyof typeof WORDS>(source: SourceLine, at: number, group: G, word: string): NotationError =>
154
+ fail(source, at, m.otherLanguageWord(word, wordOf(group, valueOf(group, word, other) as keyof (typeof WORDS)[G], lang)));
218
155
 
219
- // actor 名チェック
220
- const names = new Set(actors.map((a) => a.name));
221
- if (!names.has(from)) {
222
- errors.push({ line: l.lineNo, message: `"${from}" が登場人物にいません`, hint: `登場人物: に "- ${from}" を追加` });
223
- }
224
- if (!names.has(to)) {
225
- errors.push({ line: l.lineNo, message: `"${to}" が登場人物にいません`, hint: `登場人物: に "- ${to}" を追加` });
226
- }
156
+ const regions: Region[] = [];
157
+ const boxes: Box[] = [];
158
+ const ids = new Set<string>();
159
+ let strip: { title: string; fields: string[] } | undefined;
160
+ let view: ViewMode | undefined;
161
+ const steps: Step[] = [];
162
+ let building: Building | undefined;
227
163
 
228
- return { no, from, to, label: labelPart, sub, tone, pos: { line: l.lineNo } };
229
- }
164
+ const declare = (source: SourceLine, id: string, at: number): void => {
165
+ if (ids.has(id)) throw fail(source, at, m.duplicate(id));
166
+ ids.add(id);
167
+ };
168
+ const boxAt = (source: SourceLine, id: string, at: number): string => {
169
+ if (!boxes.some((box) => box.id === id)) throw fail(source, at, m.undeclaredBox(id));
170
+ return id;
171
+ };
172
+ const finish = (): void => {
173
+ if (!building) return;
174
+ if (building.body.length === 0) throw fail(building.source, 0, m.noBody(building.title));
175
+ steps.push({
176
+ title: building.title,
177
+ body: building.body.join("\n"),
178
+ ...(building.code !== undefined ? { code: building.code } : {}),
179
+ events: building.events,
180
+ });
181
+ };
230
182
 
231
- type ParseAnimResult = { anim: DslAnimate; nextIndex: number };
183
+ for (const source of rest) {
184
+ const text = source.text;
185
+ if (text.startsWith("#")) {
186
+ finish();
187
+ const stepTitle = text.slice(1).trim();
188
+ if (stepTitle === "") throw fail(source, 0, m.emptyStepTitle);
189
+ building = { source, title: stepTitle, body: [], events: [] };
190
+ continue;
191
+ }
192
+ if (!building) {
193
+ parseDeclaration(source);
194
+ continue;
195
+ }
196
+ building.events.push(...parseStepLine(source, building));
197
+ }
198
+ finish();
232
199
 
233
- function parseAnimate(lines: Line[], startIdx: number, errors: DslError[]): ParseAnimResult {
234
- const states: DslState[] = [];
235
- const phases: DslPhase[] = [];
236
- const anim: DslAnimate = { states, phases, pos: { line: startIdx + 1 } };
200
+ if (boxes.length === 0) throw fail(head, 0, m.noBoxes);
201
+ if (steps.length === 0) throw fail(head, 0, m.noSteps);
237
202
 
238
- let i = startIdx;
239
- while (i < lines.length) {
240
- const l = lines[i]!;
241
- if (isBlank(l)) { i += 1; continue; }
242
- // animate ブロックは indent > 0 が前提、 indent=0 なら top-level に戻る
243
- if (l.indent === 0) break;
203
+ return {
204
+ version: FORMAT_VERSION,
205
+ kind,
206
+ title,
207
+ ...(view ? { view } : {}),
208
+ ...(regions.length > 0 ? { regions } : {}),
209
+ boxes,
210
+ ...(strip ? { strip } : {}),
211
+ steps,
212
+ ...(kind === "sequence" ? { sequence: { lifelines: boxes.map((box) => box.id) } } : {}),
213
+ };
244
214
 
245
- const kv = splitKv(l.trimmed);
246
- if (kv) {
247
- // kv[0] は "ステップ「送信」 1.5 秒" のような形 もあり得るので prefix 一致で resolve
248
- // 「ステップ」 「step」 で始まる → "step"、 それ以外は exact match
249
- let sub: ReturnType<typeof resolveAnimSubkey> = null;
250
- const head = kv[0].trim();
251
- if (/^(ステップ|step|STEP)/i.test(head)) sub = "step";
252
- else sub = resolveAnimSubkey(head);
253
- if (sub === "state") {
254
- // "状態: 残高 = 100"
255
- const m = kv[1].match(/^(.+?)\s*=\s*(.+)$/);
256
- if (!m) {
257
- errors.push({ line: l.lineNo, message: `状態 書式エラー: "${kv[1]}"`, hint: "`状態: <名前> = <初期値>`" });
258
- } else {
259
- const name = m[1]!.trim();
260
- const raw = m[2]!.trim();
261
- const num = parseFloat(raw);
262
- const initial = isNaN(num) ? raw.replace(/^["']|["']$/g, "") : num;
263
- states.push({ name, initial, pos: { line: l.lineNo } });
264
- }
265
- i += 1; continue;
215
+ function parseDeclaration(source: SourceLine): void {
216
+ const text = source.text;
217
+ const viewDecl = own.view.exec(text);
218
+ if (viewDecl) {
219
+ if (view) throw fail(source, 0, m.viewTwice);
220
+ const word = viewDecl[1] ?? "";
221
+ const at = text.indexOf(word);
222
+ const chosen = valueOf("view", word, lang);
223
+ if (!chosen) {
224
+ if (valueOf("view", word, other) !== undefined) throw foreignWord(source, at, "view", word);
225
+ throw fail(source, at, m.badView);
266
226
  }
267
- if (sub === "step") {
268
- // "ステップ「送信」 1.5 秒" or "step \"submit\" 1.5s"
269
- // kv[0] には keyword + 「name」 + duration、 kv[1] は空 (`ステップ「送信」 1.5 秒:` の場合)
270
- // ただし resolveAnimSubkey は kv[0] の最初の単語 (ステップ) だけ見るので、
271
- // 残りは kv[0] 全体から keyword を除去して name + duration を抽出する。
272
- const afterKeyword = kv[0]
273
- .replace(/^(ステップ|step|STEP)\s*/i, "")
274
- .trim();
275
- const nameMatch = afterKeyword.match(/^[「"](.+?)[」"]\s*(.+)$/);
276
- if (!nameMatch) {
277
- errors.push({ line: l.lineNo, message: `ステップ 書式エラー: "${kv[0]}"`, hint: `\`ステップ「<名前>」 <時間>:\` 形式` });
278
- i += 1; continue;
279
- }
280
- const phaseName = nameMatch[1]!;
281
- const durStr = nameMatch[2]!.trim();
282
- const durMs = parseDuration(durStr);
283
- if (durMs === null) {
284
- errors.push({ line: l.lineNo, message: `時間 解釈不能: "${durStr}"`, hint: "`1.5 秒` / `1500ms` / `2s` 等" });
285
- i += 1; continue;
286
- }
287
- const phase: DslPhase = { name: phaseName, durationMs: durMs, pos: { line: l.lineNo } };
288
- // sub block を読む
289
- i += 1;
290
- const phaseBaseIndent = l.indent;
291
- while (i < lines.length && (lines[i]!.indent > phaseBaseIndent || isBlank(lines[i]!))) {
292
- if (!isBlank(lines[i]!)) {
293
- applyPhaseSubLine(lines[i]!, phase, errors);
294
- }
295
- i += 1;
296
- }
297
- phases.push(phase);
298
- continue;
227
+ view = chosen;
228
+ return;
229
+ }
230
+ const stripDecl = own.strip.exec(text);
231
+ if (stripDecl) {
232
+ if (strip) throw fail(source, 0, m.stripTwice);
233
+ const fields = (stripDecl[2] ?? "").split(",").map((field) => field.trim());
234
+ if (fields.some((field) => field === "")) throw fail(source, 0, m.emptyStripField);
235
+ strip = { title: stripDecl[1] ?? "", fields };
236
+ return;
237
+ }
238
+ const regionDecl = own.region.exec(text);
239
+ const boxDecl = regionDecl ? undefined : /^([^\s=]+)\s*=(?!>)\s*/d.exec(text);
240
+ const decl = regionDecl ?? boxDecl;
241
+ if (!decl?.indices?.[1]) {
242
+ if (foreign.view.test(text) || foreign.strip.test(text) || foreign.region.test(text)) {
243
+ throw foreignWord(source, 0, "head", text.split(/\s+/)[0] ?? "");
299
244
  }
245
+ if (looksLikeStepLine(text, lang)) throw fail(source, 0, m.stepLineBeforeStep);
246
+ throw fail(source, 0, m.unreadableDeclaration);
300
247
  }
301
- i += 1;
302
- }
303
-
304
- return { anim, nextIndex: i };
305
- }
306
-
307
- function applyPhaseSubLine(l: Line, phase: DslPhase, errors: DslError[]): void {
308
- const kv = splitKv(l.trimmed);
309
- if (!kv) return;
310
- const sub = resolveAnimSubkey(kv[0]);
311
- if (sub === "highlight") {
312
- phase.highlight = kv[1].split(",").map((s) => s.trim()).filter(Boolean);
313
- return;
314
- }
315
- if (sub === "tween") {
316
- // "残高: 100 → 90"
317
- const rest = normalizeArrow(kv[1]);
318
- const m = rest.match(/^(.+?)\s*:\s*([\d.]+)\s*→\s*([\d.]+)$/);
319
- if (!m) {
320
- errors.push({ line: l.lineNo, message: `遷移 書式エラー: "${kv[1]}"`, hint: "`遷移: <state>: <from> → <to>`" });
248
+ const id = decl[1] ?? "";
249
+ declare(source, id, decl.indices[1][0]);
250
+ const { end, attrs } = trailingAttrs(source, decl[0].length);
251
+ const { name, note } = nameAndNote(text.slice(decl[0].length, end), lang);
252
+ if (name === "") throw fail(source, decl[0].length, m.emptyName(id));
253
+ const look: { color?: Color; icon?: string; region?: string } = {};
254
+ for (const attr of attrs) {
255
+ const key = valueOf("attr", attr.key, lang);
256
+ if (key === "color") {
257
+ if (!COLORS.includes(attr.value as Color)) throw fail(source, attr.at, m.badColor);
258
+ look.color = attr.value as Color;
259
+ } else if (key === "icon" && attr.value !== "") {
260
+ look.icon = attr.value;
261
+ } else if (key === "region" && !regionDecl) {
262
+ if (!regions.some((region) => region.id === attr.value)) throw fail(source, attr.at, m.regionFirst(attr.value));
263
+ look.region = attr.value;
264
+ } else {
265
+ if (valueOf("attr", attr.key, other) !== undefined) throw foreignWord(source, attr.at, "attr", attr.key);
266
+ throw fail(source, attr.at, m.badAttr(attr.raw));
267
+ }
268
+ }
269
+ if (regionDecl) {
270
+ regions.push({
271
+ id,
272
+ name,
273
+ ...(note !== undefined ? { note } : {}),
274
+ ...(look.color ? { color: look.color } : {}),
275
+ ...(look.icon ? { icon: look.icon } : {}),
276
+ });
321
277
  return;
322
278
  }
323
- phase.tweens = phase.tweens ?? [];
324
- phase.tweens.push({
325
- state: m[1]!.trim(),
326
- from: parseFloat(m[2]!),
327
- to: parseFloat(m[3]!),
328
- pos: { line: l.lineNo },
279
+ boxes.push({
280
+ id,
281
+ name,
282
+ ...(note !== undefined ? { sub: note } : {}),
283
+ ...(look.color ? { color: look.color } : {}),
284
+ ...(look.icon ? { icon: look.icon } : {}),
285
+ ...(look.region ? { region: look.region } : {}),
329
286
  });
330
- return;
331
287
  }
332
- if (sub === "set") {
333
- // "ステータス: 完了"
334
- const m = kv[1].match(/^(.+?)\s*:\s*(.+)$/);
335
- if (!m) {
336
- errors.push({ line: l.lineNo, message: `切替 書式エラー: "${kv[1]}"`, hint: "`切替: <state>: <値>`" });
337
- return;
288
+
289
+ function parseStepLine(source: SourceLine, step: Building): DiagramEvent[] {
290
+ const text = source.text;
291
+ if (text.startsWith(">")) {
292
+ step.body.push(text.slice(1).trim());
293
+ return [];
294
+ }
295
+ const code = /^`(.*)`$/.exec(text);
296
+ if (code) {
297
+ if (step.code !== undefined) throw fail(source, 0, m.codeTwice);
298
+ step.code = code[1] ?? "";
299
+ return [];
300
+ }
301
+ if (own.stripValue.test(text)) return [parseStripValue(source)];
302
+ if (foreign.stripValue.test(text)) throw foreignWord(source, 0, "head", text.split(/\s+/)[0] ?? "");
303
+ if (text.includes("->")) return [parseEdge(source)];
304
+ if (/^[^\s=]+\s*=(?!>)/.test(text)) throw fail(source, 0, m.declarationInStep);
305
+ const swap = swapLine(text, lang);
306
+ if (swap?.indices?.[1]) {
307
+ const box = boxAt(source, swap[1] ?? "", swap.indices[1][0]);
308
+ if ((swap[2] ?? "") === "") throw fail(source, swap.indices[1][1], m.emptySwap);
309
+ return [{ type: "swap", box, text: swap[2] ?? "" }];
310
+ }
311
+ const action = /^([^\s:]+)\s*:\s*(.+)$/d.exec(text);
312
+ if (action?.indices?.[1]) return [{ type: "action", box: boxAt(source, action[1] ?? "", action.indices[1][0]), label: action[2] ?? "" }];
313
+ throw fail(source, 0, m.unreadableStepLine);
314
+ }
315
+
316
+ function parseEdge(source: SourceLine): DiagramEvent {
317
+ const { end, attrs } = trailingAttrs(source, 0);
318
+ const edge = /^([^\s:]+)\s*->\s*([^\s:]+)(?:\s*:\s*(.+))?$/d.exec(source.text.slice(0, end));
319
+ if (!edge?.indices?.[1] || !edge.indices[2]) throw fail(source, 0, m.edgeShape);
320
+ const from = boxAt(source, edge[1] ?? "", edge.indices[1][0]);
321
+ const to = boxAt(source, edge[2] ?? "", edge.indices[2][0]);
322
+ let carry: Carry | undefined;
323
+ let labelSide: LabelSide | undefined;
324
+ for (const attr of attrs) {
325
+ const kind = valueOf("carry", attr.key, lang);
326
+ const label = valueOf("attr", attr.key, lang) === "label";
327
+ if (kind !== undefined) {
328
+ if (carry) throw fail(source, attr.at, m.carryTwice);
329
+ if (kind === "tag") {
330
+ if (attr.value === "") throw fail(source, attr.at, m.tagNeedsValue);
331
+ carry = { kind, text: attr.value };
332
+ } else {
333
+ if (attr.value !== "") throw fail(source, attr.at, m.carryTakesNoValue(attr.key));
334
+ carry = { kind };
335
+ }
336
+ } else if (label && valueOf("side", attr.value, lang) !== undefined) {
337
+ labelSide = valueOf("side", attr.value, lang);
338
+ } else {
339
+ if (label && valueOf("side", attr.value, other) !== undefined) throw foreignWord(source, attr.at, "side", attr.value);
340
+ if (valueOf("carry", attr.key, other) !== undefined) throw foreignWord(source, attr.at, "carry", attr.key);
341
+ if (valueOf("attr", attr.key, other) !== undefined) throw foreignWord(source, attr.at, "attr", attr.key);
342
+ throw fail(source, attr.at, m.badAttr(attr.raw));
343
+ }
338
344
  }
339
- const raw = m[2]!.trim();
340
- const num = parseFloat(raw);
341
- const value = isNaN(num) ? raw : num;
342
- phase.sets = phase.sets ?? [];
343
- phase.sets.push({ state: m[1]!.trim(), value, pos: { line: l.lineNo } });
344
- return;
345
+ return {
346
+ type: "edge",
347
+ from,
348
+ to,
349
+ ...(edge[3] !== undefined ? { label: edge[3] } : {}),
350
+ carry: carry ?? { kind: "envelope" },
351
+ ...(labelSide ? { labelSide } : {}),
352
+ };
345
353
  }
346
- if (sub === "body") {
347
- phase.body = kv[1];
348
- return;
354
+
355
+ function parseStripValue(source: SourceLine): DiagramEvent {
356
+ if (!strip) throw fail(source, 0, m.stripUndeclared);
357
+ const start = new RegExp(`^${escape(wordOf("head", "strip", lang))}\\s+`).exec(source.text)?.[0].length ?? 0;
358
+ const { end, attrs } = trailingAttrs(source, start);
359
+ if (attrs.length > 1) throw fail(source, attrs[1]?.at ?? 0, m.stripTagOnce);
360
+ const rest = source.text.slice(start, end);
361
+ const assigned = /^(.+?)\s*=\s*(.+)$/.exec(rest);
362
+ const field = (assigned ? assigned[1] : rest)?.trim() ?? "";
363
+ if (!strip.fields.includes(field)) throw fail(source, start, m.noSuchField(field));
364
+ const tag = attrs[0]?.raw;
365
+ if (!assigned && tag === undefined) throw fail(source, start, m.stripNeedsValue);
366
+ return {
367
+ type: "strip",
368
+ field,
369
+ ...(assigned ? { value: assigned[2] ?? "" } : {}),
370
+ ...(tag !== undefined ? { tag } : {}),
371
+ };
349
372
  }
350
- if (sub === "badge") {
351
- phase.badge = kv[1];
352
- return;
373
+ }
374
+
375
+ function looksLikeStepLine(text: string, lang: NotationLang): boolean {
376
+ return text.startsWith(">") || text.startsWith("`") || text.includes("->") || swapLine(text, lang) !== null;
377
+ }
378
+
379
+ export function parseNotation(notation: string, options: ParseOptions = {}): Parsed {
380
+ try {
381
+ return { ok: true, diagram: parseLines(tokenize(notation), options.lang ?? "ja") };
382
+ } catch (error) {
383
+ if (!(error instanceof NotationError)) throw error;
384
+ return { ok: false, errors: [{ line: error.line, column: error.column, message: error.message }] };
353
385
  }
354
386
  }