@lingfan-engine/create 0.1.4 → 0.2.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 (38) hide show
  1. package/LICENSE +201 -201
  2. package/package.json +1 -1
  3. package/src/cli/cli.ts +37 -7
  4. package/src/cli/prompts.ts +4 -2
  5. package/src/generate/index.ts +1 -0
  6. package/src/generate/project.ts +26 -4
  7. package/src/index.ts +1 -0
  8. package/src/overlay/merge.ts +56 -3
  9. package/src/registry/template-meta.ts +13 -3
  10. package/src/shared/texts.ts +4 -0
  11. package/template/README.md +21 -7
  12. package/template/__PROJECT__/src/host/dialogue-view.ts +21 -0
  13. package/template/__PROJECT__/src/host/engine-wiring.ts +2 -0
  14. package/template/__PROJECT__/src/host/save-slots.ts +1 -1
  15. package/template/__PROJECT__/src/main.ts +2 -3
  16. package/template/__PROJECT__/src/stories.ts +11 -0
  17. package/template/__PROJECT__/src/styles/stage.css +6 -0
  18. package/template/overlays/README.md +15 -4
  19. package/template/overlays/miniprogram/README.md +12 -5
  20. package/template/overlays/miniprogram/package.patch.json +2 -1
  21. package/template/overlays/miniprogram/tsconfig.json +1 -2
  22. package/template/overlays/react/src/main.tsx +2 -3
  23. package/template/overlays/tauri/src/main.ts +40 -16
  24. package/template/overlays/ts/Resources/Stories/title/look_around.json +16 -0
  25. package/template/overlays/ts/Resources/Stories/title/title_main.json +30 -0
  26. package/template/overlays/ts/Resources/project.json +8 -0
  27. package/template/overlays/ts/Stories.src/config.ts +18 -0
  28. package/template/overlays/ts/Stories.src/gen/font.gen.d.ts +5 -0
  29. package/template/overlays/ts/Stories.src/gen/script.gen.d.ts +5 -0
  30. package/template/overlays/ts/Stories.src/gen/state.gen.d.ts +5 -0
  31. package/template/overlays/ts/Stories.src/gen/stories.gen.d.ts +12 -0
  32. package/template/overlays/ts/Stories.src/title/look_around.ts +15 -0
  33. package/template/overlays/ts/Stories.src/title/title_main.ts +23 -0
  34. package/template/overlays/ts/package.patch.json +14 -0
  35. package/template/overlays/ts/src/stories.ts +10 -0
  36. package/template/overlays/ts/tsconfig.stories.json +25 -0
  37. package/template/overlays/vue/src/main.ts +2 -3
  38. package/template/template-meta.json +14 -2
@@ -11,6 +11,11 @@
11
11
  * 叠加层只说「我要多一个依赖、多一条脚本」。
12
12
  * 3. 其余文件按相对路径复制。
13
13
  *
14
+ * 覆盖只能加不能减:叠加层换了整套页面与宿主之后,骨架里那些被替换掉的部件仍然会
15
+ * 落进产物(小程序形态拿不到浏览器宿主,留着就是一堆没人 import 的死文件)。所以
16
+ * 第 4 条规则是**删除**——层根的 `package.patch.json` 用 `create.remove` 点名要去掉
17
+ * 的路径(文件或目录),删除作用于它之前的层,本层自己的文件随后照常写入。
18
+ *
14
19
  * 占位符替换不在这里做——它是纯文本层面的最后一刀,见 `generate` 域。
15
20
  */
16
21
  import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
@@ -64,6 +69,7 @@ const SKIP_DIRS = new Set(["node_modules", "dist", ".git", "Saves"]);
64
69
  export function readLayer(base: string): {
65
70
  readonly files: PendingFile[];
66
71
  readonly patch: Readonly<Record<string, unknown>> | undefined;
72
+ readonly removes: readonly string[];
67
73
  } {
68
74
  const files: PendingFile[] = [];
69
75
  let patch: Record<string, unknown> | undefined;
@@ -82,7 +88,48 @@ export function readLayer(base: string): {
82
88
  }
83
89
  files.push({ path, content: readFileSync(join(base, path)) });
84
90
  }
