@cardenelabs/dragon 0.7.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/LICENSE +21 -0
- package/README.md +173 -0
- package/dist/index.cjs +4916 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +1451 -0
- package/dist/index.d.ts +1451 -0
- package/dist/index.js +4870 -0
- package/dist/index.js.map +1 -0
- package/examples/quick-start.md +142 -0
- package/package.json +79 -0
- package/src/canvas-bounds.ts +52 -0
- package/src/color.ts +172 -0
- package/src/compile.ts +3739 -0
- package/src/focus.ts +46 -0
- package/src/index.ts +226 -0
- package/src/input-size.ts +154 -0
- package/src/json-parser.ts +434 -0
- package/src/keywords.ts +114 -0
- package/src/notation-lint.ts +304 -0
- package/src/parser.ts +354 -0
- package/src/relative-pos.ts +182 -0
- package/src/schema.ts +24 -0
- package/src/schemas/diagram.json +133 -0
- package/src/types.ts +294 -0
- package/src/v05/index.ts +9 -0
- package/src/v05/parser.ts +1678 -0
- package/src/write-position.ts +270 -0
|
@@ -0,0 +1,1678 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text DSL v0.5 parser
|
|
3
|
+
*
|
|
4
|
+
* 設計方針:
|
|
5
|
+
* - keyword は英語のみ (title / type / actors / flow / states / animation / step / focus / tween / set / badge)
|
|
6
|
+
* - 値の日本語は quote 必須 (`title: "API call"` / `step: "request" 1.5s`)
|
|
7
|
+
* - YAML 風 + 短縮 keyword + 箇条書き構造
|
|
8
|
+
* - Mermaid 知ってる人にもゼロ学習、 非エンジニアにも直感的
|
|
9
|
+
*
|
|
10
|
+
* syntax 例:
|
|
11
|
+
*
|
|
12
|
+
* title: "API call"
|
|
13
|
+
* type: sequence
|
|
14
|
+
*
|
|
15
|
+
* actors:
|
|
16
|
+
* - Client
|
|
17
|
+
* - API: function
|
|
18
|
+
* - DB
|
|
19
|
+
*
|
|
20
|
+
* flow:
|
|
21
|
+
* - Client -> API: "GET /items"
|
|
22
|
+
* - API -> DB: "SELECT" (success)
|
|
23
|
+
*
|
|
24
|
+
* states:
|
|
25
|
+
* request_count: 0
|
|
26
|
+
* row_count: 0
|
|
27
|
+
*
|
|
28
|
+
* animation:
|
|
29
|
+
* - step: "request" 1.5s
|
|
30
|
+
* focus: [Client, API]
|
|
31
|
+
* tween:
|
|
32
|
+
* request_count: 0 -> 1
|
|
33
|
+
* badge: "request"
|
|
34
|
+
*
|
|
35
|
+
* - step: "query" 1.5s
|
|
36
|
+
* focus: [API, DB]
|
|
37
|
+
* tween:
|
|
38
|
+
* row_count: 0 -> 20
|
|
39
|
+
* badge: "query"
|
|
40
|
+
*
|
|
41
|
+
* 出力は v0.4 と同じ DslDocument。 既存 compile.ts で CdlDiagram に変換できる。
|
|
42
|
+
*/
|
|
43
|
+
|
|
44
|
+
import type { NodeKind, Tone, EdgeStyle } from "@cardenelabs/cdl";
|
|
45
|
+
import { TONES, NODE_KINDS } from "@cardenelabs/cdl";
|
|
46
|
+
import { TONE_ALIAS } from "../keywords";
|
|
47
|
+
import { parseRelativePos, orderByDependency } from "../relative-pos";
|
|
48
|
+
import type {
|
|
49
|
+
DslDocument,
|
|
50
|
+
DslActor,
|
|
51
|
+
DslActorNodeOverride,
|
|
52
|
+
DslStep,
|
|
53
|
+
DslAnimate,
|
|
54
|
+
DslState,
|
|
55
|
+
DslPhase,
|
|
56
|
+
DslTween,
|
|
57
|
+
DslSet,
|
|
58
|
+
DslError,
|
|
59
|
+
PresetType,
|
|
60
|
+
DslLane,
|
|
61
|
+
DslGroup,
|
|
62
|
+
DslViewport,
|
|
63
|
+
} from "../types";
|
|
64
|
+
|
|
65
|
+
export type V05ParseResult =
|
|
66
|
+
| { ok: true; doc: DslDocument }
|
|
67
|
+
| { ok: false; errors: DslError[] };
|
|
68
|
+
|
|
69
|
+
/** 受け付ける図種。 記法一覧はここを見る。 */
|
|
70
|
+
export const PRESET_TYPES: ReadonlySet<PresetType> = new Set([
|
|
71
|
+
"sequence",
|
|
72
|
+
"flow",
|
|
73
|
+
"swimlane",
|
|
74
|
+
"er",
|
|
75
|
+
"state",
|
|
76
|
+
"topology",
|
|
77
|
+
"solidity",
|
|
78
|
+
"gantt",
|
|
79
|
+
"class",
|
|
80
|
+
"pie",
|
|
81
|
+
"c4",
|
|
82
|
+
"mind",
|
|
83
|
+
]);
|
|
84
|
+
|
|
85
|
+
const NODE_KIND_DEFAULT: NodeKind = "actor";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* 記法だけが持つ種類。 描画側には無いが、 図種ごとの組み立てで意味を持つ。
|
|
89
|
+
*
|
|
90
|
+
* `contract` / `eoa` / `multisig` / `proxy` / `library` / `interface` は Solidity 図の
|
|
91
|
+
* 役割分けに、 `entity` / `state` は ER 図と状態遷移図に使う。 組み立ての段階で描画できる
|
|
92
|
+
* 種類に置き換わるため、 そのまま描画側に渡ることはない。
|
|
93
|
+
*/
|
|
94
|
+
const DSL_ONLY_KINDS = [
|
|
95
|
+
"entity", "state",
|
|
96
|
+
"contract", "eoa", "multisig", "proxy", "library", "interface",
|
|
97
|
+
] as const;
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* AWS などの固有名を、 同じ役割を表す汎用の種類に読み替える表。
|
|
101
|
+
*
|
|
102
|
+
* これらは記法が受け付けるのに描画側に無く、 書くと「kind "alb" は未対応」 とエラーになって
|
|
103
|
+
* いた。 受け付けるのをやめると今度は部品名として扱われ「そんな部品はない」 と出る。 どちらも
|
|
104
|
+
* 書いた人が困るだけなので、 意味の近い種類に読み替えて実際に図が出るようにする。
|
|
105
|
+
*
|
|
106
|
+
* 読み替え先が重なるものがある (`iam` と `kms` は権限と鍵を守る役、 `s3` と `secret` は
|
|
107
|
+
* 保管する役)。 見た目が同じになるが、 役割が同じなので嘘にはならない。
|
|
108
|
+
*/
|
|
109
|
+
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)
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 受け付ける箱の種類。 描画できる種類 (cdl の `NODE_KINDS`) に、 記法だけが持つ種類を足す。
|
|
125
|
+
*
|
|
126
|
+
* 以前は手書きの 31 種だった。 描画できる 90 種のうち 78 種が記法から書けず、 部品名として
|
|
127
|
+
* 扱われて「そんな部品は無い」 と警告が出るだけだった。 描画側を出所に加えることで
|
|
128
|
+
* 「描画できるものは書ける」 が成立する。
|
|
129
|
+
*/
|
|
130
|
+
/**
|
|
131
|
+
* 記法が受理する種類の全体。 これに載っていない種類は見本 (パーツ) の候補になる。
|
|
132
|
+
*
|
|
133
|
+
* 画面側が「本文が見本を使っているか」 を判定するのに使う (#1022)。 手書きの一覧を
|
|
134
|
+
* 別に持つと、種類が増えた時にそちらだけ取り残されて余分な読み込みが起きる。
|
|
135
|
+
*/
|
|
136
|
+
export const NODE_KIND_VALID: ReadonlySet<string> = new Set<string>([
|
|
137
|
+
...NODE_KINDS,
|
|
138
|
+
...DSL_ONLY_KINDS,
|
|
139
|
+
...Object.keys(INFRA_KIND_ALIAS),
|
|
140
|
+
]);
|
|
141
|
+
|
|
142
|
+
// 受理する色名は cdl 側の一覧をそのまま使う。 手書きすると cdl に色が増えた時に取り残される。
|
|
143
|
+
const TONE_VALID: ReadonlySet<string> = new Set<string>(TONES);
|
|
144
|
+
|
|
145
|
+
const STYLE_VALID: ReadonlySet<string> = new Set<string>(["solid", "dotted-flow"]);
|
|
146
|
+
|
|
147
|
+
type Line = {
|
|
148
|
+
raw: string;
|
|
149
|
+
trimmed: string;
|
|
150
|
+
indent: number;
|
|
151
|
+
no: number;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
export function parseTextDslV05(src: string): V05ParseResult {
|
|
155
|
+
const errors: DslError[] = [];
|
|
156
|
+
const lines = tokenize(src);
|
|
157
|
+
|
|
158
|
+
let title: string | null = null;
|
|
159
|
+
let type: PresetType | null = null;
|
|
160
|
+
let actors: DslActor[] = [];
|
|
161
|
+
const flow: DslStep[] = [];
|
|
162
|
+
let animate: DslAnimate | undefined = undefined;
|
|
163
|
+
let viewport: DslViewport | undefined = undefined;
|
|
164
|
+
let lanesMap: Record<string, DslLane> | undefined = undefined;
|
|
165
|
+
let groupsMap: Record<string, DslGroup> | undefined = undefined;
|
|
166
|
+
|
|
167
|
+
let i = 0;
|
|
168
|
+
while (i < lines.length) {
|
|
169
|
+
const line = lines[i]!;
|
|
170
|
+
if (!line.trimmed || line.trimmed.startsWith("#")) {
|
|
171
|
+
i += 1;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const head = matchTopHeader(line.trimmed);
|
|
175
|
+
if (!head) {
|
|
176
|
+
errors.push({
|
|
177
|
+
line: line.no,
|
|
178
|
+
message: `unknown top-level key: "${line.trimmed}"`,
|
|
179
|
+
hint: "expected one of: title, type, actors, flow, states, animation, viewport, lanes, groups",
|
|
180
|
+
});
|
|
181
|
+
i += 1;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (head.key === "title") {
|
|
185
|
+
title = head.value ?? null;
|
|
186
|
+
if (!title) {
|
|
187
|
+
errors.push({ line: line.no, message: "title is required", hint: 'use `title: "..."`' });
|
|
188
|
+
}
|
|
189
|
+
i += 1;
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (head.key === "type") {
|
|
193
|
+
const v = (head.value ?? "").trim().toLowerCase();
|
|
194
|
+
if (!PRESET_TYPES.has(v as PresetType)) {
|
|
195
|
+
errors.push({
|
|
196
|
+
line: line.no,
|
|
197
|
+
message: `unknown type: "${v}"`,
|
|
198
|
+
hint: `expected: ${Array.from(PRESET_TYPES).join(", ")}`,
|
|
199
|
+
});
|
|
200
|
+
} else {
|
|
201
|
+
type = v as PresetType;
|
|
202
|
+
}
|
|
203
|
+
i += 1;
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
if (head.key === "actors") {
|
|
207
|
+
// 1 行で書いた形と、 続く字下げ行に項目を並べた形の両方を受け付ける
|
|
208
|
+
const { items, next } = collectActorEntries(lines, i + 1, line.indent);
|
|
209
|
+
actors = [];
|
|
210
|
+
for (const entry of items) {
|
|
211
|
+
const base = parseActor(entry[0]!, errors);
|
|
212
|
+
if (base === null) {
|
|
213
|
+
errors.push({
|
|
214
|
+
line: entry[0]!.no,
|
|
215
|
+
message: `invalid actor entry: "${entry[0]!.trimmed}"`,
|
|
216
|
+
hint: 'use `- Client` or `- Client: storage`',
|
|
217
|
+
});
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
actors.push(applyContinuationLines(base, entry.slice(1), errors));
|
|
221
|
+
}
|
|
222
|
+
validateRelativePositions(actors, errors);
|
|
223
|
+
i = next;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (head.key === "flow") {
|
|
227
|
+
const { items, next } = collectIndentedList(lines, i + 1, line.indent);
|
|
228
|
+
let stepNo = 1;
|
|
229
|
+
for (const it of items) {
|
|
230
|
+
const step = parseFlowStep(it, stepNo);
|
|
231
|
+
if (step) {
|
|
232
|
+
flow.push(step);
|
|
233
|
+
stepNo += 1;
|
|
234
|
+
} else {
|
|
235
|
+
errors.push({
|
|
236
|
+
line: it.no,
|
|
237
|
+
message: `invalid flow entry: "${it.trimmed}"`,
|
|
238
|
+
hint: 'use `- A -> B: "label"` or `- A -> B: "label" (success)`',
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
i = next;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (head.key === "states") {
|
|
246
|
+
// states は inline (states: { a: 1, b: 2 }) もしくは block (states:\n a: 1\n b: 2)
|
|
247
|
+
const inlineMatch = head.value?.trim();
|
|
248
|
+
if (inlineMatch && inlineMatch.startsWith("{") && inlineMatch.endsWith("}")) {
|
|
249
|
+
const inner = inlineMatch.slice(1, -1).trim();
|
|
250
|
+
animate = ensureAnimate(animate, line.no);
|
|
251
|
+
for (const pair of splitTopLevelCommas(inner)) {
|
|
252
|
+
const st = parseStateEntry(pair, line.no);
|
|
253
|
+
if (st) animate.states.push(st);
|
|
254
|
+
}
|
|
255
|
+
i += 1;
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
const { items, next } = collectIndentedList(lines, i + 1, line.indent);
|
|
259
|
+
animate = ensureAnimate(animate, line.no);
|
|
260
|
+
for (const it of items) {
|
|
261
|
+
const st = parseStateEntry(it.trimmed.replace(/^-\s*/, ""), it.no);
|
|
262
|
+
if (st) animate.states.push(st);
|
|
263
|
+
else errors.push({ line: it.no, message: `invalid state entry: "${it.trimmed}"`, hint: "use `name: initial`" });
|
|
264
|
+
}
|
|
265
|
+
i = next;
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (head.key === "animation") {
|
|
269
|
+
// animation: は step を list で並べる
|
|
270
|
+
const { items: stepBlocks, next } = collectAnimationSteps(lines, i + 1, line.indent);
|
|
271
|
+
animate = ensureAnimate(animate, line.no);
|
|
272
|
+
for (const block of stepBlocks) {
|
|
273
|
+
const ph = parsePhase(block, errors);
|
|
274
|
+
if (ph) animate.phases.push(ph);
|
|
275
|
+
}
|
|
276
|
+
i = next;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
if (head.key === "viewport") {
|
|
280
|
+
// inline mapping: viewport: { width: 1400, height: 900, laneWidth: 480, gap: 80, laneGap: 100, nodeGap: 32, labelMargin: 12 }
|
|
281
|
+
const inline = head.value?.trim();
|
|
282
|
+
if (inline && inline.startsWith("{") && inline.endsWith("}")) {
|
|
283
|
+
const opts = parseInlineMapping(inline.slice(1, -1));
|
|
284
|
+
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),
|
|
293
|
+
pos: { line: line.no },
|
|
294
|
+
};
|
|
295
|
+
i += 1;
|
|
296
|
+
continue;
|
|
297
|
+
}
|
|
298
|
+
// block: viewport:\n width: 1400\n height: 900\n ...
|
|
299
|
+
const { items, next } = collectIndentedList(lines, i + 1, line.indent);
|
|
300
|
+
const opts: Record<string, string> = {};
|
|
301
|
+
for (const it of items) {
|
|
302
|
+
const m = it.trimmed.match(/^([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
|
|
303
|
+
if (m) opts[m[1]!] = stripQuotes(m[2]!.trim());
|
|
304
|
+
}
|
|
305
|
+
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),
|
|
314
|
+
pos: { line: line.no },
|
|
315
|
+
};
|
|
316
|
+
i = next;
|
|
317
|
+
continue;
|
|
318
|
+
}
|
|
319
|
+
if (head.key === "lanes") {
|
|
320
|
+
// lanes:\n l1: { x: 0, width: 320, label: "..." }\n l2: { ... }
|
|
321
|
+
const { items, next } = collectIndentedList(lines, i + 1, line.indent);
|
|
322
|
+
lanesMap = {};
|
|
323
|
+
for (const it of items) {
|
|
324
|
+
const m = it.trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*\{([^}]*)\}\s*$/);
|
|
325
|
+
if (m) {
|
|
326
|
+
const id = m[1]!;
|
|
327
|
+
const opts = parseInlineMapping(m[2]!);
|
|
328
|
+
lanesMap[id] = {
|
|
329
|
+
id,
|
|
330
|
+
x: numberOrUndef(opts.x),
|
|
331
|
+
width: numberOrUndef(opts.width),
|
|
332
|
+
label: opts.label,
|
|
333
|
+
contain: boolOrUndef(opts.contain),
|
|
334
|
+
lifeline: boolOrUndef(opts.lifeline),
|
|
335
|
+
pos: { line: it.no },
|
|
336
|
+
};
|
|
337
|
+
} else {
|
|
338
|
+
errors.push({
|
|
339
|
+
line: it.no,
|
|
340
|
+
message: `invalid lane entry: "${it.trimmed}"`,
|
|
341
|
+
hint: 'use `id: { x: 0, width: 320, label: "..." }`',
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
i = next;
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (head.key === "groups") {
|
|
349
|
+
// groups:\n aws: { label: "AWS", lanes: [ecs, rds] }
|
|
350
|
+
const { items, next } = collectIndentedList(lines, i + 1, line.indent);
|
|
351
|
+
groupsMap = {};
|
|
352
|
+
for (const it of items) {
|
|
353
|
+
const m = it.trimmed.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*\{([^}]*)\}\s*$/);
|
|
354
|
+
if (m) {
|
|
355
|
+
const id = m[1]!;
|
|
356
|
+
const opts = parseInlineMapping(m[2]!);
|
|
357
|
+
const lanesList = (opts.lanes ?? "")
|
|
358
|
+
.replace(/^\[|\]$/g, "")
|
|
359
|
+
.split(",")
|
|
360
|
+
.map((x) => x.trim())
|
|
361
|
+
.filter(Boolean);
|
|
362
|
+
groupsMap[id] = {
|
|
363
|
+
id,
|
|
364
|
+
label: opts.label,
|
|
365
|
+
lanes: lanesList,
|
|
366
|
+
pos: { line: it.no },
|
|
367
|
+
};
|
|
368
|
+
} else {
|
|
369
|
+
errors.push({
|
|
370
|
+
line: it.no,
|
|
371
|
+
message: `invalid group entry: "${it.trimmed}"`,
|
|
372
|
+
hint: 'use `id: { label: "...", lanes: [a, b] }`',
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
i = next;
|
|
377
|
+
continue;
|
|
378
|
+
}
|
|
379
|
+
i += 1;
|
|
380
|
+
}
|
|
381
|
+
|
|
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`" });
|
|
384
|
+
|
|
385
|
+
if (errors.length > 0) return { ok: false, errors };
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
ok: true,
|
|
389
|
+
doc: {
|
|
390
|
+
title: title!,
|
|
391
|
+
type: type!,
|
|
392
|
+
actors,
|
|
393
|
+
flow,
|
|
394
|
+
animate,
|
|
395
|
+
viewport,
|
|
396
|
+
lanes: lanesMap,
|
|
397
|
+
groups: groupsMap,
|
|
398
|
+
pos: { line: 1 },
|
|
399
|
+
},
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function tokenize(src: string): Line[] {
|
|
404
|
+
const out: Line[] = [];
|
|
405
|
+
const raw = src.split("\n");
|
|
406
|
+
for (let i = 0; i < raw.length; i += 1) {
|
|
407
|
+
const r = raw[i] ?? "";
|
|
408
|
+
const trimmed = r.trim();
|
|
409
|
+
const indent = r.length - r.trimStart().length;
|
|
410
|
+
out.push({ raw: r, trimmed, indent, no: i + 1 });
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
type TopHeader = { key: string; value: string | null };
|
|
416
|
+
|
|
417
|
+
function matchTopHeader(trimmed: string): TopHeader | null {
|
|
418
|
+
// 形式: `key:` or `key: value`
|
|
419
|
+
const m = trimmed.match(/^([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
|
|
420
|
+
if (!m) return null;
|
|
421
|
+
const value = (m[2] ?? "").trim();
|
|
422
|
+
return { key: (m[1] ?? "").toLowerCase(), value: value.length ? stripQuotes(value) : null };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* 対応する引用符だけを外す。
|
|
427
|
+
*
|
|
428
|
+
* 先頭と末尾を別々に外すと、対応しない形 (`'300,200"`) が中身だけ取り出せてしまう。
|
|
429
|
+
* 画面側も同じ関数を使う (#1028) = 別々に持つと、片方だけが読める本文ができる。
|
|
430
|
+
*/
|
|
431
|
+
export function stripQuotes(s: string): string {
|
|
432
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
433
|
+
return s.slice(1, -1);
|
|
434
|
+
}
|
|
435
|
+
return s;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* 引用符と角括弧の外にある最後の `:` の位置。 見つからなければ -1。
|
|
440
|
+
*
|
|
441
|
+
* 名前に `:` を含められるので後ろから探すが、 `["id: PK"]` のように値の中にも `:` が入る。
|
|
442
|
+
* 深さを数えて、 値の中の `:` を数えない。
|
|
443
|
+
*/
|
|
444
|
+
function lastTopLevelColon(s: string): number {
|
|
445
|
+
let depth = 0;
|
|
446
|
+
let quote = "";
|
|
447
|
+
let last = -1;
|
|
448
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
449
|
+
const c = s[i]!;
|
|
450
|
+
if (quote) {
|
|
451
|
+
if (c === quote) quote = "";
|
|
452
|
+
continue;
|
|
453
|
+
}
|
|
454
|
+
if (c === '"' || c === "'") { quote = c; continue; }
|
|
455
|
+
if (c === "[" || c === "{") depth += 1;
|
|
456
|
+
else if (c === "]" || c === "}") depth -= 1;
|
|
457
|
+
else if (c === ":" && depth === 0) last = i;
|
|
458
|
+
}
|
|
459
|
+
return last;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
/**
|
|
463
|
+
* 空白区切りの値を切り出す。 引用符と角括弧の中の空白では切らない。
|
|
464
|
+
*
|
|
465
|
+
* `service "API サーバー" 幅400` → `["service", '"API サーバー"', "幅400"]`
|
|
466
|
+
*/
|
|
467
|
+
function splitValues(s: string): string[] {
|
|
468
|
+
const out: string[] = [];
|
|
469
|
+
let buf = "";
|
|
470
|
+
let depth = 0;
|
|
471
|
+
let quote = "";
|
|
472
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
473
|
+
const c = s[i]!;
|
|
474
|
+
if (quote) {
|
|
475
|
+
buf += c;
|
|
476
|
+
if (c === quote) quote = "";
|
|
477
|
+
continue;
|
|
478
|
+
}
|
|
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; }
|
|
482
|
+
if (/\s/.test(c) && depth === 0) {
|
|
483
|
+
if (buf) { out.push(buf); buf = ""; }
|
|
484
|
+
continue;
|
|
485
|
+
}
|
|
486
|
+
buf += c;
|
|
487
|
+
}
|
|
488
|
+
if (buf) out.push(buf);
|
|
489
|
+
return out;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
type ActorValues = {
|
|
493
|
+
kind: string;
|
|
494
|
+
tone?: Tone;
|
|
495
|
+
subtitle?: string;
|
|
496
|
+
rows?: string[];
|
|
497
|
+
value?: string;
|
|
498
|
+
posX?: number;
|
|
499
|
+
posY?: number;
|
|
500
|
+
/** parts の状態の上書き (`v=50` の形)。 状態名は自由なので等号で示す。 */
|
|
501
|
+
state?: Record<string, number | string | boolean>;
|
|
502
|
+
/** 図形の倍率 (`倍率=2` / `scale=2` の形、 #1026)。 状態とは別枠で持つ。 */
|
|
503
|
+
scale?: number;
|
|
504
|
+
/** 書かれた倍率の名前。 値が読めない形 (`scale=x`) と書いていない形を見分ける。 */
|
|
505
|
+
scaleKeys?: string[];
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* 空白区切りで書かれた値を、 項目ごとに振り分ける。
|
|
510
|
+
*
|
|
511
|
+
* 振り分けは値の形で決まる。 引用符付きは補足 (2 つ目は値)、 角括弧は行、 色名は色、
|
|
512
|
+
* 残りが種類。 形が違うので取り違えない。
|
|
513
|
+
*/
|
|
514
|
+
function classifyValues(values: string[]): ActorValues {
|
|
515
|
+
const out: ActorValues = { kind: "" };
|
|
516
|
+
/** 書かれた倍率。 同じ名前が 2 度出たら後の値で上書きする */
|
|
517
|
+
const scaleWritten = new Map<string, string>();
|
|
518
|
+
const kindWords: string[] = [];
|
|
519
|
+
for (const v of values) {
|
|
520
|
+
if ((v.startsWith('"') && v.endsWith('"') && v.length > 1) || (v.startsWith("'") && v.endsWith("'") && v.length > 1)) {
|
|
521
|
+
// 1 つ目の引用符は補足、 2 つ目は値 (`storage` の右側に出る数値等)
|
|
522
|
+
if (out.subtitle === undefined) out.subtitle = stripQuotes(v);
|
|
523
|
+
else if (out.value === undefined) out.value = stripQuotes(v);
|
|
524
|
+
continue;
|
|
525
|
+
}
|
|
526
|
+
if (v.startsWith("[") && v.endsWith("]")) {
|
|
527
|
+
out.rows = v
|
|
528
|
+
.slice(1, -1)
|
|
529
|
+
.split(/,(?![^[]*\])/)
|
|
530
|
+
.map((x) => stripQuotes(x.trim()))
|
|
531
|
+
.filter(Boolean);
|
|
532
|
+
continue;
|
|
533
|
+
}
|
|
534
|
+
// `@300,200` は位置。 2 つ揃わないと効かないので、 1 つの値としてまとめて書く
|
|
535
|
+
const at = v.match(/^@(-?\d+(?:\.\d+)?)\s*[,、]\s*(-?\d+(?:\.\d+)?)$/);
|
|
536
|
+
if (at) { out.posX = Number(at[1]); out.posY = Number(at[2]); continue; }
|
|
537
|
+
// `名前=値` は parts の状態の上書き。 状態名は自由なので、 形では見分けられない。
|
|
538
|
+
// 等号を書いてもらう。
|
|
539
|
+
const eq = v.indexOf("=");
|
|
540
|
+
if (eq > 0) {
|
|
541
|
+
const key = v.slice(0, eq);
|
|
542
|
+
const raw = stripQuotes(v.slice(eq + 1));
|
|
543
|
+
// `倍率` だけは状態ではなく図形の倍率 (#1026)。 書かれた名前をそのまま貯めて、
|
|
544
|
+
// どれが効くかは `resolveScale` が 1 箇所で決める (3 つの書き方で規則を揃えるため)
|
|
545
|
+
if (SCALE_KEYS.has(key)) {
|
|
546
|
+
scaleWritten.set(key, raw);
|
|
547
|
+
continue;
|
|
548
|
+
}
|
|
549
|
+
if (/^[A-Za-z_][\w-]*$/.test(key)) {
|
|
550
|
+
out.state = { ...(out.state ?? {}), [key]: coerceStateValue(raw) };
|
|
551
|
+
continue;
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const tone = toneOrUndef(v);
|
|
555
|
+
if (tone) { out.tone = tone; continue; }
|
|
556
|
+
kindWords.push(v);
|
|
557
|
+
}
|
|
558
|
+
out.kind = kindWords.join(" ").toLowerCase();
|
|
559
|
+
const s = resolveScale(scaleWritten);
|
|
560
|
+
out.scale = s.scale;
|
|
561
|
+
out.scaleKeys = s.keys;
|
|
562
|
+
return out;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* 書かれた種類名を、 描画できる種類に解決する。
|
|
567
|
+
*
|
|
568
|
+
* 固有名 (`lambda` / `rds` 等) は読み替え表を通す。 それ以外はそのまま返す。
|
|
569
|
+
*/
|
|
570
|
+
function resolveKind(raw: string): NodeKind {
|
|
571
|
+
if (raw === "") return NODE_KIND_DEFAULT;
|
|
572
|
+
// `Object.hasOwn` で引く。 素の添字だと `toString` 等の既定の持ち物が引けてしまい、
|
|
573
|
+
// 種類として関数が返る。 呼ぶ前に受理集合で弾いてはいるが、 表を引く側でも閉じておく。
|
|
574
|
+
return Object.hasOwn(INFRA_KIND_ALIAS, raw) ? INFRA_KIND_ALIAS[raw]! : (raw as NodeKind);
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
function numberOrUndef(s: string | undefined): number | undefined {
|
|
578
|
+
if (s === undefined || s === "") return undefined;
|
|
579
|
+
const n = Number(s);
|
|
580
|
+
return Number.isFinite(n) ? n : undefined;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function boolOrUndef(s: string | undefined): boolean | undefined {
|
|
584
|
+
if (s === undefined) return undefined;
|
|
585
|
+
const lower = s.toLowerCase();
|
|
586
|
+
if (lower === "true") return true;
|
|
587
|
+
if (lower === "false") return false;
|
|
588
|
+
return undefined;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* 色名を解決する。 別名 (`成功` / `neutral` 等) も受け付ける。
|
|
593
|
+
*
|
|
594
|
+
* 未知の値は `undefined` にして既定色に落とす。 箱と矢印で同じ関数を通す。
|
|
595
|
+
*
|
|
596
|
+
* 別名表の参照には `Object.hasOwn` を使う。 素の添字だと `toString` / `constructor` /
|
|
597
|
+
* `valueOf` / `__proto__` が JavaScript の既定の持ち物として引けてしまい、 色名として
|
|
598
|
+
* 関数やオブジェクトが通る (実測)。 最後に解決結果が正規の色名かも確かめる。
|
|
599
|
+
*/
|
|
600
|
+
function toneOrUndef(s: string | undefined): Tone | undefined {
|
|
601
|
+
if (s === undefined) return undefined;
|
|
602
|
+
const raw = stripQuotes(s.trim());
|
|
603
|
+
const lower = raw.toLowerCase();
|
|
604
|
+
const resolved = Object.hasOwn(TONE_ALIAS, raw)
|
|
605
|
+
? TONE_ALIAS[raw]
|
|
606
|
+
: Object.hasOwn(TONE_ALIAS, lower)
|
|
607
|
+
? TONE_ALIAS[lower]
|
|
608
|
+
: undefined;
|
|
609
|
+
if (resolved !== undefined && TONE_VALID.has(resolved)) return resolved;
|
|
610
|
+
return TONE_VALID.has(lower) ? (lower as Tone) : undefined;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
/**
|
|
614
|
+
* actor 行から `name: { inner }` を depth count で抽出。 inner 内の `{ }` (例: `value: "{count}"`) を尊重。
|
|
615
|
+
*/
|
|
616
|
+
function matchActorInlineMapping(raw: string): { name: string; inner: string } | null {
|
|
617
|
+
// colon 位置を探して name と rest に分ける
|
|
618
|
+
const colonIdx = raw.indexOf(":");
|
|
619
|
+
if (colonIdx < 0) return null;
|
|
620
|
+
const name = raw.slice(0, colonIdx);
|
|
621
|
+
const rest = raw.slice(colonIdx + 1).trim();
|
|
622
|
+
if (!rest.startsWith("{")) return null;
|
|
623
|
+
// depth count で対応 brace 探す
|
|
624
|
+
let depth = 0;
|
|
625
|
+
let endIdx = -1;
|
|
626
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
627
|
+
const c = rest[i]!;
|
|
628
|
+
if (c === "{") depth += 1;
|
|
629
|
+
else if (c === "}") {
|
|
630
|
+
depth -= 1;
|
|
631
|
+
if (depth === 0) {
|
|
632
|
+
endIdx = i;
|
|
633
|
+
break;
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
if (endIdx < 0) return null;
|
|
638
|
+
const inner = rest.slice(1, endIdx);
|
|
639
|
+
return { name, inner };
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
/**
|
|
643
|
+
* inline mapping を parse: `subtitle: "送信元", kind: actor, stack: 0`
|
|
644
|
+
* brace 内は { } で wrap してから渡す、 本関数は内部だけ受ける
|
|
645
|
+
* 値が `[a, b]` 配列は文字列のまま返す (caller で split)
|
|
646
|
+
*/
|
|
647
|
+
function parseInlineMapping(inner: string): Record<string, string> {
|
|
648
|
+
const out: Record<string, string> = {};
|
|
649
|
+
for (const p of splitInlineFields(inner)) {
|
|
650
|
+
// 項目名は英字だけでなく日本語も受ける (#1026)。 受けないと `{ kind: x, 倍率: 2 }` の
|
|
651
|
+
// 倍率が消え、同じ意味を書いたのに中括弧の形だけ効かない (実測)。
|
|
652
|
+
//
|
|
653
|
+
// 読める名前を広げても、**知っている名前しか使われない**。 パーツの状態の上書きに
|
|
654
|
+
// 流れるのは `ACTOR_RESERVED_FIELDS` に無い名前だけで、日本語の項目名 (`位置` / `大きさ`
|
|
655
|
+
// 等) はそこに載せてあるため、これまでどおり落ちる
|
|
656
|
+
const m = p.match(/^\s*([^\s:,{}[\]"']+)\s*:\s*(.+?)\s*$/);
|
|
657
|
+
if (m) {
|
|
658
|
+
const key = m[1]!;
|
|
659
|
+
out[key] = stripQuotes(m[2]!.trim());
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return out;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* 中括弧の中身から、倍率として書かれた名前と値を拾う (#1026)。
|
|
667
|
+
*
|
|
668
|
+
* `parseInlineMapping` は値が 1 文字以上ある項目しか拾わない。 それをそのまま使うと、
|
|
669
|
+
* 値を書かなかった形 (`{ kind: x, scale: }`) で「書いた」 ことすら残らず、
|
|
670
|
+
* 予約の知らせが消える。 倍率だけは値が空でも名前を残す。
|
|
671
|
+
*/
|
|
672
|
+
function writtenScaleFields(inner: string): Map<string, string> {
|
|
673
|
+
const out = new Map<string, string>();
|
|
674
|
+
for (const field of splitInlineFields(inner)) {
|
|
675
|
+
const idx = field.indexOf(":");
|
|
676
|
+
if (idx < 0) continue;
|
|
677
|
+
const key = field.slice(0, idx).trim();
|
|
678
|
+
if (!SCALE_KEYS.has(key)) continue;
|
|
679
|
+
// 同じ名前を 2 度書いたら後の値を採る
|
|
680
|
+
out.set(key, stripQuotes(field.slice(idx + 1).trim()));
|
|
681
|
+
}
|
|
682
|
+
return out;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/**
|
|
686
|
+
* 中括弧の中身を、入れ子と引用符を保ったまま項目ごとに割る。
|
|
687
|
+
*
|
|
688
|
+
* `parseInlineMapping` と、倍率の「書かれた名前」 を拾う経路 (#1026) で共用する。
|
|
689
|
+
* 割り方を 2 つ持つと、片方だけが拾える項目という食い違いが生まれる。
|
|
690
|
+
*/
|
|
691
|
+
function splitInlineFields(inner: string): string[] {
|
|
692
|
+
let depth = 0;
|
|
693
|
+
let buf = "";
|
|
694
|
+
const parts: string[] = [];
|
|
695
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
696
|
+
const c = inner[i]!;
|
|
697
|
+
if (c === "[" || c === "{") depth += 1;
|
|
698
|
+
else if (c === "]" || c === "}") depth -= 1;
|
|
699
|
+
if (c === "," && depth === 0) {
|
|
700
|
+
parts.push(buf);
|
|
701
|
+
buf = "";
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
buf += c;
|
|
705
|
+
}
|
|
706
|
+
if (buf.trim()) parts.push(buf);
|
|
707
|
+
return parts;
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function collectIndentedList(lines: Line[], start: number, parentIndent: number): { items: Line[]; next: number } {
|
|
711
|
+
const items: Line[] = [];
|
|
712
|
+
let i = start;
|
|
713
|
+
while (i < lines.length) {
|
|
714
|
+
const ln = lines[i]!;
|
|
715
|
+
if (!ln.trimmed) {
|
|
716
|
+
i += 1;
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
if (ln.indent <= parentIndent) break;
|
|
720
|
+
if (ln.trimmed.startsWith("- ")) {
|
|
721
|
+
items.push({ ...ln, trimmed: ln.trimmed.slice(2).trim() });
|
|
722
|
+
} else if (ln.trimmed.includes(":")) {
|
|
723
|
+
// YAML 風 inline (key: value) は state 用 block で許容
|
|
724
|
+
items.push(ln);
|
|
725
|
+
}
|
|
726
|
+
i += 1;
|
|
727
|
+
}
|
|
728
|
+
return { items, next: i };
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 登場人物を 1 件ずつ集める。 続く字下げ行は同じ 1 件にまとめる。
|
|
733
|
+
*
|
|
734
|
+
* 項目が少なければ 1 行で書け、 多ければ縦に並べられる。 縦に並べた方が、 何を指定できるかが
|
|
735
|
+
* 見える。
|
|
736
|
+
*
|
|
737
|
+
* ```
|
|
738
|
+
* - Client
|
|
739
|
+
* - API: service
|
|
740
|
+
* - Web:
|
|
741
|
+
* kind: service
|
|
742
|
+
* 色: 失敗
|
|
743
|
+
* ```
|
|
744
|
+
*/
|
|
745
|
+
/**
|
|
746
|
+
* 色の指定を振り分ける。
|
|
747
|
+
*
|
|
748
|
+
* 書く人は「色を変えたい」 としか思わないので、 項目は `色:` 1 つにまとめる。 意味の色
|
|
749
|
+
* (`失敗`) と色番号 (`#f59e0b`) は形で見分ける。 前者は箱の色、 後者はパーツの塗りになる。
|
|
750
|
+
*/
|
|
751
|
+
function splitColorValue(raw: string): { tone?: Tone; hex?: string } {
|
|
752
|
+
const v = stripQuotes(raw.trim());
|
|
753
|
+
if (v.startsWith("#")) return { hex: v };
|
|
754
|
+
const tone = toneOrUndef(v);
|
|
755
|
+
return tone ? { tone } : {};
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
/** `色` / `color` のどちらでも書ける。 */
|
|
759
|
+
const COLOR_KEYS = new Set(["色", "color", "tone"]);
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* 見本の倍率として予約する項目名 (#1026)。
|
|
763
|
+
*
|
|
764
|
+
* 予約しないと状態の名前として読まれる。 画面側は同じ語を図形の倍率として読むため、
|
|
765
|
+
* 予約しない限り同じ本文が 2 経路で別の絵になる。 3 つの書き方すべてで同じ扱いにする。
|
|
766
|
+
*/
|
|
767
|
+
const SCALE_KEYS: ReadonlySet<string> = new Set(["scale", "倍率"]);
|
|
768
|
+
|
|
769
|
+
/** 別名を 2 つ書いた時に優先する順。 画面側 (`SCALE_KEYS`) と同じ並びにする。 */
|
|
770
|
+
const SCALE_ORDER = ["scale", "倍率"] as const;
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* 書かれた倍率から、実際に効く値と書かれた名前を決める (#1026)。
|
|
774
|
+
*
|
|
775
|
+
* 規則は 3 つの書き方と画面側で共通にする。
|
|
776
|
+
*
|
|
777
|
+
* - 別名は `scale` を先に見る (両方書いた時に書き方で効く名前が変わらないようにする)
|
|
778
|
+
* - 同じ名前を 2 度書いた時は後に書いた方を採る (前を採ると書き直した値が効かない)
|
|
779
|
+
* - 書いた名前の値が読めなくても、もう一方の名前に降りない (綴りを誤った時だけ
|
|
780
|
+
* 別の値が効く、という追いにくい形を作らない)
|
|
781
|
+
*
|
|
782
|
+
* `keys` は書かれた名前そのもの。 値が読めたかに関わらず入る。 見本が同じ名前の状態を
|
|
783
|
+
* 持つ時の知らせ (`scale-reserved`) が、値の読めなさに左右されないようにするため。
|
|
784
|
+
*/
|
|
785
|
+
function resolveScale(written: Map<string, string>): { scale?: number; keys: string[] } {
|
|
786
|
+
const keys = [...written.keys()];
|
|
787
|
+
for (const key of SCALE_ORDER) {
|
|
788
|
+
const raw = written.get(key);
|
|
789
|
+
if (raw !== undefined) return { scale: numberOrUndef(raw), keys };
|
|
790
|
+
}
|
|
791
|
+
return { keys };
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* 続く字下げ行 (`kind: service` の形) を読んで 1 件にまとめる。
|
|
796
|
+
*
|
|
797
|
+
* 1 行で書いた時と同じ結果になるよう、 同じ振り分けを通す。
|
|
798
|
+
*/
|
|
799
|
+
function applyContinuationLines(actor: DslActor, rest: Line[], errors: DslError[]): DslActor {
|
|
800
|
+
if (rest.length === 0) return actor;
|
|
801
|
+
const out: DslActor = { ...actor };
|
|
802
|
+
const state: Record<string, number | string | boolean> = { ...(actor.stateOverride ?? {}) };
|
|
803
|
+
let touchedState = false;
|
|
804
|
+
/** 縦に並べて書かれた倍率。 同じ名前が 2 度出たら後の値で上書きする */
|
|
805
|
+
const scaleWritten = new Map<string, string>();
|
|
806
|
+
// パーツでなければどこにも入らない項目。 パーツかどうかは block を読み終わるまで決まらない
|
|
807
|
+
const unknownKeys: Array<{ key: string; line: number }> = [];
|
|
808
|
+
|
|
809
|
+
for (const ln of rest) {
|
|
810
|
+
const idx = ln.trimmed.indexOf(":");
|
|
811
|
+
if (idx < 0) continue;
|
|
812
|
+
const key = ln.trimmed.slice(0, idx).trim();
|
|
813
|
+
const raw = ln.trimmed.slice(idx + 1).trim();
|
|
814
|
+
if (!key) continue;
|
|
815
|
+
// 倍率だけは値が空でも名前を残す (#1026)。 捨てると、値を書かなかった形で
|
|
816
|
+
// 予約の知らせが消え、別名 (`倍率`) に降りて別の値が効いてしまう
|
|
817
|
+
if (SCALE_KEYS.has(key)) {
|
|
818
|
+
scaleWritten.set(key, stripQuotes(raw));
|
|
819
|
+
unknownKeys.push({ key, line: ln.no });
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (!raw) continue;
|
|
823
|
+
|
|
824
|
+
if (COLOR_KEYS.has(key)) {
|
|
825
|
+
const { tone, hex } = splitColorValue(raw);
|
|
826
|
+
if (tone) out.tone = tone;
|
|
827
|
+
// 色番号を入れる状態の名前はパーツごとに違う。 組み立て時に解決する
|
|
828
|
+
if (hex) out.colorHex = hex;
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
switch (key) {
|
|
832
|
+
case "kind":
|
|
833
|
+
case "種類": {
|
|
834
|
+
const k = stripQuotes(raw).toLowerCase();
|
|
835
|
+
const isPart = k !== "" && !NODE_KIND_VALID.has(k);
|
|
836
|
+
out.kind = isPart ? NODE_KIND_DEFAULT : resolveKind(k);
|
|
837
|
+
// parts 候補は `kind` を既定に倒して `partId` へ退避するため、 名札に載せる種類としては
|
|
838
|
+
// 「書かなかった」 と同じ扱いにする (#1058)
|
|
839
|
+
out.kindWritten = k !== "" && !isPart;
|
|
840
|
+
out.partId = isPart ? k : undefined;
|
|
841
|
+
break;
|
|
842
|
+
}
|
|
843
|
+
case "subtitle":
|
|
844
|
+
case "補足":
|
|
845
|
+
out.subtitle = stripQuotes(raw);
|
|
846
|
+
break;
|
|
847
|
+
case "value":
|
|
848
|
+
case "値":
|
|
849
|
+
out.value = stripQuotes(raw);
|
|
850
|
+
break;
|
|
851
|
+
case "rows":
|
|
852
|
+
case "行":
|
|
853
|
+
out.rows = raw.replace(/^\[|\]$/g, "").split(/,(?![^[]*\])/).map((x) => stripQuotes(x.trim())).filter(Boolean);
|
|
854
|
+
break;
|
|
855
|
+
case "位置":
|
|
856
|
+
case "pos": {
|
|
857
|
+
// `位置: 300,200` の形。 posX と posY は両方揃わないと効かないので、 1 つの項目に
|
|
858
|
+
// まとめて書き分けられないようにする
|
|
859
|
+
const value = stripQuotes(raw);
|
|
860
|
+
const m = value.match(/^(-?\d+(?:\.\d+)?)\s*[,、]\s*(-?\d+(?:\.\d+)?)$/);
|
|
861
|
+
if (m) {
|
|
862
|
+
out.posX = Number(m[1]);
|
|
863
|
+
out.posY = Number(m[2]);
|
|
864
|
+
// 座標を後から書いた時は相対の指定を捨てる。 両方残すと、 どちらが効くかが
|
|
865
|
+
// 書いた順に依存して読めなくなる
|
|
866
|
+
out.posRel = undefined;
|
|
867
|
+
break;
|
|
868
|
+
}
|
|
869
|
+
// `位置: Web の右 200` の形。 座標を知らなくても位置を決められるようにする
|
|
870
|
+
const rel = parseRelativePos(value);
|
|
871
|
+
if (rel) {
|
|
872
|
+
out.posRel = rel;
|
|
873
|
+
out.posX = undefined;
|
|
874
|
+
out.posY = undefined;
|
|
875
|
+
break;
|
|
876
|
+
}
|
|
877
|
+
// どちらの形でもない値は黙って捨てない。 捨てると「書いたのに図が変わらない」 が
|
|
878
|
+
// 手掛かりなしで起きる
|
|
879
|
+
//
|
|
880
|
+
// 負の間隔 (`Web の右 -200`) もここに来る。 向きを書いた上で裏返す指定は、
|
|
881
|
+
// 書いた人の意図と図が食い違うので誤りとして返す
|
|
882
|
+
const negative = /^(.+?)\s*(?:の\s*(?:右|左|上|下)|\s(?:right|left|above|below))\s*-\s*[\d.]/i.test(value);
|
|
883
|
+
errors.push({
|
|
884
|
+
line: ln.no,
|
|
885
|
+
message: negative
|
|
886
|
+
? `間隔に負の数は書けません: "${value}"`
|
|
887
|
+
: `位置の書き方が読めません: "${value}"`,
|
|
888
|
+
hint: negative
|
|
889
|
+
? "向きを変えたい時は `右` / `左` / `上` / `下` を書き換える"
|
|
890
|
+
: "`位置: 300,200` (座標) か `位置: Web の右 200` (他の登場人物からの相対)",
|
|
891
|
+
});
|
|
892
|
+
break;
|
|
893
|
+
}
|
|
894
|
+
case "posX":
|
|
895
|
+
out.posX = numberOrUndef(raw);
|
|
896
|
+
break;
|
|
897
|
+
case "posY":
|
|
898
|
+
out.posY = numberOrUndef(raw);
|
|
899
|
+
break;
|
|
900
|
+
case "大きさ":
|
|
901
|
+
case "size": {
|
|
902
|
+
// `大きさ: 400,200` の形。 位置と揃える
|
|
903
|
+
const value = stripQuotes(raw);
|
|
904
|
+
const m = value.match(/^(-?\d+(?:\.\d+)?)\s*[,、]\s*(-?\d+(?:\.\d+)?)$/);
|
|
905
|
+
if (m) {
|
|
906
|
+
out.posW = Number(m[1]);
|
|
907
|
+
out.posH = Number(m[2]);
|
|
908
|
+
break;
|
|
909
|
+
}
|
|
910
|
+
// 読めない値を黙って捨てると「書いたのに大きさが変わらない」 が手掛かりなしで起きる。
|
|
911
|
+
// 位置と同じく行番号付きで知らせる (#1028)
|
|
912
|
+
errors.push({
|
|
913
|
+
line: ln.no,
|
|
914
|
+
message: `大きさの書き方が読めません: "${value}"`,
|
|
915
|
+
hint: "`大きさ: 400,200` (幅, 高さ) の形で書く",
|
|
916
|
+
});
|
|
917
|
+
break;
|
|
918
|
+
}
|
|
919
|
+
case "lane":
|
|
920
|
+
out.lane = stripQuotes(raw);
|
|
921
|
+
break;
|
|
922
|
+
case "stack":
|
|
923
|
+
out.stack = numberOrUndef(raw);
|
|
924
|
+
break;
|
|
925
|
+
default:
|
|
926
|
+
// 残りはパーツの状態の上書き
|
|
927
|
+
state[key] = coerceStateValue(stripQuotes(raw));
|
|
928
|
+
touchedState = true;
|
|
929
|
+
unknownKeys.push({ key, line: ln.no });
|
|
930
|
+
break;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
// 名前の行と縦に並べた行の両方に倍率がある形では、後に書いた縦の行を採る。
|
|
934
|
+
// 知らせ (`scale-reserved`) は書かれた名前をすべて見るので、名前だけは足し合わせる
|
|
935
|
+
if (scaleWritten.size > 0) {
|
|
936
|
+
const s = resolveScale(scaleWritten);
|
|
937
|
+
out.scale = s.scale;
|
|
938
|
+
out.scaleKeys = [...new Set([...(actor.scaleKeys ?? []), ...s.keys])];
|
|
939
|
+
}
|
|
940
|
+
// 状態も倍率も parts でだけ意味を持つ。 パーツなら知らせずに返す
|
|
941
|
+
if (out.partId !== undefined) {
|
|
942
|
+
if (touchedState) out.stateOverride = state;
|
|
943
|
+
return out;
|
|
944
|
+
}
|
|
945
|
+
// パーツでない箱に書かれた見知らぬ項目は、 どこにも入らずに消える。 黙って捨てると
|
|
946
|
+
// 「書いたのに図が変わらない」 が手掛かりなしで起きるので、 綴りの誤りとして知らせる
|
|
947
|
+
for (const u of unknownKeys) {
|
|
948
|
+
errors.push({
|
|
949
|
+
line: u.line,
|
|
950
|
+
message: `項目名が読めません: "${u.key}"`,
|
|
951
|
+
hint: `使える項目 = ${[...ACTOR_ITEM_KEYS].join(", ")}`,
|
|
952
|
+
});
|
|
953
|
+
}
|
|
954
|
+
return out;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
/**
|
|
958
|
+
* 縦に並べて書ける項目名。
|
|
959
|
+
*
|
|
960
|
+
* 綴りを誤った時の知らせに使う。 `applyContinuationLines` の分岐と揃える。
|
|
961
|
+
*/
|
|
962
|
+
export const ACTOR_ITEM_KEYS: ReadonlySet<string> = new Set([
|
|
963
|
+
...COLOR_KEYS,
|
|
964
|
+
"kind", "種類",
|
|
965
|
+
"subtitle", "補足",
|
|
966
|
+
"value", "値",
|
|
967
|
+
"rows", "行",
|
|
968
|
+
"位置", "pos", "posX", "posY",
|
|
969
|
+
"大きさ", "size",
|
|
970
|
+
"倍率", "scale",
|
|
971
|
+
"lane", "stack",
|
|
972
|
+
]);
|
|
973
|
+
|
|
974
|
+
/**
|
|
975
|
+
* 相対で書かれた位置が解けるかを確かめる。
|
|
976
|
+
*
|
|
977
|
+
* 解けない書き方は 3 通りある。 相手が居ない / 自分を基準にした / 基準が輪になっている。
|
|
978
|
+
* どれも「書いたのに図が変わらない」 形で表に出るため、 図を出す前に行番号付きで知らせる。
|
|
979
|
+
*
|
|
980
|
+
* 誤りを見つけた actor からは相対の指定を外す。 残したままだと、 誤りを直さずに読み込んだ
|
|
981
|
+
* 経路 (error を無視する呼出) で解決できない指定が組み立てまで届く。
|
|
982
|
+
*/
|
|
983
|
+
function validateRelativePositions(actors: DslActor[], errors: DslError[]): void {
|
|
984
|
+
const named = new Set(actors.map((a) => a.name));
|
|
985
|
+
const broken = new Set<string>();
|
|
986
|
+
|
|
987
|
+
for (const a of actors) {
|
|
988
|
+
const rel = a.posRel;
|
|
989
|
+
if (!rel) continue;
|
|
990
|
+
if (rel.anchor === a.name) {
|
|
991
|
+
errors.push({
|
|
992
|
+
line: a.pos.line,
|
|
993
|
+
message: `位置の基準が自分自身です: "${a.name}"`,
|
|
994
|
+
hint: "別の登場人物の名前を書く",
|
|
995
|
+
});
|
|
996
|
+
broken.add(a.name);
|
|
997
|
+
continue;
|
|
998
|
+
}
|
|
999
|
+
if (!named.has(rel.anchor)) {
|
|
1000
|
+
errors.push({
|
|
1001
|
+
line: a.pos.line,
|
|
1002
|
+
message: `位置の基準が見つかりません: "${rel.anchor}"`,
|
|
1003
|
+
hint:
|
|
1004
|
+
named.size > 0
|
|
1005
|
+
? `actors: に書かれている名前 = ${[...named].join(", ")}`
|
|
1006
|
+
: "actors: に基準にする登場人物を書く",
|
|
1007
|
+
});
|
|
1008
|
+
broken.add(a.name);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
const { cyclic } = orderByDependency(
|
|
1013
|
+
actors.map((a) => ({ name: a.name, rel: broken.has(a.name) ? undefined : a.posRel })),
|
|
1014
|
+
);
|
|
1015
|
+
for (const name of cyclic) {
|
|
1016
|
+
const a = actors.find((x) => x.name === name);
|
|
1017
|
+
errors.push({
|
|
1018
|
+
line: a?.pos.line ?? 1,
|
|
1019
|
+
message: `位置の基準が互いを指しています: "${name}"`,
|
|
1020
|
+
hint: "どれか 1 つは座標 (`位置: 300,200`) か自動配置にする",
|
|
1021
|
+
});
|
|
1022
|
+
broken.add(name);
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
for (const a of actors) {
|
|
1026
|
+
if (broken.has(a.name)) a.posRel = undefined;
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function collectActorEntries(lines: Line[], start: number, parentIndent: number): { items: Line[][]; next: number } {
|
|
1031
|
+
const items: Line[][] = [];
|
|
1032
|
+
let cur: Line[] | null = null;
|
|
1033
|
+
let headIndent = -1;
|
|
1034
|
+
let i = start;
|
|
1035
|
+
while (i < lines.length) {
|
|
1036
|
+
const ln = lines[i]!;
|
|
1037
|
+
if (!ln.trimmed) { i += 1; continue; }
|
|
1038
|
+
if (ln.indent <= parentIndent) break;
|
|
1039
|
+
if (ln.trimmed.startsWith("- ")) {
|
|
1040
|
+
if (cur) items.push(cur);
|
|
1041
|
+
cur = [{ ...ln, trimmed: ln.trimmed.slice(2).trim() }];
|
|
1042
|
+
headIndent = ln.indent;
|
|
1043
|
+
} else if (cur && ln.indent > headIndent) {
|
|
1044
|
+
// 頭より深い字下げは、 直前の 1 件の続き
|
|
1045
|
+
cur.push(ln);
|
|
1046
|
+
}
|
|
1047
|
+
i += 1;
|
|
1048
|
+
}
|
|
1049
|
+
if (cur) items.push(cur);
|
|
1050
|
+
return { items, next: i };
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
function collectAnimationSteps(lines: Line[], start: number, parentIndent: number): { items: Line[][]; next: number } {
|
|
1054
|
+
// 各 `- step: "..."` 開始を 1 block の頭として識別、 後続の同 indent 以下を block 本文として吸収
|
|
1055
|
+
const out: Line[][] = [];
|
|
1056
|
+
let i = start;
|
|
1057
|
+
let cur: Line[] | null = null;
|
|
1058
|
+
while (i < lines.length) {
|
|
1059
|
+
const ln = lines[i]!;
|
|
1060
|
+
if (!ln.trimmed) {
|
|
1061
|
+
i += 1;
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
if (ln.indent <= parentIndent) break;
|
|
1065
|
+
if (ln.trimmed.startsWith("- step")) {
|
|
1066
|
+
if (cur) out.push(cur);
|
|
1067
|
+
cur = [{ ...ln, trimmed: ln.trimmed.slice(2).trim() }];
|
|
1068
|
+
} else if (cur) {
|
|
1069
|
+
// 続く property line (focus / tween / set / badge / description)
|
|
1070
|
+
cur.push(ln);
|
|
1071
|
+
}
|
|
1072
|
+
i += 1;
|
|
1073
|
+
}
|
|
1074
|
+
if (cur) out.push(cur);
|
|
1075
|
+
return { items: out, next: i };
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* inline option の残 field (kind + 既存 reserved 除外後) を parts state override として抽出する。
|
|
1080
|
+
* CAR-1657 unified syntax = `- arc1: { kind: arc-gauge, v: 50, count: 100 }` の `v` / `count` を
|
|
1081
|
+
* `{ v: 50, count: 100 }` state override map に集約する経路。
|
|
1082
|
+
* 予約語衝突時は `state: { v: 50 }` 明示 fallback を使う (別 field で処理)。
|
|
1083
|
+
*/
|
|
1084
|
+
const ACTOR_RESERVED_FIELDS: ReadonlySet<string> = new Set([
|
|
1085
|
+
"kind",
|
|
1086
|
+
"subtitle",
|
|
1087
|
+
"eyebrow",
|
|
1088
|
+
"value",
|
|
1089
|
+
"rows",
|
|
1090
|
+
"lane",
|
|
1091
|
+
"stack",
|
|
1092
|
+
"initial",
|
|
1093
|
+
"final",
|
|
1094
|
+
"state",
|
|
1095
|
+
// canvas pivot 新 spec = 絶対座標 4 field (dragon canvas pivot spec §layout-role-conversion)
|
|
1096
|
+
"posX",
|
|
1097
|
+
"posY",
|
|
1098
|
+
"posW",
|
|
1099
|
+
"posH",
|
|
1100
|
+
// canvas pivot UX 修正 (B1) = sub-node 単位 override map (nested `nodes: { header: {...} }`)
|
|
1101
|
+
"nodes",
|
|
1102
|
+
// 図形の倍率 (#1026)。 状態の名前としては読まない
|
|
1103
|
+
"scale",
|
|
1104
|
+
"倍率",
|
|
1105
|
+
// 日本語の項目名 (#1026)。 中括弧の形が日本語の項目名を読めるようになったため、
|
|
1106
|
+
// ここに載せないと状態の名前として拾われる。 縦に並べた形での意味 (位置 / 大きさ 等) は
|
|
1107
|
+
// 中括弧の形では未対応なので、これまでどおり落とす方に揃える
|
|
1108
|
+
"種類",
|
|
1109
|
+
"補足",
|
|
1110
|
+
"値",
|
|
1111
|
+
"行",
|
|
1112
|
+
"位置",
|
|
1113
|
+
"大きさ",
|
|
1114
|
+
"色",
|
|
1115
|
+
]);
|
|
1116
|
+
|
|
1117
|
+
function extractStateOverride(opts: Record<string, string>): Record<string, number | string | boolean> | undefined {
|
|
1118
|
+
const out: Record<string, number | string | boolean> = {};
|
|
1119
|
+
let count = 0;
|
|
1120
|
+
// 明示 `state: {...}` fallback がある場合はそちらを優先 (nested map parse)
|
|
1121
|
+
const explicit = opts.state;
|
|
1122
|
+
if (explicit && explicit.startsWith("{") && explicit.endsWith("}")) {
|
|
1123
|
+
const inner = parseInlineMapping(explicit.slice(1, -1));
|
|
1124
|
+
for (const [k, v] of Object.entries(inner)) {
|
|
1125
|
+
out[k] = coerceStateValue(v);
|
|
1126
|
+
count += 1;
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
// inline 拡散 = 予約語以外を state override として拾う (explicit と併用時は明示 wins)
|
|
1130
|
+
for (const [k, v] of Object.entries(opts)) {
|
|
1131
|
+
if (ACTOR_RESERVED_FIELDS.has(k)) continue;
|
|
1132
|
+
if (k in out) continue; // explicit で set 済 skip
|
|
1133
|
+
out[k] = coerceStateValue(v);
|
|
1134
|
+
count += 1;
|
|
1135
|
+
}
|
|
1136
|
+
return count > 0 ? out : undefined;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
/**
|
|
1140
|
+
* canvas pivot UX 修正 (B1) = actor entry の inline map から `nodes: { header: { posX: ..., ... }, ... }`
|
|
1141
|
+
* 形式の nested override を抽出する。 outer parseInlineMapping が opts.nodes を string としてそのまま
|
|
1142
|
+
* 保持 (value 内 nested `{ }` は depth-aware で保護済) しているので、 本 fn で「outer `{...}` を剥がして
|
|
1143
|
+
* key: sub-map ペアに再 split → 各 sub-map を parseInlineMapping で解いて posX/Y/W/H に coerce」 する。
|
|
1144
|
+
* 未 field or 空 object なら undefined 返し (caller は actor.nodes を set しない)。
|
|
1145
|
+
*/
|
|
1146
|
+
function parseActorNodesField(raw: string | undefined): Record<string, DslActorNodeOverride> | undefined {
|
|
1147
|
+
if (!raw) return undefined;
|
|
1148
|
+
const trimmed = raw.trim();
|
|
1149
|
+
if (!trimmed.startsWith("{") || !trimmed.endsWith("}")) return undefined;
|
|
1150
|
+
const inner = trimmed.slice(1, -1).trim();
|
|
1151
|
+
if (!inner) return undefined;
|
|
1152
|
+
// depth-aware split (parseInlineMapping と同じ logic を local reuse、 nested `{ }` / `[ ]` 保護)
|
|
1153
|
+
const parts: string[] = [];
|
|
1154
|
+
let depth = 0;
|
|
1155
|
+
let buf = "";
|
|
1156
|
+
for (let i = 0; i < inner.length; i += 1) {
|
|
1157
|
+
const c = inner[i]!;
|
|
1158
|
+
if (c === "[" || c === "{") depth += 1;
|
|
1159
|
+
else if (c === "]" || c === "}") depth -= 1;
|
|
1160
|
+
if (c === "," && depth === 0) {
|
|
1161
|
+
parts.push(buf);
|
|
1162
|
+
buf = "";
|
|
1163
|
+
continue;
|
|
1164
|
+
}
|
|
1165
|
+
buf += c;
|
|
1166
|
+
}
|
|
1167
|
+
if (buf.trim()) parts.push(buf);
|
|
1168
|
+
const out: Record<string, DslActorNodeOverride> = {};
|
|
1169
|
+
for (const p of parts) {
|
|
1170
|
+
const colonIdx = p.indexOf(":");
|
|
1171
|
+
if (colonIdx < 0) continue;
|
|
1172
|
+
const key = p.slice(0, colonIdx).trim();
|
|
1173
|
+
const val = p.slice(colonIdx + 1).trim();
|
|
1174
|
+
if (!key || !val.startsWith("{") || !val.endsWith("}")) continue;
|
|
1175
|
+
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
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
function coerceStateValue(raw: string): number | string | boolean {
|
|
1187
|
+
const stripped = stripQuotes(raw);
|
|
1188
|
+
if (stripped === "true") return true;
|
|
1189
|
+
if (stripped === "false") return false;
|
|
1190
|
+
const n = Number(stripped);
|
|
1191
|
+
if (Number.isFinite(n) && stripped !== "" && !isNaN(n)) return n;
|
|
1192
|
+
return stripped;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/**
|
|
1196
|
+
* パーツでない箱に倍率を書いた時に知らせる (#1026)。
|
|
1197
|
+
*
|
|
1198
|
+
* 倍率はパーツにしか効かない。 黙って捨てると「書いたのに大きさが変わらない」 が手掛かり
|
|
1199
|
+
* なしで起きる。 3 つの書き方すべてで同じ知らせを出す (縦に並べた形だけ知らせて他が黙る、
|
|
1200
|
+
* という状態を作らない)。
|
|
1201
|
+
*/
|
|
1202
|
+
function reportScaleOnNonPart(
|
|
1203
|
+
isPart: boolean,
|
|
1204
|
+
key: string | undefined,
|
|
1205
|
+
line: number,
|
|
1206
|
+
errors: DslError[],
|
|
1207
|
+
): void {
|
|
1208
|
+
if (isPart || key === undefined) return;
|
|
1209
|
+
errors.push({
|
|
1210
|
+
line,
|
|
1211
|
+
message: `項目名が読めません: "${key}"`,
|
|
1212
|
+
hint: `使える項目 = ${[...ACTOR_ITEM_KEYS].join(", ")}`,
|
|
1213
|
+
});
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
/**
|
|
1217
|
+
* 中括弧の形で読める項目名。
|
|
1218
|
+
*
|
|
1219
|
+
* ここに無い名前は、 パーツでない箱ではどこにも入らずに消える。 `ACTOR_ITEM_KEYS` (縦に
|
|
1220
|
+
* 並べた形) とは別に持つ = 中括弧の形は位置や大きさを未対応にしてあり、 同じ集合にすると
|
|
1221
|
+
* 「知らせない」 側がずれる。
|
|
1222
|
+
*/
|
|
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",
|
|
1227
|
+
// 倍率は別経路 (`reportScaleOnNonPart`) が知らせる。 ここでも読める扱いにしないと
|
|
1228
|
+
// 同じ名前で 2 度知らせることになる
|
|
1229
|
+
"scale", "倍率",
|
|
1230
|
+
]);
|
|
1231
|
+
// `state` はパーツでだけ意味を持つ (`extractStateOverride` がパーツの時しか作らない)。
|
|
1232
|
+
// 通常の箱で読める扱いにすると `- A: { state: { foo: 1 } }` が黙って消え、 本 file が塞ごうと
|
|
1233
|
+
// している経路が予約語で残る (Round 1 review の指摘、 実測で確認)。 パーツ側は `isPart` の
|
|
1234
|
+
// 早期 return が先に効くのでここに載せる必要が無い
|
|
1235
|
+
|
|
1236
|
+
/**
|
|
1237
|
+
* 中括弧に書かれた読めない項目名を知らせる (#1090)。
|
|
1238
|
+
*
|
|
1239
|
+
* 縦に並べた形は `applyContinuationLines` が既に知らせている。 中括弧の形だけが黙って
|
|
1240
|
+
* 捨てていた = 同じ意味を書いても、 書き方によって知らされたりされなかったりする。
|
|
1241
|
+
*
|
|
1242
|
+
* 実測 = 見本「プロジェクト構想」 は `- root: { title: "新プロジェクト" }` と書かれており、
|
|
1243
|
+
* 5 つの箱すべてで題が捨てられて識別子 (`root` 等) が出ていた。 知らせも出ないため、 書いた
|
|
1244
|
+
* 人には「書いたのに図が変わらない」 としか見えない。
|
|
1245
|
+
*
|
|
1246
|
+
* パーツでは知らせない。 中括弧に書いた名前は状態の上書きとして意味を持つ (`extractStateOverride`)。
|
|
1247
|
+
*/
|
|
1248
|
+
function reportUnknownInlineKeys(
|
|
1249
|
+
isPart: boolean,
|
|
1250
|
+
inner: string,
|
|
1251
|
+
line: number,
|
|
1252
|
+
errors: DslError[],
|
|
1253
|
+
): void {
|
|
1254
|
+
if (isPart) return;
|
|
1255
|
+
// 値が空の形 (`{ title: }`) も見る。 `parseInlineMapping` は値が 1 文字以上ある項目しか
|
|
1256
|
+
// 拾わないため、 その結果を走査すると空白の有無で知らせが消える (実測 = `{title:}` と
|
|
1257
|
+
// `{ title:}` は黙って通り、 `{ title: }` だけ知らせが出た)。 契約が入力の整形に依存する
|
|
1258
|
+
// (Round 1 review の指摘)。 倍率が `writtenScaleFields` で同じ境界を持つのと揃える
|
|
1259
|
+
for (const field of splitInlineFields(inner)) {
|
|
1260
|
+
const idx = field.indexOf(":");
|
|
1261
|
+
if (idx < 0) continue;
|
|
1262
|
+
const key = field.slice(0, idx).trim();
|
|
1263
|
+
if (!key) continue;
|
|
1264
|
+
if (INLINE_ACTOR_KEYS.has(key)) continue;
|
|
1265
|
+
errors.push({
|
|
1266
|
+
line,
|
|
1267
|
+
message: `項目名が読めません: "${key}"`,
|
|
1268
|
+
hint: `使える項目 = ${[...INLINE_ACTOR_KEYS].join(", ")}`,
|
|
1269
|
+
});
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
function parseActor(line: Line, errors: DslError[]): DslActor | null {
|
|
1274
|
+
// 5 形式 サポート:
|
|
1275
|
+
// 1. `Client` ... name のみ、 kind=actor default
|
|
1276
|
+
// 2. `Client: storage` ... name + kind 略記
|
|
1277
|
+
// 3. `Client: { kind: actor, subtitle: "..." }` ... name + inline option mapping
|
|
1278
|
+
// 4. `"画面"` / `"画面": event` ... 日本語 quote
|
|
1279
|
+
// 5. `arc1: { kind: arc-gauge, v: 50 }` ... CAR-1657 parts kind (partId + stateOverride)
|
|
1280
|
+
const raw = line.trimmed.trim();
|
|
1281
|
+
if (!raw) return null;
|
|
1282
|
+
// 3 / 5. inline mapping check (`Client: { ... }`)、 nested { } を depth count で正しく抽出
|
|
1283
|
+
const mapMatch = matchActorInlineMapping(raw);
|
|
1284
|
+
if (mapMatch) {
|
|
1285
|
+
const namePart = stripQuotes(mapMatch.name.trim());
|
|
1286
|
+
if (!namePart) return null;
|
|
1287
|
+
const opts = parseInlineMapping(mapMatch.inner);
|
|
1288
|
+
const kindRaw = (opts.kind ?? "").toLowerCase();
|
|
1289
|
+
// CAR-1657 = kind が既存 NODE_KIND_VALID に無い場合 parts identifier 候補として partId に格納、
|
|
1290
|
+
// kind は actor default fallback。 compile 側 partsCatalog lookup で解決する。
|
|
1291
|
+
const isPart = kindRaw !== "" && !NODE_KIND_VALID.has(kindRaw);
|
|
1292
|
+
// 倍率はパーツにしか効かない。 書いたのに効かない状態を黙って作らない (#1026)。
|
|
1293
|
+
// 値が空の形でも名前を残すため、`opts` ではなく中身から直接拾う
|
|
1294
|
+
const inlineScale = resolveScale(writtenScaleFields(mapMatch.inner));
|
|
1295
|
+
reportScaleOnNonPart(isPart, inlineScale.keys[0], line.no, errors);
|
|
1296
|
+
// 中括弧に書いた読めない項目名も知らせる (#1090)。 縦に並べた形だけが知らせていた
|
|
1297
|
+
reportUnknownInlineKeys(isPart, mapMatch.inner, line.no, errors);
|
|
1298
|
+
const kind = isPart ? NODE_KIND_DEFAULT : resolveKind(NODE_KIND_VALID.has(kindRaw) ? kindRaw : "");
|
|
1299
|
+
return {
|
|
1300
|
+
name: namePart,
|
|
1301
|
+
kind,
|
|
1302
|
+
// parts 候補は `kind` を既定に倒して `partId` へ退避するため、 名札に載せる種類としては
|
|
1303
|
+
// 「書かなかった」 と同じ扱いにする (#1058)
|
|
1304
|
+
kindWritten: kindRaw !== "" && !isPart,
|
|
1305
|
+
subtitle: opts.subtitle,
|
|
1306
|
+
eyebrow: opts.eyebrow,
|
|
1307
|
+
value: opts.value,
|
|
1308
|
+
rows: opts.rows
|
|
1309
|
+
? opts.rows
|
|
1310
|
+
.replace(/^\[|\]$/g, "")
|
|
1311
|
+
.split(/,(?![^[]*\])/)
|
|
1312
|
+
.map((x) => stripQuotes(x.trim()))
|
|
1313
|
+
.filter(Boolean)
|
|
1314
|
+
: undefined,
|
|
1315
|
+
lane: opts.lane,
|
|
1316
|
+
stack: numberOrUndef(opts.stack),
|
|
1317
|
+
initial: boolOrUndef(opts.initial),
|
|
1318
|
+
final: boolOrUndef(opts.final),
|
|
1319
|
+
// parts では `tone` を状態の上書きとして従来から使えるため、 色として横取りしない
|
|
1320
|
+
tone: isPart ? undefined : toneOrUndef(opts.tone),
|
|
1321
|
+
partId: isPart ? kindRaw : undefined,
|
|
1322
|
+
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),
|
|
1328
|
+
// 図形の倍率 (#1026)。 どれが効くかは `resolveScale` が 1 箇所で決める
|
|
1329
|
+
scale: inlineScale.scale,
|
|
1330
|
+
scaleKeys: inlineScale.keys.length ? inlineScale.keys : undefined,
|
|
1331
|
+
// canvas pivot UX 修正 (B1) = sub-node 単位 override map (`nodes: { header: {posX:..., ...}, ...}`)
|
|
1332
|
+
nodes: parseActorNodesField(opts.nodes),
|
|
1333
|
+
pos: { line: line.no },
|
|
1334
|
+
};
|
|
1335
|
+
}
|
|
1336
|
+
// 1 / 2 / 4
|
|
1337
|
+
if (lastTopLevelColon(raw) >= 0) {
|
|
1338
|
+
const idx = lastTopLevelColon(raw);
|
|
1339
|
+
const namePart = stripQuotes(raw.slice(0, idx).trim());
|
|
1340
|
+
const rest = raw.slice(idx + 1).trim();
|
|
1341
|
+
if (!namePart) return null;
|
|
1342
|
+
|
|
1343
|
+
// `名前: 種類 "補足" [行, 行] 色` の形。 `{ }` を書かせない。
|
|
1344
|
+
//
|
|
1345
|
+
// 値は形で見分ける。 引用符付きは補足、 角括弧は行、 色名は色、 残りが種類。
|
|
1346
|
+
// 種類と色は語の集合が閉じているので取り違えない。
|
|
1347
|
+
const v = classifyValues(splitValues(rest));
|
|
1348
|
+
|
|
1349
|
+
// CAR-1657 = short form (`arc1: arc-gauge`) でも parts kind 対応、 未知 kind は partId 経路
|
|
1350
|
+
const isPart = v.kind !== "" && !NODE_KIND_VALID.has(v.kind);
|
|
1351
|
+
// 倍率はパーツにしか効かない (#1026)
|
|
1352
|
+
reportScaleOnNonPart(isPart, v.scaleKeys?.[0], line.no, errors);
|
|
1353
|
+
const kind = isPart ? NODE_KIND_DEFAULT : resolveKind(NODE_KIND_VALID.has(v.kind) ? v.kind : "");
|
|
1354
|
+
return {
|
|
1355
|
+
name: namePart,
|
|
1356
|
+
kind,
|
|
1357
|
+
// parts 候補は `kind` を既定に倒して `partId` へ退避するため、 名札に載せる種類としては
|
|
1358
|
+
// 「書かなかった」 と同じ扱いにする (#1058)
|
|
1359
|
+
kindWritten: v.kind !== "" && !isPart,
|
|
1360
|
+
// parts では `tone` を状態の上書きとして扱うため、 色として渡さない
|
|
1361
|
+
tone: isPart ? undefined : v.tone,
|
|
1362
|
+
subtitle: v.subtitle,
|
|
1363
|
+
rows: v.rows,
|
|
1364
|
+
value: v.value,
|
|
1365
|
+
posX: v.posX,
|
|
1366
|
+
posY: v.posY,
|
|
1367
|
+
scale: v.scale,
|
|
1368
|
+
scaleKeys: v.scaleKeys?.length ? v.scaleKeys : undefined,
|
|
1369
|
+
partId: isPart ? v.kind : undefined,
|
|
1370
|
+
stateOverride: isPart ? v.state : undefined,
|
|
1371
|
+
pos: { line: line.no },
|
|
1372
|
+
};
|
|
1373
|
+
}
|
|
1374
|
+
const namePart = stripQuotes(raw);
|
|
1375
|
+
if (!namePart) return null;
|
|
1376
|
+
return { name: namePart, kind: NODE_KIND_DEFAULT, kindWritten: false, pos: { line: line.no } };
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
function parseFlowStep(line: Line, no: number): DslStep | null {
|
|
1380
|
+
// 形式 (順序自由、 部分省略可):
|
|
1381
|
+
// 1. `Client -> API` ... label / option なし
|
|
1382
|
+
// 2. `Client -> API: "deposit"` ... label
|
|
1383
|
+
// 3. `Client -> API: "deposit" (success)` ... label + tone tuple
|
|
1384
|
+
// 4. `Client -> API: "deposit" { sub: "...", guard: "...", cardinality: "1:N", labelOffsetY: -8 }` ... inline option
|
|
1385
|
+
// 5. `Client -> API: "deposit" (success) { guard: "..." }` ... 両方
|
|
1386
|
+
const raw = line.trimmed;
|
|
1387
|
+
const arrowIdx = raw.indexOf("->");
|
|
1388
|
+
if (arrowIdx < 0) return null;
|
|
1389
|
+
const from = raw.slice(0, arrowIdx).trim();
|
|
1390
|
+
let rest = raw.slice(arrowIdx + 2).trim();
|
|
1391
|
+
let label = "";
|
|
1392
|
+
let tone: Tone | undefined;
|
|
1393
|
+
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;
|
|
1399
|
+
// inline option (`{ ... }`) を末尾から抽出
|
|
1400
|
+
const mapMatch = rest.match(/\s*\{([^}]*)\}\s*$/);
|
|
1401
|
+
if (mapMatch) {
|
|
1402
|
+
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);
|
|
1408
|
+
rest = rest.slice(0, mapMatch.index ?? 0).trim();
|
|
1409
|
+
}
|
|
1410
|
+
// 色と線種を末尾から取る。 括弧 (`(成功)`) と空白区切り (`成功`) の両方を受け付ける。
|
|
1411
|
+
//
|
|
1412
|
+
// 括弧は従来の書き方で、 catalog が使っている。 空白区切りは登場人物と揃えた形。
|
|
1413
|
+
const optMatch = rest.match(/\s*\(([^)]*)\)\s*$/);
|
|
1414
|
+
if (optMatch) {
|
|
1415
|
+
const opts = (optMatch[1] ?? "").split(",").map((s) => s.trim());
|
|
1416
|
+
for (const opt of opts) {
|
|
1417
|
+
const resolvedTone = toneOrUndef(opt);
|
|
1418
|
+
if (resolvedTone !== undefined) tone = resolvedTone;
|
|
1419
|
+
else if (STYLE_VALID.has(opt.toLowerCase())) style = opt.toLowerCase() as EdgeStyle;
|
|
1420
|
+
}
|
|
1421
|
+
rest = rest.slice(0, optMatch.index ?? 0).trim();
|
|
1422
|
+
} else {
|
|
1423
|
+
// 末尾から順に、 色か線種として読める語を取る。 語の集合が閉じているので、 説明文の
|
|
1424
|
+
// 一部を誤って取ることはない。 読めない語に当たった時点で止める。
|
|
1425
|
+
const words = splitValues(rest);
|
|
1426
|
+
while (words.length > 1) {
|
|
1427
|
+
const last = words[words.length - 1]!;
|
|
1428
|
+
// 引用符付きは説明文なので取らない
|
|
1429
|
+
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; }
|
|
1433
|
+
break;
|
|
1434
|
+
}
|
|
1435
|
+
rest = words.join(" ");
|
|
1436
|
+
}
|
|
1437
|
+
let to = rest;
|
|
1438
|
+
const labelMatch = rest.match(/^(.+?):\s*(.+)$/);
|
|
1439
|
+
if (labelMatch) {
|
|
1440
|
+
to = (labelMatch[1] ?? "").trim();
|
|
1441
|
+
label = stripQuotes((labelMatch[2] ?? "").trim());
|
|
1442
|
+
}
|
|
1443
|
+
if (!from || !to) return null;
|
|
1444
|
+
return {
|
|
1445
|
+
no,
|
|
1446
|
+
from: stripQuotes(from),
|
|
1447
|
+
to: stripQuotes(to),
|
|
1448
|
+
label,
|
|
1449
|
+
tone,
|
|
1450
|
+
style,
|
|
1451
|
+
sub,
|
|
1452
|
+
guard,
|
|
1453
|
+
cardinality,
|
|
1454
|
+
labelOffsetX,
|
|
1455
|
+
labelOffsetY,
|
|
1456
|
+
pos: { line: line.no },
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
function parseStateEntry(text: string, lineNo: number): DslState | null {
|
|
1461
|
+
// `client_bal: 100` / `status: "idle"`
|
|
1462
|
+
const m = text.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*:\s*(.+)$/);
|
|
1463
|
+
if (!m) return null;
|
|
1464
|
+
const name = m[1] ?? "";
|
|
1465
|
+
const raw = (m[2] ?? "").trim();
|
|
1466
|
+
const stripped = stripQuotes(raw);
|
|
1467
|
+
const asNum = Number(stripped);
|
|
1468
|
+
const initial: number | string = Number.isFinite(asNum) && stripped !== "" && !isNaN(asNum) ? asNum : stripped;
|
|
1469
|
+
return { name, initial, pos: { line: lineNo } };
|
|
1470
|
+
}
|
|
1471
|
+
|
|
1472
|
+
function splitTopLevelCommas(s: string): string[] {
|
|
1473
|
+
// brace 内を考慮 ... 今回は単純 split (動作する範囲)
|
|
1474
|
+
return s.split(",").map((x) => x.trim()).filter(Boolean);
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
function ensureAnimate(a: DslAnimate | undefined, lineNo: number): DslAnimate {
|
|
1478
|
+
if (a) return a;
|
|
1479
|
+
return { states: [], phases: [], pos: { line: lineNo } };
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
function parsePhase(block: Line[], errors: DslError[]): DslPhase | null {
|
|
1483
|
+
// block[0]!: `step: "request" 1.5s`
|
|
1484
|
+
const head = block[0]!;
|
|
1485
|
+
const m = head.trimmed.match(/^step\s*:\s*(.+)$/);
|
|
1486
|
+
if (!m) {
|
|
1487
|
+
errors.push({ line: head.no, message: `invalid step header: "${head.trimmed}"`, hint: 'use `- step: "name" 1.5s`' });
|
|
1488
|
+
return null;
|
|
1489
|
+
}
|
|
1490
|
+
const headRest = (m[1] ?? "").trim();
|
|
1491
|
+
// `"request" 1.5s` 形式 ... quote 後の duration 抽出
|
|
1492
|
+
const headParse = parseStepHead(headRest);
|
|
1493
|
+
if (!headParse) {
|
|
1494
|
+
errors.push({ line: head.no, message: `invalid step value: "${headRest}"`, hint: 'use `"name" 1.5s` (duration in s)' });
|
|
1495
|
+
return null;
|
|
1496
|
+
}
|
|
1497
|
+
const phase: DslPhase = {
|
|
1498
|
+
name: headParse.name,
|
|
1499
|
+
durationMs: headParse.durationMs,
|
|
1500
|
+
pos: { line: head.no },
|
|
1501
|
+
highlight: [],
|
|
1502
|
+
tweens: [],
|
|
1503
|
+
sets: [],
|
|
1504
|
+
};
|
|
1505
|
+
// 後続 property を順次 parse
|
|
1506
|
+
let i = 1;
|
|
1507
|
+
while (i < block.length) {
|
|
1508
|
+
const ln = block[i]!;
|
|
1509
|
+
const t = ln.trimmed;
|
|
1510
|
+
const propMatch = t.match(/^([a-zA-Z][a-zA-Z0-9_]*)\s*:\s*(.*)$/);
|
|
1511
|
+
if (!propMatch) {
|
|
1512
|
+
i += 1;
|
|
1513
|
+
continue;
|
|
1514
|
+
}
|
|
1515
|
+
const key = (propMatch[1] ?? "").toLowerCase();
|
|
1516
|
+
const value = (propMatch[2] ?? "").trim();
|
|
1517
|
+
if (key === "focus") {
|
|
1518
|
+
phase.highlight = parseFocusList(value);
|
|
1519
|
+
i += 1;
|
|
1520
|
+
continue;
|
|
1521
|
+
}
|
|
1522
|
+
if (key === "badge") {
|
|
1523
|
+
phase.badge = stripQuotes(value);
|
|
1524
|
+
i += 1;
|
|
1525
|
+
continue;
|
|
1526
|
+
}
|
|
1527
|
+
if (key === "description" || key === "body") {
|
|
1528
|
+
phase.body = stripQuotes(value);
|
|
1529
|
+
i += 1;
|
|
1530
|
+
continue;
|
|
1531
|
+
}
|
|
1532
|
+
if (key === "tween") {
|
|
1533
|
+
// inline (tween: client_bal 100 -> 90) or block
|
|
1534
|
+
if (value) {
|
|
1535
|
+
const tw = parseTweenLine(value, ln.no);
|
|
1536
|
+
if (tw) phase.tweens!.push(tw);
|
|
1537
|
+
else errors.push({ line: ln.no, message: `invalid tween: "${value}"`, hint: "use `tween: name 100 -> 90`" });
|
|
1538
|
+
i += 1;
|
|
1539
|
+
continue;
|
|
1540
|
+
}
|
|
1541
|
+
// block ... 後続の同 indent + 1 以上の行を取り込む
|
|
1542
|
+
const baseIndent = ln.indent;
|
|
1543
|
+
let j = i + 1;
|
|
1544
|
+
while (j < block.length) {
|
|
1545
|
+
const nx = block[j]!;
|
|
1546
|
+
if (nx.indent <= baseIndent) break;
|
|
1547
|
+
const tw = parseTweenLine(nx.trimmed, nx.no);
|
|
1548
|
+
if (tw) phase.tweens!.push(tw);
|
|
1549
|
+
else errors.push({ line: nx.no, message: `invalid tween entry: "${nx.trimmed}"`, hint: "use `name: 100 -> 90`" });
|
|
1550
|
+
j += 1;
|
|
1551
|
+
}
|
|
1552
|
+
i = j;
|
|
1553
|
+
continue;
|
|
1554
|
+
}
|
|
1555
|
+
if (key === "set") {
|
|
1556
|
+
if (value) {
|
|
1557
|
+
const st = parseSetLine(value, ln.no);
|
|
1558
|
+
if (st) phase.sets!.push(st);
|
|
1559
|
+
i += 1;
|
|
1560
|
+
continue;
|
|
1561
|
+
}
|
|
1562
|
+
const baseIndent = ln.indent;
|
|
1563
|
+
let j = i + 1;
|
|
1564
|
+
while (j < block.length) {
|
|
1565
|
+
const nx = block[j]!;
|
|
1566
|
+
if (nx.indent <= baseIndent) break;
|
|
1567
|
+
const st = parseSetLine(nx.trimmed, nx.no);
|
|
1568
|
+
if (st) phase.sets!.push(st);
|
|
1569
|
+
j += 1;
|
|
1570
|
+
}
|
|
1571
|
+
i = j;
|
|
1572
|
+
continue;
|
|
1573
|
+
}
|
|
1574
|
+
i += 1;
|
|
1575
|
+
}
|
|
1576
|
+
return phase;
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
function parseStepHead(s: string): { name: string; durationMs: number } | null {
|
|
1580
|
+
// 例: `"request" 1.5s` / `"step1" 1500ms` / `step1 2s`
|
|
1581
|
+
let rest = s.trim();
|
|
1582
|
+
let name = "";
|
|
1583
|
+
if (rest.startsWith('"') || rest.startsWith("'")) {
|
|
1584
|
+
const q = rest[0] ?? '"';
|
|
1585
|
+
const end = rest.indexOf(q, 1);
|
|
1586
|
+
if (end < 0) return null;
|
|
1587
|
+
name = rest.slice(1, end);
|
|
1588
|
+
rest = rest.slice(end + 1).trim();
|
|
1589
|
+
} else {
|
|
1590
|
+
const spaceIdx = rest.indexOf(" ");
|
|
1591
|
+
if (spaceIdx < 0) return null;
|
|
1592
|
+
name = rest.slice(0, spaceIdx);
|
|
1593
|
+
rest = rest.slice(spaceIdx + 1).trim();
|
|
1594
|
+
}
|
|
1595
|
+
const dm = rest.match(/^(\d+(?:\.\d+)?)\s*(ms|s)?$/);
|
|
1596
|
+
if (!dm) return null;
|
|
1597
|
+
const n = parseFloat(dm[1] ?? "0");
|
|
1598
|
+
const unit = dm[2] ?? "s";
|
|
1599
|
+
const durationMs = unit === "ms" ? Math.round(n) : Math.round(n * 1000);
|
|
1600
|
+
return { name, durationMs };
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
function parseFocusList(s: string): string[] {
|
|
1604
|
+
// `[Client, API]` / `Client, API` / `Client API` / `[Client, API, "Client -> API"]`
|
|
1605
|
+
// quote 内の space / comma / arrow は保護し、 quote 外の comma でのみ split する。
|
|
1606
|
+
let body = s.trim();
|
|
1607
|
+
if (body.startsWith("[") && body.endsWith("]")) body = body.slice(1, -1);
|
|
1608
|
+
const parts: string[] = [];
|
|
1609
|
+
let buf = "";
|
|
1610
|
+
let quote: string | null = null;
|
|
1611
|
+
for (const ch of body) {
|
|
1612
|
+
if (quote) {
|
|
1613
|
+
if (ch === quote) {
|
|
1614
|
+
quote = null;
|
|
1615
|
+
continue;
|
|
1616
|
+
}
|
|
1617
|
+
buf += ch;
|
|
1618
|
+
continue;
|
|
1619
|
+
}
|
|
1620
|
+
if (ch === "\"" || ch === "'") {
|
|
1621
|
+
quote = ch;
|
|
1622
|
+
continue;
|
|
1623
|
+
}
|
|
1624
|
+
if (ch === ",") {
|
|
1625
|
+
const t = buf.trim();
|
|
1626
|
+
if (t) parts.push(t);
|
|
1627
|
+
buf = "";
|
|
1628
|
+
continue;
|
|
1629
|
+
}
|
|
1630
|
+
buf += ch;
|
|
1631
|
+
}
|
|
1632
|
+
const tail = buf.trim();
|
|
1633
|
+
if (tail) parts.push(tail);
|
|
1634
|
+
// "User -> API" のような quote 済 item は「1 item」 として parts に入る。
|
|
1635
|
+
// quote 外 item は依然として space split (旧挙動、 「Client API」 が 2 item として解釈される互換維持)。
|
|
1636
|
+
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);
|
|
1641
|
+
continue;
|
|
1642
|
+
}
|
|
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);
|
|
1647
|
+
}
|
|
1648
|
+
continue;
|
|
1649
|
+
}
|
|
1650
|
+
out.push(p);
|
|
1651
|
+
}
|
|
1652
|
+
return out;
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
function parseTweenLine(s: string, lineNo: number): DslTween | null {
|
|
1656
|
+
// `client_bal 100 -> 90` / `client_bal: 100 -> 90`
|
|
1657
|
+
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+)?)$/);
|
|
1659
|
+
if (!m) return null;
|
|
1660
|
+
return {
|
|
1661
|
+
state: m[1] ?? "",
|
|
1662
|
+
from: parseFloat(m[2] ?? "0"),
|
|
1663
|
+
to: parseFloat(m[3] ?? "0"),
|
|
1664
|
+
pos: { line: lineNo },
|
|
1665
|
+
};
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
function parseSetLine(s: string, lineNo: number): DslSet | null {
|
|
1669
|
+
// `status: "loading"` / `status loading`
|
|
1670
|
+
const cleaned = s.replace(/^-\s*/, "").trim();
|
|
1671
|
+
const m = cleaned.match(/^([a-zA-Z_][a-zA-Z0-9_]*)\s*[:\s]\s*(.+)$/);
|
|
1672
|
+
if (!m) return null;
|
|
1673
|
+
const raw = (m[2] ?? "").trim();
|
|
1674
|
+
const stripped = stripQuotes(raw);
|
|
1675
|
+
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 } };
|
|
1678
|
+
}
|