create-nexwire 4.1.2 → 4.2.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.
Files changed (54) hide show
  1. package/express/gitignore-stub +31 -24
  2. package/express/package.json +2 -2
  3. package/express/src/database/migrations/sqlite/meta/0000_snapshot.json +411 -411
  4. package/express/src/database/migrations/sqlite/meta/_journal.json +12 -12
  5. package/express/src/framework/http/router.ts +16 -2
  6. package/express/src/framework/maker-cli/db/index.mjs +1 -1
  7. package/express/src/framework/maker-cli/deploy/core.mjs +0 -1
  8. package/express/src/framework/maker-cli/index.mjs +13 -3
  9. package/express/src/framework/maker-cli/module/core.mjs +121 -43
  10. package/express/src/framework/maker-cli/runtime/core.mjs +26 -7
  11. package/express/src/framework/maker-cli/runtime/index.mjs +120 -104
  12. package/express/src/framework/maker-cli/stubs/controller/openapi.ts.stub +6 -6
  13. package/express/src/framework/maker-cli/stubs/controller/plain.ts.stub +6 -6
  14. package/express/src/framework/maker-cli/stubs/helper/name.ts.stub +1 -1
  15. package/express/src/framework/maker-cli/stubs/seeder/name.ts.stub +17 -19
  16. package/express/src/framework/maker-cli/stubs/seeder/standalone.ts.stub +22 -0
  17. package/express/src/framework/maker-cli/stubs/service/name.plain.ts.stub +2 -2
  18. package/express/src/framework/maker-cli/stubs/service/name.ts.stub +1 -1
  19. package/express/src/framework/maker-cli/utils/help.mjs +374 -50
  20. package/express/src/framework/maker-cli/utils/naming.mjs +83 -20
  21. package/express/src/framework/maker-cli/utils/project-commands.mjs +140 -0
  22. package/express/src/framework/queue/queue.ts +402 -342
  23. package/express/src/modules/auth/database/seeders/role.ts +18 -18
  24. package/express/src/modules/auth/database/seeders/user.ts +35 -35
  25. package/express/vitest.config.ts +38 -23
  26. package/hono/gitignore-stub +31 -24
  27. package/hono/package.json +2 -2
  28. package/hono/src/database/migrations/sqlite/meta/0000_snapshot.json +411 -411
  29. package/hono/src/database/migrations/sqlite/meta/_journal.json +12 -12
  30. package/hono/src/framework/database/paginate.ts +253 -253
  31. package/hono/src/framework/http/router.ts +35 -14
  32. package/hono/src/framework/maker-cli/db/index.mjs +1 -1
  33. package/hono/src/framework/maker-cli/deploy/core.mjs +0 -1
  34. package/hono/src/framework/maker-cli/index.mjs +13 -3
  35. package/hono/src/framework/maker-cli/module/core.mjs +116 -38
  36. package/hono/src/framework/maker-cli/runtime/core.mjs +24 -6
  37. package/hono/src/framework/maker-cli/runtime/index.mjs +120 -104
  38. package/hono/src/framework/maker-cli/stubs/controller/openapi.ts.stub +6 -6
  39. package/hono/src/framework/maker-cli/stubs/controller/plain.ts.stub +6 -6
  40. package/hono/src/framework/maker-cli/stubs/helper/name.ts.stub +1 -1
  41. package/hono/src/framework/maker-cli/stubs/seeder/name.ts.stub +17 -19
  42. package/hono/src/framework/maker-cli/stubs/seeder/standalone.ts.stub +22 -0
  43. package/hono/src/framework/maker-cli/stubs/service/name.plain.ts.stub +2 -2
  44. package/hono/src/framework/maker-cli/stubs/service/name.ts.stub +1 -1
  45. package/hono/src/framework/maker-cli/utils/help.mjs +374 -50
  46. package/hono/src/framework/maker-cli/utils/naming.mjs +83 -20
  47. package/hono/src/framework/maker-cli/utils/project-commands.mjs +140 -0
  48. package/hono/src/framework/queue/queue.ts +402 -342
  49. package/hono/src/modules/auth/database/seeders/role.ts +18 -18
  50. package/hono/src/modules/auth/database/seeders/user.ts +35 -35
  51. package/hono/vitest.config.ts +38 -23
  52. package/package.json +1 -1
  53. package/express/src/framework/maker-cli/stubs/controller/openapi.with-model.ts.stub +0 -73
  54. package/hono/src/framework/maker-cli/stubs/controller/openapi.with-model.ts.stub +0 -69
