@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.
Files changed (55) hide show
  1. package/package.json +4 -3
  2. package/src/authoring.d.ts +31 -0
  3. package/src/cell/imports.ts +21 -7
  4. package/src/cell/index.ts +3 -2
  5. package/src/cell/scan-cells.ts +10 -1
  6. package/src/cell/scan-file.ts +11 -1
  7. package/src/cell/types.ts +10 -0
  8. package/src/cli/cli.ts +49 -4
  9. package/src/compiler/cell-scan.ts +12 -2
  10. package/src/compiler/compiler-contract.ts +12 -0
  11. package/src/compiler/story-compiler/assemble/mount-uis.ts +43 -0
  12. package/src/compiler/story-compiler/assemble/session.ts +30 -0
  13. package/src/compiler/story-compiler/graph/build.ts +86 -14
  14. package/src/compiler/story-compiler/graph/core.ts +66 -2
  15. package/src/compiler/story-compiler/graph/fold.ts +3 -0
  16. package/src/compiler/story-compiler/graph/index.ts +12 -1
  17. package/src/compiler/story-compiler/graph/scan/alias.ts +100 -0
  18. package/src/compiler/story-compiler/graph/scan/imports.ts +53 -6
  19. package/src/compiler/story-compiler/graph/scan/module-scan.ts +5 -0
  20. package/src/compiler/story-compiler/graph/scan/ui-import.ts +136 -0
  21. package/src/compiler/story-compiler/graph/scan/ui-specifier.ts +202 -0
  22. package/src/compiler/story-compiler/graph/state-root.ts +1 -1
  23. package/src/compiler/story-compiler/graph.ts +1 -0
  24. package/src/compiler/story-compiler/index.ts +1 -0
  25. package/src/compiler/story-compiler/translate/expr/words/arg.ts +5 -0
  26. package/src/compiler/story-compiler.ts +11 -0
  27. package/src/config/story-config.ts +6 -2
  28. package/src/const-eval/const-eval-pass.ts +18 -3
  29. package/src/discovery/source-discovery.ts +8 -3
  30. package/src/emit/emit.ts +11 -1
  31. package/src/host-plugins/index.ts +11 -0
  32. package/src/host-plugins/virtual-modules.ts +138 -0
  33. package/src/index.ts +20 -0
  34. package/src/pipeline/gates.ts +73 -2
  35. package/src/pipeline/index.ts +2 -0
  36. package/src/pipeline/merge.ts +132 -0
  37. package/src/pipeline/orchestrate.ts +101 -126
  38. package/src/pipeline/tsconfig-paths.ts +196 -0
  39. package/src/pipeline/watch.ts +4 -3
  40. package/src/shared/generated-dir.ts +31 -0
  41. package/src/shared/index.ts +9 -5
  42. package/src/shared/limits.ts +17 -0
  43. package/src/shared/texts.ts +41 -0
  44. package/src/story-list/index.ts +2 -0
  45. package/src/story-list/story-registry.ts +55 -0
  46. package/src/typegen/font-render.ts +1 -1
  47. package/src/typegen/index.ts +1 -1
  48. package/src/typegen/render.ts +1 -1
  49. package/src/typegen/script-render.ts +1 -1
  50. package/src/typegen/state-enum-ast.ts +1 -1
  51. package/src/typegen/state-render.ts +34 -8
  52. package/src/ui/index.ts +9 -0
  53. package/src/ui/render-register.ts +114 -0
  54. package/src/ui/scan-imports.ts +314 -0
  55. package/src/ui/ui-registry.ts +71 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lingfan-engine/stories-build",
3
- "version": "0.1.5",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "description": "Stories 创作链编译包:把 Stories.src 的多目录原生 TS 源编译成 Resources/Stories/** + project.json,并产出 cell 注册生成物",
6
6
  "license": "Apache-2.0",
@@ -9,6 +9,7 @@
9
9
  "./authoring": "./src/authoring.d.ts",
10
10
  "./cell": "./src/cell/index.ts",
11
11
  "./cli": "./src/cli/cli.ts",
12
+ "./host-plugins": "./src/host-plugins/index.ts",
12
13
  "./watch": "./src/pipeline/watch.ts",
13
14
  "./watch-cli": "./src/cli/watch-cli.ts"
14
15
  },
@@ -23,8 +24,8 @@
23
24
  ],
24
25
  "sideEffects": false,
