create-yerd 0.0.0-stage → 0.2.2

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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +84 -2
  3. package/bin/create-yerd.js +17 -0
  4. package/package.json +42 -4
  5. package/src/args.js +140 -0
  6. package/src/cli.js +180 -0
  7. package/src/pm.js +33 -0
  8. package/src/prompt.js +284 -0
  9. package/src/render.js +159 -0
  10. package/src/style.js +33 -0
  11. package/src/target.js +68 -0
  12. package/src/versions.js +14 -0
  13. package/templates/_shared/_env.example +9 -0
  14. package/templates/_shared/_gitignore +6 -0
  15. package/templates/_shared/vitest.config.ts +8 -0
  16. package/templates/api/README.md +39 -0
  17. package/templates/api/src/app.ts +9 -0
  18. package/templates/api/src/main.ts +5 -0
  19. package/templates/api/src/notes.ts +65 -0
  20. package/templates/api/src/routes/notes.ts +49 -0
  21. package/templates/api/test/notes.test.ts +68 -0
  22. package/templates/api/tsconfig.json +16 -0
  23. package/templates/api/vite.config.ts +7 -0
  24. package/templates/full/README.md +46 -0
  25. package/templates/full/e2e/shop.spec.ts +18 -0
  26. package/templates/full/playwright.config.ts +16 -0
  27. package/templates/full/public/favicon.svg +1 -0
  28. package/templates/full/src/app.ts +8 -0
  29. package/templates/full/src/cart.ts +50 -0
  30. package/templates/full/src/catalog.ts +59 -0
  31. package/templates/full/src/components.tsx +31 -0
  32. package/templates/full/src/layouts.tsx +67 -0
  33. package/templates/full/src/main.ts +5 -0
  34. package/templates/full/src/orders.ts +52 -0
  35. package/templates/full/src/routes/api.ts +34 -0
  36. package/templates/full/src/routes/auth.tsx +71 -0
  37. package/templates/full/src/routes/cart.tsx +66 -0
  38. package/templates/full/src/routes/checkout.tsx +101 -0
  39. package/templates/full/src/routes/products.tsx +79 -0
  40. package/templates/full/src/styles.css +72 -0
  41. package/templates/full/src/users.ts +41 -0
  42. package/templates/full/test/shop.test.ts +130 -0
  43. package/templates/full/tsconfig.json +18 -0
  44. package/templates/full/vite.config.ts +7 -0
  45. package/templates/minimal/README.md +26 -0
  46. package/templates/minimal/public/favicon.svg +1 -0
  47. package/templates/minimal/src/app.ts +7 -0
  48. package/templates/minimal/src/layouts.tsx +32 -0
  49. package/templates/minimal/src/main.ts +5 -0
  50. package/templates/minimal/src/routes/home.tsx +44 -0
  51. package/templates/minimal/src/styles.css +32 -0
  52. package/templates/minimal/test/app.test.ts +35 -0
  53. package/templates/minimal/tsconfig.json +18 -0
  54. package/templates/minimal/vite.config.ts +7 -0
  55. package/templates/tailwind/_shared/vite.config.ts +8 -0
  56. package/templates/tailwind/full/src/styles.css +49 -0
  57. package/templates/tailwind/minimal/src/layouts.tsx +33 -0
  58. package/templates/tailwind/minimal/src/routes/home.tsx +48 -0
  59. package/templates/tailwind/minimal/src/styles.css +2 -0