@@ -1,50 +1,374 @@
1
- /**
2
- * Why: Detects the package manager used to run the CLI, for help usage text.
3
- * When: Building the program name shown in the "Usage:" line.
4
- * Where: Maker CLI entry flow.
5
- * How: Reads npm config user-agent and maps to the corresponding prefix string.
6
- */
7
- export function makerCommandPrefix() {
8
- const ua = String(process.env.npm_config_user_agent || "").toLowerCase();
9
- if (ua.startsWith("pnpm/")) return "pnpm";
10
- if (ua.startsWith("yarn/")) return "yarn";
11
- if (ua.startsWith("bun/")) return "bun";
12
- if (ua.startsWith("npm/")) return "npm run";
13
- return "";
14
- }
15
-
16
- /**
17
- * Why: Overrides Commander's help so the subcommand listing shows the actual
18
- * long flags (e.g. `--app-only|--server-only|--dev`) instead of a generic
19
- * `[options]` marker.
20
- * When: Building the CLI program before commands are registered.
21
- * Where: Maker CLI entry flow.
22
- * How: Injects a custom `subcommandTerm` via `configureHelp`.
23
- */
24
- export function configureDeployHelp(program) {
25
- program.configureHelp({
26
- subcommandTerm(cmd) {
27
- const flags = cmd.options
28
- .map((option) => option.flags.split(" ")[0])
29
- .filter((flag) => flag.startsWith("--"))
30
- .join("|");
31
- const args = cmd.registeredArguments.map((arg) => humanReadableArgName(arg)).join(" ");
32
- return cmd.name() + (flags ? ` [${flags}]` : "") + (args ? ` ${args}` : "");
33
- }
34
- });
35
- }
36
-
37
- function humanReadableArgName(arg) {
38
- const nameOutput = arg.name() + (arg.variadic === true ? "..." : "");
39
- return arg.required ? `<${nameOutput}>` : `[${nameOutput}]`;
40
- }
41
-
42
- /**
43
- * Why: Shows commander-generated help for the maker CLI program.
44
- * When: CLI runs without a valid command.
45
- * Where: Maker CLI entry flow and error handlers.
46
- * How: Delegates to commander's outputHelp for consistent automatically-synced output.
47
- */
48
- export function showHelp(program) {
49
- program.outputHelp();
50
- }
1
+ /**
2
+ * Why: Detects the package manager used to run the CLI, for help usage text.
3
+ * When: Building the program name shown in the "Usage:" line.
4
+ * Where: Maker CLI entry flow.
5
+ * How: Reads npm config user-agent and maps to the corresponding prefix string.
6
+ */
7
+ export function makerCommandPrefix() {
8
+ const ua = String(process.env.npm_config_user_agent || "").toLowerCase();
9
+ if (ua.startsWith("pnpm/")) return "pnpm";
10
+ if (ua.startsWith("yarn/")) return "yarn";
11
+ if (ua.startsWith("bun/")) return "bun";
12
+ if (ua.startsWith("npm/")) return "npm run";
13
+ return "";
14
+ }
15
+
16
+ /**
17
+ * Why: Overrides Commander's help so the subcommand listing shows the actual
18
+ * long flags (e.g. `--app-only|--server-only|--dev`) instead of a generic
19
+ * `[options]` marker.
20
+ * When: Building the CLI program before commands are registered.
21
+ * Where: Maker CLI entry flow.
22
+ * How: Injects a custom `subcommandTerm` via `configureHelp`.
23
+ */
24
+ export function configureDeployHelp(program) {
25
+ program.configureHelp({
26
+ subcommandTerm(cmd) {
27
+ const flags = cmd.options
28
+ .map((option) => option.flags.split(" ")[0])
29
+ .filter((flag) => flag.startsWith("--"))
30
+ .join("|");
31
+ const args = cmd.registeredArguments.map((arg) => humanReadableArgName(arg)).join(" ");
32
+ return cmd.name() + (flags ? ` [${flags}]` : "") + (args ? ` ${args}` : "");
33
+ }
34
+ });
35
+ }
36
+
37
+ function humanReadableArgName(arg) {
38
+ const nameOutput = arg.name() + (arg.variadic === true ? "..." : "");
39
+ return arg.required ? `<${nameOutput}>` : `[${nameOutput}]`;
40
+ }
41
+
42
+ /**
43
+ * Why: Shows commander-generated help for the maker CLI program.
44
+ * When: CLI runs without a valid command.
45
+ * Where: Maker CLI entry flow and error handlers.
46
+ * How: Delegates to commander's outputHelp for consistent automatically-synced output.
47
+ */
48
+ export function showHelp(program) {
49
+ program.outputHelp();
50
+ }
51
+
52
+ /**
53
+ * Marks a command as coming from the project's own maker/commands.mjs, so the
54
+ * help screen can put it beside `maker:init` instead of in the fallback section.
55
+ * Set by registerProjectCommands, read by groupCommands below.
56
+ */
57
+ export const PROJECT_COMMAND_FLAG = "makerProjectCommand";
58
+
59
+ /** Where a command nobody classified ends up. Better labelled than missing. */
60
+ export const FALLBACK_GROUP_TITLE = "Other commands";
61
+
62
+ /**
63
+ * Why: Groups the top-level command list so a developer can find a command by
64
+ * the question they are asking rather than by scrolling 57 flat lines.
65
+ * When: `maker --help`, and the help commander prints for an unknown command.
66
+ * Where: Maker CLI help rendering, applied by configureGroupedHelp.
67
+ * How: Explicit name lists rather than prefix patterns. A `module:make-*`
68
+ * pattern would also swallow `module:migrate` and `module:make-notification`
69
+ * into the generators section, and a command filed under the wrong heading
70
+ * is worse than an extra line of configuration here. The cost is that a new
71
+ * command must be added here too - which is the intent, because
72
+ * __tests__/help.test.ts fails when it is not, so nothing can be quietly
73
+ * dropped from the screen.
74
+ *
75
+ * Names only, no argument or option markers. Commander's default listing
76
+ * prints `[options] <module> <name>`, which pushed the description column
77
+ * to 47 characters and left almost no room to say what anything did. That
78
+ * detail is still one command away: `maker <command> --help`.
79
+ */
80
+ const COMMAND_GROUPS = [
81
+ {
82
+ title: "Getting started",
83
+ commands: ["dev", "serve", "ui:dev", "admin:dev", "maildev:view", "redis:view", "vite:cache:clear"]
84
+ },
85
+ {
86
+ title: "Generators - global",
87
+ // Deliberately separated from the module generators. `middleware:make`
88
+ // writes to src/middlewares and applies to the whole app;
89
+ // `module:make-middleware` writes inside one module and applies only to it.
90
+ // In the same section the two look interchangeable, which is exactly the
91
+ // mistake a developer is about to make.
92
+ commands: ["middleware:make"]
93
+ },
94
+ {
95
+ title: "Generators - modules",
96
+ commands: [
97
+ "module:make",
98
+ "module:make-notification",
99
+ "module:make-model",
100
+ "module:make-schema",
101
+ "module:make-service",
102
+ "module:make-controller",
103
+ "module:make-route",
104
+ "module:make-job",
105
+ "module:make-console",
106
+ "module:make-seeder",
107
+ "module:make-test",
108
+ "module:make-helper",
109
+ "module:make-middleware",
110
+ "module:make-type"
111
+ ]
112
+ },
113
+ {
114
+ title: "Modules - manage",
115
+ commands: [
116
+ "module:list",
117
+ "module:seed",
118
+ "module:migrate",
119
+ "module:delete",
120
+ "module:delete-notification",
121
+ "module:trash:clean"
122
+ ]
123
+ },
124
+ {
125
+ title: "Database",
126
+ commands: [
127
+ "db",
128
+ "db:schema",
129
+ "db:generate",
130
+ "db:migrate",
131
+ "db:migrate:run",
132
+ "db:migrate:reset",
133
+ "db:fresh",
134
+ "db:seed",
135
+ "db:module:seed",
136
+ "db:status",
137
+ "db:push",
138
+ "db:check",
139
+ "db:studio",
140
+ "db:reset",
141
+ "db:wipe"
142
+ ]
143
+ },
144
+ {
145
+ title: "Queue and scheduler",
146
+ commands: ["queue:work", "queue:clear", "schedule:work"]
147
+ },
148
+ {
149
+ title: "Deploy",
150
+ commands: [
151
+ "deploy:init",
152
+ "deploy:db:import",
153
+ "deploy:db:import:remote",
154
+ "deploy:workflow",
155
+ "deploy:workflow:remote",
156
+ "deploy:workflow:promote"
157
+ ]
158
+ },
159
+ {
160
+ title: "Testing",
161
+ commands: ["test", "test:watch", "test:coverage", "test:ui"]
162
+ },
163
+ {
164
+ title: "Your commands",
165
+ commands: ["maker:init"],
166
+ includeProjectCommands: true
167
+ }
168
+ ];
169
+
170
+ /** Widest name column before descriptions start on their own space anyway. */
171
+ const MAX_TERM_WIDTH = 30;
172
+ const MIN_DESCRIPTION_WIDTH = 28;
173
+
174
+ /**
175
+ * Why: Sorts commands into their display sections.
176
+ * When: Rendering `maker --help`, and asserted directly by the tests.
177
+ * Where: Maker CLI help rendering.
178
+ * How: Walks COMMAND_GROUPS in declared order and claims each named command.
179
+ * Anything left over goes to a fallback section rather than disappearing,
180
+ * so a newly added command is visible even in the moment before anyone
181
+ * decides where it belongs.
182
+ */
183
+ export function groupCommands(commands) {
184
+ const visible = commands.filter((command) => !command._hidden);
185
+ const claimed = new Set();
186
+ const groups = [];
187
+
188
+ for (const group of COMMAND_GROUPS) {
189
+ const members = [];
190
+
191
+ for (const name of group.commands) {
192
+ const command = visible.find((candidate) => candidate.name() === name && !claimed.has(name));
193
+ if (command) {
194
+ members.push(command);
195
+ claimed.add(name);
196
+ }
197
+ }
198
+
199
+ if (group.includeProjectCommands) {
200
+ for (const command of visible) {
201
+ if (command[PROJECT_COMMAND_FLAG] && !claimed.has(command.name())) {
202
+ members.push(command);
203
+ claimed.add(command.name());
204
+ }
205
+ }
206
+ }
207
+
208
+ if (members.length > 0) {
209
+ groups.push({ title: group.title, commands: members });
210
+ }
211
+ }
212
+
213
+ const leftovers = visible.filter((command) => !claimed.has(command.name()));
214
+ if (leftovers.length > 0) {
215
+ groups.push({ title: FALLBACK_GROUP_TITLE, commands: leftovers });
216
+ }
217
+
218
+ return groups;
219
+ }
220
+
221
+ /**
222
+ * Why: The plain-text header, and the ASCII wordmark used when there is room.
223
+ * When: Rendering the top of `maker --help`.
224
+ * Where: Maker CLI help rendering.
225
+ * How: The wordmark is a fixed literal. figlet was used to design it offline and
226
+ * is deliberately not a dependency: the CLI runs from source inside the
227
+ * user's project, so a runtime figlet would mean every scaffolded project
228
+ * installs it purely to print five fixed lines. Nothing is computed here.
229
+ *
230
+ * Three rules keep it from getting in the developer's way, which is the
231
+ * only thing a banner has to be careful about:
232
+ *
233
+ * 1. A terminal narrower than the art gets plain text, because art that
234
+ * overflows wraps into an unreadable mess.
235
+ * 2. Piped output gets plain text. Nobody asked for a wordmark at the top
236
+ * of their log file, and `maker > out.txt` should stay readable.
237
+ * 3. Per-command help gets no banner at all - see configureGroupedHelp.
238
+ */
239
+ const NEXWIRE_WORDMARK = [
240
+ " _",
241
+ " ____ ___ _ ___ __(_)_______",
242
+ " / __ \\/ _ \\| |/_/ | /| / / / ___/ _ \\",
243
+ " / / / / __/> < | |/ |/ / / / / __/",
244
+ "/_/ /_/\\___/_/|_| |__/|__/_/_/ \\___/"
245
+ ];
246
+
247
+ const PLAIN_HEADER = "nexwire maker";
248
+
249
+ /** Widest wordmark line, measured from the literal above. */
250
+ const WORDMARK_WIDTH = Math.max(...NEXWIRE_WORDMARK.map((line) => line.length));
251
+
252
+ /** Two columns of breathing room, so the art never touches the window edge. */
253
+ const MINIMUM_TERMINAL_WIDTH = WORDMARK_WIDTH + 2;
254
+
255
+ export function makerBanner(stream = process.stdout) {
256
+ if (!stream.isTTY) return PLAIN_HEADER;
257
+ if ((stream.columns || 80) < MINIMUM_TERMINAL_WIDTH) return PLAIN_HEADER;
258
+ return NEXWIRE_WORDMARK.join("\n");
259
+ }
260
+
261
+ /**
262
+ * Why: Replaces the top-level help screen with the grouped one.
263
+ * When: Once, while building the program, before anything prints help.
264
+ * Where: Maker CLI entry flow, alongside the other program configuration.
265
+ * How: Overrides commander's formatHelp and rebuilds the screen from the
266
+ * documented Help helpers (commandUsage, visibleOptions, subcommandTerm).
267
+ * Deliberately not string surgery on commander's output: that would depend
268
+ * on the exact shape of its "Commands:" heading and break on a version bump.
269
+ * Per-command help is left to commander, so `maker <command> --help` is
270
+ * unchanged and carries no wordmark.
271
+ */
272
+ export function configureGroupedHelp(program, { stream = process.stdout } = {}) {
273
+ program.configureHelp({
274
+ formatHelp(cmd, helper) {
275
+ const width = helper.helpWidth || stream.columns || 100;
276
+ // commandUsage returns the bare "maker [options] [command]"; the heading
277
+ // word is styled separately by commander and is not part of that string.
278
+ const blocks = [];
279
+
280
+ // cmd.parent is null only for the top-level program, which is what makes
281
+ // the wordmark an overview-only flourish rather than something on screen
282
+ // every time someone checks a flag.
283
+ if (!cmd.parent) blocks.push(makerBanner(stream));
284
+
285
+ blocks.push(`Usage: ${helper.commandUsage(cmd)}`);
286
+
287
+ const description = helper.commandDescription(cmd);
288
+ if (description) blocks.push(description);
289
+
290
+ const options = helper.visibleOptions(cmd);
291
+ if (options.length > 0) {
292
+ blocks.push(renderTable("Options", options.map((option) => [helper.optionTerm(option), helper.optionDescription(option)]), width));
293
+ }
294
+
295
+ const argumentsList = helper.visibleArguments(cmd);
296
+ if (argumentsList.length > 0) {
297
+ blocks.push(
298
+ renderTable(
299
+ "Arguments",
300
+ argumentsList.map((argument) => [helper.argumentTerm(argument), helper.argumentDescription(argument)]),
301
+ width
302
+ )
303
+ );
304
+ }
305
+
306
+ for (const group of groupCommands(cmd.commands)) {
307
+ blocks.push(
308
+ renderTable(
309
+ group.title,
310
+ group.commands.map((command) => [command.name(), helper.subcommandDescription(command)]),
311
+ width
312
+ )
313
+ );
314
+ }
315
+
316
+ // Commander adds its own `help [command]` lazily, after this formatter has
317
+ // already run, so the command is working but cannot be listed here. Rather
318
+ // than drop it silently, name both spellings in the footer - and prefer
319
+ // that to a heading containing one leftover entry, which reads as a
320
+ // mistake. __tests__/help.test.ts asserts `maker help <command>` works.
321
+ blocks.push("Run `maker <command> --help`, or `maker help <command>`, for the options and arguments of one command.");
322
+
323
+ return `${blocks.join("\n\n")}\n`;
324
+ }
325
+ });
326
+ }
327
+
328
+ /** Renders one `Heading:` block of `name description` rows. Exported for tests. */
329
+ export function renderTable(heading, rows, width) {
330
+ const termWidth = Math.min(MAX_TERM_WIDTH, Math.max(0, ...rows.map(([term]) => term.length)));
331
+ const descriptionWidth = Math.max(MIN_DESCRIPTION_WIDTH, width - termWidth - 4);
332
+
333
+ const lines = [`${heading}:`];
334
+
335
+ for (const [term, description] of rows) {
336
+ const gap = Math.max(2, termWidth - term.length + 2);
337
+ // A continuation line must start where the description started, not at the
338
+ // left edge. That column is 2 (indent) + termWidth (widest term) + gap.
339
+ const descriptionColumn = 2 + termWidth + gap;
340
+ const indent = " ".repeat(descriptionColumn);
341
+
342
+ if (!description) {
343
+ lines.push(` ${term}`);
344
+ continue;
345
+ }
346
+
347
+ const wrapped = wrapText(description, descriptionWidth);
348
+ lines.push(` ${term}${" ".repeat(gap)}${wrapped[0]}`);
349
+ for (const line of wrapped.slice(1)) lines.push(`${indent}${line}`);
350
+ }
351
+
352
+ return lines.join("\n");
353
+ }
354
+
355
+ /** Greedy word wrap. Keeps a long description readable instead of one huge line. */
356
+ function wrapText(text, width) {
357
+ const words = String(text).split(/\s+/).filter(Boolean);
358
+ if (words.length === 0) return [""];
359
+
360
+ const lines = [];
361
+ let line = "";
362
+
363
+ for (const word of words) {
364
+ if (line === "") line = word;
365
+ else if (line.length + 1 + word.length <= width) line += ` ${word}`;
366
+ else {
367
+ lines.push(line);
368
+ line = word;
369
+ }
370
+ }
371
+ if (line !== "") lines.push(line);
372
+
373
+ return lines;
374
+ }
@@ -1,32 +1,93 @@
1
+ /**
2
+ * Name handling for the generators.
3
+ *
4
+ * A developer may type a name in any common casing: `game-config`,
5
+ * `game_config`, `gameConfig` or `GameConfig`. All of them must resolve to the
6
+ * same thing, because a generator writes the name into four different places
7
+ * that each need a different shape:
8
+ *
9
+ * file name game-config.ts kebab
10
+ * SQL table game_configs snake
11
+ * identifier gameConfigs camel
12
+ * class name GameConfigSeeder pascal
13
+ *
14
+ * `normalizeName` produces the single canonical form (kebab). The other three
15
+ * are derived from it. This has to happen *before* lowercasing, because
16
+ * lowercasing `gameConfig` to `gameconfig` destroys the word boundary and no
17
+ * later step can recover it.
18
+ */
19
+
1
20
  /**
2
21
  * A name is safe when it can be used as a folder name, a URL segment, a table
3
22
  * name and a TypeScript identifier without further quoting.
4
23
  *
5
- * Kebab-case, snake_case and camelCase all pass: `pascal` already folds `-` and
6
- * `_`, and names are lowercased before this test. What fails is anything that
7
- * would produce uncompilable code - spaces and dots break the generated
8
- * `@/modules/...` import, a leading digit breaks the identifier.
24
+ * Kebab-case, snake_case, camelCase and PascalCase all pass: they are folded
25
+ * to one canonical kebab form first. What fails is anything that would produce
26
+ * uncompilable code - spaces and dots break the generated `@/modules/...`
27
+ * import, a leading digit breaks the identifier.
28
+ */
29
+ const RAW_PATTERN = /^[A-Za-z][A-Za-z0-9_-]*$/;
30
+
31
+ /**
32
+ * Fold any accepted casing to canonical kebab-case.
33
+ *
34
+ * `gameConfig` and `GameConfig` split on the lowercase/uppercase boundary;
35
+ * `game_config` splits on the underscore; repeated separators collapse to one
36
+ * dash. An all-caps run such as `GAMECONFIG` carries no boundary information
37
+ * and is deliberately kept as the single word `gameconfig`.
38
+ */
39
+ export function normalizeName(value) {
40
+ return String(value)
41
+ .trim()
42
+ .replace(/([a-z0-9])([A-Z])/g, "$1-$2")
43
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1-$2")
44
+ .replace(/[^A-Za-z0-9]+/g, "-")
45
+ .replace(/-{2,}/g, "-")
46
+ .replace(/^-+|-+$/g, "")
47
+ .toLowerCase();
48
+ }
49
+
50
+ /** Convert a kebab-case or camelCase string to snake_case (the SQL table name). */
51
+ export function snakeCase(input) {
52
+ return String(input)
53
+ .trim()
54
+ .replace(/[^A-Za-z0-9]+/g, "_")
55
+ .replace(/_{2,}/g, "_")
56
+ .replace(/^_+|_+$/g, "")
57
+ .toLowerCase();
58
+ }
59
+
60
+ /**
61
+ * Pluralise an identifier for a table/collection name.
62
+ *
63
+ * Deliberately minimal: consonant + `y` becomes `ies` (so `auditEntry` yields
64
+ * `auditEntries`, matching the export a developer would hand-write), anything
65
+ * else takes a plain `s`. A word that already ends in `s` is returned
66
+ * unchanged so the helper is idempotent. English irregulars (`person` ->
67
+ * `people`) are not modelled; correct those by hand after generating.
9
68
  */
