@lingfan-engine/stories-build 0.0.0-stage → 0.1.2

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.
Files changed (110) hide show
  1. package/LICENSE +201 -0
  2. package/bin/stories-build.mjs +16 -0
  3. package/bin/stories-watch.mjs +16 -0
  4. package/package.json +50 -3
  5. package/src/authoring.d.ts +51 -0
  6. package/src/cell/errors.ts +12 -0
  7. package/src/cell/imports.ts +127 -0
  8. package/src/cell/index.ts +33 -0
  9. package/src/cell/render-register.ts +33 -0
  10. package/src/cell/scan-cells.ts +63 -0
  11. package/src/cell/scan-file.ts +108 -0
  12. package/src/cell/types.ts +27 -0
  13. package/src/cell/whitelist.ts +22 -0
  14. package/src/cli/cli.ts +99 -0
  15. package/src/cli/watch-cli.ts +42 -0
  16. package/src/compiler/cell-scan.ts +27 -0
  17. package/src/compiler/compiler-contract.ts +87 -0
  18. package/src/compiler/extension-load.ts +67 -0
  19. package/src/compiler/index.ts +22 -0
  20. package/src/compiler/story-compiler/assemble/functions.ts +202 -0
  21. package/src/compiler/story-compiler/assemble/index.ts +8 -0
  22. package/src/compiler/story-compiler/assemble/session.ts +207 -0
  23. package/src/compiler/story-compiler/assemble/types.ts +37 -0
  24. package/src/compiler/story-compiler/assemble.ts +5 -0
  25. package/src/compiler/story-compiler/graph/build.ts +208 -0
  26. package/src/compiler/story-compiler/graph/core.ts +102 -0
  27. package/src/compiler/story-compiler/graph/fold.ts +121 -0
  28. package/src/compiler/story-compiler/graph/index.ts +17 -0
  29. package/src/compiler/story-compiler/graph/scan/bindings.ts +277 -0
  30. package/src/compiler/story-compiler/graph/scan/imports.ts +170 -0
  31. package/src/compiler/story-compiler/graph/scan/index.ts +9 -0
  32. package/src/compiler/story-compiler/graph/scan/module-scan.ts +199 -0
  33. package/src/compiler/story-compiler/graph/state-root.ts +147 -0
  34. package/src/compiler/story-compiler/graph.ts +18 -0
  35. package/src/compiler/story-compiler/index.ts +34 -0
  36. package/src/compiler/story-compiler/translate/expr/assign.ts +204 -0
  37. package/src/compiler/story-compiler/translate/expr/engine-text.ts +16 -0
  38. package/src/compiler/story-compiler/translate/expr/expr.ts +174 -0
  39. package/src/compiler/story-compiler/translate/expr/index.ts +24 -0
  40. package/src/compiler/story-compiler/translate/expr/lower/index.ts +4 -0
  41. package/src/compiler/story-compiler/translate/expr/lower/lower.ts +266 -0
  42. package/src/compiler/story-compiler/translate/expr/lower/numeric.ts +56 -0
  43. package/src/compiler/story-compiler/translate/expr/probe.ts +23 -0
  44. package/src/compiler/story-compiler/translate/expr/state-path.ts +47 -0
  45. package/src/compiler/story-compiler/translate/expr/words/arg.ts +87 -0
  46. package/src/compiler/story-compiler/translate/expr/words/call.ts +125 -0
  47. package/src/compiler/story-compiler/translate/expr/words/callee.ts +43 -0
  48. package/src/compiler/story-compiler/translate/expr/words/command.ts +93 -0
  49. package/src/compiler/story-compiler/translate/expr/words/dispatch.ts +16 -0
  50. package/src/compiler/story-compiler/translate/expr/words/index.ts +8 -0
  51. package/src/compiler/story-compiler/translate/expr/words/static-for.ts +96 -0
  52. package/src/compiler/story-compiler/translate/font-check.ts +94 -0
  53. package/src/compiler/story-compiler/translate/index.ts +10 -0
  54. package/src/compiler/story-compiler/translate/shared.ts +132 -0
  55. package/src/compiler/story-compiler/translate/stmt/control/branch.ts +71 -0
  56. package/src/compiler/story-compiler/translate/stmt/control/index.ts +11 -0
  57. package/src/compiler/story-compiler/translate/stmt/control/iter.ts +72 -0
  58. package/src/compiler/story-compiler/translate/stmt/control/loop.ts +114 -0
  59. package/src/compiler/story-compiler/translate/stmt/control/switch.ts +75 -0
  60. package/src/compiler/story-compiler/translate/stmt/control-flow.ts +25 -0
  61. package/src/compiler/story-compiler/translate/stmt/index.ts +17 -0
  62. package/src/compiler/story-compiler/translate/stmt/statements.ts +145 -0
  63. package/src/compiler/story-compiler/translate.ts +15 -0
  64. package/src/compiler/story-compiler.ts +119 -0
  65. package/src/config/index.ts +15 -0
  66. package/src/config/manifest-read.ts +26 -0
  67. package/src/config/state-type.ts +85 -0
  68. package/src/config/story-config.ts +113 -0
  69. package/src/const-eval/config-eval.ts +181 -0
  70. package/src/const-eval/const-eval-pass.ts +133 -0
  71. package/src/const-eval/const-eval.ts +114 -0
  72. package/src/const-eval/const-expr.ts +358 -0
  73. package/src/const-eval/contracts.ts +31 -0
  74. package/src/const-eval/define-merge.ts +195 -0
  75. package/src/const-eval/diag/compare.ts +39 -0
  76. package/src/const-eval/diag/index.ts +5 -0
  77. package/src/const-eval/diag/locate.ts +45 -0
  78. package/src/const-eval/index.ts +44 -0
  79. package/src/const-eval/module-characters.ts +353 -0
  80. package/src/const-eval/module-define.ts +300 -0
  81. package/src/const-eval/round/evaluate.ts +149 -0
  82. package/src/const-eval/round/index.ts +4 -0
  83. package/src/const-eval/round/source.ts +134 -0
  84. package/src/const-eval/state-schema.ts +269 -0
  85. package/src/discovery/index.ts +7 -0
  86. package/src/discovery/source-discovery.ts +193 -0
  87. package/src/emit/compile-roundtrip.ts +45 -0
  88. package/src/emit/emit.ts +147 -0
  89. package/src/emit/index.ts +9 -0
  90. package/src/emit/report.ts +29 -0
  91. package/src/fonts/font-registry.ts +53 -0
  92. package/src/fonts/index.ts +7 -0
  93. package/src/index.ts +76 -0
  94. package/src/pipeline/gates.ts +107 -0
  95. package/src/pipeline/index.ts +18 -0
  96. package/src/pipeline/orchestrate.ts +459 -0
  97. package/src/pipeline/watch.ts +244 -0
  98. package/src/shared/errors.ts +2 -0
  99. package/src/shared/index.ts +11 -0
  100. package/src/shared/texts.ts +85 -0
  101. package/src/shared/walk-files.ts +18 -0
  102. package/src/typegen/collect.ts +107 -0
  103. package/src/typegen/font-render.ts +30 -0
  104. package/src/typegen/index.ts +18 -0
  105. package/src/typegen/render.ts +68 -0
  106. package/src/typegen/script-render.ts +21 -0
  107. package/src/typegen/state-enum-ast.ts +365 -0
  108. package/src/typegen/state-render.ts +242 -0
  109. package/src/typegen/types.ts +21 -0
  110. package/README.md +0 -3
