@cardenelabs/dragon 0.8.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,7 +43,7 @@
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
48
  import {
49
49
  checkValueExpression,
@@ -71,9 +71,7 @@ import type {
71
71
  DslViewport,
72
72
  } from "../types";
73
73
 
74
- export type V05ParseResult =
75
- | { ok: true; doc: DslDocument }
76
- | { ok: false; errors: DslError[] };
74
+ export type V05ParseResult = { ok: true; doc: DslDocument } | { ok: false; errors: DslError[] };
77
75
 
78
76
  /**
79
77
  * 記法が受ける top-level の項目 (#1190)。
@@ -83,7 +81,16 @@ export type V05ParseResult =
83
81
  * 一覧に 1 件も無い状態で放置されていた)。
84
82
  */
85
83
  export const TOP_LEVEL_KEYS = [
86
- "title", "type", "actors", "flow", "states", "values", "animation", "viewport", "lanes", "groups",
84
+ "title",
85
+ "type",
86
+ "actors",
87
+ "flow",
88
+ "states",
89
+ "values",
90
+ "animation",
91
+ "viewport",
92
+ "lanes",
93
+ "groups",
87
94
  // 図全体を 1 箱にする図種で、 その箱の上に出す小見出し (#1247)
88
95
  "eyebrow",
89
96
  // 2 軸で仕分ける図の軸の名前 (#1251)
@@ -145,8 +152,14 @@ const NODE_KIND_DEFAULT: NodeKind = "actor";
145
152
  * 種類に置き換わるため、 そのまま描画側に渡ることはない。
146
153
  */
147
154
  const DSL_ONLY_KINDS = [
148
- "entity", "state",
149
- "contract", "eoa", "multisig", "proxy", "library", "interface",
155
+ "entity",
156
+ "state",
157
+ "contract",
158
+ "eoa",
159
+ "multisig",
160
+ "proxy",
161
+ "library",
162
+ "interface",
150
163
  ] as const;
151
164
 
152
165
  /**
@@ -160,17 +173,17 @@ const DSL_ONLY_KINDS = [
160
173
  * 保管する役)。 見た目が同じになるが、 役割が同じなので嘘にはならない。
161
174
  */
162
175
  const INFRA_KIND_ALIAS: Record<string, NodeKind> = {
163
- alb: "shape-api-gateway", // 入口で振り分ける
164
- browser: "frontend", // 画面側
165
- ecs: "microservice", // コンテナ群
166
- iam: "admin", // 権限を守る
167
- kms: "admin", // 鍵を守る
168
- lambda: "function", // 呼ぶと動く
169
- rds: "database", // 表を持つ
170
- s3: "storage", // 置き場
171
- secret: "storage", // 機密の置き場
172
- user: "person", // 人
173
- 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)
174
187
  };
175
188
 
176
189
  /**
@@ -195,7 +208,52 @@ export const NODE_KIND_VALID: ReadonlySet<string> = new Set<string>([
195
208
  // 受理する色名は cdl 側の一覧をそのまま使う。 手書きすると cdl に色が増えた時に取り残される。
196
209
  const TONE_VALID: ReadonlySet<string> = new Set<string>(TONES);
197
210
 
198
- 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
+ }
199
257
 
200
258
  type Line = {
201
259
  raw: string;
@@ -280,7 +338,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
280
338
  errors.push({
281
339
  line: entry[0]!.no,
282
340
  message: `invalid actor entry: "${entry[0]!.trimmed}"`,
283
- hint: 'use `- Client` or `- Client: storage`',
341
+ hint: "use `- Client` or `- Client: storage`",
284
342
  });
285
343
  continue;
286
344
  }
@@ -294,7 +352,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
294
352
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
295
353
  let stepNo = 1;
296
354
  for (const it of items) {
297
- const step = parseFlowStep(it, stepNo);
355
+ const step = parseFlowStep(it, stepNo, errors);
298
356
  if (step) {
299
357
  flow.push(step);
300
358
  stepNo += 1;
@@ -327,7 +385,12 @@ export function parseTextDslV05(src: string): V05ParseResult {
327
385
  for (const it of items) {
328
386
  const st = parseStateEntry(it.trimmed.replace(/^-\s*/, ""), it.no);
329
387
  if (st) animate.states.push(st);
330
- 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
+ });
331
394
  }
332
395
  i = next;
333
396
  continue;
