@cardenelabs/dragon 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -0
- package/dist/index.cjs +2365 -277
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +240 -3
- package/dist/index.d.ts +240 -3
- package/dist/index.js +2366 -279
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/compile.ts +2895 -258
- package/src/index.ts +4 -1
- package/src/input-size.ts +8 -1
- package/src/json-parser.ts +480 -30
- package/src/notation-lint.ts +27 -4
- package/src/schemas/diagram.json +56 -5
- package/src/types.ts +125 -0
- package/src/v05/parser.ts +341 -41
- package/src/value-syntax.ts +313 -0
package/src/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { compileToCdl } from "./compile";
|
|
|
13
13
|
export type { CompileNotice } from "./compile";
|
|
14
14
|
export { parseTextDslV05 } from "./v05";
|
|
15
15
|
// 記法一覧が「実際に受け付ける値」 を実装から引くための公開。 手書きすると説明と実装がずれる。
|
|
16
|
-
export { PRESET_TYPES } from "./v05/parser";
|
|
16
|
+
export { PRESET_TYPES, TOP_LEVEL_KEYS } from "./v05/parser";
|
|
17
17
|
export { TONE_ALIAS, NODE_KIND_ALIAS } from "./keywords";
|
|
18
18
|
export { lintDiagram, autoFix } from "./notation-lint";
|
|
19
19
|
export type { LintIssue, LintReport, LintSeverity } from "./notation-lint";
|
|
@@ -109,6 +109,9 @@ export type {
|
|
|
109
109
|
DslPhase,
|
|
110
110
|
DslTween,
|
|
111
111
|
DslSet,
|
|
112
|
+
// `DslDocument.values` を触る利用者が型を import できるようにする (#1169)。
|
|
113
|
+
// 兄弟の `DslState` / `DslTween` / `DslSet` は公開されており、これだけ漏れていた
|
|
114
|
+
DslValue,
|
|
112
115
|
DslError,
|
|
113
116
|
PresetType,
|
|
114
117
|
DslLane,
|
package/src/input-size.ts
CHANGED
|
@@ -49,6 +49,9 @@ export interface InputSize {
|
|
|
49
49
|
* **段の中身 (光らせる相手 / 遷移 / 即時変更) も数える**。 段の数だけを見ると、
|
|
50
50
|
* 1 段に 1,000 件の相手を書いた形が 1 件として通る。 実測ではこの形が組み立ての中で
|
|
51
51
|
* 約 100 万件に展開され、 呼び出しの深さが上限を超えて落ちた。
|
|
52
|
+
*
|
|
53
|
+
* **他の値から決まる値 (`values:`) も数える** (#1162)。 これらは毎 frame 解かれるので、
|
|
54
|
+
* 数に入れないと上限をすり抜けた本文が描画のたびに重さを持つ。
|
|
52
55
|
*/
|
|
53
56
|
export function countDocElements(doc: DslDocument): number {
|
|
54
57
|
const phases = doc.animate?.phases ?? [];
|
|
@@ -62,6 +65,7 @@ export function countDocElements(doc: DslDocument): number {
|
|
|
62
65
|
phases.length +
|
|
63
66
|
phaseChildren +
|
|
64
67
|
(doc.animate?.states.length ?? 0) +
|
|
68
|
+
(doc.values?.length ?? 0) +
|
|
65
69
|
(doc.groups ? Object.keys(doc.groups).length : 0) +
|
|
66
70
|
(doc.lanes ? Object.keys(doc.lanes).length : 0)
|
|
67
71
|
);
|
|
@@ -87,6 +91,7 @@ export function countDiagramElements(diagram: {
|
|
|
87
91
|
formulas?: unknown[];
|
|
88
92
|
scrollTriggers?: unknown[];
|
|
89
93
|
eventBindings?: unknown[];
|
|
94
|
+
derived?: unknown[];
|
|
90
95
|
}): number {
|
|
91
96
|
const phases = Array.isArray(diagram.phases) ? diagram.phases : [];
|
|
92
97
|
const phaseChildren = phases.reduce((acc: number, p) => {
|
|
@@ -111,7 +116,9 @@ export function countDiagramElements(diagram: {
|
|
|
111
116
|
(diagram.inputs?.length ?? 0) +
|
|
112
117
|
(diagram.formulas?.length ?? 0) +
|
|
113
118
|
(diagram.scrollTriggers?.length ?? 0) +
|
|
114
|
-
(diagram.eventBindings?.length ?? 0)
|
|
119
|
+
(diagram.eventBindings?.length ?? 0) +
|
|
120
|
+
// 他の値から決まる値 (#1162)。 記法側 (`countDocElements`) が数えるので、こちらも揃える
|
|
121
|
+
(diagram.derived?.length ?? 0)
|
|
115
122
|
);
|
|
116
123
|
}
|
|
117
124
|
|
package/src/json-parser.ts
CHANGED
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
* YAML `animation: [step: "..."]` ⇔ JSON `{animation: [{step: "...", duration: 1.4, focus: [...]}]}`
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
+
import { PRESET_TYPES } from "./v05/parser";
|
|
22
|
+
import type { CompileToCdlOpts } from "./compile";
|
|
21
23
|
import type { CdlDiagram, NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
|
|
22
|
-
import type { DslDocument, DslActor, DslStep, DslAnimate, DslPhase, PresetType, LayoutMode, LayoutPos } from "./types";
|
|
24
|
+
import type { DslDocument, DslActor, DslStep, DslAnimate, DslPhase, DslState, PresetType, LayoutMode, LayoutPos } from "./types";
|
|
25
|
+
import { checkValueExpression, isValueName, valueNameIssue } from "./value-syntax";
|
|
23
26
|
import { compileToCdl } from "./compile";
|
|
24
27
|
|
|
25
28
|
/**
|
|
@@ -30,10 +33,35 @@ export interface DragonJson {
|
|
|
30
33
|
title: string;
|
|
31
34
|
/** preset type (必須): sequence / flow / swimlane / er / state / topology / solidity / gantt / class / pie / c4 / mind */
|
|
32
35
|
type: PresetType;
|
|
36
|
+
/**
|
|
37
|
+
* 図表の箱の上に出す小見出し (optional)。 記法の最上位 `eyebrow:` と同じ (#1247)。
|
|
38
|
+
*
|
|
39
|
+
* 効くのは図全体を 1 箱にする図種 (`pie` / `bar` / `line` / `funnel` / `tree` / `journey` /
|
|
40
|
+
* `quadrant` / `mind` / `gantt`) だけ。 箱ごとに分かれる図種では相手が決まらないため、
|
|
41
|
+
* 組み立て側が知らせを出す。 そちらは `actors[].eyebrow` に書く。
|
|
42
|
+
*/
|
|
43
|
+
eyebrow?: string;
|
|
33
44
|
/** 登場人物 (必須): 文字列 or { name, kind, ... } object */
|
|
34
45
|
actors: (string | JsonActor)[];
|
|
35
46
|
/** flow step 配列 (必須): { from, to, label, ... } */
|
|
36
47
|
flow: JsonStep[];
|
|
48
|
+
/**
|
|
49
|
+
* 状態の初期値 (optional)。 記法の `states:` と同じ (#1181)。
|
|
50
|
+
*
|
|
51
|
+
* `{名前}` を箱の文字に置くと、ここに書いた値が描画側で置き換わる。 名前は英数字と `_`
|
|
52
|
+
* だけ (描画側が置き換える時に見る範囲と揃える)。
|
|
53
|
+
*
|
|
54
|
+
* ここに書けるのは初期値まで。 段で動かすのは `animation[].tween` / `animation[].set`
|
|
55
|
+
* (`#1186` で追加、記法の `tween:` / `set:` と同じ)。
|
|
56
|
+
*/
|
|
57
|
+
states?: Record<string, number | string>;
|
|
58
|
+
/**
|
|
59
|
+
* 他の値から自動で決まる値 (optional)。 記法の `values:` と同じ (#1181)。
|
|
60
|
+
*
|
|
61
|
+
* 式には四則 (`+ - * /`) と括弧、比較 (`> >= < <= == !=`)、`min` / `max` が書ける。
|
|
62
|
+
* 他の値は `{名前}` で読む。 解くのは描画側で、毎 frame 参照から順に決まる。
|
|
63
|
+
*/
|
|
64
|
+
values?: Record<string, string>;
|
|
37
65
|
/** animation phase 配列 (optional) */
|
|
38
66
|
animation?: JsonPhase[];
|
|
39
67
|
/** viewport (optional): 全体 canvas size / gap */
|
|
@@ -114,6 +142,8 @@ export interface JsonStep {
|
|
|
114
142
|
cardinality?: string;
|
|
115
143
|
labelOffsetX?: number;
|
|
116
144
|
labelOffsetY?: number;
|
|
145
|
+
/** true で説明文を矢印の線の上に重ねる。 分岐図の条件ラベル用。 */
|
|
146
|
+
overlay?: boolean;
|
|
117
147
|
/**
|
|
118
148
|
* canvas pivot (CAR-1693 Phase 1) DSL 表面 `pos: {x, y}` = edge label offset。 未指定は
|
|
119
149
|
* backward compat、 set 済は Phase 2 の applyPosOffset で edge label 位置を shift する。
|
|
@@ -132,6 +162,19 @@ export interface JsonPhase {
|
|
|
132
162
|
body?: string;
|
|
133
163
|
/** badge label */
|
|
134
164
|
badge?: string;
|
|
165
|
+
/**
|
|
166
|
+
* 段の中で値を動かす (#1186)。 記法の `tween: name 100 -> 90` と同じ。
|
|
167
|
+
*
|
|
168
|
+
* 足すまで JSON の入口は `states:` で初期値を書けても **動かす手段が無かった** ため、
|
|
169
|
+
* 同じ図を記法で書くと動き JSON で書くと静止する状態だった (#1181 で状態を足した時の残り)。
|
|
170
|
+
*/
|
|
171
|
+
tween?: Record<string, readonly [number, number]>;
|
|
172
|
+
/**
|
|
173
|
+
* 段の切替で値を差し替える (#1186)。 記法の `set: name value` と同じ。
|
|
174
|
+
*
|
|
175
|
+
* `tween` が段の中を補間するのに対し、こちらは段の切替時に 1 度だけ変える。
|
|
176
|
+
*/
|
|
177
|
+
set?: Record<string, number | string>;
|
|
135
178
|
}
|
|
136
179
|
|
|
137
180
|
/**
|
|
@@ -155,20 +198,13 @@ const VALID_KIND_SET: ReadonlySet<string> = new Set([
|
|
|
155
198
|
"contract", "eoa", "multisig", "proxy", "library", "interface",
|
|
156
199
|
]);
|
|
157
200
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
"solidity",
|
|
166
|
-
"gantt",
|
|
167
|
-
"class",
|
|
168
|
-
"pie",
|
|
169
|
-
"c4",
|
|
170
|
-
"mind",
|
|
171
|
-
] as const;
|
|
201
|
+
/**
|
|
202
|
+
* 受け付ける図種。 **記法側と同じ集合を使う** (`v05/parser.ts` の `PRESET_TYPES`)。
|
|
203
|
+
*
|
|
204
|
+
* 以前はここに一覧を写していたため、 記法に型を足しても JSON 経路が古い一覧のまま弾いた
|
|
205
|
+
* (`bar` / `line` で実際に起きた)。 型が 3 箇所に散らばると、 必ずどれかが古くなる。
|
|
206
|
+
*/
|
|
207
|
+
const VALID_PRESETS: readonly PresetType[] = [...PRESET_TYPES];
|
|
172
208
|
|
|
173
209
|
/**
|
|
174
210
|
* shape validation。 layer 1 = 必須 field + 型 check、 layer 2 は compile 側の validation に委譲。
|
|
@@ -193,16 +229,266 @@ function validateLayoutPos(v: unknown, path: string, errors: JsonDslError[]): vo
|
|
|
193
229
|
}
|
|
194
230
|
}
|
|
195
231
|
|
|
232
|
+
/**
|
|
233
|
+
* 写しを作る時の入れ子の深さの上限。
|
|
234
|
+
*
|
|
235
|
+
* 枠の並びの長さが入れ子の深さで決まる。 図の入れ子は深くても数段で、 64 に届く形は書けない。
|
|
236
|
+
*/
|
|
237
|
+
const 写しの最大の深さ = 64;
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* 写しを作る時に触る値の数の上限。
|
|
241
|
+
*
|
|
242
|
+
* **書式の規則ではなく、 資源を使い切らないための歯止め**。 図の書式は値の数を制限していないので、
|
|
243
|
+
* ここで拒むのは「構造としては正しいが大きすぎる」 入力になる。 だから **正当な入力が届かない
|
|
244
|
+
* 高さ** に置く。
|
|
245
|
+
*
|
|
246
|
+
* 500 万は、 記法の入力の大きさの上限 (`input-size.ts` の 512KB) を全て 2 文字の値で埋めても
|
|
247
|
+
* 届かない数になる。 JSON でも同じ規模の図が 500 万個の値を持つことはない。
|
|
248
|
+
*
|
|
249
|
+
* **数を数えないと守れない** (Round 4 の指摘)。 一度は「写しの大きさは元の入力の大きさで決まる
|
|
250
|
+
* から数える意味が無い」 として外したが、 これは誤りだった。 Proxy は読まれるたびに新しい object
|
|
251
|
+
* を返せるため、 **小さな入力から枝を生やせる** (実測 = 1 個の Proxy が深さ 6 / 6 分岐で
|
|
252
|
+
* 55,987 個の object に膨らんだ)。 深さの上限だけでは横の広がりを止められない。
|
|
253
|
+
*/
|
|
254
|
+
const 写しの最大の項目数 = 5_000_000;
|
|
255
|
+
|
|
256
|
+
/** 写しを作れなかった理由 (path 付き) */
|
|
257
|
+
class 写せない extends Error {
|
|
258
|
+
constructor(
|
|
259
|
+
readonly path: string,
|
|
260
|
+
readonly 理由: string,
|
|
261
|
+
) {
|
|
262
|
+
super(`${path}: ${理由}`);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* 検査の前に 1 度だけ読んで作る、 素のデータの複製 (#1217)。
|
|
268
|
+
*
|
|
269
|
+
* 入口は検査する時と図に写す時で同じ項目を 2 度読んでいた。 渡された object が値を返す関数
|
|
270
|
+
* (getter) を持っていると、 2 度目の読み取りで別の値を返せる = **検査を通った値と図に届く値が
|
|
271
|
+
* 別物になり、 検査が意味を持たない** (実測 = `animation[0].tween` を 6 回目から
|
|
272
|
+
* `[NaN, Infinity]` を返す getter にすると、 検査を通って図に `from: null` が届いた)。
|
|
273
|
+
*
|
|
274
|
+
* ここで 1 度だけ読んで写しを作り、 以降は写しだけを読む。 各項目の読み取りは 1 回で、
|
|
275
|
+
* 項目の名前も添字も同じ値を 2 度取りに行かない。
|
|
276
|
+
*
|
|
277
|
+
* **`structuredClone` は使わない**。 関数や symbol を含む入力で `DataCloneError` を投げるため、
|
|
278
|
+
* `validateDragonJson` が約束している「誤りは `{ ok: false, errors }` で返す」 が破れる。
|
|
279
|
+
* 自前で写せば、 写せない値もそのまま持ち越して検査側の型の判定に落とせる。
|
|
280
|
+
*
|
|
281
|
+
* **再帰では書かない** (Round 1 の指摘)。 検査が見ない項目も含めて写すため、 深い入れ子を渡すと
|
|
282
|
+
* 呼び出しの積み上げが溢れる (実測 = 使わない項目に 20,000 段の入れ子を付けると
|
|
283
|
+
* `RangeError: Maximum call stack size exceeded`)。 枠を自前で積んで回す。
|
|
284
|
+
*
|
|
285
|
+
* **読む順は書いた順のまま、 深さ優先で降りる** (Round 2 / 3 の指摘)。 値を返す関数が副作用を
|
|
286
|
+
* 持つ入力では読む順が結果に出るため、 再帰で書いた時と同じ順を保つ。 幅優先で回すと、 先に
|
|
287
|
+
* 書いた兄弟の深い所より後の兄弟の浅い所を先に読む。
|
|
288
|
+
*
|
|
289
|
+
* **読み取りの例外も外に出さない** (Round 1 の指摘)。 項目の名前を数える所も値を読む所も、
|
|
290
|
+
* getter や Proxy が投げれば `validateDragonJson` 自体が throw して約束が破れる。 投げた場所を
|
|
291
|
+
* path として拾い、 検査の誤りに変える。
|
|
292
|
+
*
|
|
293
|
+
* 書き込みは `Object.defineProperty` で行う = `__proto__` を項目名に持つ入力で代入が
|
|
294
|
+
* prototype の setter に落ちるのを避ける (`JSON.parse` と同じく普通の項目として持つ)。
|
|
295
|
+
* `__proto__` を書いた時の扱いそのものは `#1184` が持つ。
|
|
296
|
+
*
|
|
297
|
+
* 輪 (自分を指す入れ子) は同じ写しを返して止める。 JSON からは作れないが、 object を直接
|
|
298
|
+
* 渡す経路では作れる。
|
|
299
|
+
*
|
|
300
|
+
* ## 守る範囲 (Round 6 で線を引いた)
|
|
301
|
+
*
|
|
302
|
+
* この関数が守るのは **自分が確保する量** = 写しの入れ物と、 名前の一覧と、 枠の並び。 いずれも
|
|
303
|
+
* 上限 (深さ / 数) を見てから作る。
|
|
304
|
+
*
|
|
305
|
+
* **渡された側が自分で確保する量は守れない**。 Proxy の `ownKeys` は「名前の並びを返す」 のが
|
|
306
|
+
* 仕事で、 その並びは trap の中で作られる。 こちらが受け取った時点で既に在るため、 長さを見て
|
|
307
|
+
* 拒んでも確保そのものは起きた後になる。 これは呼ぶ側の code が確保するもので、 同じ process に
|
|
308
|
+
* 任意の object を渡せる相手は、 この関数を通さずに同じことができる。
|
|
309
|
+
*
|
|
310
|
+
* 線を引くのは、 5 round にわたって「読む前に量を作れる経路」 を潰し続けた末に、 残りが
|
|
311
|
+
* 呼ぶ側の code の中に移ったため。 潰す対象が自分の外に出た時点で、 この関数の責務ではない。
|
|
312
|
+
*/
|
|
313
|
+
function 素のデータに写す(
|
|
314
|
+
root: unknown,
|
|
315
|
+
): { ok: true; value: unknown } | { ok: false; error: JsonDslError } {
|
|
316
|
+
const 写し済 = new WeakMap<object, unknown[] | Record<string, unknown>>();
|
|
317
|
+
let 項目数 = 0;
|
|
318
|
+
// 例外を拾った時に「どこを読んでいたか」 を言うために持つ。 投げるのは値を読む所と名前を
|
|
319
|
+
// 数える所の両方で、 どちらも path を持たないまま外へ出ると `$` としか言えない
|
|
320
|
+
let 読んでいる場所 = "$";
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* まだ中身を埋めていない入れ物と、 その進み具合。
|
|
324
|
+
*
|
|
325
|
+
* 配列は名前の並びを持たず長さだけを持つ (Round 6 の指摘)。 添字を文字の並びとして実体化すると、
|
|
326
|
+
* **上限を見る前にその並びを作ってしまう** (実測 = `new Array(5_000_001)` で 500 万個の添字を
|
|
327
|
+
* 作ろうとした)。 添字は数から導けるので持つ必要がない。
|
|
328
|
+
*/
|
|
329
|
+
type 枠 = {
|
|
330
|
+
元: object;
|
|
331
|
+
器: unknown[] | Record<string, unknown>;
|
|
332
|
+
/** object の時だけ持つ。 配列は `長さ` を使う */
|
|
333
|
+
名前の並び: string[] | null;
|
|
334
|
+
長さ: number;
|
|
335
|
+
次: number;
|
|
336
|
+
深さ: number;
|
|
337
|
+
path: string;
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
/** 入れ物だけ作る (ここでは降りない)。 新しく作った時だけ枠を返す */
|
|
341
|
+
const 器を作る = (
|
|
342
|
+
v: unknown,
|
|
343
|
+
深さ: number,
|
|
344
|
+
path: string,
|
|
345
|
+
): { 値: unknown; 枠: 枠 | null } => {
|
|
346
|
+
項目数 += 1;
|
|
347
|
+
if (項目数 > 写しの最大の項目数) {
|
|
348
|
+
throw new 写せない(path, `項目が多すぎる (上限 ${写しの最大の項目数})`);
|
|
349
|
+
}
|
|
350
|
+
if (v === null || typeof v !== "object") return { 値: v, 枠: null };
|
|
351
|
+
|
|
352
|
+
const 既にある = 写し済.get(v);
|
|
353
|
+
if (既にある !== undefined) return { 値: 既にある, 枠: null };
|
|
354
|
+
|
|
355
|
+
if (深さ >= 写しの最大の深さ) {
|
|
356
|
+
throw new 写せない(path, `入れ子が深すぎる (上限 ${写しの最大の深さ})`);
|
|
357
|
+
}
|
|
358
|
+
読んでいる場所 = path;
|
|
359
|
+
const 並びか = Array.isArray(v);
|
|
360
|
+
const 器: unknown[] | Record<string, unknown> = 並びか ? [] : {};
|
|
361
|
+
写し済.set(v, 器);
|
|
362
|
+
|
|
363
|
+
if (並びか) {
|
|
364
|
+
// **長さを先に見てから降りる** (Round 6 の指摘)。 添字を文字の並びとして作ると、 上限を
|
|
365
|
+
// 見る前にその並びを作ってしまう。 長さは数を読むだけなので何も作らない。
|
|
366
|
+
//
|
|
367
|
+
// **長さは正規化してから使う** (Round 7 の指摘)。 `Array.from({ length })` は仕様の
|
|
368
|
+
// `ToLength` を通しており、 生の値をそのまま使うと 2 つの形で壊れる。
|
|
369
|
+
//
|
|
370
|
+
// | `length` が返す値 | 正規化しないと |
|
|
371
|
+
// |---|---|
|
|
372
|
+
// | `2.5` | 3 回読む (`Array.from` は 2 要素) |
|
|
373
|
+
// | `NaN` | 数の合計が `NaN` になり、 上限も終わりも判定できず読み続ける |
|
|
374
|
+
//
|
|
375
|
+
// `ToLength` と同じく 0 へ丸め、 0 以上 2^53-1 以下に収める。
|
|
376
|
+
//
|
|
377
|
+
// **数に直すのは単項 `+`** (Round 8 の指摘)。 `Number()` は `BigInt` を通してしまうが、
|
|
378
|
+
// 仕様の `ToNumber` は `TypeError` を投げる = `Array.from({ length: 2n })` は投げる。
|
|
379
|
+
// 単項 `+` は `ToNumber` そのものなので、 投げる形も含めて元の挙動と揃う (投げた分は
|
|
380
|
+
// 下の `catch` が検査の誤りに変える)。
|
|
381
|
+
const 生の長さ = +(v as unknown[]).length;
|
|
382
|
+
const 長さ = Number.isNaN(生の長さ)
|
|
383
|
+
? 0
|
|
384
|
+
: Math.min(Math.max(Math.trunc(生の長さ), 0), Number.MAX_SAFE_INTEGER);
|
|
385
|
+
項目数 += 長さ;
|
|
386
|
+
if (項目数 > 写しの最大の項目数) {
|
|
387
|
+
throw new 写せない(path, `項目が多すぎる (上限 ${写しの最大の項目数})`);
|
|
388
|
+
}
|
|
389
|
+
return { 値: 器, 枠: { 元: v, 器, 名前の並び: null, 長さ, 次: 0, 深さ, path } };
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
// **名前も数に入れる** (Round 5 の指摘)。 値を読む前に名前の一覧を作るため、 値だけを
|
|
393
|
+
// 数えると「名前が 20,000 個ある段を 63 回降りる」 形で 126 万個を並べられる = 上限を
|
|
394
|
+
// 見る前に資源を使い切れる
|
|
395
|
+
const 名前の並び = Object.keys(v as Record<string, unknown>);
|
|
396
|
+
項目数 += 名前の並び.length;
|
|
397
|
+
if (項目数 > 写しの最大の項目数) {
|
|
398
|
+
throw new 写せない(path, `項目が多すぎる (上限 ${写しの最大の項目数})`);
|
|
399
|
+
}
|
|
400
|
+
return { 値: 器, 枠: { 元: v, 器, 名前の並び, 長さ: 名前の並び.length, 次: 0, 深さ, path } };
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
try {
|
|
404
|
+
const 先頭 = 器を作る(root, 0, "$");
|
|
405
|
+
const 積み: 枠[] = 先頭.枠 ? [先頭.枠] : [];
|
|
406
|
+
|
|
407
|
+
while (積み.length > 0) {
|
|
408
|
+
const 今 = 積み[積み.length - 1]!;
|
|
409
|
+
if (今.次 >= 今.長さ) {
|
|
410
|
+
積み.pop();
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// 配列は添字をその場で作る (並びとして持たない)
|
|
414
|
+
const key = 今.名前の並び === null ? String(今.次) : 今.名前の並び[今.次]!;
|
|
415
|
+
今.次 += 1;
|
|
416
|
+
const 子のpath = 今.名前の並び === null ? `${今.path}[${key}]` : `${今.path}.${key}`;
|
|
417
|
+
|
|
418
|
+
// 読む直前に場所を控える = 値の読み取りそのものが投げるため、 読んだ後では遅い
|
|
419
|
+
読んでいる場所 = 子のpath;
|
|
420
|
+
const 生の値 = (今.元 as Record<string, unknown>)[key];
|
|
421
|
+
|
|
422
|
+
const 子 = 器を作る(生の値, 今.深さ + 1, 子のpath);
|
|
423
|
+
if (Array.isArray(今.器)) 今.器.push(子.値);
|
|
424
|
+
else {
|
|
425
|
+
Object.defineProperty(今.器, key, {
|
|
426
|
+
value: 子.値,
|
|
427
|
+
enumerable: true,
|
|
428
|
+
writable: true,
|
|
429
|
+
configurable: true,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
// 深さ優先で降りる = 次の兄弟を読む前に、 この子の中身を全部読む
|
|
433
|
+
if (子.枠) 積み.push(子.枠);
|
|
434
|
+
}
|
|
435
|
+
return { ok: true, value: 先頭.値 };
|
|
436
|
+
} catch (e) {
|
|
437
|
+
if (e instanceof 写せない) {
|
|
438
|
+
return { ok: false, error: { path: e.path, message: e.理由 } };
|
|
439
|
+
}
|
|
440
|
+
// getter / Proxy が投げた形。 約束どおり誤りとして返す (throw しない)
|
|
441
|
+
return {
|
|
442
|
+
ok: false,
|
|
443
|
+
error: {
|
|
444
|
+
path: 読んでいる場所,
|
|
445
|
+
message: "入力を読み取れない",
|
|
446
|
+
hint: e instanceof Error ? e.message : String(e),
|
|
447
|
+
},
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
196
452
|
function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: false; errors: JsonDslError[] } {
|
|
197
453
|
const errors: JsonDslError[] = [];
|
|
198
|
-
|
|
454
|
+
// root の形は写しより先に見る = 形が違う入力には従来どおり `root must be a JSON object` を
|
|
455
|
+
// 返すため。 写した後に見ると、 root が配列の入力で中の getter が先に動き、 別の誤りに化ける
|
|
456
|
+
// (Round 3 の指摘)。
|
|
457
|
+
//
|
|
458
|
+
// ただし `Array.isArray` は失効した Proxy で `TypeError` を投げる (Round 2 の指摘)。 判定
|
|
459
|
+
// そのものを受けて、 投げた形は「読み取れない」 として返す。
|
|
460
|
+
let rootがobjectか: boolean;
|
|
461
|
+
try {
|
|
462
|
+
rootがobjectか = !!json && typeof json === "object" && !Array.isArray(json);
|
|
463
|
+
} catch (e) {
|
|
464
|
+
return {
|
|
465
|
+
ok: false,
|
|
466
|
+
errors: [
|
|
467
|
+
{
|
|
468
|
+
path: "$",
|
|
469
|
+
message: "入力を読み取れない",
|
|
470
|
+
hint: e instanceof Error ? e.message : String(e),
|
|
471
|
+
},
|
|
472
|
+
],
|
|
473
|
+
};
|
|
474
|
+
}
|
|
475
|
+
if (!rootがobjectか) {
|
|
199
476
|
return { ok: false, errors: [{ path: "$", message: "root must be a JSON object" }] };
|
|
200
477
|
}
|
|
201
|
-
|
|
478
|
+
|
|
479
|
+
// 以降は写しだけを読む。 元の object には二度と触らない (#1217)
|
|
480
|
+
const 写し = 素のデータに写す(json);
|
|
481
|
+
if (!写し.ok) return { ok: false, errors: [写し.error] };
|
|
482
|
+
const j = 写し.value as Record<string, unknown>;
|
|
202
483
|
|
|
203
484
|
if (typeof j.title !== "string" || j.title.length === 0) {
|
|
204
485
|
errors.push({ path: "$.title", message: "title must be a non-empty string" });
|
|
205
486
|
}
|
|
487
|
+
// 図表の箱の上の小見出し (#1247)。 空文字は「書かなかった」 と同じ扱いにするため通す
|
|
488
|
+
// (記法側の `eyebrow:` と揃える。 落とすのは `jsonToDoc`)
|
|
489
|
+
if (j.eyebrow !== undefined && typeof j.eyebrow !== "string") {
|
|
490
|
+
errors.push({ path: "$.eyebrow", message: "eyebrow must be a string if present" });
|
|
491
|
+
}
|
|
206
492
|
// CAR-1693 Phase 1: diagram-level layout mode の validation (未指定 = auto default で backward compat)
|
|
207
493
|
if (j.layout !== undefined && j.layout !== "auto" && j.layout !== "manual") {
|
|
208
494
|
errors.push({ path: "$.layout", message: 'layout must be "auto" or "manual" if present' });
|
|
@@ -290,19 +576,153 @@ function validateJson(json: unknown): { ok: true; data: DragonJson } | { ok: fal
|
|
|
290
576
|
if (typeof po.step !== "string" || po.step.length === 0) {
|
|
291
577
|
errors.push({ path: `$.animation[${i}].step`, message: "phase.step must be a non-empty string" });
|
|
292
578
|
}
|
|
579
|
+
validatePhaseMotion(po, i, errors);
|
|
293
580
|
});
|
|
294
581
|
}
|
|
295
582
|
}
|
|
583
|
+
validateStates(j.states, errors);
|
|
584
|
+
validateValues(j.values, errors);
|
|
296
585
|
if (errors.length > 0) return { ok: false, errors };
|
|
297
586
|
return { ok: true, data: j as unknown as DragonJson };
|
|
298
587
|
}
|
|
299
588
|
|
|
589
|
+
/**
|
|
590
|
+
* 段の中で値を動かす指定を見る (#1186)。
|
|
591
|
+
*
|
|
592
|
+
* 状態名の記法は `states:` と同じ判定を使う。 参照先はこの JSON の `states` だけでは決めない。
|
|
593
|
+
* 見本や preset が持つ状態を動かす指定もあるためで、記法側と同じく compile 後の図で解決する。
|
|
594
|
+
*/
|
|
595
|
+
function validatePhaseMotion(
|
|
596
|
+
po: Record<string, unknown>,
|
|
597
|
+
i: number,
|
|
598
|
+
errors: JsonDslError[],
|
|
599
|
+
): void {
|
|
600
|
+
if (po.tween !== undefined) {
|
|
601
|
+
if (!po.tween || typeof po.tween !== "object" || Array.isArray(po.tween)) {
|
|
602
|
+
errors.push({
|
|
603
|
+
path: `$.animation[${i}].tween`,
|
|
604
|
+
message: "tween must be a plain object of state -> [from, to]",
|
|
605
|
+
});
|
|
606
|
+
} else {
|
|
607
|
+
for (const [name, range] of Object.entries(po.tween as Record<string, unknown>)) {
|
|
608
|
+
const path = `$.animation[${i}].tween.${name}`;
|
|
609
|
+
if (!isValueName(name)) errors.push({ path, ...valueNameIssue(name) });
|
|
610
|
+
if (!Array.isArray(range) || range.length !== 2) {
|
|
611
|
+
errors.push({ path, message: "tween value must be [from, to]" });
|
|
612
|
+
continue;
|
|
613
|
+
}
|
|
614
|
+
// 補間は数どうしでしか成り立たない。 文字列を通すと描画側が数として読めず
|
|
615
|
+
// 段の途中が壊れる (記法側も数だけを受ける)
|
|
616
|
+
for (const v of range) {
|
|
617
|
+
if (typeof v !== "number" || !Number.isFinite(v)) {
|
|
618
|
+
errors.push({ path, message: "tween value must be finite numbers", hint: `got ${typeof v}` });
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (po.set !== undefined) {
|
|
627
|
+
if (!po.set || typeof po.set !== "object" || Array.isArray(po.set)) {
|
|
628
|
+
errors.push({
|
|
629
|
+
path: `$.animation[${i}].set`,
|
|
630
|
+
message: "set must be a plain object of state -> value",
|
|
631
|
+
});
|
|
632
|
+
} else {
|
|
633
|
+
for (const [name, value] of Object.entries(po.set as Record<string, unknown>)) {
|
|
634
|
+
const path = `$.animation[${i}].set.${name}`;
|
|
635
|
+
if (!isValueName(name)) errors.push({ path, ...valueNameIssue(name) });
|
|
636
|
+
const t = typeof value;
|
|
637
|
+
if (t !== "number" && t !== "string") {
|
|
638
|
+
errors.push({ path, message: "set value must be a number or string", hint: `got ${t}` });
|
|
639
|
+
} else if (t === "number" && !Number.isFinite(value as number)) {
|
|
640
|
+
errors.push({ path, message: "set value must be a finite number" });
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* 状態の初期値を見る (#1181)。
|
|
649
|
+
*
|
|
650
|
+
* 名前の判定は記法と同じものを使う (`value-syntax.ts`)。 別々に持つと、YAML では弾かれる
|
|
651
|
+
* 名前が JSON では通る形ができ、描画側が `{名前}` を置き換えられない図が生まれる。
|
|
652
|
+
*/
|
|
653
|
+
function validateStates(v: unknown, errors: JsonDslError[]): void {
|
|
654
|
+
if (v === undefined) return;
|
|
655
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
656
|
+
errors.push({ path: "$.states", message: "states must be a plain object of name -> initial value" });
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
for (const [name, initial] of Object.entries(v as Record<string, unknown>)) {
|
|
660
|
+
if (!isValueName(name)) {
|
|
661
|
+
errors.push({ path: `$.states.${name}`, ...valueNameIssue(name) });
|
|
662
|
+
}
|
|
663
|
+
const t = typeof initial;
|
|
664
|
+
if (t !== "number" && t !== "string") {
|
|
665
|
+
errors.push({
|
|
666
|
+
path: `$.states.${name}`,
|
|
667
|
+
message: "state initial must be a number or string",
|
|
668
|
+
hint: `got ${t}`,
|
|
669
|
+
});
|
|
670
|
+
} else if (t === "number" && !Number.isFinite(initial as number)) {
|
|
671
|
+
// `NaN` / `Infinity` は JSON には書けないが、object を直接渡す経路では届く。
|
|
672
|
+
// 描画側は文字列に直して式に流すため、そのまま通すと計算が全て壊れる
|
|
673
|
+
errors.push({ path: `$.states.${name}`, message: "state initial must be a finite number" });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* 他の値から決まる値を見る (#1181)。
|
|
680
|
+
*
|
|
681
|
+
* 名前と式の判定は記法と同じものを使う。 式が文法として正しいかまでは見ない (描画側が
|
|
682
|
+
* 評価する時に判定して、その値だけを止める = spec § 4.2)。
|
|
683
|
+
*/
|
|
684
|
+
function validateValues(v: unknown, errors: JsonDslError[]): void {
|
|
685
|
+
if (v === undefined) return;
|
|
686
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
687
|
+
errors.push({ path: "$.values", message: "values must be a plain object of name -> expression" });
|
|
688
|
+
return;
|
|
689
|
+
}
|
|
690
|
+
for (const [name, expression] of Object.entries(v as Record<string, unknown>)) {
|
|
691
|
+
if (!isValueName(name)) {
|
|
692
|
+
errors.push({ path: `$.values.${name}`, ...valueNameIssue(name) });
|
|
693
|
+
}
|
|
694
|
+
if (typeof expression !== "string" || expression.trim() === "") {
|
|
695
|
+
errors.push({
|
|
696
|
+
path: `$.values.${name}`,
|
|
697
|
+
message: "value expression must be a non-empty string",
|
|
698
|
+
hint: '`"{inflow} - {done}"` の形で書く',
|
|
699
|
+
});
|
|
700
|
+
continue;
|
|
701
|
+
}
|
|
702
|
+
for (const issue of checkValueExpression(expression, name)) {
|
|
703
|
+
errors.push({ path: `$.values.${name}`, ...issue });
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
300
708
|
/**
|
|
301
709
|
* JSON DSL → DslDocument (AST) 変換。 pos は JSON なので line 情報なし、 全て line 0。
|
|
302
710
|
*
|
|
303
711
|
* CAR-1693 Phase 1: DSL 表面 `pos: {x, y}` → 内部 AST `layoutPos:` の 2 層 mapping の実装 core。
|
|
304
712
|
* test で mapping logic を実 execute するため export する (pos-field.test.ts の regression guard)。
|
|
305
713
|
*/
|
|
714
|
+
/**
|
|
715
|
+
* 図表の箱の上の小見出しを、 記法側と同じ形に整える (#1247)。
|
|
716
|
+
*
|
|
717
|
+
* 記法は値を `trim()` してから空かどうかを見る。 JSON でも同じ順で見ないと、 空白だけの値が
|
|
718
|
+
* 入口によって別の意味になる (記法は書かなかった扱い、 JSON は中身のない帯)。
|
|
719
|
+
*/
|
|
720
|
+
function 整えた小見出し(v: string | undefined): string | undefined {
|
|
721
|
+
if (v === undefined) return undefined;
|
|
722
|
+
const t = v.trim();
|
|
723
|
+
return t.length > 0 ? t : undefined;
|
|
724
|
+
}
|
|
725
|
+
|
|
306
726
|
export function jsonToDoc(json: DragonJson): DslDocument {
|
|
307
727
|
const p0 = { line: 0 };
|
|
308
728
|
const actors: DslActor[] = json.actors.map((a) => {
|
|
@@ -345,28 +765,55 @@ export function jsonToDoc(json: DragonJson): DslDocument {
|
|
|
345
765
|
cardinality: s.cardinality,
|
|
346
766
|
labelOffsetX: s.labelOffsetX,
|
|
347
767
|
labelOffsetY: s.labelOffsetY,
|
|
768
|
+
overlay: s.overlay,
|
|
348
769
|
// CAR-1693 Phase 1: DSL 表面 pos → 内部 AST layoutPos
|
|
349
770
|
layoutPos: s.pos,
|
|
350
771
|
pos: p0,
|
|
351
772
|
}));
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
773
|
+
// 状態は段が無くても図に載る (#1162 で組み立ての出口が載せる)。 **段の有無で分けない** =
|
|
774
|
+
// 分けると `states` だけを書いた JSON で値が 1 つも届かない (記法側で起きていた形、 #1181)
|
|
775
|
+
const states: DslState[] = Object.entries(json.states ?? {}).map(([name, initial]) => ({
|
|
776
|
+
name,
|
|
777
|
+
initial,
|
|
778
|
+
pos: p0,
|
|
779
|
+
}));
|
|
780
|
+
const phases: DslPhase[] = (json.animation ?? []).map((p) => ({
|
|
781
|
+
name: p.step,
|
|
782
|
+
durationMs: Math.round((p.duration ?? 1.4) * 1000),
|
|
783
|
+
highlight: p.focus,
|
|
784
|
+
body: p.body,
|
|
785
|
+
badge: p.badge,
|
|
786
|
+
// 段の中で動かす分 (#1186)。 記法側の `tweens` / `sets` と同じ形に写す。
|
|
787
|
+
// 空の配列を置かないのは、記法側が「無ければ field ごと持たない」 形だから
|
|
788
|
+
...(p.tween && Object.keys(p.tween).length > 0
|
|
789
|
+
? {
|
|
790
|
+
tweens: Object.entries(p.tween).map(([state, [from, to]]) => ({ state, from, to, pos: p0 })),
|
|
791
|
+
}
|
|
792
|
+
: {}),
|
|
793
|
+
...(p.set && Object.keys(p.set).length > 0
|
|
794
|
+
? { sets: Object.entries(p.set).map(([state, value]) => ({ state, value, pos: p0 })) }
|
|
795
|
+
: {}),
|
|
796
|
+
pos: p0,
|
|
797
|
+
}));
|
|
798
|
+
const animate: DslAnimate | undefined =
|
|
799
|
+
states.length > 0 || phases.length > 0 ? { states, phases, pos: p0 } : undefined;
|
|
364
800
|
return {
|
|
365
801
|
title: json.title,
|
|
366
802
|
type: json.type,
|
|
803
|
+
// 前後の空白を落としてから見る。 記法側 (`v05/parser.ts`) が `trim()` してから
|
|
804
|
+
// 空かどうかを判定するため、 揃えないと **空白だけの値で入口ごとに図が変わる**
|
|
805
|
+
// (記法は書かなかった扱い、 JSON は中身のない帯を描く。 Round 2 の指摘で実測)
|
|
806
|
+
...(整えた小見出し(json.eyebrow) !== undefined
|
|
807
|
+
? { eyebrow: 整えた小見出し(json.eyebrow), eyebrowPos: p0 }
|
|
808
|
+
: {}),
|
|
367
809
|
actors,
|
|
368
810
|
flow,
|
|
369
811
|
animate,
|
|
812
|
+
// 他の値から決まる値 (#1181)。 書いた順に並べる = 解く順は参照から決まるので順序に
|
|
813
|
+
// 意味は無いが、知らせの並びが書いた順になる
|
|
814
|
+
values: json.values
|
|
815
|
+
? Object.entries(json.values).map(([name, expression]) => ({ name, expression, pos: p0 }))
|
|
816
|
+
: undefined,
|
|
370
817
|
viewport: json.viewport ? { ...json.viewport, pos: p0 } : undefined,
|
|
371
818
|
lanes: json.lanes
|
|
372
819
|
? Object.fromEntries(
|
|
@@ -412,7 +859,10 @@ export function jsonToDoc(json: DragonJson): DslDocument {
|
|
|
412
859
|
*/
|
|
413
860
|
export function jsonToDiagram(
|
|
414
861
|
json: unknown,
|
|
415
|
-
|
|
862
|
+
// **`onNotice` も通す**。 記法経路だけに通知を付けていたため、 同じ型を受ける JSON / YAML
|
|
863
|
+
// 経路では読めない値や捨てた矢印が利用者へ届かなかった (review 指摘)。 エディタの YAML タブは
|
|
864
|
+
// ここを通る
|
|
865
|
+
opts?: { partsCatalog?: Record<string, CdlDiagram>; onNotice?: CompileToCdlOpts["onNotice"] },
|
|
416
866
|
): CdlDiagram {
|
|
417
867
|
const v = validateJson(json);
|
|
418
868
|
if (!v.ok) {
|