10
- const NAME_PATTERN = /^[a-z][a-z0-9_-]*$/;
69
+ export function plural(word) {
70
+ const value = String(word);
71
+ if (!value) return value;
72
+ if (/[^aeiou]y$/i.test(value)) return `${value.slice(0, -1)}ies`;
73
+ if (/s$/i.test(value)) return value;
74
+ return `${value}s`;
75
+ }
11
76
 
12
77
  /**
13
78
  * Best-effort readable repair of a rejected name, used only in error text.
14
- * Returns "" when no safe suggestion exists (e.g. a leading digit cannot be
79
+ * Returns "" when no safe suggestion exists (e.g., a leading digit cannot be
15
80
  * reordered without guessing the developer's intent).
16
81
  */
17
82
  function suggestName(value) {
18
- const cleaned = value
19
- .trim()
20
- .toLowerCase()
21
- .replace(/[^a-z0-9_-]+/g, "-")
22
- .replace(/-{2,}/g, "-")
23
- .replace(/^[-_]+|[-_]+$/g, "");
83
+ const cleaned = normalizeName(value);
24
84
  if (!cleaned || !/^[a-z]/.test(cleaned)) return "";
25
85
  return cleaned;
26
86
  }
27
87
 
28
88
  /**
29
- * Validate that a name value exists and is safe to use.
89
+ * Validate a name value exists and is safe to use, and return its canonical
90
+ * form so every generator writes the same name to disk.
30
91
  * Throws with the exact command to run instead when the value is unusable.
31
92
  */
