@lingfan-engine/stories-build 0.1.5 → 0.3.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/package.json +4 -3
- package/src/authoring.d.ts +31 -0
- package/src/cell/imports.ts +21 -7
- package/src/cell/index.ts +3 -2
- package/src/cell/scan-cells.ts +10 -1
- package/src/cell/scan-file.ts +11 -1
- package/src/cell/types.ts +10 -0
- package/src/cli/cli.ts +49 -4
- package/src/compiler/cell-scan.ts +12 -2
- package/src/compiler/compiler-contract.ts +12 -0
- package/src/compiler/story-compiler/assemble/mount-uis.ts +43 -0
- package/src/compiler/story-compiler/assemble/session.ts +30 -0
- package/src/compiler/story-compiler/graph/build.ts +86 -14
- package/src/compiler/story-compiler/graph/core.ts +66 -2
- package/src/compiler/story-compiler/graph/fold.ts +3 -0
- package/src/compiler/story-compiler/graph/index.ts +12 -1
- package/src/compiler/story-compiler/graph/scan/alias.ts +100 -0
- package/src/compiler/story-compiler/graph/scan/imports.ts +53 -6
- package/src/compiler/story-compiler/graph/scan/module-scan.ts +5 -0
- package/src/compiler/story-compiler/graph/scan/ui-import.ts +136 -0
- package/src/compiler/story-compiler/graph/scan/ui-specifier.ts +202 -0
- package/src/compiler/story-compiler/graph/state-root.ts +1 -1
- package/src/compiler/story-compiler/graph.ts +1 -0
- package/src/compiler/story-compiler/index.ts +1 -0
- package/src/compiler/story-compiler/translate/expr/words/arg.ts +5 -0
- package/src/compiler/story-compiler.ts +11 -0
- package/src/config/story-config.ts +6 -2
- package/src/const-eval/const-eval-pass.ts +18 -3
- package/src/discovery/source-discovery.ts +8 -3
- package/src/emit/emit.ts +11 -1
- package/src/host-plugins/index.ts +11 -0
- package/src/host-plugins/virtual-modules.ts +138 -0
- package/src/index.ts +20 -0
- package/src/pipeline/gates.ts +73 -2
- package/src/pipeline/index.ts +2 -0
- package/src/pipeline/merge.ts +132 -0
- package/src/pipeline/orchestrate.ts +101 -126
- package/src/pipeline/tsconfig-paths.ts +196 -0
- package/src/pipeline/watch.ts +4 -3
- package/src/shared/generated-dir.ts +31 -0
- package/src/shared/index.ts +9 -5
- package/src/shared/limits.ts +17 -0
- package/src/shared/texts.ts +41 -0
- package/src/story-list/index.ts +2 -0
- package/src/story-list/story-registry.ts +55 -0
- package/src/typegen/font-render.ts +1 -1
- package/src/typegen/index.ts +1 -1
- package/src/typegen/render.ts +1 -1
- package/src/typegen/script-render.ts +1 -1
- package/src/typegen/state-enum-ast.ts +1 -1
- package/src/typegen/state-render.ts +34 -8
- package/src/ui/index.ts +9 -0
- package/src/ui/render-register.ts +114 -0
- package/src/ui/scan-imports.ts +314 -0
- package/src/ui/ui-registry.ts +71 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 构建期追溯类算法的统一上限。
|
|
3
|
+
*
|
|
4
|
+
* 这里放的是「追到底」这类算法的步数上限:追溯目标本身不保证可达(别名链、
|
|
5
|
+
* 引用链都可能成环),不设上限就是一个必然的挂死点。上限的取值属实现细节,
|
|
6
|
+
* 但「有没有上限、上限叫什么」是跨域约定,故只在此声明一处。
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* 类型别名链的最大回溯跳数。
|
|
11
|
+
*
|
|
12
|
+
* 编译器发现状态根名(沿 `type A = B` 找到 `StoryState`)与类型生成回收枚举槽位
|
|
13
|
+
* (沿别名链找到该标识符的 import 来源)都要走这条链;两者同属「沿 TS 类型别名链
|
|
14
|
+
* 追到终点,追不到就放弃」,共用同一上限。跳数耗尽即视为「追不到」,由调用方
|
|
15
|
+
* 按各自语义回退(前者回退默认根名,后者回退内联字面量联合)。
|
|
16
|
+
*/
|
|
17
|
+
export const MAX_ALIAS_HOPS = 10;
|
package/src/shared/texts.ts
CHANGED
|
@@ -50,6 +50,24 @@ export interface StoriesBuildTexts {
|
|
|
50
50
|
npmNameConstOnly(name: string): string;
|
|
51
51
|
/** config.ts 缺失(const-eval/config-eval) */
|
|
52
52
|
configMissing(sourcesDirName: string, configFile: string): string;
|
|
53
|
+
/** UI 说明符落在 `src/ui/` 之外(graph/scan/imports) */
|
|
54
|
+
uiOutside(spec: string, sourceRootName: string): string;
|
|
55
|
+
/** UI 导入的绑定名与目录名不一致(graph/scan/imports) */
|
|
56
|
+
uiBindingName(name: string, received: string): string;
|
|
57
|
+
/** UI 导入的文件名与目录名不一致(graph/scan/imports) */
|
|
58
|
+
uiFileName(name: string, file: string): string;
|
|
59
|
+
/** UI 导入必须是 default 导入(graph/scan/imports) */
|
|
60
|
+
uiDefaultImport(name: string): string;
|
|
61
|
+
/** UI 注册表里没有这个句柄名(graph/scan/ui-import) */
|
|
62
|
+
uiRegisterMissing(name: string): string;
|
|
63
|
+
/** UI 注册表不接受 default 导入与命名空间导入(graph/scan/ui-import) */
|
|
64
|
+
uiRegisterNoDefault(): string;
|
|
65
|
+
/** UI 注册表的副作用导入不取任何句柄(graph/scan/ui-import) */
|
|
66
|
+
uiRegisterSideEffect(): string;
|
|
67
|
+
/** 列级 uis 指向了不存在的 UI 目录(pipeline/gates;detail = 已折叠的明细行集) */
|
|
68
|
+
unknownUisGate(detail: string, sourcesDir: string, uiDirName: string): string;
|
|
69
|
+
/** UI 文件的 import 面越界(ui/scan-imports;detail = 已折叠的明细行集) */
|
|
70
|
+
uiImportGate(detail: string, uiDirName: string): string;
|
|
53
71
|
}
|
|
54
72
|
|
|
55
73
|
/** 缺省文案:即 stories-build 的既有文案。 */
|
|
@@ -82,4 +100,27 @@ export const defaultStoriesBuildTexts: StoriesBuildTexts = {
|
|
|
82
100
|
`${sourcesDirName}/${configFile} 不存在——多目录 TS 源工程需要布局声明` +
|
|
83
101
|
`(export const folders/start/vars/state):folders 声明源目录(可含 "*" 全量兜底),` +
|
|
84
102
|
`start 声明入口故事文件名`,
|
|
103
|
+
uiOutside: (spec, sourceRootName) =>
|
|
104
|
+
`UI 说明符 \`${spec}\` 必须解析到 ${sourceRootName} 的兄弟目录 \`ui/<名>/<名>.<扩展名>\`` +
|
|
105
|
+
`(UI 沙箱是源根的上一级,不是源根内部)`,
|
|
106
|
+
uiBindingName: (name, received) =>
|
|
107
|
+
`UI 导入的绑定名必须是目录名(import ${name} from ".../ui/${name}/${name}.vue"),收到 \`${received}\``,
|
|
108
|
+
uiFileName: (name, file) =>
|
|
109
|
+
`UI 文件名必须等于目录名:\`ui/${name}/\` 下只能有 \`${name}.<扩展名>\`,收到 \`${file}\``,
|
|
110
|
+
uiDefaultImport: (name) =>
|
|
111
|
+
`UI 导入必须是 default 导入(import ${name} from ".../ui/${name}/${name}.vue")——一个 UI 只导出一个句柄`,
|
|
112
|
+
uiRegisterMissing: (name) =>
|
|
113
|
+
`UI 注册表里没有 \`${name}\`——句柄名必须与 \`ui/<名>/\` 目录名一致`,
|
|
114
|
+
uiRegisterNoDefault: () =>
|
|
115
|
+
`UI 注册表没有默认导出,也没有命名空间导出:请用具名导入(import { 句柄名 } from ".../gen/ui_register.g")`,
|
|
116
|
+
uiRegisterSideEffect: () =>
|
|
117
|
+
"UI 注册表的副作用导入不支持:它只提供句柄,请显式导入所需句柄",
|
|
118
|
+
unknownUisGate: (detail, sourcesDir, uiDirName) =>
|
|
119
|
+
`列级 uis 声明了源里不存在的 UI(宿主按名字预加载时会落空,界面要到挂载瞬间才出现):\n${detail}\n` +
|
|
120
|
+
`→ 在 ${sourcesDir} 的兄弟目录 \`${uiDirName}/<名>/\` 下放实现文件 \`<名>.vue|.tsx|.html|.ts\`,或从列里去掉该名字`,
|
|
121
|
+
uiImportGate: (detail, uiDirName) =>
|
|
122
|
+
`UI 文件的 import 面越界(UI 不归构建管,越界依赖只能在这里拦):\n${detail}\n` +
|
|
123
|
+
`→ ${uiDirName}/** 下的文件只允许四类:相对路径引用 ${uiDirName}/ 内的文件、` +
|
|
124
|
+
`框架包(vue / react / react-dom / react/jsx-runtime)、` +
|
|
125
|
+
`从 @lingfan-engine/core 取纯类型(import type)、别名 @/${uiDirName}/**`,
|
|
85
126
|
};
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 故事文件清单注册表:扫描资源根下的故事目录,得出要装载的故事逻辑路径。
|
|
3
|
+
*
|
|
4
|
+
* 清单是**磁盘事实的投影**,不是需要作者维护的声明文件。让作者手抄名单的代价不是
|
|
5
|
+
* 「多写一行」,而是漏抄时构建期毫无察觉——漏登记的那一列要到运行期才以
|
|
6
|
+
* `unknown-column` 现形。
|
|
7
|
+
*
|
|
8
|
+
* 目录名不在这里写死:资源根与故事目录名都是 `@lingfan-engine/core` 的一处声明
|
|
9
|
+
* (`RESOURCES_DIR` / `STORIES_DIR`),构建侧、引擎侧与宿主侧的扫描共用同一份判据。
|
|
10
|
+
*
|
|
11
|
+
* 两种创作形态落在同一棵树:手写 `.story` 与 TS 源编译出的 `.json` 都是故事文件,
|
|
12
|
+
* 共用这一条扫描——创作形态不改变清单的来源。
|
|
13
|
+
*/
|
|
14
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { compareOrdinal, STORIES_DIR } from "@lingfan-engine/core";
|
|
17
|
+
|
|
18
|
+
/** 故事文件扩展名:手写文本形态 `.story` 与编译产物 `.json` 都算 */
|
|
19
|
+
export const STORY_EXTENSIONS = [".story", ".json"] as const;
|
|
20
|
+
|
|
21
|
+
/** 故事文件清单条目:相对资源根的逻辑路径(`<故事目录>/<章节>/<文件>`) */
|
|
22
|
+
export type StoryPath = string;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 递归扫描故事目录,返回相对资源根的逻辑路径。
|
|
26
|
+
*
|
|
27
|
+
* 顺序 = 路径码元序,与引擎组装器的装载顺序同一条判据(`compareOrdinal`)——列序因此与磁盘
|
|
28
|
+
* 布局一致,不随目录读取顺序漂移。
|
|
29
|
+
*
|
|
30
|
+
* @param storiesDir 故事目录的绝对路径(资源根下的 `<STORIES_DIR>`)
|
|
31
|
+
*/
|
|
32
|
+
export function scanStoryRegistry(storiesDir: string): readonly StoryPath[] {
|
|
33
|
+
const out: StoryPath[] = [];
|
|
34
|
+
const walk = (dir: string, prefix: string): void => {
|
|
35
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
36
|
+
if (entry.name.startsWith(".")) continue;
|
|
37
|
+
const rel = `${prefix}/${entry.name}`;
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
walk(join(dir, entry.name), rel);
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const dot = entry.name.lastIndexOf(".");
|
|
43
|
+
if (dot <= 0) continue;
|
|
44
|
+
if (
|
|
45
|
+
(STORY_EXTENSIONS as readonly string[]).includes(
|
|
46
|
+
entry.name.slice(dot).toLowerCase(),
|
|
47
|
+
)
|
|
48
|
+
) {
|
|
49
|
+
out.push(rel);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
if (existsSync(storiesDir)) walk(storiesDir, STORIES_DIR);
|
|
54
|
+
return out.sort(compareOrdinal);
|
|
55
|
+
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 字体名注册表的 ambient 类型面渲染器(`font` 全局常量字面量类型)。
|
|
3
3
|
*
|
|
4
|
-
* 输出 = `
|
|
4
|
+
* 输出 = `src/gen/font.gen.d.ts` 全文:`declare const font` 的键与值都取
|
|
5
5
|
* 注册表字体名(引号键保非标识符名可用),未注册名在 TS 面即不存在——IDE 补全只给
|
|
6
6
|
* 正名,改拼直接 TS2551。纯编译期声明,不注入运行时对象;模块自有 `font` 声明按
|
|
7
7
|
* TS 遮蔽语义优先。空注册表产出 `Record<never, never>`:TS 里空对象字面量 `{}` 会
|
package/src/typegen/index.ts
CHANGED
package/src/typegen/render.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* script 词汇层命名空间的 ambient 类型面渲染器(免导入直呼)。
|
|
3
3
|
*
|
|
4
|
-
* 输出 = `
|
|
4
|
+
* 输出 = `src/gen/script.gen.d.ts` 全文:一条全局 `declare const script`
|
|
5
5
|
* 指向 `@lingfan-engine/editor` 根出口的 `script` 命名空间类型。纯编译期声明——不注入
|
|
6
6
|
* 任何运行时对象;模块层自有同名声明按 TS 遮蔽语义优先于本全局声明,显式
|
|
7
7
|
* import 与解构形态(含重命名)不受影响。内容恒定、与工程状态无关,同输入逐字节同输出。
|
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import { posix } from "node:path";
|
|
27
27
|
import * as ts from "typescript";
|
|
28
|
+
import { MAX_ALIAS_HOPS } from "../shared/index.ts";
|
|
28
29
|
|
|
29
30
|
/** 一个可 import 的枚举 type 槽位:源文件里的本地绑定名 + 模块说明符 */
|
|
30
31
|
export interface EnumTypeSlot {
|
|
@@ -42,7 +43,6 @@ export interface EnumTypeSlot {
|
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
/** 别名回溯上限:有限跳数防环(与 module-scan 状态根别名链同思路) */
|
|
45
|
-
const MAX_ALIAS_HOPS = 10;
|
|
46
46
|
|
|
47
47
|
export function collectEnumTypeSlots(
|
|
48
48
|
configSource: string,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* 输入 = config 求值合并树(形态 A state 树 / 形态 B define 树,与降维段同一棵)+
|
|
5
5
|
* AST 侧回收的枚举 type 槽位(collectEnumTypeSlotsForBuild)。输出 =
|
|
6
|
-
* `
|
|
6
|
+
* `src/gen/state.gen.d.ts` 全文:嵌套对象字面量的 `export type StoryState = { … }`,
|
|
7
7
|
* descriptor 叶按 type 槽位取精确类型——原生标量 / 单元素数组 leaf / 多元素数组并集 /
|
|
8
8
|
* 枚举引用(import type)或内联字面量联合。
|
|
9
9
|
*
|
|
@@ -24,7 +24,15 @@ export interface StateTypegenInput {
|
|
|
24
24
|
readonly tree: unknown;
|
|
25
25
|
/** 点路径 → 枚举 type 槽位列表(直接槽位与并集成员槽位,collectEnumTypeSlotsForBuild 的结果) */
|
|
26
26
|
readonly enumSlots: ReadonlyMap<string, EnumTypeSlot[]>;
|
|
27
|
-
/**
|
|
27
|
+
/**
|
|
28
|
+
* 源根目录(相对工程根,默认布局 `"src/Stories.src"`;源根就是工程根时省略):
|
|
29
|
+
* 槽位的 `source` 都是源根内路径,换算说明符要一个能对齐两个基准的原点。
|
|
30
|
+
*/
|
|
31
|
+
readonly sourceRootDir?: string;
|
|
32
|
+
/**
|
|
33
|
+
* 生成物目录(相对工程根,默认布局 `"src/gen"`):与 `sourceRootDir` 同一基准,
|
|
34
|
+
* import 说明符按两者的相对位置改写。
|
|
35
|
+
*/
|
|
28
36
|
readonly generatedDir: string;
|
|
29
37
|
}
|
|
30
38
|
|
|
@@ -40,7 +48,11 @@ export function renderStateTypegen(input: StateTypegenInput): string {
|
|
|
40
48
|
const used = new Map<string, EnumTypeSlot>();
|
|
41
49
|
const body = renderValue(input.tree, "", 0, input.enumSlots, used);
|
|
42
50
|
const lines: string[] = [HEADER, ""];
|
|
43
|
-
const importLines = renderImports(
|
|
51
|
+
const importLines = renderImports(
|
|
52
|
+
used,
|
|
53
|
+
input.sourceRootDir ?? "",
|
|
54
|
+
input.generatedDir,
|
|
55
|
+
);
|
|
44
56
|
if (importLines.length > 0) lines.push(...importLines, "");
|
|
45
57
|
lines.push(`export type StoryState = ${body ?? "Record<string, never>"};`);
|
|
46
58
|
return `${lines.join("\n")}\n`;
|
|
@@ -196,6 +208,7 @@ function renderEnumUnion(values: Record<string, unknown>): string {
|
|
|
196
208
|
|
|
197
209
|
function renderImports(
|
|
198
210
|
used: ReadonlyMap<string, EnumTypeSlot>,
|
|
211
|
+
sourceRootDir: string,
|
|
199
212
|
generatedDir: string,
|
|
200
213
|
): string[] {
|
|
201
214
|
const bySource = new Map<string, EnumTypeSlot[]>();
|
|
@@ -206,7 +219,9 @@ function renderImports(
|
|
|
206
219
|
}
|
|
207
220
|
const lines: string[] = [];
|
|
208
221
|
for (const source of [...bySource.keys()].sort()) {
|
|
209
|
-
const specifier = JSON.stringify(
|
|
222
|
+
const specifier = JSON.stringify(
|
|
223
|
+
rewriteSpecifier(source, sourceRootDir, generatedDir),
|
|
224
|
+
);
|
|
210
225
|
const slots = bySource.get(source);
|
|
211
226
|
if (slots === undefined) continue;
|
|
212
227
|
for (const slot of slots.filter((s) => s.importedName === "default")) {
|
|
@@ -228,12 +243,23 @@ function renderImports(
|
|
|
228
243
|
}
|
|
229
244
|
|
|
230
245
|
/**
|
|
231
|
-
* import
|
|
232
|
-
*
|
|
246
|
+
* import 说明符改写:槽位路径(源根内相对路径 `"./lib/enums"`)折到工程根坐标系,
|
|
247
|
+
* 再换算到生成物目录(默认布局 `src/Stories.src/lib/enums` 对 `src/gen` →
|
|
248
|
+
* `"../../Stories.src/lib/enums"`);包导入原样保留。
|
|
249
|
+
*
|
|
250
|
+
* 两个参数必须同基准(都相对工程根)——只把生成物目录换掉而不补源根段,会算出
|
|
251
|
+
* 丢掉 `Stories.src/` 的错误路径。
|
|
233
252
|
*/
|
|
234
|
-
function rewriteSpecifier(
|
|
253
|
+
function rewriteSpecifier(
|
|
254
|
+
source: string,
|
|
255
|
+
sourceRootDir: string,
|
|
256
|
+
generatedDir: string,
|
|
257
|
+
): string {
|
|
235
258
|
if (!source.startsWith(".")) return source;
|
|
236
|
-
const
|
|
259
|
+
const target = posix.normalize(
|
|
260
|
+
posix.join(sourceRootDir === "" ? "." : sourceRootDir, source),
|
|
261
|
+
);
|
|
262
|
+
const rel = posix.relative(generatedDir, target);
|
|
237
263
|
return rel.startsWith(".") ? rel : `./${rel}`;
|
|
238
264
|
}
|
|
239
265
|
|
package/src/ui/index.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export { UI_DIR_NAME, scanUiRegistry, uiNameSet } from "./ui-registry.ts";
|
|
2
|
+
export type { UiFileEntry } from "./ui-registry.ts";
|
|
3
|
+
export { renderUiRegister } from "./render-register.ts";
|
|
4
|
+
export {
|
|
5
|
+
checkUiImports,
|
|
6
|
+
scanUiImports,
|
|
7
|
+
scanUiTreeImports,
|
|
8
|
+
} from "./scan-imports.ts";
|
|
9
|
+
export type { UiImportRef, UiImportViolation } from "./scan-imports.ts";
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 注册表生成物的渲染器(`src/gen/ui_register.g.ts` 全文)。
|
|
3
|
+
*
|
|
4
|
+
* 一个文件三段导出,三个消费方各取一段:
|
|
5
|
+
* 1. `uis` —— 「名字 → 组件」表,宿主按 `SYS.mounts` 的键去查,查到就渲染;
|
|
6
|
+
* 2. 具名句柄 `hub` —— 故事源按具名导入直接用,不依赖 `*.vue` 的 ambient 声明;
|
|
7
|
+
* 3. `declare module "@lingfan-engine/editor"` 的 `UiNameRegistry` —— 类型面,
|
|
8
|
+
* `mount("…")` 的字符串名字写错在类型期就红。
|
|
9
|
+
*
|
|
10
|
+
* 名字的唯一来源是 `ui/<名>/` 目录名(`scanUiRegistry`),生成物里不再归一、
|
|
11
|
+
* 不再排序(扫描已排好),同一次扫描必得同一份文本 —— 差量写盘靠这一点。
|
|
12
|
+
*
|
|
13
|
+
* 注意具名句柄只对**能当标识符的名字**生成:UI 名允许 `-`(`save-panel` 合法),
|
|
14
|
+
* 那种名字没法做导出名,只能走 `uis` 表与字符串形态。这条规则写在文件头注释里,
|
|
15
|
+
* 免得作者以为生成器漏了一个。
|
|
16
|
+
*/
|
|
17
|
+
import type { UiFileEntry } from "./ui-registry.ts";
|
|
18
|
+
|
|
19
|
+
/** TS 标识符形态(UI 名允许 `-`,那种名字没有具名句柄) */
|
|
20
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 渲染 `ui_register.g.ts` 全文。
|
|
24
|
+
*
|
|
25
|
+
* @param entries 扫描出的 UI 条目(已按名字排好序)
|
|
26
|
+
* @param uiRelFromGenerated 从生成物目录到 UI 根目录的相对路径(posix,无尾斜杠),
|
|
27
|
+
* 如 `"../ui"`。生成物里一律写相对路径,不写别名 —— 别名是创作期便利,
|
|
28
|
+
* 产物与生成物里出现别名就会把宿主绑死在某个 tsconfig 上。
|
|
29
|
+
*/
|
|
30
|
+
export function renderUiRegister(
|
|
31
|
+
entries: readonly UiFileEntry[],
|
|
32
|
+
uiRelFromGenerated: string,
|
|
33
|
+
): string {
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
const bindings = bindingsOf(entries);
|
|
36
|
+
const handleEntries = entries.filter((entry) => IDENTIFIER.test(entry.name));
|
|
37
|
+
lines.push(
|
|
38
|
+
"/* GENERATED by stories:build (ui-register) — 手改会在下次构建被覆盖 */",
|
|
39
|
+
);
|
|
40
|
+
// 没有 UI 的工程不写这一行:未使用的类型导入会让消费工程的 `noUnusedLocals` 直接红
|
|
41
|
+
if (handleEntries.length > 0) {
|
|
42
|
+
lines.push('import type { UiHandle } from "@lingfan-engine/editor";');
|
|
43
|
+
}
|
|
44
|
+
for (const entry of entries) {
|
|
45
|
+
lines.push(
|
|
46
|
+
`import ${bindings.get(entry.name)} from ${JSON.stringify(`${uiRelFromGenerated}/${entry.name}/${entry.file}`)};`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
lines.push("");
|
|
50
|
+
lines.push(
|
|
51
|
+
"/** 宿主用:「名字 → 组件」表。键就是 `SYS.mounts` 里的名字,不需要再归一。 */",
|
|
52
|
+
);
|
|
53
|
+
lines.push("export const uis: Record<string, unknown> = {");
|
|
54
|
+
for (const entry of entries) {
|
|
55
|
+
lines.push(` ${JSON.stringify(entry.name)}: ${bindings.get(entry.name)},`);
|
|
56
|
+
}
|
|
57
|
+
lines.push("};");
|
|
58
|
+
|
|
59
|
+
if (handleEntries.length > 0) {
|
|
60
|
+
lines.push("");
|
|
61
|
+
lines.push(
|
|
62
|
+
'/** 故事源用:具名句柄,与 `import hub from "…/hub.vue"` 折叠出同一个名字。 */',
|
|
63
|
+
);
|
|
64
|
+
for (const entry of handleEntries) {
|
|
65
|
+
lines.push(
|
|
66
|
+
`export const ${entry.name}: UiHandle<${JSON.stringify(entry.name)}> = { face: "uis", name: ${JSON.stringify(entry.name)} };`,
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 没有 UI 的工程到此为止:类型面登记处指向 `@lingfan-engine/editor`,
|
|
72
|
+
// 而只用 `.story` 故事源的工程没有这个依赖(它是故事源 TS 层带进来的),
|
|
73
|
+
// 空表里也没有任何名字可登记——多写一段只会让那种工程解析不到模块。
|
|
74
|
+
if (entries.length === 0) return `${lines.join("\n")}\n`;
|
|
75
|
+
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push(
|
|
78
|
+
'/** UI 名的构建期登记处:`mount("…")` 引用了未登记的名字会在类型期报错。 */',
|
|
79
|
+
);
|
|
80
|
+
lines.push('declare module "@lingfan-engine/editor" {');
|
|
81
|
+
lines.push(" interface UiNameRegistry {");
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
lines.push(` ${JSON.stringify(entry.name)}: 1;`);
|
|
84
|
+
}
|
|
85
|
+
lines.push(" }");
|
|
86
|
+
lines.push("}");
|
|
87
|
+
return `${lines.join("\n")}\n`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 每个 UI 的组件导入绑定名。
|
|
92
|
+
*
|
|
93
|
+
* 不能直接用 UI 名:具名句柄 `export const hub` 已经占住了那个标识符,
|
|
94
|
+
* 同名的两个声明会重复;而且 UI 名允许 `-`(`save-panel` 合法),
|
|
95
|
+
* 那种名字根本不能当标识符。所以统一加 `View` 后缀,非法字符换成 `_`,
|
|
96
|
+
* 撞名再按出现次序加数字(同一次扫描结果稳定,文本才可差量比较)。
|
|
97
|
+
*/
|
|
98
|
+
function bindingsOf(
|
|
99
|
+
entries: readonly UiFileEntry[],
|
|
100
|
+
): ReadonlyMap<string, string> {
|
|
101
|
+
const taken = new Set<string>();
|
|
102
|
+
const out = new Map<string, string>();
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
const base = entry.name.replace(/[^A-Za-z0-9_$]/g, "_");
|
|
105
|
+
const seed = /^[A-Za-z_$]/.test(base) ? base : `_${base}`;
|
|
106
|
+
let candidate = `${seed}View`;
|
|
107
|
+
for (let n = 2; taken.has(candidate); n += 1) {
|
|
108
|
+
candidate = `${seed}View${n}`;
|
|
109
|
+
}
|
|
110
|
+
taken.add(candidate);
|
|
111
|
+
out.set(entry.name, candidate);
|
|
112
|
+
}
|
|
113
|
+
return out;
|
|
114
|
+
}
|