@lingfan-engine/editor 0.1.0 → 0.1.3

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.
@@ -1,203 +1,203 @@
1
- /**
2
- * 表达式标签的装配:把模板字面量拼成 `{…}` 文本,插值按句柄与字面量规则渲染。
3
- *
4
- * 组装同时做构建期轻类型校验(判类器在 `./audit`):警告进池、由
5
- * `drainExpressionWarnings` 取走;表达式产物与既有行为逐字节一致(校验零侵入)。
6
- */
7
-
8
- import {
9
- currentRegistry,
10
- isVarHandle,
11
- varKeyOf,
12
- warningSink,
13
- type ExpressionWarning,
14
- type VarKind,
15
- } from "./registry.ts";
16
- import { ExpressionAuditor, type AuditToken, type OperandKind } from "./audit/index.ts";
17
- import { defaultEditorTexts } from "../../contracts/index.ts";
18
-
19
- /**
20
- * expr 标签函数的文案取缺省表(例外登记):模板标签函数无法携带可选参数注入,
21
- * 这四条是构建期开发者诊断(缺省不可变直引,无模块级 setter)。
22
- * varKeyOf / ExpressionAuditor 的文案仍走调用点注入。
23
- */
24
- const texts = defaultEditorTexts;
25
-
26
- /**
27
- * 表达式标签:`expr\`${vars.player.gold} >= 25\` ⇒ "{player.gold >= 25}"`。
28
- * 插值规则:VarHandle ⇒ 注册键(未注册抛错);number/boolean ⇒ 字面量;
29
- * string ⇒ JSON 字面量(带引号,与引擎字符串字面量口径一致)。
30
- *
31
- * 组装同时做轻类型校验(见文件头):警告进池,由 `drainExpressionWarnings` 取走;
32
- * 表达式产物与既有行为逐字节一致(校验零侵入)。
33
- *
34
- * 产物是 `"{…}"` 表达式串:引擎在**执行到包含它的命令时**求值——
35
- * 条件语境({@link when} / whileDo)求出布尔;赋值语境({@link set} 等)
36
- * 求出值;台词/通知文本在显示时插值。
37
- *
38
- * 必须先经 defineVars 注册变量,否则抛错。
39
- *
40
- * @param strings 模板字面量的静态片段。
41
- * @param parts 插值:变量句柄 / number / boolean / string。
42
- * @returns `"{…}"` 表达式串(放进故事数据,运行期求值)。
43
- * @example
44
- * expr`${vars.player.gold} >= 25` // ⇒ "{player.gold >= 25}"
45
- * expr`${vars.greeting}, 旅行者` // string 插值 ⇒ 带引号字面量
46
- */
47
- export function expr(
48
- strings: TemplateStringsArray,
49
- ...parts: unknown[]
50
- ): string {
51
- const registry = currentRegistry;
52
- if (registry === null) {
53
- throw new Error(texts.exprRequiresDefineVars());
54
- }
55
- const tokens: AuditToken[] = [];
56
- const pending: Array<{ rule: ExpressionWarning["rule"]; message: string }> =
57
- [];
58
- // 引号状态跨片段/插值跟踪(`"pre${name}post"` 的吞并 footgun:插值会被并进字面量)
59
- let quote: string | null = null;
60
- let escaped = false;
61
-
62
- /** 片段词法:引号(跨片段)、双字运算符优先、字面量/数字/标识符;括号与结构标点进流 */
63
- const lexFragment = (segment: string): void => {
64
- for (let i = 0; i < segment.length; i += 1) {
65
- const ch = segment[i]!;
66
- if (quote !== null) {
67
- if (escaped) {
68
- escaped = false;
69
- continue;
70
- }
71
- if (ch === "\\") {
72
- escaped = true;
73
- continue;
74
- }
75
- if (ch === quote) quote = null;
76
- continue;
77
- }
78
- if (ch === '"' || ch === "'") {
79
- quote = ch;
80
- tokens.push({ t: "operand", kind: "str", label: "字符串字面量" });
81
- continue;
82
- }
83
- const two = segment.slice(i, i + 2);
84
- if (
85
- two === "==" ||
86
- two === "!=" ||
87
- two === "&&" ||
88
- two === "||" ||
89
- two === ">=" ||
90
- two === "<="
91
- ) {
92
- tokens.push({ t: "op", text: two });
93
- i += 1;
94
- continue;
95
- }
96
- if ("+-*/%<>!".includes(ch)) {
97
- tokens.push({ t: "op", text: ch });
98
- continue;
99
- }
100
- if ("(),?:.[]".includes(ch)) {
101
- tokens.push({ t: "punct", text: ch });
102
- continue;
103
- }
104
- if (/[0-9]/.test(ch)) {
105
- let j = i;
106
- while (j < segment.length && /[0-9.]/.test(segment[j]!)) j += 1;
107
- const literal = segment.slice(i, j);
108
- i = j - 1;
109
- tokens.push({ t: "operand", kind: "num", label: literal });
110
- continue;
111
- }
112
- if (/[A-Za-z_]/.test(ch)) {
113
- let j = i;
114
- while (j < segment.length && /[A-Za-z0-9_]/.test(segment[j]!)) j += 1;
115
- const word = segment.slice(i, j);
116
- i = j - 1;
117
- tokens.push(
118
- word === "true" || word === "false"
119
- ? { t: "operand", kind: "bool", label: word }
120
- : { t: "operand", kind: "unknown", label: word },
121
- );
122
- continue;
123
- }
124
- // 其余字符(空白等):词法噪声,不进流
125
- }
126
- };
127
-
128
- /** 插值贡献(文本 + 判类):产物文本与既有行为逐字节一致(校验零侵入) */
129
- const appendPart = (
130
- part: unknown,
131
- ): { text: string; kind: OperandKind; label: string } => {
132
- if (isVarHandle(part)) {
133
- const key = varKeyOf(part, registry, texts);
134
- return { text: key, kind: part.kind, label: `变量 ${key}` };
135
- }
136
- if (typeof part === "number" || typeof part === "boolean") {
137
- return {
138
- text: String(part),
139
- kind: typeof part as VarKind,
140
- label: String(part),
141
- };
142
- }
143
- if (typeof part === "string") {
144
- return {
145
- text: JSON.stringify(part),
146
- kind: "str",
147
- label: JSON.stringify(part),
148
- };
149
- }
150
- throw new Error(
151
- texts.unsupportedInterpolationType(typeof part),
152
- );
153
- };
154
-
155
- let out = "{";
156
- strings.forEach((segment, i) => {
157
- lexFragment(segment);
158
- out += segment;
159
- if (i >= parts.length) return;
160
- const part = parts[i];
161
- if (quote !== null) {
162
- // 校验零侵入:警告照记,但文本照旧并入产物(与既有行为逐字节一致)
163
- pending.push({
164
- rule: "interpolation-inside-string",
165
- message: texts.interpolationInsideString(),
166
- });
167
- out += appendPart(part).text;
168
- return;
169
- }
170
- const applied = appendPart(part);
171
- out += applied.text;
172
- tokens.push({ t: "operand", kind: applied.kind, label: applied.label });
173
- });
174
-
175
- const finalize = (): string => `${out}}`;
176
- if (quote !== null) {
177
- // 字面量到表达式末尾都没闭合:引擎将 parse-error——类型核查无意义,只报这一条
178
- pending.push({
179
- rule: "unclosed-string-literal",
180
- message: texts.unclosedStringLiteral(),
181
- });
182
- warningSink.push(
183
- ...pending.map((item) => ({ expression: finalize(), ...item })),
184
- );
185
- return finalize();
186
- }
187
- new ExpressionAuditor(tokens, pending, texts).audit();
188
- for (const item of pending) {
189
- warningSink.push({ expression: finalize(), ...item });
190
- }
191
- return finalize();
192
- }
193
-
194
- /**
195
- * `expr` 的别名(条件语境的语义化写法;同一实现、同一校验)。
196
- *
197
- * @param strings 模板字面量的静态片段。
198
- * @param parts 插值:变量句柄 / number / boolean / string。
199
- * @returns `"{…}"` 条件表达式串(引擎执行条件命令时求值,须产出布尔)。
200
- * @example
201
- * when(cond`${vars.door.locked} == false`, [navigate("hall")]);
202
- */
203
- export const cond = expr;
1
+ /**
2
+ * 表达式标签的装配:把模板字面量拼成 `{…}` 文本,插值按句柄与字面量规则渲染。
3
+ *
4
+ * 组装同时做构建期轻类型校验(判类器在 `./audit`):警告进池、由
5
+ * `drainExpressionWarnings` 取走;表达式产物与既有行为逐字节一致(校验零侵入)。
6
+ */
7
+
8
+ import {
9
+ currentRegistry,
10
+ isVarHandle,
11
+ varKeyOf,
12
+ warningSink,
13
+ type ExpressionWarning,
14
+ type VarKind,
15
+ } from "./registry.ts";
16
+ import { ExpressionAuditor, type AuditToken, type OperandKind } from "./audit/index.ts";
17
+ import { defaultEditorTexts } from "../../contracts/index.ts";
18
+
19
+ /**
20
+ * expr 标签函数的文案取缺省表(例外登记):模板标签函数无法携带可选参数注入,
21
+ * 这四条是构建期开发者诊断(缺省不可变直引,无模块级 setter)。
22
+ * varKeyOf / ExpressionAuditor 的文案仍走调用点注入。
23
+ */
24
+ const texts = defaultEditorTexts;
25
+
26
+ /**
27
+ * 表达式标签:`expr\`${vars.player.gold} >= 25\` ⇒ "{player.gold >= 25}"`。
28
+ * 插值规则:VarHandle ⇒ 注册键(未注册抛错);number/boolean ⇒ 字面量;
29
+ * string ⇒ JSON 字面量(带引号,与引擎字符串字面量口径一致)。
30
+ *
31
+ * 组装同时做轻类型校验(见文件头):警告进池,由 `drainExpressionWarnings` 取走;
32
+ * 表达式产物与既有行为逐字节一致(校验零侵入)。
33
+ *
34
+ * 产物是 `"{…}"` 表达式串:引擎在**执行到包含它的命令时**求值——
35
+ * 条件语境({@link when} / whileDo)求出布尔;赋值语境({@link set} 等)
36
+ * 求出值;台词/通知文本在显示时插值。
37
+ *
38
+ * 必须先经 defineVars 注册变量,否则抛错。
39
+ *
40
+ * @param strings 模板字面量的静态片段。
41
+ * @param parts 插值:变量句柄 / number / boolean / string。
42
+ * @returns `"{…}"` 表达式串(放进故事数据,运行期求值)。
43
+ * @example
44
+ * expr`${vars.player.gold} >= 25` // ⇒ "{player.gold >= 25}"
45
+ * expr`${vars.greeting}, 旅行者` // string 插值 ⇒ 带引号字面量
46
+ */
47
+ export function expr(
48
+ strings: TemplateStringsArray,
49
+ ...parts: unknown[]
50
+ ): string {
51
+ const registry = currentRegistry;
52
+ if (registry === null) {
53
+ throw new Error(texts.exprRequiresDefineVars());
54
+ }
55
+ const tokens: AuditToken[] = [];
56
+ const pending: Array<{ rule: ExpressionWarning["rule"]; message: string }> =
57
+ [];
58
+ // 引号状态跨片段/插值跟踪(`"pre${name}post"` 的吞并 footgun:插值会被并进字面量)
59
+ let quote: string | null = null;
60
+ let escaped = false;
61
+
62
+ /** 片段词法:引号(跨片段)、双字运算符优先、字面量/数字/标识符;括号与结构标点进流 */
63
+ const lexFragment = (segment: string): void => {
64
+ for (let i = 0; i < segment.length; i += 1) {
65
+ const ch = segment[i]!;
66
+ if (quote !== null) {
67
+ if (escaped) {
68
+ escaped = false;
69
+ continue;
70
+ }
71
+ if (ch === "\\") {
72
+ escaped = true;
73
+ continue;
74
+ }
75
+ if (ch === quote) quote = null;
76
+ continue;
77
+ }
78
+ if (ch === '"' || ch === "'") {
79
+ quote = ch;
80
+ tokens.push({ t: "operand", kind: "str", label: "字符串字面量" });
81
+ continue;
82
+ }
83
+ const two = segment.slice(i, i + 2);
84
+ if (
85
+ two === "==" ||
86
+ two === "!=" ||
87
+ two === "&&" ||
88
+ two === "||" ||
89
+ two === ">=" ||
90
+ two === "<="
91
+ ) {
92
+ tokens.push({ t: "op", text: two });
93
+ i += 1;
94
+ continue;
95
+ }
96
+ if ("+-*/%<>!".includes(ch)) {
97
+ tokens.push({ t: "op", text: ch });
98
+ continue;
99
+ }
100
+ if ("(),?:.[]".includes(ch)) {
101
+ tokens.push({ t: "punct", text: ch });
102
+ continue;
103
+ }
104
+ if (/[0-9]/.test(ch)) {
105
+ let j = i;
106
+ while (j < segment.length && /[0-9.]/.test(segment[j]!)) j += 1;
107
+ const literal = segment.slice(i, j);
108
+ i = j - 1;
109
+ tokens.push({ t: "operand", kind: "num", label: literal });
110
+ continue;
111
+ }
112
+ if (/[A-Za-z_]/.test(ch)) {
113
+ let j = i;
114
+ while (j < segment.length && /[A-Za-z0-9_]/.test(segment[j]!)) j += 1;
115
+ const word = segment.slice(i, j);
116
+ i = j - 1;
117
+ tokens.push(
118
+ word === "true" || word === "false"
119
+ ? { t: "operand", kind: "bool", label: word }
120
+ : { t: "operand", kind: "unknown", label: word },
121
+ );
122
+ continue;
123
+ }
124
+ // 其余字符(空白等):词法噪声,不进流
125
+ }
126
+ };
127
+
128
+ /** 插值贡献(文本 + 判类):产物文本与既有行为逐字节一致(校验零侵入) */
129
+ const appendPart = (
130
+ part: unknown,
131
+ ): { text: string; kind: OperandKind; label: string } => {
132
+ if (isVarHandle(part)) {
133
+ const key = varKeyOf(part, registry, texts);
134
+ return { text: key, kind: part.kind, label: `变量 ${key}` };
135
+ }
136
+ if (typeof part === "number" || typeof part === "boolean") {
137
+ return {
138
+ text: String(part),
139
+ kind: typeof part as VarKind,
140
+ label: String(part),
141
+ };
142
+ }
143
+ if (typeof part === "string") {
144
+ return {
145
+ text: JSON.stringify(part),
146
+ kind: "str",
147
+ label: JSON.stringify(part),
148
+ };
149
+ }
150
+ throw new Error(
151
+ texts.unsupportedInterpolationType(typeof part),
152
+ );
153
+ };
154
+
155
+ let out = "{";
156
+ strings.forEach((segment, i) => {
157
+ lexFragment(segment);
158
+ out += segment;
159
+ if (i >= parts.length) return;
160
+ const part = parts[i];
161
+ if (quote !== null) {
162
+ // 校验零侵入:警告照记,但文本照旧并入产物(与既有行为逐字节一致)
163
+ pending.push({
164
+ rule: "interpolation-inside-string",
165
+ message: texts.interpolationInsideString(),
166
+ });
167
+ out += appendPart(part).text;
168
+ return;
169
+ }
170
+ const applied = appendPart(part);
171
+ out += applied.text;
172
+ tokens.push({ t: "operand", kind: applied.kind, label: applied.label });
173
+ });
174
+
175
+ const finalize = (): string => `${out}}`;
176
+ if (quote !== null) {
177
+ // 字面量到表达式末尾都没闭合:引擎将 parse-error——类型核查无意义,只报这一条
178
+ pending.push({
179
+ rule: "unclosed-string-literal",
180
+ message: texts.unclosedStringLiteral(),
181
+ });
182
+ warningSink.push(
183
+ ...pending.map((item) => ({ expression: finalize(), ...item })),
184
+ );
185
+ return finalize();
186
+ }
187
+ new ExpressionAuditor(tokens, pending, texts).audit();
188
+ for (const item of pending) {
189
+ warningSink.push({ expression: finalize(), ...item });
190
+ }
191
+ return finalize();
192
+ }
193
+
194
+ /**
195
+ * `expr` 的别名(条件语境的语义化写法;同一实现、同一校验)。
196
+ *
197
+ * @param strings 模板字面量的静态片段。
198
+ * @param parts 插值:变量句柄 / number / boolean / string。
199
+ * @returns `"{…}"` 条件表达式串(引擎执行条件命令时求值,须产出布尔)。
200
+ * @example
201
+ * when(cond`${vars.door.locked} == false`, [navigate("hall")]);
202
+ */
203
+ export const cond = expr;
@@ -1,130 +1,130 @@
1
- /**
2
- * 变量注册表与句柄:显式注册制的声明面。
3
- *
4
- * 引擎表达式是字符串、求值归引擎,本层只负责生成表达式文本——
5
- * `defineVars` 声明一个故事源用到的变量(IDE 补全与拼写检查由此而来),
6
- * 句柄树的叶子是 `VarHandle`,装配期按注册表核对:未注册的引用直接抛错。
7
- *
8
- * 注册表与警告池是模块态,一次导入 = 一个故事源。
9
- */
10
-
11
- import type { EditorTexts } from "../../contracts/index.ts";
12
- import { resolveEditorTexts } from "../../contracts/index.ts";
13
-
14
- /** 变量种类(注册时声明;构建期轻校验的判类依据) */
15
- export type VarKind = "num" | "str" | "bool";
16
-
17
- export interface VarHandle {
18
- readonly kind: VarKind;
19
- /** 引擎状态键(如 `player.gold`) */
20
- readonly key: string;
21
- }
22
-
23
- const VAR_KINDS: ReadonlySet<string> = new Set(["num", "str", "bool"]);
24
-
25
- /** 真句柄 = kind 为三种之一且 key 为字符串——未注册子树 Proxy 的 kind 是对象,判否 */
26
- export function isVarHandle(value: unknown): value is VarHandle {
27
- if (typeof value !== "object" || value === null) return false;
28
- const candidate = value as { kind?: unknown; key?: unknown };
29
- return (
30
- typeof candidate.kind === "string" &&
31
- VAR_KINDS.has(candidate.kind) &&
32
- typeof candidate.key === "string"
33
- );
34
- }
35
-
36
- /** 嵌套句柄树(`vars.player.gold` ⇒ handle{key: "player.gold"}) */
37
- export interface VarTree {
38
- [prop: string]: VarTree | VarHandle;
39
- }
40
-
41
- /** 当前注册表(`expr` 标签读取;`defineVars` 绑定——每个故事源文件声明一次) */
42
- export let currentRegistry: ReadonlyMap<string, VarKind> | null = null;
43
-
44
- /** 构建期警告池(与 currentRegistry 同范式的模块态:一次导入 = 一个故事源;drain 后清零;装配层写入) */
45
- export let warningSink: ExpressionWarning[] = [];
46
-
47
- /**
48
- * 取走并清空构建期警告。stories-build 在源模块导入完成后调用一次进 BuildReport;
49
- * 连续两次调用,第二次必为空(drain 即清零)。
50
- */
51
- export function drainExpressionWarnings(): readonly ExpressionWarning[] {
52
- const out = warningSink;
53
- warningSink = [];
54
- return out;
55
- }
56
-
57
- /**
58
- * 构建期轻类型校验警告(引擎 type-error 的构建期预告;不拦构建)。
59
- * 规则名与引擎 `runtime/expr/` 的类型规则一一对应(见 `ExpressionAuditor`)。
60
- */
61
- export interface ExpressionWarning {
62
- /** 完整表达式文本(含 `{}` 包装) */
63
- readonly expression: string;
64
- readonly rule:
65
- | "arith-non-num"
66
- | "compare-non-num"
67
- | "equality-kind-mismatch"
68
- | "logical-non-bool"
69
- | "unary-not-non-bool"
70
- | "unary-minus-non-num"
71
- | "ternary-cond-non-bool"
72
- | "interpolation-inside-string"
73
- | "unclosed-string-literal";
74
- /** 人话说明(含引擎将抛的 type-error 语义与出错操作数) */
75
- readonly message: string;
76
- }
77
-
78
- function makeTree(
79
- prefix: string,
80
- handles: ReadonlyMap<string, VarHandle>,
81
- texts: EditorTexts,
82
- ): VarTree {
83
- return new Proxy({} as VarTree, {
84
- get(_target, prop): VarTree | VarHandle {
85
- if (typeof prop !== "string" || prop === "then") {
86
- throw new Error(texts.invalidVarHandlePath(String(prop), prefix || "(根)"));
87
- }
88
- const key = prefix === "" ? prop : `${prefix}.${prop}`;
89
- const handle = handles.get(key);
90
- if (handle !== undefined) return handle;
91
- // 未注册路径:继续给子树 Proxy(链式展开),expr 求值时校验终值
92
- return makeTree(key, handles, texts);
93
- },
94
- });
95
- }
96
-
97
- /**
98
- * 声明变量清单并绑定为本文件的 `expr` 上下文。
99
- * 每个故事源文件调用一次(重复调用 = 覆盖前一份注册表——源文件间的变量清单
100
- * 各自独立声明,不共享)。
101
- *
102
- * ```ts
103
- * const vars = defineVars({ "player.gold": "num", "player.name": "str" });
104
- * cond`${vars.player.gold} >= 25` // ⇒ "{player.gold >= 25}"
105
- * ```
106
- */
107
- export function defineVars(
108
- spec: Record<string, VarKind>,
109
- texts: Partial<EditorTexts> = {},
110
- ): VarTree {
111
- const t = resolveEditorTexts(texts);
112
- currentRegistry = new Map(Object.entries(spec));
113
- const handles = new Map<string, VarHandle>(
114
- Object.entries(spec).map(([key, kind]) => [key, { kind, key }]),
115
- );
116
- return makeTree("", handles, t);
117
- }
118
-
119
- /** 句柄对应的注册键(expr 内部用;未注册 ⇒ 抛错——拼写错误在构建期就红) */
120
- export function varKeyOf(
121
- handle: VarHandle,
122
- registry: ReadonlyMap<string, VarKind>,
123
- texts: Partial<EditorTexts> = {},
124
- ): string {
125
- const t = resolveEditorTexts(texts);
126
- if (!registry.has(handle.key)) {
127
- throw new Error(t.unregisteredVariable(handle.key));
128
- }
129
- return handle.key;
130
- }
1
+ /**
2
+ * 变量注册表与句柄:显式注册制的声明面。
3
+ *
4
+ * 引擎表达式是字符串、求值归引擎,本层只负责生成表达式文本——
5
+ * `defineVars` 声明一个故事源用到的变量(IDE 补全与拼写检查由此而来),
6
+ * 句柄树的叶子是 `VarHandle`,装配期按注册表核对:未注册的引用直接抛错。
7
+ *
8
+ * 注册表与警告池是模块态,一次导入 = 一个故事源。
9
+ */
10
+
11
+ import type { EditorTexts } from "../../contracts/index.ts";
12
+ import { resolveEditorTexts } from "../../contracts/index.ts";
13
+
14
+ /** 变量种类(注册时声明;构建期轻校验的判类依据) */
15
+ export type VarKind = "num" | "str" | "bool";
16
+
17
+ export interface VarHandle {
18
+ readonly kind: VarKind;
19
+ /** 引擎状态键(如 `player.gold`) */
20
+ readonly key: string;
21
+ }
22
+
23
+ const VAR_KINDS: ReadonlySet<string> = new Set(["num", "str", "bool"]);
24
+
25
+ /** 真句柄 = kind 为三种之一且 key 为字符串——未注册子树 Proxy 的 kind 是对象,判否 */
26
+ export function isVarHandle(value: unknown): value is VarHandle {
27
+ if (typeof value !== "object" || value === null) return false;
28
+ const candidate = value as { kind?: unknown; key?: unknown };
29
+ return (
30
+ typeof candidate.kind === "string" &&
31
+ VAR_KINDS.has(candidate.kind) &&
32
+ typeof candidate.key === "string"
33
+ );
34
+ }
35
+
36
+ /** 嵌套句柄树(`vars.player.gold` ⇒ handle{key: "player.gold"}) */
37
+ export interface VarTree {
38
+ [prop: string]: VarTree | VarHandle;
39
+ }
40
+
41
+ /** 当前注册表(`expr` 标签读取;`defineVars` 绑定——每个故事源文件声明一次) */
42
+ export let currentRegistry: ReadonlyMap<string, VarKind> | null = null;
43
+
44
+ /** 构建期警告池(与 currentRegistry 同范式的模块态:一次导入 = 一个故事源;drain 后清零;装配层写入) */
45
+ export let warningSink: ExpressionWarning[] = [];
46
+
47
+ /**
48
+ * 取走并清空构建期警告。stories-build 在源模块导入完成后调用一次进 BuildReport;
49
+ * 连续两次调用,第二次必为空(drain 即清零)。
50
+ */
51
+ export function drainExpressionWarnings(): readonly ExpressionWarning[] {
52
+ const out = warningSink;
53
+ warningSink = [];
54
+ return out;
55
+ }
56
+
57
+ /**
58
+ * 构建期轻类型校验警告(引擎 type-error 的构建期预告;不拦构建)。
59
+ * 规则名与引擎 `runtime/expr/` 的类型规则一一对应(见 `ExpressionAuditor`)。
60
+ */
61
+ export interface ExpressionWarning {
62
+ /** 完整表达式文本(含 `{}` 包装) */
63
+ readonly expression: string;
64
+ readonly rule:
65
+ | "arith-non-num"
66
+ | "compare-non-num"
67
+ | "equality-kind-mismatch"
68
+ | "logical-non-bool"
69
+ | "unary-not-non-bool"
70
+ | "unary-minus-non-num"
71
+ | "ternary-cond-non-bool"
72
+ | "interpolation-inside-string"
73
+ | "unclosed-string-literal";
74
+ /** 人话说明(含引擎将抛的 type-error 语义与出错操作数) */
75
+ readonly message: string;
76
+ }
77
+
78
+ function makeTree(
79
+ prefix: string,
80
+ handles: ReadonlyMap<string, VarHandle>,
81
+ texts: EditorTexts,
82
+ ): VarTree {
83
+ return new Proxy({} as VarTree, {
84
+ get(_target, prop): VarTree | VarHandle {
85
+ if (typeof prop !== "string" || prop === "then") {
86
+ throw new Error(texts.invalidVarHandlePath(String(prop), prefix || "(根)"));
87
+ }
88
+ const key = prefix === "" ? prop : `${prefix}.${prop}`;
89
+ const handle = handles.get(key);
90
+ if (handle !== undefined) return handle;
91
+ // 未注册路径:继续给子树 Proxy(链式展开),expr 求值时校验终值
92
+ return makeTree(key, handles, texts);
93
+ },
94
+ });
95
+ }
96
+
97
+ /**
98
+ * 声明变量清单并绑定为本文件的 `expr` 上下文。
99
+ * 每个故事源文件调用一次(重复调用 = 覆盖前一份注册表——源文件间的变量清单
100
+ * 各自独立声明,不共享)。
101
+ *
102
+ * ```ts
103
+ * const vars = defineVars({ "player.gold": "num", "player.name": "str" });
104
+ * cond`${vars.player.gold} >= 25` // ⇒ "{player.gold >= 25}"
105
+ * ```
106
+ */
107
+ export function defineVars(
108
+ spec: Record<string, VarKind>,
109
+ texts: Partial<EditorTexts> = {},
110
+ ): VarTree {
111
+ const t = resolveEditorTexts(texts);
112
+ currentRegistry = new Map(Object.entries(spec));
113
+ const handles = new Map<string, VarHandle>(
114
+ Object.entries(spec).map(([key, kind]) => [key, { kind, key }]),
115
+ );
116
+ return makeTree("", handles, t);
117
+ }
118
+
119
+ /** 句柄对应的注册键(expr 内部用;未注册 ⇒ 抛错——拼写错误在构建期就红) */
120
+ export function varKeyOf(
121
+ handle: VarHandle,
122
+ registry: ReadonlyMap<string, VarKind>,
123
+ texts: Partial<EditorTexts> = {},
124
+ ): string {
125
+ const t = resolveEditorTexts(texts);
126
+ if (!registry.has(handle.key)) {
127
+ throw new Error(t.unregisteredVariable(handle.key));
128
+ }
129
+ return handle.key;
130
+ }