@cardenelabs/dragon 0.7.0 → 0.9.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/src/v05/parser.ts CHANGED
@@ -43,15 +43,24 @@
43
43
 
44
44
  import type { NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
45
45
  import { TONES, NODE_KINDS } from "@cardenelabs/cdl";
46
- import { TONE_ALIAS } from "../keywords";
46
+ import { TONE_ALIAS, NODE_KIND_ALIAS } from "../keywords";
47
47
  import { parseRelativePos, orderByDependency } from "../relative-pos";
48
+ import {
49
+ checkValueExpression,
50
+ isTriggerBody,
51
+ isValueName,
52
+ parseValueTriggerBody,
53
+ valueNameIssue,
54
+ } from "../value-syntax";
48
55
  import type {
56
+ DslAxes,
49
57
  DslDocument,
50
58
  DslActor,
51
59
  DslActorNodeOverride,
52
60
  DslStep,
53
61
  DslAnimate,
54
62
  DslState,
63
+ DslValue,
55
64
  DslPhase,
56
65
  DslTween,
57
66
  DslSet,
@@ -62,9 +71,54 @@ import type {
62
71
  DslViewport,
63
72
  } from "../types";
64
73
 
65
- export type V05ParseResult =
66
- | { ok: true; doc: DslDocument }
67
- | { ok: false; errors: DslError[] };
74
+ export type V05ParseResult = { ok: true; doc: DslDocument } | { ok: false; errors: DslError[] };
75
+
76
+ /**
77
+ * 記法が受ける top-level の項目 (#1190)。
78
+ *
79
+ * 読めない行の案内と、記法一覧が全て載せているかの検査が、どちらもここを見る。 一覧に手で
80
+ * 書くと、項目を足した時に案内か一覧のどちらかが取り残される (実際に `states` / `values` が
81
+ * 一覧に 1 件も無い状態で放置されていた)。
82
+ */
83
+ export const TOP_LEVEL_KEYS = [
84
+ "title",
85
+ "type",
86
+ "actors",
87
+ "flow",
88
+ "states",
89
+ "values",
90
+ "animation",
91
+ "viewport",
92
+ "lanes",
93
+ "groups",
94
+ // 図全体を 1 箱にする図種で、 その箱の上に出す小見出し (#1247)
95
+ "eyebrow",
96
+ // 2 軸で仕分ける図の軸の名前 (#1251)
97
+ "axes",
98
+ ] as const;
99
+
100
+ /**
101
+ * `lanes:` / `groups:` の 1 行を読む形 (#1241)。
102
+ *
103
+ * **id は英数字と下線に限らない**。 組み立て側は登場人物の名前から縦列 id を作るため、
104
+ * hyphen と日本語が入る (実測 = `type: state` で `lane-idle` / `lane-待機`、
105
+ * `type: swimlane` で `lane-sign-up`)。 英数字と下線だけを受けていた間、
106
+ * **自動で作られた縦列の幅や見出しを書き直す手段が無かった**。
107
+ *
108
+ * 受けるのは **組み立て側が作りうる字だけ** に絞る。 字と数と下線と hyphen。
109
+ *
110
+ * 「読み取りを壊す字以外は何でも」 にすると、`lane-idle,` のような書き間違いが
111
+ * **別の縦列として通り**、書いた幅が黙って効かなくなる (Round 1 の指摘、実測)。
112
+ *
113
+ * **非 ASCII をまとめて許すのも広すぎる** (Round 2 の指摘)。 全角の読点や感嘆符、絵文字まで
114
+ * 通ってしまう (実測 = `lane-idle、` / `lane-idle!` / `lane-idle🙂` が受かった)。
115
+ * 字 (`\p{L}`) と数 (`\p{N}`) だけを許せば、日本語の縦列 id は通しつつ句読点は外せる。
116
+ */
117
+ const LANE_ID_ENTRY = /^([\p{L}\p{N}_-]+)\s*:\s*\{([^}]*)\}\s*$/u;
118
+
119
+ function isTopLevelKey(key: string): key is (typeof TOP_LEVEL_KEYS)[number] {
120
+ return (TOP_LEVEL_KEYS as readonly string[]).includes(key);
121
+ }
68
122
 
69
123
  /** 受け付ける図種。 記法一覧はここを見る。 */
70
124
  export const PRESET_TYPES: ReadonlySet<PresetType> = new Set([
@@ -78,6 +132,12 @@ export const PRESET_TYPES: ReadonlySet<PresetType> = new Set([
78
132
  "gantt",
79
133
  "class",
80
134
  "pie",
135
+ "bar",
136
+ "line",
137
+ "funnel",
138
+ "tree",
139
+ "journey",
140
+ "quadrant",
81
141
  "c4",
82
142
  "mind",
83
143
  ]);
@@ -92,8 +152,14 @@ const NODE_KIND_DEFAULT: NodeKind = "actor";
92
152
  * 種類に置き換わるため、 そのまま描画側に渡ることはない。
93
153
  */
94
154
  const DSL_ONLY_KINDS = [
95
- "entity", "state",
96
- "contract", "eoa", "multisig", "proxy", "library", "interface",
155
+ "entity",
156
+ "state",
157
+ "contract",
158
+ "eoa",
159
+ "multisig",
160
+ "proxy",
161
+ "library",
162
+ "interface",
97
163
  ] as const;
98
164
 
99
165
  /**
@@ -107,17 +173,17 @@ const DSL_ONLY_KINDS = [
107
173
  * 保管する役)。 見た目が同じになるが、 役割が同じなので嘘にはならない。
108
174
  */
109
175
  const INFRA_KIND_ALIAS: Record<string, NodeKind> = {
110
- alb: "shape-api-gateway", // 入口で振り分ける
111
- browser: "frontend", // 画面側
112
- ecs: "microservice", // コンテナ群
113
- iam: "admin", // 権限を守る
114
- kms: "admin", // 鍵を守る
115
- lambda: "function", // 呼ぶと動く
116
- rds: "database", // 表を持つ
117
- s3: "storage", // 置き場
118
- secret: "storage", // 機密の置き場
119
- user: "person", // 人
120
- container: "service", // 動かす単位 (C4 の container)
176
+ alb: "shape-api-gateway", // 入口で振り分ける
177
+ browser: "frontend", // 画面側
178
+ ecs: "microservice", // コンテナ群
179
+ iam: "admin", // 権限を守る
180
+ kms: "admin", // 鍵を守る
181
+ lambda: "function", // 呼ぶと動く
182
+ rds: "database", // 表を持つ
183
+ s3: "storage", // 置き場
184
+ secret: "storage", // 機密の置き場
185
+ user: "person", // 人
186
+ container: "service", // 動かす単位 (C4 の container)
121
187
  };
122
188
 
123
189
  /**
@@ -142,7 +208,52 @@ export const NODE_KIND_VALID: ReadonlySet<string> = new Set<string>([
142
208
  // 受理する色名は cdl 側の一覧をそのまま使う。 手書きすると cdl に色が増えた時に取り残される。
143
209
  const TONE_VALID: ReadonlySet<string> = new Set<string>(TONES);
144
210
 
145
- const STYLE_VALID: ReadonlySet<string> = new Set<string>(["solid", "dotted-flow"]);
211
+ /**
212
+ * 受理する線種。 `EdgeStyle` は型だけで実体を持たないため、 実行時の一覧はここが唯一の出どころ。
213
+ *
214
+ * JSON 経路も同じ集合を読む (#1304)。 別に持つと、 線種が増えた時に片方だけ取り残される。
215
+ */
216
+ export const STYLE_VALID: ReadonlySet<string> = new Set<string>(["solid", "dotted-flow"]);
217
+
218
+ /**
219
+ * 色の名前として書ける語の一覧 (#1304)。 知らせの `hint` に出す。
220
+ *
221
+ * 正規の色名 (`TONES`) と別名 (`TONE_ALIAS` の鍵) を合わせる。 手で並べると色が増えた時に
222
+ * 取り残されるため、 どちらも実装の集合から導く。
223
+ */
224
+ export function 書ける色名(): string[] {
225
+ return [...new Set<string>([...TONES, ...Object.keys(TONE_ALIAS)])];
226
+ }
227
+
228
+ /**
229
+ * 色名として読めない値を知らせる (#1304)。
230
+ *
231
+ * 線種を受ける場所 (矢印) と受けない場所 (箱) で hint を変える。 箱に `solid` と書いても
232
+ * 効かないため、 使える語として案内しない。
233
+ *
234
+ * 矢印の丸括弧には、 説明文の一部が入り込むことがある
235
+ * (`- A -> B: 呼び出し (非同期)` の `非同期`)。 これまでは黙って捨てられ、 **説明文から
236
+ * 括弧の中だけが消えた図** が出ていた。 直し方が「別の語に変える」 とは限らないため、
237
+ * 引用符で囲む道も併せて案内する。
238
+ */
239
+ function report読めない色(
240
+ 値: string,
241
+ line: number,
242
+ errors: DslError[],
243
+ opts: { 線種も受ける: boolean },
244
+ ): void {
245
+ const 語 = stripQuotes(値.trim());
246
+ const 使える = opts.線種も受ける ? [...書ける色名(), ...STYLE_VALID] : 書ける色名();
247
+ errors.push({
248
+ line,
249
+ message: opts.線種も受ける
250
+ ? `色名か線種が読めません: "${語}"`
251
+ : `色の名前が読めません: "${語}"`,
252
+ hint: opts.線種も受ける
253
+ ? `使える値 = ${使える.join(", ")}。 説明文に括弧を含めるなら \`"…"\` で囲む`
254
+ : `使える値 = ${使える.join(", ")}`,
255
+ });
256
+ }
146
257
 
147
258
  type Line = {
148
259
  raw: string;
@@ -157,9 +268,14 @@ export function parseTextDslV05(src: string): V05ParseResult {
157
268
 
158
269
  let title: string | null = null;
159
270
  let type: PresetType | null = null;
271
+ let eyebrow: string | null = null;
272
+ let eyebrowLine = 0;
273
+ let axes: DslAxes | undefined = undefined;
274
+ let axesLine = 0;
160
275
  let actors: DslActor[] = [];
161
276
  const flow: DslStep[] = [];
162
277
  let animate: DslAnimate | undefined = undefined;
278
+ const values: DslValue[] = [];
163
279
  let viewport: DslViewport | undefined = undefined;
164
280
  let lanesMap: Record<string, DslLane> | undefined = undefined;
165
281
  let groupsMap: Record<string, DslGroup> | undefined = undefined;
@@ -172,11 +288,11 @@ export function parseTextDslV05(src: string): V05ParseResult {
172
288
  continue;
173
289
  }
174
290
  const head = matchTopHeader(line.trimmed);
175
- if (!head) {
291
+ if (!head || !isTopLevelKey(head.key)) {
176
292
  errors.push({
177
293
  line: line.no,
178
294
  message: `unknown top-level key: "${line.trimmed}"`,
179
- hint: "expected one of: title, type, actors, flow, states, animation, viewport, lanes, groups",
295
+ hint: `expected one of: ${TOP_LEVEL_KEYS.join(", ")}`,
180
296
  });
181
297
  i += 1;
182
298
  continue;
@@ -189,6 +305,15 @@ export function parseTextDslV05(src: string): V05ParseResult {
189
305
  i += 1;
190
306
  continue;
191
307
  }
308
+ if (head.key === "eyebrow") {
309
+ // 空で書いた形 (`eyebrow:`) は「書かなかった」 と同じにする。 空文字を残すと
310
+ // 描画側が中身のない帯を出す
311
+ const v = (head.value ?? "").trim();
312
+ eyebrow = v.length > 0 ? v : null;
313
+ eyebrowLine = line.no;
314
+ i += 1;
315
+ continue;
316
+ }
192
317
  if (head.key === "type") {
193
318
  const v = (head.value ?? "").trim().toLowerCase();
194
319
  if (!PRESET_TYPES.has(v as PresetType)) {
@@ -213,7 +338,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
213
338
  errors.push({
214
339
  line: entry[0]!.no,
215
340
  message: `invalid actor entry: "${entry[0]!.trimmed}"`,
216
- hint: 'use `- Client` or `- Client: storage`',
341
+ hint: "use `- Client` or `- Client: storage`",
217
342
  });
218
343
  continue;
219
344
  }
@@ -227,7 +352,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
227
352
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
228
353
  let stepNo = 1;
229
354
  for (const it of items) {
230
- const step = parseFlowStep(it, stepNo);
355
+ const step = parseFlowStep(it, stepNo, errors);
231
356
  if (step) {
232
357
  flow.push(step);
233
358
  stepNo += 1;
@@ -260,7 +385,42 @@ export function parseTextDslV05(src: string): V05ParseResult {
260
385
  for (const it of items) {
261
386
  const st = parseStateEntry(it.trimmed.replace(/^-\s*/, ""), it.no);
262
387
  if (st) animate.states.push(st);
263
- else errors.push({ line: it.no, message: `invalid state entry: "${it.trimmed}"`, hint: "use `name: initial`" });
388
+ else
389
+ errors.push({
390
+ line: it.no,
391
+ message: `invalid state entry: "${it.trimmed}"`,
392
+ hint: "use `name: initial`",
393
+ });
394
+ }
395
+ i = next;
396
+ continue;
397
+ }
398
+ if (head.key === "values") {
399
+ // 1 行に詰める形 (`values: { a: "..." }` / `values: a: "..."`) は受けない。 式に `,` が
400
+ // 入る (`min({a}, {b})`) ため、 `states` が使う素朴な `,` 分割では式が壊れる。
401
+ //
402
+ // **`{` で始まるかを見てはいけない** (#1169)。 `values: a: "{b} + 1"` は `{` で始まらない
403
+ // ため判定を通り抜け、 その後 `collectIndentedRaw` が次行以降しか見ないので **値が
404
+ // 1 件も読まれずに黙って消える**。 書き間違いを黙って捨てないという `collectIndentedRaw`
405
+ // を自前で持った理由と矛盾する。
406
+ //
407
+ // 同じ行に何か書いてあれば形を問わず弾く = 1 行形は全て受けないという規則そのもの。
408
+ const inline = head.value?.trim();
409
+ if (inline) {
410
+ errors.push({
411
+ line: line.no,
412
+ message: "values は 1 行にまとめて書けない",
413
+ hint: '式に `,` が入るため。 次の行から字下げして `waiting: "{inflow} - {done}"` の形で並べる',
414
+ });
415
+ i += 1;
416
+ continue;
417
+ }
418
+ // `collectIndentedList` は `:` を含まない行を黙って捨てる。 捨てられると
419
+ // 書き間違えた行が「書かなかった」 と同じになり、 値が 1 つ消えたことに気付けない
420
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
421
+ for (const it of items) {
422
+ const v = parseValueEntry(it.trimmed.replace(/^-\s*/, ""), it.no, errors);
423
+ if (v) values.push(v);
264
424
  }
265
425
  i = next;
266
426
  continue;
@@ -282,14 +442,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
282
442
  if (inline && inline.startsWith("{") && inline.endsWith("}")) {
283
443
  const opts = parseInlineMapping(inline.slice(1, -1));
284
444
  viewport = {
285
- width: numberOrUndef(opts.width),
286
- height: numberOrUndef(opts.height),
287
- laneWidth: numberOrUndef(opts.laneWidth),
288
- gap: numberOrUndef(opts.gap),
289
- laneGap: numberOrUndef(opts.laneGap),
290
- nodeGap: numberOrUndef(opts.nodeGap),
291
- scale: numberOrUndef(opts.scale),
292
- labelMargin: numberOrUndef(opts.labelMargin),
445
+ ...表で読む(VIEWPORT_VALUE_KINDS, opts, "viewport の ", line.no, errors),
293
446
  pos: { line: line.no },
294
447
  };
295
448
  i += 1;
@@ -298,40 +451,81 @@ export function parseTextDslV05(src: string): V05ParseResult {
298
451
  // block: viewport:\n width: 1400\n height: 900\n ...
299
452
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
300
453
  const opts: Record<string, string> = {};
454
+ // 知らせは **値を書いた行** を指す (#1306)。 `viewport:` の行を指すと、欄が縦に並ぶ形で
455
+ // どの行を直せばよいか分からない
456
+ const optLines: Record<string, number> = {};
301
457
  for (const it of items) {
302
458
  const m = it.trimmed.match(/^([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
303
- if (m) opts[m[1]!] = stripQuotes(m[2]!.trim());
459
+ if (!m) continue;
460
+ const 欄 = m[1] ?? "";
461
+ opts[欄] = stripQuotes((m[2] ?? "").trim());
462
+ optLines[欄] = it.no;
304
463
  }
305
464
  viewport = {
306
- width: numberOrUndef(opts.width),
307
- height: numberOrUndef(opts.height),
308
- laneWidth: numberOrUndef(opts.laneWidth),
309
- gap: numberOrUndef(opts.gap),
310
- laneGap: numberOrUndef(opts.laneGap),
311
- nodeGap: numberOrUndef(opts.nodeGap),
312
- scale: numberOrUndef(opts.scale),
313
- labelMargin: numberOrUndef(opts.labelMargin),
465
+ ...表で読む(
466
+ VIEWPORT_VALUE_KINDS,
467
+ opts,
468
+ "viewport の ",
469
+ (欄) => optLines[欄] ?? line.no,
470
+ errors,
471
+ ),
314
472
  pos: { line: line.no },
315
473
  };
316
474
  i = next;
317
475
  continue;
318
476
  }
477
+ if (head.key === "axes") {
478
+ // axes:\n x: { left: "...", right: "..." }\n y: { bottom: "...", top: "..." }
479
+ //
480
+ // **1 行にまとめて書く形は受けない** (Round 1 の指摘)。 受けないなら黙って捨てず、
481
+ // その場で伝える = 捨てると軸を書いたつもりの本文が既定のまま描かれる (`values:` と同じ)
482
+ if (head.value !== null && head.value.trim() !== "") {
483
+ errors.push({
484
+ line: line.no,
485
+ message: "axes は 1 行にまとめて書けない",
486
+ hint: '次の行から字下げして `x: { left: "...", right: "..." }` の形で並べる',
487
+ });
488
+ i += 1;
489
+ continue;
490
+ }
491
+ // **`collectIndentedList` を使わない** (Round 1 の指摘)。 あちらは `:` を含まない行を
492
+ // 黙って捨てるため、書き間違えた行が「書かなかった」 と同じになる
493
+ const { items, next } = collectIndentedRaw(lines, i + 1, line.indent);
494
+ axesLine = line.no;
495
+ const 組み立て: DslAxes = {};
496
+ for (const it of items) {
497
+ const m = it.trimmed.match(/^(x|y)\s*:\s*\{([^}]*)\}\s*$/);
498
+ if (!m) {
499
+ errors.push({
500
+ line: it.no,
501
+ message: `invalid axes entry: "${it.trimmed}"`,
502
+ hint: 'use `x: { left: "...", right: "..." }` or `y: { bottom: "...", top: "..." }`',
503
+ });
504
+ continue;
505
+ }
506
+ const opts = parseInlineMapping(m[2] ?? "");
507
+ if (m[1] === "x") 組み立て.x = { left: opts.left, right: opts.right };
508
+ else 組み立て.y = { bottom: opts.bottom, top: opts.top };
509
+ }
510
+ // 1 本も読めなかった形は「書かなかった」 と同じにする。 空の軸を渡すと、
511
+ // 書いていない側の名前が空文字で描かれる
512
+ axes = 組み立て.x !== undefined || 組み立て.y !== undefined ? 組み立て : undefined;
513
+ i = next;
514
+ continue;
515
+ }
319
516
  if (head.key === "lanes") {
320
517
  // lanes:\n l1: { x: 0, width: 320, label: "..." }\n l2: { ... }
321
518
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
322
519
  lanesMap = {};
323
520
  for (const it of items) {
324
- const m = it.trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*\{([^}]*)\}\s*$/);
521
+ const m = it.trimmed.match(LANE_ID_ENTRY);
325
522
  if (m) {
326
523
  const id = m[1]!;
327
524
  const opts = parseInlineMapping(m[2]!);
328
525
  lanesMap[id] = {
329
526
  id,
330
- x: numberOrUndef(opts.x),
331
- width: numberOrUndef(opts.width),
527
+ ...表で読む(LANE_VALUE_KINDS, opts, `縦列 ${id} の `, it.no, errors),
332
528
  label: opts.label,
333
- contain: boolOrUndef(opts.contain),
334
- lifeline: boolOrUndef(opts.lifeline),
335
529
  pos: { line: it.no },
336
530
  };
337
531
  } else {
@@ -350,7 +544,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
350
544
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
351
545
  groupsMap = {};
352
546
  for (const it of items) {
353
- const m = it.trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*\{([^}]*)\}\s*$/);
547
+ const m = it.trimmed.match(LANE_ID_ENTRY);
354
548
  if (m) {
355
549
  const id = m[1]!;
356
550
  const opts = parseInlineMapping(m[2]!);
@@ -379,8 +573,14 @@ export function parseTextDslV05(src: string): V05ParseResult {
379
573
  i += 1;
380
574
  }
381
575
 
382
- if (!title) errors.push({ line: 1, message: "title is required", hint: 'add `title: "..."` at top' });
383
- if (!type) errors.push({ line: 1, message: "type is required", hint: "add `type: sequence|flow|swimlane|er|state|topology|solidity|gantt|class|pie|c4|mind`" });
576
+ if (!title)
577
+ errors.push({ line: 1, message: "title is required", hint: 'add `title: "..."` at top' });
578
+ if (!type)
579
+ errors.push({
580
+ line: 1,
581
+ message: "type is required",
582
+ hint: "add `type: sequence|flow|swimlane|er|state|topology|solidity|gantt|class|pie|c4|mind`",
583
+ });
384
584
 
385
585
  if (errors.length > 0) return { ok: false, errors };
386
586
 
@@ -389,9 +589,12 @@ export function parseTextDslV05(src: string): V05ParseResult {
389
589
  doc: {
390
590
  title: title!,
391
591
  type: type!,
592
+ ...(eyebrow !== null ? { eyebrow, eyebrowPos: { line: eyebrowLine } } : {}),
593
+ ...(axes !== undefined ? { axes, axesPos: { line: axesLine } } : {}),
392
594
  actors,
393
595
  flow,
394
596
  animate,
597
+ ...(values.length > 0 ? { values } : {}),
395
598
  viewport,
396
599
  lanes: lanesMap,
397
600
  groups: groupsMap,
@@ -451,7 +654,10 @@ function lastTopLevelColon(s: string): number {
451
654
  if (c === quote) quote = "";
452
655
  continue;
453
656
  }
454
- if (c === '"' || c === "'") { quote = c; continue; }
657
+ if (c === '"' || c === "'") {
658
+ quote = c;
659
+ continue;
660
+ }
455
661
  if (c === "[" || c === "{") depth += 1;
456
662
  else if (c === "]" || c === "}") depth -= 1;
457
663
  else if (c === ":" && depth === 0) last = i;
@@ -476,11 +682,26 @@ function splitValues(s: string): string[] {
476
682
  if (c === quote) quote = "";
477
683
  continue;
478
684
  }
479
- if (c === '"' || c === "'") { quote = c; buf += c; continue; }
480
- if (c === "[" || c === "{") { depth += 1; buf += c; continue; }
481
- if (c === "]" || c === "}") { depth -= 1; buf += c; continue; }
685
+ if (c === '"' || c === "'") {
686
+ quote = c;
687
+ buf += c;
688
+ continue;
689
+ }
690
+ if (c === "[" || c === "{") {
691
+ depth += 1;
692
+ buf += c;
693
+ continue;
694
+ }
695
+ if (c === "]" || c === "}") {
696
+ depth -= 1;
697
+ buf += c;
698
+ continue;
699
+ }
482
700
  if (/\s/.test(c) && depth === 0) {
483
- if (buf) { out.push(buf); buf = ""; }
701
+ if (buf) {
702
+ out.push(buf);
703
+ buf = "";
704
+ }
484
705
  continue;
485
706
  }
486
707
  buf += c;
@@ -511,13 +732,16 @@ type ActorValues = {
511
732
  * 振り分けは値の形で決まる。 引用符付きは補足 (2 つ目は値)、 角括弧は行、 色名は色、
512
733
  * 残りが種類。 形が違うので取り違えない。
513
734
  */
514
- function classifyValues(values: string[]): ActorValues {
735
+ function classifyValues(values: string[], line: number, errors: DslError[]): ActorValues {
515
736
  const out: ActorValues = { kind: "" };
516
737
  /** 書かれた倍率。 同じ名前が 2 度出たら後の値で上書きする */
517
738
  const scaleWritten = new Map<string, string>();
518
739
  const kindWords: string[] = [];
519
740
  for (const v of values) {
520
- if ((v.startsWith('"') && v.endsWith('"') && v.length > 1) || (v.startsWith("'") && v.endsWith("'") && v.length > 1)) {
741
+ if (
742
+ (v.startsWith('"') && v.endsWith('"') && v.length > 1) ||
743
+ (v.startsWith("'") && v.endsWith("'") && v.length > 1)
744
+ ) {
521
745
  // 1 つ目の引用符は補足、 2 つ目は値 (`storage` の右側に出る数値等)
522
746
  if (out.subtitle === undefined) out.subtitle = stripQuotes(v);
523
747
  else if (out.value === undefined) out.value = stripQuotes(v);
@@ -533,7 +757,11 @@ function classifyValues(values: string[]): ActorValues {
533
757
  }
534
758
  // `@300,200` は位置。 2 つ揃わないと効かないので、 1 つの値としてまとめて書く
535
759
  const at = v.match(/^@(-?\d+(?:\.\d+)?)\s*[,、]\s*(-?\d+(?:\.\d+)?)$/);
536
- if (at) { out.posX = Number(at[1]); out.posY = Number(at[2]); continue; }
760
+ if (at) {
761
+ out.posX = Number(at[1]);
762
+ out.posY = Number(at[2]);
763
+ continue;
764
+ }
537
765
  // `名前=値` は parts の状態の上書き。 状態名は自由なので、 形では見分けられない。
538
766
  // 等号を書いてもらう。
539
767
  const eq = v.indexOf("=");
@@ -551,12 +779,15 @@ function classifyValues(values: string[]): ActorValues {
551
779
  continue;
552
780
  }
553
781
  }
554
- const tone = toneOrUndef(v);
555
- if (tone) { out.tone = tone; continue; }
782
+ const tone = resolveTone(v);
783
+ if (tone) {
784
+ out.tone = tone;
785
+ continue;
786
+ }
556
787
  kindWords.push(v);
557
788
  }
558
789
  out.kind = kindWords.join(" ").toLowerCase();
559
- const s = resolveScale(scaleWritten);
790
+ const s = resolveScale(scaleWritten, line, errors);
560
791
  out.scale = s.scale;
561
792
  out.scaleKeys = s.keys;
562
793
  return out;
@@ -567,7 +798,7 @@ function classifyValues(values: string[]): ActorValues {
567
798
  *
568
799
  * 固有名 (`lambda` / `rds` 等) は読み替え表を通す。 それ以外はそのまま返す。
569
800
  */
570
- function resolveKind(raw: string): NodeKind {
801
+ export function resolveNodeKind(raw: string): NodeKind {
571
802
  if (raw === "") return NODE_KIND_DEFAULT;
572
803
  // `Object.hasOwn` で引く。 素の添字だと `toString` 等の既定の持ち物が引けてしまい、
573
804
  // 種類として関数が返る。 呼ぶ前に受理集合で弾いてはいるが、 表を引く側でも閉じておく。
@@ -588,6 +819,157 @@ function boolOrUndef(s: string | undefined): boolean | undefined {
588
819
  return undefined;
589
820
  }
590
821
 
822
+ /**
823
+ * 欄が期待する値の形 (#1306)。
824
+ *
825
+ * 記法の値はすべて文字列なので「型」 は無いが、**欄ごとに読める形は決まっている**
826
+ * (`posX` は数、`overlay` は真偽)。 その形を表に並べ、読めない値を行番号付きで知らせる。
827
+ */
828
+ type 値の形 = "数" | "真偽";
829
+
830
+ /** 表から作る、欄の名前と読んだ結果の対応 */
831
+ type 読んだ結果<T extends Record<string, 値の形>> = {
832
+ [K in keyof T]: T[K] extends "数" ? number | undefined : boolean | undefined;
833
+ };
834
+
835
+ /**
836
+ * 数として読む。 読めない値は行番号付きで知らせる (#1306)。
837
+ *
838
+ * `numberOrUndef` は読めない値を黙って `undefined` に落とすため、書いた欄が無かったことに
839
+ * なる。 誤りも警告も出ないので、書いた人には「書いたのに図が変わらない」 としか見えない。
840
+ *
841
+ * 値を書かなかった形 (`posX:` の右が空) は「書かなかった」 と同じ扱いのままにする。
842
+ * こちらは黙って消えているわけではなく、書いていないものが効かないだけ。
843
+ */
844
+ function 数として読む(
845
+ raw: string | undefined,
846
+ 欄: string,
847
+ line: number,
848
+ errors: DslError[],
849
+ ): number | undefined {
850
+ if (raw === undefined || raw === "") return undefined;
851
+ const n = numberOrUndef(raw);
852
+ if (n !== undefined) return n;
853
+ errors.push({
854
+ line,
855
+ message: `${欄} は数で書きます: "${raw}"`,
856
+ hint: "`300` / `-8` / `1.5` の形で書く",
857
+ });
858
+ return undefined;
859
+ }
860
+
861
+ /**
862
+ * 真偽として読む。 読めない値は行番号付きで知らせる (#1306)。
863
+ *
864
+ * 受けるのは `true` と `false` だけ。 `yes` / `1` / `はい` は読めないため、使える値を
865
+ * 添えて知らせる (読めない値を捨てるだけだと、別の綴りを試し続けることになる)。
866
+ */
867
+ function 真偽として読む(
868
+ raw: string | undefined,
869
+ 欄: string,
870
+ line: number,
871
+ errors: DslError[],
872
+ ): boolean | undefined {
873
+ if (raw === undefined || raw === "") return undefined;
874
+ const b = boolOrUndef(raw);
875
+ if (b !== undefined) return b;
876
+ errors.push({
877
+ line,
878
+ message: `${欄} は true か false で書きます: "${raw}"`,
879
+ hint: "使える値 = true, false",
880
+ });
881
+ return undefined;
882
+ }
883
+
884
+ /**
885
+ * 表に並べた欄をまとめて読む (#1306)。
886
+ *
887
+ * **呼出側に欄名を並べない**。 並べると欄を足した時に知らせだけが漏れる (JSON 入口が
888
+ * #1304 で踏んだ形と同じ)。 表を 1 つ置き、読む側も検査もそこから導く。
889
+ *
890
+ * `接頭` は知らせに出す欄の呼び名の前半 (`viewport.` / `縦列 l1 の `)。 同じ欄名が別の
891
+ * 場所に出る (`width` は図全体と縦列、`posX` は箱と箱の中の要素) ため、どこの欄かが
892
+ * 分かる形にする。
893
+ */
894
+ function 表で読む<T extends Record<string, 値の形>>(
895
+ 表: T,
896
+ opts: Record<string, string | undefined>,
897
+ 接頭: string,
898
+ // 欄ごとに行が違う書き方 (縦に並べる形) では関数で渡す。 1 行に収まる書き方 (中括弧) は数で渡す
899
+ line: number | ((欄: string) => number),
900
+ errors: DslError[],
901
+ ): 読んだ結果<T> {
902
+ const out: Record<string, number | boolean | undefined> = {};
903
+ const 行を引く = (欄: string): number => (typeof line === "number" ? line : line(欄));
904
+ for (const [欄, 形] of Object.entries<値の形>(表)) {
905
+ out[欄] =
906
+ 形 === "数"
907
+ ? 数として読む(opts[欄], `${接頭}${欄}`, 行を引く(欄), errors)
908
+ : 真偽として読む(opts[欄], `${接頭}${欄}`, 行を引く(欄), errors);
909
+ }
910
+ return out as 読んだ結果<T>;
911
+ }
912
+
913
+ /**
914
+ * 図全体の大きさと間隔の欄 (#1306)。 `DslViewport` の数の欄をすべて覆う。
915
+ *
916
+ * `satisfies` で `DslViewport` から欄を導く = 欄を足して表に書き忘れると型検査が落ちる。
917
+ */
918
+ export const VIEWPORT_VALUE_KINDS = {
919
+ width: "数",
920
+ height: "数",
921
+ scale: "数",
922
+ laneWidth: "数",
923
+ gap: "数",
924
+ laneGap: "数",
925
+ nodeGap: "数",
926
+ labelMargin: "数",
927
+ } as const satisfies Record<Exclude<keyof DslViewport, "pos">, 値の形>;
928
+
929
+ /**
930
+ * 縦列の欄 (#1306)。 `label` は文字列なので表に載せない (記法の値は全て文字列で、
931
+ * 文字列の欄には読めない値という状態が無い)。
932
+ */
933
+ export const LANE_VALUE_KINDS = {
934
+ x: "数",
935
+ width: "数",
936
+ contain: "真偽",
937
+ lifeline: "真偽",
938
+ } as const satisfies Record<string, 値の形>;
939
+
940
+ /** 箱の中の要素の欄 (#1306)。 `DslActorNodeOverride` の全欄を覆う */
941
+ export const ACTOR_NODE_VALUE_KINDS = {
942
+ posX: "数",
943
+ posY: "数",
944
+ posW: "数",
945
+ posH: "数",
946
+ } as const satisfies Record<keyof DslActorNodeOverride, 値の形>;
947
+
948
+ /** 中括弧の形で箱に書ける、数と真偽の欄 (#1306) */
949
+ export const ACTOR_INLINE_VALUE_KINDS = {
950
+ stack: "数",
951
+ initial: "真偽",
952
+ final: "真偽",
953
+ posX: "数",
954
+ posY: "数",
955
+ posW: "数",
956
+ posH: "数",
957
+ } as const satisfies Record<string, 値の形>;
958
+
959
+ /** 縦に並べる形で箱に書ける、数と真偽の欄 (#1306)。 `posW` / `posH` は `大きさ:` が受ける */
960
+ export const ACTOR_BLOCK_VALUE_KINDS = {
961
+ stack: "数",
962
+ posX: "数",
963
+ posY: "数",
964
+ } as const satisfies Record<string, 値の形>;
965
+
966
+ /** 矢印の中括弧に書ける、数と真偽の欄 (#1306) */
967
+ export const FLOW_INLINE_VALUE_KINDS = {
968
+ labelOffsetX: "数",
969
+ labelOffsetY: "数",
970
+ overlay: "真偽",
971
+ } as const satisfies Record<string, 値の形>;
972
+
591
973
  /**
592
974
  * 色名を解決する。 別名 (`成功` / `neutral` 等) も受け付ける。
593
975
  *
@@ -597,7 +979,7 @@ function boolOrUndef(s: string | undefined): boolean | undefined {
597
979
  * `valueOf` / `__proto__` が JavaScript の既定の持ち物として引けてしまい、 色名として
598
980
  * 関数やオブジェクトが通る (実測)。 最後に解決結果が正規の色名かも確かめる。
599
981
  */
600
- function toneOrUndef(s: string | undefined): Tone | undefined {
982
+ export function resolveTone(s: string | undefined): Tone | undefined {
601
983
  if (s === undefined) return undefined;
602
984
  const raw = stripQuotes(s.trim());
603
985
  const lower = raw.toLowerCase();
@@ -707,7 +1089,11 @@ function splitInlineFields(inner: string): string[] {
707
1089
  return parts;
708
1090
  }
709
1091
 
710
- function collectIndentedList(lines: Line[], start: number, parentIndent: number): { items: Line[]; next: number } {
1092
+ function collectIndentedList(
1093
+ lines: Line[],
1094
+ start: number,
1095
+ parentIndent: number,
1096
+ ): { items: Line[]; next: number } {
711
1097
  const items: Line[] = [];
712
1098
  let i = start;
713
1099
  while (i < lines.length) {
@@ -748,13 +1134,43 @@ function collectIndentedList(lines: Line[], start: number, parentIndent: number)
748
1134
  * 書く人は「色を変えたい」 としか思わないので、 項目は `色:` 1 つにまとめる。 意味の色
749
1135
  * (`失敗`) と色番号 (`#f59e0b`) は形で見分ける。 前者は箱の色、 後者はパーツの塗りになる。
750
1136
  */
751
- function splitColorValue(raw: string): { tone?: Tone; hex?: string } {
1137
+ export function splitColorValue(raw: string): { tone?: Tone; hex?: string } {
752
1138
  const v = stripQuotes(raw.trim());
753
1139
  if (v.startsWith("#")) return { hex: v };
754
- const tone = toneOrUndef(v);
1140
+ const tone = resolveTone(v);
755
1141
  return tone ? { tone } : {};
756
1142
  }
757
1143
 
1144
+ /**
1145
+ * v0.4 で使えた箱の種類の名前 (#1301)。
1146
+ *
1147
+ * v0.5 の受理集合 (`NODE_KIND_VALID`) に無いため、書くと見本 (parts) の名前として扱われ、
1148
+ * 見本帳に無ければ `actor` に潰れて **黙って消えていた**。 見本の名前と区別が付かないので、
1149
+ * 「v0.4 で種類として使えた語」 であることを根拠に誤りとして知らせる。
1150
+ *
1151
+ * 対応は `keywords.ts` の `NODE_KIND_ALIAS` が持つ (日本語 → 英語の種類名)。
1152
+ */
1153
+ function v04の種類名(値: string): string | undefined {
1154
+ if (!Object.hasOwn(NODE_KIND_ALIAS, 値)) return undefined;
1155
+ if (NODE_KIND_VALID.has(値)) return undefined; // v0.5 でも受ける名前は対象外
1156
+ return NODE_KIND_ALIAS[値];
1157
+ }
1158
+
1159
+ /**
1160
+ * 箱の種類に v0.4 の日本語を書いた時に知らせる (#1301)。
1161
+ *
1162
+ * 黙って見本の名前として扱うと、見本帳に無い場合に `actor` へ潰れて手掛かりが残らない。
1163
+ */
1164
+ function reportV04Kind(kindRaw: string, line: number, errors: DslError[]): void {
1165
+ const 英語 = v04の種類名(kindRaw);
1166
+ if (英語 === undefined) return;
1167
+ errors.push({
1168
+ line,
1169
+ message: `箱の種類に v0.4 の名前は使えません: "${kindRaw}"`,
1170
+ hint: `v0.5 では英語で書く (\`${英語}\`)`,
1171
+ });
1172
+ }
1173
+
758
1174
  /** `色` / `color` のどちらでも書ける。 */
759
1175
  const COLOR_KEYS = new Set(["色", "color", "tone"]);
760
1176
 
@@ -782,11 +1198,20 @@ const SCALE_ORDER = ["scale", "倍率"] as const;
782
1198
  * `keys` は書かれた名前そのもの。 値が読めたかに関わらず入る。 見本が同じ名前の状態を
783
1199
  * 持つ時の知らせ (`scale-reserved`) が、値の読めなさに左右されないようにするため。
784
1200
  */
785
- function resolveScale(written: Map<string, string>): { scale?: number; keys: string[] } {
1201
+ function resolveScale(
1202
+ written: Map<string, string>,
1203
+ line: number | ((key: string) => number),
1204
+ errors: DslError[],
1205
+ ): { scale?: number; keys: string[] } {
786
1206
  const keys = [...written.keys()];
787
1207
  for (const key of SCALE_ORDER) {
788
1208
  const raw = written.get(key);
789
- if (raw !== undefined) return { scale: numberOrUndef(raw), keys };
1209
+ // 読めない値は黙って捨てず知らせる (#1306) 書かれた名前 (`keys`) は値の読めなさに
1210
+ // 関わらず残す = 見本が同じ名前の状態を持つ時の知らせが消えないようにするため
1211
+ if (raw !== undefined) {
1212
+ const 当該行 = typeof line === "number" ? line : line(key);
1213
+ return { scale: 数として読む(raw, `箱の ${key}`, 当該行, errors), keys };
1214
+ }
790
1215
  }
791
1216
  return { keys };
792
1217
  }
@@ -803,6 +1228,15 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
803
1228
  let touchedState = false;
804
1229
  /** 縦に並べて書かれた倍率。 同じ名前が 2 度出たら後の値で上書きする */
805
1230
  const scaleWritten = new Map<string, string>();
1231
+ /** 倍率を名前ごとに最後に書いた行。 別名の優先順と行番号を取り違えないために保持する。 */
1232
+ const scaleLines = new Map<string, number>();
1233
+ /**
1234
+ * 縦に並べて書かれた体験の道筋の欄 (#1251)。
1235
+ *
1236
+ * パーツでは状態の上書きとして意味を持つため、どちらに入れるかは block を読み終わってから
1237
+ * 決める。 `kind:` の行が後ろに書かれることもあり、読んだ時点ではパーツか分からない。
1238
+ */
1239
+ const 図種ごとの欄 = new Map<string, string>();
806
1240
  // パーツでなければどこにも入らない項目。 パーツかどうかは block を読み終わるまで決まらない
807
1241
  const unknownKeys: Array<{ key: string; line: number }> = [];
808
1242
 
@@ -816,6 +1250,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
816
1250
  // 予約の知らせが消え、別名 (`倍率`) に降りて別の値が効いてしまう
817
1251
  if (SCALE_KEYS.has(key)) {
818
1252
  scaleWritten.set(key, stripQuotes(raw));
1253
+ scaleLines.set(key, ln.no);
819
1254
  unknownKeys.push({ key, line: ln.no });
820
1255
  continue;
821
1256
  }
@@ -826,14 +1261,18 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
826
1261
  if (tone) out.tone = tone;
827
1262
  // 色番号を入れる状態の名前はパーツごとに違う。 組み立て時に解決する
828
1263
  if (hex) out.colorHex = hex;
1264
+ // 色名としても色番号としても読めない値は黙って捨てない (#1304)。 捨てると
1265
+ // 既定色のまま描かれ、 手掛かりが 1 つも残らない
1266
+ if (!tone && !hex) report読めない色(raw, ln.no, errors, { 線種も受ける: false });
829
1267
  continue;
830
1268
  }
831
1269
  switch (key) {
832
1270
  case "kind":
833
1271
  case "種類": {
834
1272
  const k = stripQuotes(raw).toLowerCase();
1273
+ reportV04Kind(k, ln.no, errors);
835
1274
  const isPart = k !== "" && !NODE_KIND_VALID.has(k);
836
- out.kind = isPart ? NODE_KIND_DEFAULT : resolveKind(k);
1275
+ out.kind = isPart ? NODE_KIND_DEFAULT : resolveNodeKind(k);
837
1276
  // parts 候補は `kind` を既定に倒して `partId` へ退避するため、 名札に載せる種類としては
838
1277
  // 「書かなかった」 と同じ扱いにする (#1058)
839
1278
  out.kindWritten = k !== "" && !isPart;
@@ -850,7 +1289,11 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
850
1289
  break;
851
1290
  case "rows":
852
1291
  case "行":
853
- out.rows = raw.replace(/^\[|\]$/g, "").split(/,(?![^[]*\])/).map((x) => stripQuotes(x.trim())).filter(Boolean);
1292
+ out.rows = raw
1293
+ .replace(/^\[|\]$/g, "")
1294
+ .split(/,(?![^[]*\])/)
1295
+ .map((x) => stripQuotes(x.trim()))
1296
+ .filter(Boolean);
854
1297
  break;
855
1298
  case "位置":
856
1299
  case "pos": {
@@ -879,7 +1322,8 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
879
1322
  //
880
1323
  // 負の間隔 (`Web の右 -200`) もここに来る。 向きを書いた上で裏返す指定は、
881
1324
  // 書いた人の意図と図が食い違うので誤りとして返す
882
- const negative = /^(.+?)\s*(?:の\s*(?:右|左|上|下)|\s(?:right|left|above|below))\s*-\s*[\d.]/i.test(value);
1325
+ const negative =
1326
+ /^(.+?)\s*(?:の\s*(?:右|左|上|下)|\s(?:right|left|above|below))\s*-\s*[\d.]/i.test(value);
883
1327
  errors.push({
884
1328
  line: ln.no,
885
1329
  message: negative
@@ -892,10 +1336,10 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
892
1336
  break;
893
1337
  }
894
1338
  case "posX":
895
- out.posX = numberOrUndef(raw);
1339
+ out.posX = 数として読む(raw, "箱の posX", ln.no, errors);
896
1340
  break;
897
1341
  case "posY":
898
- out.posY = numberOrUndef(raw);
1342
+ out.posY = 数として読む(raw, "箱の posY", ln.no, errors);
899
1343
  break;
900
1344
  case "大きさ":
901
1345
  case "size": {
@@ -916,11 +1360,20 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
916
1360
  });
917
1361
  break;
918
1362
  }
1363
+ case "touchpoint":
1364
+ case "opportunity":
1365
+ case "owner":
1366
+ case "end":
1367
+ // **どちらに入れるかは block を読み終わるまで決まらない** (#1251 Round 1 の指摘)。
1368
+ // パーツかどうかは `kind:` の行で決まり、それが後ろに書かれることもある。
1369
+ // 倍率 (`scaleWritten`) と読めない項目名 (`unknownKeys`) が同じ理由で後回しにしている
1370
+ 図種ごとの欄.set(key, stripQuotes(raw));
1371
+ break;
919
1372
  case "lane":
920
1373
  out.lane = stripQuotes(raw);
921
1374
  break;
922
1375
  case "stack":
923
- out.stack = numberOrUndef(raw);
1376
+ out.stack = 数として読む(raw, "箱の stack", ln.no, errors);
924
1377
  break;
925
1378
  default:
926
1379
  // 残りはパーツの状態の上書き
@@ -933,10 +1386,26 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
933
1386
  // 名前の行と縦に並べた行の両方に倍率がある形では、後に書いた縦の行を採る。
934
1387
  // 知らせ (`scale-reserved`) は書かれた名前をすべて見るので、名前だけは足し合わせる
935
1388
  if (scaleWritten.size > 0) {
936
- const s = resolveScale(scaleWritten);
1389
+ const s = resolveScale(
1390
+ scaleWritten,
1391
+ (key) => scaleLines.get(key) ?? actor.pos.line,
1392
+ errors,
1393
+ );
937
1394
  out.scale = s.scale;
938
1395
  out.scaleKeys = [...new Set([...(actor.scaleKeys ?? []), ...s.keys])];
939
1396
  }
1397
+ // 体験の道筋の欄は、パーツなら状態の上書き、そうでなければ道筋の欄として入れる
1398
+ for (const [key, v] of 図種ごとの欄) {
1399
+ if (out.partId !== undefined) {
1400
+ state[key] = coerceStateValue(v);
1401
+ touchedState = true;
1402
+ continue;
1403
+ }
1404
+ if (key === "touchpoint") out.touchpoint = v;
1405
+ else if (key === "opportunity") out.opportunity = v;
1406
+ else if (key === "owner") out.owner = v;
1407
+ else out.end = v;
1408
+ }
940
1409
  // 状態も倍率も parts でだけ意味を持つ。 パーツなら知らせずに返す
941
1410
  if (out.partId !== undefined) {
942
1411
  if (touchedState) out.stateOverride = state;
@@ -961,14 +1430,30 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
961
1430
  */
962
1431
  export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
963
1432
  ...COLOR_KEYS,
964
- "kind", "種類",
965
- "subtitle", "補足",
966
- "value", "値",
967
- "rows", "行",
968
- "位置", "pos", "posX", "posY",
969
- "大きさ", "size",
970
- "倍率", "scale",
971
- "lane", "stack",
1433
+ "kind",
1434
+ "種類",
1435
+ "subtitle",
1436
+ "補足",
1437
+ "value",
1438
+ "",
1439
+ "rows",
1440
+ "",
1441
+ "位置",
1442
+ "pos",
1443
+ "posX",
1444
+ "posY",
1445
+ "大きさ",
1446
+ "size",
1447
+ "倍率",
1448
+ "scale",
1449
+ "lane",
1450
+ "stack",
1451
+ // 体験の道筋の欄 (#1251)
1452
+ "touchpoint",
1453
+ "opportunity",
1454
+ // 工程の並びの欄 (#1251)
1455
+ "owner",
1456
+ "end",
972
1457
  ]);
973
1458
 
974
1459
  /**
@@ -1027,14 +1512,21 @@ function validateRelativePositions(actors: DslActor[], errors: DslError[]): void
1027
1512
  }
1028
1513
  }
1029
1514
 
1030
- function collectActorEntries(lines: Line[], start: number, parentIndent: number): { items: Line[][]; next: number } {
1515
+ function collectActorEntries(
1516
+ lines: Line[],
1517
+ start: number,
1518
+ parentIndent: number,
1519
+ ): { items: Line[][]; next: number } {
1031
1520
  const items: Line[][] = [];
1032
1521
  let cur: Line[] | null = null;
1033
1522
  let headIndent = -1;
1034
1523
  let i = start;
1035
1524
  while (i < lines.length) {
1036
1525
  const ln = lines[i]!;
1037
- if (!ln.trimmed) { i += 1; continue; }
1526
+ if (!ln.trimmed) {
1527
+ i += 1;
1528
+ continue;
1529
+ }
1038
1530
  if (ln.indent <= parentIndent) break;
1039
1531
  if (ln.trimmed.startsWith("- ")) {
1040
1532
  if (cur) items.push(cur);
@@ -1050,7 +1542,11 @@ function collectActorEntries(lines: Line[], start: number, parentIndent: number)
1050
1542
  return { items, next: i };
1051
1543
  }
1052
1544
 
1053
- function collectAnimationSteps(lines: Line[], start: number, parentIndent: number): { items: Line[][]; next: number } {
1545
+ function collectAnimationSteps(
1546
+ lines: Line[],
1547
+ start: number,
1548
+ parentIndent: number,
1549
+ ): { items: Line[][]; next: number } {
1054
1550
  // 各 `- step: "..."` 開始を 1 block の頭として識別、 後続の同 indent 以下を block 本文として吸収
1055
1551
  const out: Line[][] = [];
1056
1552
  let i = start;
@@ -1062,7 +1558,10 @@ function collectAnimationSteps(lines: Line[], start: number, parentIndent: numbe
1062
1558
  continue;
1063
1559
  }
1064
1560
  if (ln.indent <= parentIndent) break;
1065
- if (ln.trimmed.startsWith("- step")) {
1561
+ // v0.4 の日本語の段名も block の頭として拾い、parsePhase で英語の `step` を案内する
1562
+ // (#1301)。ここで英語だけに絞ると `- ステップ:` は block 自体が作られず、段全体が
1563
+ // 誤りなしで黙って消える。
1564
+ if (ln.trimmed.startsWith("- step") || ln.trimmed.startsWith("- ステップ")) {
1066
1565
  if (cur) out.push(cur);
1067
1566
  cur = [{ ...ln, trimmed: ln.trimmed.slice(2).trim() }];
1068
1567
  } else if (cur) {
@@ -1092,6 +1591,9 @@ const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
1092
1591
  "initial",
1093
1592
  "final",
1094
1593
  "state",
1594
+ // 体験の道筋の欄 (`touchpoint` / `opportunity`) はここに載せない (#1251 Round 1 の指摘)。
1595
+ // 載せるとパーツで同じ名前の状態を書いた時に横取りされる = 既に動いている見本が静かに変わる。
1596
+ // パーツでない箱でだけ道筋の欄として読む (`parseActor` / `applyContinuationLines` が分岐する)
1095
1597
  // canvas pivot 新 spec = 絶対座標 4 field (dragon canvas pivot spec §layout-role-conversion)
1096
1598
  "posX",
1097
1599
  "posY",
@@ -1114,7 +1616,9 @@ const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
1114
1616
  "色",
1115
1617
  ]);
1116
1618
 
1117
- function extractStateOverride(opts: Record<string, string>): Record<string, number | string | boolean> | undefined {
1619
+ function extractStateOverride(
1620
+ opts: Record<string, string>,
1621
+ ): Record<string, number | string | boolean> | undefined {
1118
1622
  const out: Record<string, number | string | boolean> = {};
1119
1623
  let count = 0;
1120
1624
  // 明示 `state: {...}` fallback がある場合はそちらを優先 (nested map parse)
@@ -1143,7 +1647,11 @@ function extractStateOverride(opts: Record<string, string>): Record<string, numb
1143
1647
  * key: sub-map ペアに再 split → 各 sub-map を parseInlineMapping で解いて posX/Y/W/H に coerce」 する。
1144
1648
  * 未 field or 空 object なら undefined 返し (caller は actor.nodes を set しない)。
1145
1649
  */
1146
- function parseActorNodesField(raw: string | undefined): Record<string, DslActorNodeOverride> | undefined {
1650
+ function parseActorNodesField(
1651
+ raw: string | undefined,
1652
+ line: number,
1653
+ errors: DslError[],
1654
+ ): Record<string, DslActorNodeOverride> | undefined {
1147
1655
  if (!raw) return undefined;
1148
1656
  const trimmed = raw.trim();
1149
1657
  if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined;
@@ -1173,12 +1681,7 @@ function parseActorNodesField(raw: string | undefined): Record<string, DslActorN
1173
1681
  const val = p.slice(colonIdx + 1).trim();
1174
1682
  if (!key || !val.startsWith("{") || !val.endsWith("}")) continue;
1175
1683
  const nodeOpts = parseInlineMapping(val.slice(1, -1));
1176
- out[key] = {
1177
- posX: numberOrUndef(nodeOpts.posX),
1178
- posY: numberOrUndef(nodeOpts.posY),
1179
- posW: numberOrUndef(nodeOpts.posW),
1180
- posH: numberOrUndef(nodeOpts.posH),
1181
- };
1684
+ out[key] = 表で読む(ACTOR_NODE_VALUE_KINDS, nodeOpts, `nodes の ${key} の `, line, errors);
1182
1685
  }
1183
1686
  return Object.keys(out).length > 0 ? out : undefined;
1184
1687
  }
@@ -1220,19 +1723,94 @@ function reportScaleOnNonPart(
1220
1723
  * 並べた形) とは別に持つ = 中括弧の形は位置や大きさを未対応にしてあり、 同じ集合にすると
1221
1724
  * 「知らせない」 側がずれる。
1222
1725
  */
1223
- const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
1224
- "kind", "subtitle", "eyebrow", "value", "rows", "lane", "stack",
1225
- "initial", "final", "tone", "nodes",
1226
- "posX", "posY", "posW", "posH",
1726
+ /**
1727
+ * 中括弧の形で読める日本語と、その英語名 (#1301)。
1728
+ *
1729
+ * **英語が中括弧で読める欄だけを載せる**。 `位置` / `大きさ` / `色` は英語側
1730
+ * (`pos` / `size` / `color`) も中括弧では読めないため載せない = 英語で出来ないことを
1731
+ * 日本語で出来るようにはしない。
1732
+ *
1733
+ * 載せる前は、同じ意味の語が縦書きでは通り中括弧では「項目名が読めません」 になっていた。
1734
+ * 書き方によって日本語だけが落ちる状態を無くす。
1735
+ */
1736
+ export const INLINE_ACTOR_ALIASES: Record<string, string> = {
1737
+ 種類: "kind",
1738
+ 補足: "subtitle",
1739
+ 値: "value",
1740
+ 行: "rows",
1741
+ };
1742
+
1743
+ /** 中括弧に書かれた日本語の項目名を、同じ意味の英語名に寄せる (#1301) */
1744
+ function 中括弧の別名を寄せる(opts: Record<string, string>): Record<string, string> {
1745
+ let 触った = false;
1746
+ const out: Record<string, string> = { ...opts };
1747
+ for (const [日, 英] of Object.entries(INLINE_ACTOR_ALIASES)) {
1748
+ if (!(日 in out)) continue;
1749
+ 触った = true;
1750
+ // 英語を併記した時は英語を優先する (縦書き形が後勝ちなのと違い、こちらは 1 行に同居する)
1751
+ if (!(英 in out)) out[英] = out[日]!;
1752
+ delete out[日];
1753
+ }
1754
+ return 触った ? out : opts;
1755
+ }
1756
+
1757
+ export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
1758
+ "kind",
1759
+ "subtitle",
1760
+ "eyebrow",
1761
+ "value",
1762
+ "rows",
1763
+ "lane",
1764
+ "stack",
1765
+ "initial",
1766
+ "final",
1767
+ "tone",
1768
+ "nodes",
1769
+ // 体験の道筋の欄 (#1251)。 他の図種では組み立て側が知らせる
1770
+ "touchpoint",
1771
+ "opportunity",
1772
+ // 工程の並びの欄 (#1251)
1773
+ "owner",
1774
+ "end",
1775
+ "posX",
1776
+ "posY",
1777
+ "posW",
1778
+ "posH",
1227
1779
  // 倍率は別経路 (`reportScaleOnNonPart`) が知らせる。 ここでも読める扱いにしないと
1228
1780
  // 同じ名前で 2 度知らせることになる
1229
- "scale", "倍率",
1781
+ "scale",
1782
+ "倍率",
1783
+ // 英語が読める欄の日本語別名 (#1301)。 一覧は `INLINE_ACTOR_ALIASES` が持つ
1784
+ ...Object.keys(INLINE_ACTOR_ALIASES),
1230
1785
  ]);
1231
1786
  // `state` はパーツでだけ意味を持つ (`extractStateOverride` がパーツの時しか作らない)。
1232
1787
  // 通常の箱で読める扱いにすると `- A: { state: { foo: 1 } }` が黙って消え、 本 file が塞ごうと
1233
1788
  // している経路が予約語で残る (Round 1 review の指摘、 実測で確認)。 パーツ側は `isPart` の
1234
1789
  // 早期 return が先に効くのでここに載せる必要が無い
1235
1790
 
1791
+ /**
1792
+ * 矢印の中括弧に書ける欄と、その読み方 (#1275)。
1793
+ *
1794
+ * **parser がこの表を回して読む**。 欄ごとに `opts.xxx` を並べる形だと、README や検査が
1795
+ * 持つ一覧が実装と drift する = 欄を足しても誰も気付けない。 表を唯一の出どころにして、
1796
+ * `FLOW_INLINE_KEYS` から一覧を導けるようにする。
1797
+ */
1798
+ const FLOW_INLINE_READERS = {
1799
+ sub: (v: string | undefined) => v,
1800
+ guard: (v: string | undefined) => v,
1801
+ cardinality: (v: string | undefined) => v,
1802
+ // 数と真偽の欄は `FLOW_INLINE_VALUE_KINDS` の表が読む (#1306)。 ここでは名前だけを持つ =
1803
+ // 読める欄の一覧 (`FLOW_INLINE_KEYS`) は本表から導くため、載せないと欄ごと消える
1804
+ labelOffsetX: null,
1805
+ labelOffsetY: null,
1806
+ overlay: null,
1807
+ } as const;
1808
+
1809
+ /** 矢印の中括弧に書ける欄の名前。 README の一覧と突き合わせる (#1275) */
1810
+ export const FLOW_INLINE_KEYS = Object.keys(
1811
+ FLOW_INLINE_READERS,
1812
+ ) as readonly (keyof typeof FLOW_INLINE_READERS)[];
1813
+
1236
1814
  /**
1237
1815
  * 中括弧に書かれた読めない項目名を知らせる (#1090)。
1238
1816
  *
@@ -1284,18 +1862,28 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1284
1862
  if (mapMatch) {
1285
1863
  const namePart = stripQuotes(mapMatch.name.trim());
1286
1864
  if (!namePart) return null;
1287
- const opts = parseInlineMapping(mapMatch.inner);
1865
+ // 日本語の項目名を英語名に寄せてから読む (#1301)。 寄せないと、同じ意味の語が
1866
+ // 縦書きでは通り中括弧では落ちる
1867
+ const opts = 中括弧の別名を寄せる(parseInlineMapping(mapMatch.inner));
1288
1868
  const kindRaw = (opts.kind ?? "").toLowerCase();
1289
1869
  // CAR-1657 = kind が既存 NODE_KIND_VALID に無い場合 parts identifier 候補として partId に格納、
1290
1870
  // kind は actor default fallback。 compile 側 partsCatalog lookup で解決する。
1291
1871
  const isPart = kindRaw !== "" && !NODE_KIND_VALID.has(kindRaw);
1292
1872
  // 倍率はパーツにしか効かない。 書いたのに効かない状態を黙って作らない (#1026)。
1293
1873
  // 値が空の形でも名前を残すため、`opts` ではなく中身から直接拾う
1294
- const inlineScale = resolveScale(writtenScaleFields(mapMatch.inner));
1874
+ const inlineScale = resolveScale(writtenScaleFields(mapMatch.inner), line.no, errors);
1295
1875
  reportScaleOnNonPart(isPart, inlineScale.keys[0], line.no, errors);
1296
1876
  // 中括弧に書いた読めない項目名も知らせる (#1090)。 縦に並べた形だけが知らせていた
1297
1877
  reportUnknownInlineKeys(isPart, mapMatch.inner, line.no, errors);
1298
- const kind = isPart ? NODE_KIND_DEFAULT : resolveKind(NODE_KIND_VALID.has(kindRaw) ? kindRaw : "");
1878
+ reportV04Kind(kindRaw, line.no, errors);
1879
+ // 中括弧に書いた読めない色名も知らせる (#1304)。 パーツでは `tone` が状態の上書きとして
1880
+ // 意味を持つため対象外 = 色として読もうとしない値を色として叱らない
1881
+ if (!isPart && opts.tone !== undefined && resolveTone(opts.tone) === undefined) {
1882
+ report読めない色(opts.tone, line.no, errors, { 線種も受ける: false });
1883
+ }
1884
+ const kind = isPart
1885
+ ? NODE_KIND_DEFAULT
1886
+ : resolveNodeKind(NODE_KIND_VALID.has(kindRaw) ? kindRaw : "");
1299
1887
  return {
1300
1888
  name: namePart,
1301
1889
  kind,
@@ -1304,6 +1892,12 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1304
1892
  kindWritten: kindRaw !== "" && !isPart,
1305
1893
  subtitle: opts.subtitle,
1306
1894
  eyebrow: opts.eyebrow,
1895
+ // パーツでは状態の上書きとして意味を持つため、道筋の欄として横取りしない (#1251)
1896
+ touchpoint: isPart ? undefined : opts.touchpoint,
1897
+ opportunity: isPart ? undefined : opts.opportunity,
1898
+ // 見本では状態の上書きとして意味を持つため横取りしない (#1251)
1899
+ owner: isPart ? undefined : opts.owner,
1900
+ end: isPart ? undefined : opts.end,
1307
1901
  value: opts.value,
1308
1902
  rows: opts.rows
1309
1903
  ? opts.rows
@@ -1313,23 +1907,17 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1313
1907
  .filter(Boolean)
1314
1908
  : undefined,
1315
1909
  lane: opts.lane,
1316
- stack: numberOrUndef(opts.stack),
1317
- initial: boolOrUndef(opts.initial),
1318
- final: boolOrUndef(opts.final),
1910
+ ...表で読む(ACTOR_INLINE_VALUE_KINDS, opts, "箱の ", line.no, errors),
1319
1911
  // parts では `tone` を状態の上書きとして従来から使えるため、 色として横取りしない
1320
- tone: isPart ? undefined : toneOrUndef(opts.tone),
1912
+ tone: isPart ? undefined : resolveTone(opts.tone),
1321
1913
  partId: isPart ? kindRaw : undefined,
1322
1914
  stateOverride: isPart ? extractStateOverride(opts) : undefined,
1323
- // canvas pivot 新 spec = 絶対座標 field actor に格納、 compile 経由で CDL に受け渡す
1324
- posX: numberOrUndef(opts.posX),
1325
- posY: numberOrUndef(opts.posY),
1326
- posW: numberOrUndef(opts.posW),
1327
- posH: numberOrUndef(opts.posH),
1915
+ // canvas pivot 新 spec = 絶対座標 field `ACTOR_INLINE_VALUE_KINDS` の表が読む (#1306)
1328
1916
  // 図形の倍率 (#1026)。 どれが効くかは `resolveScale` が 1 箇所で決める
1329
1917
  scale: inlineScale.scale,
1330
1918
  scaleKeys: inlineScale.keys.length ? inlineScale.keys : undefined,
1331
1919
  // canvas pivot UX 修正 (B1) = sub-node 単位 override map (`nodes: { header: {posX:..., ...}, ...}`)
1332
- nodes: parseActorNodesField(opts.nodes),
1920
+ nodes: parseActorNodesField(opts.nodes, line.no, errors),
1333
1921
  pos: { line: line.no },
1334
1922
  };
1335
1923
  }
@@ -1344,13 +1932,16 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1344
1932
  //
1345
1933
  // 値は形で見分ける。 引用符付きは補足、 角括弧は行、 色名は色、 残りが種類。
1346
1934
  // 種類と色は語の集合が閉じているので取り違えない。
1347
- const v = classifyValues(splitValues(rest));
1935
+ const v = classifyValues(splitValues(rest), line.no, errors);
1348
1936
 
1349
1937
  // CAR-1657 = short form (`arc1: arc-gauge`) でも parts kind 対応、 未知 kind は partId 経路
1350
1938
  const isPart = v.kind !== "" && !NODE_KIND_VALID.has(v.kind);
1939
+ reportV04Kind(v.kind, line.no, errors);
1351
1940
  // 倍率はパーツにしか効かない (#1026)
1352
1941
  reportScaleOnNonPart(isPart, v.scaleKeys?.[0], line.no, errors);
1353
- const kind = isPart ? NODE_KIND_DEFAULT : resolveKind(NODE_KIND_VALID.has(v.kind) ? v.kind : "");
1942
+ const kind = isPart
1943
+ ? NODE_KIND_DEFAULT
1944
+ : resolveNodeKind(NODE_KIND_VALID.has(v.kind) ? v.kind : "");
1354
1945
  return {
1355
1946
  name: namePart,
1356
1947
  kind,
@@ -1376,12 +1967,12 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1376
1967
  return { name: namePart, kind: NODE_KIND_DEFAULT, kindWritten: false, pos: { line: line.no } };
1377
1968
  }
1378
1969
 
1379
- function parseFlowStep(line: Line, no: number): DslStep | null {
1970
+ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | null {
1380
1971
  // 形式 (順序自由、 部分省略可):
1381
1972
  // 1. `Client -> API` ... label / option なし
1382
1973
  // 2. `Client -> API: "deposit"` ... label
1383
1974
  // 3. `Client -> API: "deposit" (success)` ... label + tone tuple
1384
- // 4. `Client -> API: "deposit" { sub: "...", guard: "...", cardinality: "1:N", labelOffsetY: -8 }` ... inline option
1975
+ // 4. `Client -> API: "deposit" { sub: "...", guard: "...", cardinality: "1:N", labelOffsetY: -8, overlay: true }` ... inline option
1385
1976
  // 5. `Client -> API: "deposit" (success) { guard: "..." }` ... 両方
1386
1977
  const raw = line.trimmed;
1387
1978
  const arrowIdx = raw.indexOf("->");
@@ -1391,22 +1982,27 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1391
1982
  let label = "";
1392
1983
  let tone: Tone | undefined;
1393
1984
  let style: EdgeStyle | undefined;
1394
- let sub: string | undefined;
1395
- let guard: string | undefined;
1396
- let cardinality: string | undefined;
1397
- let labelOffsetX: number | undefined;
1398
- let labelOffsetY: number | undefined;
1985
+ // 欄は `FLOW_INLINE_READERS` の表から読む。 個別に並べると一覧が実装と drift する
1986
+ const 中括弧: Partial<Record<keyof typeof FLOW_INLINE_READERS, unknown>> = {};
1399
1987
  // inline option (`{ ... }`) を末尾から抽出
1400
1988
  const mapMatch = rest.match(/\s*\{([^}]*)\}\s*$/);
1989
+ let 数と真偽: 読んだ結果<typeof FLOW_INLINE_VALUE_KINDS> | undefined;
1401
1990
  if (mapMatch) {
1402
1991
  const opts = parseInlineMapping(mapMatch[1]!);
1403
- sub = opts.sub;
1404
- guard = opts.guard;
1405
- cardinality = opts.cardinality;
1406
- labelOffsetX = numberOrUndef(opts.labelOffsetX);
1407
- labelOffsetY = numberOrUndef(opts.labelOffsetY);
1992
+ // 文字列の欄はそのまま入れ、数と真偽の欄は表が読んで読めない値を知らせる (#1306)
1993
+ for (const k of FLOW_INLINE_KEYS) {
1994
+ if (FLOW_INLINE_READERS[k] === null) continue;
1995
+ 中括弧[k] = opts[k];
1996
+ }
1997
+ 数と真偽 = 表で読む(FLOW_INLINE_VALUE_KINDS, opts, "矢印の ", line.no, errors);
1408
1998
  rest = rest.slice(0, mapMatch.index ?? 0).trim();
1409
1999
  }
2000
+ const sub = 中括弧.sub as string | undefined;
2001
+ const guard = 中括弧.guard as string | undefined;
2002
+ const cardinality = 中括弧.cardinality as string | undefined;
2003
+ const labelOffsetX = 数と真偽?.labelOffsetX;
2004
+ const labelOffsetY = 数と真偽?.labelOffsetY;
2005
+ const overlay = 数と真偽?.overlay;
1410
2006
  // 色と線種を末尾から取る。 括弧 (`(成功)`) と空白区切り (`成功`) の両方を受け付ける。
1411
2007
  //
1412
2008
  // 括弧は従来の書き方で、 catalog が使っている。 空白区切りは登場人物と揃えた形。
@@ -1414,9 +2010,13 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1414
2010
  if (optMatch) {
1415
2011
  const opts = (optMatch[1] ?? "").split(",").map((s) => s.trim());
1416
2012
  for (const opt of opts) {
1417
- const resolvedTone = toneOrUndef(opt);
2013
+ const resolvedTone = resolveTone(opt);
1418
2014
  if (resolvedTone !== undefined) tone = resolvedTone;
1419
2015
  else if (STYLE_VALID.has(opt.toLowerCase())) style = opt.toLowerCase() as EdgeStyle;
2016
+ // 丸括弧に書けるのは色名と線種だけ。 読めない語を黙って捨てると、 書いた人には
2017
+ // 「書いたのに色が変わらない」 としか見えない (#1304)。 空の語 (`( )` / `(a,,b)`) は
2018
+ // 書き間違いというより余分な区切りなので知らせない
2019
+ else if (opt !== "") report読めない色(opt, line.no, errors, { 線種も受ける: true });
1420
2020
  }
1421
2021
  rest = rest.slice(0, optMatch.index ?? 0).trim();
1422
2022
  } else {
@@ -1427,9 +2027,17 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1427
2027
  const last = words[words.length - 1]!;
1428
2028
  // 引用符付きは説明文なので取らない
1429
2029
  if (last.startsWith('"') || last.startsWith("'")) break;
1430
- const resolvedTone = toneOrUndef(last);
1431
- if (resolvedTone !== undefined) { tone = resolvedTone; words.pop(); continue; }
1432
- if (STYLE_VALID.has(last.toLowerCase())) { style = last.toLowerCase() as EdgeStyle; words.pop(); continue; }
2030
+ const resolvedTone = resolveTone(last);
2031
+ if (resolvedTone !== undefined) {
2032
+ tone = resolvedTone;
2033
+ words.pop();
2034
+ continue;
2035
+ }
2036
+ if (STYLE_VALID.has(last.toLowerCase())) {
2037
+ style = last.toLowerCase() as EdgeStyle;
2038
+ words.pop();
2039
+ continue;
2040
+ }
1433
2041
  break;
1434
2042
  }
1435
2043
  rest = words.join(" ");
@@ -1453,25 +2061,112 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1453
2061
  cardinality,
1454
2062
  labelOffsetX,
1455
2063
  labelOffsetY,
2064
+ overlay,
1456
2065
  pos: { line: line.no },
1457
2066
  };
1458
2067
  }
1459
2068
 
1460
2069
  function parseStateEntry(text: string, lineNo: number): DslState | null {
1461
2070
  // `client_bal: 100` / `status: "idle"`
1462
- const m = text.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
2071
+ const m = text.match(/^([^:]+?)\s*:\s*(.+)$/);
1463
2072
  if (!m) return null;
1464
- const name = m[1] ?? "";
2073
+ const name = (m[1] ?? "").trim();
2074
+ if (!isValueName(name)) return null;
1465
2075
  const raw = (m[2] ?? "").trim();
1466
2076
  const stripped = stripQuotes(raw);
1467
2077
  const asNum = Number(stripped);
1468
- const initial: number | string = Number.isFinite(asNum) && stripped !== "" && !isNaN(asNum) ? asNum : stripped;
2078
+ const initial: number | string =
2079
+ Number.isFinite(asNum) && stripped !== "" && !isNaN(asNum) ? asNum : stripped;
1469
2080
  return { name, initial, pos: { line: lineNo } };
1470
2081
  }
1471
2082
 
2083
+ /**
2084
+ * 字下げした行を 1 行も落とさずに集める。
2085
+ *
2086
+ * `collectIndentedList` は形が合わない行を黙って捨てるが、 `values` では捨てずに
2087
+ * 読み手 (`parseValueEntry`) へ渡して書き間違いとして報告させる。
2088
+ */
2089
+ function collectIndentedRaw(
2090
+ lines: Line[],
2091
+ start: number,
2092
+ parentIndent: number,
2093
+ ): { items: Line[]; next: number } {
2094
+ const items: Line[] = [];
2095
+ let i = start;
2096
+ while (i < lines.length) {
2097
+ const ln = lines[i];
2098
+ if (!ln || !ln.trimmed || ln.trimmed.startsWith("#")) {
2099
+ i += 1;
2100
+ continue;
2101
+ }
2102
+ if (ln.indent <= parentIndent) break;
2103
+ items.push(ln);
2104
+ i += 1;
2105
+ }
2106
+ return { items, next: i };
2107
+ }
2108
+
2109
+ /**
2110
+ * `waiting: "{inflow} - {done}"` を 1 件の値として読む。
2111
+ *
2112
+ * 名前と式の判定は `value-syntax.ts` が持つ (#1181)。 JSON 経路も同じ判定を使うため、
2113
+ * ここでは行番号を付けて報告する形にだけ責任を持つ。
2114
+ */
2115
+ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslValue | null {
2116
+ const m = text.match(/^([^:]+?)\s*:\s*(.+)$/);
2117
+ if (!m) {
2118
+ errors.push({
2119
+ line: lineNo,
2120
+ message: `invalid value entry: "${text}"`,
2121
+ hint: '`waiting: "{inflow} - {done}"` の形で書く',
2122
+ });
2123
+ return null;
2124
+ }
2125
+ const name = (m[1] ?? "").trim();
2126
+ if (!isValueName(name)) {
2127
+ errors.push({ line: lineNo, ...valueNameIssue(name) });
2128
+ return null;
2129
+ }
2130
+ const rest = (m[2] ?? "").trim();
2131
+ // きっかけ形 (`{ trigger: ..., to: ..., dur: ... }`) を先に見る。 式として読むと中括弧の中身が
2132
+ // 値の名前として検査され、 「trigger は名前に使えない」 のような直し方の伝わらない誤りになる
2133
+ if (isTriggerBody(rest)) {
2134
+ const { spec, issues } = parseValueTriggerBody(rest.slice(1, -1), name);
2135
+ if (spec === null) {
2136
+ for (const issue of issues) errors.push({ line: lineNo, ...issue });
2137
+ return null;
2138
+ }
2139
+ return {
2140
+ name,
2141
+ trigger: spec.trigger,
2142
+ to: spec.to,
2143
+ durationMs: spec.durationMs,
2144
+ pos: { line: lineNo },
2145
+ };
2146
+ }
2147
+ const expression = stripQuotes(rest);
2148
+ if (expression === "") {
2149
+ errors.push({
2150
+ line: lineNo,
2151
+ message: `empty expression for "${name}"`,
2152
+ hint: '`"{a} + {b}"` のように式を書く',
2153
+ });
2154
+ return null;
2155
+ }
2156
+ const issues = checkValueExpression(expression, name);
2157
+ if (issues.length > 0) {
2158
+ for (const issue of issues) errors.push({ line: lineNo, ...issue });
2159
+ return null;
2160
+ }
2161
+ return { name, expression, pos: { line: lineNo } };
2162
+ }
2163
+
1472
2164
  function splitTopLevelCommas(s: string): string[] {
1473
2165
  // brace 内を考慮 ... 今回は単純 split (動作する範囲)
1474
- return s.split(",").map((x) => x.trim()).filter(Boolean);
2166
+ return s
2167
+ .split(",")
2168
+ .map((x) => x.trim())
2169
+ .filter(Boolean);
1475
2170
  }
1476
2171
 
1477
2172
  function ensureAnimate(a: DslAnimate | undefined, lineNo: number): DslAnimate {
@@ -1484,14 +2179,22 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1484
2179
  const head = block[0]!;
1485
2180
  const m = head.trimmed.match(/^step\s*:\s*(.+)$/);
1486
2181
  if (!m) {
1487
- errors.push({ line: head.no, message: `invalid step header: "${head.trimmed}"`, hint: 'use `- step: "name" 1.5s`' });
2182
+ errors.push({
2183
+ line: head.no,
2184
+ message: `invalid step header: "${head.trimmed}"`,
2185
+ hint: 'use `- step: "name" 1.5s`',
2186
+ });
1488
2187
  return null;
1489
2188
  }
1490
2189
  const headRest = (m[1] ?? "").trim();
1491
2190
  // `"request" 1.5s` 形式 ... quote 後の duration 抽出
1492
2191
  const headParse = parseStepHead(headRest);
1493
2192
  if (!headParse) {
1494
- errors.push({ line: head.no, message: `invalid step value: "${headRest}"`, hint: 'use `"name" 1.5s` (duration in s)' });
2193
+ errors.push({
2194
+ line: head.no,
2195
+ message: `invalid step value: "${headRest}"`,
2196
+ hint: 'use `"name" 1.5s` (duration in s)',
2197
+ });
1495
2198
  return null;
1496
2199
  }
1497
2200
  const phase: DslPhase = {
@@ -1507,7 +2210,10 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1507
2210
  while (i < block.length) {
1508
2211
  const ln = block[i]!;
1509
2212
  const t = ln.trimmed;
1510
- const propMatch = t.match(/^([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
2213
+ // **英数字以外の項目名も拾う** (#1301)。 以前は `[a-zA-Z]` で始まる名前しか見ておらず、
2214
+ // 日本語の項目名 (`強調` / `説明` 等) は match そのものが外れて **黙って捨てられていた**。
2215
+ // 拾った上で、知らない名前は下で誤りとして知らせる
2216
+ const propMatch = t.match(/^([^\s:]+)\s*:\s*(.*)$/);
1511
2217
  if (!propMatch) {
1512
2218
  i += 1;
1513
2219
  continue;
@@ -1524,6 +2230,23 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1524
2230
  i += 1;
1525
2231
  continue;
1526
2232
  }
2233
+ if (key === "draw") {
2234
+ const 語 = stripQuotes(value).trim();
2235
+ // **読めない語を黙って捨てない** (#1304 / #1306 と同じ扱い)。 受ける語は 1 つだけで、
2236
+ // 書き間違いはその段が何も描かない形になって手掛かりが残らない
2237
+ if (!DRAW_WORDS.has(語)) {
2238
+ errors.push({
2239
+ line: ln.no,
2240
+ message: `draw に書けない語です: "${語}"`,
2241
+ hint: `使える語 = ${[...DRAW_WORDS].join(", ")}`,
2242
+ });
2243
+ } else {
2244
+ phase.draw = 語;
2245
+ phase.drawPos = { line: ln.no };
2246
+ }
2247
+ i += 1;
2248
+ continue;
2249
+ }
1527
2250
  if (key === "description" || key === "body") {
1528
2251
  phase.body = stripQuotes(value);
1529
2252
  i += 1;
@@ -1534,7 +2257,12 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1534
2257
  if (value) {
1535
2258
  const tw = parseTweenLine(value, ln.no);
1536
2259
  if (tw) phase.tweens!.push(tw);
1537
- else errors.push({ line: ln.no, message: `invalid tween: "${value}"`, hint: "use `tween: name 100 -> 90`" });
2260
+ else
2261
+ errors.push({
2262
+ line: ln.no,
2263
+ message: `invalid tween: "${value}"`,
2264
+ hint: "use `tween: name 100 -> 90`",
2265
+ });
1538
2266
  i += 1;
1539
2267
  continue;
1540
2268
  }
@@ -1546,7 +2274,12 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1546
2274
  if (nx.indent <= baseIndent) break;
1547
2275
  const tw = parseTweenLine(nx.trimmed, nx.no);
1548
2276
  if (tw) phase.tweens!.push(tw);
1549
- else errors.push({ line: nx.no, message: `invalid tween entry: "${nx.trimmed}"`, hint: "use `name: 100 -> 90`" });
2277
+ else
2278
+ errors.push({
2279
+ line: nx.no,
2280
+ message: `invalid tween entry: "${nx.trimmed}"`,
2281
+ hint: "use `name: 100 -> 90`",
2282
+ });
1550
2283
  j += 1;
1551
2284
  }
1552
2285
  i = j;
@@ -1571,11 +2304,81 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1571
2304
  i = j;
1572
2305
  continue;
1573
2306
  }
2307
+ // ここに来るのは上のどれにも当たらなかった名前 (#1301)。 黙って捨てると
2308
+ // 「書いたのに段が変わらない」 が手掛かりなしで起きる
2309
+ errors.push({
2310
+ line: ln.no,
2311
+ message: `段の項目名が読めません: "${propMatch[1] ?? ""}"`,
2312
+ hint: 段の項目のヒント(propMatch[1] ?? ""),
2313
+ });
1574
2314
  i += 1;
1575
2315
  }
1576
2316
  return phase;
1577
2317
  }
1578
2318
 
2319
+ /**
2320
+ * 段の項目名が読めない時のヒント (#1301)。
2321
+ *
2322
+ * 日本語の名前は v0.4 の記法では使えたため、**同じ意味の英語を勧める**。
2323
+ * 「使えません」 だけだと、書いた人は代わりに何を書けばよいか分からない。
2324
+ */
2325
+ function 段の項目のヒント(書いた名前: string): string {
2326
+ const 英語 = 段の項目の日本語[書いた名前];
2327
+ return 英語 !== undefined
2328
+ ? `v0.5 では英語で書く (\`${英語}\`)`
2329
+ : `使える項目 = ${段の項目の英語.join(", ")}`;
2330
+ }
2331
+
2332
+ /** 段に書ける項目の英語名。 `parsePhase` の分岐から導く一覧 */
2333
+ const 段の項目の英語 = ["focus", "badge", "body", "description", "tween", "set", "draw"] as const;
2334
+
2335
+ /**
2336
+ * `draw:` に書ける語と、その語が効く図種 (#1312 / #1314)。
2337
+ *
2338
+ * **語と図種の対応をここ 1 箇所で持つ**。 受ける語の一覧 (`DRAW_WORDS`) も、組み立て側が見る
2339
+ * 図種の一覧 (`DRAWABLE_DOC_TYPES`) も、この表から導く。 3 つを別々に並べると、語を足した時に
2340
+ * どれかが古いまま残る (#1310 / #1304 で 3 度直した形)。
2341
+ *
2342
+ * | 語 | 図種 | 起点 |
2343
+ * |---|---|---|
2344
+ * | `line` | `line` | 左端から右へ線が伸びる |
2345
+ * | `bar` | `bar` | 横軸から上へ棒が伸びる |
2346
+ * | `pie` | `pie` | 12 時から時計回りに扇が開く |
2347
+ *
2348
+ * いまは語と図種が同じ綴りだが、**同じものとして扱わない**。 語は書き手が書く名前で、
2349
+ * 図種は `type:` が取る値。 片方だけ別名を足したくなった時に、対応が表に残っている形にする。
2350
+ */
2351
+ export const DRAW_TARGETS: ReadonlyMap<string, PresetType> = new Map<string, PresetType>([
2352
+ ["line", "line"],
2353
+ ["bar", "bar"],
2354
+ ["pie", "pie"],
2355
+ ]);
2356
+
2357
+ /** `draw:` に書ける語。 表から導く (#1314) */
2358
+ export const DRAW_WORDS: ReadonlySet<string> = new Set(DRAW_TARGETS.keys());
2359
+
2360
+ /**
2361
+ * v0.4 で使えた段の項目名と、v0.5 での書き方 (#1301)。
2362
+ *
2363
+ * **`ANIM_SUBKEYS` からは導けない**。 あの表は v0.4 の日本語と v0.4 の英語を組にしており、
2364
+ * v0.5 が使う名前とは一致しない (`強調` の相手は v0.4 では `highlight`、v0.5 では `focus`)。
2365
+ * 2 つの記法の間の翻訳なので、対応は手で書く。
2366
+ *
2367
+ * 表に語が増えた時に取り残されないよう、`ANIM_SUBKEYS` の日本語を全て覆っていることを
2368
+ * 検査が確かめる (`v05-japanese-scope.test.ts`)。
2369
+ */
2370
+ export const 段の項目の日本語: Record<string, string> = {
2371
+ 強調: "focus",
2372
+ 説明: "body",
2373
+ バッジ: "badge",
2374
+ 遷移: "tween",
2375
+ 切替: "set",
2376
+ // 状態は段の中ではなく最上位に書く (`states:`)
2377
+ 状態: "states (最上位に書く)",
2378
+ // 段そのものの名前
2379
+ ステップ: "step",
2380
+ };
2381
+
1579
2382
  function parseStepHead(s: string): { name: string; durationMs: number } | null {
1580
2383
  // 例: `"request" 1.5s` / `"step1" 1500ms` / `step1 2s`
1581
2384
  let rest = s.trim();
@@ -1605,49 +2408,73 @@ function parseFocusList(s: string): string[] {
1605
2408
  // quote 内の space / comma / arrow は保護し、 quote 外の comma でのみ split する。
1606
2409
  let body = s.trim();
1607
2410
  if (body.startsWith("[") && body.endsWith("]")) body = body.slice(1, -1);
1608
- const parts: string[] = [];
2411
+ // **引用部分を非引用部分と分けて覚えておく** (#1192)。 区間全体に quoted flag を
2412
+ // 付けるだけだと `Client "Aave v3"` まで 1 item になり、従来の空白区切りと混在できない。
2413
+ type FocusFragment = { text: string; quoted: boolean };
2414
+ const groups: FocusFragment[][] = [];
2415
+ let group: FocusFragment[] = [];
1609
2416
  let buf = "";
1610
2417
  let quote: string | null = null;
2418
+ const pushFragment = (quoted: boolean): void => {
2419
+ if (buf.trim()) group.push({ text: buf, quoted });
2420
+ buf = "";
2421
+ };
2422
+ const pushGroup = (): void => {
2423
+ pushFragment(false);
2424
+ if (group.length > 0) groups.push(group);
2425
+ group = [];
2426
+ };
1611
2427
  for (const ch of body) {
1612
2428
  if (quote) {
1613
2429
  if (ch === quote) {
2430
+ pushFragment(true);
1614
2431
  quote = null;
1615
2432
  continue;
1616
2433
  }
1617
2434
  buf += ch;
1618
2435
  continue;
1619
2436
  }
1620
- if (ch === "\"" || ch === "'") {
1621
- quote = ch;
1622
- continue;
2437
+ if (ch === '"' || ch === "'") {
2438
+ // item の途中にある引用符は名前の一部。 空白または区切りの直後だけ囲みを開始する。
2439
+ if (!buf || /\s$/.test(buf)) {
2440
+ pushFragment(false);
2441
+ quote = ch;
2442
+ continue;
2443
+ }
1623
2444
  }
1624
2445
  if (ch === ",") {
1625
- const t = buf.trim();
1626
- if (t) parts.push(t);
1627
- buf = "";
2446
+ pushGroup();
1628
2447
  continue;
1629
2448
  }
1630
2449
  buf += ch;
1631
2450
  }
1632
- const tail = buf.trim();
1633
- if (tail) parts.push(tail);
1634
- // "User -> API" のような quote 済 item は「1 item」 として parts に入る。
2451
+ pushFragment(quote !== null);
2452
+ if (group.length > 0) groups.push(group);
2453
+ // "User -> API" のような quote 済 item は「1 item」 として groups に入る。
1635
2454
  // quote 外 item は依然として space split (旧挙動、 「Client API」 が 2 item として解釈される互換維持)。
1636
2455
  const out: string[] = [];
1637
- for (const p of parts) {
1638
- if (/[-→][>]?/.test(p) && /\s/.test(p)) {
1639
- // arrow を含む item は「A -> B」 パターン、 分割せず 1 item として保持
1640
- out.push(p);
2456
+ for (const fragments of groups) {
2457
+ const whole = fragments
2458
+ .map(({ text }) => text)
2459
+ .join("")
2460
+ .trim();
2461
+ if (fragments.every(({ quoted }) => !quoted) && /[-→][>]?/.test(whole) && /\s/.test(whole)) {
2462
+ // arrow を含む非引用区間は「A -> B」パターン。 空白で分割しない。
2463
+ out.push(whole);
1641
2464
  continue;
1642
2465
  }
1643
- if (/\s/.test(p)) {
1644
- // space 含み + arrow なし = 旧挙動の「Client API」 → 2 item
1645
- for (const x of p.split(/\s+/)) {
1646
- if (x) out.push(x);
2466
+ for (const { text, quoted } of fragments) {
2467
+ const p = text.trim();
2468
+ if (!p) continue;
2469
+ // 引用符で囲んだ item は空白があっても切らず、非引用部分だけを従来どおり空白で切る。
2470
+ if (quoted) {
2471
+ out.push(p);
2472
+ } else {
2473
+ for (const x of p.split(/\s+/)) {
2474
+ if (x) out.push(x);
2475
+ }
1647
2476
  }
1648
- continue;
1649
2477
  }
1650
- out.push(p);
1651
2478
  }
1652
2479
  return out;
1653
2480
  }
@@ -1655,10 +2482,12 @@ function parseFocusList(s: string): string[] {
1655
2482
  function parseTweenLine(s: string, lineNo: number): DslTween | null {
1656
2483
  // `client_bal 100 -> 90` / `client_bal: 100 -> 90`
1657
2484
  const cleaned = s.replace(/^-\s*/, "").trim();
1658
- const m = cleaned.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*[:\s]\s*(-?\d+(?:\.\d+)?)\s*->\s*(-?\d+(?:\.\d+)?)$/);
2485
+ const m = cleaned.match(/^([^:\s]+)\s*[:\s]\s*(-?\d+(?:\.\d+)?)\s*->\s*(-?\d+(?:\.\d+)?)$/);
1659
2486
  if (!m) return null;
2487
+ const state = m[1] ?? "";
2488
+ if (!isValueName(state)) return null;
1660
2489
  return {
1661
- state: m[1] ?? "",
2490
+ state,
1662
2491
  from: parseFloat(m[2] ?? "0"),
1663
2492
  to: parseFloat(m[3] ?? "0"),
1664
2493
  pos: { line: lineNo },
@@ -1668,11 +2497,14 @@ function parseTweenLine(s: string, lineNo: number): DslTween | null {
1668
2497
  function parseSetLine(s: string, lineNo: number): DslSet | null {
1669
2498
  // `status: "loading"` / `status loading`
1670
2499
  const cleaned = s.replace(/^-\s*/, "").trim();
1671
- const m = cleaned.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*[:\s]\s*(.+)$/);
2500
+ const m = cleaned.match(/^([^:\s]+)\s*[:\s]\s*(.+)$/);
1672
2501
  if (!m) return null;
2502
+ const state = m[1] ?? "";
2503
+ if (!isValueName(state)) return null;
1673
2504
  const raw = (m[2] ?? "").trim();
1674
2505
  const stripped = stripQuotes(raw);
1675
2506
  const asNum = Number(stripped);
1676
- const value: number | string = Number.isFinite(asNum) && stripped !== "" && !isNaN(asNum) ? asNum : stripped;
1677
- return { state: m[1] ?? "", value, pos: { line: lineNo } };
2507
+ const value: number | string =
2508
+ Number.isFinite(asNum) && stripped !== "" && !isNaN(asNum) ? asNum : stripped;
2509
+ return { state, value, pos: { line: lineNo } };
1678
2510
  }