@lingfan-engine/create 0.3.4 → 0.3.5

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.
@@ -1,31 +1,43 @@
1
1
  /**
2
2
  * 交互问答。
3
3
  *
4
- * 生成器要问三件事:工程名、框架、壳。故事源形态不再是问题——骨架自带整条 TS
5
- * 创作链,它就是缺省形态;要手艺写的 `.story` 用 `--sources story` 指定即可。
4
+ * 生成器按固定顺序问三件事:工程名 → 描述 → 形态。顺序不能换:工程名决定目录与
5
+ * 包名,描述与形态都是在「这个工程」之上的补充;先问后者再问名字,人得回头改口。
6
+ *
7
+ * 形态是**一次多选**而不是三个单选:可叠加的层是一张网,不是三根互不相干的轴——
8
+ * 选 Vue 就排除了小程序,选桌面壳又与小程序互斥。逐个维度问会把矛盾留到最后才
9
+ * 暴露,一次问清则当场说穿重问。
6
10
  *
7
11
  * 目标目录非空时另问一次怎么处置:那是覆盖别人的东西,必须由使用者当场表态,
8
12
  * 不能拿缺省值糊过去(缺省是取消)。
9
13
  *
10
14
  * 实现只用 `node:readline` 的 promise 版:它随 Node 自带,不引第三方交互库。
11
- * 提问写 stderr 而不是 stdout,产物路径之类的结果才走 stdout,这样
12
- * `npm create … > log.txt` 不会把问题一起吞掉。
15
+ * 提问写 stderr 而不是 stdout,这样 `npm create … > log.txt` 不会把问题一起吞掉。
13
16
  */
14
17
  import { createInterface } from "node:readline/promises";
15
- import { isValidProjectName, suggestProjectName } from "../shared/index.ts";
16
- import type { TemplateOption } from "../registry/index.ts";
18
+ import {
19
+ isValidProjectName,
20
+ normalizeDescription,
21
+ suggestProjectName,
22
+ } from "../shared/index.ts";
23
+ import type { LayerConflict, TemplateOption } from "../registry/index.ts";
17
24
  import type { ExistingTarget } from "../generate/index.ts";
18
25
 
19
26
  /** 问答需要的一份文案;由调用方从共享文案表传入,便于测试替换 */