@@ -379,14 +442,7 @@ export function parseTextDslV05(src: string): V05ParseResult {
379
442
  if (inline && inline.startsWith("{") && inline.endsWith("}")) {
380
443
  const opts = parseInlineMapping(inline.slice(1, -1));
381
444
  viewport = {
382
- width: numberOrUndef(opts.width),
383
- height: numberOrUndef(opts.height),
384
- laneWidth: numberOrUndef(opts.laneWidth),
385
- gap: numberOrUndef(opts.gap),
386
- laneGap: numberOrUndef(opts.laneGap),
387
- nodeGap: numberOrUndef(opts.nodeGap),
388
- scale: numberOrUndef(opts.scale),
389
- labelMargin: numberOrUndef(opts.labelMargin),
445
+ ...表で読む(VIEWPORT_VALUE_KINDS, opts, "viewport の ", line.no, errors),
390
446
  pos: { line: line.no },
391
447
  };
392
448
  i += 1;
@@ -395,19 +451,24 @@ export function parseTextDslV05(src: string): V05ParseResult {
395
451
  // block: viewport:\n width: 1400\n height: 900\n ...
396
452
  const { items, next } = collectIndentedList(lines, i + 1, line.indent);
397
453
  const opts: Record<string, string> = {};
454
+ // 知らせは **値を書いた行** を指す (#1306)。 `viewport:` の行を指すと、欄が縦に並ぶ形で
455
+ // どの行を直せばよいか分からない
456
+ const optLines: Record<string, number> = {};
398
457
  for (const it of items) {
399
458
  const m = it.trimmed.match(/^([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
400
- 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;
401
463
  }
402
464
  viewport = {
403
- width: numberOrUndef(opts.width),
404
- height: numberOrUndef(opts.height),
405
- laneWidth: numberOrUndef(opts.laneWidth),
406
- gap: numberOrUndef(opts.gap),
407
- laneGap: numberOrUndef(opts.laneGap),
408
- nodeGap: numberOrUndef(opts.nodeGap),
409
- scale: numberOrUndef(opts.scale),
410
- labelMargin: numberOrUndef(opts.labelMargin),
465
+ ...表で読む(
466
+ VIEWPORT_VALUE_KINDS,
467
+ opts,
468
+ "viewport の ",
469
+ (欄) => optLines[欄] ?? line.no,
470
+ errors,
471
+ ),
411
472
  pos: { line: line.no },
412
473
  };
413
474
  i = next;
@@ -463,11 +524,8 @@ export function parseTextDslV05(src: string): V05ParseResult {
463
524
  const opts = parseInlineMapping(m[2]!);
464
525
  lanesMap[id] = {
465
526
  id,
466
- x: numberOrUndef(opts.x),
467
- width: numberOrUndef(opts.width),
527
+ ...表で読む(LANE_VALUE_KINDS, opts, `縦列 ${id} の `, it.no, errors),
468
528
  label: opts.label,
469
- contain: boolOrUndef(opts.contain),
470
- lifeline: boolOrUndef(opts.lifeline),
471
529
  pos: { line: it.no },
472
530
  };
473
531
  } else {
@@ -515,8 +573,14 @@ export function parseTextDslV05(src: string): V05ParseResult {
515
573
  i += 1;
516
574
  }
517
575
 
518
- if (!title) errors.push({ line: 1, message: "title is required", hint: 'add `title: "..."` at top' });
519
- 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
+ });
520
584
 
521
585
  if (errors.length > 0) return { ok: false, errors };
522
586
 
@@ -590,7 +654,10 @@ function lastTopLevelColon(s: string): number {
590
654
  if (c === quote) quote = "";
591
655
  continue;
592
656
  }
593
- if (c === '"' || c === "'") { quote = c; continue; }
657
+ if (c === '"' || c === "'") {
658
+ quote = c;
659
+ continue;
660
+ }
594
661
  if (c === "[" || c === "{") depth += 1;
595
662
  else if (c === "]" || c === "}") depth -= 1;
596
663
  else if (c === ":" && depth === 0) last = i;
@@ -615,11 +682,26 @@ function splitValues(s: string): string[] {
615
682
  if (c === quote) quote = "";
616
683
  continue;
617
684
  }
618
- if (c === '"' || c === "'") { quote = c; buf += c; continue; }
619
- if (c === "[" || c === "{") { depth += 1; buf += c; continue; }
620
- 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
+ }
621
700
  if (/\s/.test(c) && depth === 0) {
622
- if (buf) { out.push(buf); buf = ""; }
701
+ if (buf) {
702
+ out.push(buf);
703
+ buf = "";
704
+ }
623
705
  continue;
624
706
  }
625
707
  buf += c;
@@ -650,13 +732,16 @@ type ActorValues = {
650
732
  * 振り分けは値の形で決まる。 引用符付きは補足 (2 つ目は値)、 角括弧は行、 色名は色、
651
733
  * 残りが種類。 形が違うので取り違えない。
652
734
  */
653
- function classifyValues(values: string[]): ActorValues {
735
+ function classifyValues(values: string[], line: number, errors: DslError[]): ActorValues {
654
736
  const out: ActorValues = { kind: "" };
655
737
  /** 書かれた倍率。 同じ名前が 2 度出たら後の値で上書きする */
656
738
  const scaleWritten = new Map<string, string>();
657
739
  const kindWords: string[] = [];
658
740
  for (const v of values) {
659
- 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
+ ) {
660
745
  // 1 つ目の引用符は補足、 2 つ目は値 (`storage` の右側に出る数値等)
661
746
  if (out.subtitle === undefined) out.subtitle = stripQuotes(v);
662
747
  else if (out.value === undefined) out.value = stripQuotes(v);
@@ -672,7 +757,11 @@ function classifyValues(values: string[]): ActorValues {
672
757
  }
673
758
  // `@300,200` は位置。 2 つ揃わないと効かないので、 1 つの値としてまとめて書く
674
759
  const at = v.match(/^@(-?\d+(?:\.\d+)?)\s*[,、]\s*(-?\d+(?:\.\d+)?)$/);
675
- 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
+ }
676
765
  // `名前=値` は parts の状態の上書き。 状態名は自由なので、 形では見分けられない。
677
766
  // 等号を書いてもらう。
678
767
  const eq = v.indexOf("=");
@@ -690,12 +779,15 @@ function classifyValues(values: string[]): ActorValues {
690
779
  continue;
691
780
  }
692
781
  }
693
- const tone = toneOrUndef(v);
694
- if (tone) { out.tone = tone; continue; }
782
+ const tone = resolveTone(v);
783
+ if (tone) {
784
+ out.tone = tone;
785
+ continue;
786
+ }
695
787
  kindWords.push(v);
696
788
  }
697
789
  out.kind = kindWords.join(" ").toLowerCase();
698
- const s = resolveScale(scaleWritten);
790
+ const s = resolveScale(scaleWritten, line, errors);
699
791
  out.scale = s.scale;
700
792
  out.scaleKeys = s.keys;
701
793
  return out;
@@ -706,7 +798,7 @@ function classifyValues(values: string[]): ActorValues {
706
798
  *
707
799
  * 固有名 (`lambda` / `rds` 等) は読み替え表を通す。 それ以外はそのまま返す。
708
800
  */
709
- function resolveKind(raw: string): NodeKind {
801
+ export function resolveNodeKind(raw: string): NodeKind {
710
802
  if (raw === "") return NODE_KIND_DEFAULT;
711
803
  // `Object.hasOwn` で引く。 素の添字だと `toString` 等の既定の持ち物が引けてしまい、
712
804
  // 種類として関数が返る。 呼ぶ前に受理集合で弾いてはいるが、 表を引く側でも閉じておく。
@@ -727,6 +819,157 @@ function boolOrUndef(s: string | undefined): boolean | undefined {
727
819
  return undefined;
728
820
  }
729
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
+
730
973
  /**
731
974
  * 色名を解決する。 別名 (`成功` / `neutral` 等) も受け付ける。
732
975
  *
@@ -736,7 +979,7 @@ function boolOrUndef(s: string | undefined): boolean | undefined {
736
979
  * `valueOf` / `__proto__` が JavaScript の既定の持ち物として引けてしまい、 色名として
737
980
  * 関数やオブジェクトが通る (実測)。 最後に解決結果が正規の色名かも確かめる。
738
981
  */
739
- function toneOrUndef(s: string | undefined): Tone | undefined {
982
+ export function resolveTone(s: string | undefined): Tone | undefined {
740
983
  if (s === undefined) return undefined;
741
984
  const raw = stripQuotes(s.trim());
742
985
  const lower = raw.toLowerCase();
@@ -846,7 +1089,11 @@ function splitInlineFields(inner: string): string[] {
846
1089
  return parts;
847
1090
  }
848
1091
 
849
- 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 } {
850
1097
  const items: Line[] = [];
851
1098
  let i = start;
852
1099
  while (i < lines.length) {
@@ -887,13 +1134,43 @@ function collectIndentedList(lines: Line[], start: number, parentIndent: number)
887
1134
  * 書く人は「色を変えたい」 としか思わないので、 項目は `色:` 1 つにまとめる。 意味の色
888
1135
  * (`失敗`) と色番号 (`#f59e0b`) は形で見分ける。 前者は箱の色、 後者はパーツの塗りになる。
889
1136
  */
890
- function splitColorValue(raw: string): { tone?: Tone; hex?: string } {
1137
+ export function splitColorValue(raw: string): { tone?: Tone; hex?: string } {
891
1138
  const v = stripQuotes(raw.trim());
892
1139
  if (v.startsWith("#")) return { hex: v };
893
- const tone = toneOrUndef(v);
1140
+ const tone = resolveTone(v);
894
1141
  return tone ? { tone } : {};
895
1142
  }
896
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
+
897
1174
  /** `色` / `color` のどちらでも書ける。 */
898
1175
  const COLOR_KEYS = new Set(["色", "color", "tone"]);
899
1176
 
@@ -921,11 +1198,20 @@ const SCALE_ORDER = ["scale", "倍率"] as const;
921
1198
  * `keys` は書かれた名前そのもの。 値が読めたかに関わらず入る。 見本が同じ名前の状態を
922
1199
  * 持つ時の知らせ (`scale-reserved`) が、値の読めなさに左右されないようにするため。
923
1200
  */
924
- 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[] } {
925
1206
  const keys = [...written.keys()];
926
1207
  for (const key of SCALE_ORDER) {
927
1208
  const raw = written.get(key);
928
- 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
+ }
929
1215
  }
930
1216
  return { keys };
931
1217
  }
@@ -942,6 +1228,8 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
942
1228
  let touchedState = false;
943
1229
  /** 縦に並べて書かれた倍率。 同じ名前が 2 度出たら後の値で上書きする */
944
1230
  const scaleWritten = new Map<string, string>();
1231
+ /** 倍率を名前ごとに最後に書いた行。 別名の優先順と行番号を取り違えないために保持する。 */
1232
+ const scaleLines = new Map<string, number>();
945
1233
  /**
946
1234
  * 縦に並べて書かれた体験の道筋の欄 (#1251)。
947
1235
  *
@@ -962,6 +1250,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
962
1250
  // 予約の知らせが消え、別名 (`倍率`) に降りて別の値が効いてしまう
963
1251
  if (SCALE_KEYS.has(key)) {
964
1252
  scaleWritten.set(key, stripQuotes(raw));
1253
+ scaleLines.set(key, ln.no);
965
1254
  unknownKeys.push({ key, line: ln.no });
966
1255
  continue;
967
1256
  }
@@ -972,14 +1261,18 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
972
1261
  if (tone) out.tone = tone;
973
1262
  // 色番号を入れる状態の名前はパーツごとに違う。 組み立て時に解決する
974
1263
  if (hex) out.colorHex = hex;
1264
+ // 色名としても色番号としても読めない値は黙って捨てない (#1304)。 捨てると
1265
+ // 既定色のまま描かれ、 手掛かりが 1 つも残らない
1266
+ if (!tone && !hex) report読めない色(raw, ln.no, errors, { 線種も受ける: false });
975
1267
  continue;
976
1268
  }
977
1269
  switch (key) {
978
1270
  case "kind":
979
1271
  case "種類": {
980
1272
  const k = stripQuotes(raw).toLowerCase();
1273
+ reportV04Kind(k, ln.no, errors);
981
1274
  const isPart = k !== "" && !NODE_KIND_VALID.has(k);
982
- out.kind = isPart ? NODE_KIND_DEFAULT : resolveKind(k);
1275
+ out.kind = isPart ? NODE_KIND_DEFAULT : resolveNodeKind(k);
983
1276
  // parts 候補は `kind` を既定に倒して `partId` へ退避するため、 名札に載せる種類としては
984
1277
  // 「書かなかった」 と同じ扱いにする (#1058)
985
1278
  out.kindWritten = k !== "" && !isPart;
@@ -996,7 +1289,11 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
996
1289
  break;
997
1290
  case "rows":
998
1291
  case "行":
999
- 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);
1000
1297
  break;
1001
1298
  case "位置":
1002
1299
  case "pos": {
@@ -1025,7 +1322,8 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1025
1322
  //
1026
1323
  // 負の間隔 (`Web の右 -200`) もここに来る。 向きを書いた上で裏返す指定は、
1027
1324
  // 書いた人の意図と図が食い違うので誤りとして返す
1028
- 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);
1029
1327
  errors.push({
1030
1328
  line: ln.no,
1031
1329
  message: negative
@@ -1038,10 +1336,10 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1038
1336
  break;
1039
1337
  }
1040
1338
  case "posX":
1041
- out.posX = numberOrUndef(raw);
1339
+ out.posX = 数として読む(raw, "箱の posX", ln.no, errors);
1042
1340
  break;
1043
1341
  case "posY":
1044
- out.posY = numberOrUndef(raw);
1342
+ out.posY = 数として読む(raw, "箱の posY", ln.no, errors);
1045
1343
  break;
1046
1344
  case "大きさ":
1047
1345
  case "size": {
@@ -1075,7 +1373,7 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1075
1373
  out.lane = stripQuotes(raw);
1076
1374
  break;
1077
1375
  case "stack":
1078
- out.stack = numberOrUndef(raw);
1376
+ out.stack = 数として読む(raw, "箱の stack", ln.no, errors);
1079
1377
  break;
1080
1378
  default:
1081
1379
  // 残りはパーツの状態の上書き
@@ -1088,7 +1386,11 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1088
1386
  // 名前の行と縦に並べた行の両方に倍率がある形では、後に書いた縦の行を採る。
1089
1387
  // 知らせ (`scale-reserved`) は書かれた名前をすべて見るので、名前だけは足し合わせる
1090
1388
  if (scaleWritten.size > 0) {
1091
- const s = resolveScale(scaleWritten);
1389
+ const s = resolveScale(
1390
+ scaleWritten,
1391
+ (key) => scaleLines.get(key) ?? actor.pos.line,
1392
+ errors,
1393
+ );
1092
1394
  out.scale = s.scale;
1093
1395
  out.scaleKeys = [...new Set([...(actor.scaleKeys ?? []), ...s.keys])];
1094
1396
  }
@@ -1128,18 +1430,30 @@ function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[
1128
1430
  */
1129
1431
  export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
1130
1432
  ...COLOR_KEYS,
1131
- "kind", "種類",
1132
- "subtitle", "補足",
1133
- "value", "値",
1134
- "rows", "行",
1135
- "位置", "pos", "posX", "posY",
1136
- "大きさ", "size",
1137
- "倍率", "scale",
1138
- "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",
1139
1451
  // 体験の道筋の欄 (#1251)
1140
- "touchpoint", "opportunity",
1452
+ "touchpoint",
1453
+ "opportunity",
1141
1454
  // 工程の並びの欄 (#1251)
1142
- "owner", "end",
1455
+ "owner",
1456
+ "end",
1143
1457
  ]);
1144
1458
 
1145
1459
  /**
@@ -1198,14 +1512,21 @@ function validateRelativePositions(actors: DslActor[], errors: DslError[]): void
1198
1512
  }
1199
1513
  }
1200
1514
 
1201
- 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 } {
1202
1520
  const items: Line[][] = [];
1203
1521
  let cur: Line[] | null = null;
1204
1522
  let headIndent = -1;
1205
1523
  let i = start;
1206
1524
  while (i < lines.length) {
1207
1525
  const ln = lines[i]!;
1208
- if (!ln.trimmed) { i += 1; continue; }
1526
+ if (!ln.trimmed) {
1527
+ i += 1;
1528
+ continue;
1529
+ }
1209
1530
  if (ln.indent <= parentIndent) break;
1210
1531
  if (ln.trimmed.startsWith("- ")) {
1211
1532
  if (cur) items.push(cur);
@@ -1221,7 +1542,11 @@ function collectActorEntries(lines: Line[], start: number, parentIndent: number)
1221
1542
  return { items, next: i };
1222
1543
  }
1223
1544
 
1224
- 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 } {
1225
1550
  // 各 `- step: "..."` 開始を 1 block の頭として識別、 後続の同 indent 以下を block 本文として吸収
1226
1551
  const out: Line[][] = [];
1227
1552
  let i = start;
@@ -1233,7 +1558,10 @@ function collectAnimationSteps(lines: Line[], start: number, parentIndent: numbe
1233
1558
  continue;
1234
1559
  }
1235
1560
  if (ln.indent <= parentIndent) break;
1236
- if (ln.trimmed.startsWith("- step")) {
1561
+ // v0.4 の日本語の段名も block の頭として拾い、parsePhase で英語の `step` を案内する
1562
+ // (#1301)。ここで英語だけに絞ると `- ステップ:` は block 自体が作られず、段全体が
1563
+ // 誤りなしで黙って消える。
1564
+ if (ln.trimmed.startsWith("- step") || ln.trimmed.startsWith("- ステップ")) {
1237
1565
  if (cur) out.push(cur);
1238
1566
  cur = [{ ...ln, trimmed: ln.trimmed.slice(2).trim() }];
1239
1567
  } else if (cur) {
@@ -1288,7 +1616,9 @@ const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
1288
1616
  "色",
1289
1617
  ]);
1290
1618
 
1291
- 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 {
1292
1622
  const out: Record<string, number | string | boolean> = {};
1293
1623
  let count = 0;
1294
1624
  // 明示 `state: {...}` fallback がある場合はそちらを優先 (nested map parse)
@@ -1317,7 +1647,11 @@ function extractStateOverride(opts: Record<string, string>): Record<string, numb
1317
1647
  * key: sub-map ペアに再 split → 各 sub-map を parseInlineMapping で解いて posX/Y/W/H に coerce」 する。
1318
1648
  * 未 field or 空 object なら undefined 返し (caller は actor.nodes を set しない)。
1319
1649
  */
1320
- 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 {
1321
1655
  if (!raw) return undefined;
1322
1656
  const trimmed = raw.trim();
1323
1657
  if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined;
@@ -1347,12 +1681,7 @@ function parseActorNodesField(raw: string | undefined): Record<string, DslActorN
1347
1681
  const val = p.slice(colonIdx + 1).trim();
1348
1682
  if (!key || !val.startsWith("{") || !val.endsWith("}")) continue;
1349
1683
  const nodeOpts = parseInlineMapping(val.slice(1, -1));
1350
- out[key] = {
1351
- posX: numberOrUndef(nodeOpts.posX),
1352
- posY: numberOrUndef(nodeOpts.posY),
1353
- posW: numberOrUndef(nodeOpts.posW),
1354
- posH: numberOrUndef(nodeOpts.posH),
1355
- };
1684
+ out[key] = 表で読む(ACTOR_NODE_VALUE_KINDS, nodeOpts, `nodes の ${key} の `, line, errors);
1356
1685
  }
1357
1686
  return Object.keys(out).length > 0 ? out : undefined;
1358
1687
  }
@@ -1394,17 +1723,65 @@ function reportScaleOnNonPart(
1394
1723
  * 並べた形) とは別に持つ = 中括弧の形は位置や大きさを未対応にしてあり、 同じ集合にすると
1395
1724
  * 「知らせない」 側がずれる。
1396
1725
  */
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
+
1397
1757
  export const INLINE_ACTOR_KEYS: ReadonlySet<string> = new Set([
1398
- "kind", "subtitle", "eyebrow", "value", "rows", "lane", "stack",
1399
- "initial", "final", "tone", "nodes",
1758
+ "kind",
1759
+ "subtitle",
1760
+ "eyebrow",
1761
+ "value",
1762
+ "rows",
1763
+ "lane",
1764
+ "stack",
1765
+ "initial",
1766
+ "final",
1767
+ "tone",
1768
+ "nodes",
1400
1769
  // 体験の道筋の欄 (#1251)。 他の図種では組み立て側が知らせる
1401
- "touchpoint", "opportunity",
1770
+ "touchpoint",
1771
+ "opportunity",
1402
1772
  // 工程の並びの欄 (#1251)
1403
- "owner", "end",
1404
- "posX", "posY", "posW", "posH",
1773
+ "owner",
1774
+ "end",
1775
+ "posX",
1776
+ "posY",
1777
+ "posW",
1778
+ "posH",
1405
1779
  // 倍率は別経路 (`reportScaleOnNonPart`) が知らせる。 ここでも読める扱いにしないと
1406
1780
  // 同じ名前で 2 度知らせることになる
1407
- "scale", "倍率",
1781
+ "scale",
1782
+ "倍率",
1783
+ // 英語が読める欄の日本語別名 (#1301)。 一覧は `INLINE_ACTOR_ALIASES` が持つ
1784
+ ...Object.keys(INLINE_ACTOR_ALIASES),
1408
1785
  ]);
1409
1786
  // `state` はパーツでだけ意味を持つ (`extractStateOverride` がパーツの時しか作らない)。
1410
1787
  // 通常の箱で読める扱いにすると `- A: { state: { foo: 1 } }` が黙って消え、 本 file が塞ごうと
@@ -1422,13 +1799,17 @@ const FLOW_INLINE_READERS = {
1422
1799
  sub: (v: string | undefined) => v,
1423
1800
  guard: (v: string | undefined) => v,
1424
1801
  cardinality: (v: string | undefined) => v,
1425
- labelOffsetX: (v: string | undefined) => numberOrUndef(v),
1426
- labelOffsetY: (v: string | undefined) => numberOrUndef(v),
1427
- overlay: (v: string | undefined) => boolOrUndef(v),
1802
+ // 数と真偽の欄は `FLOW_INLINE_VALUE_KINDS` の表が読む (#1306) ここでは名前だけを持つ =
1803
+ // 読める欄の一覧 (`FLOW_INLINE_KEYS`) は本表から導くため、載せないと欄ごと消える
1804
+ labelOffsetX: null,
1805
+ labelOffsetY: null,
1806
+ overlay: null,
1428
1807
  } as const;
1429
1808
 
1430
1809
  /** 矢印の中括弧に書ける欄の名前。 README の一覧と突き合わせる (#1275) */
1431
- export const FLOW_INLINE_KEYS = Object.keys(FLOW_INLINE_READERS) as readonly (keyof typeof FLOW_INLINE_READERS)[];
1810
+ export const FLOW_INLINE_KEYS = Object.keys(
1811
+ FLOW_INLINE_READERS,
1812
+ ) as readonly (keyof typeof FLOW_INLINE_READERS)[];
1432
1813
 
1433
1814
  /**
1434
1815
  * 中括弧に書かれた読めない項目名を知らせる (#1090)。
@@ -1481,18 +1862,28 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1481
1862
  if (mapMatch) {
1482
1863
  const namePart = stripQuotes(mapMatch.name.trim());
1483
1864
  if (!namePart) return null;
1484
- const opts = parseInlineMapping(mapMatch.inner);
1865
+ // 日本語の項目名を英語名に寄せてから読む (#1301)。 寄せないと、同じ意味の語が
1866
+ // 縦書きでは通り中括弧では落ちる
1867
+ const opts = 中括弧の別名を寄せる(parseInlineMapping(mapMatch.inner));
1485
1868
  const kindRaw = (opts.kind ?? "").toLowerCase();
1486
1869
  // CAR-1657 = kind が既存 NODE_KIND_VALID に無い場合 parts identifier 候補として partId に格納、
1487
1870
  // kind は actor default fallback。 compile 側 partsCatalog lookup で解決する。
1488
1871
  const isPart = kindRaw !== "" && !NODE_KIND_VALID.has(kindRaw);
1489
1872
  // 倍率はパーツにしか効かない。 書いたのに効かない状態を黙って作らない (#1026)。
1490
1873
  // 値が空の形でも名前を残すため、`opts` ではなく中身から直接拾う
1491
- const inlineScale = resolveScale(writtenScaleFields(mapMatch.inner));
1874
+ const inlineScale = resolveScale(writtenScaleFields(mapMatch.inner), line.no, errors);
1492
1875
  reportScaleOnNonPart(isPart, inlineScale.keys[0], line.no, errors);
1493
1876
  // 中括弧に書いた読めない項目名も知らせる (#1090)。 縦に並べた形だけが知らせていた
1494
1877
  reportUnknownInlineKeys(isPart, mapMatch.inner, line.no, errors);
1495
- 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 : "");
1496
1887
  return {
1497
1888
  name: namePart,
1498
1889
  kind,
@@ -1516,23 +1907,17 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1516
1907
  .filter(Boolean)
1517
1908
  : undefined,
1518
1909
  lane: opts.lane,
1519
- stack: numberOrUndef(opts.stack),
1520
- initial: boolOrUndef(opts.initial),
1521
- final: boolOrUndef(opts.final),
1910
+ ...表で読む(ACTOR_INLINE_VALUE_KINDS, opts, "箱の ", line.no, errors),
1522
1911
  // parts では `tone` を状態の上書きとして従来から使えるため、 色として横取りしない
1523
- tone: isPart ? undefined : toneOrUndef(opts.tone),
1912
+ tone: isPart ? undefined : resolveTone(opts.tone),
1524
1913
  partId: isPart ? kindRaw : undefined,
1525
1914
  stateOverride: isPart ? extractStateOverride(opts) : undefined,
1526
- // canvas pivot 新 spec = 絶対座標 field actor に格納、 compile 経由で CDL に受け渡す
1527
- posX: numberOrUndef(opts.posX),
1528
- posY: numberOrUndef(opts.posY),
1529
- posW: numberOrUndef(opts.posW),
1530
- posH: numberOrUndef(opts.posH),
1915
+ // canvas pivot 新 spec = 絶対座標 field `ACTOR_INLINE_VALUE_KINDS` の表が読む (#1306)
1531
1916
  // 図形の倍率 (#1026)。 どれが効くかは `resolveScale` が 1 箇所で決める
1532
1917
  scale: inlineScale.scale,
1533
1918
  scaleKeys: inlineScale.keys.length ? inlineScale.keys : undefined,
1534
1919
  // canvas pivot UX 修正 (B1) = sub-node 単位 override map (`nodes: { header: {posX:..., ...}, ...}`)
1535
- nodes: parseActorNodesField(opts.nodes),
1920
+ nodes: parseActorNodesField(opts.nodes, line.no, errors),
1536
1921
  pos: { line: line.no },
1537
1922
  };
1538
1923
  }
@@ -1547,13 +1932,16 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1547
1932
  //
1548
1933
  // 値は形で見分ける。 引用符付きは補足、 角括弧は行、 色名は色、 残りが種類。
1549
1934
  // 種類と色は語の集合が閉じているので取り違えない。
1550
- const v = classifyValues(splitValues(rest));
1935
+ const v = classifyValues(splitValues(rest), line.no, errors);
1551
1936
 
1552
1937
  // CAR-1657 = short form (`arc1: arc-gauge`) でも parts kind 対応、 未知 kind は partId 経路
1553
1938
  const isPart = v.kind !== "" && !NODE_KIND_VALID.has(v.kind);
1939
+ reportV04Kind(v.kind, line.no, errors);
1554
1940
  // 倍率はパーツにしか効かない (#1026)
1555
1941
  reportScaleOnNonPart(isPart, v.scaleKeys?.[0], line.no, errors);
1556
- 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 : "");
1557
1945
  return {
1558
1946
  name: namePart,
1559
1947
  kind,
@@ -1579,7 +1967,7 @@ function parseActor(line: Line, errors: DslError[]): DslActor | null {
1579
1967
  return { name: namePart, kind: NODE_KIND_DEFAULT, kindWritten: false, pos: { line: line.no } };
1580
1968
  }
1581
1969
 
1582
- function parseFlowStep(line: Line, no: number): DslStep | null {
1970
+ function parseFlowStep(line: Line, no: number, errors: DslError[]): DslStep | null {
1583
1971
  // 形式 (順序自由、 部分省略可):
1584
1972
  // 1. `Client -> API` ... label / option なし
1585
1973
  // 2. `Client -> API: "deposit"` ... label
@@ -1598,17 +1986,23 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1598
1986
  const 中括弧: Partial<Record<keyof typeof FLOW_INLINE_READERS, unknown>> = {};
1599
1987
  // inline option (`{ ... }`) を末尾から抽出
1600
1988
  const mapMatch = rest.match(/\s*\{([^}]*)\}\s*$/);
1989
+ let 数と真偽: 読んだ結果<typeof FLOW_INLINE_VALUE_KINDS> | undefined;
1601
1990
  if (mapMatch) {
1602
1991
  const opts = parseInlineMapping(mapMatch[1]!);
1603
- for (const k of FLOW_INLINE_KEYS) 中括弧[k] = FLOW_INLINE_READERS[k](opts[k]);
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);
1604
1998
  rest = rest.slice(0, mapMatch.index ?? 0).trim();
1605
1999
  }
1606
2000
  const sub = 中括弧.sub as string | undefined;
1607
2001
  const guard = 中括弧.guard as string | undefined;
1608
2002
  const cardinality = 中括弧.cardinality as string | undefined;
1609
- const labelOffsetX = 中括弧.labelOffsetX as number | undefined;
1610
- const labelOffsetY = 中括弧.labelOffsetY as number | undefined;
1611
- const overlay = 中括弧.overlay as boolean | undefined;
2003
+ const labelOffsetX = 数と真偽?.labelOffsetX;
2004
+ const labelOffsetY = 数と真偽?.labelOffsetY;
2005
+ const overlay = 数と真偽?.overlay;
1612
2006
  // 色と線種を末尾から取る。 括弧 (`(成功)`) と空白区切り (`成功`) の両方を受け付ける。
1613
2007
  //
1614
2008
  // 括弧は従来の書き方で、 catalog が使っている。 空白区切りは登場人物と揃えた形。
@@ -1616,9 +2010,13 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1616
2010
  if (optMatch) {
1617
2011
  const opts = (optMatch[1] ?? "").split(",").map((s) => s.trim());
1618
2012
  for (const opt of opts) {
1619
- const resolvedTone = toneOrUndef(opt);
2013
+ const resolvedTone = resolveTone(opt);
1620
2014
  if (resolvedTone !== undefined) tone = resolvedTone;
1621
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 });
1622
2020
  }
1623
2021
  rest = rest.slice(0, optMatch.index ?? 0).trim();
1624
2022
  } else {
@@ -1629,9 +2027,17 @@ function parseFlowStep(line: Line, no: number): DslStep | null {
1629
2027
  const last = words[words.length - 1]!;
1630
2028
  // 引用符付きは説明文なので取らない
1631
2029
  if (last.startsWith('"') || last.startsWith("'")) break;
1632
- const resolvedTone = toneOrUndef(last);
1633
- if (resolvedTone !== undefined) { tone = resolvedTone; words.pop(); continue; }
1634
- 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
+ }
1635
2041
  break;
1636
2042
  }
1637
2043
  rest = words.join(" ");
@@ -1669,7 +2075,8 @@ function parseStateEntry(text: string, lineNo: number): DslState | null {
1669
2075
  const raw = (m[2] ?? "").trim();
1670
2076
  const stripped = stripQuotes(raw);
1671
2077
  const asNum = Number(stripped);
1672
- 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;
1673
2080
  return { name, initial, pos: { line: lineNo } };
1674
2081
  }
1675
2082
 
@@ -1729,11 +2136,21 @@ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslV
1729
2136
  for (const issue of issues) errors.push({ line: lineNo, ...issue });
1730
2137
  return null;
1731
2138
  }
1732
- return { name, trigger: spec.trigger, to: spec.to, durationMs: spec.durationMs, pos: { line: lineNo } };
2139
+ return {
2140
+ name,
2141
+ trigger: spec.trigger,
2142
+ to: spec.to,
2143
+ durationMs: spec.durationMs,
2144
+ pos: { line: lineNo },
2145
+ };
1733
2146
  }
1734
2147
  const expression = stripQuotes(rest);
1735
2148
  if (expression === "") {
1736
- errors.push({ line: lineNo, message: `empty expression for "${name}"`, hint: '`"{a} + {b}"` のように式を書く' });
2149
+ errors.push({
2150
+ line: lineNo,
2151
+ message: `empty expression for "${name}"`,
2152
+ hint: '`"{a} + {b}"` のように式を書く',
2153
+ });
1737
2154
  return null;
1738
2155
  }
1739
2156
  const issues = checkValueExpression(expression, name);
@@ -1746,7 +2163,10 @@ function parseValueEntry(text: string, lineNo: number, errors: DslError[]): DslV
1746
2163
 
1747
2164
  function splitTopLevelCommas(s: string): string[] {
1748
2165
  // brace 内を考慮 ... 今回は単純 split (動作する範囲)
1749
- return s.split(",").map((x) => x.trim()).filter(Boolean);
2166
+ return s
2167
+ .split(",")
2168
+ .map((x) => x.trim())
2169
+ .filter(Boolean);
1750
2170
  }
1751
2171
 
1752
2172
  function ensureAnimate(a: DslAnimate | undefined, lineNo: number): DslAnimate {
@@ -1759,14 +2179,22 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1759
2179
  const head = block[0]!;
1760
2180
  const m = head.trimmed.match(/^step\s*:\s*(.+)$/);
1761
2181
  if (!m) {
1762
- 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
+ });
1763
2187
  return null;
1764
2188
  }
1765
2189
  const headRest = (m[1] ?? "").trim();
1766
2190
  // `"request" 1.5s` 形式 ... quote 後の duration 抽出
1767
2191
  const headParse = parseStepHead(headRest);
1768
2192
  if (!headParse) {
1769
- 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
+ });
1770
2198
  return null;
1771
2199
  }
1772
2200
  const phase: DslPhase = {
@@ -1782,7 +2210,10 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1782
2210
  while (i < block.length) {
1783
2211
  const ln = block[i]!;
1784
2212
  const t = ln.trimmed;
1785
- 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*(.*)$/);
1786
2217
  if (!propMatch) {
1787
2218
  i += 1;
1788
2219
  continue;
@@ -1799,6 +2230,23 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1799
2230
  i += 1;
1800
2231
  continue;
1801
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
+ }
1802
2250
  if (key === "description" || key === "body") {
1803
2251
  phase.body = stripQuotes(value);
1804
2252
  i += 1;
@@ -1809,7 +2257,12 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1809
2257
  if (value) {
1810
2258
  const tw = parseTweenLine(value, ln.no);
1811
2259
  if (tw) phase.tweens!.push(tw);
1812
- 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
+ });
1813
2266
  i += 1;
1814
2267
  continue;
1815
2268
  }
@@ -1821,7 +2274,12 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1821
2274
  if (nx.indent <= baseIndent) break;
1822
2275
  const tw = parseTweenLine(nx.trimmed, nx.no);
1823
2276
  if (tw) phase.tweens!.push(tw);
1824
- 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
+ });
1825
2283
  j += 1;
1826
2284
  }
1827
2285
  i = j;
@@ -1846,11 +2304,81 @@ function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
1846
2304
  i = j;
1847
2305
  continue;
1848
2306
  }
2307
+ // ここに来るのは上のどれにも当たらなかった名前 (#1301)。 黙って捨てると
2308
+ // 「書いたのに段が変わらない」 が手掛かりなしで起きる
2309
+ errors.push({
2310
+ line: ln.no,
2311
+ message: `段の項目名が読めません: "${propMatch[1] ?? ""}"`,
2312
+ hint: 段の項目のヒント(propMatch[1] ?? ""),
2313
+ });
1849
2314
  i += 1;
1850
2315
  }
1851
2316
  return phase;
1852
2317
  }
1853
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
+
1854
2382
  function parseStepHead(s: string): { name: string; durationMs: number } | null {
1855
2383
  // 例: `"request" 1.5s` / `"step1" 1500ms` / `step1 2s`
1856
2384
  let rest = s.trim();
@@ -1906,7 +2434,7 @@ function parseFocusList(s: string): string[] {
1906
2434
  buf += ch;
1907
2435
  continue;
1908
2436
  }
1909
- if (ch === "\"" || ch === "'") {
2437
+ if (ch === '"' || ch === "'") {
1910
2438
  // item の途中にある引用符は名前の一部。 空白または区切りの直後だけ囲みを開始する。
1911
2439
  if (!buf || /\s$/.test(buf)) {
1912
2440
  pushFragment(false);
@@ -1926,7 +2454,10 @@ function parseFocusList(s: string): string[] {
1926
2454
  // quote 外 item は依然として space split (旧挙動、 「Client API」 が 2 item として解釈される互換維持)。
1927
2455
  const out: string[] = [];
1928
2456
  for (const fragments of groups) {
1929
- const whole = fragments.map(({ text }) => text).join("").trim();
2457
+ const whole = fragments
2458
+ .map(({ text }) => text)
2459
+ .join("")
2460
+ .trim();
1930
2461
  if (fragments.every(({ quoted }) => !quoted) && /[-→][>]?/.test(whole) && /\s/.test(whole)) {
1931
2462
  // arrow を含む非引用区間は「A -> B」パターン。 空白で分割しない。
1932
2463
  out.push(whole);
@@ -1973,6 +2504,7 @@ function parseSetLine(s: string, lineNo: number): DslSet | null {
1973
2504
  const raw = (m[2] ?? "").trim();
1974
2505
  const stripped = stripQuotes(raw);
1975
2506
  const asNum = Number(stripped);
1976
- const value: number | string = Number.isFinite(asNum) && stripped !== "" && !isNaN(asNum) ? asNum : stripped;
2507
+ const value: number | string =
2508
+ Number.isFinite(asNum) && stripped !== "" && !isNaN(asNum) ? asNum : stripped;
1977
2509
  return { state, value, pos: { line: lineNo } };
1978
2510
  }