@lingfan-engine/stories-build 0.2.0 → 0.3.1
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/host-plugins.mjs +20 -0
- package/package.json +8 -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 +85 -13
- 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
|
@@ -5,11 +5,11 @@
|
|
|
5
5
|
* 根名与词函数冲突、或多个异名声明并存 → fail-closed 诊断(定位声明处)。
|
|
6
6
|
*/
|
|
7
7
|
import * as ts from "typescript";
|
|
8
|
+
import { MAX_ALIAS_HOPS } from "../../../shared/index.ts";
|
|
8
9
|
import { DEFAULT_STATE_ROOT, WORD_NAMES } from "./core.ts";
|
|
9
10
|
import type { DiagBag, ModuleRecord } from "./core.ts";
|
|
10
11
|
|
|
11
12
|
const STATE_ROOT_ANNOTATION = "StoryState";
|
|
12
|
-
const MAX_ALIAS_HOPS = 10;
|
|
13
13
|
|
|
14
14
|
interface StateRootCandidate {
|
|
15
15
|
rel: string;
|
|
@@ -22,6 +22,11 @@ export function evaluateWordArg(
|
|
|
22
22
|
if (binding?.kind === "cell") {
|
|
23
23
|
return { name: binding.guard };
|
|
24
24
|
}
|
|
25
|
+
// UI 句柄作词实参时就是它的名字(`mount(hub, true)` 的 `hub`),
|
|
26
|
+
// 与句柄在其他位置被折叠成字符串是同一件事。
|
|
27
|
+
if (binding?.kind === "ui") {
|
|
28
|
+
return { name: binding.name };
|
|
29
|
+
}
|
|
25
30
|
}
|
|
26
31
|
if (ts.isArrayLiteralExpression(expr)) {
|
|
27
32
|
const out: unknown[] = [];
|
|
@@ -17,8 +17,10 @@ import {
|
|
|
17
17
|
type CompileDiag,
|
|
18
18
|
type CompiledStory,
|
|
19
19
|
type Graph,
|
|
20
|
+
type UiRegistryEntry,
|
|
20
21
|
} from "./story-compiler/index.ts";
|
|
21
22
|
import type { CompilerConstValues } from "./compiler-contract.ts";
|
|
23
|
+
import type { AliasResolver } from "./story-compiler/graph/scan/alias.ts";
|
|
22
24
|
|
|
23
25
|
export interface CompileStoriesResult {
|
|
24
26
|
stories: Array<{ columnId: string; sourceRelPath: string; story: Story }>;
|
|
@@ -41,6 +43,13 @@ export function compileStories(input: {
|
|
|
41
43
|
readonly characters?: ReadonlyMap<string, CharacterDef>;
|
|
42
44
|
/** 文案注入(Partial);缺省 = 内建缺省文案(即构建层的既有文案) */
|
|
43
45
|
readonly texts?: Partial<StoriesBuildTexts>;
|
|
46
|
+
/** 工程 `tsconfig.json` 的 paths 解析器;缺省 = 只认相对路径 */
|
|
47
|
+
readonly alias?: AliasResolver;
|
|
48
|
+
/**
|
|
49
|
+
* UI 名注册表(扫描 `ui/` 目录的产物);缺省 = 空表。
|
|
50
|
+
* 由编排层扫描后注入,编译器自己不读 UI 目录。
|
|
51
|
+
*/
|
|
52
|
+
readonly uiRegistry?: readonly UiRegistryEntry[];
|
|
44
53
|
}): CompileStoriesResult {
|
|
45
54
|
const texts: StoriesBuildTexts = {
|
|
46
55
|
...defaultStoriesBuildTexts,
|
|
@@ -53,6 +62,8 @@ export function compileStories(input: {
|
|
|
53
62
|
input.config,
|
|
54
63
|
input.constValues,
|
|
55
64
|
texts,
|
|
65
|
+
input.alias,
|
|
66
|
+
input.uiRegistry,
|
|
56
67
|
);
|
|
57
68
|
if (graph === null) return { stories: [], diags: diags.list() };
|
|
58
69
|
const entry = resolveStart(graph, diags);
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
2
|
import { isSafeFileNameSegment } from "@lingfan-engine/core";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
StoryBuildError,
|
|
5
|
+
DEFAULT_GENERATED_DIR,
|
|
6
|
+
GEN_PREFIX,
|
|
7
|
+
} from "../shared/index.ts";
|
|
4
8
|
|
|
5
9
|
/**
|
|
6
10
|
* 源工程布局声明(`Stories.src/config.ts`,根级唯一根文件):
|
|
@@ -55,7 +59,7 @@ export function parseFolders(
|
|
|
55
59
|
`${sourcesDirName}/${CONFIG_FILE}: folders[${index}] 须为目录名字符串或 "*",得到 ${describe(entry)}`,
|
|
56
60
|
);
|
|
57
61
|
}
|
|
58
|
-
if (entry ===
|
|
62
|
+
if (entry === DEFAULT_GENERATED_DIR || entry.startsWith(GEN_PREFIX)) {
|
|
59
63
|
throw new StoryBuildError(
|
|
60
64
|
`${sourcesDirName}/${CONFIG_FILE}: folders[${index}] "${entry}" 指向生成物目录——gen/ 由构建管理,不参与源扫描`,
|
|
61
65
|
);
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* `import type` 整句编译期擦除,不触发求值;副作用导入同样不触发(图谱层自有
|
|
8
8
|
* fail-closed 诊断)。
|
|
9
9
|
*
|
|
10
|
+
* 能展开成工程 `paths` 别名的说明符也不算 npm 值导入:别名指向的是工程内的文件,
|
|
11
|
+
* 由图谱层按普通模块连边。这里只问一次解析器(「有模式命中吗」),不判存在性与越界——
|
|
12
|
+
* 那两类错误由图谱层给出各自的诊断,本段提前拦会把别名笔误误报成 npm 导入。
|
|
13
|
+
*
|
|
10
14
|
* fail-closed(一次报全部):
|
|
11
15
|
* - 故事文件含 node_modules 值导入 → 定向诊断(求值器只作用于纯模块;故事文件的
|
|
12
16
|
* default 函数导出必然不可序列化,npm 值请收敛为纯模块的导出 const 再相对导入);
|
|
@@ -26,6 +30,7 @@ import {
|
|
|
26
30
|
StoryBuildError,
|
|
27
31
|
type StoriesBuildTexts,
|
|
28
32
|
} from "../shared/index.ts";
|
|
33
|
+
import { expandAlias, type PathsConfig } from "../pipeline/tsconfig-paths.ts";
|
|
29
34
|
|
|
30
35
|
/** 词汇层两包不触发求值:editor 是词汇出口(图谱层按词/命名空间绑定),engine 不参与创作导入。 */
|
|
31
36
|
const RESERVED_SPECS: ReadonlySet<string> = new Set([
|
|
@@ -39,7 +44,11 @@ interface NpmValueImport {
|
|
|
39
44
|
col: number;
|
|
40
45
|
}
|
|
41
46
|
|
|
42
|
-
function findNpmValueImports(
|
|
47
|
+
function findNpmValueImports(
|
|
48
|
+
rel: string,
|
|
49
|
+
text: string,
|
|
50
|
+
paths: PathsConfig | null,
|
|
51
|
+
): NpmValueImport[] {
|
|
43
52
|
const sf = ts.createSourceFile(rel, text, ts.ScriptTarget.Latest, true);
|
|
44
53
|
const hits: NpmValueImport[] = [];
|
|
45
54
|
for (const stmt of sf.statements) {
|
|
@@ -47,6 +56,8 @@ function findNpmValueImports(rel: string, text: string): NpmValueImport[] {
|
|
|
47
56
|
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
48
57
|
const spec = stmt.moduleSpecifier.text;
|
|
49
58
|
if (spec.startsWith(".") || RESERVED_SPECS.has(spec)) continue;
|
|
59
|
+
// 工程别名指向工程内文件,不是 npm 值:留给图谱层按普通模块连边。
|
|
60
|
+
if (paths !== null && expandAlias(paths, spec).length > 0) continue;
|
|
50
61
|
const clause = stmt.importClause;
|
|
51
62
|
if (clause === undefined) continue; // 副作用导入:不触发求值(图谱层自有诊断)
|
|
52
63
|
if (clause.isTypeOnly) continue; // import type:编译期擦除,不在 npm 值导入约束内
|
|
@@ -63,7 +74,10 @@ function findNpmValueImports(rel: string, text: string): NpmValueImport[] {
|
|
|
63
74
|
return hits;
|
|
64
75
|
}
|
|
65
76
|
|
|
66
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* 求值段:返回喂给编译器的 constValues(无触发文件 = 空对象,行为与未接入一致)。
|
|
79
|
+
* `paths` 为工程 `tsconfig.json` 的别名配置——别名说明符指向工程内文件,不算 npm 值导入。
|
|
80
|
+
*/
|
|
67
81
|
export async function runConstEvalPass(
|
|
68
82
|
sourcesDir: string,
|
|
69
83
|
storyFiles: readonly string[],
|
|
@@ -71,6 +85,7 @@ export async function runConstEvalPass(
|
|
|
71
85
|
sourcesDirName: string,
|
|
72
86
|
texts: StoriesBuildTexts = defaultStoriesBuildTexts,
|
|
73
87
|
timeoutMs?: number,
|
|
88
|
+
paths: PathsConfig | null = null,
|
|
74
89
|
): Promise<CompilerConstValues> {
|
|
75
90
|
const storySet = new Set(storyFiles);
|
|
76
91
|
const failures: string[] = [];
|
|
@@ -83,7 +98,7 @@ export async function runConstEvalPass(
|
|
|
83
98
|
} catch {
|
|
84
99
|
continue; // 文件消失等读取异常交由源发现/编译段按既有路径报错
|
|
85
100
|
}
|
|
86
|
-
const hits = findNpmValueImports(rel, text);
|
|
101
|
+
const hits = findNpmValueImports(rel, text, paths);
|
|
87
102
|
if (hits.length === 0) continue;
|
|
88
103
|
if (storySet.has(rel)) {
|
|
89
104
|
for (const hit of hits) {
|
|
@@ -2,6 +2,7 @@ import { join } from "node:path";
|
|
|
2
2
|
import ts from "typescript";
|
|
3
3
|
import {
|
|
4
4
|
defaultStoriesBuildTexts,
|
|
5
|
+
GEN_PREFIX,
|
|
5
6
|
walkFiles,
|
|
6
7
|
type StoriesBuildTexts,
|
|
7
8
|
} from "../shared/index.ts";
|
|
@@ -42,7 +43,7 @@ export async function discoverSources(
|
|
|
42
43
|
// —— 探测域:全部 .ts(排除生成物目录;config.ts 只能是根级唯一根文件)——
|
|
43
44
|
const probe: string[] = [];
|
|
44
45
|
for (const rel of tree.keys()) {
|
|
45
|
-
if (!rel.endsWith(".ts") || rel.startsWith(
|
|
46
|
+
if (!rel.endsWith(".ts") || rel.startsWith(GEN_PREFIX)) continue;
|
|
46
47
|
if (rel.endsWith(`/${CONFIG_FILE}`)) {
|
|
47
48
|
throw new StoryBuildError(
|
|
48
49
|
`${sourcesDirName}/${rel}——${CONFIG_FILE} 只能是根级唯一根文件,故事与模块源请放进 folders 声明的目录`,
|
|
@@ -132,10 +133,14 @@ function collectCoveredDirs(
|
|
|
132
133
|
}
|
|
133
134
|
if (config.folders.includes("*")) {
|
|
134
135
|
for (const rel of tree.keys()) {
|
|
135
|
-
if (
|
|
136
|
+
if (
|
|
137
|
+
!rel.endsWith(".ts") ||
|
|
138
|
+
rel.startsWith(GEN_PREFIX) ||
|
|
139
|
+
rel === CONFIG_FILE
|
|
140
|
+
)
|
|
136
141
|
continue;
|
|
137
142
|
const dir = rel.includes("/") ? rel.slice(0, rel.lastIndexOf("/")) : "";
|
|
138
|
-
if (dir !== "" && !dir.startsWith(
|
|
143
|
+
if (dir !== "" && !dir.startsWith(GEN_PREFIX)) dirs.add(dir);
|
|
139
144
|
}
|
|
140
145
|
}
|
|
141
146
|
return dirs;
|
package/src/emit/emit.ts
CHANGED
|
@@ -26,6 +26,8 @@ export interface EmitOptions {
|
|
|
26
26
|
readonly scriptGen: { readonly rel: string; readonly content: string };
|
|
27
27
|
/** font 字面量类型面生成物(font.gen.d.ts):同规则的恒产出生成物(空注册表也恒产出) */
|
|
28
28
|
readonly fontGen: { readonly rel: string; readonly content: string };
|
|
29
|
+
/** UI 注册表生成物(ui_register.g.ts):同规则的恒产出生成物(没有 UI 也恒产出) */
|
|
30
|
+
readonly uiGen: { readonly rel: string; readonly content: string };
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
@@ -33,7 +35,7 @@ export interface EmitOptions {
|
|
|
33
35
|
* - `Stories/**` 由源全量管理——内容不同的产物才写(新增/更新分开计),不在产物集内的
|
|
34
36
|
* 既有列文件 = 陈旧,删除;
|
|
35
37
|
* - 生成物(`<sourcesDir>/<generatedDir>/fun_register.g.ts`、`stories.gen.d.ts`、
|
|
36
|
-
* `state.gen.d.ts`、`script.gen.d.ts`、`font.gen.d.ts`)同规则:零守卫时若存在则删除,否则内容不同才写。它们的逻辑路径与
|
|
38
|
+
* `state.gen.d.ts`、`script.gen.d.ts`、`font.gen.d.ts`、`ui_register.g.ts`)同规则:零守卫时若存在则删除,否则内容不同才写。它们的逻辑路径与
|
|
37
39
|
* `Stories/**` 不同命名空间,报告里以 `Stories.src/` 开头即生成物。
|
|
38
40
|
* 返回值里的路径都是逻辑路径(相对工程根),供 CLI 直接打印。
|
|
39
41
|
*/
|
|
@@ -53,6 +55,7 @@ export function emitArtifacts(options: EmitOptions): {
|
|
|
53
55
|
stateGen,
|
|
54
56
|
scriptGen,
|
|
55
57
|
fontGen,
|
|
58
|
+
uiGen,
|
|
56
59
|
} = options;
|
|
57
60
|
const existing = walkFiles(storiesDir);
|
|
58
61
|
const added: string[] = [];
|
|
@@ -123,6 +126,13 @@ export function emitArtifacts(options: EmitOptions): {
|
|
|
123
126
|
added,
|
|
124
127
|
updated,
|
|
125
128
|
);
|
|
129
|
+
writeConstantArtifact(
|
|
130
|
+
join(root, uiGen.rel),
|
|
131
|
+
uiGen.rel,
|
|
132
|
+
uiGen.content,
|
|
133
|
+
added,
|
|
134
|
+
updated,
|
|
135
|
+
);
|
|
126
136
|
return { added, updated, removed };
|
|
127
137
|
}
|
|
128
138
|
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 宿主构建期的清单虚拟模块:字体清单与故事清单。
|
|
3
|
+
*
|
|
4
|
+
* 两个清单都是**磁盘事实的投影**,不是需要作者维护的声明文件。让作者手抄名单的代价
|
|
5
|
+
* 不是「多写一行」,而是漏抄时构建期毫无察觉——漏登记的那一列要到运行期才以
|
|
6
|
+
* `unknown-column` 现形。
|
|
7
|
+
*
|
|
8
|
+
* 资源根与故事目录名都取 `@lingfan-engine/core` 的一处声明(`RESOURCES_DIR` /
|
|
9
|
+
* `STORIES_DIR`),且都可由调用方覆盖:工程把资源根搬到别处时改配置,不改扫描逻辑。
|
|
10
|
+
*
|
|
11
|
+
* 本模块只依赖 `node:*` 与引擎常量,**不 import vite**——插件对象按结构类型返回,
|
|
12
|
+
* 宿主自带的 vite 版本决定实际类型,这里不引入版本约束。
|
|
13
|
+
*/
|
|
14
|
+
import { fileURLToPath } from "node:url";
|
|
15
|
+
import { join, resolve } from "node:path";
|
|
16
|
+
import { RESOURCES_DIR, STORIES_DIR } from "@lingfan-engine/core";
|
|
17
|
+
import { scanFontRegistry } from "../fonts/font-registry.ts";
|
|
18
|
+
import { scanStoryRegistry } from "../story-list/story-registry.ts";
|
|
19
|
+
|
|
20
|
+
/** 虚拟模块 id:字体清单 */
|
|
21
|
+
export const STORY_FONTS_MODULE_ID = "virtual:story-fonts";
|
|
22
|
+
/** 虚拟模块 id:故事清单 */
|
|
23
|
+
export const STORY_LIST_MODULE_ID = "virtual:story-list";
|
|
24
|
+
|
|
25
|
+
/** 宿主构建插件的最小结构面(vite 的 `Plugin` 与其它打包器同名成员兼容) */
|
|
26
|
+
export interface HostPlugin {
|
|
27
|
+
readonly name: string;
|
|
28
|
+
readonly resolveId: (id: string) => string | null;
|
|
29
|
+
readonly load: (id: string) => string | null;
|
|
30
|
+
readonly configureServer?: (server: DevServerLike) => void;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** dev server 的最小结构面:只用到「加监听目录、作废模块、整页刷新」三件事 */
|
|
34
|
+
export interface DevServerLike {
|
|
35
|
+
readonly watcher: {
|
|
36
|
+
add: (paths: readonly string[]) => void;
|
|
37
|
+
on: (event: "add" | "unlink", listener: (file: string) => void) => void;
|
|
38
|
+
};
|
|
39
|
+
readonly moduleGraph: {
|
|
40
|
+
getModuleById: (id: string) => unknown;
|
|
41
|
+
invalidateModule: (mod: unknown) => void;
|
|
42
|
+
};
|
|
43
|
+
readonly ws: { send: (payload: { type: "full-reload" }) => void };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** 资源根定位选项 */
|
|
47
|
+
export interface ResourceRootOptions {
|
|
48
|
+
/**
|
|
49
|
+
* 资源根目录的绝对路径;缺省 = 调用方所在模块同级的 `<RESOURCES_DIR>`。
|
|
50
|
+
*
|
|
51
|
+
* 给绝对路径就完全由调用方决定资源根落在哪里——扫描不假设工程布局。
|
|
52
|
+
*/
|
|
53
|
+
readonly resourcesDir?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 缺省资源根:与调用方模块同级的 `<RESOURCES_DIR>`(模板/宿主的常规布局) */
|
|
57
|
+
function defaultResourcesDir(moduleUrl: string): string {
|
|
58
|
+
return fileURLToPath(new URL(`./${RESOURCES_DIR}`, moduleUrl));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* 造一个只提供固定值的虚拟模块插件。
|
|
63
|
+
*
|
|
64
|
+
* `watch` 里的目录在 dev 下被监听:新增或删除文件时把本模块作废并整页刷新,
|
|
65
|
+
* 这样「放一个故事文件进去」在 dev 里立即生效,不必重启 dev server。
|
|
66
|
+
*/
|
|
67
|
+
function virtualModule(
|
|
68
|
+
name: string,
|
|
69
|
+
moduleId: string,
|
|
70
|
+
build: () => string,
|
|
71
|
+
watch: readonly string[],
|
|
72
|
+
): HostPlugin {
|
|
73
|
+
const resolved = `\0${moduleId}`;
|
|
74
|
+
return {
|
|
75
|
+
name,
|
|
76
|
+
resolveId: (id) => (id === moduleId ? resolved : null),
|
|
77
|
+
load: (id) => (id === resolved ? build() : null),
|
|
78
|
+
configureServer(server) {
|
|
79
|
+
const roots = watch.map((dir) => resolve(dir).replace(/\\/g, "/"));
|
|
80
|
+
server.watcher.add([...watch]);
|
|
81
|
+
const onChange = (file: string): void => {
|
|
82
|
+
const path = resolve(file).replace(/\\/g, "/");
|
|
83
|
+
if (!roots.some((root) => path.startsWith(`${root}/`))) return;
|
|
84
|
+
const mod = server.moduleGraph.getModuleById(resolved);
|
|
85
|
+
if (mod) server.moduleGraph.invalidateModule(mod);
|
|
86
|
+
server.ws.send({ type: "full-reload" });
|
|
87
|
+
};
|
|
88
|
+
server.watcher.on("add", onChange);
|
|
89
|
+
server.watcher.on("unlink", onChange);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 字体清单虚拟模块:扫描资源根下的字体目录产出字体名清单,
|
|
96
|
+
* 宿主据此注入 @font-face 与项目默认字体——与 `stories:build` 的字体名注册表同源。
|
|
97
|
+
*
|
|
98
|
+
* @param options 资源根覆盖;缺省取调用方模块同级的 `<RESOURCES_DIR>`
|
|
99
|
+
* @param moduleUrl 调用方模块的 `import.meta.url`(决定缺省资源根位置)
|
|
100
|
+
*/
|
|
101
|
+
export function storyFontsVirtual(
|
|
102
|
+
options: ResourceRootOptions = {},
|
|
103
|
+
moduleUrl: string,
|
|
104
|
+
): HostPlugin {
|
|
105
|
+
const resourcesDir = options.resourcesDir ?? defaultResourcesDir(moduleUrl);
|
|
106
|
+
const fontsDir = join(resourcesDir, "Fonts");
|
|
107
|
+
return virtualModule(
|
|
108
|
+
"story-fonts",
|
|
109
|
+
STORY_FONTS_MODULE_ID,
|
|
110
|
+
() =>
|
|
111
|
+
`export const storyFonts = ${JSON.stringify(scanFontRegistry(fontsDir))};`,
|
|
112
|
+
[fontsDir],
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 故事清单虚拟模块:扫描资源根下的故事目录得出要装载的故事文件表。
|
|
118
|
+
*
|
|
119
|
+
* TS 创作链的产物(`Stories.src/` 编译出的 `.json`)与手写 `.story` 落在同一棵树,
|
|
120
|
+
* 共用这一条扫描——创作形态不改变清单的来源。
|
|
121
|
+
*
|
|
122
|
+
* @param options 资源根覆盖;缺省取调用方模块同级的 `<RESOURCES_DIR>`
|
|
123
|
+
* @param moduleUrl 调用方模块的 `import.meta.url`(决定缺省资源根位置)
|
|
124
|
+
*/
|
|
125
|
+
export function storyListVirtual(
|
|
126
|
+
options: ResourceRootOptions = {},
|
|
127
|
+
moduleUrl: string,
|
|
128
|
+
): HostPlugin {
|
|
129
|
+
const resourcesDir = options.resourcesDir ?? defaultResourcesDir(moduleUrl);
|
|
130
|
+
const storiesDir = join(resourcesDir, STORIES_DIR);
|
|
131
|
+
return virtualModule(
|
|
132
|
+
"story-list",
|
|
133
|
+
STORY_LIST_MODULE_ID,
|
|
134
|
+
() =>
|
|
135
|
+
`export const STORIES = ${JSON.stringify(scanStoryRegistry(storiesDir))};`,
|
|
136
|
+
[storiesDir],
|
|
137
|
+
);
|
|
138
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -74,3 +74,23 @@ export {
|
|
|
74
74
|
scanFontRegistry,
|
|
75
75
|
type FontEntry,
|
|
76
76
|
} from "./fonts/index.ts";
|
|
77
|
+
/** 故事文件清单注册表(资源根下故事目录扫描;宿主 Vite 插件与构建期检查同源消费) */
|
|
78
|
+
export {
|
|
79
|
+
scanStoryRegistry,
|
|
80
|
+
STORY_EXTENSIONS,
|
|
81
|
+
type StoryPath,
|
|
82
|
+
} from "./story-list/index.ts";
|
|
83
|
+
/** UI 名注册表(源根兄弟目录 `ui/<名>/` 扫描;构建期 uis 闸门的判据来源) */
|
|
84
|
+
export {
|
|
85
|
+
UI_DIR_NAME,
|
|
86
|
+
scanUiRegistry,
|
|
87
|
+
uiNameSet,
|
|
88
|
+
type UiFileEntry,
|
|
89
|
+
} from "./ui/index.ts";
|
|
90
|
+
/** UI import 面静态校验(UI 不归构建管,越界依赖只能在这里拦) */
|
|
91
|
+
export {
|
|
92
|
+
checkUiImports,
|
|
93
|
+
scanUiImports,
|
|
94
|
+
type UiImportRef,
|
|
95
|
+
type UiImportViolation,
|
|
96
|
+
} from "./ui/index.ts";
|
package/src/pipeline/gates.ts
CHANGED
|
@@ -9,11 +9,14 @@ import {
|
|
|
9
9
|
StoryBuildError,
|
|
10
10
|
type StoriesBuildTexts,
|
|
11
11
|
} from "../shared/index.ts";
|
|
12
|
+
import type { UiImportViolation } from "../ui/scan-imports.ts";
|
|
12
13
|
|
|
13
14
|
/** 闸门判定的可配置项:文案里出现的目录名(缺省 = 现状;只影响文案,不影响判定) */
|
|
14
15
|
export interface GateOptions {
|
|
15
16
|
readonly resourcesDir: string;
|
|
16
17
|
readonly sourcesDir: string;
|
|
18
|
+
/** UI 根目录名(源根的兄弟目录;只用于 `uis` 闸门的文案定位) */
|
|
19
|
+
readonly uiDirName: string;
|
|
17
20
|
/** 文案注入(Partial);缺省 = 内建缺省文案(即构建层的既有文案) */
|
|
18
21
|
readonly texts?: Partial<StoriesBuildTexts>;
|
|
19
22
|
}
|
|
@@ -71,11 +74,79 @@ export function assertKnownOps(
|
|
|
71
74
|
);
|
|
72
75
|
}
|
|
73
76
|
|
|
77
|
+
/**
|
|
78
|
+
* 列级 `uis` 闸门:产物里出现的每个 UI 名,源里必须有对应实现文件。
|
|
79
|
+
*
|
|
80
|
+
* 判据取 `src/ui/<名>/<名>.<扩展名>`(与导入期同一条落位规则)——名字写错一个字母
|
|
81
|
+
* 在编译期毫无反应,只在宿主预加载落空时表现为「界面闪一下才出现」,很难归因;
|
|
82
|
+
* 构建期对着目录核一遍就把这类笔误变成一次构建失败。
|
|
83
|
+
*/
|
|
84
|
+
export function assertKnownUis(
|
|
85
|
+
story: Story,
|
|
86
|
+
uiNames: ReadonlySet<string>,
|
|
87
|
+
options: GateOptions,
|
|
88
|
+
): void {
|
|
89
|
+
const unknownUis = new Map<string, string[]>();
|
|
90
|
+
story.columns.forEach((column, index) => {
|
|
91
|
+
const uis = column.uis;
|
|
92
|
+
if (!Array.isArray(uis)) return;
|
|
93
|
+
for (const name of uis) {
|
|
94
|
+
if (typeof name !== "string" || uiNames.has(name)) continue;
|
|
95
|
+
const hits = unknownUis.get(name) ?? [];
|
|
96
|
+
hits.push(`/columns/${index}`);
|
|
97
|
+
unknownUis.set(name, hits);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
if (unknownUis.size === 0) return;
|
|
101
|
+
const texts: StoriesBuildTexts = {
|
|
102
|
+
...defaultStoriesBuildTexts,
|
|
103
|
+
...options.texts,
|
|
104
|
+
};
|
|
105
|
+
const at = columnLabeler(story);
|
|
106
|
+
const detail = [...unknownUis.entries()]
|
|
107
|
+
.map(
|
|
108
|
+
([name, pointers]) =>
|
|
109
|
+
` - ui "${name}" —— ${pointers.map(at).join("、")}`,
|
|
110
|
+
)
|
|
111
|
+
.join("\n");
|
|
112
|
+
throw new StoryBuildError(
|
|
113
|
+
texts.unknownUisGate(detail, options.sourcesDir, options.uiDirName),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* UI import 面闸门(fail-closed):`src/ui/**` 下任何越界 import 都让构建整体失败。
|
|
119
|
+
*
|
|
120
|
+
* 为什么在构建期拦:UI 不归 stories-build 编译,宿主打包器又拦不住 `@/host/boot`
|
|
121
|
+
* 这类合法路径。越界依赖只在这里可见,放过一次就再也看不见了。
|
|
122
|
+
* 明细每行带 `文件:行` 与说明符,超过 3 处折叠为「(共 N 处)」。
|
|
123
|
+
*/
|
|
124
|
+
export function assertUiImports(
|
|
125
|
+
violations: readonly UiImportViolation[],
|
|
126
|
+
options: GateOptions,
|
|
127
|
+
): void {
|
|
128
|
+
if (violations.length === 0) return;
|
|
129
|
+
const texts: StoriesBuildTexts = {
|
|
130
|
+
...defaultStoriesBuildTexts,
|
|
131
|
+
...options.texts,
|
|
132
|
+
};
|
|
133
|
+
const shown = violations
|
|
134
|
+
.slice(0, 3)
|
|
135
|
+
.map(
|
|
136
|
+
(violation) =>
|
|
137
|
+
` - ${violation.file}:${violation.line} \`${violation.specifier}\` —— ${violation.reason}`,
|
|
138
|
+
);
|
|
139
|
+
const more =
|
|
140
|
+
violations.length > 3 ? `\n (共 ${violations.length} 处)` : "";
|
|
141
|
+
throw new StoryBuildError(
|
|
142
|
+
texts.uiImportGate(shown.join("\n") + more, options.uiDirName),
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
|
|
74
146
|
/**
|
|
75
147
|
* 守卫名闸门(cell 范式的闭合校验):故事 `guard.fn` 必须都在 cell 注册表键集内。
|
|
76
148
|
* fail-closed 带命令指针定位(编辑器不校验守卫名——构建期兜住)。
|
|
77
|
-
*/
|
|
78
|
-
export function assertKnownGuards(
|
|
149
|
+
*/ export function assertKnownGuards(
|
|
79
150
|
story: Story,
|
|
80
151
|
guardNames: ReadonlySet<string>,
|
|
81
152
|
options: GateOptions,
|
package/src/pipeline/index.ts
CHANGED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 工程级合并:把编译器交回的条目拼成一份 `Story`,并把跨来源的 defines 收敛成一张表。
|
|
3
|
+
*
|
|
4
|
+
* 与 `orchestrate.ts` 的分工:那边负责「按什么顺序做哪些段」,这边负责「段与段之间的
|
|
5
|
+
* 拼接与冲突判定」——都是纯函数,入参出参都是普通值,不碰 fs、不读配置。
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
STORIES_DIR,
|
|
9
|
+
type Story,
|
|
10
|
+
type StoryColumn,
|
|
11
|
+
} from "@lingfan-engine/core";
|
|
12
|
+
import { StoryBuildError } from "../shared/index.ts";
|
|
13
|
+
import type { CompiledStoryEntry } from "../compiler/index.ts";
|
|
14
|
+
import type { LocatedDefineCall } from "../const-eval/index.ts";
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* 把一个编译条目读成 `{ story, column }`:取条目列(columnId 必须在产物列集中)、
|
|
18
|
+
* 回填产物 sourcePath、复核 story 骨架(columns 列集 + id/entry 字符串)。
|
|
19
|
+
* 更深的合法性(列 id 安全段、重复、entry 存在性等)交给 serializeProject /
|
|
20
|
+
* assembleProject 的既有校验兜底。
|
|
21
|
+
*/
|
|
22
|
+
export function readCompiledEntry(
|
|
23
|
+
entry: CompiledStoryEntry,
|
|
24
|
+
sourcesDirName: string,
|
|
25
|
+
): { story: Story; column: StoryColumn } {
|
|
26
|
+
const sourceLabel = `${sourcesDirName}/${entry.sourceRelPath}`;
|
|
27
|
+
if (!isRecord(entry.story) || !Array.isArray(entry.story.columns)) {
|
|
28
|
+
throw new StoryBuildError(
|
|
29
|
+
`${sourceLabel}: 编译器条目 story 缺少列集(compileStories 契约破坏)`,
|
|
30
|
+
);
|
|
31
|
+
}
|
|
32
|
+
const column = entry.story.columns.find(
|
|
33
|
+
(item) => isRecord(item) && item.id === entry.columnId,
|
|
34
|
+
);
|
|
35
|
+
if (column === undefined) {
|
|
36
|
+
throw new StoryBuildError(
|
|
37
|
+
`${sourceLabel}: 编译器条目 columnId "${entry.columnId}" 不在产物列集中(compileStories 契约破坏)`,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
if (
|
|
41
|
+
typeof entry.story.id !== "string" ||
|
|
42
|
+
entry.story.id === "" ||
|
|
43
|
+
typeof entry.story.entry !== "string" ||
|
|
44
|
+
entry.story.entry === ""
|
|
45
|
+
) {
|
|
46
|
+
throw new StoryBuildError(
|
|
47
|
+
`${sourceLabel}: 编译器条目 story 缺少 id/entry(compileStories 契约破坏)`,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
const story = entry.story as unknown as Story;
|
|
51
|
+
const typedColumn = column as unknown as StoryColumn;
|
|
52
|
+
typedColumn.sourcePath = `${STORIES_DIR}/${entry.sourceRelPath.replace(/\.ts$/, ".json")}`;
|
|
53
|
+
return { story, column: typedColumn };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** 形态 A(state 导出)与模块层 define 互斥时的失败文案:每条调用一行,带落点。 */
|
|
57
|
+
export function raiseFormAConflict(
|
|
58
|
+
sourcesDirName: string,
|
|
59
|
+
configFile: string,
|
|
60
|
+
calls: readonly LocatedDefineCall[],
|
|
61
|
+
): never {
|
|
62
|
+
throw new StoryBuildError(
|
|
63
|
+
[
|
|
64
|
+
`${sourcesDirName}/${configFile}: 形态 A(state 导出)与模块层构建期 define 互斥——形态 A/B 二选一:`,
|
|
65
|
+
...calls.map((call) => `- ${call.origin}${defineLabel(call)}`),
|
|
66
|
+
].join("\n"),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 跨来源的 defines 合并:同键同值收敛,同键异值 = 冲突 fail-closed(一次报全部)。
|
|
72
|
+
* 来源 = config 状态(config 先行:形态 A state 或形态 B define 合并树)+ 各故事文件;
|
|
73
|
+
* config 与故事同键异值同样冲突。
|
|
74
|
+
*/
|
|
75
|
+
export function mergeDefines(
|
|
76
|
+
bySource: ReadonlyMap<string, { story: Story; columns: StoryColumn[] }>,
|
|
77
|
+
sourcesDirName: string,
|
|
78
|
+
schemaDefines: Record<string, unknown> = {},
|
|
79
|
+
): Record<string, unknown> {
|
|
80
|
+
const merged: Record<string, unknown> = {};
|
|
81
|
+
const conflicts: string[] = [];
|
|
82
|
+
const put = (key: string, value: unknown, source: string): void => {
|
|
83
|
+
if (key in merged && merged[key] !== value) {
|
|
84
|
+
conflicts.push(
|
|
85
|
+
`- defines["${key}"] 在 ${sourcesDirName}/${source} 与先前的声明不一致(${JSON.stringify(merged[key])} ≠ ${JSON.stringify(value)})`,
|
|
86
|
+
);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
merged[key] = value;
|
|
90
|
+
};
|
|
91
|
+
for (const [key, value] of Object.entries(schemaDefines)) {
|
|
92
|
+
put(key, value, "config.ts(state/define)");
|
|
93
|
+
}
|
|
94
|
+
for (const [source, { story }] of bySource) {
|
|
95
|
+
for (const [key, value] of Object.entries(story.defines ?? {})) {
|
|
96
|
+
put(key, value, source);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (conflicts.length > 0) {
|
|
100
|
+
throw new StoryBuildError(
|
|
101
|
+
`跨故事文件的 defines 冲突(工程级 defines 只有一份,请统一到工程清单或消除同名异值):\n${conflicts.join("\n")}`,
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return merged;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 枚举槽位回收的模块源文:按调用落点去重排序,树里缺文本的文件跳过。
|
|
109
|
+
* 排序是为让回收顺序稳定(同样的源必得同样的类型面)。
|
|
110
|
+
*/
|
|
111
|
+
export function moduleSlotFiles(
|
|
112
|
+
sourceTree: ReadonlyMap<string, string>,
|
|
113
|
+
calls: readonly { rel: string }[],
|
|
114
|
+
): Map<string, string> {
|
|
115
|
+
const files = new Map<string, string>();
|
|
116
|
+
for (const rel of [...new Set(calls.map((call) => call.rel))].sort()) {
|
|
117
|
+
const text = sourceTree.get(rel);
|
|
118
|
+
if (text !== undefined) files.set(rel, text);
|
|
119
|
+
}
|
|
120
|
+
return files;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
124
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** 互斥文案里的一条调用签名(path 形带键名;spec 形统一签名) */
|
|
128
|
+
function defineLabel(call: LocatedDefineCall): string {
|
|
129
|
+
return call.call.kind === "path"
|
|
130
|
+
? `define("${call.call.path}")`
|
|
131
|
+
: "define({…})";
|
|
132
|
+
}
|