@lingfan-engine/create 0.3.2 → 0.3.4
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/bin/create.mjs +2 -2
- package/package.json +1 -1
- package/src/cli/cli.ts +135 -55
- package/src/cli/index.ts +21 -0
- package/src/cli/install.ts +60 -0
- package/src/cli/prompts.ts +51 -4
- package/src/generate/index.ts +7 -2
- package/src/generate/project.ts +67 -20
- package/src/overlay/index.ts +1 -0
- package/src/overlay/merge.ts +27 -1
- package/src/shared/index.ts +8 -1
- package/src/shared/package-manager.ts +87 -0
- package/src/shared/texts.ts +21 -11
- package/template/README.md +34 -23
- package/template/__PROJECT__/package.json +6 -1
- package/template/__PROJECT__/package.patch.json +5 -0
- package/template/__PROJECT__/src/host/index.ts +1 -0
- package/template/__PROJECT__/src/host/native-ports.ts +56 -0
- package/template/__PROJECT__/src/main.ts +5 -2
- package/template/{overlays/ts → __PROJECT__}/tsconfig.stories.json +4 -1
- package/template/overlays/README.md +27 -13
- package/template/overlays/react/src/main.tsx +3 -0
- package/template/{__PROJECT__ → overlays/story}/Resources/Stories/title/title_main.story +1 -1
- package/template/overlays/story/package.patch.json +17 -0
- package/template/overlays/tauri/README.md +6 -5
- package/template/overlays/tauri/package.patch.json +1 -4
- package/template/overlays/vue/src/main.ts +3 -0
- package/template/template-meta.json +5 -5
- package/template/overlays/tauri/src/main.ts +0 -101
- package/template/overlays/ts/Resources/project.json +0 -8
- package/template/overlays/ts/package.patch.json +0 -13
- /package/template/{overlays/ts → __PROJECT__}/Resources/Stories/title/look_around.json +0 -0
- /package/template/{overlays/ts → __PROJECT__}/Resources/Stories/title/title_main.json +0 -0
- /package/template/{overlays/ts → __PROJECT__}/src/Stories.src/config.ts +0 -0
- /package/template/{overlays/ts → __PROJECT__}/src/Stories.src/title/look_around.ts +0 -0
- /package/template/{overlays/ts → __PROJECT__}/src/Stories.src/title/title_main.ts +0 -0
- /package/template/{overlays/ts → __PROJECT__}/src/gen/font.gen.d.ts +0 -0
- /package/template/{overlays/ts → __PROJECT__}/src/gen/script.gen.d.ts +0 -0
- /package/template/{overlays/ts → __PROJECT__}/src/gen/state.gen.d.ts +0 -0
- /package/template/{overlays/ts → __PROJECT__}/src/gen/stories.gen.d.ts +0 -0
package/src/generate/project.ts
CHANGED
|
@@ -14,6 +14,7 @@ import {
|
|
|
14
14
|
mkdirSync,
|
|
15
15
|
readdirSync,
|
|
16
16
|
readFileSync,
|
|
17
|
+
rmSync,
|
|
17
18
|
writeFileSync,
|
|
18
19
|
} from "node:fs";
|
|
19
20
|
import { dirname, join } from "node:path";
|
|
@@ -34,6 +35,14 @@ import { restoreDotfile } from "./dotfiles.ts";
|
|
|
34
35
|
/** 骨架目录名:占位符本身,也是模板里唯一随框架无关的目录 */
|
|
35
36
|
export const SKELETON_DIR = "__PROJECT__";
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* 目标目录已存在且非空时的处置方式。
|
|
40
|
+
*
|
|
41
|
+
* 三种都是使用者在生成前明确选过的,不可省略成「默认覆盖」:覆盖别人的工程
|
|
42
|
+
* 无法撤销,所以缺省仍是 `cancel`。
|
|
43
|
+
*/
|
|
44
|
+
export type ExistingTarget = "cancel" | "ignore" | "clear";
|
|
45
|
+
|
|
37
46
|
/** 生成参数 */
|
|
38
47
|
export interface GenerateOptions {
|
|
39
48
|
/** 模板根目录(含 `template-meta.json` 与骨架目录) */
|
|
@@ -52,6 +61,8 @@ export interface GenerateOptions {
|
|
|
52
61
|
* 可选是为了不破坏既有调用方——只关心框架与壳的代码不必为一个用不到的参数改签名。
|
|
53
62
|
*/
|
|
54
63
|
readonly sources?: string;
|
|
64
|
+
/** 目标目录非空时怎么办;缺省 `cancel`(报错退出,不碰磁盘) */
|
|
65
|
+
readonly existing?: ExistingTarget;
|
|
55
66
|
/** 引擎包的最新版本;缺席项沿用模板记录的值 */
|
|
56
67
|
readonly resolved: ReadonlyMap<string, string>;
|
|
57
68
|
}
|
|
@@ -81,19 +92,29 @@ export const DEFAULT_SOURCES = "ts";
|
|
|
81
92
|
/**
|
|
82
93
|
* 生成一个工程。
|
|
83
94
|
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
95
|
+
* 目标目录已存在且非空时按 `options.existing` 处置,缺省 `cancel`——覆盖别人的工程
|
|
96
|
+
* 无法撤销,所以静默覆盖不是可选项,必须由使用者先表态(见 `ExistingTarget`)。
|
|
86
97
|
*/
|
|
87
98
|
export function generateProject(options: GenerateOptions): GenerateResult {
|
|
88
99
|
const meta = readTemplateMeta(options.templateRoot);
|
|
89
|
-
const projectDir =
|
|
90
|
-
|
|
100
|
+
const projectDir = projectDirOf(options);
|
|
101
|
+
const occupied = isOccupied(projectDir);
|
|
102
|
+
const existing = options.existing ?? "cancel";
|
|
103
|
+
if (occupied && existing === "cancel") {
|
|
91
104
|
throw new Error(`目标目录已存在且非空:${projectDir}`);
|
|
92
105
|
}
|
|
106
|
+
if (occupied && existing === "clear") {
|
|
107
|
+
// 清空的是「目录里的内容」而不是目录本身:调用方可能已经把 shell 的 cwd
|
|
108
|
+
// 放在里面,删掉目录本身会让它指向一个不存在的 inode。
|
|
109
|
+
for (const entry of readdirSync(projectDir)) {
|
|
110
|
+
rmSync(join(projectDir, entry), { recursive: true, force: true });
|
|
111
|
+
}
|
|
112
|
+
}
|
|
93
113
|
const merged = mergeLayers(layerBases(options));
|
|
94
114
|
const files = finalize(
|
|
95
115
|
merged.files,
|
|
96
116
|
merged.patch,
|
|
117
|
+
merged.pinned,
|
|
97
118
|
options.resolved,
|
|
98
119
|
meta.placeholder,
|
|
99
120
|
options.projectName,
|
|
@@ -139,8 +160,15 @@ export function layerBases(options: GenerateOptions): string[] {
|
|
|
139
160
|
return bases;
|
|
140
161
|
}
|
|
141
162
|
|
|
163
|
+
/** 产物目录:`<targetBase>/<projectName>`;单独抽出来供 CLI 在写盘前提示用 */
|
|
164
|
+
export function projectDirOf(
|
|
165
|
+
options: Pick<GenerateOptions, "targetBase" | "projectName">,
|
|
166
|
+
): string {
|
|
167
|
+
return join(options.targetBase, options.projectName);
|
|
168
|
+
}
|
|
169
|
+
|
|
142
170
|
/** 目标目录是否已被占用:存在且含有内容即算占用,空目录可以复用 */
|
|
143
|
-
function isOccupied(dir: string): boolean {
|
|
171
|
+
export function isOccupied(dir: string): boolean {
|
|
144
172
|
if (!existsSync(dir)) return false;
|
|
145
173
|
return readdirSync(dir).length > 0;
|
|
146
174
|
}
|
|
@@ -181,10 +209,14 @@ export function collectDependencyNames(bases: readonly string[]): string[] {
|
|
|
181
209
|
*
|
|
182
210
|
* 点文件还原必须在这里发生:模板里存的是躲开 npm 改名的普通名(见 `dotfiles.ts`),
|
|
183
211
|
* 产物路径要写回带点的形态,占位符替换才按产物路径判文本/二进制。
|
|
212
|
+
*
|
|
213
|
+
* `pinned` 由合并层给出而不是在这里从 `patch` 里再读一次:`pinned` 是数组,
|
|
214
|
+
* 深合并后只剩最后一层的名单,必须用逐层取并集的结果(见 `overlay` 域)。
|
|
184
215
|
*/
|
|
185
216
|
export function finalize(
|
|
186
217
|
files: readonly PendingFile[],
|
|
187
218
|
patch: Readonly<Record<string, unknown>> | undefined,
|
|
219
|
+
pinned: readonly string[],
|
|
188
220
|
resolved: ReadonlyMap<string, string>,
|
|
189
221
|
placeholder: string,
|
|
190
222
|
projectName: string,
|
|
@@ -193,7 +225,7 @@ export function finalize(
|
|
|
193
225
|
const path = restoreDotfile(file.path);
|
|
194
226
|
const content =
|
|
195
227
|
file.path === "package.json"
|
|
196
|
-
? renderManifest(file.content, patch, resolved)
|
|
228
|
+
? renderManifest(file.content, patch, pinned, resolved)
|
|
197
229
|
: file.content;
|
|
198
230
|
return {
|
|
199
231
|
path,
|
|
@@ -212,6 +244,7 @@ export function finalize(
|
|
|
212
244
|
function renderManifest(
|
|
213
245
|
content: Buffer,
|
|
214
246
|
patch: Readonly<Record<string, unknown>> | undefined,
|
|
247
|
+
pinned: readonly string[],
|
|
215
248
|
resolved: ReadonlyMap<string, string>,
|
|
216
249
|
): Buffer {
|
|
217
250
|
const manifest = JSON.parse(content.toString("utf8")) as Record<
|
|
@@ -220,14 +253,15 @@ function renderManifest(
|
|
|
220
253
|
>;
|
|
221
254
|
const withPatch =
|
|
222
255
|
patch === undefined ? manifest : mergeManifest(manifest, patch);
|
|
256
|
+
applyUnset(withPatch, patch);
|
|
223
257
|
delete withPatch[CREATE_FIELD];
|
|
224
|
-
const
|
|
258
|
+
const pinnedSet = new Set(pinned);
|
|
225
259
|
for (const field of ["dependencies", "devDependencies"] as const) {
|
|
226
260
|
const table = withPatch[field];
|
|
227
261
|
if (isStringMap(table)) {
|
|
228
262
|
withPatch[field] = applyResolvedVersions(
|
|
229
263
|
table,
|
|
230
|
-
withoutNames(resolved,
|
|
264
|
+
withoutNames(resolved, pinnedSet),
|
|
231
265
|
);
|
|
232
266
|
}
|
|
233
267
|
}
|
|
@@ -272,21 +306,34 @@ function mergeManifest(
|
|
|
272
306
|
const CREATE_FIELD = "create";
|
|
273
307
|
|
|
274
308
|
/**
|
|
275
|
-
*
|
|
309
|
+
* 把增量点名的清单键抹掉。
|
|
276
310
|
*
|
|
277
|
-
*
|
|
278
|
-
*
|
|
279
|
-
*
|
|
280
|
-
|
|
281
|
-
|
|
311
|
+
* `mergeManifest` 只合并键、不删键,所以 `scripts` 与 `devDependencies` 这类表
|
|
312
|
+
* 在叠加层里只能增不能减。可反向叠加层需要的正是减法:故事文件形态摘掉骨架的
|
|
313
|
+
* TS 创作链之后,配套的三条脚本与创作期依赖也应当一起消失,否则产物里留着
|
|
314
|
+
* 一个必然失败的命令。
|
|
315
|
+
*
|
|
316
|
+
* 形状与 `create.remove` 并列:外层是 `package.json` 的字段名,内层是要去掉的键名。
|
|
317
|
+
* 缺省项不必写它——缺省形态什么都不叠,本来就没有要减的东西。
|
|
318
|
+
*/ function applyUnset(
|
|
319
|
+
manifest: Record<string, unknown>,
|
|
282
320
|
patch: Readonly<Record<string, unknown>> | undefined,
|
|
283
|
-
):
|
|
284
|
-
if (patch === undefined) return
|
|
321
|
+
): void {
|
|
322
|
+
if (patch === undefined) return;
|
|
285
323
|
const section = patch[CREATE_FIELD];
|
|
286
|
-
if (!isPlainRecord(section)) return
|
|
287
|
-
const
|
|
288
|
-
if (!
|
|
289
|
-
|
|
324
|
+
if (!isPlainRecord(section)) return;
|
|
325
|
+
const unset = section.unset;
|
|
326
|
+
if (!isPlainRecord(unset)) return;
|
|
327
|
+
for (const [field, keys] of Object.entries(unset)) {
|
|
328
|
+
if (!Array.isArray(keys)) continue;
|
|
329
|
+
const table = manifest[field];
|
|
330
|
+
if (!isPlainRecord(table)) continue;
|
|
331
|
+
const next: Record<string, unknown> = { ...table };
|
|
332
|
+
for (const key of keys) {
|
|
333
|
+
if (typeof key === "string") delete next[key];
|
|
334
|
+
}
|
|
335
|
+
manifest[field] = next;
|
|
336
|
+
}
|
|
290
337
|
}
|
|
291
338
|
|
|
292
339
|
/** 只认普通对象:数组与 null 不算 */
|
package/src/overlay/index.ts
CHANGED
package/src/overlay/merge.ts
CHANGED
|
@@ -132,10 +132,33 @@ function isRemoved(path: string, removes: readonly string[]): boolean {
|
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
-
/** 合并结果:文件表(后写覆盖先写)+ 合并后的工程清单增量 */
|
|
135
|
+
/** 合并结果:文件表(后写覆盖先写)+ 合并后的工程清单增量 + 各层钉住的依赖名并集 */
|
|
136
136
|
export interface MergedLayer {
|
|
137
137
|
readonly files: readonly PendingFile[];
|
|
138
138
|
readonly patch: Readonly<Record<string, unknown>> | undefined;
|
|
139
|
+
readonly pinned: readonly string[];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* 读本层 `create.pinned` 点名的依赖名。
|
|
144
|
+
*
|
|
145
|
+
* 与 `removeList` 同理,必须**每层各读一次**:`pinned` 是数组,深合并时由后者整体
|
|
146
|
+
* 取代,两层各钉各的包,合并后只剩最后一层的名单——`vue` 钉了 `typescript`、
|
|
147
|
+
* `tauri` 钉了 `@tauri-apps/*`,后者在后就把前者的钉法抹掉,于是 `vue-tsc` 拿到
|
|
148
|
+
* 了它不兼容的 typescript 大版本,`typecheck` 直接崩。
|
|
149
|
+
*
|
|
150
|
+
* 与 `remove` 不同的是,钉住是**累加声明**(每个层都在说「这几个包别跟最新」),
|
|
151
|
+
* 所以合并语义是并集而不是逐层施加的动作。
|
|
152
|
+
*/
|
|
153
|
+
export function pinnedNames(
|
|
154
|
+
patch: Readonly<Record<string, unknown>> | undefined,
|
|
155
|
+
): readonly string[] {
|
|
156
|
+
if (patch === undefined) return [];
|
|
157
|
+
const section = patch[CREATE_FIELD];
|
|
158
|
+
if (!isPlainObject(section)) return [];
|
|
159
|
+
const raw = section.pinned;
|
|
160
|
+
if (!Array.isArray(raw)) return [];
|
|
161
|
+
return raw.filter((item): item is string => typeof item === "string");
|
|
139
162
|
}
|
|
140
163
|
|
|
141
164
|
/**
|
|
@@ -149,6 +172,7 @@ export function mergeLayers(bases: readonly string[]): MergedLayer {
|
|
|
149
172
|
const byPath = new Map<string, PendingFile>();
|
|
150
173
|
let patch: Record<string, unknown> = {};
|
|
151
174
|
let hasPatch = false;
|
|
175
|
+
const pinned = new Set<string>();
|
|
152
176
|
for (const base of bases) {
|
|
153
177
|
if (!existsSync(base)) continue;
|
|
154
178
|
const layer = readLayer(base);
|
|
@@ -158,6 +182,7 @@ export function mergeLayers(bases: readonly string[]): MergedLayer {
|
|
|
158
182
|
}
|
|
159
183
|
}
|
|
160
184
|
for (const file of layer.files) byPath.set(file.path, file);
|
|
185
|
+
for (const name of pinnedNames(layer.patch)) pinned.add(name);
|
|
161
186
|
if (layer.patch) {
|
|
162
187
|
patch = deepMerge(patch, layer.patch);
|
|
163
188
|
hasPatch = true;
|
|
@@ -166,6 +191,7 @@ export function mergeLayers(bases: readonly string[]): MergedLayer {
|
|
|
166
191
|
return {
|
|
167
192
|
files: [...byPath.values()],
|
|
168
193
|
patch: hasPatch ? patch : undefined,
|
|
194
|
+
pinned: [...pinned],
|
|
169
195
|
};
|
|
170
196
|
}
|
|
171
197
|
|
package/src/shared/index.ts
CHANGED
|
@@ -1,10 +1,17 @@
|
|
|
1
|
-
/** shared
|
|
1
|
+
/** shared 域唯一出口:文案表、参数校验、包管理器识别与版本运算。 */
|
|
2
2
|
export { defaultCreateTexts, type CreateTexts } from "./texts.ts";
|
|
3
3
|
export {
|
|
4
4
|
caretRange,
|
|
5
5
|
isValidProjectName,
|
|
6
6
|
suggestProjectName,
|
|
7
7
|
} from "./guards.ts";
|
|
8
|
+
export {
|
|
9
|
+
currentPackageManager,
|
|
10
|
+
detectPackageManager,
|
|
11
|
+
parsePackageManager,
|
|
12
|
+
type PackageManager,
|
|
13
|
+
type PackageManagerId,
|
|
14
|
+
} from "./package-manager.ts";
|
|
8
15
|
export {
|
|
9
16
|
compareSemver,
|
|
10
17
|
floorOfRange,
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 包管理器识别。
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要识别:生成器是给**别人**用的工具,不能假定对方用 pnpm。安装依赖与
|
|
5
|
+
* 打印后续步骤都要跟随调用方的包管理器——`npm create` 就该给出 npm 的命令,
|
|
6
|
+
* `pnpm create` 给出 pnpm 的。本仓内部开发固定 pnpm 是仓库约定,不是产物约定。
|
|
7
|
+
*
|
|
8
|
+
* 识别依据是 npm 生态的既定事实 `npm_config_user_agent`(`npm create` /
|
|
9
|
+
* `pnpm create` / `yarn create` / `bun create` 都会设置它)。拿不到时退回
|
|
10
|
+
* `npm_execpath` 的文件名,再拿不到按 npm 处理——这是最不会误导人的缺省,
|
|
11
|
+
* 因为 npm 的命令形式(`npm run dev`)在 pnpm/yarn 下也基本能跑。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** 生成器认得的包管理器;不认识的都按 npm 处理 */
|
|
15
|
+
export type PackageManagerId = "npm" | "pnpm" | "yarn" | "bun";
|
|
16
|
+
|
|
17
|
+
/** 一个包管理器的命令写法 */
|
|
18
|
+
export interface PackageManager {
|
|
19
|
+
readonly id: PackageManagerId;
|
|
20
|
+
/** 跑一个 `package.json` 脚本的写法,如 `dev` / `run dev` */
|
|
21
|
+
readonly script: string;
|
|
22
|
+
/** 安装依赖的写法(四家都是 `install`,保留字段是为了日后出现例外时有落点) */
|
|
23
|
+
readonly install: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const MANAGERS: Readonly<Record<PackageManagerId, PackageManager>> = {
|
|
27
|
+
npm: { id: "npm", script: "run dev", install: "install" },
|
|
28
|
+
pnpm: { id: "pnpm", script: "dev", install: "install" },
|
|
29
|
+
yarn: { id: "yarn", script: "dev", install: "install" },
|
|
30
|
+
bun: { id: "bun", script: "run dev", install: "install" },
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const KNOWN = ["npm", "pnpm", "yarn", "bun"] as const;
|
|
34
|
+
|
|
35
|
+
/** 把一段文本折成包管理器名:去扩展名、去版本尾缀 */
|
|
36
|
+
function normalize(segment: string): PackageManagerId | undefined {
|
|
37
|
+
const name = segment
|
|
38
|
+
.toLowerCase()
|
|
39
|
+
.replace(/\.(?:c|m)?js$|\.exe$/u, "")
|
|
40
|
+
.split("@")[0];
|
|
41
|
+
return KNOWN.find((id) => id === name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 从 `user-agent` 或可执行路径里认出包管理器。
|
|
46
|
+
*
|
|
47
|
+
* 两种输入形态不同,故首段与末段都试:`user-agent` 是
|
|
48
|
+
* `pm/版本 平台/…`(首段有名),可执行路径是 `/usr/local/bin/pm.cjs`
|
|
49
|
+
* (末段有名,首段是空串或盘符)。取第一个认得出的。
|
|
50
|
+
*/
|
|
51
|
+
export function parsePackageManager(
|
|
52
|
+
hint: string | undefined,
|
|
53
|
+
): PackageManagerId | undefined {
|
|
54
|
+
if (hint === undefined) return undefined;
|
|
55
|
+
const segments = hint.split(/[/\\]/u).filter((part) => part !== "");
|
|
56
|
+
for (const segment of [segments[0], segments[segments.length - 1]]) {
|
|
57
|
+
if (segment === undefined) continue;
|
|
58
|
+
const found = normalize(segment);
|
|
59
|
+
if (found !== undefined) return found;
|
|
60
|
+
}
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* 当前进程由哪个包管理器发起。
|
|
66
|
+
*
|
|
67
|
+
* 优先级:`npm_config_user_agent`(最权威,四家都会设)→ `npm_execpath`
|
|
68
|
+
* (垫片被直接 `node` 调用时的退路)→ npm(缺省)。
|
|
69
|
+
*/
|
|
70
|
+
export function detectPackageManager(env: {
|
|
71
|
+
readonly userAgent?: string | undefined;
|
|
72
|
+
readonly execPath?: string | undefined;
|
|
73
|
+
}): PackageManager {
|
|
74
|
+
const id =
|
|
75
|
+
parsePackageManager(env.userAgent) ??
|
|
76
|
+
parsePackageManager(env.execPath) ??
|
|
77
|
+
"npm";
|
|
78
|
+
return MANAGERS[id];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 从真实进程环境里取一个 */
|
|
82
|
+
export function currentPackageManager(): PackageManager {
|
|
83
|
+
return detectPackageManager({
|
|
84
|
+
userAgent: process.env.npm_config_user_agent,
|
|
85
|
+
execPath: process.env.npm_execpath,
|
|
86
|
+
});
|
|
87
|
+
}
|
package/src/shared/texts.ts
CHANGED
|
@@ -13,47 +13,57 @@ export const defaultCreateTexts = {
|
|
|
13
13
|
"用法:",
|
|
14
14
|
" npm create @lingfan-engine [工程名] [选项]",
|
|
15
15
|
" pnpm create @lingfan-engine [工程名] [选项]",
|
|
16
|
+
" yarn create @lingfan-engine [工程名] [选项]",
|
|
16
17
|
"",
|
|
18
|
+
"包管理器不限:生成后的安装命令与后续步骤按调用方自己的包管理器给出。",
|
|
17
19
|
"选项:",
|
|
20
|
+
" --template <id> 一次性指定形态,写作 框架[-壳[-故事源]],如 vue-tauri",
|
|
18
21
|
" -f, --framework <id> 框架(缺省取模板声明的默认值)",
|
|
19
22
|
" -s, --shell <id> 壳(缺省取模板声明的默认值)",
|
|
20
|
-
" -t, --sources <id>
|
|
23
|
+
" -t, --sources <id> 故事源形态(缺省 ts,一般不必指定)",
|
|
21
24
|
" -d, --dir <路径> 生成到哪个目录下(缺省 = 当前目录)",
|
|
22
|
-
" --
|
|
25
|
+
" --install 生成后立即安装依赖",
|
|
26
|
+
" --overwrite 目标目录非空时清空后继续",
|
|
23
27
|
" -y, --yes 全部取缺省值,不进入交互问答",
|
|
24
28
|
" -h, --help 显示本说明",
|
|
25
29
|
"",
|
|
26
30
|
"示例:",
|
|
27
|
-
" npm create @lingfan-engine my-story -- --
|
|
28
|
-
" npm create @lingfan-engine my-story -- --
|
|
31
|
+
" npm create @lingfan-engine my-story -- --template vue-browser",
|
|
32
|
+
" npm create @lingfan-engine my-story -- --template vue-tauri --install",
|
|
29
33
|
].join("\n"),
|
|
30
34
|
/** 交互问答的提示语 */
|
|
31
35
|
promptProjectName: "工程名",
|
|
32
36
|
promptFramework: "框架",
|
|
33
37
|
promptShell: "壳",
|
|
34
|
-
|
|
35
|
-
promptInstall: "生成后立即安装依赖?",
|
|
38
|
+
promptExisting: "目标目录已存在且有内容,怎么处理",
|
|
36
39
|
/** 参数校验失败 */
|
|
37
40
|
nameRequired: "工程名不能为空",
|
|
38
41
|
nameInvalid:
|
|
39
42
|
"工程名只能包含小写字母、数字、连字符与下划线,且不能以点或连字符开头",
|
|
40
|
-
dirMissing: "目标目录不存在",
|
|
41
43
|
frameworkUnknown: "未知框架",
|
|
42
44
|
shellUnknown: "未知壳",
|
|
43
45
|
sourcesUnknown: "未知故事源形态",
|
|
44
|
-
|
|
45
|
-
|
|
46
|
+
templateMalformed: "形态写法不对,应为 框架[-壳[-故事源]]",
|
|
47
|
+
/** 目标目录已存在时的三种处置 */
|
|
48
|
+
existingIgnore: "保留现有文件,同名覆盖",
|
|
49
|
+
existingIgnoreHint: "只写本模板的文件,别的东西原地不动",
|
|
50
|
+
existingClear: "清空目录后生成",
|
|
51
|
+
existingClearHint: "删掉目录里的全部内容,不可撤销",
|
|
52
|
+
existingCancel: "取消",
|
|
53
|
+
existingCancelHint: "什么都不做,退出",
|
|
54
|
+
targetExists: "目标目录已存在且非空,未做任何改动",
|
|
46
55
|
templateMissing: "找不到模板目录:本包未随附模板内容,或开发态仓库结构不完整",
|
|
47
56
|
/** 生成过程 */
|
|
48
57
|
stageResolve: "解析模板与版本…",
|
|
49
58
|
stageCopy: "写入工程文件…",
|
|
50
59
|
stageInstall: "安装依赖…",
|
|
51
60
|
doneTitle: "工程已生成",
|
|
52
|
-
|
|
61
|
+
/** 生成后的三步提示;`<install>` / `<dev>` 由调用方按包管理器替换 */
|
|
62
|
+
nextSteps: ["cd <name>", "<install>", "<dev>"].join("\n"),
|
|
53
63
|
/** 非致命提示 */
|
|
54
64
|
warnVersionOffline:
|
|
55
65
|
"无法查询 npm registry 上的最新版本,已沿用模板记录的版本",
|
|
56
|
-
warnInstallFailed: "
|
|
66
|
+
warnInstallFailed: "依赖安装失败,请在工程目录内手动安装依赖",
|
|
57
67
|
} as const;
|
|
58
68
|
|
|
59
69
|
export type CreateTexts = typeof defaultCreateTexts;
|
package/template/README.md
CHANGED
|
@@ -41,27 +41,31 @@ npm create @lingfan-engine my-story
|
|
|
41
41
|
pnpm create @lingfan-engine my-story
|
|
42
42
|
```
|
|
43
43
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
生成器会问三件事:工程名、框架、壳。当前框架可选「原生(框架无关)」「Vue 3」「React」,
|
|
45
|
+
壳可选「浏览器」「小程序(UniApp)」与「桌面(Tauri 2)」。故事源形态不是问题——骨架自带整条 TS 创作链,
|
|
46
|
+
它就是缺省形态;要手写 `.story` 文件用 `--sources story` 指定。全部取缺省值(原生 + TypeScript 源 + 浏览器)可以跳过问答:
|
|
47
47
|
|
|
48
48
|
```
|
|
49
49
|
npm create @lingfan-engine my-story -- --yes
|
|
50
50
|
```
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
`
|
|
52
|
+
生成后按提示进目录、装依赖、跑起来。生成器不指定包管理器——它按启动自己的那个(npm / pnpm / yarn / bun)给出命令,用哪个都行。骨架自带编译好的故事产物,开箱即跑;
|
|
53
|
+
改过 `src/Stories.src/` 之后才需要跑一次 `pnpm stories:build`(或在开发时挂 `pnpm stories:watch`)。
|
|
54
|
+
生成器默认不装依赖,想一步到位加 `--install`。
|
|
54
55
|
|
|
55
|
-
|
|
56
|
+
想不开交互问答、一次指定形态(写作 `框架[-壳[-故事源]]`):
|
|
56
57
|
|
|
57
58
|
```
|
|
58
|
-
npm create @lingfan-engine my-story -- --
|
|
59
|
-
npm create @lingfan-engine my-story -- --
|
|
60
|
-
npm create @lingfan-engine my-story -- --
|
|
61
|
-
npm create @lingfan-engine my-story -- --
|
|
62
|
-
npm create @lingfan-engine my-story -- --
|
|
59
|
+
npm create @lingfan-engine my-story -- --template vue-browser
|
|
60
|
+
npm create @lingfan-engine my-story -- --template react-browser
|
|
61
|
+
npm create @lingfan-engine my-story -- --template none-miniprogram
|
|
62
|
+
npm create @lingfan-engine my-story -- --template vue-tauri
|
|
63
|
+
npm create @lingfan-engine my-story -- --template none-browser-story
|
|
63
64
|
```
|
|
64
65
|
|
|
66
|
+
也可以逐项给:`--framework vue --shell browser`、`--shell miniprogram`、`--sources story`;两者冲突时逐项参数优先。
|
|
67
|
+
目标目录非空时生成器会问怎么处置(保留现有文件 / 清空 / 取消),`--overwrite` 是「清空」的非交互写法。
|
|
68
|
+
|
|
65
69
|
**框架层只换页面结构与入口,`src/host/**`(装配、订阅、帧循环、输入映射)一行不改,
|
|
66
70
|
样式表仍是同一份 `src/styles/stage.css`**,所以换框架不影响游玩界面的外观口径,
|
|
67
71
|
也不影响引擎与适配器——它们不认识任何 UI 框架。选 Vue 3 是 `index.html` 换成挂载点、
|
|
@@ -71,12 +75,15 @@ npm create @lingfan-engine my-story -- --sources ts
|
|
|
71
75
|
壳层与框架层可以叠加(先框架后壳):`--framework react --shell tauri` 得到 React 页面 + 桌面外壳。
|
|
72
76
|
小程序是例外——它自带 Vue 页面层,不与其他框架层叠加。
|
|
73
77
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
78
|
+
**故事源形态层与框架层、壳层正交**,只决定故事从哪来。缺省形态是 **TypeScript 源**,整条链在骨架里:
|
|
79
|
+
改 `src/Stories.src/**/*.ts`,跑 `pnpm stories:build` 编译成 `Resources/Stories/**/*.json`。产物目录由构建
|
|
80
|
+
**全量管理**——源里没有的列构建会当陈旧列删掉,所以别在产物目录里手改;类型检查走独立的
|
|
81
|
+
`tsconfig.stories.json`(故事源有 ambient 全局,不该和页面 DOM 类型面混在一个工程里)。骨架自带三条脚本
|
|
82
|
+
(`stories:build` / `stories:watch` / `stories-typecheck`)与开箱可跑的产物,`pnpm dev` 不必先构建。
|
|
83
|
+
|
|
84
|
+
选「故事文件(`.story`)」则是**反向叠加**:`overlays/story/` 用 `create.remove` 摘掉骨架的整条 TS 创作链
|
|
85
|
+
(`src/Stories.src`、`src/gen/*.gen.d.ts`、`tsconfig.stories.json`),换上一份手写的
|
|
86
|
+
`Resources/Stories/title/title_main.story`,改完保存即生效。这条形态的产物目录里可以 `.story` 与 `.json` 混存。
|
|
80
87
|
|
|
81
88
|
**壳层是换一整套页面与宿主**:小程序形态的页面结构由 `src/components/**` 承接,宿主装配在
|
|
82
89
|
`src/mp/**`(`uni.*` 只在 `src/mp/platform.ts` 出现一处),内容不进包体、走工程接口按基址取。
|
|
@@ -94,8 +101,9 @@ npm create @lingfan-engine my-story -- --sources ts
|
|
|
94
101
|
|
|
95
102
|
模板依赖三个引擎包:`@lingfan-engine/core`(核心)、`@lingfan-engine/adapters`(预设适配器)、`@lingfan-engine/ui`(参考展示层)。
|
|
96
103
|
它们以 **TS 源码出口**(`exports: "./src/index.ts"`)发布——消费方由 Vite/TS 直接编译,**无中间构建步骤**,
|
|
97
|
-
`pnpm install`
|
|
98
|
-
|
|
104
|
+
`pnpm install` 直接可用。骨架还声明两个创作期依赖(`@lingfan-engine/stories-build` 与
|
|
105
|
+
`@lingfan-engine/editor`)——缺省故事源形态是 TS,这条链归骨架;选「故事文件」形态时
|
|
106
|
+
`overlays/story/` 会把链上的文件、脚本与依赖条目一并摘掉(见下文叠加层说明)。
|
|
99
107
|
|
|
100
108
|
## 工程结构(单资源根)
|
|
101
109
|
|
|
@@ -106,6 +114,7 @@ __PROJECT__/
|
|
|
106
114
|
├── pnpm-workspace.yaml # 只声明 allowBuilds:pnpm 12 起「未批准的构建脚本」是硬错误
|
|
107
115
|
├── tsconfig.json # 浏览器侧:src/ 下的 ts 与 vue
|
|
108
116
|
├── tsconfig.node.json # Node 侧:vite.config.ts 与 vite.plugins.ts(要 node:fs 与 @types/node)
|
|
117
|
+
├── tsconfig.stories.json # 故事源独立工程:查 src/Stories.src/ 与 src/gen/ 的类型面(纯 tsc,不带页面 DOM 类型面)
|
|
109
118
|
├── vite.config.ts # publicDir: "Resources" ← 资源根即静态根
|
|
110
119
|
├── vite.plugins.ts # 构建期扫描:故事清单与字体清单(虚拟模块,层只换插件数组)
|
|
111
120
|
├── index.html # RenderTargets 落地:stage/dialogue/choices/notifications/toolbar(+ 可选 #prefs / #mounts)
|
|
@@ -113,7 +122,8 @@ __PROJECT__/
|
|
|
113
122
|
├── src/stories/ # 故事清单出口(转发 virtual:story-list;清单本身来自磁盘扫描)
|
|
114
123
|
├── src/mounts.ts # 挂载表出口 + mountStore(框架形态从这里订阅挂载值)
|
|
115
124
|
├── src/controls/ # 宿主控制面出口:挂载 UI 拿 pause/navigate/save/load 的地方
|
|
116
|
-
├── src/
|
|
125
|
+
├── src/Stories.src/ # 故事源(TS 创作链):config.ts 声明章节与入口,一函数 = 一列
|
|
126
|
+
├── src/gen/ # 构建生成物:故事类型面、字体、守卫表、UI 注册表(手改会被覆盖)
|
|
117
127
|
├── src/ui/ # 可挂载界面(`<名>/<名>.<扩展名>`,可缺省:没有就不生成注册表)
|
|
118
128
|
├── src/host/ # 宿主:装配、订阅、帧循环、输入映射、挂载层与面板(不认识 UI 框架)
|
|
119
129
|
│ ├── mount/ # UI 挂载层(原生形态):差量渲染器 + 工厂表契约
|
|
@@ -121,7 +131,7 @@ __PROJECT__/
|
|
|
121
131
|
├── src/styles/stage.css # 页面与层样式(换框架不动这份:叠加层共用同一张表)
|
|
122
132
|
└── Resources/ # 故事工程根(自包含、可整体搬运)
|
|
123
133
|
├── project.json # 工程清单:id/name/entry/lang/formatVersion
|
|
124
|
-
├── Stories/ #
|
|
134
|
+
├── Stories/ # 故事产物(构建全量管理;故事文件形态下可 .story 与 .json 混存)
|
|
125
135
|
├── Audio/ Images/ Video/
|
|
126
136
|
├── Lang/{lang}/ # I18N overlay
|
|
127
137
|
└── Saves/ # 运行期产物(不入 git)
|
|
@@ -175,8 +185,9 @@ pwsh -File pack-template.ps1 -SourceDir ./__PROJECT__ -OutputFile ./__PROJECT__.
|
|
|
175
185
|
|
|
176
186
|
`template-meta.json` 的 `frameworks` / `sources` / `shells` 声明生成器会问哪些选项,每项的 `id` 对应
|
|
177
187
|
`overlays/<id>/` 目录:同路径文件**覆盖**骨架,根目录的 `*.patch.json` **深合并**进 `package.json`,
|
|
178
|
-
其中 `create.remove` 列出本层要删掉的骨架路径(换形态时清残留)。缺省项(`none` / `
|
|
179
|
-
|
|
188
|
+
其中 `create.remove` 列出本层要删掉的骨架路径(换形态时清残留)。缺省项(`none` / `ts` / `browser`)
|
|
189
|
+
没有目录,表示「这一层什么都不加」,不是错误——缺省故事源 `ts` 正是如此:整条创作链在骨架里,
|
|
190
|
+
没有叠加层可加。`story` 反过来是「摘掉骨架的 TS 链、换上手写故事」,所以它有目录。合并顺序固定为「骨架 → 框架 → 故事源 → 壳」:
|
|
180
191
|
故事源要晚于框架层才能把各框架入口的故事表指向生成物;壳放最后,因为它对工程形态有最终发言权。
|
|
181
192
|
|
|
182
193
|
骨架里的占位符有两种大小写形态:`__PROJECT__`(清单 id/name、页面标题、目录名)与
|
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
"dev": "vite",
|
|
8
8
|
"build": "vite build",
|
|
9
9
|
"preview": "vite preview",
|
|
10
|
+
"stories:build": "lingfan-stories-build .",
|
|
11
|
+
"stories:watch": "lingfan-stories-watch .",
|
|
12
|
+
"stories-typecheck": "tsc --noEmit -p tsconfig.stories.json",
|
|
10
13
|
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.node.json"
|
|
11
14
|
},
|
|
12
15
|
"dependencies": {
|
|
@@ -14,9 +17,11 @@
|
|
|
14
17
|
"@lingfan-engine/core": "^0.3.2",
|
|
15
18
|
"@lingfan-engine/shell": "^0.3.2",
|
|
16
19
|
"@lingfan-engine/stories-build": "^0.3.2",
|
|
17
|
-
"@lingfan-engine/ui": "^0.3.2"
|
|
20
|
+
"@lingfan-engine/ui": "^0.3.2",
|
|
21
|
+
"@tauri-apps/api": "^2"
|
|
18
22
|
},
|
|
19
23
|
"devDependencies": {
|
|
24
|
+
"@lingfan-engine/editor": "^0.3.2",
|
|
20
25
|
"@types/node": "^26.6.3",
|
|
21
26
|
"typescript": "~6.0.3",
|
|
22
27
|
"vite": "^8.0.16"
|
|
@@ -39,6 +39,7 @@ export {
|
|
|
39
39
|
type NativeUiTable,
|
|
40
40
|
} from "./mount/index";
|
|
41
41
|
export { wireEngine, type HostWiringOptions } from "./engine-wiring";
|
|
42
|
+
export { createNativePorts, isTauriHosted } from "./native-ports";
|
|
42
43
|
export { startHostFrameLoop, type HostFrameLoopOptions } from "./frame-loop";
|
|
43
44
|
export { wireInput, type HostInputOptions } from "./input-map";
|
|
44
45
|
export {
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 原生宿主端口:让同一份工程既能跑在浏览器里,也能被桌面壳加载。
|
|
3
|
+
*
|
|
4
|
+
* 为什么判定放在这里而不是壳层:`tauri` 叠加层只补 `src-tauri/` 与清单补丁,
|
|
5
|
+
* 页面结构与组合根永远归属框架层。若壳层自己带一份组合根,`vue`/`react` 形态的
|
|
6
|
+
* 页面节点就不再与那份组合根同源。所以「是否需要原生端口」由运行时事实判定,
|
|
7
|
+
* 各形态的组合根都调同一个函数。
|
|
8
|
+
*
|
|
9
|
+
* 为什么用运行时判定:`tauri dev` 时页面可能被外部浏览器打开,构建期开关会判错。
|
|
10
|
+
* `__TAURI_INTERNALS__` 只由真实 WebView 注入,是此刻唯一可靠的事实。
|
|
11
|
+
*
|
|
12
|
+
* 为什么模块名写在这里:打包器按字面量说明符静态解析(所以本工程必须声明
|
|
13
|
+
* `@tauri-apps/api`),而适配器包不出现该模块名,浏览器与小程序形态才能在
|
|
14
|
+
* 没有它时也装得上。非壳形态直接返回 `undefined`,动态导入一次都不会发生。
|
|
15
|
+
*/
|
|
16
|
+
import {
|
|
17
|
+
createTauriSavePort,
|
|
18
|
+
type TauriInvoke,
|
|
19
|
+
} from "@lingfan-engine/adapters";
|
|
20
|
+
import type { BootPortOverrides } from "./boot";
|
|
21
|
+
|
|
22
|
+
/** 当前页面是否由桌面壳加载(只有真实 WebView 会注入 `__TAURI_INTERNALS__`) */
|
|
23
|
+
export function isTauriHosted(): boolean {
|
|
24
|
+
return typeof window !== "undefined" && "__TAURI_INTERNALS__" in window;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* 惰性 invoke:首次真正调用存档命令时才装载 `@tauri-apps/api/core`。
|
|
29
|
+
*
|
|
30
|
+
* 不在启动路径上 await 是刻意的——存档端口是同步构造的,而装载要等模块解析;
|
|
31
|
+
* 把等待推迟到第一次读写,启动时序与浏览器形态完全一致。装载失败会原样抛给
|
|
32
|
+
* 调用方,由存档流程的既有错误处理呈现,不会变成静默失败。
|
|
33
|
+
*/
|
|
34
|
+
function createLazyInvoke(): TauriInvoke {
|
|
35
|
+
let loading: Promise<TauriInvoke> | undefined;
|
|
36
|
+
return async (command, args) => {
|
|
37
|
+
loading ??= import("@tauri-apps/api/core").then((module) => {
|
|
38
|
+
const loaded: TauriInvoke = (name, payload) =>
|
|
39
|
+
module.invoke(name, payload);
|
|
40
|
+
return loaded;
|
|
41
|
+
});
|
|
42
|
+
const invoke = await loading;
|
|
43
|
+
return invoke(command, args);
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 按运行时事实给出端口覆盖;非桌面壳形态返回 `undefined`。
|
|
49
|
+
*
|
|
50
|
+
* 现在只换存档(`localStorage` 落在 WebView 数据目录,清缓存、换账户、重装都会
|
|
51
|
+
* 带走存档)。其余端口沿用 `bootHost` 的 Web 缺省实现,随需要再加。
|
|
52
|
+
*/
|
|
53
|
+
export function createNativePorts(): BootPortOverrides | undefined {
|
|
54
|
+
if (!isTauriHosted()) return undefined;
|
|
55
|
+
return { saves: createTauriSavePort(createLazyInvoke()) };
|
|
56
|
+
}
|