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.
- package/LICENSE +21 -0
- package/README.md +84 -2
- package/bin/create-yerd.js +17 -0
- package/package.json +42 -4
- package/src/args.js +140 -0
- package/src/cli.js +180 -0
- package/src/pm.js +33 -0
- package/src/prompt.js +284 -0
- package/src/render.js +159 -0
- package/src/style.js +33 -0
- package/src/target.js +68 -0
- package/src/versions.js +14 -0
- package/templates/_shared/_env.example +9 -0
- package/templates/_shared/_gitignore +6 -0
- package/templates/_shared/vitest.config.ts +8 -0
- package/templates/api/README.md +39 -0
- package/templates/api/src/app.ts +9 -0
- package/templates/api/src/main.ts +5 -0
- package/templates/api/src/notes.ts +65 -0
- package/templates/api/src/routes/notes.ts +49 -0
- package/templates/api/test/notes.test.ts +68 -0
- package/templates/api/tsconfig.json +16 -0
- package/templates/api/vite.config.ts +7 -0
- package/templates/full/README.md +46 -0
- package/templates/full/e2e/shop.spec.ts +18 -0
- package/templates/full/playwright.config.ts +16 -0
- package/templates/full/public/favicon.svg +1 -0
- package/templates/full/src/app.ts +8 -0
- package/templates/full/src/cart.ts +50 -0
- package/templates/full/src/catalog.ts +59 -0
- package/templates/full/src/components.tsx +31 -0
- package/templates/full/src/layouts.tsx +67 -0
- package/templates/full/src/main.ts +5 -0
- package/templates/full/src/orders.ts +52 -0
- package/templates/full/src/routes/api.ts +34 -0
- package/templates/full/src/routes/auth.tsx +71 -0
- package/templates/full/src/routes/cart.tsx +66 -0
- package/templates/full/src/routes/checkout.tsx +101 -0
- package/templates/full/src/routes/products.tsx +79 -0
- package/templates/full/src/styles.css +72 -0
- package/templates/full/src/users.ts +41 -0
- package/templates/full/test/shop.test.ts +130 -0
- package/templates/full/tsconfig.json +18 -0
- package/templates/full/vite.config.ts +7 -0
- package/templates/minimal/README.md +26 -0
- package/templates/minimal/public/favicon.svg +1 -0
- package/templates/minimal/src/app.ts +7 -0
- package/templates/minimal/src/layouts.tsx +32 -0
- package/templates/minimal/src/main.ts +5 -0
- package/templates/minimal/src/routes/home.tsx +44 -0
- package/templates/minimal/src/styles.css +32 -0
- package/templates/minimal/test/app.test.ts +35 -0
- package/templates/minimal/tsconfig.json +18 -0
- package/templates/minimal/vite.config.ts +7 -0
- package/templates/tailwind/_shared/vite.config.ts +8 -0
- package/templates/tailwind/full/src/styles.css +49 -0
- package/templates/tailwind/minimal/src/layouts.tsx +33 -0
- package/templates/tailwind/minimal/src/routes/home.tsx +48 -0
- 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
|
+
}
|
package/src/versions.js
ADDED
|
@@ -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,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 }));
|