@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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import { compareOrdinal } from "@lingfan-engine/core";
|
|
3
|
+
import type { SceneType } from "@lingfan-engine/core";
|
|
3
4
|
import { script } from "@lingfan-engine/editor";
|
|
4
5
|
|
|
5
6
|
export interface CompileDiag {
|
|
@@ -49,7 +50,23 @@ export type Binding =
|
|
|
49
50
|
name: string;
|
|
50
51
|
members: ReadonlyMap<string, ConstValue>;
|
|
51
52
|
}
|
|
52
|
-
| { kind: "story"; file: string; name: string }
|
|
53
|
+
| { kind: "story"; file: string; name: string }
|
|
54
|
+
| { kind: "ui"; name: string };
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 一次 UI 导入在模块记录里留下的痕迹:只有一个名字与一条相对路径。
|
|
58
|
+
*
|
|
59
|
+
* 装配期靠 `name` 写列级 `uis` 声明,`relPath` 只用于生成/审计定位;UI 文件本身
|
|
60
|
+
* 从不进图谱、不读内容——句柄是纯静态事实,运行期怎么挂载不归编译期管。
|
|
61
|
+
*/
|
|
62
|
+
export interface UiRecord {
|
|
63
|
+
/** UI 名(= 目录名 = 绑定名 = 文件名去扩展名) */
|
|
64
|
+
name: string;
|
|
65
|
+
/** UI 文件相对 `src/` 的 posix 路径(如 `ui/hub/hub.vue`) */
|
|
66
|
+
relPath: string;
|
|
67
|
+
/** 触发这次登记的导入声明(诊断定位用) */
|
|
68
|
+
node: ts.Node;
|
|
69
|
+
}
|
|
53
70
|
|
|
54
71
|
export interface ModuleRecord {
|
|
55
72
|
rel: string;
|
|
@@ -66,6 +83,10 @@ export interface ModuleRecord {
|
|
|
66
83
|
evaluated?: Readonly<Record<string, unknown>>;
|
|
67
84
|
/** 列级字体声明(`export const columnFont = "<名>"`;仅故事文件合法,null = 未声明) */
|
|
68
85
|
columnFont: { value: string; node: ts.Node } | null;
|
|
86
|
+
/** 列类型声明(`export const columnType = "ui"`;仅故事文件合法,null = 未声明) */
|
|
87
|
+
columnType: { value: SceneType; node: ts.Node } | null;
|
|
88
|
+
/** UI 导入登记:UI 名 → 记录。UI 文件不进图谱,这里只留名字与路径 */
|
|
89
|
+
uis: Map<string, UiRecord>;
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
export interface StoryConfig {
|
|
@@ -84,11 +105,54 @@ export interface Graph {
|
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
export const ROOT_TS_FILES_ALLOWED = new Set(["config.ts"]);
|
|
87
|
-
|
|
108
|
+
/** 生成物目录前缀:权威声明在 `shared/generated-dir.ts`(编译器只是消费方之一) */
|
|
109
|
+
export { GEN_PREFIX } from "../../../shared/index.ts";
|
|
88
110
|
|
|
89
111
|
/** 列级字体声明的保留导出名(`export const columnFont = "<名>"`,仅故事文件顶层) */
|
|
90
112
|
export const COLUMN_FONT_DECL = "columnFont";
|
|
91
113
|
|
|
114
|
+
/** 列类型声明的保留导出名(`export const columnType = "ui"`,仅故事文件顶层) */
|
|
115
|
+
export const COLUMN_TYPE_DECL = "columnType";
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* UI 导入可用的文件扩展名(小写比对)。
|
|
119
|
+
*
|
|
120
|
+
* 只出现在 `src/ui/<名>/<名>.<扩展名>` 这一种形态里;扩展名本身不决定「这是不是 UI」——
|
|
121
|
+
* 相对位置同时要落在 UI 目录下。`.ts` 是特例:故事源自己的 `.ts` 也是这个扩展名,
|
|
122
|
+
* 所以 `.ts` 只有路径合上 `ui/<名>/<名>.ts` 且不在故事源树内时才当 UI,其余照常走模块图。
|
|
123
|
+
*/
|
|
124
|
+
export const UI_EXTENSIONS = [".vue", ".tsx", ".html", ".ts"] as const;
|
|
125
|
+
|
|
126
|
+
/** 说明符的 UI 扩展名(小写);不是 UI 扩展名 ⇒ `undefined` */
|
|
127
|
+
export function uiExtensionOf(specifier: string): string | undefined {
|
|
128
|
+
const lower = specifier.toLowerCase();
|
|
129
|
+
return UI_EXTENSIONS.find((ext) => lower.endsWith(ext));
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** UI 根目录名(源根的兄弟目录,与故事源平级) */
|
|
133
|
+
export const UI_DIR_NAME = "ui";
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* UI 注册表生成物的基名(不带扩展名):生成物是 `<生成物目录>/<基名>.ts`,
|
|
137
|
+
* 故事源以**命名导入**从 `../../gen/ui_register.g` 取句柄(形如 `{ hub }`)。
|
|
138
|
+
*
|
|
139
|
+
* 它没有 UI 扩展名,因此不走 `uiExtensionOf` 那条判定——由 UI 通道单独认这一条形状。
|
|
140
|
+
*/
|
|
141
|
+
export const UI_REGISTER_BASENAME = "ui_register.g";
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* UI 注册表条目:一个 UI 的名字与它的实现文件名。
|
|
145
|
+
*
|
|
146
|
+
* 声明在这里而不是 UI 注册表实现里,是因为图谱层(UI 通道)也要消费它,而
|
|
147
|
+
* 注册表实现反过来依赖本文件的 `UI_EXTENSIONS`——放实现里会成环。
|
|
148
|
+
*/
|
|
149
|
+
export interface UiRegistryEntry {
|
|
150
|
+
/** UI 名(= 目录名 = 文件名去扩展名) */
|
|
151
|
+
readonly name: string;
|
|
152
|
+
/** 实现文件名(含扩展名,如 `hub.vue`) */
|
|
153
|
+
readonly file: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
92
156
|
export const WORD_NAMES: ReadonlySet<string> = new Set(
|
|
93
157
|
Object.keys(script).filter(
|
|
94
158
|
(k) => typeof (script as Record<string, unknown>)[k] === "function",
|
|
@@ -76,6 +76,9 @@ export function foldConst(
|
|
|
76
76
|
}
|
|
77
77
|
if (ts.isIdentifier(expr)) {
|
|
78
78
|
const b = lookup(expr.text);
|
|
79
|
+
// UI 句柄在编译期就是一个名字:`export const picked = hub` 折成 `"hub"`,
|
|
80
|
+
// 于是 `mount(picked, true)` 与 `mount(hub, true)` 的产物完全一致。
|
|
81
|
+
if (b?.kind === "ui") return b.name;
|
|
79
82
|
return b?.kind === "const" ? b.value : undefined;
|
|
80
83
|
}
|
|
81
84
|
if (ts.isPropertyAccessExpression(expr) && ts.isIdentifier(expr.expression)) {
|
|
@@ -11,7 +11,18 @@ export type {
|
|
|
11
11
|
Graph,
|
|
12
12
|
ModuleRecord,
|
|
13
13
|
StoryConfig,
|
|
14
|
+
UiRecord,
|
|
15
|
+
UiRegistryEntry,
|
|
16
|
+
} from "./core.ts";
|
|
17
|
+
export {
|
|
18
|
+
DiagBag,
|
|
19
|
+
toPosix,
|
|
20
|
+
UI_DIR_NAME,
|
|
21
|
+
UI_EXTENSIONS,
|
|
22
|
+
UI_REGISTER_BASENAME,
|
|
23
|
+
uiExtensionOf,
|
|
14
24
|
} from "./core.ts";
|
|
15
|
-
export { DiagBag, toPosix } from "./core.ts";
|
|
16
25
|
export { foldConst } from "./fold.ts";
|
|
17
26
|
export { buildGraph, resolveStart } from "./build.ts";
|
|
27
|
+
export { createUiChannel } from "./scan/ui-specifier.ts";
|
|
28
|
+
export type { UiChannel, UiOutcome } from "./scan/ui-specifier.ts";
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { posix, relative } from "node:path";
|
|
2
|
+
import * as ts from "typescript";
|
|
3
|
+
import type { PathsConfig } from "../../../../pipeline/tsconfig-paths.ts";
|
|
4
|
+
import { expandAlias } from "../../../../pipeline/tsconfig-paths.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 别名说明符解析:把工程 `tsconfig.json` 的 `paths` 展开成源根内的模块键。
|
|
8
|
+
*
|
|
9
|
+
* 只服务作者手写的那一行 import——产物里一律写相对路径,宿主零配置也能跑。判定结果分五种:
|
|
10
|
+
* - `module`:命中且是源根内的 `.ts`,`rel` 是相对源根的 posix 模块键,交给图谱层当普通模块连边;
|
|
11
|
+
* - `other`:命中且在源根内,但不是 `.ts`(如 `.vue`)。调用方据此给出「不是 .ts 模块」诊断;
|
|
12
|
+
* - `outside`:命中但文件在源根之外。创作导入只接受源根内的模块,越界 fail-closed,
|
|
13
|
+
* 不静默丢弃;`target` 是绝对路径,调用方若按更宽的沙箱复判(例如宿主源码)不必重新展开;
|
|
14
|
+
* - `missing`:有模式命中但展开出的候选都不存在——作者写错路径;
|
|
15
|
+
* - `unmatched`:没有任何模式命中——说明符根本不是别名,调用方按既有的外部包判定继续。
|
|
16
|
+
*
|
|
17
|
+
* `missing` 与 `unmatched` 必须分开:混成一个「未命中」会让普通的 npm 导入也背上一句
|
|
18
|
+
* 「别名没命中」的误导诊断,而真正的别名笔误又会被当成「不支持外部包」。
|
|
19
|
+
*
|
|
20
|
+
* 存在性探测走 `ts.sys.fileExists`(自带平台语义),候选按 tsc 的补全习惯依次试
|
|
21
|
+
* 「原样 / `.ts` / `.d.ts` / 目录索引」,命中即止。
|
|
22
|
+
*/
|
|
23
|
+
export interface AliasResolver {
|
|
24
|
+
/** 展开一条非相对说明符;`undefined` = 工程没配 `paths`(调用方退回既有判定) */
|
|
25
|
+
resolveSpecifier(specifier: string): AliasOutcome;
|
|
26
|
+
/**
|
|
27
|
+
* 只定位文件、不判沙箱:返回命中的绝对路径,未命中 ⇒ `undefined`。
|
|
28
|
+
*
|
|
29
|
+
* 用途是沙箱根与源根不同的调用方(UI 文件在 `src/ui/` 而不在 `src/Stories.src/`):
|
|
30
|
+
* 它们拿到绝对路径后按自己的根折算并判越界,不必让别名解析器猜第二套沙箱。
|
|
31
|
+
*/
|
|
32
|
+
locateFile(specifier: string): string | undefined;
|
|
33
|
+
/**
|
|
34
|
+
* 只展开不探测:返回第一个候选的绝对路径,文件存不存在都不影响。
|
|
35
|
+
*
|
|
36
|
+
* 生成物在编译期还没落盘(它是这次构建的产物),但引用它的那条 import 仍然合法;
|
|
37
|
+
* 用存在性探测就会把「第一次构建」判成路径写错。
|
|
38
|
+
*/
|
|
39
|
+
expandTarget(specifier: string): string | undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type AliasOutcome =
|
|
43
|
+
| { readonly kind: "module"; readonly target: string; readonly rel: string }
|
|
44
|
+
| { readonly kind: "other"; readonly target: string; readonly rel: string }
|
|
45
|
+
| { readonly kind: "outside"; readonly target: string }
|
|
46
|
+
| { readonly kind: "missing" }
|
|
47
|
+
| { readonly kind: "unmatched" };
|
|
48
|
+
|
|
49
|
+
/** 候选路径的补全形态(tsc 的模块解析习惯:原样 → .ts → .d.ts → 目录索引) */
|
|
50
|
+
const PROBES = ["", ".ts", ".d.ts", "/index.ts"] as const;
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* 按工程配置造解析器。`config` 为 `null`(无 tsconfig 或无 paths)⇒ 返回 `undefined`,
|
|
54
|
+
* 让调用方保持「只认相对路径」的既有行为。
|
|
55
|
+
*/
|
|
56
|
+
export function createAliasResolver(
|
|
57
|
+
config: PathsConfig | null,
|
|
58
|
+
sourcesDir: string,
|
|
59
|
+
): AliasResolver | undefined {
|
|
60
|
+
if (config === null) return undefined;
|
|
61
|
+
return {
|
|
62
|
+
resolveSpecifier(specifier): AliasOutcome {
|
|
63
|
+
const candidates = expandAlias(config, specifier);
|
|
64
|
+
if (candidates.length === 0) return { kind: "unmatched" };
|
|
65
|
+
const target = locateIn(candidates);
|
|
66
|
+
if (target === undefined) return { kind: "missing" };
|
|
67
|
+
const rel = insideSources(sourcesDir, target);
|
|
68
|
+
if (rel === null) return { kind: "outside", target };
|
|
69
|
+
return rel.endsWith(".ts")
|
|
70
|
+
? { kind: "module", target, rel }
|
|
71
|
+
: { kind: "other", target, rel };
|
|
72
|
+
},
|
|
73
|
+
locateFile(specifier): string | undefined {
|
|
74
|
+
return locateIn(expandAlias(config, specifier));
|
|
75
|
+
},
|
|
76
|
+
expandTarget(specifier): string | undefined {
|
|
77
|
+
return expandAlias(config, specifier)[0];
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 按 tsc 的补全习惯逐个候选探测存在性,命中即止 */
|
|
83
|
+
function locateIn(candidates: readonly string[]): string | undefined {
|
|
84
|
+
for (const candidate of candidates) {
|
|
85
|
+
for (const probe of PROBES) {
|
|
86
|
+
const target = `${candidate}${probe}`;
|
|
87
|
+
if (ts.sys.fileExists(target)) return target;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** 绝对路径 → 相对源根的 posix 路径;越出源根 ⇒ `null` */
|
|
94
|
+
function insideSources(sourcesDir: string, absolute: string): string | null {
|
|
95
|
+
const rel = posix.normalize(
|
|
96
|
+
relative(sourcesDir, absolute).split("\\").join("/"),
|
|
97
|
+
);
|
|
98
|
+
if (rel === "" || rel.startsWith("..") || posix.isAbsolute(rel)) return null;
|
|
99
|
+
return rel;
|
|
100
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import { WORD_NAMES, GEN_PREFIX, toPosix } from "../core.ts";
|
|
3
3
|
import type { ModuleRecord, DiagBag } from "../core.ts";
|
|
4
|
+
import type { AliasResolver } from "./alias.ts";
|
|
5
|
+
import type { UiChannel } from "./ui-specifier.ts";
|
|
4
6
|
|
|
5
7
|
function resolveModuleFile(rel: string, spec: string): string | null {
|
|
6
8
|
const dirParts = toPosix(rel).split("/").slice(0, -1);
|
|
@@ -39,6 +41,8 @@ export function resolveImports(
|
|
|
39
41
|
records: Map<string, ModuleRecord>,
|
|
40
42
|
diags: DiagBag,
|
|
41
43
|
editorPass: boolean,
|
|
44
|
+
alias?: AliasResolver,
|
|
45
|
+
uiChannel?: UiChannel,
|
|
42
46
|
): void {
|
|
43
47
|
const { sf, rel } = record;
|
|
44
48
|
for (const imp of record.imports) {
|
|
@@ -96,21 +100,64 @@ export function resolveImports(
|
|
|
96
100
|
diags.add(rel, sf, imp, "引擎包不参与创作导入:请使用词汇层词函数");
|
|
97
101
|
continue;
|
|
98
102
|
}
|
|
103
|
+
// UI 说明符已由 UI 通道处置(登记或诊断),这里只负责不再按模块解析它——
|
|
104
|
+
// 否则 `.vue`/`.tsx`/`.html` 会落到「不支持外部包」或「必须解析到相对 .ts 文件」。
|
|
105
|
+
if (
|
|
106
|
+
uiChannel !== undefined &&
|
|
107
|
+
uiChannel.classify(rel, spec).kind !== "not-ui"
|
|
108
|
+
)
|
|
109
|
+
continue;
|
|
110
|
+
let targetRel: string | null;
|
|
99
111
|
if (!spec.startsWith(".")) {
|
|
100
|
-
|
|
112
|
+
// 非相对说明符先问工程别名:引擎包(editor/core)已在上面的分支判定完毕,
|
|
113
|
+
// 这里只剩作者自己在 tsconfig.json 里定义的 paths。
|
|
114
|
+
const outcome = alias?.resolveSpecifier(spec);
|
|
115
|
+
if (outcome?.kind === "missing") {
|
|
101
116
|
diags.add(
|
|
102
117
|
rel,
|
|
103
118
|
sf,
|
|
104
119
|
imp,
|
|
105
|
-
|
|
120
|
+
`已读 tsconfig.json 的 paths,未命中 \`${spec}\``,
|
|
106
121
|
);
|
|
107
122
|
continue;
|
|
108
123
|
}
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
124
|
+
if (outcome?.kind === "other") {
|
|
125
|
+
diags.add(
|
|
126
|
+
rel,
|
|
127
|
+
sf,
|
|
128
|
+
imp,
|
|
129
|
+
`别名 \`${spec}\` 解析到 ${outcome.rel}:创作导入只接受 .ts 模块`,
|
|
130
|
+
);
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (outcome?.kind === "outside") {
|
|
134
|
+
// 越界的别名一律 fail-closed:静默丢弃会让作者以为引用生效了。
|
|
135
|
+
diags.add(
|
|
136
|
+
rel,
|
|
137
|
+
sf,
|
|
138
|
+
imp,
|
|
139
|
+
`别名 \`${spec}\` 解析到源根之外(${outcome.target}):创作导入只接受源根内的文件`,
|
|
140
|
+
);
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (outcome?.kind !== "module") {
|
|
144
|
+
if (record.evaluated === undefined) {
|
|
145
|
+
diags.add(
|
|
146
|
+
rel,
|
|
147
|
+
sf,
|
|
148
|
+
imp,
|
|
149
|
+
`导入仅限相对路径与词汇层既有出口,不支持外部包 \`${spec}\``,
|
|
150
|
+
);
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// 经编译期求值的文件允许 node_modules 值导入——值经求值结果内联,
|
|
154
|
+
// 引用域仅限模块层 const 初始化链(buildGraph 末尾 assertNpmNamesConstOnly 兜底)。
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
targetRel = outcome.rel;
|
|
158
|
+
} else {
|
|
159
|
+
targetRel = resolveModuleImportPath(rel, spec);
|
|
112
160
|
}
|
|
113
|
-
const targetRel = resolveModuleImportPath(rel, spec);
|
|
114
161
|
if (targetRel === null) {
|
|
115
162
|
diags.add(rel, sf, imp, `模块说明符 \`${spec}\` 必须解析到相对 .ts 文件`);
|
|
116
163
|
continue;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as ts from "typescript";
|
|
2
2
|
import { scanEnum } from "../fold.ts";
|
|
3
|
+
import { uiExtensionOf } from "../core.ts";
|
|
3
4
|
import type { Binding, DiagBag, ModuleRecord } from "../core.ts";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -26,6 +27,10 @@ function collectNpmNames(
|
|
|
26
27
|
spec === "@lingfan-engine/core"
|
|
27
28
|
)
|
|
28
29
|
return;
|
|
30
|
+
// UI 说明符带 `.vue`/`.tsx`/`.html` 这类扩展名(`.ts` 也只在 `ui/<名>/<名>.ts`
|
|
31
|
+
// 这一种形状上算 UI),它们由 UI 通道处置,不是 npm 包名——登记进来会让
|
|
32
|
+
// 「npm 值导入只能出现在模块层 const」的检查误报。
|
|
33
|
+
if (uiExtensionOf(spec) !== undefined) return;
|
|
29
34
|
const clause = imp.importClause;
|
|
30
35
|
if (clause === undefined || clause.isTypeOnly) return;
|
|
31
36
|
if (clause.name !== undefined) record.npmNames.add(clause.name.text);
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import * as ts from "typescript";
|
|
2
|
+
import { posix } from "node:path";
|
|
3
|
+
import type { StoriesBuildTexts } from "../../../../shared/index.ts";
|
|
4
|
+
import { UI_DIR_NAME, type DiagBag, type ModuleRecord } from "../core.ts";
|
|
5
|
+
import type { UiChannel } from "./ui-specifier.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* UI 导入通道的落账一段:把模块里每条 UI 说明符登记成「一个 UI + 一个句柄绑定」。
|
|
9
|
+
*
|
|
10
|
+
* 这一遍必须**先于模块层常量扫描**跑:`export const picked = hub` 要在扫描时就能把
|
|
11
|
+
* `hub` 折成 `"hub"`,否则折叠看不到 UI 绑定。它也独立于模块图的两遍导入解析——
|
|
12
|
+
* UI 文件从不进 `records`(那里是会参与连边与常量求值的 TS 模块,`.vue` 进去会被当
|
|
13
|
+
* TS 解析),句柄是纯静态事实。
|
|
14
|
+
*
|
|
15
|
+
* 两种写法:default 导入 UI 文件,或命名导入 UI 注册表。前者三件必须同时成立——
|
|
16
|
+
* 落在源根的兄弟目录 `ui/<名>/` 内、default 导入、绑定名等于目录名——任一不成立都
|
|
17
|
+
* fail-closed:UI 名字是全工程的键(列声明、生成物、运行期挂载都用它),静默接受一个
|
|
18
|
+
* 歪名字会让后面每一步都拿着不同的名字。后者见 `bindRegisterImport`。
|
|
19
|
+
*/
|
|
20
|
+
export function resolveUiImports(
|
|
21
|
+
record: ModuleRecord,
|
|
22
|
+
diags: DiagBag,
|
|
23
|
+
channel: UiChannel,
|
|
24
|
+
texts: StoriesBuildTexts,
|
|
25
|
+
): void {
|
|
26
|
+
for (const imp of record.imports) {
|
|
27
|
+
const spec = ts.isStringLiteral(imp.moduleSpecifier)
|
|
28
|
+
? imp.moduleSpecifier.text
|
|
29
|
+
: "";
|
|
30
|
+
bindUiImport(record, imp, spec, diags, channel, texts);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** 单条说明符的判定与登记;不归 UI 管时什么都不做(交回模块图)。 */
|
|
35
|
+
function bindUiImport(
|
|
36
|
+
record: ModuleRecord,
|
|
37
|
+
imp: ts.ImportDeclaration,
|
|
38
|
+
spec: string,
|
|
39
|
+
diags: DiagBag,
|
|
40
|
+
channel: UiChannel,
|
|
41
|
+
texts: StoriesBuildTexts,
|
|
42
|
+
): void {
|
|
43
|
+
const outcome = channel.classify(record.rel, spec);
|
|
44
|
+
if (outcome.kind === "not-ui") return;
|
|
45
|
+
const { sf, rel } = record;
|
|
46
|
+
if (outcome.kind === "outside") {
|
|
47
|
+
diags.add(rel, sf, imp, texts.uiOutside(spec, channel.sourceRootName));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (outcome.kind === "file-name") {
|
|
51
|
+
diags.add(
|
|
52
|
+
rel,
|
|
53
|
+
sf,
|
|
54
|
+
imp,
|
|
55
|
+
texts.uiFileName(outcome.name, posix.basename(outcome.relPath)),
|
|
56
|
+
);
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
if (outcome.kind === "register") {
|
|
60
|
+
bindRegisterImport(record, imp, diags, channel, texts);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
const clause = imp.importClause;
|
|
64
|
+
// 类型导入不产生运行期绑定,也没有句柄可用;`import type` 交给 tsc 自己判。
|
|
65
|
+
if (clause?.isTypeOnly === true) return;
|
|
66
|
+
if (clause?.name === undefined || clause.namedBindings !== undefined) {
|
|
67
|
+
diags.add(rel, sf, clause ?? imp, texts.uiDefaultImport(outcome.name));
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
if (clause.name.text !== outcome.name) {
|
|
71
|
+
diags.add(
|
|
72
|
+
rel,
|
|
73
|
+
sf,
|
|
74
|
+
clause.name,
|
|
75
|
+
texts.uiBindingName(outcome.name, clause.name.text),
|
|
76
|
+
);
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
record.uis.set(outcome.name, {
|
|
80
|
+
name: outcome.name,
|
|
81
|
+
relPath: outcome.relPath,
|
|
82
|
+
node: imp,
|
|
83
|
+
});
|
|
84
|
+
record.bindings.set(clause.name.text, { kind: "ui", name: outcome.name });
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* UI 注册表的命名导入:从注册表说明符取句柄(形如 `{ hub }`)。
|
|
89
|
+
*
|
|
90
|
+
* 名字来自注册表键,而不是说明符本身——一条注册表说明符能同时供出所有 UI 句柄,
|
|
91
|
+
* 所以必须逐个导入名去注册表里查。查不到就是笔误,报错而不是静默跳过。
|
|
92
|
+
*
|
|
93
|
+
* 与 default 导入的差异只有一处:这里**不要求**绑定名等于 UI 名(`import { hub as h }`
|
|
94
|
+
* 合法)。绑定名只是本地别名,折叠用的是 UI 名,落账时两者分开记。
|
|
95
|
+
*/
|
|
96
|
+
function bindRegisterImport(
|
|
97
|
+
record: ModuleRecord,
|
|
98
|
+
imp: ts.ImportDeclaration,
|
|
99
|
+
diags: DiagBag,
|
|
100
|
+
channel: UiChannel,
|
|
101
|
+
texts: StoriesBuildTexts,
|
|
102
|
+
): void {
|
|
103
|
+
const { sf, rel } = record;
|
|
104
|
+
const clause = imp.importClause;
|
|
105
|
+
// 副作用导入不取任何句柄,写在这里只可能是笔误。
|
|
106
|
+
if (clause === undefined) {
|
|
107
|
+
diags.add(rel, sf, imp, texts.uiRegisterSideEffect());
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
// 类型导入不产生运行期绑定,也没有句柄可用。
|
|
111
|
+
if (clause.isTypeOnly) return;
|
|
112
|
+
const named = clause.namedBindings;
|
|
113
|
+
// 注册表只有具名导出:default 与命名空间导入都取不到句柄。
|
|
114
|
+
if (clause.name !== undefined || named === undefined) {
|
|
115
|
+
diags.add(rel, sf, clause, texts.uiRegisterNoDefault());
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
if (ts.isNamespaceImport(named)) {
|
|
119
|
+
diags.add(rel, sf, named, texts.uiRegisterNoDefault());
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const element of named.elements) {
|
|
123
|
+
const imported = element.propertyName?.text ?? element.name.text;
|
|
124
|
+
const entry = channel.registry.find((item) => item.name === imported);
|
|
125
|
+
if (entry === undefined) {
|
|
126
|
+
diags.add(rel, sf, element, texts.uiRegisterMissing(imported));
|
|
127
|
+
continue;
|
|
128
|
+
}
|
|
129
|
+
record.uis.set(entry.name, {
|
|
130
|
+
name: entry.name,
|
|
131
|
+
relPath: posix.join(UI_DIR_NAME, entry.name, entry.file),
|
|
132
|
+
node: imp,
|
|
133
|
+
});
|
|
134
|
+
record.bindings.set(element.name.text, { kind: "ui", name: entry.name });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { posix } from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
GEN_PREFIX,
|
|
4
|
+
toPosix,
|
|
5
|
+
uiExtensionOf,
|
|
6
|
+
UI_REGISTER_BASENAME,
|
|
7
|
+
type UiRegistryEntry,
|
|
8
|
+
} from "../core.ts";
|
|
9
|
+
import type { AliasResolver } from "./alias.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* UI 导入通道:把一条 import 说明符判成「指向某个 UI」还是「不归 UI 管」。
|
|
13
|
+
*
|
|
14
|
+
* UI 文件与故事源是同一棵树上的兄弟目录——故事源在 `src/Stories.src/`,UI 在
|
|
15
|
+
* `src/ui/<名>/<名>.<扩展名>`。因此这里的沙箱根是 `src/`(源根的上一级),比模块导入
|
|
16
|
+
* 的沙箱更宽:模块导入只认源根内,UI 恰好必须落在源根外。
|
|
17
|
+
*
|
|
18
|
+
* 一个说明符要同时满足三件事才算 UI:
|
|
19
|
+
* - 落在 `src/ui/` 下(越过 `src/` 或落在别处一律 fail-closed,不静默丢弃);
|
|
20
|
+
* - 相对 `src/ui/` 恰两层:`<目录>/<文件>`;
|
|
21
|
+
* - 文件名去扩展名 = 目录名(名字 = 目录名,同一条规则决定绑定名与文件位置)。
|
|
22
|
+
*
|
|
23
|
+
* `.ts` 额外收紧:故事源自己也是 `.ts`,所以只有位置完全合上 `ui/<名>/<名>.ts` 才当 UI;
|
|
24
|
+
* 其余 `.ts` 说明符一律交回模块图,免得把源树里的列文件当成 UI。
|
|
25
|
+
*/
|
|
26
|
+
export interface UiChannel {
|
|
27
|
+
/** 源根目录名(如 `Stories.src`),诊断文案里用来指认「UI 是它的兄弟目录」 */
|
|
28
|
+
readonly sourceRootName: string;
|
|
29
|
+
/** UI 注册表(扫描期产物):命名导入注册表时按名字查条目 */
|
|
30
|
+
readonly registry: readonly UiRegistryEntry[];
|
|
31
|
+
/**
|
|
32
|
+
* 判定一条说明符。`fromRel` 是发起导入的文件相对**源根**的路径(如
|
|
33
|
+
* `title/title_main.ts`),`spec` 是原样的说明符文本。
|
|
34
|
+
*/
|
|
35
|
+
classify(fromRel: string, spec: string): UiOutcome;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type UiOutcome =
|
|
39
|
+
/** 合上 `ui/<名>/<名>.<扩展名>`:`relPath` 相对 `src/`,`name` 即目录名 */
|
|
40
|
+
| { readonly kind: "ui"; readonly name: string; readonly relPath: string }
|
|
41
|
+
/** 在 `src/ui/` 之外(越出 `src/`、层级不对、或被引到别处)——fail-closed */
|
|
42
|
+
| { readonly kind: "outside"; readonly target: string }
|
|
43
|
+
/** 落位合规但文件名与目录名不一致——fail-closed */
|
|
44
|
+
| {
|
|
45
|
+
readonly kind: "file-name";
|
|
46
|
+
readonly name: string;
|
|
47
|
+
readonly relPath: string;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* UI 注册表生成物(`<生成物目录>/ui_register.g`):句柄经**命名导入**取,
|
|
51
|
+
* 名字来自注册表键而不是目录名,故与上面四支分开处置。
|
|
52
|
+
*/
|
|
53
|
+
| { readonly kind: "register" }
|
|
54
|
+
/** 不归 UI 管:不是 UI 扩展名,或 `.ts` 位置不合规,交回模块图判定 */
|
|
55
|
+
| { readonly kind: "not-ui" };
|
|
56
|
+
|
|
57
|
+
const UI_DIR = "ui";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* 造通道。`sourcesDir` 是源根绝对路径(`<工程>/src/Stories.src`),沙箱根由它推出
|
|
61
|
+
* `<工程>/src`——调用方不必额外传一个「UI 根」,两处目录天生是父子。
|
|
62
|
+
*
|
|
63
|
+
* `registry` 是 UI 名注册表(`scanUiRegistry` 的产物):注册表说明符本身不带名字,
|
|
64
|
+
* 具名句柄要拿导入名去这张表里查,才知它对不对得上一个真实 UI 文件。
|
|
65
|
+
*/
|
|
66
|
+
export function createUiChannel(
|
|
67
|
+
sourcesDir: string,
|
|
68
|
+
alias?: AliasResolver,
|
|
69
|
+
registry: readonly UiRegistryEntry[] = [],
|
|
70
|
+
): UiChannel {
|
|
71
|
+
const sourceRootName = posix.basename(toPosix(sourcesDir));
|
|
72
|
+
const srcRoot = posix.dirname(toPosix(sourcesDir));
|
|
73
|
+
return {
|
|
74
|
+
sourceRootName,
|
|
75
|
+
registry,
|
|
76
|
+
classify(fromRel, spec): UiOutcome {
|
|
77
|
+
// 注册表形状先判:它可能带 `.ts` 后缀,而 `.ts` 同时也是故事源自己的扩展名,
|
|
78
|
+
// 走扩展名判定会被当成普通模块交回模块图。两者的区别不在扩展名,在位置——
|
|
79
|
+
// 只有生成物目录下那一个文件才算注册表,别处的同名文件仍交回模块图。
|
|
80
|
+
if (isRegisterSpecifier(spec)) {
|
|
81
|
+
const located = locate(
|
|
82
|
+
srcRoot,
|
|
83
|
+
sourceRootName,
|
|
84
|
+
fromRel,
|
|
85
|
+
spec,
|
|
86
|
+
alias,
|
|
87
|
+
true,
|
|
88
|
+
);
|
|
89
|
+
if (located === undefined) return { kind: "not-ui" };
|
|
90
|
+
return judgeRegister(located, sourceRootName);
|
|
91
|
+
}
|
|
92
|
+
const extension = uiExtensionOf(spec);
|
|
93
|
+
if (extension === undefined) return { kind: "not-ui" };
|
|
94
|
+
const located = locate(srcRoot, sourceRootName, fromRel, spec, alias);
|
|
95
|
+
if (located === undefined) return { kind: "not-ui" };
|
|
96
|
+
return judge(located, sourceRootName, extension);
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 说明符的文件名(去 `.ts`)是否就是注册表生成物的基名。
|
|
103
|
+
*
|
|
104
|
+
* 生成物在故事源里既可能写成不带扩展名的 `<基名>`(工程配置把 `.g` 映到生成物),
|
|
105
|
+
* 也可能显式带 `.ts`;两者指同一个文件,判定必须一视同仁。
|
|
106
|
+
*/
|
|
107
|
+
function isRegisterSpecifier(spec: string): boolean {
|
|
108
|
+
const base = posix.basename(toPosix(spec));
|
|
109
|
+
return base.replace(/\.ts$/, "") === UI_REGISTER_BASENAME;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 注册表说明符的形状判定:只认生成物目录下那一个位置(`src/<生成物目录>/ui_register.g`)。
|
|
114
|
+
*
|
|
115
|
+
* 越出 `src/` 是越界(与 UI 说明符同样 fail-closed);落在别处则只是「恰好同名」,
|
|
116
|
+
* 交回模块图——别的目录下的同名文件不归 UI 通道管。
|
|
117
|
+
*
|
|
118
|
+
* 落在**源根之内**同理交回模块图:生成物是源根的兄弟目录,源树里那份 `gen/` 归模块
|
|
119
|
+
* 图管(那里有「生成物不参与创作导入」的既有判定),两者不能混为一谈。
|
|
120
|
+
*
|
|
121
|
+
* `locate` 对相对写法与别名写法都会折成「相对 `src/`」的路径,但别名展开会带 `.ts`、
|
|
122
|
+
* 相对写法不带,故判定时再去一次扩展名。
|
|
123
|
+
*/
|
|
124
|
+
function judgeRegister(located: string, sourceRootName: string): UiOutcome {
|
|
125
|
+
if (located.startsWith("..") || posix.isAbsolute(located)) {
|
|
126
|
+
return { kind: "outside", target: located };
|
|
127
|
+
}
|
|
128
|
+
if (located === sourceRootName || located.startsWith(`${sourceRootName}/`)) {
|
|
129
|
+
return { kind: "not-ui" };
|
|
130
|
+
}
|
|
131
|
+
const expected = `${GEN_PREFIX}${UI_REGISTER_BASENAME}`;
|
|
132
|
+
return located.replace(/\.ts$/, "") === expected
|
|
133
|
+
? { kind: "register" }
|
|
134
|
+
: { kind: "not-ui" };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 折成「相对 `src/` 的 posix 路径」:相对说明符从发起文件的目录起算(此时要补回
|
|
139
|
+
* 源根那一段前缀,因为 `fromRel` 不含它);非相对说明符先问别名表拿绝对路径,再折算。
|
|
140
|
+
*
|
|
141
|
+
* 返回 `undefined` 表示这里判不了(别名未配/未命中),交回模块图按原路径报错。
|
|
142
|
+
*
|
|
143
|
+
* `existenceFree` 关掉别名解析里的存在性探测:注册表生成物是本次构建的产物,编译时
|
|
144
|
+
* 它还没落盘,用探测会把「第一次构建」误判成路径写错。此时只取别名展开的第一个候选,
|
|
145
|
+
* 是否真有这个文件交给写入阶段决定。
|
|
146
|
+
*/
|
|
147
|
+
function locate(
|
|
148
|
+
srcRoot: string,
|
|
149
|
+
sourceRootName: string,
|
|
150
|
+
fromRel: string,
|
|
151
|
+
spec: string,
|
|
152
|
+
alias?: AliasResolver,
|
|
153
|
+
existenceFree = false,
|
|
154
|
+
): string | undefined {
|
|
155
|
+
if (spec.startsWith(".")) {
|
|
156
|
+
return posix.join(sourceRootName, posix.dirname(toPosix(fromRel)), spec);
|
|
157
|
+
}
|
|
158
|
+
const target =
|
|
159
|
+
alias?.locateFile(spec) ??
|
|
160
|
+
(existenceFree ? alias?.expandTarget(spec) : undefined);
|
|
161
|
+
if (target === undefined) return undefined;
|
|
162
|
+
return posix.relative(srcRoot, toPosix(target));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* 形状判定:先看沙箱与层级,再看文件名是否等于目录名。
|
|
167
|
+
*
|
|
168
|
+
* `.ts` 与其余扩展名分开收口——`.ts` 同时也是故事源自己的扩展名,位置不合规时它只是
|
|
169
|
+
* 一个普通模块说明符,必须原样交回模块图(那里有源根沙箱与更准确的诊断);其余扩展名
|
|
170
|
+
* 在本设计里只可能是 UI,位置不合规就是越界,fail-closed。
|
|
171
|
+
*/
|
|
172
|
+
function judge(
|
|
173
|
+
located: string,
|
|
174
|
+
sourceRootName: string,
|
|
175
|
+
extension: string,
|
|
176
|
+
): UiOutcome {
|
|
177
|
+
if (located.startsWith("..") || posix.isAbsolute(located)) {
|
|
178
|
+
return { kind: "outside", target: located };
|
|
179
|
+
}
|
|
180
|
+
// 源根自己可能就叫 `ui`(sourcesDir 配成 `src/ui`)——那样 `ui/` 前缀指的是源树内部,
|
|
181
|
+
// 不是 UI 目录,必须交回模块图。
|
|
182
|
+
if (located === sourceRootName || located.startsWith(`${sourceRootName}/`)) {
|
|
183
|
+
return extension === ".ts"
|
|
184
|
+
? { kind: "not-ui" }
|
|
185
|
+
: { kind: "outside", target: located };
|
|
186
|
+
}
|
|
187
|
+
const parts = located.split("/");
|
|
188
|
+
const shapeOk = parts.length === 3 && parts[0] === UI_DIR;
|
|
189
|
+
if (!shapeOk) {
|
|
190
|
+
return extension === ".ts"
|
|
191
|
+
? { kind: "not-ui" }
|
|
192
|
+
: { kind: "outside", target: located };
|
|
193
|
+
}
|
|
194
|
+
const [, name, fileName] = parts as [string, string, string];
|
|
195
|
+
const base = fileName.slice(0, -extension.length);
|
|
196
|
+
if (base !== name) {
|
|
197
|
+
return extension === ".ts"
|
|
198
|
+
? { kind: "not-ui" }
|
|
199
|
+
: { kind: "file-name", name, relPath: located };
|
|
200
|
+
}
|
|
201
|
+
return { kind: "ui", name, relPath: located };
|
|
202
|
+
}
|