85
- return { files, patch };
91
+ return { files, patch, removes: removeList(patch) };
92
+ }
93
+
94
+ /**
95
+ * 读本层 `create.remove` 点名的路径,归一成 posix 相对路径。
96
+ *
97
+ * 必须在**每层各读一次**,不能读合并后的增量:增量深合并时数组由后者整体取代,
98
+ * 两个叠加层各删各的,合并后只剩最后一层的名单。删除是逐层施加的动作,不是配置。
99
+ *
100
+ * 归一规则只有两条——去掉开头的 `./` 与结尾的 `/`:前者是手写 JSON 时的常见笔误,
101
+ * 后者让人能写 `src/pages/` 表示「这个目录」,与写 `src/pages` 等价。
102
+ */
103
+ function removeList(
104
+ patch: Readonly<Record<string, unknown>> | undefined,
105
+ ): readonly string[] {
106
+ if (patch === undefined) return [];
107
+ const section = patch[CREATE_FIELD];
108
+ if (!isPlainObject(section)) return [];
109
+ const raw = section.remove;
110
+ if (!Array.isArray(raw)) return [];
111
+ const out: string[] = [];
112
+ for (const item of raw) {
113
+ if (typeof item !== "string") continue;
114
+ const normalized = item.replace(/^\.\//, "").replace(/\/+$/, "");
115
+ if (normalized.length > 0) out.push(normalized);
116
+ }
117
+ return out;
118
+ }
119
+
120
+ /** 生成器专用字段名;与 `generate` 域共用同一个约定 */
121
+ const CREATE_FIELD = "create";
122
+
123
+ /**
124
+ * 这个路径是否被删除名单命中:精确同名,或落在被点名的目录下。
125
+ *
126
+ * 目录按前缀判,而不是先查磁盘上有没有这个目录——删除发生在「文件表」这一层,
127
+ * 表里可能已经因为上一层被删而不再有该目录,此时仍要拦住本层不该写的文件。
128
+ */
129
+ function isRemoved(path: string, removes: readonly string[]): boolean {
130
+ return removes.some(
131
+ (entry) => path === entry || path.startsWith(`${entry}/`),
132
+ );
86
133
  }
87
134
 
88
135
  /** 合并结果:文件表(后写覆盖先写)+ 合并后的工程清单增量 */
@@ -94,8 +141,9 @@ export interface MergedLayer {
94
141
  /**
95
142
  * 依次合并多层目录,先出现的层在前(骨架先,叠加层后)。
96
143
  *
97
- * 同路径后者覆盖前者;增量逐层深合并。返回的文件表保持「每个路径只出现一次」,
98
- * 值是最后写入的那份——调用方可以直接顺序写盘,不需要自己判重。
144
+ * 同路径后者覆盖前者;每层的删除名单先作用于已有文件表,再写入本层文件;
145
+ * 增量逐层深合并。返回的文件表保持「每个路径只出现一次」,值是最后写入的那份——
146
+ * 调用方可以直接顺序写盘,不需要自己判重。
99
147
  */
100
148
  export function mergeLayers(bases: readonly string[]): MergedLayer {
101
149
  const byPath = new Map<string, PendingFile>();
@@ -104,6 +152,11 @@ export function mergeLayers(bases: readonly string[]): MergedLayer {
104
152
  for (const base of bases) {
105
153
  if (!existsSync(base)) continue;
106
154
  const layer = readLayer(base);
155
+ if (layer.removes.length > 0) {
156
+ for (const path of [...byPath.keys()]) {
157
+ if (isRemoved(path, layer.removes)) byPath.delete(path);
158
+ }
159
+ }
107
160
  for (const file of layer.files) byPath.set(file.path, file);
108
161
  if (layer.patch) {
109
162
  patch = deepMerge(patch, layer.patch);
@@ -17,7 +17,7 @@ const PACKAGE_ROOT = resolve(
17
17
  "..",
18
18
  );
19
19
 
20
- /** 模板元数据:模板版本、占位符、可选的框架与壳 */
20
+ /** 模板元数据:模板版本、占位符、可选的框架、壳与故事源形态 */
21
21
  export interface TemplateMeta {
22
22
  /** 模板自身版本,独立于引擎版本 */
23
23
  readonly version: string;
@@ -29,6 +29,8 @@ export interface TemplateMeta {
29
29
  readonly frameworks: readonly TemplateOption[];
30
30
  /** 可选壳,首项为缺省值 */
31
31
  readonly shells: readonly TemplateOption[];
32
+ /** 可选故事源形态,首项为缺省值 */
33
+ readonly sources: readonly TemplateOption[];
32
34
  }
33
35
 
34
36
  /** 一个可选项(框架或壳):`id` 对应叠加层目录名 */
@@ -59,8 +61,8 @@ export function resolveTemplateRoot(): string | undefined {
59
61
  /**
60
62
  * 读取模板元数据。
61
63
  *
62
- * `frameworks` / `shells` 缺失时补成只剩缺省项的单元素表,而不是报错——旧模板
63
- * (只有骨架、没有框架分层)仍然可以被生成器消费,向后兼容由此保证。
64
+ * `frameworks` / `shells` / `sources` 缺失时补成只剩缺省项的单元素表,而不是报错——旧模板
65
+ * (只有骨架、没有分层)仍然可以被生成器消费,向后兼容由此保证。
64
66
  */
65
67
  export function readTemplateMeta(root: string): TemplateMeta {
66
68
  const raw = JSON.parse(
@@ -72,6 +74,7 @@ export function readTemplateMeta(root: string): TemplateMeta {
72
74
  placeholder: raw.placeholder,
73
75
  frameworks: raw.frameworks ?? [SKELETON_ONLY],
74
76
  shells: raw.shells ?? [BROWSER_ONLY],
77
+ sources: raw.sources ?? [STORY_ONLY],
75
78
  };
76
79
  }
77
80
 
@@ -89,6 +92,13 @@ const BROWSER_ONLY: TemplateOption = {
89
92
  hint: "只生成 Web 端工程",
90
93
  };
91
94
 
95
+ /** 旧模板无故事源分层时的缺省项 */
96
+ const STORY_ONLY: TemplateOption = {
97
+ id: "story",
98
+ label: "故事文件(.story)",
99
+ hint: "直接写引擎自带的文本故事格式",
100
+ };
101
+
92
102
  /** 叠加层目录名 */
93
103
  export const OVERLAY_DIR = "overlays";
94
104
 
@@ -17,6 +17,7 @@ export const defaultCreateTexts = {
17
17
  "选项:",
18
18
  " -f, --framework <id> 框架(缺省取模板声明的默认值)",
19
19
  " -s, --shell <id> 壳(缺省取模板声明的默认值)",
20
+ " -t, --sources <id> 故事源形态(缺省取模板声明的默认值)",
20
21
  " -d, --dir <路径> 生成到哪个目录下(缺省 = 当前目录)",
21
22
  " --no-install 生成后不自动安装依赖",
22
23
  " -y, --yes 全部取缺省值,不进入交互问答",
@@ -24,11 +25,13 @@ export const defaultCreateTexts = {
24
25
  "",
25
26
  "示例:",
26
27
  " npm create @lingfan-engine my-story -- --framework vanilla --shell browser",
28
+ " npm create @lingfan-engine my-story -- --sources ts",
27
29
  ].join("\n"),
28
30
  /** 交互问答的提示语 */
29
31
  promptProjectName: "工程名",
30
32
  promptFramework: "框架",
31
33
  promptShell: "壳",
34
+ promptSources: "故事源形态",
32
35
  promptInstall: "生成后立即安装依赖?",
33
36
  /** 参数校验失败 */
34
37
  nameRequired: "工程名不能为空",
@@ -37,6 +40,7 @@ export const defaultCreateTexts = {
37
40
  dirMissing: "目标目录不存在",
38
41
  frameworkUnknown: "未知框架",
39
42
  shellUnknown: "未知壳",
43
+ sourcesUnknown: "未知故事源形态",
40
44
  overlayMissing: "模板缺少该选项对应的叠加层",
41
45
  targetExists: "目标目录已存在且非空",
42
46
  templateMissing: "找不到模板目录:本包未随附模板内容,或开发态仓库结构不完整",
@@ -40,14 +40,16 @@ npm create @lingfan-engine my-story
40
40
  pnpm create @lingfan-engine my-story
41
41
  ```
42
42
 
43
- 生成器会问两件事:工程名、框架与壳。当前框架可选「原生(框架无关)」「Vue 3」「React」,
44
- 壳可选「浏览器」「小程序(UniApp)」与「桌面(Tauri 2)」。全部取缺省值(原生 + 浏览器)可以跳过问答:
43
+ 生成器会问四件事:工程名、框架、故事源形态、壳。当前框架可选「原生(框架无关)」「Vue 3」「React」,
44
+ 故事源形态可选「故事文件(`.story`)」与「TypeScript 源(`Stories.src/`)」,
45
+ 壳可选「浏览器」「小程序(UniApp)」与「桌面(Tauri 2)」。全部取缺省值(原生 + 故事文件 + 浏览器)可以跳过问答:
45
46
 
46
47
  ```
47
48
  npm create @lingfan-engine my-story -- --yes
48
49
  ```
49
50
 
50
- 生成后按提示 `cd my-story && pnpm install && pnpm dev`。
51
+ 生成后按提示 `cd my-story && pnpm install && pnpm dev`。选了 TypeScript 源形态的,
52
+ `pnpm dev` 之前先跑一次 `pnpm stories:build`(模板已带产物,改过源之后才需要)。
51
53
 
52
54
  想不开交互问答、直接指定框架与壳:
53
55
 
@@ -56,6 +58,7 @@ npm create @lingfan-engine my-story -- --framework vue --shell browser
56
58
  npm create @lingfan-engine my-story -- --framework react --shell browser
57
59
  npm create @lingfan-engine my-story -- --shell miniprogram
58
60
  npm create @lingfan-engine my-story -- --framework vue --shell tauri
61
+ npm create @lingfan-engine my-story -- --sources ts
59
62
  ```
60
63
 
61
64
  **框架层只换页面结构与入口,`src/host/**`(装配、订阅、帧循环、输入映射)一行不改,
@@ -67,6 +70,13 @@ npm create @lingfan-engine my-story -- --framework vue --shell tauri
67
70
  壳层与框架层可以叠加(先框架后壳):`--framework react --shell tauri` 得到 React 页面 + 桌面外壳。
68
71
  小程序是例外——它自带 Vue 页面层,不与其他框架层叠加。
69
72
 
73
+ **故事源形态层与框架层、壳层正交**,只决定故事从哪来:缺省形态直接改 `Resources/Stories/**/*.story`;
74
+ `ts` 形态改 `Stories.src/**/*.ts` 再跑构建,产物是 `Resources/Stories/**/*.json`。后者会删掉骨架那份
75
+ 手写故事(层里声明 `create.remove: ["Resources/Stories"]`),再补上开箱可跑的生成物与三条脚本
76
+ (`stories:build` / `stories:watch` / `stories-typecheck`)。产物目录由构建**全量管理**——源里没有的列
77
+ 构建会当陈旧列删掉,所以别在产物目录里手改;类型检查走独立的 `tsconfig.stories.json`(故事源有 ambient
78
+ 全局,不该和页面 DOM 类型面混在一个工程里)。
79
+
70
80
  **壳层是换一整套页面与宿主**:小程序形态的页面结构由 `src/components/**` 承接,宿主装配在
71
81
  `src/mp/**`(`uni.*` 只在 `src/mp/platform.ts` 出现一处),内容不进包体、走工程接口按基址取。
72
82
  因此它是壳而不是框架,与 `--framework` 不叠加:小程序已自带 Vue 页面层,框架取缺省值即可。
@@ -83,7 +93,8 @@ npm create @lingfan-engine my-story -- --framework vue --shell tauri
83
93
 
84
94
  模板依赖三个引擎包:`@lingfan-engine/core`(核心)、`@lingfan-engine/adapters`(预设适配器)、`@lingfan-engine/ui`(参考展示层)。
85
95
  它们以 **TS 源码出口**(`exports: "./src/index.ts"`)发布——消费方由 Vite/TS 直接编译,**无中间构建步骤**,
86
- `pnpm install` 直接可用。
96
+ `pnpm install` 直接可用。选了 TypeScript 源形态的工程会多两个创作期依赖
97
+ (`@lingfan-engine/stories-build` 与 `@lingfan-engine/editor`),由 `ts` 层自己声明。
87
98
 
88
99
  ## 工程结构(单资源根)
89
100
 
@@ -97,6 +108,7 @@ __PROJECT__/
97
108
  ├── vite.config.ts # publicDir: "Resources" ← 资源根即静态根
98
109
  ├── index.html # RenderTargets 落地:stage/dialogue/choices/notifications/toolbar
99
110
  ├── src/main.ts # 组合根:装配适配器 → 组装工程 → 建引擎 → 订阅渲染 + 帧驱动
111
+ ├── src/stories.ts # 故事文件表(Web 形态按显式清单取文件,全工程唯一声明点)
100
112
  ├── src/styles/stage.css # 页面与层样式(换框架不动这份:叠加层共用同一张表)
101
113
  └── Resources/ # 故事工程根(自包含、可整体搬运)
102
114
  ├── project.json # 工程清单:id/name/entry/lang/formatVersion
@@ -123,9 +135,11 @@ pwsh -File pack-template.ps1 -SourceDir ./__PROJECT__ -OutputFile ./__PROJECT__.
123
135
 
124
136
  ## 叠加层与占位符
125
137
 
126
- `template-meta.json` 的 `frameworks` / `shells` 声明生成器会问哪些选项,每项的 `id` 对应
127
- `overlays/<id>/` 目录:同路径文件**覆盖**骨架,根目录的 `*.patch.json` **深合并**进 `package.json`。
128
- 缺省项(`none` / `browser`)没有目录,表示「这一层什么都不加」,不是错误。
138
+ `template-meta.json` 的 `frameworks` / `sources` / `shells` 声明生成器会问哪些选项,每项的 `id` 对应
139
+ `overlays/<id>/` 目录:同路径文件**覆盖**骨架,根目录的 `*.patch.json` **深合并**进 `package.json`,
140
+ 其中 `create.remove` 列出本层要删掉的骨架路径(换形态时清残留)。缺省项(`none` / `story` / `browser`)
141
+ 没有目录,表示「这一层什么都不加」,不是错误。合并顺序固定为「骨架 → 框架 → 故事源 → 壳」:
142
+ 故事源要晚于框架层才能把各框架入口的故事表指向生成物;壳放最后,因为它对工程形态有最终发言权。
129
143
 
130
144
  骨架里的占位符有两种大小写形态:`__PROJECT__`(清单 id/name、页面标题、目录名)与
131
145
  `__project__`(`package.json` 的 name,npm 要求小写)。生成时两者都替换成工程名。
@@ -168,6 +168,27 @@ export function createDialogueView(options: DialogueViewOptions): DialogueView {
168
168
  });
169
169
  dom.choices.append(prompt, row);
170
170
  input.focus();
171
+ // 候选快捷值:点一项直接把该项 value 当输入提交(输入框仍可用)
172
+ const candidates =
173
+ (engine.get(SYS.inputOptions) as
174
+ Array<{ text?: unknown; value?: unknown }> | undefined) ?? [];
175
+ const shortcuts = candidates.filter(
176
+ (item) =>
177
+ typeof item?.text === "string" && typeof item?.value === "string",
178
+ ) as Array<{ text: string; value: string }>;
179
+ if (shortcuts.length > 0) {
180
+ const row2 = document.createElement("div");
181
+ row2.className = "choice-row";
182
+ for (const candidate of shortcuts) {
183
+ const button = document.createElement("button");
184
+ button.className = "choice";
185
+ button.type = "button";
186
+ button.textContent = candidate.text;
187
+ button.addEventListener("click", () => engine.input(candidate.value));
188
+ row2.append(button);
189
+ }
190
+ dom.choices.append(row2);
191
+ }
171
192
  } else if (waiting === "wait" || waiting === "video") {
172
193
  const note = document.createElement("p");
173
194
  note.className = "note";
@@ -45,6 +45,8 @@ const STATE_KEYS = {
45
45
  nvlBuffer: SYS.nvlBuffer,
46
46
  menuOptions: SYS.menuOptions,
47
47
  menuTargets: SYS.menuTargets,
48
+ // 输入候选:到达也走 choices 意图(否则候选要等下一次 waiting 变化才出现)
49
+ inputOptions: SYS.inputOptions,
48
50
  dialogVisible: SYS.dialogVisible,
49
51
  elements: SYS.elements,
50
52
  } as const;
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 存档接线:槽位下拉与存 / 读两个按钮。
3
3
  *
4
- * 槽位有几个、叫什么,由引擎按工程配置裁定(`project.json` 的 `shell.saves.slots`),
4
+ * 槽位有几个、叫什么,由引擎按工程配置判定(`project.json` 的 `shell.saves.slots`),
5
5
  * 宿主只负责把 id 放进 `<option>`。槽位口径若搬进宿主,「改工程配置却不生效」就会变成
6
6
  * 难查的静默失败。
7
7
  *
@@ -20,16 +20,15 @@
20
20
 
21
21
  import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
22
22
  import { bootHost, must, reportBootFailure, type StageDom } from "./host";
23
+ import { STORIES } from "./stories";
23
24
 
24
25
  /**
25
26
  * 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
26
27
  * 宿主把 `Resources` 作为静态根,故逻辑路径就是 `project.json` / `Stories/**`。
27
28
  * 清单放在资源根内是有意为之——只有资源根内的文件才会进打包产物,dev 与 prod 同机制。
28
- * **新增故事文件必须同步此表**(Web 形态按显式清单取文件)。
29
+ * 故事表在 `./stories`(唯一声明点,各形态共用)。
29
30
  */
30
31
  const MANIFEST = "project.json";
31
- /** 本工程的故事文件表(相对资源根;新增故事必须同步此表) */
32
- const STORIES = ["Stories/title/title_main.story"];
33
32
 
34
33
  /** 页面节点句柄表:每个 id 都必须存在于 index.html(缺一个 `must` 当场抛) */
35
34
  const dom: StageDom = {
@@ -0,0 +1,11 @@
1
+ /**
2
+ * 本工程的故事文件表(相对资源根 `Resources/`)——**全工程唯一声明点**。
3
+ *
4
+ * Web 形态按显式清单取文件(不做目录扫描:扫描会让「多一个文件」变成静默行为,
5
+ * 而打包产物里到底带了哪些故事必须一眼可见),因此新增故事文件要改这里。
6
+ * 各框架/壳的组合根都从这里取,不在自己的入口里各写一份。
7
+ *
8
+ * 走 TS 创作链的工程会整体替换本文件(构建产物是 `.json`,清单随之指向它),
9
+ * 入口与宿主一行不用改。
10
+ */
11
+ export const STORIES: readonly string[] = ["Stories/title/title_main.story"];
@@ -103,6 +103,12 @@ textarea,
103
103
  .input-row input {
104
104
  min-width: 200px;
105
105
  }
106
+ /* 输入候选:横向一排,不挤占输入行 */
107
+ .choice-row {
108
+ display: flex;
109
+ flex-wrap: wrap;
110
+ gap: 8px;
111
+ }
106
112
  .note {
107
113
  color: #6b7280;
108
114
  font-size: 13px;
@@ -1,15 +1,20 @@
1
1
  # 叠加层
2
2
 
3
- 每个子目录是一层可选的工程内容,目录名就是 `template-meta.json` 里 `frameworks[]` / `shells[]` 的 `id`。
4
- 生成器按「骨架 → 框架层 → 壳层」的顺序合并,同路径文件由后一层覆盖前一层。
3
+ 每个子目录是一层可选的工程内容,目录名就是 `template-meta.json` 里 `frameworks[]` / `sources[]` / `shells[]` 的 `id`。
4
+ 生成器按「骨架 → 框架层 → 故事源层 → 壳层」的顺序合并,同路径文件由后一层覆盖前一层。
5
5
 
6
6
  ## 缺省项没有目录
7
7
 
8
- `none`(框架无关)与 `browser`(浏览器)表示「这一层什么都不加」,所以这里看不到它们。
8
+ `none`(框架无关)、`story`(故事文件)与 `browser`(浏览器)表示「这一层什么都不加」,所以这里看不到它们。
9
9
  缺目录不是错误,只是没有内容可叠。
10
10
 
11
11
  ## 现有层
12
12
 
13
+ - `ts/` —— 故事源层,只换故事素材、不碰页面:新增 `Stories.src/`(`config.ts` + 两个示例列)、
14
+ 构建产物 `Resources/Stories/title/*.json` 与 `Stories.src/gen/*.gen.d.ts`、覆盖骨架的
15
+ `src/stories.ts`(清单改指 `.json` 产物)与独立的 `tsconfig.stories.json`。
16
+ `package.patch.json` 补 `@lingfan-engine/stories-build` / `@lingfan-engine/editor` 与三条脚本,
17
+ 并用 `create.remove` 删掉骨架那份手写故事(产物目录由构建全量管理,两套并存会把 `.story` 当陈旧列清掉)。
13
18
  - `vue/` —— 页面结构换成单文件组件:`index.html` 变成挂载点,新增 `src/App.vue` 与 Vue 版组合根,
14
19
  `package.patch.json` 补 `vue` / `@vitejs/plugin-vue` / `vue-tsc`,并把 `typecheck` 换成
15
20
  `vue-tsc --noEmit && tsc --noEmit -p tsconfig.node.json`。`src/host/**` 一行不改,
@@ -56,13 +61,19 @@
56
61
  - 任意工程文件:路径相对工程根,与骨架同名即覆盖。
57
62
  - `package.patch.json`(只在层根):深合并进产物 `package.json`。依赖表按字段合并,
58
63
  数组与标量整体取代——数组拼接会得到既非骨架也非叠加层的第三种值。
64
+ - `create.remove`(写在同一份 `package.patch.json` 里):字符串数组,点在它之前各层里的路径
65
+ (文件或目录,目录写不带尾斜杠也行),本层写入前先删掉它们。用来清掉换形态后不再需要的骨架文件。
66
+
67
+ ```json
68
+ { "create": { "remove": ["src/host", "src/styles"] } }
69
+ ```
59
70
 
60
71
  新增依赖只写进 `package.patch.json`,不要复制整份 `package.json`:整份复制会让骨架后来加的字段
61
72
  在这一层永久丢失。生成器会在合并之后统一查一次 registry,把依赖写成当下最新的 `^x.y.z`。
62
73
 
63
74
  ## 新增一层
64
75
 
65
- 1. 在 `template-meta.json` 的 `frameworks` 或 `shells` 里加一项 `{ id, label, hint }`。
76
+ 1. 在 `template-meta.json` 的 `frameworks`、`sources` 或 `shells` 里加一项 `{ id, label, hint }`。
66
77
  2. 建同名目录,放入该层独有的文件与 `package.patch.json`。
67
78
  3. 生成器不改:它对目录是缺席即跳过,不需要为新 `id` 写分支。
68
79
 
@@ -14,7 +14,6 @@
14
14
  | `src/pages.json`、`src/manifest.json` | 页面与平台声明 |
15
15
  | `Resources/` | 内容根:清单、故事、音频、视频、图片(**不随包体分发**) |
16
16
  | `scripts/serve-project.mjs` | 开发期内容服务 |
17
- | `src/host/**` | 浏览器形态的宿主,本形态不使用(见「已知残留」) |
18
17
 
19
18
  ## 起工程
20
19
 
@@ -81,11 +80,19 @@ pnpm dev:h5 # 等价 uni
81
80
  代价:`vite.config.ts` 不能跟着升到 8,脚本与插件类型按 5 的形态写;上游哪天跟进新 vite,
82
81
  这三处(`vite`、`vue`、插件)要成对改完再放开钉版。
83
82
 
84
- ## 已知残留
83
+ ## 被换掉的骨架文件
85
84
 
86
- `src/host/**` 与 `src/styles/stage.css` 是骨架的浏览器宿主,本形态不使用:没有人 import 它们,
87
- `tsconfig.json` 已把两者排除,类型检查不会碰。脚手架目前只做「同路径覆盖」、没有删除能力,
88
- 所以它们会留在工程里;删掉不影响运行。
85
+ 本形态换掉了整套页面与宿主,骨架里那些只服务浏览器形态的部件由本层
86
+ `package.patch.json` 的 `create.remove` 点名删除——生成器在合并时会真把它们从产物里去掉,
87
+ 不会留在工程里:
88
+
89
+ | 路径 | 为什么不带 |
90
+ | --------------- | ---------------- |
91
+ | `src/host/**` | 浏览器宿主的装配 |
92
+ | `src/styles/**` | 浏览器页面样式表 |
93
+
94
+ `src/vite-env.d.ts` 保留:它声明的 `virtual:story-fonts` 与本层 `vite.config.ts` 的字体插件
95
+ 一一对应,`src/mp/project-source.ts` 里的 `import.meta.env` 也靠它拿到 `vite/client` 类型。
89
96
 
90
97
  ## 验收这条链路
91
98
 
@@ -35,6 +35,7 @@
35
35
  "typescript",
36
36
  "vite",
37
37
  "vue"
38
- ]
38
+ ],
39
+ "remove": ["src/host", "src/styles"]
39
40
  }
40
41
  }
@@ -15,6 +15,5 @@
15
15
  "noUnusedParameters": true,
16
16
  "noFallthroughCasesInSwitch": true
17
17
  },
18
- "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"],
19
- "exclude": ["src/host/**", "src/styles/**"]
18
+ "include": ["src/**/*.ts", "src/**/*.vue", "vite.config.ts"]
20
19
  }
@@ -12,16 +12,15 @@ import { createElement, StrictMode } from "react";
12
12
  import { createRoot } from "react-dom/client";
13
13
  import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
14
14
  import { bootHost, must, reportBootFailure, type StageDom } from "./host";
15
+ import { STORIES } from "./stories";
15
16
  import { App } from "./App.tsx";
16
17
 
17
18
  /**
18
19
  * 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
19
20
  * 宿主把 `Resources` 作为静态根,故逻辑路径就是 `project.json` / `Stories/**`。
20
- * **新增故事文件必须同步此表**(Web 形态按显式清单取文件)。
21
+ * 故事表在 `./stories`(唯一声明点,各形态共用)。
21
22
  */
22
23
  const MANIFEST = "project.json";
23
- /** 本工程的故事文件表(相对资源根;新增故事必须同步此表) */
24
- const STORIES = ["Stories/title/title_main.story"];
25
24
 
26
25
  /** 页面节点句柄表:每个 id 都必须由 `App.tsx` 渲染(缺一个 `must` 当场抛) */
27
26
  function readDom(): StageDom {
@@ -1,5 +1,11 @@
1
1
  /**
2
- * 组合根(桌面壳形态):与骨架只差一处——把存档端口换成 Rust 实现。
2
+ * 组合根(桌面壳形态):与骨架只差两处——先装宿主桥,再把存档端口换成 Rust 实现。
3
+ *
4
+ * 为什么先装桥:`@lingfan-engine/adapters` 是给外部工程消费的源码包,包内不写
5
+ * `@tauri-apps/api` 的模块名(写了会让纯 Web 形态在构建期解析失败,写成变量说明符又会在
6
+ * 产物里残留裸模块名、WebView 执行时抛错)。模块名留在本文件这一层——本工程有这个依赖,
7
+ * 且只在壳里跑到这里;`installTauriBridge` 把两个函数交给适配器,之后各端口工厂的
8
+ * 缺省实现才可用。
3
9
  *
4
10
  * 为什么换存档:WebView 的 `localStorage` 落在系统给这个应用分配的 WebView 数据目录里,
5
11
  * 清缓存、换系统账户、重装都会带走存档;桌面工程的存档应当是可指路径上的文件。
@@ -12,17 +18,19 @@
12
18
  * 页面结构沿用骨架的 `index.html`(本层不带自己的),故节点解析与骨架同源。
13
19
  */
14
20
  import { DEFAULT_TEXT_CPS, DEFAULT_TOAST_MS } from "@lingfan-engine/ui";
15
- import { createTauriSavePort } from "@lingfan-engine/adapters";
21
+ import {
22
+ createTauriSavePort,
23
+ installTauriBridge,
24
+ } from "@lingfan-engine/adapters";
16
25
  import { bootHost, must, reportBootFailure, type StageDom } from "./host";
26
+ import { STORIES } from "./stories";
17
27
 
18
28
  /**
19
29
  * 工程清单与故事文件:**都在应用资源根 `Resources/` 内**。
20
30
  * 打包后它们位于 `dist/` 顶层,路径与开发期一致。
21
- * **新增故事文件必须同步此表**(Web 形态按显式清单取文件)。
31
+ * 故事表在 `./stories`(唯一声明点,各形态共用)。
22
32
  */
23
33
  const MANIFEST = "project.json";
24
- /** 本工程的故事文件表(相对资源根;新增故事必须同步此表) */
25
- const STORIES = ["Stories/title/title_main.story"];
26
34
 
27
35
  /** 页面节点句柄表:每个 id 都必须存在于 index.html(缺一个 `must` 当场抛) */
28
36
  const dom: StageDom = {
@@ -43,16 +51,32 @@ const dom: StageDom = {
43
51
  error: must("#error"),
44
52
  };
45
53
 
46
- void bootHost({
47
- dom,
48
- manifestFile: MANIFEST,
49
- stories: STORIES,
50
- defaultCps: DEFAULT_TEXT_CPS,
51
- toastDurationMs: DEFAULT_TOAST_MS,
52
- ports: {
53
- // 存档落到应用数据目录的 saves/ 下,一槽一文件(加密与回档保护在 Rust 侧扩展)
54
- saves: createTauriSavePort(),
55
- },
56
- }).catch((error: unknown) => {
54
+ /**
55
+ * 装配宿主桥并启动宿主。
56
+ *
57
+ * 静态说明符(不带 `@vite-ignore`)是刻意的:打包器据此把这两个模块打进产物,
58
+ * 运行期不再需要解析裸模块名。
59
+ */
60
+ async function boot(): Promise<void> {
61
+ const [{ invoke }, { listen }] = await Promise.all([
62
+ import("@tauri-apps/api/core"),
63
+ import("@tauri-apps/api/event"),
64
+ ]);
65
+ installTauriBridge({ invoke, listen });
66
+
67
+ await bootHost({
68
+ dom,
69
+ manifestFile: MANIFEST,
70
+ stories: STORIES,
71
+ defaultCps: DEFAULT_TEXT_CPS,
72
+ toastDurationMs: DEFAULT_TOAST_MS,
73
+ ports: {
74
+ // 存档落到应用数据目录的 saves/ 下,一槽一文件(加密与回档保护在 Rust 侧扩展)
75
+ saves: createTauriSavePort(),
76
+ },
77
+ });
78
+ }
79
+
80
+ boot().catch((error: unknown) => {
57
81
  reportBootFailure(dom, error);
58
82
  });
@@ -0,0 +1,16 @@
1
+ {
2
+ "formatVersion": 1,
3
+ "id": "look_around",
4
+ "kind": "flow",
5
+ "commands": [
6
+ {
7
+ "op": "say",
8
+ "text": "你绕着走了一圈,什么也没发生。",
9
+ "speaker": "旁白"
10
+ },
11
+ {
12
+ "op": "say",
13
+ "text": "想加点东西,就打开 Stories.src/title/look_around.ts 改写这一列。"
14
+ }
15
+ ]
16
+ }
@@ -0,0 +1,30 @@
1
+ {
2
+ "formatVersion": 1,
3
+ "id": "title_main",
4
+ "kind": "flow",
5
+ "commands": [
6
+ {
7
+ "op": "say",
8
+ "text": "这是用 TypeScript 写的故事列。",
9
+ "speaker": "旁白"
10
+ },
11
+ {
12
+ "op": "say",
13
+ "text": "改完保存,在工程根跑 pnpm stories:build 就会重新生成故事文件。"
14
+ },
15
+ {
16
+ "op": "menu",
17
+ "prompt": "接下来做什么?",
18
+ "options": [
19
+ {
20
+ "text": "四处看看",
21
+ "target": "look_around"
22
+ },
23
+ {
24
+ "text": "重来一次",
25
+ "target": "title_main"
26
+ }
27
+ ]
28
+ }
29
+ ]
30
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "formatVersion": 1,
3
+ "id": "title_main",
4
+ "name": "__PROJECT__",
5
+ "entry": "title_main",
6
+ "lang": "zh-CN",
7
+ "tsSource": true
8
+ }
@@ -0,0 +1,18 @@
1
+ /**
2
+ * 故事源配置:**整条 TS 创作链唯一的声明点**。
3
+ *
4
+ * 构建器只认这个文件:`folders` 说明哪些目录参与编译(相对本文件所在目录),
5
+ * `start` 说明入口故事是哪个文件(写文件名、含扩展名,全工程恰好一个)。
6
+ * 不在这里列出的目录会被当作草稿忽略——扫描整个目录会让「临时存个文件」
7
+ * 变成静默进产物,作者应该显式决定哪些目录算数。
8
+ *
9
+ * `vars` 声明插值键的取值类型(键名进类型检查,值只用于校验与编辑器提示)。
10
+ * 需要跨列共享的初始状态用 `define("player.gold", 100)` 声明,本工程示例没有用到。
11
+ */
12
+ import type {} from "@lingfan-engine/stories-build/authoring";
13
+
14
+ export default {
15
+ folders: ["title"],
16
+ start: "title_main.ts",
17
+ vars: defineVars({ name: "string" }),
18
+ } satisfies StoriesConfig;
@@ -0,0 +1,5 @@
1
+ /* GENERATED by stories:build (font-typegen) — 构建期自动生成,手改会在下次构建被覆盖。
2
+ 字体名注册表(Resources/Fonts 文件名去扩展名)的全局字面量类型面:键 = 值 = 字体名;
3
+ 未注册名不在本类型面,运行期字符串形态 {font=名} 仍走构建期注册表校验。 */
4
+
5
+ declare const font: Record<never, never>;
@@ -0,0 +1,5 @@
1
+ /* GENERATED by stories:build (script-typegen) — 构建期自动生成,手改会在下次构建被覆盖。
2
+ script 词汇层命名空间的免导入直呼声明:纯编译期类型面,不注入任何运行时对象;
3
+ 显式 import 与解构形态优先于本声明(TS 遮蔽语义,重命名不受影响)。 */
4
+
5
+ declare const script: typeof import("@lingfan-engine/editor").script;
@@ -0,0 +1,5 @@
1
+ /* GENERATED by stories:build (state-typegen) — 构建期自动生成,手改会在下次构建被覆盖。
2
+ state(支持自定义状态根名)为强类型产物,可直接以根名操作关键状态变量;
3
+ 运行期 define 动态注入的键不在本类型面。 */
4
+
5
+ export type StoryState = Record<string, never>;