@zhin.js/command 1.1.17 → 1.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.
package/src/provider.ts DELETED
@@ -1,320 +0,0 @@
1
- import { basename, dirname, join, relative, sep } from 'node:path';
2
- import { featureId, isCapabilityLocalSegment } from '@zhin.js/plugin-runtime';
3
- import {
4
- defineFeatureProvider,
5
- type DiscoveryContext,
6
- type DiscoveredSource,
7
- type SourceConvention,
8
- } from '@zhin.js/feature-kit';
9
- import { CommandIndex, assertParameterSegment } from './command-index.js';
10
- import {
11
- bindCommandParameter,
12
- parseCommandDefinition,
13
- type CommandDefinition,
14
- type CommandParameterDefinition,
15
- } from './definition.js';
16
-
17
- export const commandFeatureId = featureId('zhin.command');
18
-
19
- const commandFiles: SourceConvention = {
20
- id: 'commands-ts',
21
- async *discover(context) {
22
- const directory = join(context.packageRoot, 'commands');
23
- const entries = [...await context.host.list(directory)]
24
- .filter((entry) => entry.kind === 'directory')
25
- .sort((left, right) => left.name.localeCompare(right.name));
26
- for (const entry of entries) {
27
- const segment = parseCommandDirectory(entry.name);
28
- if (!segment) continue;
29
- yield* discoverCommandDirectory(context, join(directory, entry.name), [segment]);
30
- }
31
- },
32
- async load(source, context) {
33
- const module = await context.host.loadModule<{ default?: unknown }>(source.source);
34
- const definition = parseCommandDefinition(module.default);
35
- const commandDirectories = commandDirectoriesOf(context.packageRoot, source.source);
36
- assertSingleTrailingParameterDirectory(commandDirectories, source.source);
37
- const file = parseCommandDirectory(basename(dirname(source.source)));
38
- return bindCommandParameter(definition, resolveParameter(definition, file, source.source));
39
- },
40
- };
41
-
42
- async function* discoverCommandDirectory(
43
- context: DiscoveryContext,
44
- directory: string,
45
- ancestors: readonly ParsedCommandFile[],
46
- ): AsyncIterable<DiscoveredSource> {
47
- const entries = [...await context.host.list(directory)]
48
- .sort((left, right) => left.name < right.name ? -1 : left.name > right.name ? 1 : 0);
49
- const preferJavaScript = context.packageRoot
50
- .split(sep)
51
- .includes('node_modules');
52
- const index = preferredCommandIndex(entries, preferJavaScript);
53
- if (index) {
54
- yield {
55
- localName: ancestors.map((segment) => segment.localSegment).join('/'),
56
- source: join(directory, index),
57
- relatedSources: Object.freeze(entries
58
- .filter((entry) => entry.kind === 'file' && entry.name !== index)
59
- .map((entry) => join(directory, entry.name))),
60
- target: 'server',
61
- };
62
- }
63
- for (const entry of entries) {
64
- if (entry.kind !== 'directory') continue;
65
- const segment = parseCommandDirectory(entry.name);
66
- if (!segment) continue;
67
- yield* discoverCommandDirectory(context, join(directory, entry.name), [...ancestors, segment]);
68
- }
69
- }
70
-
71
- interface ParsedCommandFile {
72
- readonly localSegment: string;
73
- readonly parameter?: CommandParameterHint;
74
- }
75
-
76
- /** 文件名声明的参数形态;类型与默认值来自 `defineCommand({ params })`。 */
77
- interface CommandParameterHint {
78
- readonly name: string;
79
- readonly optional: boolean;
80
- readonly rest: boolean;
81
- }
82
-
83
- const dynamicCommandFilePatterns: ReadonlyArray<{
84
- readonly pattern: RegExp;
85
- readonly optional: boolean;
86
- readonly rest: boolean;
87
- }> = [
88
- { pattern: /^\[\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: true },
89
- { pattern: /^\[\.\.\.([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: true },
90
- { pattern: /^\[\[([a-zA-Z][a-zA-Z0-9]*)\]\]\.(?:tsx?|[cm]?js)$/, optional: true, rest: false },
91
- { pattern: /^\[([a-zA-Z][a-zA-Z0-9]*)\]\.(?:tsx?|[cm]?js)$/, optional: false, rest: false },
92
- ];
93
-
94
- function parseCommandDirectory(value: string): ParsedCommandFile | undefined {
95
- for (const { pattern, optional, rest } of dynamicCommandFilePatterns) {
96
- const match = pattern.exec(`${value}.ts`);
97
- if (!match || !match[1]) continue;
98
- const name = match[1];
99
- // Metadata can change during HMR while $name keeps the Capability identity stable.
100
- return {
101
- localSegment: `$${name}`,
102
- parameter: { name, optional, rest },
103
- };
104
- }
105
- if (isCapabilityLocalSegment(value)) {
106
- return { localSegment: value };
107
- }
108
- if (value.startsWith('[') || value.includes(']')) {
109
- throw new CommandPathSyntaxError(value);
110
- }
111
- return undefined;
112
- }
113
-
114
- /** Directory segments between `commands/` and the command's `index.*` file. */
115
- function commandDirectoriesOf(packageRoot: string, source: string): string[] {
116
- const commandsRoot = join(packageRoot, 'commands');
117
- const commandDirectory = dirname(source);
118
- return relative(commandsRoot, commandDirectory)
119
- .split(sep)
120
- .filter((segment) => segment !== '');
121
- }
122
-
123
- interface ParameterDirectory {
124
- readonly position: number;
125
- readonly directory: string;
126
- readonly hint: CommandParameterHint;
127
- }
128
-
129
- /**
130
- * A command path may contain at most one parameter directory, and it must be the
131
- * last directory before `index.*`. Further arguments belong in `defineCommand({ params })`.
132
- */
133
- function assertSingleTrailingParameterDirectory(directories: readonly string[], source: string): void {
134
- const parameterDirectories: ParameterDirectory[] = [];
135
- directories.forEach((directory, position) => {
136
- const hint = parseCommandDirectory(directory)?.parameter;
137
- if (hint) parameterDirectories.push({ position, directory, hint });
138
- });
139
- const [keptDirectory, ...movedDirectories] = parameterDirectories;
140
- if (!keptDirectory) return;
141
- const lastPosition = directories.length - 1;
142
- if (movedDirectories.length === 0 && keptDirectory.position === lastPosition) return;
143
- throw new CommandPathSyntaxError(
144
- source,
145
- describeParameterDirectoryRule(directories, keptDirectory, movedDirectories),
146
- );
147
- }
148
-
149
- function describeParameterDirectoryRule(
150
- directories: readonly string[],
151
- keptDirectory: ParameterDirectory,
152
- movedDirectories: readonly ParameterDirectory[],
153
- ): string {
154
- const parameterDirectories = [keptDirectory, ...movedDirectories];
155
- const currentPath = `commands/${directories.join('/')}/index.ts`;
156
- const foundDirectories = parameterDirectories.map((entry) => entry.directory).join(', ');
157
- const staticDirectories = directories.filter((_directory, position) =>
158
- !parameterDirectories.some((entry) => entry.position === position));
159
- const suggestedPath = `commands/${[...staticDirectories, keptDirectory.directory].join('/')}/index.ts`;
160
- const suggestedParams = parameterDirectories
161
- .map((entry) => describeParamDeclaration(entry.hint))
162
- .join(', ');
163
- const movedNote = movedDirectories.length > 0
164
- ? `move ${movedDirectories.map((entry) => entry.directory).join(', ')} into defineCommand({ params })`
165
- : 'move the parameter directory to the end of the path';
166
- return `only the last directory of a command path may be a parameter directory `
167
- + `([name], [[name]], [...name] or [[...name]]), and a command may have at most one; `
168
- + `found ${foundDirectories} in ${currentPath}. `
169
- + `Fix: ${movedNote}, e.g. ${suggestedPath} with `
170
- + `defineCommand({ params: { ${suggestedParams} } }) `
171
- + `(the directory parameter must be listed first). `
172
- + 'See https://zhin.js.org/authoring/commands#目录参数规则';
173
- }
174
-
175
- function describeParamDeclaration(hint: CommandParameterHint): string {
176
- const type = hint.rest ? 'text' : 'string';
177
- const flags: string[] = [];
178
- if (hint.optional) flags.push('optional: true');
179
- if (hint.rest) flags.push('rest: true');
180
- const flagText = flags.map((flag) => `, ${flag}`).join('');
181
- return `${hint.name}: { type: '${type}'${flagText} }`;
182
- }
183
-
184
- /** 把文件名形态与 `definition.params` 合并成完整参数定义。 */
185
- function resolveParameter(
186
- definition: CommandDefinition,
187
- file: ParsedCommandFile | undefined,
188
- source: string,
189
- ): CommandParameterDefinition | undefined {
190
- const hint = file?.parameter;
191
- if (!hint) return undefined;
192
- const schema = definition.params?.[hint.name];
193
- if (!schema) {
194
- throw new CommandPathSyntaxError(
195
- source,
196
- `missing params.${hint.name} declaration in defineCommand({ params })`,
197
- );
198
- }
199
- const declaredNames = Object.keys(definition.params ?? {});
200
- if (declaredNames[0] !== hint.name) {
201
- const reorderedNames = [hint.name, ...declaredNames.filter((name) => name !== hint.name)];
202
- throw new CommandPathSyntaxError(
203
- source,
204
- `params.${hint.name} must be the first parameter declaration: the parameter directory `
205
- + `binds the first argument after the command name, so it has to come first in `
206
- + `defineCommand({ params }). Current order: { ${declaredNames.join(', ')} }; `
207
- + `reorder to { ${reorderedNames.join(', ')} }`,
208
- );
209
- }
210
- if (schema.rest !== undefined && schema.rest !== hint.rest
211
- || schema.optional !== undefined && schema.optional !== hint.optional) {
212
- throw new CommandPathSyntaxError(source, `params.${hint.name} optional/rest declaration conflicts with the directory shape`);
213
- }
214
- if (!hint.optional && schema.default !== undefined) {
215
- throw new CommandPathSyntaxError(
216
- source,
217
- `params.${hint.name} has a default but the directory is required: rename it to [[${hint.name}]]`,
218
- );
219
- }
220
- return {
221
- name: hint.name,
222
- type: schema.type,
223
- ...(schema.default !== undefined ? { defaultValue: schema.default } : {}),
224
- optional: hint.optional,
225
- rest: hint.rest,
226
- ...(schema.description !== undefined ? { description: schema.description } : {}),
227
- };
228
- }
229
-
230
- function commandFilePriority(value: string, preferJavaScript: boolean): number {
231
- const extension = value.slice(value.lastIndexOf('.') + 1);
232
- const order = preferJavaScript
233
- ? ['js', 'mjs', 'cjs', 'ts', 'tsx']
234
- : ['ts', 'tsx', 'js', 'mjs', 'cjs'];
235
- const priority = order.indexOf(extension);
236
- return priority < 0 ? Number.MAX_SAFE_INTEGER : priority;
237
- }
238
-
239
- function preferredCommandIndex(
240
- entries: readonly { readonly name: string; readonly kind: 'file' | 'directory' }[],
241
- preferJavaScript: boolean,
242
- ): string | undefined {
243
- return entries
244
- .filter((entry) => entry.kind === 'file' && /^index\.(?:tsx?|[cm]?js)$/u.test(entry.name))
245
- .sort((left, right) => commandFilePriority(left.name, preferJavaScript)
246
- - commandFilePriority(right.name, preferJavaScript))[0]?.name;
247
- }
248
-
249
- export interface BoundCommandSource<TDefinition> {
250
- /** Plugin-local name the runtime registers, e.g. `term/$word`. */
251
- readonly localName: string;
252
- /** The definition with `$parameter` bound from the directory shape, as the runtime loads it. */
253
- readonly definition: TDefinition;
254
- }
255
-
256
- /**
257
- * Apply the runtime's convention-path rules to one command module: directory syntax
258
- * (`[name]`, `[[name]]`, `[...name]`, `[[...name]]`), the `params` declaration the directory
259
- * implies, and the route shape (one dynamic segment, last). `source` is the command's `index`
260
- * file or its directory; everything after the last `commands` directory is the command path.
261
- * Throws the same errors discovery, loading and the command index throw.
262
- */
263
- export function bindCommandSource<TDefinition extends CommandDefinition>(
264
- value: TDefinition,
265
- source: string,
266
- ): BoundCommandSource<TDefinition>;
267
- export function bindCommandSource(value: unknown, source: string): BoundCommandSource<CommandDefinition>;
268
- export function bindCommandSource(value: unknown, source: string): BoundCommandSource<CommandDefinition> {
269
- const directory = /^index\.(?:tsx?|[cm]?js)$/u.test(basename(source)) ? dirname(source) : source;
270
- const parts = directory.split(/[\\/]/u).filter(Boolean);
271
- const commandsAt = parts.lastIndexOf('commands');
272
- if (commandsAt < 0 || commandsAt === parts.length - 1) {
273
- throw new CommandPathSyntaxError(source, 'expected a path under commands/, e.g. commands/foo/index.ts');
274
- }
275
- const segments = parts.slice(commandsAt + 1).map((name) => {
276
- const segment = parseCommandDirectory(name);
277
- if (!segment) {
278
- throw new CommandPathSyntaxError(source, `"${name}" is not a command directory name, so the runtime does not discover it`);
279
- }
280
- return segment;
281
- });
282
- const definition = parseCommandDefinition(value);
283
- const bound = bindCommandParameter(definition, resolveParameter(definition, segments.at(-1), source));
284
- const localName = segments.map((segment) => segment.localSegment).join('/');
285
- assertParameterSegment(localName.split('/'), bound.$parameter, source);
286
- return Object.freeze({ localName, definition: bound });
287
- }
288
-
289
- export class CommandPathSyntaxError extends TypeError {
290
- constructor(
291
- file: string,
292
- detail = 'expected commands/foo/index.ts or commands/foo/[name]/index.ts',
293
- ) {
294
- super(`Invalid Command path ${file}: ${detail}`);
295
- this.name = 'CommandPathSyntaxError';
296
- }
297
- }
298
-
299
- const commandFeature = defineFeatureProvider({
300
- protocol: 1,
301
- id: commandFeatureId,
302
- authoring: {
303
- setupMethod: 'addCommand',
304
- conventions: [commandFiles],
305
- validate: parseCommandDefinition,
306
- },
307
- runtime: {
308
- project(slots, context) {
309
- const rootConfig = context.snapshot.config.get(context.snapshot.root) as
310
- | Record<string, unknown> | undefined;
311
- const rawKeyword = rootConfig?.menuKeyword;
312
- const menu = rawKeyword === false || rawKeyword === ''
313
- ? undefined
314
- : typeof rawKeyword === 'string' ? { keyword: rawKeyword } : {};
315
- return { value: new CommandIndex(slots, context.snapshot, menu) };
316
- },
317
- },
318
- });
319
-
320
- export default commandFeature;