@wscytz/dsl 0.0.0-stage → 0.1.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 +201 -0
- package/README.md +81 -2
- package/dist/canvas-dsl.d.ts +62 -0
- package/dist/canvas-dsl.js +215 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +389 -0
- package/dist/dsl-compute.d.ts +48 -0
- package/dist/dsl-compute.js +248 -0
- package/dist/dsl-grammar.d.ts +134 -0
- package/dist/dsl-grammar.js +227 -0
- package/dist/dsl-parser.d.ts +199 -0
- package/dist/dsl-parser.gen.d.ts +4 -0
- package/dist/dsl-parser.gen.js +3365 -0
- package/dist/dsl-parser.js +683 -0
- package/dist/dsl-relational-solver.d.ts +47 -0
- package/dist/dsl-relational-solver.js +221 -0
- package/dist/dsl-sanitize.d.ts +56 -0
- package/dist/dsl-sanitize.js +257 -0
- package/dist/index.d.ts +26 -0
- package/dist/index.js +26 -0
- package/dist/routing.d.ts +73 -0
- package/dist/routing.js +617 -0
- package/dist/to-json-canvas.d.ts +74 -0
- package/dist/to-json-canvas.js +149 -0
- package/dist/to-svg.d.ts +48 -0
- package/dist/to-svg.js +542 -0
- package/dist/types.d.ts +81 -0
- package/dist/types.js +12 -0
- package/package.json +53 -3
package/dist/cli.js
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* cys-dsl CLI — .sift 文件的 validate / render / roundtrip(执行意向书 Step 1 第 3 件)。
|
|
4
|
+
*
|
|
5
|
+
* - validate 语法(parse)→ 卫生(sanitize)→ 关系式求解(solve)全链检查,报诊断;
|
|
6
|
+
* - render 转 JSON Canvas 1.0(Obsidian .canvas / React Flow 等通用格式)到 stdout;
|
|
7
|
+
* - roundtrip DSL→元素→DSL 双往返,签名比对验证转义保真(serialize∘parse 不漂移)。
|
|
8
|
+
*
|
|
9
|
+
* 退出码:0 = 干净;1 = 有诊断/往返发散;2 = 用法或文件读取错误。
|
|
10
|
+
*
|
|
11
|
+
* 结构:纯函数核心(validateText / renderText / roundtripText)进测试与库;
|
|
12
|
+
* 本文件**不进公开 barrel**(node:fs 不许进 web bundle)。
|
|
13
|
+
* 运行:`pnpm --filter @wscytz/dsl cli <命令> <file.sift>`(tsx,devDep)。
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
import { pathToFileURL } from 'node:url';
|
|
17
|
+
import { DSL_VERSION } from './dsl-grammar.js';
|
|
18
|
+
import { parseDslWithDiagnostics } from './dsl-parser.js';
|
|
19
|
+
import { sanitizeDslOps } from './dsl-sanitize.js';
|
|
20
|
+
import { solveRelational } from './dsl-relational-solver.js';
|
|
21
|
+
import { serializeCanvas } from './canvas-dsl.js';
|
|
22
|
+
import { toSvg } from './to-svg.js';
|
|
23
|
+
import { toJsonCanvas, JSON_CANVAS_DEFAULT_CARD_W, JSON_CANVAS_DEFAULT_CARD_H, JSON_CANVAS_DEFAULT_TEXT_W, JSON_CANVAS_DEFAULT_TEXT_H, } from './to-json-canvas.js';
|
|
24
|
+
const USAGE = `cys-dsl — cy's Stift 画布 DSL(v${DSL_VERSION})命令行
|
|
25
|
+
|
|
26
|
+
用法:cys-dsl <命令> <file.sift>
|
|
27
|
+
命令:
|
|
28
|
+
validate 语法 / 卫生 / 关系式求解全链检查(诊断报告)
|
|
29
|
+
render 转 JSON Canvas 1.0 到 stdout(Obsidian .canvas 等通用格式);--svg 转 SVG
|
|
30
|
+
roundtrip DSL→元素→DSL 双往返签名比对(转义保真检查)
|
|
31
|
+
退出码:0 = 干净(警告不阻断);1 = 有诊断 / 往返发散;2 = 用法或文件错误
|
|
32
|
+
`;
|
|
33
|
+
/** 全链:parse(行级容错丢弃)→ sanitize → solve(关系式→绝对坐标)。 */
|
|
34
|
+
function pipeline(text) {
|
|
35
|
+
const parsed = parseDslWithDiagnostics(text);
|
|
36
|
+
const sanitized = sanitizeDslOps(parsed.ops);
|
|
37
|
+
const solved = solveRelational(sanitized.ops);
|
|
38
|
+
return { parsed, sanitized, solved };
|
|
39
|
+
}
|
|
40
|
+
/** parse 丢弃行的标准警告行(stderr 用)。 */
|
|
41
|
+
function parseWarnings(parsed) {
|
|
42
|
+
return parsed.errors.map((e) => `parse 丢弃 行${e.line}:${e.message}(源:${e.text})`);
|
|
43
|
+
}
|
|
44
|
+
// ---------- validate ----------
|
|
45
|
+
export function validateText(text) {
|
|
46
|
+
const { parsed, sanitized, solved } = pipeline(text);
|
|
47
|
+
const lines = [`cys-dsl v${DSL_VERSION} · validate`];
|
|
48
|
+
const errors = [];
|
|
49
|
+
let code = 0;
|
|
50
|
+
for (const w of parseWarnings(parsed)) {
|
|
51
|
+
code = 1;
|
|
52
|
+
errors.push(`✗ ${w}`);
|
|
53
|
+
}
|
|
54
|
+
// 软信号(全角标点/拼写手误):不影响 exit code,只提示 —— 元素已保留,可修可不修
|
|
55
|
+
for (const w of parsed.warnings) {
|
|
56
|
+
errors.push(`ℹ 行${w.line}:${w.message}(源:${w.text})`);
|
|
57
|
+
}
|
|
58
|
+
for (const d of sanitized.diagnostics)
|
|
59
|
+
errors.push(`⚠ sanitize op#${d.opIndex}:${d.message}`);
|
|
60
|
+
for (const d of solved.diagnostics)
|
|
61
|
+
errors.push(`⚠ solve op#${d.opIndex}:${d.message}`);
|
|
62
|
+
if (code === 0) {
|
|
63
|
+
const cards = solved.ops.filter((o) => o.type === 'card').length;
|
|
64
|
+
const free = solved.ops.filter((o) => o.type === 'free').length;
|
|
65
|
+
const arrows = solved.ops.filter((o) => o.type === 'arrow').length;
|
|
66
|
+
const warns = sanitized.diagnostics.length + solved.diagnostics.length;
|
|
67
|
+
lines.push(warns > 0
|
|
68
|
+
? `✓ ${solved.ops.length} op(card ${cards} / free ${free} / arrow ${arrows})——语法通过;${warns} 条警告见 stderr,不阻断`
|
|
69
|
+
: `✓ ${solved.ops.length} op(card ${cards} / free ${free} / arrow ${arrows})——语法、卫生检查与关系式求解均通过`);
|
|
70
|
+
}
|
|
71
|
+
return {
|
|
72
|
+
code,
|
|
73
|
+
stdout: lines.join('\n') + '\n',
|
|
74
|
+
stderr: errors.length > 0 ? errors.join('\n') + '\n' : '',
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
// ---------- render(JSON Canvas 1.0 / SVG)----------
|
|
78
|
+
export function renderText(text, opts) {
|
|
79
|
+
const { parsed, sanitized, solved } = pipeline(text);
|
|
80
|
+
const stderrLines = parseWarnings(parsed);
|
|
81
|
+
for (const d of sanitized.diagnostics)
|
|
82
|
+
stderrLines.push(`sanitize op#${d.opIndex}:${d.message}`);
|
|
83
|
+
for (const d of solved.diagnostics)
|
|
84
|
+
stderrLines.push(`solve op#${d.opIndex}:${d.message}`);
|
|
85
|
+
if (opts?.svg === true) {
|
|
86
|
+
const { svg, skipped } = toSvg(solved.ops);
|
|
87
|
+
for (const s of skipped)
|
|
88
|
+
stderrLines.push(`跳过 op#${s.opIndex} ${s.op}:${s.reason}`);
|
|
89
|
+
return {
|
|
90
|
+
code: parsed.errors.length > 0 ? 1 : 0,
|
|
91
|
+
stdout: svg,
|
|
92
|
+
stderr: stderrLines.length > 0 ? stderrLines.join('\n') + '\n' : '',
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
const { canvas, skipped } = toJsonCanvas(solved.ops);
|
|
96
|
+
for (const s of skipped)
|
|
97
|
+
stderrLines.push(`跳过 op#${s.opIndex} ${s.op}:${s.reason}`);
|
|
98
|
+
return {
|
|
99
|
+
code: parsed.errors.length > 0 ? 1 : 0,
|
|
100
|
+
stdout: JSON.stringify(canvas, null, 2) + '\n',
|
|
101
|
+
stderr: stderrLines.length > 0 ? stderrLines.join('\n') + '\n' : '',
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
// ---------- roundtrip ----------
|
|
105
|
+
/** toFixed(1) 是 serialize 的数值精度;签名两侧同用,防量化假发散。 */
|
|
106
|
+
function r1(n) {
|
|
107
|
+
return Number(n.toFixed(1));
|
|
108
|
+
}
|
|
109
|
+
/** 元素 id:无 id 的 free/arrow op 用 op 下标生成确定性兜底名(两侧签名同函数,保对齐)。 */
|
|
110
|
+
function elemId(id, opIndex) {
|
|
111
|
+
return id ?? `anon-${opIndex}`;
|
|
112
|
+
}
|
|
113
|
+
/** route 兼容规则(parser/serialize 同源):无 route 但有 curve → 视为 curve。 */
|
|
114
|
+
function normRoute(op) {
|
|
115
|
+
return op.route ?? (op.curve ? 'curve' : null);
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* 参与 roundtrip 的 op:输入专用 op 排除(keepExistingPos 卡 / 自由箭头——
|
|
119
|
+
* 它们依赖宿主画布状态,serializeCanvas 按设计永不输出,双往返中必然消失)。
|
|
120
|
+
*/
|
|
121
|
+
function roundtrippable(ops) {
|
|
122
|
+
return ops.filter((op) => {
|
|
123
|
+
if (op.type === 'card' && op.keepExistingPos === true)
|
|
124
|
+
return false;
|
|
125
|
+
if (op.type === 'arrow' && (op.freeArrow || !op.from || !op.to))
|
|
126
|
+
return false;
|
|
127
|
+
return true;
|
|
128
|
+
});
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* 归一化签名:比较「语义内容」而非字面文本。排除输入专用字段(create / rel /
|
|
132
|
+
* keepExistingPos),数值过 serialize 同款 toFixed(1) 量化,缺省几何按转换侧同一
|
|
133
|
+
* 缺省值归一。首轮与二轮 ops 各算一次,逐位比对。
|
|
134
|
+
*/
|
|
135
|
+
function signature(ops) {
|
|
136
|
+
return ops.map((op, i) => {
|
|
137
|
+
if (op.type === 'card') {
|
|
138
|
+
return JSON.stringify([
|
|
139
|
+
'card',
|
|
140
|
+
op.cardId,
|
|
141
|
+
r1(op.x),
|
|
142
|
+
r1(op.y),
|
|
143
|
+
r1(op.w ?? JSON_CANVAS_DEFAULT_CARD_W),
|
|
144
|
+
r1(op.h ?? JSON_CANVAS_DEFAULT_CARD_H),
|
|
145
|
+
op.color ?? null,
|
|
146
|
+
op.title ?? null,
|
|
147
|
+
op.content ?? null,
|
|
148
|
+
op.cardType ?? null,
|
|
149
|
+
op.tags ?? null,
|
|
150
|
+
op.links ?? null,
|
|
151
|
+
op.code ?? null,
|
|
152
|
+
op.quotes ?? null,
|
|
153
|
+
op.group ?? null,
|
|
154
|
+
op.href ?? null,
|
|
155
|
+
]);
|
|
156
|
+
}
|
|
157
|
+
if (op.type === 'free') {
|
|
158
|
+
// text 的 w/h 不进 DSL(serializer 不 emit @size),签名不含。
|
|
159
|
+
const base = ['free', op.shape, elemId(op.id, i), r1(op.x), r1(op.y)];
|
|
160
|
+
if (op.shape === 'text') {
|
|
161
|
+
base.push(op.text ?? null, op.color ?? null, op.group ?? null, op.compute ?? null);
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
base.push(r1(op.w ?? 0), r1(op.h ?? 0), op.shape === 'frame' ? (op.text ?? null) : null, op.color ?? null, op.group ?? null);
|
|
165
|
+
}
|
|
166
|
+
return JSON.stringify(base);
|
|
167
|
+
}
|
|
168
|
+
return JSON.stringify([
|
|
169
|
+
'arrow',
|
|
170
|
+
elemId(op.id, i),
|
|
171
|
+
op.from,
|
|
172
|
+
op.to,
|
|
173
|
+
op.label ?? null,
|
|
174
|
+
op.color ?? null,
|
|
175
|
+
op.dash ?? null,
|
|
176
|
+
op.arrowhead ?? null,
|
|
177
|
+
op.wikilink ?? null,
|
|
178
|
+
op.curve ?? null,
|
|
179
|
+
normRoute(op),
|
|
180
|
+
op.elbow ?? null,
|
|
181
|
+
]);
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
/** solved ops → 最小 CanvasElement[](供 serializeCanvas;与 apply 层同缺省尺寸)。 */
|
|
185
|
+
function opsToElements(ops) {
|
|
186
|
+
const elements = [];
|
|
187
|
+
ops.forEach((op, i) => {
|
|
188
|
+
if (op.type === 'card') {
|
|
189
|
+
if (op.keepExistingPos === true)
|
|
190
|
+
return;
|
|
191
|
+
elements.push({
|
|
192
|
+
id: op.cardId,
|
|
193
|
+
kind: 'card',
|
|
194
|
+
x: op.x,
|
|
195
|
+
y: op.y,
|
|
196
|
+
w: op.w ?? JSON_CANVAS_DEFAULT_CARD_W,
|
|
197
|
+
h: op.h ?? JSON_CANVAS_DEFAULT_CARD_H,
|
|
198
|
+
rotation: 0,
|
|
199
|
+
...(op.color !== undefined ? { color: op.color } : {}),
|
|
200
|
+
...(op.group !== undefined || op.href?.length
|
|
201
|
+
? {
|
|
202
|
+
meta: {
|
|
203
|
+
...(op.group !== undefined ? { group: op.group } : {}),
|
|
204
|
+
...(op.href?.length ? { href: op.href } : {}),
|
|
205
|
+
},
|
|
206
|
+
}
|
|
207
|
+
: {}),
|
|
208
|
+
});
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (op.type === 'free') {
|
|
212
|
+
if (op.shape === 'text') {
|
|
213
|
+
elements.push({
|
|
214
|
+
id: elemId(op.id, i),
|
|
215
|
+
kind: 'text',
|
|
216
|
+
x: op.x,
|
|
217
|
+
y: op.y,
|
|
218
|
+
w: op.w ?? JSON_CANVAS_DEFAULT_TEXT_W,
|
|
219
|
+
h: op.h ?? JSON_CANVAS_DEFAULT_TEXT_H,
|
|
220
|
+
rotation: 0,
|
|
221
|
+
text: op.text ?? '',
|
|
222
|
+
...(op.color !== undefined ? { color: op.color } : {}),
|
|
223
|
+
...(op.group !== undefined || op.compute !== undefined
|
|
224
|
+
? {
|
|
225
|
+
meta: {
|
|
226
|
+
...(op.group !== undefined ? { group: op.group } : {}),
|
|
227
|
+
...(op.compute !== undefined ? { compute: op.compute } : {}),
|
|
228
|
+
},
|
|
229
|
+
}
|
|
230
|
+
: {}),
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
else {
|
|
234
|
+
elements.push({
|
|
235
|
+
id: elemId(op.id, i),
|
|
236
|
+
kind: op.shape,
|
|
237
|
+
x: op.x,
|
|
238
|
+
y: op.y,
|
|
239
|
+
w: op.w ?? 0,
|
|
240
|
+
h: op.h ?? 0,
|
|
241
|
+
rotation: 0,
|
|
242
|
+
...(op.shape === 'frame' ? { text: op.text ?? '' } : {}),
|
|
243
|
+
...(op.color !== undefined ? { color: op.color } : {}),
|
|
244
|
+
...(op.group !== undefined ? { meta: { group: op.group } } : {}),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
// arrow:自由箭头(无端点)无法以元素形式往返,跳过(roundtrippable 已排除)。
|
|
250
|
+
if (op.freeArrow || !op.from || !op.to)
|
|
251
|
+
return;
|
|
252
|
+
elements.push({
|
|
253
|
+
id: elemId(op.id, i),
|
|
254
|
+
kind: 'arrow',
|
|
255
|
+
x: 0,
|
|
256
|
+
y: 0,
|
|
257
|
+
w: 0,
|
|
258
|
+
h: 0,
|
|
259
|
+
rotation: 0,
|
|
260
|
+
from: op.from,
|
|
261
|
+
to: op.to,
|
|
262
|
+
...(op.label !== undefined ? { text: op.label } : {}),
|
|
263
|
+
...(op.color !== undefined ? { color: op.color } : {}),
|
|
264
|
+
...(op.dash !== undefined ? { dash: op.dash } : {}),
|
|
265
|
+
...(op.arrowhead !== undefined ? { arrowhead: op.arrowhead } : {}),
|
|
266
|
+
...(op.curve !== undefined ? { curve: op.curve } : {}),
|
|
267
|
+
...(op.route !== undefined ? { route: op.route } : {}),
|
|
268
|
+
...(op.elbow !== undefined ? { elbow: op.elbow } : {}),
|
|
269
|
+
...(op.wikilink === true ? { meta: { wikilink: true } } : {}),
|
|
270
|
+
});
|
|
271
|
+
});
|
|
272
|
+
return elements;
|
|
273
|
+
}
|
|
274
|
+
/** op₁ 的结构化字段 → CardDslContent(serializeCanvas resolve 注入)。
|
|
275
|
+
* 占位值:tags 色给 canonical 灰(serialize 只读 .value);links fetchedAt 用 epoch(只读 .url)。 */
|
|
276
|
+
function collectContent(ops) {
|
|
277
|
+
const map = new Map();
|
|
278
|
+
for (const op of ops) {
|
|
279
|
+
if (op.type !== 'card')
|
|
280
|
+
continue;
|
|
281
|
+
const content = {};
|
|
282
|
+
if (op.title !== undefined)
|
|
283
|
+
content.title = op.title;
|
|
284
|
+
if (op.content !== undefined)
|
|
285
|
+
content.content = op.content;
|
|
286
|
+
if (op.cardType !== undefined)
|
|
287
|
+
content.type = op.cardType;
|
|
288
|
+
if (op.tags?.length)
|
|
289
|
+
content.tags = op.tags.map((value) => ({ value, color: 'var(--color-gray)' }));
|
|
290
|
+
if (op.links?.length)
|
|
291
|
+
content.links = op.links.map((url) => ({ url, fetchedAt: new Date(0) }));
|
|
292
|
+
if (op.code?.length)
|
|
293
|
+
content.codeSnippets = op.code;
|
|
294
|
+
if (op.quotes?.length)
|
|
295
|
+
content.quotes = op.quotes;
|
|
296
|
+
if (Object.keys(content).length > 0)
|
|
297
|
+
map.set(op.cardId, content);
|
|
298
|
+
}
|
|
299
|
+
return map;
|
|
300
|
+
}
|
|
301
|
+
export function roundtripText(text) {
|
|
302
|
+
const { parsed, sanitized, solved } = pipeline(text);
|
|
303
|
+
const stderrLines = parseWarnings(parsed);
|
|
304
|
+
for (const d of sanitized.diagnostics)
|
|
305
|
+
stderrLines.push(`sanitize op#${d.opIndex}:${d.message}`);
|
|
306
|
+
for (const d of solved.diagnostics)
|
|
307
|
+
stderrLines.push(`solve op#${d.opIndex}:${d.message}`);
|
|
308
|
+
const pass1 = roundtrippable(solved.ops);
|
|
309
|
+
const contentById = collectContent(solved.ops);
|
|
310
|
+
const dsl2 = serializeCanvas(opsToElements(solved.ops), (id) => contentById.get(id));
|
|
311
|
+
const parsed2 = parseDslWithDiagnostics(dsl2);
|
|
312
|
+
const sanitized2 = sanitizeDslOps(parsed2.ops);
|
|
313
|
+
const solved2 = solveRelational(sanitized2.ops);
|
|
314
|
+
const pass2 = roundtrippable(solved2.ops);
|
|
315
|
+
const sig1 = signature(pass1);
|
|
316
|
+
const sig2 = signature(pass2);
|
|
317
|
+
const lines = [];
|
|
318
|
+
let code = 0;
|
|
319
|
+
if (sig1.length !== sig2.length) {
|
|
320
|
+
code = 1;
|
|
321
|
+
lines.push(`roundtrip:DIVERGED(op 数 ${sig1.length} → ${sig2.length})`);
|
|
322
|
+
}
|
|
323
|
+
else {
|
|
324
|
+
const diffs = [];
|
|
325
|
+
for (let i = 0; i < sig1.length; i++)
|
|
326
|
+
if (sig1[i] !== sig2[i])
|
|
327
|
+
diffs.push(i);
|
|
328
|
+
if (diffs.length === 0) {
|
|
329
|
+
lines.push(`roundtrip:STABLE(${sig1.length} op 签名一致,转义保真)`);
|
|
330
|
+
}
|
|
331
|
+
else {
|
|
332
|
+
code = 1;
|
|
333
|
+
lines.push(`roundtrip:DIVERGED(${diffs.length}/${sig1.length} op 签名不一致)`);
|
|
334
|
+
for (const i of diffs.slice(0, 5)) {
|
|
335
|
+
lines.push(` op#${i} 首轮:${sig1[i]}`);
|
|
336
|
+
lines.push(` op#${i} 二轮:${sig2[i]}`);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
const inputOnly = solved.ops.length - pass1.length;
|
|
341
|
+
if (inputOnly > 0) {
|
|
342
|
+
lines.push(`注:${inputOnly} 个输入专用 op(keepExistingPos 卡 / 自由箭头)不参与往返——它们依赖宿主画布状态,serialize 按设计永不输出`);
|
|
343
|
+
}
|
|
344
|
+
return {
|
|
345
|
+
code,
|
|
346
|
+
stdout: lines.join('\n') + '\n',
|
|
347
|
+
stderr: stderrLines.length > 0 ? stderrLines.join('\n') + '\n' : '',
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
// ---------- argv 包装 ----------
|
|
351
|
+
export function main(argv) {
|
|
352
|
+
const [cmd, file] = argv;
|
|
353
|
+
if (!cmd || !file || (cmd !== 'validate' && cmd !== 'render' && cmd !== 'roundtrip')) {
|
|
354
|
+
process.stderr.write(USAGE);
|
|
355
|
+
return 2;
|
|
356
|
+
}
|
|
357
|
+
// flag 解析:目前仅 render 认 --svg;其余 flag 一律用法错误(不静默吞)
|
|
358
|
+
const rest = argv.slice(2);
|
|
359
|
+
const unknownFlag = rest.find((f) => f.startsWith('--') && f !== '--svg');
|
|
360
|
+
if (unknownFlag !== undefined || (rest.includes('--svg') && cmd !== 'render')) {
|
|
361
|
+
process.stderr.write(USAGE);
|
|
362
|
+
return 2;
|
|
363
|
+
}
|
|
364
|
+
let text;
|
|
365
|
+
try {
|
|
366
|
+
text = readFileSync(file, 'utf8');
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
process.stderr.write(`cys-dsl:读不到文件:${file}\n`);
|
|
370
|
+
return 2;
|
|
371
|
+
}
|
|
372
|
+
const result = cmd === 'validate'
|
|
373
|
+
? validateText(text)
|
|
374
|
+
: cmd === 'render'
|
|
375
|
+
? renderText(text, { svg: rest.includes('--svg') })
|
|
376
|
+
: roundtripText(text);
|
|
377
|
+
if (result.stdout)
|
|
378
|
+
process.stdout.write(result.stdout);
|
|
379
|
+
if (result.stderr)
|
|
380
|
+
process.stderr.write(result.stderr);
|
|
381
|
+
return result.code;
|
|
382
|
+
}
|
|
383
|
+
/* 直接执行时才跑 main(被 import / 测试时不触发)。
|
|
384
|
+
* 用 process.exitCode 而非 process.exit:后者不等 stdout 管道 flush,>64KiB 输出
|
|
385
|
+
* (大画布 render)在 `| jq` 等管道下会静默截断且仍 exit 0。exitCode 让 Node 在
|
|
386
|
+
* 写队列排空后自然退出。 */
|
|
387
|
+
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
388
|
+
process.exitCode = main(process.argv.slice(2));
|
|
389
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsl-compute — v7 `@compute` 的**安全**公式求值器(纯函数,框架无关)。
|
|
3
|
+
*
|
|
4
|
+
* 设计铁律(安全):
|
|
5
|
+
* - **绝不 eval / new Function**。公式经手写 tokenizer + 递归下降 parser 求值,只认一个
|
|
6
|
+
* 固定的小语言(数字 / + - * / / 括号 / min,max,abs,round / `#id.x|y|w|h` 几何引用)。
|
|
7
|
+
* 任何越界字符 → 求值失败(返回 undefined),不执行。
|
|
8
|
+
* - **只引用元素几何**(x/y/w/h),**不碰卡片内容**(title/body)→ 无隐私泄漏
|
|
9
|
+
* (对齐 AI 隐私:content 不外发;这里连内部求值都只读几何)。
|
|
10
|
+
* - 除零 → 0;非有限结果(NaN/Infinity)→ 0。递归深度上限,防 `((((…))))` 栈炸(DoS)。
|
|
11
|
+
*
|
|
12
|
+
* 语义:apply 时(snapshot 批量)求值,结果写 text 元素的 text;原式存 element.meta.compute
|
|
13
|
+
* 往返对称。**live 重算**(被引用元素一动就重算)是文档化的后续增强,v7 只在每次 apply 重算。
|
|
14
|
+
*
|
|
15
|
+
* 语言(EBNF):
|
|
16
|
+
* expr = term (('+'|'-') term)*
|
|
17
|
+
* term = factor (('*'|'/') factor)*
|
|
18
|
+
* factor = '-' factor | number | ref | func '(' args ')' | '(' expr ')'
|
|
19
|
+
* ref = '#' idChar+ '.' ('x'|'y'|'w'|'h')
|
|
20
|
+
* func = 'min' | 'max' | 'abs' | 'round' # min/max ≥1 参,abs/round 恰 1 参
|
|
21
|
+
* args = expr (',' expr)*
|
|
22
|
+
*/
|
|
23
|
+
/** 求值器的几何解析器:给元素 id 返回几何,不存在 → undefined(该引用求值失败 → 整式失败)。 */
|
|
24
|
+
export interface ComputeGeom {
|
|
25
|
+
x: number;
|
|
26
|
+
y: number;
|
|
27
|
+
w: number;
|
|
28
|
+
h: number;
|
|
29
|
+
}
|
|
30
|
+
export type ComputeResolver = (id: string) => ComputeGeom | undefined;
|
|
31
|
+
/** 数字格式化:去浮点噪声,最多 2 位小数,去尾 0(12.00→12, 3.50→3.5)。 */
|
|
32
|
+
export declare function formatComputeNumber(n: number): string;
|
|
33
|
+
/** 求值结果:成功→value(有限数);失败→value undefined + error(原因,供 apply 诊断)。 */
|
|
34
|
+
export interface ComputeResult {
|
|
35
|
+
value: number | undefined;
|
|
36
|
+
error?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* 求值 `@compute` 公式(同 evalCompute,但暴露失败原因 error 供 apply 诊断)。
|
|
40
|
+
* 成功 → { value: 有限数 };任何失败(语法 / 未解析引用 / 越界)→ { value: undefined, error }。
|
|
41
|
+
* 永不抛错。resolve 用来把 `#id.field` 解析成几何值。
|
|
42
|
+
*/
|
|
43
|
+
export declare function evalComputeDetail(expr: string, resolve: ComputeResolver): ComputeResult;
|
|
44
|
+
/**
|
|
45
|
+
* 求值 `@compute` 公式(薄封装,仅返回 value);需失败原因用 {@link evalComputeDetail}。
|
|
46
|
+
* 成功 → 有限数;任何失败 → undefined。永不抛错。
|
|
47
|
+
*/
|
|
48
|
+
export declare function evalCompute(expr: string, resolve: ComputeResolver): number | undefined;
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsl-compute — v7 `@compute` 的**安全**公式求值器(纯函数,框架无关)。
|
|
3
|
+
*
|
|
4
|
+
* 设计铁律(安全):
|
|
5
|
+
* - **绝不 eval / new Function**。公式经手写 tokenizer + 递归下降 parser 求值,只认一个
|
|
6
|
+
* 固定的小语言(数字 / + - * / / 括号 / min,max,abs,round / `#id.x|y|w|h` 几何引用)。
|
|
7
|
+
* 任何越界字符 → 求值失败(返回 undefined),不执行。
|
|
8
|
+
* - **只引用元素几何**(x/y/w/h),**不碰卡片内容**(title/body)→ 无隐私泄漏
|
|
9
|
+
* (对齐 AI 隐私:content 不外发;这里连内部求值都只读几何)。
|
|
10
|
+
* - 除零 → 0;非有限结果(NaN/Infinity)→ 0。递归深度上限,防 `((((…))))` 栈炸(DoS)。
|
|
11
|
+
*
|
|
12
|
+
* 语义:apply 时(snapshot 批量)求值,结果写 text 元素的 text;原式存 element.meta.compute
|
|
13
|
+
* 往返对称。**live 重算**(被引用元素一动就重算)是文档化的后续增强,v7 只在每次 apply 重算。
|
|
14
|
+
*
|
|
15
|
+
* 语言(EBNF):
|
|
16
|
+
* expr = term (('+'|'-') term)*
|
|
17
|
+
* term = factor (('*'|'/') factor)*
|
|
18
|
+
* factor = '-' factor | number | ref | func '(' args ')' | '(' expr ')'
|
|
19
|
+
* ref = '#' idChar+ '.' ('x'|'y'|'w'|'h')
|
|
20
|
+
* func = 'min' | 'max' | 'abs' | 'round' # min/max ≥1 参,abs/round 恰 1 参
|
|
21
|
+
* args = expr (',' expr)*
|
|
22
|
+
*/
|
|
23
|
+
/** 递归深度上限(防嵌套括号栈炸;200 字公式远到不了,纯防御)。 */
|
|
24
|
+
const MAX_DEPTH = 64;
|
|
25
|
+
/** 数字格式化:去浮点噪声,最多 2 位小数,去尾 0(12.00→12, 3.50→3.5)。 */
|
|
26
|
+
export function formatComputeNumber(n) {
|
|
27
|
+
if (!Number.isFinite(n))
|
|
28
|
+
return '0';
|
|
29
|
+
const rounded = Math.round(n * 100) / 100;
|
|
30
|
+
return String(rounded);
|
|
31
|
+
}
|
|
32
|
+
const FUNC_NAMES = new Set(['min', 'max', 'abs', 'round']);
|
|
33
|
+
const FIELDS = new Set(['x', 'y', 'w', 'h']);
|
|
34
|
+
// id 字符集(与 dsl.peggy idChars 一致);`-` 在内,但 ref 必以 `#` 起头,故不与减号冲突。
|
|
35
|
+
const ID_CHAR = /[a-zA-Z0-9_:-]/;
|
|
36
|
+
/** tokenize:越界字符直接 throw(求值失败)。行级无换行(公式单行)。 */
|
|
37
|
+
function tokenize(src) {
|
|
38
|
+
const toks = [];
|
|
39
|
+
let i = 0;
|
|
40
|
+
const n = src.length;
|
|
41
|
+
while (i < n) {
|
|
42
|
+
const ch = src[i];
|
|
43
|
+
if (ch === ' ' || ch === '\t') {
|
|
44
|
+
i += 1;
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (ch === '#') {
|
|
48
|
+
i += 1;
|
|
49
|
+
let id = '';
|
|
50
|
+
while (i < n && ID_CHAR.test(src[i])) {
|
|
51
|
+
id += src[i];
|
|
52
|
+
i += 1;
|
|
53
|
+
}
|
|
54
|
+
if (id === '')
|
|
55
|
+
throw new Error('empty ref id');
|
|
56
|
+
if (src[i] !== '.')
|
|
57
|
+
throw new Error('ref missing .field');
|
|
58
|
+
i += 1; // consume '.'
|
|
59
|
+
let field = '';
|
|
60
|
+
while (i < n && /[a-z]/.test(src[i])) {
|
|
61
|
+
field += src[i];
|
|
62
|
+
i += 1;
|
|
63
|
+
}
|
|
64
|
+
if (!FIELDS.has(field))
|
|
65
|
+
throw new Error(`bad ref field: ${field}`);
|
|
66
|
+
toks.push({ t: 'ref', id, field: field });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if ((ch >= '0' && ch <= '9') || ch === '.') {
|
|
70
|
+
let num = '';
|
|
71
|
+
while (i < n && ((src[i] >= '0' && src[i] <= '9') || src[i] === '.')) {
|
|
72
|
+
num += src[i];
|
|
73
|
+
i += 1;
|
|
74
|
+
}
|
|
75
|
+
const v = Number(num);
|
|
76
|
+
if (!Number.isFinite(v))
|
|
77
|
+
throw new Error(`bad number: ${num}`);
|
|
78
|
+
toks.push({ t: 'num', v });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) {
|
|
82
|
+
let name = '';
|
|
83
|
+
while (i < n && /[a-zA-Z]/.test(src[i])) {
|
|
84
|
+
name += src[i];
|
|
85
|
+
i += 1;
|
|
86
|
+
}
|
|
87
|
+
if (!FUNC_NAMES.has(name))
|
|
88
|
+
throw new Error(`unknown func: ${name}`);
|
|
89
|
+
toks.push({ t: 'func', name: name });
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (ch === '+' || ch === '-' || ch === '*' || ch === '/') {
|
|
93
|
+
toks.push({ t: 'op', v: ch });
|
|
94
|
+
i += 1;
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (ch === '(') {
|
|
98
|
+
toks.push({ t: 'lp' });
|
|
99
|
+
i += 1;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
if (ch === ')') {
|
|
103
|
+
toks.push({ t: 'rp' });
|
|
104
|
+
i += 1;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (ch === ',') {
|
|
108
|
+
toks.push({ t: 'comma' });
|
|
109
|
+
i += 1;
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
throw new Error(`bad char: ${ch}`);
|
|
113
|
+
}
|
|
114
|
+
return toks;
|
|
115
|
+
}
|
|
116
|
+
function errMsg(e) {
|
|
117
|
+
return e instanceof Error ? e.message : String(e);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* 求值 `@compute` 公式(同 evalCompute,但暴露失败原因 error 供 apply 诊断)。
|
|
121
|
+
* 成功 → { value: 有限数 };任何失败(语法 / 未解析引用 / 越界)→ { value: undefined, error }。
|
|
122
|
+
* 永不抛错。resolve 用来把 `#id.field` 解析成几何值。
|
|
123
|
+
*/
|
|
124
|
+
export function evalComputeDetail(expr, resolve) {
|
|
125
|
+
if (typeof expr !== 'string' || expr.trim() === '')
|
|
126
|
+
return { value: undefined };
|
|
127
|
+
let toks;
|
|
128
|
+
try {
|
|
129
|
+
toks = tokenize(expr);
|
|
130
|
+
}
|
|
131
|
+
catch (e) {
|
|
132
|
+
return { value: undefined, error: errMsg(e) };
|
|
133
|
+
}
|
|
134
|
+
if (toks.length === 0)
|
|
135
|
+
return { value: undefined };
|
|
136
|
+
let pos = 0;
|
|
137
|
+
const peek = () => toks[pos];
|
|
138
|
+
const next = () => toks[pos++];
|
|
139
|
+
function parseExpr(depth) {
|
|
140
|
+
if (depth > MAX_DEPTH)
|
|
141
|
+
throw new Error('too deep');
|
|
142
|
+
let left = parseTerm(depth + 1);
|
|
143
|
+
for (;;) {
|
|
144
|
+
const tk = peek();
|
|
145
|
+
if (tk && tk.t === 'op' && (tk.v === '+' || tk.v === '-')) {
|
|
146
|
+
next();
|
|
147
|
+
const right = parseTerm(depth + 1);
|
|
148
|
+
left = tk.v === '+' ? left + right : left - right;
|
|
149
|
+
}
|
|
150
|
+
else {
|
|
151
|
+
return left;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function parseTerm(depth) {
|
|
156
|
+
if (depth > MAX_DEPTH)
|
|
157
|
+
throw new Error('too deep');
|
|
158
|
+
let left = parseFactor(depth + 1);
|
|
159
|
+
for (;;) {
|
|
160
|
+
const tk = peek();
|
|
161
|
+
if (tk && tk.t === 'op' && (tk.v === '*' || tk.v === '/')) {
|
|
162
|
+
next();
|
|
163
|
+
const right = parseFactor(depth + 1);
|
|
164
|
+
if (tk.v === '*')
|
|
165
|
+
left = left * right;
|
|
166
|
+
else
|
|
167
|
+
left = right === 0 ? 0 : left / right; // 除零 → 0(安全,不产 Infinity)
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
return left;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
function parseFactor(depth) {
|
|
175
|
+
if (depth > MAX_DEPTH)
|
|
176
|
+
throw new Error('too deep');
|
|
177
|
+
const tk = next();
|
|
178
|
+
if (!tk)
|
|
179
|
+
throw new Error('unexpected end');
|
|
180
|
+
// 一元负号
|
|
181
|
+
if (tk.t === 'op' && tk.v === '-')
|
|
182
|
+
return -parseFactor(depth + 1);
|
|
183
|
+
if (tk.t === 'num')
|
|
184
|
+
return tk.v;
|
|
185
|
+
if (tk.t === 'ref') {
|
|
186
|
+
const g = resolve(tk.id);
|
|
187
|
+
if (g === undefined)
|
|
188
|
+
throw new Error(`unresolved ref: #${tk.id}`);
|
|
189
|
+
const v = g[tk.field];
|
|
190
|
+
if (!Number.isFinite(v))
|
|
191
|
+
throw new Error(`non-finite geom: #${tk.id}.${tk.field}`);
|
|
192
|
+
return v;
|
|
193
|
+
}
|
|
194
|
+
if (tk.t === 'func') {
|
|
195
|
+
const lp = next();
|
|
196
|
+
if (!lp || lp.t !== 'lp')
|
|
197
|
+
throw new Error('func missing (');
|
|
198
|
+
const args = [];
|
|
199
|
+
// 允许 min() 空?不 —— 至少 1 参。
|
|
200
|
+
args.push(parseExpr(depth + 1));
|
|
201
|
+
while (peek() && peek().t === 'comma') {
|
|
202
|
+
next();
|
|
203
|
+
args.push(parseExpr(depth + 1));
|
|
204
|
+
}
|
|
205
|
+
const rp = next();
|
|
206
|
+
if (!rp || rp.t !== 'rp')
|
|
207
|
+
throw new Error('func missing )');
|
|
208
|
+
switch (tk.name) {
|
|
209
|
+
case 'min':
|
|
210
|
+
return Math.min(...args);
|
|
211
|
+
case 'max':
|
|
212
|
+
return Math.max(...args);
|
|
213
|
+
case 'abs':
|
|
214
|
+
if (args.length !== 1)
|
|
215
|
+
throw new Error('abs expects 1 arg');
|
|
216
|
+
return Math.abs(args[0]);
|
|
217
|
+
case 'round':
|
|
218
|
+
if (args.length !== 1)
|
|
219
|
+
throw new Error('round expects 1 arg');
|
|
220
|
+
return Math.round(args[0]);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
if (tk.t === 'lp') {
|
|
224
|
+
const v = parseExpr(depth + 1);
|
|
225
|
+
const rp = next();
|
|
226
|
+
if (!rp || rp.t !== 'rp')
|
|
227
|
+
throw new Error('missing )');
|
|
228
|
+
return v;
|
|
229
|
+
}
|
|
230
|
+
throw new Error('unexpected token');
|
|
231
|
+
}
|
|
232
|
+
try {
|
|
233
|
+
const result = parseExpr(0);
|
|
234
|
+
if (pos !== toks.length)
|
|
235
|
+
return { value: undefined, error: 'trailing tokens' }; // 尾部残余 → 语法错
|
|
236
|
+
return { value: Number.isFinite(result) ? result : 0 };
|
|
237
|
+
}
|
|
238
|
+
catch (e) {
|
|
239
|
+
return { value: undefined, error: errMsg(e) };
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* 求值 `@compute` 公式(薄封装,仅返回 value);需失败原因用 {@link evalComputeDetail}。
|
|
244
|
+
* 成功 → 有限数;任何失败 → undefined。永不抛错。
|
|
245
|
+
*/
|
|
246
|
+
export function evalCompute(expr, resolve) {
|
|
247
|
+
return evalComputeDetail(expr, resolve).value;
|
|
248
|
+
}
|