package/src/prompt.js ADDED
@@ -0,0 +1,284 @@
1
+ // @ts-check
2
+ // Prompts on node:readline. In a terminal: raw mode, arrow keys for selects,
3
+ // y/n for confirms. Anywhere else (a pipe, CI): one plain question per line,
4
+ // and the default for every question once input runs out.
5
+ import { createInterface, emitKeypressEvents } from "node:readline";
6
+
7
+ /** @typedef {import("./style.js").Style} Style */
8
+ /** @typedef {{ name?: string, ctrl?: boolean, meta?: boolean, sequence?: string }} Key */
9
+
10
+ /** Ctrl-C or Escape at a prompt. */
11
+ export class CancelError extends Error {
12
+ name = "CancelError";
13
+ constructor() {
14
+ super("Cancelled");
15
+ }
16
+ }
17
+
18
+ /**
19
+ * @template {string} T
20
+ * @typedef {{ value: T, label: string, hint?: string }} Choice
21
+ */
22
+
23
+ /**
24
+ * @param {object} io
25
+ * @param {NodeJS.ReadableStream & { isTTY?: boolean, setRawMode?: (on: boolean) => unknown, isRaw?: boolean }} io.input
26
+ * @param {NodeJS.WritableStream} io.output
27
+ * @param {Style} io.style
28
+ * @param {boolean} [io.interactive] raw-mode prompts; default: input and output are both terminals
29
+ */
30
+ export function createPrompter({ input, output, style, interactive }) {
31
+ const raw = interactive ?? Boolean(input.isTTY && /** @type {{ isTTY?: boolean }} */ (output).isTTY && input.setRawMode);
32
+ return raw ? rawPrompter(input, output, style) : linePrompter(input, output, style);
33
+ }
34
+
35
+ /** @typedef {ReturnType<typeof createPrompter>} Prompter */
36
+
37
+ /**
38
+ * The answered question, left on screen: "+ Project name my-app".
39
+ * @param {Style} style @param {string} message @param {string} answer
40
+ */
41
+ function answered(style, message, answer) {
42
+ return `${style.green("+")} ${message} ${style.cyan(answer)}\n`;
43
+ }
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Terminal: raw mode
47
+
48
+ /**
49
+ * @param {NodeJS.ReadableStream & { setRawMode?: (on: boolean) => unknown, isRaw?: boolean }} input
50
+ * @param {NodeJS.WritableStream} output
51
+ * @param {Style} style
52
+ */
53
+ function rawPrompter(input, output, style) {
54
+ emitKeypressEvents(input);
55
+
56
+ /**
57
+ * Draws `frame()` and redraws it after every key until `onKey` returns a result.
58
+ * @template R
59
+ * @param {() => string} frame
60
+ * @param {(str: string | undefined, key: Key) => { done: R } | void} onKey
61
+ * @returns {Promise<R>}
62
+ */
63
+ function interact(frame, onKey) {
64
+ return new Promise((resolve, reject) => {
65
+ const wasRaw = Boolean(input.isRaw);
66
+ let lines = 0;
67
+ const draw = () => {
68
+ // Back to the frame's first line, clear to the end, draw again.
69
+ const up = lines > 1 ? `\x1b[${lines - 1}A` : "";
70
+ const text = frame();
71
+ output.write(`${up}\r\x1b[J${text}`);
72
+ lines = text.split("\n").length;
73
+ };
74
+ const finish = () => {
75
+ input.off("keypress", onKeypress);
76
+ input.setRawMode?.(wasRaw);
77
+ input.pause();
78
+ const up = lines > 1 ? `\x1b[${lines - 1}A` : "";
79
+ output.write(`${up}\r\x1b[J\x1b[?25h`);
80
+ };
81
+ /** @param {string | undefined} str @param {Key | undefined} key */
82
+ const onKeypress = (str, key = {}) => {
83
+ if ((key.ctrl && key.name === "c") || key.name === "escape") {
84
+ finish();
85
+ reject(new CancelError());
86
+ return;
87
+ }
88
+ const result = onKey(str, key);
89
+ if (result) {
90
+ finish();
91
+ resolve(result.done);
92
+ } else draw();
93
+ };
94
+ input.setRawMode?.(true);
95
+ input.on("keypress", onKeypress);
96
+ input.resume();
97
+ draw();
98
+ });
99
+ }
100
+
101
+ return {
102
+ /**
103
+ * @param {{ message: string, initial?: string, validate?: (value: string) => string | undefined }} options
104
+ * @returns {Promise<string>}
105
+ */
106
+ async text({ message, initial = "", validate }) {
107
+ let value = "";
108
+ /** @type {string | undefined} */
109
+ let error;
110
+ const result = await interact(
111
+ () => {
112
+ const shown = value === "" && initial ? style.dim(initial) : value;
113
+ return `${style.cyan("?")} ${style.bold(message)} ${shown}${error ? `\n ${style.red(error)}` : ""}`;
114
+ },
115
+ (str, key) => {
116
+ if (key.name === "return" || key.name === "enter") {
117
+ const answer = value.trim() || initial;
118
+ error = validate?.(answer);
119
+ return error ? undefined : { done: answer };
120
+ }
121
+ if (key.name === "backspace") value = value.slice(0, -1);
122
+ else if (key.ctrl && key.name === "u") value = "";
123
+ else if (str && !key.ctrl && !key.meta && !/[\x00-\x1f\x7f]/.test(str)) value += str;
124
+ error = undefined;
125
+ },
126
+ );
127
+ output.write(answered(style, message, result));
128
+ return result;
129
+ },
130
+
131
+ /**
132
+ * @template {string} T
133
+ * @param {{ message: string, choices: Choice<T>[], initial?: T }} options
134
+ * @returns {Promise<T>}
135
+ */
136
+ async select({ message, choices, initial }) {
137
+ let index = Math.max(0, choices.findIndex((c) => c.value === initial));
138
+ const width = Math.max(...choices.map((c) => c.label.length));
139
+ output.write("\x1b[?25l");
140
+ const choice = await interact(
141
+ () =>
142
+ [
143
+ `${style.cyan("?")} ${style.bold(message)} ${style.dim("(arrow keys, enter)")}`,
144
+ ...choices.map((c, i) => {
145
+ const label = c.hint ? c.label.padEnd(width) : c.label;
146
+ const hint = c.hint ? ` ${style.dim(c.hint)}` : "";
147
+ return i === index ? `${style.cyan(">")} ${style.cyan(label)}${hint}` : ` ${label}${hint}`;
148
+ }),
149
+ ].join("\n"),
150
+ (_str, key) => {
151
+ if (key.name === "up" || key.name === "k") index = (index - 1 + choices.length) % choices.length;
152
+ else if (key.name === "down" || key.name === "j" || key.name === "tab") index = (index + 1) % choices.length;
153
+ else if (key.name === "return" || key.name === "enter") return { done: /** @type {Choice<T>} */ (choices[index]) };
154
+ },
155
+ );
156
+ output.write(answered(style, message, choice.label));
157
+ return choice.value;
158
+ },
159
+
160
+ /**
161
+ * @param {{ message: string, initial?: boolean }} options
162
+ * @returns {Promise<boolean>}
163
+ */
164
+ async confirm({ message, initial = true }) {
165
+ const result = await interact(
166
+ () => `${style.cyan("?")} ${style.bold(message)} ${style.dim(initial ? "(Y/n)" : "(y/N)")}`,
167
+ (str, key) => {
168
+ if (key.name === "return" || key.name === "enter") return { done: initial };
169
+ if (str?.toLowerCase() === "y") return { done: true };
170
+ if (str?.toLowerCase() === "n") return { done: false };
171
+ },
172
+ );
173
+ output.write(answered(style, message, result ? "yes" : "no"));
174
+ return result;
175
+ },
176
+
177
+ close() {},
178
+ };
179
+ }
180
+
181
+ // ---------------------------------------------------------------------------
182
+ // Pipes and CI: plain lines
183
+
184
+ /**
185
+ * @param {NodeJS.ReadableStream} input
186
+ * @param {NodeJS.WritableStream} output
187
+ * @param {Style} style
188
+ */
189
+ function linePrompter(input, output, style) {
190
+ /** @type {import("node:readline").Interface | undefined} */
191
+ let rl;
192
+ /** @type {AsyncIterator<string> | undefined} */
193
+ let lines;
194
+ let ended = false;
195
+
196
+ /** The next line of input, or undefined once there's no more. */
197
+ async function nextLine() {
198
+ if (ended) return undefined;
199
+ if (!lines) {
200
+ rl = createInterface({ input, terminal: false });
201
+ lines = rl[Symbol.asyncIterator]();
202
+ }
203
+ const next = await lines.next();
204
+ if (next.done) {
205
+ ended = true;
206
+ return undefined;
207
+ }
208
+ return next.value.trim();
209
+ }
210
+
211
+ /**
212
+ * Asks until `parse` accepts the line; no input left means the default.
213
+ * `parse` returns `{ value }`, or a message saying what's wrong.
214
+ * @template T
215
+ * @param {string} question
216
+ * @param {T} fallback
217
+ * @param {(line: string) => { value: T } | string} parse
218
+ * @returns {Promise<T>}
219
+ */
220
+ async function ask(question, fallback, parse) {
221
+ for (;;) {
222
+ output.write(question);
223
+ const line = await nextLine();
224
+ if (line === undefined) {
225
+ output.write("\n");
226
+ return fallback;
227
+ }
228
+ if (line === "") return fallback;
229
+ const result = parse(line);
230
+ if (typeof result !== "string") return result.value;
231
+ output.write(`${style.red(result)}\n`);
232
+ }
233
+ }
234
+
235
+ return {
236
+ /**
237
+ * @param {{ message: string, initial?: string, validate?: (value: string) => string | undefined }} options
238
+ * @returns {Promise<string>}
239
+ */
240
+ async text({ message, initial = "", validate }) {
241
+ const value = await ask(`${message}${initial ? ` (${initial})` : ""}: `, initial, (line) => {
242
+ const error = validate?.(line);
243
+ return error ?? { value: line };
244
+ });
245
+ const error = validate?.(value);
246
+ // The default itself failing (no input left) has nobody to ask again.
247
+ if (error) throw new Error(`${message}: ${error}`);
248
+ return value;
249
+ },
250
+
251
+ /**
252
+ * @template {string} T
253
+ * @param {{ message: string, choices: Choice<T>[], initial?: T }} options
254
+ * @returns {Promise<T>}
255
+ */
256
+ async select({ message, choices, initial }) {
257
+ const fallback = initial ?? /** @type {Choice<T>} */ (choices[0]).value;
258
+ const list = choices.map((c, i) => ` ${i + 1}) ${c.label}${c.hint ? ` - ${c.hint}` : ""}`).join("\n");
259
+ output.write(`${message}\n${list}\n`);
260
+ return ask(`Choose 1-${choices.length} or a name (${fallback}): `, fallback, (line) => {
261
+ const byNumber = /^\d+$/.test(line) ? choices[Number(line) - 1] : undefined;
262
+ const lower = line.toLowerCase();
263
+ const match = byNumber ?? choices.find((c) => c.value.toLowerCase() === lower || c.label.toLowerCase() === lower);
264
+ return match ? { value: match.value } : `Pick 1-${choices.length} or one of: ${choices.map((c) => c.value).join(", ")}`;
265
+ });
266
+ },
267
+
268
+ /**
269
+ * @param {{ message: string, initial?: boolean }} options
270
+ * @returns {Promise<boolean>}
271
+ */
272
+ async confirm({ message, initial = true }) {
273
+ return ask(`${message} ${initial ? "(Y/n)" : "(y/N)"}: `, initial, (line) => {
274
+ if (/^(y|yes)$/i.test(line)) return { value: true };
275
+ if (/^(n|no)$/i.test(line)) return { value: false };
276
+ return "Answer y or n";
277
+ });
278
+ },
279
+
280
+ close() {
281
+ rl?.close();
282
+ },
283
+ };
284
+ }
package/src/render.js ADDED
@@ -0,0 +1,159 @@
1
+ // @ts-check
2
+ import { mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { dirname, join, relative, sep } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+ import { commands } from "./pm.js";
6
+ import { VERSIONS } from "./versions.js";
7
+
8
+ /** @typedef {import("./args.js").Template} Template */
9
+ /** @typedef {import("./args.js").PackageManager} PackageManager */
10
+
11
+ export const TEMPLATES_DIR = fileURLToPath(new URL("../templates/", import.meta.url));
12
+
13
+ const own = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8"));
14
+
15
+ /** This package's version. create-yerd is released with yerd, at the same version. */
16
+ export const VERSION = /** @type {string} */ (own.version);
17
+
18
+ /**
19
+ * The @toyz/yerd dependency for generated apps: the matching release, or
20
+ * CREATE_YERD_YERD_SPEC (`file:/abs/path` to the repo or a packed tarball) for tests.
21
+ * @param {NodeJS.ProcessEnv} env
22
+ */
23
+ export function yerdSpec(env = process.env) {
24
+ return env.CREATE_YERD_YERD_SPEC || `^${VERSION}`;
25
+ }
26
+
27
+ /**
28
+ * @typedef {object} Options
29
+ * @property {string} name the package name
30
+ * @property {Template} template
31
+ * @property {boolean} tailwind
32
+ * @property {PackageManager} pm
33
+ * @property {string} yerd the @toyz/yerd version range or spec
34
+ */
35
+
36
+ /**
37
+ * Template folders to copy, in order; later ones overwrite earlier files.
38
+ * @param {Template} template @param {boolean} tailwind
39
+ */
40
+ export function layers(template, tailwind) {
41
+ const list = ["_shared", template];
42
+ if (tailwind) list.push("tailwind/_shared", `tailwind/${template}`);
43
+ return list;
44
+ }
45
+
46
+ /**
47
+ * The values `{{key}}` placeholders in template files are replaced with.
48
+ * @param {Options} options
49
+ * @returns {Record<string, string>}
50
+ */
51
+ export function variables({ name, pm }) {
52
+ const cmd = commands(pm);
53
+ return {
54
+ name,
55
+ pm,
56
+ install: cmd.install.join(" "),
57
+ exec: cmd.exec,
58
+ run_dev: cmd.run("dev"),
59
+ run_build: cmd.run("build"),
60
+ run_start: cmd.run("start"),
61
+ run_test: cmd.run("test"),
62
+ run_check: cmd.run("check"),
63
+ run_e2e: cmd.run("e2e"),
64
+ };
65
+ }
66
+
67
+ /**
68
+ * Replaces `{{key}}` with its value; an unknown key is a bug in the template.
69
+ * @param {string} text @param {Record<string, string>} vars @param {string} file
70
+ */
71
+ export function substitute(text, vars, file = "template") {
72
+ return text.replace(/\{\{(\w+)\}\}/g, (_, key) => {
73
+ const value = vars[key];
74
+ if (value === undefined) throw new Error(`${file}: unknown placeholder {{${key}}}`);
75
+ return value;
76
+ });
77
+ }
78
+
79
+ /**
80
+ * `_gitignore` -> `.gitignore`: npm won't publish some dotfiles, so templates name them with `_`.
81
+ * @param {string} path relative, with `/`
82
+ */
83
+ export function outputPath(path) {
84
+ return path.replace(/(^|\/)_([^/]+)$/, "$1.$2");
85
+ }
86
+
87
+ /**
88
+ * Every file of the layers, by output path, the last layer winning.
89
+ * @param {Options} options
90
+ * @returns {Map<string, string>} output path -> rendered contents
91
+ */
92
+ export function renderFiles(options) {
93
+ const vars = variables(options);
94
+ /** @type {Map<string, string>} */
95
+ const files = new Map();
96
+ for (const layer of layers(options.template, options.tailwind)) {
97
+ const root = join(TEMPLATES_DIR, layer);
98
+ for (const file of walk(root)) {
99
+ const rel = relative(root, file).split(sep).join("/");
100
+ files.set(outputPath(rel), substitute(readFileSync(file, "utf8"), vars, `${layer}/${rel}`));
101
+ }
102
+ }
103
+ files.set("package.json", `${JSON.stringify(packageJson(options), null, 2)}\n`);
104
+ return new Map([...files].sort(([a], [b]) => a.localeCompare(b)));
105
+ }
106
+
107
+ /**
108
+ * Writes the project into `dir` (created if missing). Returns the paths written.
109
+ * @param {string} dir @param {Options} options
110
+ */
111
+ export function writeProject(dir, options) {
112
+ const files = renderFiles(options);
113
+ for (const [path, contents] of files) {
114
+ const out = join(dir, path);
115
+ mkdirSync(dirname(out), { recursive: true });
116
+ writeFileSync(out, contents);
117
+ }
118
+ return [...files.keys()];
119
+ }
120
+
121
+ /**
122
+ * The generated package.json.
123
+ * @param {Options} options
124
+ */
125
+ export function packageJson({ name, template, tailwind, yerd }) {
126
+ /** @type {Record<string, string>} */
127
+ const scripts = {
128
+ dev: "vite",
129
+ build: "vite build",
130
+ start: "node --env-file-if-exists=.env dist/server/server.js",
131
+ test: "vitest run",
132
+ check: "tsc --noEmit",
133
+ };
134
+ const dev = ["@types/node", "esbuild", "typescript", "vite", "vitest"];
135
+ if (tailwind) dev.push("@tailwindcss/vite", "tailwindcss");
136
+ if (template === "full") {
137
+ scripts.e2e = "playwright test";
138
+ dev.push("@playwright/test");
139
+ }
140
+ return {
141
+ name,
142
+ version: "0.0.0",
143
+ private: true,
144
+ type: "module",
145
+ engines: { node: ">=24" },
146
+ scripts,
147
+ dependencies: { "@toyz/yerd": yerd },
148
+ devDependencies: Object.fromEntries(dev.sort().map((dep) => [dep, VERSIONS[/** @type {keyof typeof VERSIONS} */ (dep)]])),
149
+ };
150
+ }
151
+
152
+ /** @param {string} dir @returns {Generator<string>} */
153
+ function* walk(dir) {
154
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
155
+ const path = join(dir, entry.name);
156
+ if (entry.isDirectory()) yield* walk(path);
157
+ else if (entry.isFile()) yield path;
158
+ }
159
+ }
package/src/style.js ADDED
@@ -0,0 +1,33 @@
1
+ // @ts-check
2
+ // Plain ANSI styling. Off for NO_COLOR, TERM=dumb and anything that isn't a terminal.
3
+
4
+ /**
5
+ * Whether to color output written to `stream`.
6
+ * @param {{ isTTY?: boolean }} stream
7
+ * @param {NodeJS.ProcessEnv} env
8
+ */
9
+ export function useColor(stream, env = process.env) {
10
+ if (env.NO_COLOR) return false;
11
+ if (env.FORCE_COLOR && env.FORCE_COLOR !== "0") return true;
12
+ if (env.TERM === "dumb") return false;
13
+ return Boolean(stream.isTTY);
14
+ }
15
+
16
+ /**
17
+ * @param {boolean} enabled
18
+ */
19
+ export function createStyle(enabled) {
20
+ /** @param {number} open @param {number} close */
21
+ const wrap = (open, close) => (/** @type {string} */ text) => (enabled ? `\x1b[${open}m${text}\x1b[${close}m` : text);
22
+ return {
23
+ enabled,
24
+ bold: wrap(1, 22),
25
+ dim: wrap(2, 22),
26
+ red: wrap(31, 39),
27
+ green: wrap(32, 39),
28
+ yellow: wrap(33, 39),
29
+ cyan: wrap(36, 39),
30
+ };
31
+ }
32
+
33
+ /** @typedef {ReturnType<typeof createStyle>} Style */
package/src/target.js ADDED
@@ -0,0 +1,68 @@
1
+ // @ts-check
2
+ import { readdirSync, statSync } from "node:fs";
3
+ import { basename, resolve } from "node:path";
4
+
5
+ /** Files that don't make a directory "not empty". */
6
+ const IGNORABLE = new Set([".git", ".DS_Store", "Thumbs.db", ".idea", ".vscode"]);
7
+
8
+ /**
9
+ * The project directory as typed: `.` is the current folder.
10
+ * @param {string} dir
11
+ * @param {string} cwd
12
+ */
13
+ export function resolveTarget(dir, cwd = process.cwd()) {
14
+ return resolve(cwd, dir);
15
+ }
16
+
17
+ /**
18
+ * A valid npm package name for the folder: lowercased, anything npm refuses
19
+ * turned into dashes. A scope typed as the directory (`@me/app`) is kept.
20
+ * @param {string} path the absolute target
21
+ * @param {string} [typed] what the user typed, for a scoped name
22
+ */
23
+ export function packageName(path, typed) {
24
+ if (typed && /^@[^/]+\/[^/]+$/.test(typed.trim())) {
25
+ const [scope, name] = typed.trim().slice(1).split("/");
26
+ return `@${slug(scope ?? "")}/${slug(name ?? "") || "yerd-app"}`;
27
+ }
28
+ return slug(basename(path)) || "yerd-app";
29
+ }
30
+
31
+ /** @param {string} text */
32
+ function slug(text) {
33
+ return text
34
+ .trim()
35
+ .toLowerCase()
36
+ .replace(/\s+/g, "-")
37
+ .replace(/^[._]+/, "")
38
+ .replace(/[^a-z0-9._~-]+/g, "-")
39
+ .replace(/-+/g, "-")
40
+ .replace(/^-|-$/g, "")
41
+ .slice(0, 214);
42
+ }
43
+
44
+ /**
45
+ * Why `dir` can't be a project directory, or undefined if it can (as typed into the prompt).
46
+ * @param {string} dir
47
+ */
48
+ export function validateDir(dir) {
49
+ if (dir.trim() === "") return "Enter a directory name, or . for this one";
50
+ if (/[<>:"|?*\x00-\x1f]/.test(dir)) return "That name has characters a folder can't have";
51
+ return undefined;
52
+ }
53
+
54
+ /**
55
+ * What's at the target: nothing, an empty folder, a folder with files, or a file.
56
+ * @param {string} path
57
+ * @returns {"missing" | "empty" | "not-empty" | "file"}
58
+ */
59
+ export function inspectTarget(path) {
60
+ let stats;
61
+ try {
62
+ stats = statSync(path);
63
+ } catch {
64
+ return "missing";
65
+ }
66
+ if (!stats.isDirectory()) return "file";
67
+ return readdirSync(path).some((name) => !IGNORABLE.has(name)) ? "not-empty" : "empty";
68
+ }
@@ -0,0 +1,14 @@
1
+ // @ts-check
2
+ // Dev dependency ranges for generated apps. Kept equal to the yerd repo's own
3
+ // devDependencies (a test checks), so templates are built with what yerd is tested with.
4
+
5
+ export const VERSIONS = {
6
+ "@playwright/test": "^1.64.1",
7
+ "@tailwindcss/vite": "^4.3.3",
8
+ "@types/node": "^26.6.5",
9
+ esbuild: "^0.28.2",
10
+ tailwindcss: "^4.3.3",
11
+ typescript: "^7.0.2",
12
+ vite: "^8.3.4",
13
+ vitest: "^5.0.5",
14
+ };
@@ -0,0 +1,9 @@
1
+ # Copy to .env for `{{run_start}}`; in production, set these in the environment.
2
+
3
+ # Signs action tokens and seals session cookies: 32 bytes or more, the same on
4
+ # every instance. Without it, a random one, so sessions and forms break on restart.
5
+ # node -e "console.log(require('node:crypto').randomBytes(32).toString('base64url'))"
6
+ YERD_SECRET=
7
+
8
+ # Log filter, e.g. info or info,yerd=debug
9
+ # YERD_LOG=info
@@ -0,0 +1,6 @@
1
+ node_modules/
2
+ dist/
3
+ .env
4
+ *.tsbuildinfo
5
+ test-results/
6
+ playwright-report/
@@ -0,0 +1,8 @@
1
+ import { defineConfig } from "vitest/config";
2
+ import { yerdDecorators } from "@toyz/yerd/vite";
3
+
4
+ export default defineConfig({
5
+ // Lowers TC39 decorators, which Vite can't yet. Tests don't need the dev server.
6
+ plugins: [yerdDecorators()],
7
+ test: { include: ["test/**/*.test.{ts,tsx}"] },
8
+ });
@@ -0,0 +1,39 @@
1
+ # {{name}}
2
+
3
+ A JSON API on [yerd](https://toyz.github.io/yerd/): routes are decorated
4
+ classes, bodies are validated DTOs, errors are classes with a status.
5
+
6
+ ```sh
7
+ {{run_dev}} # dev server with reloads, on http://localhost:5173
8
+ {{run_test}} # vitest, in-process: no port
9
+ {{run_check}} # type-check (Vite doesn't)
10
+ {{run_build}} # production build: dist/server/server.js
11
+ {{run_start}} # node dist/server/server.js, on PORT or 3000
12
+ ```
13
+
14
+ ```sh
15
+ curl localhost:5173/api/notes
16
+ curl -X POST localhost:5173/api/notes -H 'content-type: application/json' -d '{"title":"Ship it"}'
17
+ curl -X DELETE localhost:5173/api/notes/1
18
+ ```
19
+
20
+ | Route | What |
21
+ | --- | --- |
22
+ | `GET /api/notes` | every note; `?done=true` filters |
23
+ | `POST /api/notes` | 201 with the note; 400 listing every invalid field; 409 for a duplicate title |
24
+ | `GET /api/notes/:id` | the note, or a 404 carrying the id |
25
+ | `PUT /api/notes/:id` | replace it |
26
+ | `DELETE /api/notes/:id` | 204, no body |
27
+ | `GET /api/health` | 204, for load balancers |
28
+
29
+ | File | What |
30
+ | --- | --- |
31
+ | `src/main.ts` | the Vite entry: the app with every route under `src/routes/` |
32
+ | `src/app.ts` | `createApp()`, shared with the tests |
33
+ | `src/notes.ts` | `NoteInput` DTO, `NoteNotFound`, the `Notes` service |
34
+ | `src/routes/notes.ts` | the routes |
35
+ | `test/notes.test.ts` | `app.request()` tests |
36
+
37
+ Add `.sessions()` in `src/app.ts` when you need cookies, and see
38
+ [request data](https://toyz.github.io/yerd/#/guide/request-data) and
39
+ [responses](https://toyz.github.io/yerd/#/guide/responses) for the rest.
@@ -0,0 +1,9 @@
1
+ import { yerd, type YerdOptions } from "@toyz/yerd";
2
+
3
+ /**
4
+ * The app without routes: main.ts discovers them, tests import the ones they need.
5
+ * No sessions, no layout: every answer is JSON (or a bare status).
6
+ */
7
+ export function createApp(options?: YerdOptions) {
8
+ return yerd(options);
9
+ }
@@ -0,0 +1,5 @@
1
+ /// <reference types="vite/client" />
2
+ import { createApp } from "./app.js";
3
+
4
+ // The Vite entry. The plugin serves it in dev and the build's server.js listens: no listen() here.
5
+ export default createApp().discover(import.meta.glob("./routes/**/*.ts", { eager: true }));