@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,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 文件的 import 面:先提取说明符,再对照允许清单判定。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要这一层:UI 文件不进 stories-build 的编译链(不读、不编译、不拷贝),
|
|
5
|
+
* 所以里面写了什么 import 构建期本来一无所知。而宿主侧的打包器**不一定拦得住**——
|
|
6
|
+
* `@/host/boot` 在宿主工程里是合法路径,能解析、能打包,但那是把宿主拖进 UI 的
|
|
7
|
+
* 反向依赖:换形态、换壳必炸,且依赖图上看不见。这里补的就是这道静态校验。
|
|
8
|
+
*
|
|
9
|
+
* 两侧禁的是同一件事的两个方向,别混:**UI 侧**不许 import `@/gen/**`(依赖构建顺序),
|
|
10
|
+
* 由本文件判定;**故事源侧**不许 import `gen/**`(生成物不参与创作导入),由编译器
|
|
11
|
+
* 图谱层判定。执行点不同,报错文案也不同。
|
|
12
|
+
*
|
|
13
|
+
* 提取不用正则:`import` / `export` / `import type` / `import()` / `require` /
|
|
14
|
+
* 注释里的假 import 六种形态,正则必漏一种。走 TypeScript 自己的 AST,
|
|
15
|
+
* 注释与字符串天然被排除,动态导入与 `require` 递归补齐。
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync } from "node:fs";
|
|
18
|
+
import { join, posix } from "node:path";
|
|
19
|
+
import * as ts from "typescript";
|
|
20
|
+
import { DEFAULT_GENERATED_DIR } from "../shared/index.ts";
|
|
21
|
+
import { UI_DIR_NAME, type UiFileEntry } from "./ui-registry.ts";
|
|
22
|
+
|
|
23
|
+
/** 一条 import 引用:说明符 + 1-based 行号(报错用)+ 是否纯类型 */
|
|
24
|
+
export interface UiImportRef {
|
|
25
|
+
readonly specifier: string;
|
|
26
|
+
readonly line: number;
|
|
27
|
+
readonly typeOnly: boolean;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** 一条越界 import 的判定结果;`file` 是相对 `src/` 的 posix 路径 */
|
|
31
|
+
export interface UiImportViolation {
|
|
32
|
+
readonly file: string;
|
|
33
|
+
readonly line: number;
|
|
34
|
+
readonly specifier: string;
|
|
35
|
+
readonly reason: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** 允许的框架包(UI 存在的理由,精确匹配,不接受子路径) */
|
|
39
|
+
const FRAMEWORK_PACKAGES: ReadonlySet<string> = new Set([
|
|
40
|
+
"vue",
|
|
41
|
+
"react",
|
|
42
|
+
"react-dom",
|
|
43
|
+
"react/jsx-runtime",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 从一份 UI 文件源码提取全部 import 引用。
|
|
48
|
+
*
|
|
49
|
+
* `ext` 决定解析方式:`.vue` 只扫 `<script>` / `<script setup>` 块(`<style>` 里的
|
|
50
|
+
* `@import` 不管,它不是模块依赖);`.html` 只扫 `<script type="module">` 块
|
|
51
|
+
* (老式 `<script>` 不能 import);`.tsx` / `.ts` 整份按 TS 处理。
|
|
52
|
+
*
|
|
53
|
+
* 块内 import 的行号取原文件的行号:抽块时把块外内容替换成等量空格、保留换行,
|
|
54
|
+
* 于是 AST 报出的位置与作者在编辑器里看到的一致。
|
|
55
|
+
*/
|
|
56
|
+
export function scanUiImports(source: string, ext: string): UiImportRef[] {
|
|
57
|
+
const extension = ext.toLowerCase();
|
|
58
|
+
if (extension === ".vue") {
|
|
59
|
+
return refsIn(scriptSkeleton(source, false), "ui.vue");
|
|
60
|
+
}
|
|
61
|
+
if (extension === ".html") {
|
|
62
|
+
return refsIn(scriptSkeleton(source, true), "ui.html");
|
|
63
|
+
}
|
|
64
|
+
return refsIn(source, extension === ".tsx" ? "ui.tsx" : "ui.ts");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* 对照允许清单判定一批引用,返回越界项。
|
|
69
|
+
*
|
|
70
|
+
* 判定顺序**先判越界再判允许**:`@/host/x` 与 `@/ui/../host/x` 都必须落到「越出
|
|
71
|
+
* src/ui」那条,而不是被「别名 UI」规则顺手放过。
|
|
72
|
+
*/
|
|
73
|
+
export function checkUiImports(
|
|
74
|
+
file: string,
|
|
75
|
+
refs: readonly UiImportRef[],
|
|
76
|
+
): UiImportViolation[] {
|
|
77
|
+
const violations: UiImportViolation[] = [];
|
|
78
|
+
for (const ref of refs) {
|
|
79
|
+
const reason = reasonOf(file, ref);
|
|
80
|
+
if (reason === undefined) continue;
|
|
81
|
+
violations.push({
|
|
82
|
+
file,
|
|
83
|
+
line: ref.line,
|
|
84
|
+
specifier: ref.specifier,
|
|
85
|
+
reason,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return violations;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* 扫描整棵 UI 树并判定:`uiRoot` 是磁盘上的 `src/ui/`,`entries` 来自注册表扫描。
|
|
93
|
+
*
|
|
94
|
+
* 只认注册表登记过的 `<名>/<名>.<扩展名>`,与导入期、列级 `uis` 闸门同一套落位规则。
|
|
95
|
+
*/
|
|
96
|
+
export function scanUiTreeImports(
|
|
97
|
+
uiRoot: string,
|
|
98
|
+
entries: readonly UiFileEntry[],
|
|
99
|
+
): UiImportViolation[] {
|
|
100
|
+
const violations: UiImportViolation[] = [];
|
|
101
|
+
for (const entry of entries) {
|
|
102
|
+
const source = readFileSync(join(uiRoot, entry.name, entry.file), "utf8");
|
|
103
|
+
const ext = entry.file.slice(entry.file.lastIndexOf("."));
|
|
104
|
+
const file = `${UI_DIR_NAME}/${entry.name}/${entry.file}`;
|
|
105
|
+
for (const violation of checkUiImports(file, scanUiImports(source, ext))) {
|
|
106
|
+
violations.push(violation);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return violations;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 一条引用的拒绝理由;`undefined` = 通过 */
|
|
113
|
+
function reasonOf(file: string, ref: UiImportRef): string | undefined {
|
|
114
|
+
const specifier = ref.specifier;
|
|
115
|
+
const root = specifier.startsWith("@/") ? specifier.slice(2) : undefined;
|
|
116
|
+
|
|
117
|
+
// 相对路径与 `@/ui/**` 等价:都折成相对 `src/` 的路径再判沙箱。
|
|
118
|
+
if (
|
|
119
|
+
isRelative(specifier) ||
|
|
120
|
+
root === "ui" ||
|
|
121
|
+
root?.startsWith("ui/") === true
|
|
122
|
+
) {
|
|
123
|
+
const resolved = resolveInSrc(file, specifier);
|
|
124
|
+
if (resolved === "ui" || resolved.startsWith("ui/")) return undefined;
|
|
125
|
+
return `UI 不得引用 src/ui 之外的文件(解析为 src/${resolved})`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (root !== undefined) {
|
|
129
|
+
if (isUnder(root, DEFAULT_GENERATED_DIR)) {
|
|
130
|
+
return `UI 不得依赖构建产物:生成物随构建顺序变,UI 会被迫跟着重建(${specifier})`;
|
|
131
|
+
}
|
|
132
|
+
if (isUnder(root, "host")) {
|
|
133
|
+
return `UI 不得反向依赖宿主:宿主是 UI 的调用方,反向依赖成环(${specifier})`;
|
|
134
|
+
}
|
|
135
|
+
if (isUnder(root, "main") || isUnder(root, "boot")) {
|
|
136
|
+
return `UI 不得引用组合根:入口负责装配 UI,UI 不该反过来找入口(${specifier})`;
|
|
137
|
+
}
|
|
138
|
+
if (isUnder(root, "Stories.src")) {
|
|
139
|
+
return `UI 不得依赖故事源:创作输入与展示层一旦互相引用就焊死了(${specifier})`;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if (FRAMEWORK_PACKAGES.has(specifier)) return undefined;
|
|
144
|
+
|
|
145
|
+
if (specifier === "@lingfan-engine/core") {
|
|
146
|
+
return ref.typeOnly
|
|
147
|
+
? undefined
|
|
148
|
+
: "UI 只能从引擎包取类型(import type),不能取运行期值";
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return `UI 不得依赖外部包(拷贝后无法解析):${specifier}`;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** 说明符是否相对路径(`./` 与 `../` 两种形态) */
|
|
155
|
+
function isRelative(specifier: string): boolean {
|
|
156
|
+
return specifier.startsWith("./") || specifier.startsWith("../");
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 去掉 `@/` 前缀后的路径是否落在 `base` 之下:`base` 本身、`base/…`、或
|
|
161
|
+
* `base.<扩展名>`(作者常写 `@/main.ts` 而不是 `@/main`)。
|
|
162
|
+
*
|
|
163
|
+
* 不用 `startsWith(base)`——那会把 `@/mainmenu` 误判成组合根。
|
|
164
|
+
*/
|
|
165
|
+
function isUnder(path: string, base: string): boolean {
|
|
166
|
+
return (
|
|
167
|
+
path === base || path.startsWith(`${base}/`) || path.startsWith(`${base}.`)
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* 把说明符折成相对 `src/` 的 posix 路径:`@/ui/x` 去掉前缀,相对路径与 `file`
|
|
173
|
+
* 所在目录 join 后 normalize。`..` 段在这里被消掉,越界因此表现为不以 `ui/` 开头。
|
|
174
|
+
*/
|
|
175
|
+
function resolveInSrc(file: string, specifier: string): string {
|
|
176
|
+
if (specifier.startsWith("@/")) return posix.normalize(specifier.slice(2));
|
|
177
|
+
return posix.normalize(posix.join(posix.dirname(file), specifier));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* 把源码抽成「等行数的骨架」:只有合格的 script 块内容留下,其余全部换成空格。
|
|
182
|
+
*
|
|
183
|
+
* 换成空格而不是删除,是为了让 AST 报出的行列与作者在编辑器里看到的一致;
|
|
184
|
+
* 只留 script 块内容,是为了不让模板里的 `<div>` 之类被 TS 当成类型断言解析,
|
|
185
|
+
* 那会把语法树搅乱(虽然不吞语句,但没人愿意依赖这种巧合)。
|
|
186
|
+
*/
|
|
187
|
+
function scriptSkeleton(source: string, moduleOnly: boolean): string {
|
|
188
|
+
const lower = source.toLowerCase();
|
|
189
|
+
const chars = source.split("");
|
|
190
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
191
|
+
if (chars[index] !== "\n") chars[index] = " ";
|
|
192
|
+
}
|
|
193
|
+
const keep = (from: number, to: number): void => {
|
|
194
|
+
for (let index = from; index < to; index += 1) {
|
|
195
|
+
chars[index] = source.charAt(index);
|
|
196
|
+
}
|
|
197
|
+
};
|
|
198
|
+
let cursor = 0;
|
|
199
|
+
while (cursor < source.length) {
|
|
200
|
+
const open = findTagStart(lower, cursor);
|
|
201
|
+
if (open === -1) break;
|
|
202
|
+
const tagEnd = source.indexOf(">", open);
|
|
203
|
+
if (tagEnd === -1) break;
|
|
204
|
+
const close = lower.indexOf("</script", tagEnd + 1);
|
|
205
|
+
if (close === -1) break;
|
|
206
|
+
const closeEnd = source.indexOf(">", close);
|
|
207
|
+
if (closeEnd === -1) break;
|
|
208
|
+
if (!moduleOnly || hasModuleType(source.slice(open, tagEnd + 1))) {
|
|
209
|
+
keep(tagEnd + 1, close);
|
|
210
|
+
}
|
|
211
|
+
cursor = closeEnd + 1;
|
|
212
|
+
}
|
|
213
|
+
return chars.join("");
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** 找 `<script` 起始位置;标签名后面必须紧跟空白、`>` 或 `/`(排除 `<scripted>`) */
|
|
217
|
+
function findTagStart(lower: string, from: number): number {
|
|
218
|
+
const needle = "<script";
|
|
219
|
+
let index = lower.indexOf(needle, from);
|
|
220
|
+
while (index !== -1) {
|
|
221
|
+
const next = lower.charAt(index + needle.length);
|
|
222
|
+
if (next === "" || next === ">" || next === "/" || isSpace(next)) {
|
|
223
|
+
return index;
|
|
224
|
+
}
|
|
225
|
+
index = lower.indexOf(needle, index + 1);
|
|
226
|
+
}
|
|
227
|
+
return -1;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/** 开标签是否带 `type="module"`(比较前去掉空白,`type = "module"` 同样认) */
|
|
231
|
+
function hasModuleType(tag: string): boolean {
|
|
232
|
+
let compact = "";
|
|
233
|
+
for (const char of tag.toLowerCase()) {
|
|
234
|
+
if (!isSpace(char)) compact += char;
|
|
235
|
+
}
|
|
236
|
+
return compact.includes('type="module"') || compact.includes("type='module'");
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** 空白字符判定(不用正则:这里只比四个码元) */
|
|
240
|
+
function isSpace(char: string): boolean {
|
|
241
|
+
return char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 解析骨架里的 import 面。
|
|
246
|
+
*
|
|
247
|
+
* 语句层取 `ImportDeclaration` / `ExportDeclaration`;随后递归整棵树补齐动态
|
|
248
|
+
* `import()` 与 `require()`——它们同样是运行期依赖,只是不在语句层。
|
|
249
|
+
*/
|
|
250
|
+
function refsIn(text: string, fileName: string): UiImportRef[] {
|
|
251
|
+
const kind = fileName.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
252
|
+
const sf = ts.createSourceFile(
|
|
253
|
+
fileName,
|
|
254
|
+
text,
|
|
255
|
+
ts.ScriptTarget.Latest,
|
|
256
|
+
true,
|
|
257
|
+
kind,
|
|
258
|
+
);
|
|
259
|
+
const refs: UiImportRef[] = [];
|
|
260
|
+
const push = (specifier: string, node: ts.Node, typeOnly: boolean): void => {
|
|
261
|
+
const position = sf.getLineAndCharacterOfPosition(node.getStart(sf));
|
|
262
|
+
refs.push({ specifier, line: position.line + 1, typeOnly });
|
|
263
|
+
};
|
|
264
|
+
for (const statement of sf.statements) {
|
|
265
|
+
if (ts.isImportDeclaration(statement)) {
|
|
266
|
+
if (!ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
267
|
+
push(
|
|
268
|
+
statement.moduleSpecifier.text,
|
|
269
|
+
statement,
|
|
270
|
+
importTypeOnly(statement),
|
|
271
|
+
);
|
|
272
|
+
} else if (ts.isExportDeclaration(statement)) {
|
|
273
|
+
const specifier = statement.moduleSpecifier;
|
|
274
|
+
if (specifier === undefined || !ts.isStringLiteral(specifier)) continue;
|
|
275
|
+
push(specifier.text, statement, statement.isTypeOnly);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
const visit = (node: ts.Node): void => {
|
|
279
|
+
if (ts.isCallExpression(node) && node.arguments.length > 0) {
|
|
280
|
+
const first = node.arguments[0];
|
|
281
|
+
if (
|
|
282
|
+
first !== undefined &&
|
|
283
|
+
ts.isStringLiteralLike(first) &&
|
|
284
|
+
isLoadCall(node)
|
|
285
|
+
) {
|
|
286
|
+
push(first.text, node, false);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
ts.forEachChild(node, visit);
|
|
290
|
+
};
|
|
291
|
+
ts.forEachChild(sf, visit);
|
|
292
|
+
return refs;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 导入子句是否纯类型:`import type { A } from "b"` 或
|
|
297
|
+
* `import { type A, type B } from "c"`(内联修饰符,全部元素都是 type 才算)。
|
|
298
|
+
* 副作用导入(只写路径、不带子句的形态)与命名空间导入不算纯类型。
|
|
299
|
+
*/
|
|
300
|
+
function importTypeOnly(node: ts.ImportDeclaration): boolean {
|
|
301
|
+
const clause = node.importClause;
|
|
302
|
+
if (clause === undefined) return false;
|
|
303
|
+
if (clause.isTypeOnly) return true;
|
|
304
|
+
const bindings = clause.namedBindings;
|
|
305
|
+
if (bindings === undefined || !ts.isNamedImports(bindings)) return false;
|
|
306
|
+
if (bindings.elements.length === 0) return false;
|
|
307
|
+
return bindings.elements.every((element) => element.isTypeOnly);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** 调用是否为模块加载形态:`import(...)` 或 `require(...)` */
|
|
311
|
+
function isLoadCall(node: ts.CallExpression): boolean {
|
|
312
|
+
if (node.expression.kind === ts.SyntaxKind.ImportKeyword) return true;
|
|
313
|
+
return ts.isIdentifier(node.expression) && node.expression.text === "require";
|
|
314
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* UI 名注册表:扫描源根兄弟目录 `ui/<名>/` 下的实现文件,以**目录名**为合法 UI 名。
|
|
3
|
+
*
|
|
4
|
+
* 与字体注册表同构(`fonts/font-registry.ts`):UI 名只在构建链内流转,不含运行时状态,
|
|
5
|
+
* 因此引擎侧不设端口。目录缺失 = 空注册表(未引用 UI 的工程零打扰;引用了即 fail-closed)。
|
|
6
|
+
*
|
|
7
|
+
* 为什么要有这张表:列级 `uis` 里的名字是**静态声明**,写错一个字母编译期毫无反应,
|
|
8
|
+
* 要等宿主预加载落空、界面在挂载瞬间才闪出来。构建期对着目录核一遍,把这类笔误
|
|
9
|
+
* 变成一次构建失败。
|
|
10
|
+
*/
|
|
11
|
+
import { existsSync, readdirSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { compareOrdinal } from "@lingfan-engine/core";
|
|
14
|
+
import {
|
|
15
|
+
UI_DIR_NAME,
|
|
16
|
+
UI_EXTENSIONS,
|
|
17
|
+
type UiRegistryEntry,
|
|
18
|
+
} from "../compiler/story-compiler/graph/core.ts";
|
|
19
|
+
|
|
20
|
+
// 目录名与条目形状的权威声明在图谱层 `core.ts`:UI 通道(导入期)与注册表
|
|
21
|
+
// (扫描期)消费的是同一份事实,两边各写一份就会各自演化。这里只做转发。
|
|
22
|
+
export { UI_DIR_NAME };
|
|
23
|
+
|
|
24
|
+
export type UiFileEntry = UiRegistryEntry;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 扫描 UI 根目录产注册表;目录不存在或为空 = 空数组(合法,等价「本工程没有 UI」)。
|
|
28
|
+
*
|
|
29
|
+
* 收录条件 = 目录下存在 `<目录名>.<UI 扩展名>` 的文件(名字与文件名必须一致,
|
|
30
|
+
* 这条规则同时决定导入的绑定名与文件位置)。只认一层目录:`ui/<名>/<名>.<ext>` 之外
|
|
31
|
+
* 的布局在导入期已被判越界,这里不重复放宽。
|
|
32
|
+
*
|
|
33
|
+
* 名字按码元序稳定排序,保证注册表(与后续诊断顺序)不受文件系统枚举顺序影响。
|
|
34
|
+
*/
|
|
35
|
+
export function scanUiRegistry(uiRoot: string): readonly UiFileEntry[] {
|
|
36
|
+
if (!existsSync(uiRoot)) {
|
|
37
|
+
return [];
|
|
38
|
+
}
|
|
39
|
+
const entries: UiFileEntry[] = [];
|
|
40
|
+
for (const name of readdirSync(uiRoot)) {
|
|
41
|
+
if (name.startsWith(".")) {
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
const dir = join(uiRoot, name);
|
|
45
|
+
let files: string[];
|
|
46
|
+
try {
|
|
47
|
+
files = readdirSync(dir);
|
|
48
|
+
} catch {
|
|
49
|
+
// 不是目录(`ui/README.md` 这类散文件):不构成一个 UI,跳过。
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const dot = file.lastIndexOf(".");
|
|
54
|
+
if (dot <= 0) continue;
|
|
55
|
+
const ext = file.slice(dot).toLowerCase();
|
|
56
|
+
if (!(UI_EXTENSIONS as readonly string[]).includes(ext)) continue;
|
|
57
|
+
if (file.slice(0, dot) !== name) continue;
|
|
58
|
+
entries.push({ name, file });
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
entries.sort((a, b) => compareOrdinal(a.name, b.name));
|
|
63
|
+
return entries;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** UI 名集合视图(供构建期 fail-closed 检查使用) */
|
|
67
|
+
export function uiNameSet(
|
|
68
|
+
entries: readonly UiFileEntry[],
|
|
69
|
+
): ReadonlySet<string> {
|
|
70
|
+
return new Set(entries.map((entry) => entry.name));
|
|
71
|
+
}
|