32
93
  export function assertName(value, label) {
@@ -34,14 +95,16 @@ export function assertName(value, label) {
34
95
  throw new Error(`${label} is required.`);
35
96
  }
36
97
 
37
- const name = value.trim().toLowerCase();
38
- if (NAME_PATTERN.test(name)) return name;
98
+ const trimmed = value.trim();
99
+ if (!RAW_PATTERN.test(trimmed)) {
100
+ const suggestion = suggestName(value);
101
+ const reason = /^[0-9]/.test(trimmed)
102
+ ? "must start with a lowercase letter"
103
+ : "may only contain lowercase letters, numbers, dashes and underscores";
104
+ throw new Error(`Invalid ${label.toLowerCase()}: "${value}" - a name ${reason}.${suggestion ? ` Use "${suggestion}" instead.` : ""}`);
105
+ }
39
106
 
40
- const suggestion = suggestName(value);
41
- const reason = /^[0-9]/.test(name)
42
- ? "must start with a lowercase letter"
43
- : "may only contain lowercase letters, numbers, dashes and underscores";
44
- throw new Error(`Invalid ${label.toLowerCase()}: "${value}" - a name ${reason}.${suggestion ? ` Use "${suggestion}" instead.` : ""}`);
107
+ return normalizeName(trimmed);
45
108
  }
46
109
 
47
110
  /** Convert a kebab-case or snake_case string to PascalCase. */