20
27
  export interface PromptTexts {
21
28
  readonly promptProjectName: string;
22
- readonly promptFramework: string;
23
- readonly promptShell: string;
29
+ readonly promptDescription: string;
30
+ readonly descriptionHint: string;
31
+ readonly promptLayers: string;
32
+ readonly nonInteractive: string;
33
+ readonly nonInteractiveHint: string;
24
34
  readonly promptExisting: string;
25
35
  readonly nameRequired: string;
26
36
  readonly nameInvalid: string;
27
- readonly frameworkUnknown: string;
28
- readonly shellUnknown: string;
37
+ readonly layersEmptyHint: string;
38
+ readonly layersInvalid: string;
39
+ readonly layersConflict: string;
40
+ readonly layersConflictHint: string;
29
41
  readonly existingIgnore: string;
30
42
  readonly existingIgnoreHint: string;
31
43
  readonly existingClear: string;
@@ -40,15 +52,48 @@ export interface PromptIo {
40
52
  close(): void;
41
53
  }
42
54
 
43
- /** 造一个接到标准输入输出的问答面 */
44
- export function createTerminalIo(): PromptIo {
55
+ /** 输入已关闭时的失败信息:调用方会把它印出来并退出,因此要人读得懂 */
56
+ const INPUT_CLOSED = "标准输入已关闭,无法继续问答";
57
+
58
+ /**
59
+ * 造一个接到标准输入输出的问答面。
60
+ *
61
+ * 参数是为了可测:非交互终端下 `question()` 必须失败而不是永远挂着。`readline` 在
62
+ * 输入流结束后只会发一个 `close` 事件,它自己的 promise 因此既不 resolve 也不
63
+ * reject——进程于是停在「等一个永远不会到的回车」,靠外部超时才退出。这里把
64
+ * `close` 转成 reject,让失败变成一次可捕获的异常。
65
+ */
66
+ export function createTerminalIo(
67
+ input: NodeJS.ReadableStream = process.stdin,
68
+ output: NodeJS.WritableStream = process.stderr,
69
+ ): PromptIo {
45
70
  const rl = createInterface({
46
- input: process.stdin,
47
- output: process.stderr,
48
- terminal: process.stdin.isTTY === true,
71
+ input,
72
+ output,
73
+ terminal: input === process.stdin && process.stdin.isTTY === true,
74
+ });
75
+ let closed = false;
76
+ rl.once("close", () => {
77
+ closed = true;
49
78
  });
50
79
  return {
51
- question: (text) => rl.question(text),
80
+ question: (text) => {
81
+ if (closed) return Promise.reject(new Error(INPUT_CLOSED));
82
+ return new Promise<string>((resolve, reject) => {
83
+ const fail = (): void => reject(new Error(INPUT_CLOSED));
84
+ rl.once("close", fail);
85
+ rl.question(text).then(
86
+ (answer) => {
87
+ rl.off("close", fail);
88
+ resolve(answer);
89
+ },
90
+ (error: unknown) => {
91
+ rl.off("close", fail);
92
+ reject(error instanceof Error ? error : new Error(String(error)));
93
+ },
94
+ );
95
+ });
96
+ },
52
97
  close: () => rl.close(),
53
98
  };
54
99
  }
@@ -76,30 +121,69 @@ export async function askProjectName(
76
121
  }
77
122
 
78
123
  /**
79
- * 从选项表里选一项:输入序号(从 1 起)或直接输入 id,空输入取首项。
124
+ * 问描述:只问一次,回车即空。
80
125
  *
81
- * 两种取值都认,是因为脚本里写 `--framework vue` 更自然,而交互时按数字更省事;
82
- * 让同一套问答支持两种输入,参数模式与交互模式就不必分两套校验。
126
+ * 不重问也不校验:描述只影响清单里的一行说明,不影响任何路径与命令。为难一个
127
+ * 可以留空的输入框,只会让人多点几次回车。超长按上限截断——`package.json` 的
128
+ * 描述在工具链里本就是短文本,与其被别处悄悄截,不如自己截。
83
129
  */
84
- export async function askOption(
130
+ export async function askDescription(
85
131
  io: PromptIo,
86
- label: string,
87
- options: readonly TemplateOption[],
88
- fallbackId: string | undefined,
89
- invalidText: string,
132
+ texts: PromptTexts,
90
133
  ): Promise<string> {
91
- if (fallbackId !== undefined) return fallbackId;
92
- const lines = options
93
- .map((option, index) => ` ${index + 1}. ${option.label} —— ${option.hint}`)
134
+ const answer = await io.question(
135
+ `${texts.promptDescription}(${texts.descriptionHint}):`,
136
+ );
137
+ return normalizeDescription(answer);
138
+ }
139
+
140
+ /**
141
+ * 问形态:一次多选,空格或逗号分隔,回车即空集。
142
+ *
143
+ * 选项表由调用方按「这一层有没有目录」筛过,所以这里看不到 `none`/`browser`/`ts`
144
+ * 这类缺省层——它们不是一个可以叠加的东西,而是「什么都不叠」。
145
+ *
146
+ * 互斥对由元数据的 `excludes` 推导,本文件不认识任何具体层 id:新增一层只要在
147
+ * 元数据里声明互斥,问答自动跟着变。撞上互斥就说穿是哪两层、然后重问同一问,
148
+ * 不静默丢掉其中一层——丢掉会让「我明明选了小程序」无法解释。
149
+ */
150
+ export async function askLayers(
151
+ io: PromptIo,
152
+ texts: PromptTexts,
153
+ layers: readonly TemplateOption[],
154
+ catalogue: readonly TemplateOption[],
155
+ ): Promise<string[]> {
156
+ const labels = new Map(catalogue.map((option) => [option.id, option.label]));
157
+ const lines = layers
158
+ .map((option, index) => {
159
+ const excludes = (option.excludes ?? []).map(
160
+ (id) => labels.get(id) ?? id,
161
+ );
162
+ const tail =
163
+ excludes.length > 0 ? ` 互斥:${excludes.join("、")}` : " 互斥:无";
164
+ return ` ${index + 1}. ${option.label} —— ${option.hint}${tail}`;
165
+ })
94
166
  .join("\n");
95
- process.stderr.write(`${label}:\n${lines}\n`);
167
+ process.stderr.write(`${texts.promptLayers}:\n${lines}\n`);
96
168
  for (;;) {
97
169
  const answer = (
98
- await io.question(`选择(1-${options.length},回车取 1):`)
170
+ await io.question(
171
+ `选择(1-${layers.length},可多选,空格或逗号分隔;${texts.layersEmptyHint}):`,
172
+ )
99
173
  ).trim();
100
- const picked = pickOption(options, answer);
101
- if (picked !== undefined) return picked;
102
- process.stderr.write(`${invalidText}\n`);
174
+ const picked = parseLayerAnswer(layers, answer);
175
+ if (picked === undefined) {
176
+ process.stderr.write(`${texts.layersInvalid}\n`);
177
+ continue;
178
+ }
179
+ const conflicts = findLayerConflicts(picked, catalogue);
180
+ if (conflicts.length === 0) return picked;
181
+ for (const [left, right] of conflicts) {
182
+ process.stderr.write(
183
+ `${texts.layersConflict}:${labels.get(left) ?? left} 与 ${labels.get(right) ?? right} 不能共存\n`,
184
+ );
185
+ }
186
+ process.stderr.write(`${texts.layersConflictHint}:`);
103
187
  }
104
188
  }
105
189
 
@@ -162,3 +246,71 @@ export function pickOption(
162
246
  }
163
247
  return undefined;
164
248
  }
249
+
250
+ /**
251
+ * 把一次多选输入解析成层 id 列表,保序去重;有认不出的编号或名字时返回 undefined。
252
+ *
253
+ * 认两种写法(序号与 id)是为了脚本与手敲都顺手;认空格、半角逗号与全角逗号是因为
254
+ * 中文输入法下逗号常常是全角的,为这个报错只会让人困惑。
255
+ *
256
+ * 空输入是空集而**不是**错误:不选任何层就是「只用骨架」,那是最常见的形态。
257
+ */
258
+ export function parseLayerAnswer(
259
+ layers: readonly TemplateOption[],
260
+ answer: string,
261
+ ): string[] | undefined {
262
+ const tokens = answer
263
+ .split(/[\s,,]+/)
264
+ .map((token) => token.trim())
265
+ .filter((token) => token !== "");
266
+ if (tokens.length === 0) return [];
267
+ const out: string[] = [];
268
+ for (const token of tokens) {
269
+ const picked = pickOptionStrict(layers, token);
270
+ if (picked === undefined) return undefined;
271
+ if (!out.includes(picked)) out.push(picked);
272
+ }
273
+ return out;
274
+ }
275
+
276
+ /**
277
+ * 从选中的层里找出互斥对。
278
+ *
279
+ * 只看「选中的这两层彼此排斥」,不看谁先声明的:`excludes` 单边声明即生效,
280
+ * 因此两边都写了也只算一对。
281
+ */
282
+ export function findLayerConflicts(
283
+ selected: readonly string[],
284
+ catalogue: readonly TemplateOption[],
285
+ ): LayerConflict[] {
286
+ const chosen = new Set(selected);
287
+ const seen = new Set<string>();
288
+ const out: LayerConflict[] = [];
289
+ for (const option of catalogue) {
290
+ if (!chosen.has(option.id)) continue;
291
+ for (const excluded of option.excludes ?? []) {
292
+ if (!chosen.has(excluded)) continue;
293
+ const key = [option.id, excluded].sort().join("\u0000");
294
+ if (seen.has(key)) continue;
295
+ seen.add(key);
296
+ out.push([option.id, excluded]);
297
+ }
298
+ }
299
+ return out;
300
+ }
301
+
302
+ /** 与 `pickOption` 同义,但不把空输入当首项:多选里空输入是「什么都不选」 */
303
+ function pickOptionStrict(
304
+ options: readonly TemplateOption[],
305
+ token: string,
306
+ ): string | undefined {
307
+ if (token === "") return undefined;
308
+ const index = Number(token);
309
+ if (Number.isInteger(index) && index >= 1 && index <= options.length) {
310
+ return options[index - 1].id;
311
+ }
312
+ for (const option of options) {
313
+ if (option.id === token) return option.id;
314
+ }
315
+ return undefined;
316
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * 生成阶段:查版本 → 写文件 → 可选安装 → 打印后续步骤。
3
+ *
4
+ * 为什么与参数解析分开:这一段对使用者是「正在干活」的进度叙事,对测试是纯副作用,
5
+ * 而参数解析是纯函数。两者放在一个文件里,任何一边的改动都要重新读另一边的代码。
6
+ *
7
+ * 入参自带形状而不复用命令行类型:本文件不需要知道参数是怎么解析出来的,只认得
8
+ * 「要生成什么」。这也让方向保持单向——参数层依赖这里,这里不回头依赖参数层。
9
+ */
10
+ import { resolve as resolvePath } from "node:path";
11
+ import { currentPackageManager, defaultCreateTexts } from "../shared/index.ts";
12
+ import {
13
+ collectDependencyNames,
14
+ generateProject,
15
+ layerBases,
16
+ type ExistingTarget,
17
+ } from "../generate/index.ts";
18
+ import {
19
+ checkEngineCompatibility,
20
+ resolveVersions,
21
+ type TemplateMeta,
22
+ } from "../registry/index.ts";
23
+ import { installProject, renderNextSteps } from "./install.ts";
24
+
25
+ /** 生成阶段需要的信息;`description` 为空串表示产物里不出现该字段 */
26
+ export interface StageOptions {
27
+ readonly meta: TemplateMeta;
28
+ readonly templateRoot: string;
29
+ /** 生成到哪个目录下;缺省为当前目录 */
30
+ readonly dir: string | undefined;
31
+ readonly projectName: string;
32
+ readonly description: string;
33
+ readonly framework: string;
34
+ readonly shell: string;
35
+ readonly sources: string;
36
+ readonly existing: ExistingTarget;
37
+ readonly install: boolean;
38
+ }
39
+
40
+ /**
41
+ * 执行一次生成,返回进程退出码。
42
+ *
43
+ * 版本查询与兼容性判定都排在写盘之前:模板要求的引擎还没发布时,生成出来的工程
44
+ * 装不上,让它失败在「什么都没写」的时刻,而不是在别人的工程里第一次启动时。
45
+ */
46
+ export async function runStage(options: StageOptions): Promise<number> {
47
+ const texts = defaultCreateTexts;
48
+ const manager = currentPackageManager();
49
+ const targetBase = resolvePath(options.dir ?? process.cwd());
50
+ const layout = {
51
+ templateRoot: options.templateRoot,
52
+ targetBase,
53
+ projectName: options.projectName,
54
+ description: options.description,
55
+ framework: options.framework,
56
+ shell: options.shell,
57
+ sources: options.sources,
58
+ resolved: new Map<string, string>(),
59
+ };
60
+
61
+ process.stderr.write(`${texts.stageResolve}\n`);
62
+ const names = collectDependencyNames(layerBases(layout));
63
+ const resolved = await resolveVersions(names);
64
+ if (resolved.size === 0 && names.length > 0) {
65
+ process.stderr.write(`${texts.warnVersionOffline}\n`);
66
+ }
67
+ const incompatible = checkEngineCompatibility(
68
+ options.meta.minEngineVersion,
69
+ resolved,
70
+ );
71
+ if (incompatible !== undefined) {
72
+ process.stderr.write(`${incompatible}\n`);
73
+ return 1;
74
+ }
75
+
76
+ process.stderr.write(`${texts.stageCopy}\n`);
77
+ let result;
78
+ try {
79
+ result = generateProject({
80
+ ...layout,
81
+ existing: options.existing,
82
+ resolved,
83
+ });
84
+ } catch (error) {
85
+ process.stderr.write(`${describe(error)}\n`);
86
+ return 1;
87
+ }
88
+
89
+ let installed = false;
90
+ if (options.install) {
91
+ process.stderr.write(`${texts.stageInstall}\n`);
92
+ installed = await installProject(result.projectDir, manager);
93
+ if (!installed) process.stderr.write(`${texts.warnInstallFailed}\n`);
94
+ }
95
+
96
+ process.stdout.write(
97
+ [
98
+ "",
99
+ `${texts.doneTitle}:${result.projectDir}`,
100
+ `共 ${result.fileCount} 个文件,框架 ${result.framework},壳 ${result.shell},故事源 ${result.sources}`,
101
+ "",
102
+ installed ? "依赖已安装。" : "依赖未安装。",
103
+ renderNextSteps(result.projectDir, manager, texts),
104
+ "",
105
+ ].join("\n"),
106
+ );
107
+ return 0;
108
+ }
109
+
110
+ /** 把异常折成一行提示:只暴露信息,不暴露堆栈 */
111
+ function describe(error: unknown): string {
112
+ return error instanceof Error ? error.message : String(error);
113
+ }
@@ -61,6 +61,13 @@ export interface GenerateOptions {
61
61
  * 可选是为了不破坏既有调用方——只关心框架与壳的代码不必为一个用不到的参数改签名。
62
62
  */
63
63
  readonly sources?: string;
64
+ /**
65
+ * 工程描述;写进 `package.json` 与 `Resources/project.json` 两处。
66
+ *
67
+ * 缺省与空串都表示「没有描述」,两处清单里都不出现该键。这两种状态不做区分:
68
+ * 调用方要么知道有描述,要么什么都没说,第三种「有但是空」没有语义。
69
+ */
70
+ readonly description?: string;
64
71
  /** 目标目录非空时怎么办;缺省 `cancel`(报错退出,不碰磁盘) */
65
72
  readonly existing?: ExistingTarget;
66
73
  /** 引擎包的最新版本;缺席项沿用模板记录的值 */
@@ -111,13 +118,16 @@ export function generateProject(options: GenerateOptions): GenerateResult {
111
118
  }
112
119
  }
113
120
  const merged = mergeLayers(layerBases(options));
114
- const files = finalize(
115
- merged.files,
116
- merged.patch,
117
- merged.pinned,
118
- options.resolved,
119
- meta.placeholder,
120
- options.projectName,
121
+ const files = applyDescription(
122
+ finalize(
123
+ merged.files,
124
+ merged.patch,
125
+ merged.pinned,
126
+ options.resolved,
127
+ meta.placeholder,
128
+ options.projectName,
129
+ ),
130
+ options.description ?? "",
121
131
  );
122
132
  mkdirSync(projectDir, { recursive: true });
123
133
  for (const file of files) {
@@ -234,6 +244,47 @@ export function finalize(
234
244
  });
235
245
  }
236
246
 
247
+ /**
248
+ * 把工程描述写进两份清单。
249
+ *
250
+ * 写两处是因为它们服务不同读者:`package.json` 是开发者与 npm 工具链的门面,
251
+ * `Resources/project.json` 跟资源一起走,运行时与构建期都读它。同源同值,
252
+ * 由同一个变量一次写入,不会出现两处说法不一致。
253
+ *
254
+ * 没有描述时**一个字节都不改**:模板里本就不写这个键,于是「没填描述」的产物
255
+ * 与引入描述之前逐字节一致,也不存在「空描述」这种中间状态。
256
+ */
257
+ function applyDescription(
258
+ files: readonly PendingFile[],
259
+ description: string,
260
+ ): PendingFile[] {
261
+ if (description === "") return [...files];
262
+ return files.map((file) => {
263
+ if (!MANIFEST_WITH_DESCRIPTION.has(file.path)) return file;
264
+ const manifest = JSON.parse(file.content.toString("utf8")) as Record<
265
+ string,
266
+ unknown
267
+ >;
268
+ return {
269
+ path: file.path,
270
+ content: Buffer.from(
271
+ `${JSON.stringify({ ...manifest, description }, null, 2)}\n`,
272
+ "utf8",
273
+ ),
274
+ };
275
+ });
276
+ }
277
+
278
+ /**
279
+ * 描述落点的产物路径。
280
+ *
281
+ * 用产物路径而不是模板路径判断:点文件还原已经发生,这里的取值就是最终写盘的名字。
282
+ */
283
+ const MANIFEST_WITH_DESCRIPTION: ReadonlySet<string> = new Set([
284
+ "package.json",
285
+ "Resources/project.json",
286
+ ]);
287
+
237
288
  /**
238
289
  * 把增量与版本号写进 `package.json`。
239
290
  *
@@ -1,10 +1,14 @@
1
1
  /** registry 域唯一出口:模板元数据定位 + npm 版本查询 + 引擎兼容判定。 */
2
2
  export { checkEngineCompatibility, ENGINE_SCOPE } from "./compat.ts";
3
3
  export {
4
+ existsLayerDirectory,
5
+ findConflicts,
4
6
  overlayDirOf,
5
7
  readTemplateMeta,
6
8
  resolveTemplateRoot,
9
+ selectableLayers,
7
10
  OVERLAY_DIR,
11
+ type LayerConflict,
8
12
  type TemplateMeta,
9
13
  type TemplateOption,
10
14
  } from "./template-meta.ts";
@@ -41,6 +41,13 @@ export interface TemplateOption {
41
41
  readonly label: string;
42
42
  /** 一句话说明,交互问答里作为提示 */
43
43
  readonly hint: string;
44
+ /**
45
+ * 不能与本层共存的其他层 id;缺省视为空数组。
46
+ *
47
+ * 元素是**跨维度**的层 id(框架可以排斥壳),不是同一维度内的兄弟项。
48
+ * 单边声明即生效:只要一对层里有一方点名了另一方,这一对就是互斥的。
49
+ */
50
+ readonly excludes?: readonly string[];
44
51
  }
45
52
 
46
53
  /**
@@ -115,3 +122,69 @@ export function overlayDirOf(
115
122
  const dir = join(templateRoot, OVERLAY_DIR, optionId);
116
123
  return existsSync(dir) ? dir : undefined;
117
124
  }
125
+
126
+ /**
127
+ * 这一层是否有自己的目录。
128
+ *
129
+ * 判据是磁盘事实而不是 id 白名单:新增一层只要建了目录就自动可选,删掉目录就自动
130
+ * 退出选项面,生成器不必跟着改。缺省项(`none`/`browser`/`ts`)没有目录,
131
+ * 因此永远不出现在「可以叠哪些层」的选项里。
132
+ */
133
+ export function existsLayerDirectory(
134
+ templateRoot: string,
135
+ optionId: string,
136
+ ): boolean {
137
+ return existsSync(join(templateRoot, OVERLAY_DIR, optionId));
138
+ }
139
+
140
+ /**
141
+ * 列出可以在形态问答里勾选的层:框架与壳里有目录的那些。
142
+ *
143
+ * 故事源不是可叠加的层:TS 源、手写 `.story`、手写 `.json` 是同一棵
144
+ * `Resources/Stories/` 树上的三种文件形态,本来就能共存于一个工程,勾选它没有
145
+ * 「叠一层」的含义,所以 `sources` 维度不参与问答,只在脚本参数面(`--sources`)保留。
146
+ *
147
+ * 顺序沿用元数据里的维度顺序「框架 → 壳」,与合并顺序(骨架 → 框架 → 故事源 →
148
+ * 壳)不同——选项面按使用者心里的粒度排,合并顺序按覆盖关系排;维度内保持声明顺序。
149
+ */
150
+ export function selectableLayers(
151
+ templateRoot: string,
152
+ meta: TemplateMeta,
153
+ ): TemplateOption[] {
154
+ return [...meta.frameworks, ...meta.shells].filter((option) =>
155
+ existsLayerDirectory(templateRoot, option.id),
156
+ );
157
+ }
158
+
159
+ /** 一对互斥的层 id,按选项面出现顺序排列 */
160
+ export type LayerConflict = readonly [string, string];
161
+
162
+ /**
163
+ * 从选中的层里找出互斥对。
164
+ *
165
+ * 只看「选中集合里同时出现了互斥的两方」,不关心是哪一方先声明的:单边声明即生效,
166
+ * 因此两边都声明也不会被算成两对。返回的每一对都按选项面顺序排列,便于报错文本稳定。
167
+ */
168
+ export function findConflicts(
169
+ selected: readonly string[],
170
+ options: readonly TemplateOption[],
171
+ ): LayerConflict[] {
172
+ const order = new Map(options.map((option, index) => [option.id, index]));
173
+ const chosen = new Set(selected);
174
+ const pairs: LayerConflict[] = [];
175
+ const seen = new Set<string>();
176
+ for (const option of options) {
177
+ if (!chosen.has(option.id)) continue;
178
+ for (const excluded of option.excludes ?? []) {
179
+ if (!chosen.has(excluded)) continue;
180
+ const key = [option.id, excluded].sort().join("\u0000");
181
+ if (seen.has(key)) continue;
182
+ seen.add(key);
183
+ const pair = [option.id, excluded].sort(
184
+ (left, right) => (order.get(left) ?? 0) - (order.get(right) ?? 0),
185
+ ) as [string, string];
186
+ pairs.push(pair);
187
+ }
188
+ }
189
+ return pairs;
190
+ }
@@ -66,6 +66,20 @@ export function suggestProjectName(input: string): string {
66
66
  return collapsed.length > 0 ? collapsed : "lingfan-story";
67
67
  }
68
68
 
69
+ /** 描述的字符上限:`package.json#description` 在 npm 生态里的通行上限 */
70
+ export const DESCRIPTION_MAX_LENGTH = 200;
71
+
72
+ /**
73
+ * 把自由文本收敛成可写入清单的描述。
74
+ *
75
+ * 全空白视为空,返回空串——调用方据此决定「不写这个字段」,而不是写一个空串进去。
76
+ * 超长直接截断而不报错:描述不影响任何路径与命令,没有理由为一个输入框拦住生成。
77
+ * 不校验字符集:它是给人看的自由文本,不是标识符。
78
+ */
79
+ export function normalizeDescription(input: string): string {
80
+ return input.trim().slice(0, DESCRIPTION_MAX_LENGTH).trim();
81
+ }
82
+
69
83
  /**
70
84
  * 把用户输入的版本号归一成 `^x.y.z`。
71
85
  *
@@ -2,7 +2,9 @@
2
2
  export { defaultCreateTexts, type CreateTexts } from "./texts.ts";
3
3
  export {
4
4
  caretRange,
5
+ DESCRIPTION_MAX_LENGTH,
5
6
  isValidProjectName,
7
+ normalizeDescription,
6
8
  suggestProjectName,
7
9
  } from "./guards.ts";
8
10
  export {
@@ -11,30 +11,43 @@ export const defaultCreateTexts = {
11
11
  banner: "灵泛叙事引擎 · 工程生成器",
12
12
  help: [
13
13
  "用法:",
14
- " npm create @lingfan-engine [工程名] [选项]",
15
- " pnpm create @lingfan-engine [工程名] [选项]",
16
- " yarn create @lingfan-engine [工程名] [选项]",
14
+ " npm create @lingfan-engine [工程名]",
15
+ " pnpm create @lingfan-engine [工程名]",
16
+ " bun create @lingfan-engine [工程名]",
17
+ "",
18
+ "工程名之后按提示回答:工程名 → 描述 → 形态(可多选)。",
19
+ "yarn 需要 2 及以上版本(classic 没有 create 子命令);仍用 classic 时等价写法:",
20
+ " yarn dlx create-@lingfan-engine [工程名]",
17
21
  "",
18
22
  "包管理器不限:生成后的安装命令与后续步骤按调用方自己的包管理器给出。",
19
23
  "选项:",
20
- " --template <id> 一次性指定形态,写作 框架[-壳[-故事源]],如 vue-tauri",
21
- " -f, --framework <id> 框架(缺省取模板声明的默认值)",
22
- " -s, --shell <id> 壳(缺省取模板声明的默认值)",
23
- " -t, --sources <id> 故事源形态(缺省 ts,一般不必指定)",
24
+ " --description <文本> 工程描述(写进 package.json 与 Resources/project.json;可留空)",
24
25
  " -d, --dir <路径> 生成到哪个目录下(缺省 = 当前目录)",
25
26
  " --install 生成后立即安装依赖",
26
27
  " --overwrite 目标目录非空时清空后继续",
27
28
  " -y, --yes 全部取缺省值,不进入交互问答",
28
29
  " -h, --help 显示本说明",
29
30
  "",
31
+ "脚本用法:CI 里可以用参数直接指定形态,跳过问答。",
32
+ " -f, --framework <id> 框架(none / vue / react)",
33
+ " -s, --shell <id> 壳(browser / tauri / miniprogram)",
34
+ " -t, --sources <id> 故事源形态(ts / story)",
35
+ " --template <id> 等价写法:框架[-壳[-故事源]],如 vue-tauri",
36
+ "",
30
37
  "示例:",
31
- " npm create @lingfan-engine my-story -- --template vue-browser",
32
- " npm create @lingfan-engine my-story -- --template vue-tauri --install",
38
+ " npm create @lingfan-engine my-story",
39
+ " npm create @lingfan-engine my-story --description 试菜单的小工程",
40
+ " npm create @lingfan-engine my-story --yes --install",
33
41
  ].join("\n"),
34
42
  /** 交互问答的提示语 */
35
43
  promptProjectName: "工程名",
36
- promptFramework: "框架",
37
- promptShell: "壳",
44
+ promptDescription: "描述",
45
+ descriptionHint: "可留空;会写进 package.json 与 Resources/project.json",
46
+ promptLayers: "形态",
47
+ /** 非交互终端的处置 */
48
+ nonInteractive: "当前不是交互终端,还有未指定的项",
49
+ nonInteractiveHint:
50
+ "请补 --yes 取全部缺省,或用参数指定:--description --framework --shell --sources",
38
51
  promptExisting: "目标目录已存在且有内容,怎么处理",
39
52
  /** 参数校验失败 */
40
53
  nameRequired: "工程名不能为空",
@@ -44,6 +57,11 @@ export const defaultCreateTexts = {
44
57
  shellUnknown: "未知壳",
45
58
  sourcesUnknown: "未知故事源形态",
46
59
  templateMalformed: "形态写法不对,应为 框架[-壳[-故事源]]",
60
+ /** 形态多选 */
61
+ layersEmptyHint: "回车 = 只用骨架(框架无关 + 浏览器)",
62
+ layersInvalid: "没听懂这组编号,请用空格或逗号分隔的序号",
63
+ layersConflict: "选中的层互相排斥",
64
+ layersConflictHint: "重新选择(回车 = 全部缺省)",
47
65
  /** 目标目录已存在时的三种处置 */
48
66
  existingIgnore: "保留现有文件,同名覆盖",
49
67
  existingIgnoreHint: "只写本模板的文件,别的东西原地不动",