@@ -0,0 +1,193 @@
1
+ import { join } from "node:path";
2
+ import ts from "typescript";
3
+ import {
4
+ defaultStoriesBuildTexts,
5
+ walkFiles,
6
+ type StoriesBuildTexts,
7
+ } from "../shared/index.ts";
8
+ import { CONFIG_FILE, type StorySourceConfig } from "../config/index.ts";
9
+ import { StoryBuildError } from "../shared/index.ts";
10
+
11
+ /**
12
+ * 源发现 v2(多目录形态):
13
+ * - 编译域 = folders 显式目录递归(含 "*" 时扩展为全部含故事目录),始终排除生成物目录;
14
+ * - 探测域 = Stories.src 递归全集(同样排除生成物)——覆盖外发现 default 导出按覆盖规则拒绝
15
+ * fail-closed(Stories.src 是全量管理:未登记的新章节文件不进编译域,其旧产物会被
16
+ * 当作陈旧产物删除,必须在发现期拦住);
17
+ * - 故事 = default 具名 function 声明(列 id = 函数名,编译器侧解析);default 存在但
18
+ * 匿名 / 表达式形态 fail-closed;仅具名导出 = 纯模块(无列,import 可达即可,
19
+ * 不要求在 folders 内);
20
+ * - config.start = 入口故事文件名(含 .ts),跨探测域精确匹配,全局恰一。
21
+ */
22
+ export interface DiscoveredSources {
23
+ /** 故事文件(相对 `Stories.src/`,posix 排序)——default 具名 function 声明的 .ts */
24
+ readonly stories: readonly string[];
25
+ /** 模块文件(相对 `Stories.src/`,posix 排序)——被 import 的支撑 .ts(含覆盖外纯模块) */
26
+ readonly modules: readonly string[];
27
+ /** `config.start` 匹配到的入口故事(相对 `Stories.src/`) */
28
+ readonly startSource: string;
29
+ /** 布局声明(编排层复核 / 报告用) */
30
+ readonly config: StorySourceConfig;
31
+ }
32
+
33
+ export async function discoverSources(
34
+ root: string,
35
+ sourcesDirName: string,
36
+ config: StorySourceConfig,
37
+ texts: StoriesBuildTexts = defaultStoriesBuildTexts,
38
+ ): Promise<DiscoveredSources> {
39
+ const tree = walkFiles(join(root, sourcesDirName));
40
+ const dirs = collectCoveredDirs(config, tree);
41
+
42
+ // —— 探测域:全部 .ts(排除生成物目录;config.ts 只能是根级唯一根文件)——
43
+ const probe: string[] = [];
44
+ for (const rel of tree.keys()) {
45
+ if (!rel.endsWith(".ts") || rel.startsWith("gen/")) continue;
46
+ if (rel.endsWith(`/${CONFIG_FILE}`)) {
47
+ throw new StoryBuildError(
48
+ `${sourcesDirName}/${rel}——${CONFIG_FILE} 只能是根级唯一根文件,故事与模块源请放进 folders 声明的目录`,
49
+ );
50
+ }
51
+ probe.push(rel);
52
+ }
53
+
54
+ const isCovered = (rel: string): boolean => {
55
+ if (rel === CONFIG_FILE) return false;
56
+ for (const dir of dirs) {
57
+ if (rel.startsWith(`${dir}/`)) return true;
58
+ }
59
+ return false;
60
+ };
61
+
62
+ // —— 覆盖外 default 导出(含转发形态;纯模块无 default 不受影响;
63
+ // 根级 config.ts 自身的 default 是布局声明,豁免)——
64
+ const uncoveredDefaults = probe.filter(
65
+ (rel) =>
66
+ rel !== CONFIG_FILE &&
67
+ !isCovered(rel) &&
68
+ classifyDefault(tree.get(rel)!) !== "none",
69
+ );
70
+ if (uncoveredDefaults.length > 0) {
71
+ throw new StoryBuildError(
72
+ texts.uncoveredDefaults(uncoveredDefaults, sourcesDirName),
73
+ );
74
+ }
75
+
76
+ // —— 覆盖域分类:故事(default 具名 function 声明)/ 模块(无 default)——
77
+ const stories: string[] = [];
78
+ const modules: string[] = [];
79
+ for (const rel of probe) {
80
+ if (rel === CONFIG_FILE) continue;
81
+ if (!isCovered(rel)) {
82
+ modules.push(rel);
83
+ continue;
84
+ }
85
+ const verdict = classifyDefault(tree.get(rel)!);
86
+ if (verdict === "story") {
87
+ stories.push(rel);
88
+ } else if (verdict === "none") {
89
+ modules.push(rel);
90
+ } else {
91
+ throw new StoryBuildError(
92
+ `${sourcesDirName}/${rel}: default 导出须是具名 function 声明(如 export default function main() {})——匿名函数、类与表达式形态无法确定列 id`,
93
+ );
94
+ }
95
+ }
96
+
97
+ // —— start:跨探测域按精确文件名匹配,全局恰一 ——
98
+ const hits = probe.filter((rel) => fileNameOf(rel) === config.start);
99
+ if (hits.length === 0) {
100
+ throw new StoryBuildError(
101
+ stories.length === 0
102
+ ? `${sourcesDirName}/${CONFIG_FILE}: start "${config.start}" 未匹配任何故事文件——folders 声明的目录里没有 default 导出故事函数的 .ts`
103
+ : `${sourcesDirName}/${CONFIG_FILE}: start "${config.start}" 未匹配任何故事文件(候选:${stories.map(fileNameOf).join("、")})`,
104
+ );
105
+ }
106
+ if (hits.length > 1) {
107
+ throw new StoryBuildError(
108
+ `${sourcesDirName}/${CONFIG_FILE}: start "${config.start}" 命中 ${hits.length} 个文件(${hits.join("、")})——入口故事必须全局恰一,请重命名文件消歧`,
109
+ );
110
+ }
111
+ const startSource = hits[0]!;
112
+ if (classifyDefault(tree.get(startSource)!) !== "story") {
113
+ throw new StoryBuildError(
114
+ `${sourcesDirName}/${startSource}: start "${config.start}" 命中的文件没有 default 导出的具名故事函数`,
115
+ );
116
+ }
117
+ return { stories, modules, startSource, config };
118
+ }
119
+
120
+ function fileNameOf(rel: string): string {
121
+ return rel.slice(rel.lastIndexOf("/") + 1);
122
+ }
123
+
124
+ /** 编译域目录集:显式 folders(含 "*" 时扩展为全树含 .ts 的目录),始终排除生成物目录前缀。 */
125
+ function collectCoveredDirs(
126
+ config: StorySourceConfig,
127
+ tree: ReadonlyMap<string, string>,
128
+ ): Set<string> {
129
+ const dirs = new Set<string>();
130
+ for (const folder of config.folders) {
131
+ if (folder !== "*") dirs.add(folder);
132
+ }
133
+ if (config.folders.includes("*")) {
134
+ for (const rel of tree.keys()) {
135
+ if (!rel.endsWith(".ts") || rel.startsWith("gen/") || rel === CONFIG_FILE)
136
+ continue;
137
+ const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
138
+ if (dir !== "" && !dir.startsWith("gen/")) dirs.add(dir);
139
+ }
140
+ }
141
+ return dirs;
142
+ }
143
+
144
+ type DefaultVerdict = "story" | "none" | "bad";
145
+
146
+ /**
147
+ * default 导出形态判定(AST,非文本猜测)。TS 语法两条路径:
148
+ * - `export default function main() {}` = 直接 FunctionDeclaration 节点(default 修饰符);
149
+ * - `export default …`(表达式 / 匿名函数)= ExportAssignment 包装;
150
+ * - `export { main as default }` = 具名转发(编译器侧解析函数名)。
151
+ * 具名 function 声明 / 具名转发 → "story";匿名、类、表达式 → "bad"(不可作故事入口);无 default → "none"。
152
+ */
153
+ function classifyDefault(text: string): DefaultVerdict {
154
+ const source = ts.createSourceFile(
155
+ "source.ts",
156
+ text,
157
+ ts.ScriptTarget.Latest,
158
+ true,
159
+ );
160
+ let verdict: DefaultVerdict = "none";
161
+ for (const statement of source.statements) {
162
+ if (ts.isFunctionDeclaration(statement) && hasDefaultModifier(statement)) {
163
+ return statement.name !== undefined ? "story" : "bad";
164
+ }
165
+ if (ts.isExportAssignment(statement)) {
166
+ if (statement.isExportEquals) return "bad";
167
+ verdict = isNamedFunctionDeclaration(statement.expression)
168
+ ? "story"
169
+ : "bad";
170
+ continue;
171
+ }
172
+ if (!ts.isExportDeclaration(statement)) continue;
173
+ const clause = statement.exportClause;
174
+ if (clause === undefined || !ts.isNamedExports(clause)) continue;
175
+ const forwardsDefault = clause.elements.some(
176
+ (element) =>
177
+ element.name.text === "default" ||
178
+ element.propertyName?.text === "default",
179
+ );
180
+ if (forwardsDefault) return "story";
181
+ }
182
+ return verdict;
183
+ }
184
+
185
+ function hasDefaultModifier(node: ts.FunctionDeclaration): boolean {
186
+ return (node.modifiers ?? []).some(
187
+ (modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword,
188
+ );
189
+ }
190
+
191
+ function isNamedFunctionDeclaration(node: ts.Expression): boolean {
192
+ return ts.isFunctionDeclaration(node) && node.name !== undefined;
193
+ }
@@ -0,0 +1,45 @@
1
+ import {
2
+ assembleProject,
3
+ MANIFEST_FILE,
4
+ serializeProject,
5
+ type SerializedProject,
6
+ type Story,
7
+ } from "@lingfan-engine/core";
8
+ import { StoryBuildError } from "../shared/index.ts";
9
+
10
+ /**
11
+ * 编译:`serializeProject` = 编辑器写回同一条布局规则
12
+ * (单列拆分 + 清单托管键更新 + 非托管键保真)。
13
+ */
14
+ export function compileStory(
15
+ story: Story,
16
+ manifest: unknown,
17
+ ): SerializedProject {
18
+ return serializeProject(story, manifest);
19
+ }
20
+
21
+ /**
22
+ * 往返自检(编译期互锁):产物重组回 story,再序列化必须逐字节幂等。
23
+ * 不幂等说明「序列化 ↔ 组装」两条规则出现了分叉——这会让编辑器每次保存都漂移,
24
+ * 故构建期直接拦下(报第一个不一致的键名)。
25
+ */
26
+ export function assertRoundTrip(serialized: SerializedProject): void {
27
+ const productManifestText = serialized.files.get(MANIFEST_FILE);
28
+ if (productManifestText === undefined) {
29
+ throw new StoryBuildError(
30
+ `编译产物缺 ${MANIFEST_FILE}(serializeProject 契约破坏)`,
31
+ );
32
+ }
33
+ // assembleProject 的 files = 故事文件集(不含清单);清单单独走 manifest 参数
34
+ const columnFiles = new Map(serialized.files);
35
+ columnFiles.delete(MANIFEST_FILE);
36
+ const rebuilt = assembleProject(JSON.parse(productManifestText), columnFiles);
37
+ const recheck = serializeProject(rebuilt, JSON.parse(productManifestText));
38
+ for (const key of serialized.files.keys()) {
39
+ if (recheck.files.get(key) !== serialized.files.get(key)) {
40
+ throw new StoryBuildError(
41
+ `编译往返自检失败(serialize → assemble → serialize 不幂等):${key}`,
42
+ );
43
+ }
44
+ }
45
+ }
@@ -0,0 +1,147 @@
1
+ import {
2
+ existsSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ rmSync,
6
+ writeFileSync,
7
+ } from "node:fs";
8
+ import { dirname, join } from "node:path";
9
+ import { STORIES_DIR, type SerializedProject } from "@lingfan-engine/core";
10
+ import { renderFunRegister, type CellScan } from "../cell/index.ts";
11
+ import { walkFiles } from "../shared/index.ts";
12
+
13
+ /** 写盘所需的位置与内容(全部由编排层算好,本模块只做差量判定与落盘) */
14
+ export interface EmitOptions {
15
+ readonly root: string;
16
+ readonly resourcesDir: string;
17
+ readonly storiesDir: string;
18
+ readonly generatedRel: string;
19
+ readonly serialized: SerializedProject;
20
+ readonly cellScan: CellScan;
21
+ /** 类型面生成物:逻辑相对路径 + 已渲染好的全文(内容由编排层算,本模块只差量落盘) */
22
+ readonly typegen: { readonly rel: string; readonly content: string };
23
+ /** state 类型面生成物(state.gen.d.ts):与 typegen 同一差量规则的恒产出生成物 */
24
+ readonly stateGen: { readonly rel: string; readonly content: string };
25
+ /** script 命名空间 ambient 声明生成物(script.gen.d.ts):同规则的恒产出生成物 */
26
+ readonly scriptGen: { readonly rel: string; readonly content: string };
27
+ /** font 字面量类型面生成物(font.gen.d.ts):同规则的恒产出生成物(空注册表也恒产出) */
28
+ readonly fontGen: { readonly rel: string; readonly content: string };
29
+ }
30
+
31
+ /**
32
+ * 差量写盘 + 生成物更新:
33
+ * - `Stories/**` 由源全量管理——内容不同的产物才写(新增/更新分开计),不在产物集内的
34
+ * 既有列文件 = 陈旧,删除;
35
+ * - 生成物(`<sourcesDir>/<generatedDir>/fun_register.g.ts`、`stories.gen.d.ts`、
36
+ * `state.gen.d.ts`、`script.gen.d.ts`、`font.gen.d.ts`)同规则:零守卫时若存在则删除,否则内容不同才写。它们的逻辑路径与
37
+ * `Stories/**` 不同命名空间,报告里以 `Stories.src/` 开头即生成物。
38
+ * 返回值里的路径都是逻辑路径(相对工程根),供 CLI 直接打印。
39
+ */
40
+ export function emitArtifacts(options: EmitOptions): {
41
+ added: string[];
42
+ updated: string[];
43
+ removed: string[];
44
+ } {
45
+ const {
46
+ root,
47
+ resourcesDir,
48
+ storiesDir,
49
+ generatedRel,
50
+ serialized,
51
+ cellScan,
52
+ typegen,
53
+ stateGen,
54
+ scriptGen,
55
+ fontGen,
56
+ } = options;
57
+ const existing = walkFiles(storiesDir);
58
+ const added: string[] = [];
59
+ const updated: string[] = [];
60
+ const removed: string[] = [];
61
+ for (const [rel, text] of serialized.files) {
62
+ const target = join(resourcesDir, rel);
63
+ const exists = existsSync(target);
64
+ if (exists && readFileSync(target, "utf8") === text) continue;
65
+ mkdirSync(dirname(target), { recursive: true });
66
+ writeFileSync(target, text);
67
+ (exists ? updated : added).push(rel);
68
+ }
69
+ for (const rel of existing.keys()) {
70
+ const logical = `${STORIES_DIR}/${rel}`;
71
+ if (serialized.files.has(logical)) continue;
72
+ rmSync(join(storiesDir, rel), { force: true });
73
+ removed.push(logical);
74
+ }
75
+
76
+ const genTarget = join(root, generatedRel);
77
+ if (cellScan.guards.length === 0) {
78
+ if (existsSync(genTarget)) {
79
+ rmSync(genTarget, { force: true });
80
+ removed.push(generatedRel);
81
+ }
82
+ } else {
83
+ const content = renderFunRegister(cellScan);
84
+ if (existsSync(genTarget)) {
85
+ if (readFileSync(genTarget, "utf8") !== content) {
86
+ writeFileSync(genTarget, content);
87
+ updated.push(generatedRel);
88
+ }
89
+ } else {
90
+ mkdirSync(dirname(genTarget), { recursive: true });
91
+ writeFileSync(genTarget, content);
92
+ added.push(generatedRel);
93
+ }
94
+ }
95
+ // 类型面生成物(stories.gen.d.ts / state.gen.d.ts / script.gen.d.ts / font.gen.d.ts):恒产出的差量落盘。语义与守卫注册表
96
+ // 相反——空集合也写文件、结构在(注册表退化为 string / 状态面退化为 Record<string, never> / script 声明恒定 /
97
+ // font 面退化为 Record<never, never>),不拿空集合把作者的正常写法判红(见 typegen/render.ts 与 typegen/state-render.ts)。
98
+ writeConstantArtifact(
99
+ join(root, typegen.rel),
100
+ typegen.rel,
101
+ typegen.content,
102
+ added,
103
+ updated,
104
+ );
105
+ writeConstantArtifact(
106
+ join(root, stateGen.rel),
107
+ stateGen.rel,
108
+ stateGen.content,
109
+ added,
110
+ updated,
111
+ );
112
+ writeConstantArtifact(
113
+ join(root, scriptGen.rel),
114
+ scriptGen.rel,
115
+ scriptGen.content,
116
+ added,
117
+ updated,
118
+ );
119
+ writeConstantArtifact(
120
+ join(root, fontGen.rel),
121
+ fontGen.rel,
122
+ fontGen.content,
123
+ added,
124
+ updated,
125
+ );
126
+ return { added, updated, removed };
127
+ }
128
+
129
+ /** 恒产出生成物的差量落盘:存在即内容比较,不同才写(新增/更新分开计)。 */
130
+ function writeConstantArtifact(
131
+ target: string,
132
+ rel: string,
133
+ content: string,
134
+ added: string[],
135
+ updated: string[],
136
+ ): void {
137
+ if (existsSync(target)) {
138
+ if (readFileSync(target, "utf8") !== content) {
139
+ writeFileSync(target, content);
140
+ updated.push(rel);
141
+ }
142
+ return;
143
+ }
144
+ mkdirSync(dirname(target), { recursive: true });
145
+ writeFileSync(target, content);
146
+ added.push(rel);
147
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * 写盘域:编译产物落盘、结果报告、以及编译前后的往返比对。
3
+ *
4
+ * 三者共用同一个关注点——「源编译成引擎能读的东西」——往返比对是落盘前
5
+ * 的最后一道闸,报告是落盘的结果描述,故同域。
6
+ */
7
+ export { emitArtifacts, type EmitOptions } from "./emit.ts";
8
+ export { drainBuildWarnings, type BuildReport } from "./report.ts";
9
+ export { assertRoundTrip, compileStory } from "./compile-roundtrip.ts";
@@ -0,0 +1,29 @@
1
+ import { script, type ExpressionWarning } from "@lingfan-engine/editor";
2
+
3
+ /** 一次构建的结果(CLI 据此打印,测试据此断言) */
4
+ export interface BuildReport {
5
+ /** 入口故事源(相对 `Stories.src/`,= config.start 命中的文件) */
6
+ source: string;
7
+ storyId: string;
8
+ columns: number;
9
+ /** 本次新写入的产物(逻辑路径,相对资源根;首次出现的文件) */
10
+ added: string[];
11
+ /** 本次更新内容的产物(逻辑路径;已存在但内容变化,含生成物) */
12
+ updated: string[];
13
+ /** 本次清理的陈旧项(逻辑路径;`Stories.src/...` 是生成物命名空间) */
14
+ removed: string[];
15
+ /** 本轮按清单声明装载的扩展说明符(声明缺席/为空 = 空数组,装载器一次不触) */
16
+ extensions: string[];
17
+ /** cell(...) 声明并生成进注册物的守卫名(书写序;缺席 = 空数组) */
18
+ functions: string[];
19
+ /** 词汇层轻类型校验警告(expr/cond 组装时按引擎类型规则产出;不拦构建——引擎/编辑期仍是权威) */
20
+ warnings: ExpressionWarning[];
21
+ }
22
+
23
+ /**
24
+ * 取走并清空词汇层类型警告。
25
+ * 源模块导入即完成全部表达式组装,故整次构建只在收尾调一次——不拦构建,只进报告由 CLI 呈现。
26
+ */
27
+ export function drainBuildWarnings(): ExpressionWarning[] {
28
+ return [...script.drainExpressionWarnings()];
29
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * 字体名注册表:扫描 Resources/Fonts 下的字体文件,以「文件名去扩展名」为合法字体名。
3
+ *
4
+ * 字体名只在 stories-build 构建链内流转(构建期检查 + 宿主 @font-face 注入),不含运行时状态,
5
+ * 因此引擎侧不设端口。目录缺失 = 空注册表(未引用字体名的工程零打扰;引用了即 fail-closed)。
6
+ */
7
+ import { existsSync, readdirSync } from "node:fs";
8
+ import { compareOrdinal } from "@lingfan-engine/core";
9
+
10
+ /** 字体根目录名(位于 Resources/ 之下) */
11
+ export const FONTS_DIR_NAME = "Fonts";
12
+
13
+ /** 参与注册表的字体扩展名(小写比较);注册表只发文件名,宿主按扩展名选 format() */
14
+ export const FONT_EXTENSIONS = [".ttf", ".otf", ".woff", ".woff2"] as const;
15
+
16
+ export interface FontEntry {
17
+ /** 文件名(含扩展名),供宿主拼 URL */
18
+ readonly file: string;
19
+ /** 字体名 = 文件名去扩展名,故事层只引用这个名字 */
20
+ readonly name: string;
21
+ }
22
+
23
+ /**
24
+ * 扫描字体根目录产注册表;目录不存在或为空 = 空数组(合法,等价「未声明任何字体」)。
25
+ * 文件名按码元序稳定排序,保证注册表(与后续产物)不受文件系统枚举顺序影响。
26
+ */
27
+ export function scanFontRegistry(fontsDir: string): readonly FontEntry[] {
28
+ if (!existsSync(fontsDir)) {
29
+ return [];
30
+ }
31
+ const entries: FontEntry[] = [];
32
+ for (const file of readdirSync(fontsDir)) {
33
+ const dot = file.lastIndexOf(".");
34
+ // 点文件(.DS_Store、.hidden.ttf)整族跳过:后者虽有扩展名,但「.hidden」不是作者意图内的字体名
35
+ if (dot <= 0 || file.startsWith(".")) {
36
+ continue;
37
+ }
38
+ const ext = file.slice(dot).toLowerCase();
39
+ if (!(FONT_EXTENSIONS as readonly string[]).includes(ext)) {
40
+ continue;
41
+ }
42
+ entries.push({ file, name: file.slice(0, dot) });
43
+ }
44
+ entries.sort((a, b) => compareOrdinal(a.file, b.file));
45
+ return entries;
46
+ }
47
+
48
+ /** 字体名集合视图(供编译器 fail-closed 检查使用) */
49
+ export function fontNameSet(
50
+ entries: readonly FontEntry[],
51
+ ): ReadonlySet<string> {
52
+ return new Set(entries.map((entry) => entry.name));
53
+ }
@@ -0,0 +1,7 @@
1
+ export {
2
+ FONTS_DIR_NAME,
3
+ FONT_EXTENSIONS,
4
+ fontNameSet,
5
+ scanFontRegistry,
6
+ } from "./font-registry.ts";
7
+ export type { FontEntry } from "./font-registry.ts";
package/src/index.ts ADDED
@@ -0,0 +1,76 @@
1
+ /**
2
+ * TS 故事源编译流水线出口(唯一出口):把 `Stories.src/` 多目录原生 TS 语句源编译成
3
+ * `Resources/Stories/**` + `project.json`,并维护 cell 生成物。
4
+ *
5
+ * 布局声明 `Stories.src/config.ts`(根级唯一根文件,named exports 单次求值):
6
+ * `folders` 声明源目录(可含 `"*"` 通配全部目录)、`start` 声明入口故事文件名(跨目录全局
7
+ * 恰一);`vars`(键集对象)登记插值变量键、`state`(形态 A)/`define(...)`(形态 B)
8
+ * 声明 state 初始值,两者可缺省但互斥。
9
+ * 源发现按「default 导出存在」区分故事/模块文件;产物路径 = 源文件路径
10
+ * (`Stories/<相对源路径>.json`,文件名与列 id 解耦)。`config.start` 经托管键
11
+ * 更新落到 `project.json` 的 `entry`(`serializeProject` 既有语义)。
12
+ *
13
+ * 零第二套规则:TS 源合法 ⇔ 其编译产物作为 JSON 合法(与 JSON 同一 `parseStory`)。
14
+ * 工程清单(entry/defines/shell)不归 TS 源管——必须先有 `Resources/project.json`,
15
+ * 编译只更新其托管键。`Stories/**` 列集由源全量管理:启用 TS 源的工程,手写列文件
16
+ * 会被当作陈旧产物清理(报告逐条列出)。构建在清单写入非托管标记键 `tsSource`,
17
+ * 编辑器据此对 TS 源工程的 JSON 产物只读(`packages/editor` 的 `tsSourceGuard`)。
18
+ *
19
+ * 编译器对接:`compileStories` 契约(`./compiler`)——默认装载其中的
20
+ * `story-compiler.ts`,`StoryBuildOptions.compiler` 可注入(过渡期/测试)。
21
+ *
22
+ * 扩展 op 放行(对齐运行期声明制):清单 `extensions` 声明扩展模块说明符——编译期
23
+ * 用与运行期同一个装载契约(`loadDeclaredExtensions`)装载,收集其提供的 op 名;
24
+ * 故事里既非内建、也未被声明扩展提供的 op 名 = 运行期必 unknown-op ⇒ 构建期即拦截
25
+ * (fail-early,带列与命令指针定位,一次报全部)。声明缺席/为空 = 零装载零副作用。
26
+ *
27
+ * 测试直接调 `buildStories`(纯编排,fs 全在参数 root 之下);CLI 主守卫在
28
+ * `cli` 域——那里依赖被直接执行这一事实,不属于本流水线的出口面,故不在下面导出
29
+ * (它以包说明符 `@lingfan-engine/stories-build/cli` 暴露给宿主)。
30
+ */
31
+
32
+ /** 构建失败(fail-closed)。消息即 CLI 打印的失败原因,逐字稳定——不要在别处重写措辞 */
33
+ export { StoryBuildError } from "./shared/index.ts";
34
+ /** 集中文案表(StoryBuildOptions.texts / compileStories input.texts 的注入类型与缺省值) */
35
+ export {
36
+ defaultStoriesBuildTexts,
37
+ type StoriesBuildTexts,
38
+ } from "./shared/index.ts";
39
+ /** 一次构建的结果与词汇层警告的取口 */
40
+ export { drainBuildWarnings, type BuildReport } from "./emit/index.ts";
41
+ /** 目录布局的默认值与可配置项 */
42
+ export {
43
+ DEFAULT_GENERATED_DIR,
44
+ DEFAULT_RESOURCES_DIR,
45
+ DEFAULT_SOURCES_DIR,
46
+ type StoryBuildOptions,
47
+ } from "./pipeline/index.ts";
48
+ /** 十二段串行编排(流水线主入口) */
49
+ export { buildStories } from "./pipeline/index.ts";
50
+ /** 编译期求值域(config.ts 单次求值 + define 内建 + 形态 A/B 状态降维) */
51
+ export {
52
+ evalConfigModule,
53
+ type ConfigEvalOutcome,
54
+ } from "./const-eval/index.ts";
55
+ /** 布局声明与 config.ts 契约类型 */
56
+ export { CONFIG_FILE, type StorySourceConfig } from "./config/index.ts";
57
+ /** 创作垫片类型(@lingfan-engine/stories-build/authoring 随包 .d.ts 的主出口同源) */
58
+ export type { InferStateSchema } from "./config/state-type.ts";
59
+ /** 源发现 v2(folders 多目录 + start 全局恰一) */
60
+ export { discoverSources, type DiscoveredSources } from "./discovery/index.ts";
61
+ /** 编译器契约(story-compiler 对接面) */
62
+ export {
63
+ loadDefaultCompiler,
64
+ type CompiledStories,
65
+ type CompiledStoryEntry,
66
+ type CompilerDiagnostic,
67
+ type StoriesCompiler,
68
+ } from "./compiler/index.ts";
69
+ /** 字体名注册表(Resources/Fonts 扫描;宿主 Vite 插件与构建期检查同源消费) */
70
+ export {
71
+ FONTS_DIR_NAME,
72
+ FONT_EXTENSIONS,
73
+ fontNameSet,
74
+ scanFontRegistry,
75
+ type FontEntry,
76
+ } from "./fonts/index.ts";
@@ -0,0 +1,107 @@
1
+ import {
2
+ BUILTIN_OP_NAMES,
3
+ MANIFEST_FILE,
4
+ type Story,
5
+ } from "@lingfan-engine/core";
6
+ import { walkStoryCommands } from "@lingfan-engine/editor";
7
+ import {
8
+ defaultStoriesBuildTexts,
9
+ StoryBuildError,
10
+ type StoriesBuildTexts,
11
+ } from "../shared/index.ts";
12
+
13
+ /** 闸门判定的可配置项:文案里出现的目录名(缺省 = 现状;只影响文案,不影响判定) */
14
+ export interface GateOptions {
15
+ readonly resourcesDir: string;
16
+ readonly sourcesDir: string;
17
+ /** 文案注入(Partial);缺省 = 内建缺省文案(即构建层的既有文案) */
18
+ readonly texts?: Partial<StoriesBuildTexts>;
19
+ }
20
+
21
+ /** 指针 → 「列 "id" /columns/N/...」:指针里的列下标映射回列 id,越界则原样返回 */
22
+ function columnLabeler(story: Story): (pointer: string) => string {
23
+ const columnIds = story.columns.map((column) => column.id);
24
+ return (pointer: string): string => {
25
+ const index = Number(/^\/columns\/(\d+)/.exec(pointer)?.[1] ?? NaN);
26
+ const id = columnIds[index];
27
+ return id === undefined ? pointer : `列 "${id}" ${pointer}`;
28
+ };
29
+ }
30
+
31
+ /**
32
+ * 扩展 op 闸门(fail-early):运行期 `unknown-op` 的构建期等价判定。
33
+ * 内建与已声明扩展之外出现的 op 名 = 运行期必 unknown-op ⇒ 构建期即拦截
34
+ * (遍历含嵌套块体——`if.then`/`while.body`/`func.body` 内的命令同样在列;一次报全部,
35
+ * 同一 op 超过 3 处折叠为「(共 N 处)」)。
36
+ */
37
+ export function assertKnownOps(
38
+ story: Story,
39
+ extensionOpNames: ReadonlySet<string>,
40
+ options: GateOptions,
41
+ ): void {
42
+ const unknownOps = new Map<string, string[]>();
43
+ walkStoryCommands(story, (cmd, pointer) => {
44
+ const op = cmd.op;
45
+ if (
46
+ typeof op !== "string" ||
47
+ BUILTIN_OP_NAMES.has(op) ||
48
+ extensionOpNames.has(op)
49
+ ) {
50
+ return;
51
+ }
52
+ const hits = unknownOps.get(op) ?? [];
53
+ hits.push(pointer);
54
+ unknownOps.set(op, hits);
55
+ });
56
+ if (unknownOps.size === 0) return;
57
+ const texts: StoriesBuildTexts = {
58
+ ...defaultStoriesBuildTexts,
59
+ ...options.texts,
60
+ };
61
+ const at = columnLabeler(story);
62
+ const detail = [...unknownOps.entries()]
63
+ .map(([op, pointers]) => {
64
+ const shown = pointers.slice(0, 3).map(at).join("、");
65
+ const more = pointers.length > 3 ? `(共 ${pointers.length} 处)` : "";
66
+ return ` - op "${op}" —— ${shown}${more}`;
67
+ })
68
+ .join("\n");
69
+ throw new StoryBuildError(
70
+ texts.unknownOpsGate(detail, options.resourcesDir, MANIFEST_FILE),
71
+ );
72
+ }
73
+
74
+ /**
75
+ * 守卫名闸门(cell 范式的闭合校验):故事 `guard.fn` 必须都在 cell 注册表键集内。
76
+ * fail-closed 带命令指针定位(编辑器不校验守卫名——构建期兜住)。
77
+ */
78
+ export function assertKnownGuards(
79
+ story: Story,
80
+ guardNames: ReadonlySet<string>,
81
+ options: GateOptions,
82
+ ): void {
83
+ const unknownGuards = new Map<string, string[]>();
84
+ walkStoryCommands(story, (cmd, pointer) => {
85
+ if (cmd.op !== "guard") return;
86
+ const fn = cmd.fn;
87
+ if (typeof fn !== "string" || guardNames.has(fn)) return;
88
+ const hits = unknownGuards.get(fn) ?? [];
89
+ hits.push(pointer);
90
+ unknownGuards.set(fn, hits);
91
+ });
92
+ if (unknownGuards.size === 0) return;
93
+ const texts: StoriesBuildTexts = {
94
+ ...defaultStoriesBuildTexts,
95
+ ...options.texts,
96
+ };
97
+ const detail = [...unknownGuards.entries()]
98
+ .map(([fn, pointers]) => ` - guard "${fn}" —— ${pointers.join("、")}`)
99
+ .join("\n");
100
+ throw new StoryBuildError(
101
+ texts.unknownGuardsGate(
102
+ detail,
103
+ options.sourcesDir,
104
+ [...unknownGuards.keys()][0],
105
+ ),
106
+ );
107
+ }