25
26
  "dependencies": {
26
- "@lingfan-engine/core": "^0.1.5",
27
- "@lingfan-engine/editor": "^0.1.5",
27
+ "@lingfan-engine/core": "^0.3.0",
28
+ "@lingfan-engine/editor": "^0.3.0",
28
29
  "esbuild": "^0.28.2",
29
30
  "tsx": "^4.23.15",
30
31
  "typescript": "~6.0.3"
@@ -13,8 +13,16 @@
13
13
  * 全局声明供 config.ts 书写与 tsc 通过。
14
14
  * - `StoriesConfig`:config.ts default 对象形态的类型(declare global,零 import 全局可用);
15
15
  * `satisfies StoriesConfig` 给 default 导出强校验与 IDE 键名补全。
16
+ * - `*.vue` / `*.tsx` / `*.html`:UI 单文件的模块形状。故事源用一行 import
17
+ * (说明符指向 `ui/<名>/<名>.<扩展名>`)拿到句柄,编译器只记名字、不读文件;
18
+ * tsc 侧则要有人告诉它「这类文件导出一个 UiHandle」,否则每个 UI 导入都是一条
19
+ * TS2307。`.ts` UI 不需要这一段——它本来就是 TS 模块,类型由文件自己提供。
16
20
  */
17
21
  import type { StoryCommand } from "@lingfan-engine/core";
22
+ import type { UiHandle } from "@lingfan-engine/editor";
23
+
24
+ /** UI 句柄形状的转发出口:唯一声明处是 `@lingfan-engine/editor` 的词汇层,此处不重复声明。 */
25
+ export type { UiHandle };
18
26
 
19
27
  export interface StateRoot {
20
28
  // 宽索引签名是未接 gen 桥工程的缺省回退:gen/state.gen.d.ts 已提供精确 StoryState,
@@ -48,4 +56,27 @@ declare global {
48
56
  /** 这个是你需要注册的状态,注意它对应的是你 `state` 的这个 state,你也可以define,都可以的 */
49
57
  readonly state?: Record<string, unknown>;
50
58
  }
59
+
60
+ /**
61
+ * UI 单文件通配模块:三种非 TS 扩展名各声明一次。
62
+ *
63
+ * 写成 `declare global { module "…" }` 而不是裸 `declare module "…"`:本文件是模块
64
+ *(有 import/export),裸 `declare module` 在模块文件里是「模块增强」,找不到可增强
65
+ * 的目标就静默失效 —— 表现为每个 UI 导入仍报 TS2307。放进 `declare global` 才是
66
+ * 真正的全局通配声明。
67
+ *
68
+ * `.ts` 有意不在这里:TS 文件的类型由它自己提供,通配拦过去只会盖掉真实类型。
69
+ */
70
+ module "*.vue" {
71
+ const handle: UiHandle;
72
+ export default handle;
73
+ }
74
+ module "*.tsx" {
75
+ const handle: UiHandle;
76
+ export default handle;
77
+ }
78
+ module "*.html" {
79
+ const handle: UiHandle;
80
+ export default handle;
81
+ }
51
82
  }
@@ -53,13 +53,19 @@ export function lineOf(sf: ts.SourceFile, node: ts.Node): number {
53
53
  }
54
54
 
55
55
  /**
56
- * 相对说明符重写:源文件(相对 Stories.src 的目录 `fromDir`)→ 生成物(`gen/`,恰深一层)。
57
- * 解析到 Stories.src 内的规范相对路径后加 `../` 前缀;越出根 = null(调用方 fail-closed);
56
+ * 相对说明符重写:源文件(相对源根的目录 `fromDir`)→ 生成物所在目录。
57
+ *
58
+ * `rootPrefix` = 从生成物目录回到源根的相对路径(默认布局下 `"../Stories.src"`:
59
+ * 生成物在 `src/gen/`,源根在 `src/Stories.src/`,两者同级)。缺省 `".."` = 生成物
60
+ * 就在源根内一层(旧布局)。调用方按工程布局算好传进来,本函数不猜目录。
61
+ *
62
+ * 解析到源根内的规范相对路径后拼前缀;越出根 = null(调用方 fail-closed);
58
63
  * 裸说明符(包名)原样。
59
64
  */
60
65
  export function adjustSpecifier(
61
66
  specifier: string,
62
67
  fromDir: string,
68
+ rootPrefix = "..",
63
69
  ): string | null {
64
70
  if (!specifier.startsWith("./") && !specifier.startsWith("../"))
65
71
  return specifier;
@@ -67,22 +73,24 @@ export function adjustSpecifier(
67
73
  for (const part of `${fromDir}/${specifier}`.split("/")) {
68
74
  if (part === "" || part === ".") continue;
69
75
  if (part === "..") {
70
- if (segments.pop() === undefined) return null; // 越出 Stories.src 根
76
+ if (segments.pop() === undefined) return null; // 越出源根
71
77
  continue;
72
78
  }
73
79
  segments.push(part);
74
80
  }
75
81
  if (segments.length === 0) return null;
76
- return `../${segments.join("/")}`;
82
+ return `${rootPrefix}/${segments.join("/")}`;
77
83
  }
78
84
 
79
- /** 文件顶层 import 声明 → 绑定名集 + 「按 gen/ 位置重写过」的语句文本 */
85
+ /** 文件顶层 import 声明 → 绑定名集 + 「按生成物位置重写过」的语句文本 */
80
86
  export function collectFileImports(
81
87
  sf: ts.SourceFile,
82
88
  text: string,
83
89
  fromDir: string,
84
90
  issues: CellIssue[],
85
91
  relPath: string,
92
+ rootPrefix = "..",
93
+ isForeignImport?: (fromRel: string, specifier: string) => boolean,
86
94
  ): FileImports {
87
95
  const statements: Array<{ adjustedText: string; bindings: string[] }> = [];
88
96
  const bindingNames = new Set<string>();
@@ -101,11 +109,17 @@ export function collectFileImports(
101
109
  }
102
110
  }
103
111
  for (const binding of bindings) bindingNames.add(binding);
104
- // 说明符重写(生成物在 gen/,比源文件深一层)
112
+ // `import type` 只进类型面,运行期不存在 ⇒ 不搬运、也不受源根沙箱约束
113
+ // (与编译图的导入扫描同一口径:`clause.isTypeOnly` 先放行)
114
+ if (clause?.isTypeOnly === true) continue;
115
+ // 说明符重写(生成物在源根之外的同级目录,比源文件深一层)
105
116
  const raw = statement.moduleSpecifier;
106
117
  let adjustedText = text.slice(statement.getStart(sf), statement.getEnd());
107
118
  if (ts.isStringLiteralLike(raw)) {
108
- const adjusted = adjustSpecifier(raw.text, fromDir);
119
+ // 由别的通道处置的导入(如 UI 句柄)不搬进生成物,也不受源根沙箱约束:
120
+ // 生成注册物只需要 cell 实现文本,句柄是构建期事实,运行期不再需要那条语句。
121
+ if (isForeignImport?.(relPath, raw.text) === true) continue;
122
+ const adjusted = adjustSpecifier(raw.text, fromDir, rootPrefix);
109
123
  if (adjusted === null) {
110
124
  issues.push({
111
125
  message: `cell 所在文件 import "${raw.text}" 越出 Stories.src——生成物无法搬运该导入`,
package/src/cell/index.ts CHANGED
@@ -10,8 +10,9 @@
10
10
  * - 身份键 = (face, name)、指纹 = 实现文本 trim:**同键同纹去重、同键异纹冲突**
11
11
  * (fail-closed 带两处定位,不隐式覆盖);
12
12
  * - face v1 恒 `guards`;`cell` 只认裸标识符调用(house style 解构导入);
13
- * - **导入搬运按生成物位置重写相对说明符**:生成物在 `Stories.src/gen/`(比源文件
14
- * 深一层),`"./x"`/`"../x"` 必须换算到同一目标——越出 `Stories.src` 根 fail-closed。
13
+ * - **导入搬运按生成物位置重写相对说明符**:生成物目录通常与源根同级(默认布局
14
+ * `src/gen/` 对 `src/Stories.src/`),`"./x"`/`"../x"` 由调用方给出的 `rootPrefix`
15
+ * 换算到同一目标——越出源根 fail-closed。
15
16
  */
16
17
 
17
18
  /** cell 声明提取失败(带 `origin` 定位:`相对路径:行`)——由调用方决定是否中断构建 */
@@ -12,6 +12,8 @@ export function scanCells(
12
12
  options: ScanCellsOptions = {},
13
13
  ): { errors: CellIssue[]; scan: CellScan } {
14
14
  const globalWhitelist = options.globalWhitelist ?? DEFAULT_GLOBAL_WHITELIST;
15
+ const rootPrefix = options.rootPrefix ?? "..";
16
+ const isForeignImport = options.isForeignImport;
15
17
  const issues: CellIssue[] = [];
16
18
  const byName = new Map<
17
19
  string,
@@ -21,7 +23,14 @@ export function scanCells(
21
23
  const seenImports = new Set<string>();
22
24
 
23
25
  for (const [relPath, text] of files) {
24
- for (const found of scanFile(relPath, text, globalWhitelist, issues)) {
26
+ for (const found of scanFile(
27
+ relPath,
28
+ text,
29
+ globalWhitelist,
30
+ issues,
31
+ rootPrefix,
32
+ isForeignImport,
33
+ )) {
25
34
  const existing = byName.get(found.name);
26
35
  if (existing === undefined) {
27
36
  byName.set(found.name, {
@@ -23,6 +23,8 @@ export function scanFile(
23
23
  text: string,
24
24
  globalWhitelist: ReadonlySet<string>,
25
25
  issues: CellIssue[],
26
+ rootPrefix = "..",
27
+ isForeignImport?: (fromRel: string, specifier: string) => boolean,
26
28
  ): Array<{
27
29
  name: string;
28
30
  implText: string;
@@ -33,7 +35,15 @@ export function scanFile(
33
35
  const fromDir = relPath.includes("/")
34
36
  ? relPath.slice(0, relPath.lastIndexOf("/"))
35
37
  : "";
36
- const fileImports = collectFileImports(sf, text, fromDir, issues, relPath);
38
+ const fileImports = collectFileImports(
39
+ sf,
40
+ text,
41
+ fromDir,
42
+ issues,
43
+ relPath,
44
+ rootPrefix,
45
+ isForeignImport,
46
+ );
37
47
  const found: Array<{
38
48
  name: string;
39
49
  implText: string;
package/src/cell/types.ts CHANGED
@@ -24,4 +24,14 @@ export interface FileImports {
24
24
  export interface ScanCellsOptions {
25
25
  /** 允许在内联实现里直接引用的全局名(缺省 = 标准全局白名单 16 项) */
26
26
  readonly globalWhitelist?: ReadonlySet<string>;
27
+ /**
28
+ * 生成物目录回到源根的相对前缀(缺省 `".."` = 生成物在源根内恰深一层)。
29
+ * 生成物挪到源根之外的同级目录时传 `"../Stories.src"` 这类值。
30
+ */
31
+ readonly rootPrefix?: string;
32
+ /**
33
+ * 判断某条 import 是否由别的通道处置(家例:UI 句柄导入)。命中的语句既不搬进
34
+ * 生成物、也不算问题——它压根不是生成注册物的运行期依赖,越界与否由那条通道自己判。
35
+ */
36
+ readonly isForeignImport?: (fromRel: string, specifier: string) => boolean;
27
37
  }
package/src/cli/cli.ts CHANGED
@@ -17,8 +17,10 @@
17
17
  * Node 类型擦除加载,垫片用 tsx 的 `tsImport` 装本文件再调同一个 `runBuildCli`,两条路
18
18
  * 因此共用一份呈现逻辑。
19
19
  *
20
- * 用法:`<工程根可省略>`;缺省工程根 = 进程工作目录(调用方脚本已 cd 到宿主工程根,
21
- * 或经 DSH_STORIES_ROOT 指明——包内没有「谁在调我、宿主工程在哪」这件事实)。
20
+ * 用法:`[工程根] [--sources-dir=<目录>] [--resources-dir=<目录>]`;缺省工程根 = 进程
21
+ * 工作目录(调用方脚本已 cd 到宿主工程根,或经 DSH_STORIES_ROOT 指明——包内没有
22
+ * 「谁在调我、宿主工程在哪」这件事实)。目录参数缺省 = `buildStories` 的默认常量,
23
+ * 自定义布局的工程用它们覆盖(拒绝在包内猜目录名)。
22
24
  * 测试不经这里——它们直接调 `buildStories`(纯编排,fs 全在参数 root 之下)。
23
25
  */
24
26
  import { resolve } from "node:path";
@@ -28,6 +30,7 @@ import {
28
30
  StoryBuildError,
29
31
  buildStories,
30
32
  } from "../index.ts";
33
+ import type { StoryBuildOptions } from "../index.ts";
31
34
 
32
35
  export { StoryBuildError, buildStories, type BuildReport } from "../index.ts";
33
36
 
@@ -40,6 +43,48 @@ export function resolveBuildRoot(argv: readonly string[]): string {
40
43
  );
41
44
  }
42
45
 
46
+ /** 目录名类参数:命令行可覆盖 `buildStories` 的默认目录名 */
47
+ const DIR_FLAGS = [
48
+ ["--sources-dir", "sourcesDir"],
49
+ ["--resources-dir", "resourcesDir"],
50
+ ] as const;
51
+
52
+ /**
53
+ * 拆命令行:目录参数进 `options`(缺省不下发,让 `buildStories` 用默认常量),
54
+ * 剩下的位置参数按原序留给 `resolveBuildRoot`。
55
+ *
56
+ * 写成 `<目录>=<绝对值>` 或 `<目录>=<相对路径>` 都行——值原样交给 `buildStories`,
57
+ * 它按工程根 join(与配置文件里的相对口径一致)。缺值时按空串下发会让构建落到工程根
58
+ * 本身,这类误用直接 fail-closed 报参数名。
59
+ */
60
+ export function parseCliArgs(argv: readonly string[]): {
61
+ root: string;
62
+ options: StoryBuildOptions;
63
+ } {
64
+ const positional: string[] = [];
65
+ const options: { sourcesDir?: string; resourcesDir?: string } = {};
66
+ for (let index = 0; index < argv.length; index += 1) {
67
+ const arg = argv[index]!;
68
+ const match = DIR_FLAGS.find(
69
+ ([flag]) => arg === flag || arg.startsWith(`${flag}=`),
70
+ );
71
+ if (match === undefined) {
72
+ positional.push(arg);
73
+ continue;
74
+ }
75
+ const [flag, key] = match;
76
+ const inline = arg.startsWith(`${flag}=`) ? arg.slice(flag.length + 1) : "";
77
+ const value = inline !== "" ? inline : argv[++index];
78
+ if (value === undefined || value === "") {
79
+ throw new StoryBuildError(
80
+ `${flag} 需要一个目录值(如 ${flag}=src/Stories.src)`,
81
+ );
82
+ }
83
+ options[key] = value;
84
+ }
85
+ return { root: resolveBuildRoot(positional), options };
86
+ }
87
+
43
88
  /**
44
89
  * 跑一次编译并把结果打到标准输出/标准错误,返回进程退出码。
45
90
  * 不调用 `process.exit`——谁来决定退出由调用方(主模块守卫或 bin 垫片)负责。
@@ -47,9 +92,9 @@ export function resolveBuildRoot(argv: readonly string[]): string {
47
92
  export async function runBuildCli(
48
93
  argv: readonly string[] = process.argv.slice(2),
49
94
  ): Promise<number> {
50
- const root = resolveBuildRoot(argv);
51
95
  try {
52
- const report = await buildStories(root);
96
+ const { root, options } = parseCliArgs(argv);
97
+ const report = await buildStories(root, options);
53
98
  console.log(
54
99
  `TS 故事源编译完成:${DEFAULT_SOURCES_DIR}/${report.source} → story "${report.storyId}"(${report.columns} 列)`,
55
100
  );
@@ -2,20 +2,30 @@ import { scanCells, type CellScan } from "../cell/index.ts";
2
2
  import { StoryBuildError } from "../shared/index.ts";
3
3
 
4
4
  /**
5
- * cell 声明扫描(构建期 AST 扫描):`Stories.src` 树(排除生成物目录,仅 `.ts`)。
5
+ * cell 声明扫描(构建期 AST 扫描):源树(排除生成物目录,仅 `.ts`)。
6
6
  * 「名字在数据、实现在代码、build 做名字闭合」:实现住生成模块,故事 JSON 只留名字。
7
7
  * 任一条扫描问题都 fail-closed,一次报全部(每条带 `路径:行` 定位)。
8
+ *
9
+ * `generatedDir` 排除项是防手工把生成物放进源树林;`rootPrefix` 只在生成物住在源根
10
+ * 之外时给(见 `ScanCellsOptions`),决定搬运进来的 import 说明符怎么写。
11
+ * `isForeignImport` 交给调用方指认「这条导入由别的通道处置」(如 UI 句柄)——这类语句
12
+ * 既不搬进生成物,也不参与越界判定。
8
13
  */
9
14
  export function scanCellSources(
10
15
  sourcesTree: ReadonlyMap<string, string>,
11
16
  generatedDir: string,
17
+ rootPrefix = "..",
18
+ isForeignImport?: (fromRel: string, specifier: string) => boolean,
12
19
  ): CellScan {
13
20
  const cellSourceTree = new Map<string, string>();
14
21
  for (const [rel, text] of sourcesTree) {
15
22
  if (rel.startsWith(`${generatedDir}/`) || !rel.endsWith(".ts")) continue;
16
23
  cellSourceTree.set(rel, text);
17
24
  }
18
- const cellScanResult = scanCells(cellSourceTree);
25
+ const cellScanResult = scanCells(cellSourceTree, {
26
+ rootPrefix,
27
+ ...(isForeignImport === undefined ? {} : { isForeignImport }),
28
+ });
19
29
  if (cellScanResult.errors.length > 0) {
20
30
  throw new StoryBuildError(
21
31
  `cell 声明扫描失败:\n${cellScanResult.errors
@@ -1,6 +1,8 @@
1
1
  import { StoryBuildError } from "../shared/index.ts";
2
2
  import type { StoriesBuildTexts } from "../shared/index.ts";
3
3
  import type { StorySourceConfig } from "../config/story-config.ts";
4
+ import type { AliasResolver } from "./story-compiler/graph/scan/alias.ts";
5
+ import type { UiRegistryEntry } from "./story-compiler/graph/core.ts";
4
6
 
5
7
  /**
6
8
  * story-compiler 契约(编译器下游的唯一对接面):
@@ -59,6 +61,16 @@ export interface StoriesCompiler {
59
61
  >;
60
62
  /** 文案注入(Partial);缺省 = 内建缺省文案(即构建层的既有文案) */
61
63
  readonly texts?: Partial<StoriesBuildTexts>;
64
+ /**
65
+ * 工程 `tsconfig.json` 的 `paths` 解析器;缺省 = 只认相对路径。
66
+ * 由编排层读工程配置后注入,编译器自己不读 tsconfig。
67
+ */
68
+ readonly alias?: AliasResolver;
69
+ /**
70
+ * UI 名注册表(扫描 `ui/` 目录的产物);缺省 = 空表,此时命名导入注册表
71
+ * 会因查不到名字而 fail-closed。由编排层扫描后注入,编译器自己不读 UI 目录。
72
+ */
73
+ readonly uiRegistry?: readonly UiRegistryEntry[];
62
74
  }): CompiledStories | Promise<CompiledStories>;
63
75
  }
64
76
 
@@ -0,0 +1,43 @@
1
+ /**
2
+ * `mount` 命令与列级 `uis` 声明的互锁判定。
3
+ *
4
+ * 为什么要有这道判定:`uis` 是**静态声明**(本列引用了哪些 UI,宿主据此预加载),
5
+ * `mount(...)` 是**动态挂载**。两者指向同一个名字空间,但来源不同——静态那份来自
6
+ * 导入语句,动态那份来自列体代码。若代码 `mount` 了一个本列没导入的 UI,静态声明就漏了
7
+ * 一个名字:宿主不会预加载它,界面第一次挂载时才去取,表现为「点了才闪一下」。
8
+ * 这类缺陷在运行期只表现为时序抖动,很难归因,所以在编译期就判死。
9
+ *
10
+ * 判定只看名字是否在集合里,不判断 UI 是否存在(那是构建期的另一道闸门)。
11
+ */
12
+ import { compareOrdinal, type StoryCommand } from "@lingfan-engine/core";
13
+ import { walkCommandBodies } from "@lingfan-engine/editor";
14
+
15
+ /**
16
+ * 找出「`mount` 了但未在 `uis` 里声明」的名字(去重 + 码点序,保证诊断顺序稳定)。
17
+ *
18
+ * 遍历含嵌套块体(`if.then` / `while.body` / `func.body` 等):块体里的 `mount`
19
+ * 与列体顶层的 `mount` 在运行期一样会执行,静态声明漏了它同样是缺陷。
20
+ */
21
+ export function undeclaredMountUis(
22
+ commands: readonly StoryCommand[],
23
+ uis: ReadonlySet<string>,
24
+ ): string[] {
25
+ const missing = new Set<string>();
26
+ const consider = (cmd: Record<string, unknown>): void => {
27
+ if (cmd.op !== "mount") return;
28
+ const name = cmd.name;
29
+ if (typeof name !== "string" || uis.has(name)) return;
30
+ missing.add(name);
31
+ };
32
+ for (const cmd of commands) {
33
+ consider(cmd as unknown as Record<string, unknown>);
34
+ walkCommandBodies(
35
+ cmd as unknown as Record<string, unknown>,
36
+ "",
37
+ (inner) => {
38
+ consider(inner);
39
+ },
40
+ );
41
+ }
42
+ return [...missing].sort(compareOrdinal);
43
+ }
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  compareOrdinal,
3
+ normalizeUiNames,
3
4
  type CharacterDef,
4
5
  type Story,
5
6
  type StoryCommand,
@@ -10,6 +11,7 @@ import {
10
11
  } from "../../../shared/index.ts";
11
12
  import type { DiagBag, Graph } from "../graph.ts";
12
13
  import { compileFunction, partitionFunctions, tryFuncOp } from "./functions.ts";
14
+ import { undeclaredMountUis } from "./mount-uis.ts";
13
15
  import {
14
16
  createBodyCtx,
15
17
  translateStatements,
@@ -66,6 +68,18 @@ export function compileStoryFile(
66
68
  }
67
69
  }
68
70
 
71
+ // —— 列类型:`export const columnType = "ui"` 与 JSON 形态的 `type` 字段同义。
72
+ // 显式写 `game` 与不写等价,一律不带该字段——「缺省 game 不写」是序列化口径,
73
+ // 产物要跟手写 JSON 编译出来的字节一致,否则同一份故事两条路径产出两种文件。 ——
74
+ const declaredType =
75
+ mod.columnType === null ? undefined : mod.columnType.value;
76
+ const columnType = declaredType === "game" ? undefined : declaredType;
77
+
78
+ // —— 列级 UI 声明:模块扫描期已把 `import hub from ".../ui/hub/hub.vue"` 登记进
79
+ // `mod.uis`,这里只取名字集合(不回头扫 import 语句——那会让同一件事有第二个判定点)。
80
+ // 定序与去重交给契约层的唯一实现,保证同源同产物 ——
81
+ const columnUis = normalizeUiNames([...mod.uis.keys()]);
82
+
69
83
  const registry: FuncRegistry = { refs: new Map(), names: new Map() };
70
84
  const shared = {
71
85
  mod,
@@ -178,6 +192,20 @@ export function compileStoryFile(
178
192
  }
179
193
  commands.push(...bodyCommands);
180
194
 
195
+ // —— 静态声明与动态挂载互锁:列体里 `mount` 的每个名字都必须在 `uis` 里 ——
196
+ // 静态那份来自导入语句,动态那份来自代码;不一致时宿主不会预加载,
197
+ // 界面要到第一次挂载才取,症状只是「闪一下」,所以在编译期判死。
198
+ const declared = new Set(columnUis);
199
+ for (const name of undeclaredMountUis(commands, declared)) {
200
+ diags.add(
201
+ rel,
202
+ sf,
203
+ storyName,
204
+ `mount("${name}") 引用了本列未导入的 UI——请在本列补一条默认导入,` +
205
+ `指向 \`../../ui/${name}/${name}.vue\`,否则产物缺这条静态声明`,
206
+ );
207
+ }
208
+
181
209
  const story: Story = {
182
210
  formatVersion: 1,
183
211
  id: columnId,
@@ -186,7 +214,9 @@ export function compileStoryFile(
186
214
  {
187
215
  id: columnId,
188
216
  kind: "flow",
217
+ ...(columnType !== undefined ? { type: columnType } : {}),
189
218
  ...(columnFont !== undefined ? { font: columnFont } : {}),
219
+ ...(columnUis.length > 0 ? { uis: columnUis } : {}),
190
220
  commands,
191
221
  },
192
222
  ],
@@ -1,5 +1,10 @@
1
1
  import * as ts from "typescript";
2
- import { walkFiles, type StoriesBuildTexts } from "../../../shared/index.ts";
2
+ import { isSceneType } from "@lingfan-engine/core";
3
+ import {
4
+ walkFiles,
5
+ defaultStoriesBuildTexts,
6
+ type StoriesBuildTexts,
7
+ } from "../../../shared/index.ts";
3
8
  import type { StorySourceConfig } from "../../../config/story-config.ts";
4
9
  import type { CompilerConstValues } from "../../compiler-contract.ts";
5
10
  import {
@@ -7,9 +12,10 @@ import {
7
12
  ROOT_TS_FILES_ALLOWED,
8
13
  GEN_PREFIX,
9
14
  COLUMN_FONT_DECL,
15
+ COLUMN_TYPE_DECL,
10
16
  toPosix,
11
17
  } from "./core.ts";
12
- import type { Graph, ModuleRecord } from "./core.ts";
18
+ import type { Graph, ModuleRecord, UiRegistryEntry } from "./core.ts";
13
19
  import { scanModule } from "./scan/module-scan.ts";
14
20
  import { discoverStateRoot } from "./state-root.ts";
15
21
  import {
@@ -17,10 +23,18 @@ import {
17
23
  assertNpmNamesConstOnly,
18
24
  } from "./scan/bindings.ts";
19
25
  import { resolveImports } from "./scan/imports.ts";
26
+ import { createUiChannel } from "./scan/ui-specifier.ts";
27
+ import { resolveUiImports } from "./scan/ui-import.ts";
28
+ import type { AliasResolver } from "./scan/alias.ts";
20
29
 
21
30
  /**
22
31
  * 图谱构建:布局声明(config 求值产物)为必填入参——本层不再解析 config.ts
23
32
  *(读取/校验统一走 const-eval/config-eval),只消费 folders/start。
33
+ *
34
+ * `alias` 是工程 `tsconfig.json` 的 `paths` 解析器;缺省 = 只认相对路径。
35
+ *
36
+ * `uiRegistry` 是 UI 名注册表(扫描 `ui/` 目录的产物);缺省 = 空表,此时命名导入
37
+ * 注册表会因查不到名字而报错——只有编排层知道 `ui/` 在哪,编译器自己不扫目录。
24
38
  */
25
39
  export function buildGraph(
26
40
  sourcesDir: string,
@@ -28,6 +42,8 @@ export function buildGraph(
28
42
  config: StorySourceConfig,
29
43
  constValues?: CompilerConstValues,
30
44
  texts?: StoriesBuildTexts,
45
+ alias?: AliasResolver,
46
+ uiRegistry?: readonly UiRegistryEntry[],
31
47
  ): Graph | null {
32
48
  const files = new Map<string, string>();
33
49
  for (const [rel, text] of walkFiles(sourcesDir)) {
@@ -91,19 +107,28 @@ export function buildGraph(
91
107
  npmNames: new Set(),
92
108
  evaluated: constValues?.[rel],
93
109
  columnFont: null,
110
+ columnType: null,
111
+ uis: new Map(),
94
112
  });
95
113
  }
96
114
 
115
+ // UI 沙箱根是源根的上一级(`src/`):UI 目录与故事源树是兄弟,不是它的子目录。
116
+ // 这一遍必须早于模块层常量扫描——`export const picked = hub` 折叠时要看得到 UI 绑定。
117
+ const uiChannel = createUiChannel(sourcesDir, alias, uiRegistry);
118
+ const uiTexts = texts ?? defaultStoriesBuildTexts;
97
119
  for (const record of records.values()) scanModule(record, diags);
98
120
  for (const record of records.values())
99
- resolveImports(record, records, diags, true);
121
+ resolveUiImports(record, diags, uiChannel, uiTexts);
122
+ for (const record of records.values())
123
+ resolveImports(record, records, diags, true, alias, uiChannel);
100
124
  for (const record of records.values()) {
101
125
  for (const stmt of record.consts)
102
126
  scanConstStatement(stmt, record, diags, texts);
103
127
  }
104
128
  collectColumnFonts(records, diags);
129
+ collectColumnTypes(records, diags);
105
130
  for (const record of records.values())
106
- resolveImports(record, records, diags, false);
131
+ resolveImports(record, records, diags, false, alias, uiChannel);
107
132
  for (const record of records.values())
108
133
  assertNpmNamesConstOnly(record, diags, texts);
109
134
 
@@ -132,10 +157,28 @@ export function buildGraph(
132
157
  return { files, records, config, configSf, coveredStories, stateRoot };
133
158
  }
134
159
 
160
+ /**
161
+ * 列级声明所在的语法节点(诊断定位用)。
162
+ *
163
+ * 找不到对应的 `const` 语句时退回整个源文件:报错仍能指出哪个文件,
164
+ * 比拿不到位置更好,也和 `columnFont` 既有行为一致。
165
+ */
166
+ function declNodeOf(record: ModuleRecord, name: string): ts.Node {
167
+ const stmt = record.consts.find((s) => {
168
+ const decl = s.declarationList.declarations[0];
169
+ return (
170
+ decl !== undefined &&
171
+ ts.isIdentifier(decl.name) &&
172
+ decl.name.text === name
173
+ );
174
+ });
175
+ return stmt ?? record.sf;
176
+ }
177
+
135
178
  /**
136
179
  * 列级字体声明拾取:`export const columnFont = "<名>"`。
137
180
  * 值走既有 const 绑定通道(字面量 / 常量运算 / 其他 const / 编译期求值内联同源),
138
- * 这里只做归属与形状裁定;字体名是否在注册表内由汇编段对照 fontNames 报(注册表
181
+ * 这里只做归属与形状判定;字体名是否在注册表内由汇编段对照 fontNames 报(注册表
139
182
  * 在会话上,图谱层不可见)。
140
183
  */
141
184
  function collectColumnFonts(
@@ -145,15 +188,7 @@ function collectColumnFonts(
145
188
  for (const record of records.values()) {
146
189
  const binding = record.exports.get(COLUMN_FONT_DECL);
147
190
  if (binding === undefined) continue;
148
- const stmt = record.consts.find((s) => {
149
- const decl = s.declarationList.declarations[0];
150
- return (
151
- decl !== undefined &&
152
- ts.isIdentifier(decl.name) &&
153
- decl.name.text === COLUMN_FONT_DECL
154
- );
155
- });
156
- const node = stmt ?? record.sf;
191
+ const node = declNodeOf(record, COLUMN_FONT_DECL);
157
192
  if (record.story === null) {
158
193
  diags.add(
159
194
  record.rel,
@@ -180,6 +215,43 @@ function collectColumnFonts(
180
215
  }
181
216
  }
182
217
 
218
+ /**
219
+ * 列类型声明拾取:`export const columnType = "ui"`。
220
+ *
221
+ * 与 JSON / 文本形态的 `type` 字段同义:不写 = `game`,写了必须是契约承认的两个取值之一。
222
+ * 为什么要这条通道:列类型是**列自身的语义**,而 TS 创作链里一列就是一个函数模块,
223
+ * 没有别的位置能放下它;让作者改生成物或手写 JSON 会把同一份事实劈成两处。
224
+ */
225
+ function collectColumnTypes(
226
+ records: ReadonlyMap<string, ModuleRecord>,
227
+ diags: DiagBag,
228
+ ): void {
229
+ for (const record of records.values()) {
230
+ const binding = record.exports.get(COLUMN_TYPE_DECL);
231
+ if (binding === undefined) continue;
232
+ const node = declNodeOf(record, COLUMN_TYPE_DECL);
233
+ if (record.story === null) {
234
+ diags.add(
235
+ record.rel,
236
+ record.sf,
237
+ node,
238
+ "columnType 只能声明在故事文件(default 导出列)的模块顶层",
239
+ );
240
+ continue;
241
+ }
242
+ if (binding.kind !== "const" || !isSceneType(binding.value)) {
243
+ diags.add(
244
+ record.rel,
245
+ record.sf,
246
+ node,
247
+ 'columnType 必须折叠为 "game" 或 "ui" 字面量(缺省即 game,不必声明)',
248
+ );
249
+ continue;
250
+ }
251
+ record.columnType = { value: binding.value, node };
252
+ }
253
+ }
254
+
183
255
  export function resolveStart(graph: Graph, diags: DiagBag): string | undefined {
184
256
  const candidates = graph.coveredStories.filter((rel) => {
185
257
  const idx = rel.lastIndexOf("/");