create-next-style-guide 0.0.0-stage → 0.1.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/addons/ci/.cursor/rules/ci.mdc +11 -0
- package/addons/ci/docs/style-guide/ops/ci.md +7 -0
- package/addons/docker/.cursor/rules/docker.mdc +12 -0
- package/addons/docker/.dockerignore +12 -0
- package/addons/docker/docker/production.Dockerfile +53 -0
- package/addons/docker/docs/style-guide/ops/docker.md +14 -0
- package/addons/docs/data/tanstack-query.md +7 -0
- package/addons/docs/examples/api-app-crud.md +94 -0
- package/addons/docs/ui/icons.md +5 -0
- package/addons/endpoints/.cursor/rules/endpoints.mdc +17 -0
- package/addons/endpoints/docs/style-guide/http/endpoints.md +21 -0
- package/addons/endpoints/scripts/endpoints.codegen.json +7 -0
- package/addons/endpoints/scripts/generate-endpoints.ts +1384 -0
- package/addons/endpoints/src/endpoints/Types.ts +105 -0
- package/addons/endpoints/src/endpoints/index.ts +2 -0
- package/addons/endpoints/src/utils/api-error.ts +181 -0
- package/addons/endpoints/src/utils/api-result.ts +13 -0
- package/addons/endpoints/src/utils/axios-instance.ts +23 -0
- package/addons/endpoints/src/utils/form-data.ts +37 -0
- package/addons/endpoints/src/utils/search-params.ts +21 -0
- package/addons/eslint/.cursor/rules/eslint.mdc +12 -0
- package/addons/eslint/docs/style-guide/tooling/eslint.md +13 -0
- package/addons/eslint/eslint.config.mjs +85 -0
- package/addons/husky/.husky/pre-commit +2 -0
- package/addons/husky/docs/style-guide/tooling/husky.md +10 -0
- package/addons/prettier/.cursor/rules/prettier.mdc +11 -0
- package/addons/prettier/.prettierrc +14 -0
- package/addons/prettier/docs/style-guide/tooling/prettier.md +9 -0
- package/dist/index.js +3848 -0
- package/dist/index.js.map +1 -0
- package/package.json +38 -4
- package/template/.cursor/rules/components.mdc +27 -0
- package/template/.cursor/rules/core.mdc +16 -0
- package/template/.cursor/rules/forms.mdc +16 -0
- package/template/.env.example +6 -0
- package/template/AGENTS.md +38 -0
- package/template/README.md +28 -0
- package/template/docs/style-guide/README.md +11 -0
- package/template/docs/style-guide/http/client.md +9 -0
- package/template/docs/style-guide/overview/architecture.md +21 -0
- package/template/docs/style-guide/overview/project-structure.md +36 -0
- package/template/eslint.config.mjs +13 -0
- package/template/gitignore +39 -0
- package/template/next-env.d.ts +4 -0
- package/template/next.config.ts +7 -0
- package/template/package.json +27 -0
- package/template/src/app/_components/welcome-card/index.tsx +24 -0
- package/template/src/app/globals.css +84 -0
- package/template/src/app/layout.tsx +22 -0
- package/template/src/app/page.tsx +25 -0
- package/template/src/components/.gitkeep +0 -0
- package/template/src/constants/.gitkeep +0 -0
- package/template/src/hooks/.gitkeep +0 -0
- package/template/src/lib/env.ts +17 -0
- package/template/src/lib/http.ts +28 -0
- package/template/src/lib/utils.ts +6 -0
- package/template/src/providers/.gitkeep +0 -0
- package/template/src/types/.gitkeep +0 -0
- package/template/src/utils/.gitkeep +0 -0
- package/template/tsconfig.json +23 -0
- package/README.md +0 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,3848 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/index.ts
|
|
4
|
+
import * as p4 from "@clack/prompts";
|
|
5
|
+
import color4 from "picocolors";
|
|
6
|
+
import path30 from "path";
|
|
7
|
+
|
|
8
|
+
// src/constants.ts
|
|
9
|
+
import path from "path";
|
|
10
|
+
import { fileURLToPath } from "url";
|
|
11
|
+
var here = path.dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
var TEMPLATE_DIR = path.resolve(here, "../template");
|
|
13
|
+
var ADDONS_DIR = path.resolve(here, "../addons");
|
|
14
|
+
var TITLE = "Next Style Guide";
|
|
15
|
+
var PKG_NAME = "create-next-style-guide";
|
|
16
|
+
|
|
17
|
+
// src/helpers/package-manager.ts
|
|
18
|
+
function joinArgs(args) {
|
|
19
|
+
return args?.length ? ` ${args.join(" ")}` : "";
|
|
20
|
+
}
|
|
21
|
+
var PACKAGE_MANAGERS = {
|
|
22
|
+
pnpm: {
|
|
23
|
+
id: "pnpm",
|
|
24
|
+
packageManagerField: "pnpm@9.15.0",
|
|
25
|
+
lockfile: "pnpm-lock.yaml",
|
|
26
|
+
installArgs: ["install", "--ignore-workspace"],
|
|
27
|
+
runScript: (script, args) => `pnpm ${script}${joinArgs(args)}`,
|
|
28
|
+
exec: (bin, args) => `pnpm exec ${bin}${joinArgs(args)}`
|
|
29
|
+
},
|
|
30
|
+
npm: {
|
|
31
|
+
id: "npm",
|
|
32
|
+
packageManagerField: null,
|
|
33
|
+
lockfile: "package-lock.json",
|
|
34
|
+
// --install-strategy=hoisted keeps the app isolated if nested under another workspace tree
|
|
35
|
+
installArgs: ["install", "--install-strategy=hoisted"],
|
|
36
|
+
runScript: (script, args) => `npm run ${script}${joinArgs(args)}`,
|
|
37
|
+
exec: (bin, args) => `npx ${bin}${joinArgs(args)}`
|
|
38
|
+
},
|
|
39
|
+
yarn: {
|
|
40
|
+
id: "yarn",
|
|
41
|
+
packageManagerField: "yarn@1.22.22",
|
|
42
|
+
lockfile: "yarn.lock",
|
|
43
|
+
installArgs: ["install"],
|
|
44
|
+
runScript: (script, args) => `yarn ${script}${joinArgs(args)}`,
|
|
45
|
+
exec: (bin, args) => `yarn ${bin}${joinArgs(args)}`
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
function getPackageManager(id) {
|
|
49
|
+
return PACKAGE_MANAGERS[id];
|
|
50
|
+
}
|
|
51
|
+
function ciInstallCommand(id) {
|
|
52
|
+
switch (id) {
|
|
53
|
+
case "pnpm":
|
|
54
|
+
return "pnpm install --frozen-lockfile";
|
|
55
|
+
case "npm":
|
|
56
|
+
return "npm ci";
|
|
57
|
+
case "yarn":
|
|
58
|
+
return "yarn install --frozen-lockfile";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// src/helpers/slugify.ts
|
|
63
|
+
function slugify(name) {
|
|
64
|
+
const slug = name.trim().toLowerCase().replace(/[\s.-]+/g, "_").replace(/[^a-z0-9_]/g, "").replace(/_+/g, "_").replace(/^_|_$/g, "");
|
|
65
|
+
if (!slug) {
|
|
66
|
+
throw new Error(`Could not derive a package slug from "${name}".`);
|
|
67
|
+
}
|
|
68
|
+
if (/^[0-9]/.test(slug)) {
|
|
69
|
+
return `app_${slug}`;
|
|
70
|
+
}
|
|
71
|
+
return slug;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// src/presets.ts
|
|
75
|
+
var PRESET_SEEDS = {
|
|
76
|
+
minimal: {
|
|
77
|
+
packageManager: "pnpm",
|
|
78
|
+
auth: "none",
|
|
79
|
+
styling: "none",
|
|
80
|
+
ui: "none",
|
|
81
|
+
data: "rsc",
|
|
82
|
+
store: "none",
|
|
83
|
+
icons: "none",
|
|
84
|
+
validation: "zod",
|
|
85
|
+
i18n: false,
|
|
86
|
+
darkMode: false,
|
|
87
|
+
apiEnvelope: false,
|
|
88
|
+
endpoints: false,
|
|
89
|
+
seo: false,
|
|
90
|
+
eslint: false,
|
|
91
|
+
prettier: false,
|
|
92
|
+
husky: false,
|
|
93
|
+
docker: false,
|
|
94
|
+
ci: false,
|
|
95
|
+
testing: false,
|
|
96
|
+
playwright: false
|
|
97
|
+
},
|
|
98
|
+
"api-app": {
|
|
99
|
+
packageManager: "pnpm",
|
|
100
|
+
auth: "jwt",
|
|
101
|
+
styling: "tailwind",
|
|
102
|
+
ui: "shadcn",
|
|
103
|
+
data: "tanstack-query",
|
|
104
|
+
store: "zustand",
|
|
105
|
+
icons: "lucide",
|
|
106
|
+
validation: "zod",
|
|
107
|
+
i18n: false,
|
|
108
|
+
darkMode: true,
|
|
109
|
+
apiEnvelope: true,
|
|
110
|
+
endpoints: true,
|
|
111
|
+
seo: false,
|
|
112
|
+
eslint: true,
|
|
113
|
+
prettier: true,
|
|
114
|
+
husky: true,
|
|
115
|
+
docker: false,
|
|
116
|
+
ci: true,
|
|
117
|
+
testing: true,
|
|
118
|
+
playwright: false
|
|
119
|
+
},
|
|
120
|
+
marketing: {
|
|
121
|
+
packageManager: "pnpm",
|
|
122
|
+
auth: "none",
|
|
123
|
+
styling: "tailwind",
|
|
124
|
+
ui: "shadcn",
|
|
125
|
+
data: "rsc",
|
|
126
|
+
store: "none",
|
|
127
|
+
icons: "lucide",
|
|
128
|
+
validation: "zod",
|
|
129
|
+
i18n: true,
|
|
130
|
+
darkMode: true,
|
|
131
|
+
apiEnvelope: false,
|
|
132
|
+
endpoints: false,
|
|
133
|
+
seo: true,
|
|
134
|
+
eslint: true,
|
|
135
|
+
prettier: true,
|
|
136
|
+
husky: true,
|
|
137
|
+
docker: false,
|
|
138
|
+
ci: true,
|
|
139
|
+
testing: true,
|
|
140
|
+
playwright: false
|
|
141
|
+
},
|
|
142
|
+
dashboard: {
|
|
143
|
+
packageManager: "pnpm",
|
|
144
|
+
auth: "none",
|
|
145
|
+
styling: "tailwind",
|
|
146
|
+
ui: "shadcn",
|
|
147
|
+
data: "tanstack-query",
|
|
148
|
+
store: "zustand",
|
|
149
|
+
icons: "lucide",
|
|
150
|
+
validation: "zod",
|
|
151
|
+
i18n: false,
|
|
152
|
+
darkMode: true,
|
|
153
|
+
apiEnvelope: false,
|
|
154
|
+
endpoints: false,
|
|
155
|
+
seo: false,
|
|
156
|
+
eslint: true,
|
|
157
|
+
prettier: true,
|
|
158
|
+
husky: true,
|
|
159
|
+
docker: false,
|
|
160
|
+
ci: true,
|
|
161
|
+
testing: true,
|
|
162
|
+
playwright: false
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
function describePreset(id) {
|
|
166
|
+
const seed = PRESET_SEEDS[id];
|
|
167
|
+
return [
|
|
168
|
+
`styling=${seed.styling}`,
|
|
169
|
+
seed.ui === "shadcn" ? "shadcn" : null,
|
|
170
|
+
`auth=${seed.auth}`,
|
|
171
|
+
seed.store !== "none" ? `store=${seed.store}` : null,
|
|
172
|
+
`validation=${seed.validation}`,
|
|
173
|
+
seed.endpoints ? "endpoints" : null,
|
|
174
|
+
seed.data === "tanstack-query" ? "query" : null,
|
|
175
|
+
seed.darkMode ? "dark" : null,
|
|
176
|
+
seed.seo ? "seo" : null,
|
|
177
|
+
seed.i18n ? "i18n" : null,
|
|
178
|
+
seed.testing ? "vitest" : null
|
|
179
|
+
].filter(Boolean).join(" \xB7 ");
|
|
180
|
+
}
|
|
181
|
+
function collectOptionWarnings(options) {
|
|
182
|
+
const warnings = [];
|
|
183
|
+
if (options.auth === "jwt" && !options.endpoints) {
|
|
184
|
+
warnings.push(
|
|
185
|
+
"JWT without endpoints: only token-storage is added (no axios refresh wiring)."
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
if (options.ui === "shadcn" && options.styling !== "tailwind") {
|
|
189
|
+
warnings.push("shadcn requires Tailwind \u2014 ui forced to none.");
|
|
190
|
+
}
|
|
191
|
+
if (options.darkMode && options.styling !== "tailwind") {
|
|
192
|
+
warnings.push("Dark mode requires Tailwind \u2014 disabled.");
|
|
193
|
+
}
|
|
194
|
+
if (options.apiEnvelope && !options.endpoints) {
|
|
195
|
+
warnings.push(
|
|
196
|
+
"API envelope enabled without endpoints: unwrap applies to src/lib/http.ts apiFetch."
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
if (options.playwright && !options.testing) {
|
|
200
|
+
warnings.push("Playwright was requested without Vitest \u2014 enabling testing.");
|
|
201
|
+
}
|
|
202
|
+
if (options.store !== "none" && options.data === "tanstack-query") {
|
|
203
|
+
warnings.push(
|
|
204
|
+
"Client store + TanStack Query: use Query for server cache; store for UI/session client state only."
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
return warnings;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// src/prompts.ts
|
|
211
|
+
import * as p from "@clack/prompts";
|
|
212
|
+
import color from "picocolors";
|
|
213
|
+
|
|
214
|
+
// src/types.ts
|
|
215
|
+
var DEFAULT_OPTIONS = {
|
|
216
|
+
packageManager: "pnpm",
|
|
217
|
+
auth: "none",
|
|
218
|
+
styling: "none",
|
|
219
|
+
ui: "none",
|
|
220
|
+
data: "rsc",
|
|
221
|
+
store: "none",
|
|
222
|
+
icons: "none",
|
|
223
|
+
validation: "zod",
|
|
224
|
+
i18n: false,
|
|
225
|
+
darkMode: false,
|
|
226
|
+
/** Neutral base — enable via preset / flags when building an API client app */
|
|
227
|
+
apiEnvelope: false,
|
|
228
|
+
endpoints: false,
|
|
229
|
+
seo: false,
|
|
230
|
+
eslint: true,
|
|
231
|
+
prettier: true,
|
|
232
|
+
husky: true,
|
|
233
|
+
docker: false,
|
|
234
|
+
ci: true,
|
|
235
|
+
testing: true,
|
|
236
|
+
playwright: false,
|
|
237
|
+
noInstall: false
|
|
238
|
+
};
|
|
239
|
+
var ICON_PACKAGES = {
|
|
240
|
+
lucide: { name: "lucide-react", version: "^0.469.0" },
|
|
241
|
+
tabler: { name: "@tabler/icons-react", version: "^3.46.0" }
|
|
242
|
+
};
|
|
243
|
+
function normalizeOptions(options) {
|
|
244
|
+
let next = { ...options };
|
|
245
|
+
if (next.styling !== "tailwind" && next.ui === "shadcn") {
|
|
246
|
+
next = { ...next, ui: "none" };
|
|
247
|
+
}
|
|
248
|
+
if (next.styling !== "tailwind" && next.darkMode) {
|
|
249
|
+
next = { ...next, darkMode: false };
|
|
250
|
+
}
|
|
251
|
+
if (next.playwright && !next.testing) {
|
|
252
|
+
next = { ...next, testing: true };
|
|
253
|
+
}
|
|
254
|
+
return next;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// src/prompts.ts
|
|
258
|
+
function assertNotCancel(value) {
|
|
259
|
+
if (p.isCancel(value)) {
|
|
260
|
+
p.cancel("Scaffold cancelled.");
|
|
261
|
+
process.exit(0);
|
|
262
|
+
}
|
|
263
|
+
return value;
|
|
264
|
+
}
|
|
265
|
+
async function promptCustomOptions(base) {
|
|
266
|
+
const styling = assertNotCancel(
|
|
267
|
+
await p.select({
|
|
268
|
+
message: "Styling system?",
|
|
269
|
+
options: [
|
|
270
|
+
{ value: "none", label: "None", hint: "plain CSS" },
|
|
271
|
+
{
|
|
272
|
+
value: "tailwind",
|
|
273
|
+
label: "Tailwind CSS",
|
|
274
|
+
hint: "enables optional shadcn"
|
|
275
|
+
},
|
|
276
|
+
{
|
|
277
|
+
value: "mui",
|
|
278
|
+
label: "MUI",
|
|
279
|
+
hint: "no shadcn"
|
|
280
|
+
}
|
|
281
|
+
],
|
|
282
|
+
initialValue: base.styling
|
|
283
|
+
})
|
|
284
|
+
);
|
|
285
|
+
let ui = "none";
|
|
286
|
+
let darkMode = false;
|
|
287
|
+
if (styling === "tailwind") {
|
|
288
|
+
const useShadcn = assertNotCancel(
|
|
289
|
+
await p.confirm({
|
|
290
|
+
message: "Add shadcn/ui?",
|
|
291
|
+
initialValue: base.ui === "shadcn"
|
|
292
|
+
})
|
|
293
|
+
);
|
|
294
|
+
ui = useShadcn ? "shadcn" : "none";
|
|
295
|
+
darkMode = assertNotCancel(
|
|
296
|
+
await p.confirm({
|
|
297
|
+
message: "Add dark mode (next-themes)?",
|
|
298
|
+
initialValue: base.darkMode
|
|
299
|
+
})
|
|
300
|
+
);
|
|
301
|
+
}
|
|
302
|
+
const icons = assertNotCancel(
|
|
303
|
+
await p.select({
|
|
304
|
+
message: "Icon library?",
|
|
305
|
+
options: [
|
|
306
|
+
{ value: "none", label: "None" },
|
|
307
|
+
{ value: "lucide", label: "Lucide" },
|
|
308
|
+
{ value: "tabler", label: "Tabler" }
|
|
309
|
+
],
|
|
310
|
+
initialValue: base.icons
|
|
311
|
+
})
|
|
312
|
+
);
|
|
313
|
+
const validation = assertNotCancel(
|
|
314
|
+
await p.select({
|
|
315
|
+
message: "Validation library?",
|
|
316
|
+
options: [
|
|
317
|
+
{ value: "zod", label: "Zod" },
|
|
318
|
+
{ value: "yup", label: "Yup" }
|
|
319
|
+
],
|
|
320
|
+
initialValue: base.validation
|
|
321
|
+
})
|
|
322
|
+
);
|
|
323
|
+
const useTanstackQuery = assertNotCancel(
|
|
324
|
+
await p.confirm({
|
|
325
|
+
message: "Add TanStack Query?",
|
|
326
|
+
initialValue: base.data === "tanstack-query"
|
|
327
|
+
})
|
|
328
|
+
);
|
|
329
|
+
const needsStore = assertNotCancel(
|
|
330
|
+
await p.confirm({
|
|
331
|
+
message: "Add a client store (global UI state)?",
|
|
332
|
+
initialValue: base.store !== "none"
|
|
333
|
+
})
|
|
334
|
+
);
|
|
335
|
+
let store = "none";
|
|
336
|
+
if (needsStore) {
|
|
337
|
+
store = assertNotCancel(
|
|
338
|
+
await p.select({
|
|
339
|
+
message: "Which store library?",
|
|
340
|
+
options: [
|
|
341
|
+
{
|
|
342
|
+
value: "zustand",
|
|
343
|
+
label: "Zustand",
|
|
344
|
+
hint: "lightweight"
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
value: "redux",
|
|
348
|
+
label: "Redux Toolkit",
|
|
349
|
+
hint: "structured slices"
|
|
350
|
+
}
|
|
351
|
+
],
|
|
352
|
+
initialValue: base.store === "redux" ? "redux" : "zustand"
|
|
353
|
+
})
|
|
354
|
+
);
|
|
355
|
+
}
|
|
356
|
+
const endpoints = assertNotCancel(
|
|
357
|
+
await p.confirm({
|
|
358
|
+
message: "Add OpenAPI endpoints codegen?",
|
|
359
|
+
initialValue: base.endpoints
|
|
360
|
+
})
|
|
361
|
+
);
|
|
362
|
+
let apiEnvelope = base.apiEnvelope;
|
|
363
|
+
if (endpoints) {
|
|
364
|
+
apiEnvelope = assertNotCancel(
|
|
365
|
+
await p.confirm({
|
|
366
|
+
message: "Unwrap API envelopes in http/axios helpers?",
|
|
367
|
+
initialValue: true
|
|
368
|
+
})
|
|
369
|
+
);
|
|
370
|
+
} else {
|
|
371
|
+
apiEnvelope = assertNotCancel(
|
|
372
|
+
await p.confirm({
|
|
373
|
+
message: "Unwrap API envelopes in apiFetch (lib/http.ts)?",
|
|
374
|
+
initialValue: false
|
|
375
|
+
})
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
const auth = assertNotCancel(
|
|
379
|
+
await p.select({
|
|
380
|
+
message: "Auth?",
|
|
381
|
+
options: [
|
|
382
|
+
{ value: "none", label: "None" },
|
|
383
|
+
{
|
|
384
|
+
value: "jwt",
|
|
385
|
+
label: "JWT",
|
|
386
|
+
hint: "Bearer + refresh"
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
value: "authjs",
|
|
390
|
+
label: "Auth.js",
|
|
391
|
+
hint: "Credentials demo"
|
|
392
|
+
}
|
|
393
|
+
],
|
|
394
|
+
initialValue: base.auth
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
const seo = assertNotCancel(
|
|
398
|
+
await p.confirm({
|
|
399
|
+
message: "Add SEO helpers (sitemap/robots)?",
|
|
400
|
+
initialValue: base.seo
|
|
401
|
+
})
|
|
402
|
+
);
|
|
403
|
+
const i18n = assertNotCancel(
|
|
404
|
+
await p.confirm({
|
|
405
|
+
message: "Add i18n (next-intl)?",
|
|
406
|
+
initialValue: base.i18n
|
|
407
|
+
})
|
|
408
|
+
);
|
|
409
|
+
const testing = assertNotCancel(
|
|
410
|
+
await p.confirm({
|
|
411
|
+
message: "Add Vitest?",
|
|
412
|
+
initialValue: base.testing
|
|
413
|
+
})
|
|
414
|
+
);
|
|
415
|
+
let playwright = false;
|
|
416
|
+
if (testing) {
|
|
417
|
+
playwright = assertNotCancel(
|
|
418
|
+
await p.confirm({
|
|
419
|
+
message: "Also add Playwright e2e?",
|
|
420
|
+
initialValue: base.playwright
|
|
421
|
+
})
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
const eslint = assertNotCancel(
|
|
425
|
+
await p.confirm({
|
|
426
|
+
message: "Enhanced ESLint?",
|
|
427
|
+
initialValue: base.eslint
|
|
428
|
+
})
|
|
429
|
+
);
|
|
430
|
+
const prettier = assertNotCancel(
|
|
431
|
+
await p.confirm({
|
|
432
|
+
message: "Add Prettier?",
|
|
433
|
+
initialValue: base.prettier
|
|
434
|
+
})
|
|
435
|
+
);
|
|
436
|
+
const husky = assertNotCancel(
|
|
437
|
+
await p.confirm({
|
|
438
|
+
message: "Add Husky + lint-staged?",
|
|
439
|
+
initialValue: base.husky
|
|
440
|
+
})
|
|
441
|
+
);
|
|
442
|
+
const docker = assertNotCancel(
|
|
443
|
+
await p.confirm({
|
|
444
|
+
message: "Add Docker production image?",
|
|
445
|
+
initialValue: base.docker
|
|
446
|
+
})
|
|
447
|
+
);
|
|
448
|
+
const ci = assertNotCancel(
|
|
449
|
+
await p.confirm({
|
|
450
|
+
message: "Add GitHub Actions CI?",
|
|
451
|
+
initialValue: base.ci
|
|
452
|
+
})
|
|
453
|
+
);
|
|
454
|
+
return {
|
|
455
|
+
...base,
|
|
456
|
+
styling,
|
|
457
|
+
ui,
|
|
458
|
+
darkMode,
|
|
459
|
+
icons,
|
|
460
|
+
validation,
|
|
461
|
+
data: useTanstackQuery ? "tanstack-query" : "rsc",
|
|
462
|
+
store,
|
|
463
|
+
endpoints,
|
|
464
|
+
apiEnvelope,
|
|
465
|
+
auth,
|
|
466
|
+
seo,
|
|
467
|
+
i18n,
|
|
468
|
+
testing,
|
|
469
|
+
playwright,
|
|
470
|
+
eslint,
|
|
471
|
+
prettier,
|
|
472
|
+
husky,
|
|
473
|
+
docker,
|
|
474
|
+
ci
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
async function runPrompts(initialName) {
|
|
478
|
+
p.intro(color.bgCyan(color.black(` ${TITLE} `)));
|
|
479
|
+
const name = assertNotCancel(
|
|
480
|
+
await p.text({
|
|
481
|
+
message: "Project name?",
|
|
482
|
+
placeholder: "my-app",
|
|
483
|
+
initialValue: initialName ?? "my-app",
|
|
484
|
+
validate(value) {
|
|
485
|
+
if (!value.trim()) return "Name is required.";
|
|
486
|
+
try {
|
|
487
|
+
slugify(value);
|
|
488
|
+
} catch (error) {
|
|
489
|
+
return error instanceof Error ? error.message : "Invalid name.";
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
})
|
|
493
|
+
);
|
|
494
|
+
const slug = slugify(name);
|
|
495
|
+
const packageManager = assertNotCancel(
|
|
496
|
+
await p.select({
|
|
497
|
+
message: "Package manager?",
|
|
498
|
+
options: [
|
|
499
|
+
{ value: "pnpm", label: "pnpm", hint: "default" },
|
|
500
|
+
{ value: "npm", label: "npm" },
|
|
501
|
+
{ value: "yarn", label: "yarn", hint: "classic" }
|
|
502
|
+
],
|
|
503
|
+
initialValue: "pnpm"
|
|
504
|
+
})
|
|
505
|
+
);
|
|
506
|
+
const preset = assertNotCancel(
|
|
507
|
+
await p.select({
|
|
508
|
+
message: "Preset?",
|
|
509
|
+
options: [
|
|
510
|
+
{
|
|
511
|
+
value: "minimal",
|
|
512
|
+
label: "Minimal",
|
|
513
|
+
hint: describePreset("minimal")
|
|
514
|
+
},
|
|
515
|
+
{
|
|
516
|
+
value: "api-app",
|
|
517
|
+
label: "API app",
|
|
518
|
+
hint: describePreset("api-app")
|
|
519
|
+
},
|
|
520
|
+
{
|
|
521
|
+
value: "marketing",
|
|
522
|
+
label: "Marketing / site",
|
|
523
|
+
hint: describePreset("marketing")
|
|
524
|
+
},
|
|
525
|
+
{
|
|
526
|
+
value: "dashboard",
|
|
527
|
+
label: "Dashboard",
|
|
528
|
+
hint: describePreset("dashboard")
|
|
529
|
+
},
|
|
530
|
+
{
|
|
531
|
+
value: "custom",
|
|
532
|
+
label: "Custom",
|
|
533
|
+
hint: "answer every question"
|
|
534
|
+
}
|
|
535
|
+
],
|
|
536
|
+
initialValue: "custom"
|
|
537
|
+
})
|
|
538
|
+
);
|
|
539
|
+
let optionSeed = preset === "custom" ? { ...DEFAULT_OPTIONS, packageManager } : { ...PRESET_SEEDS[preset], packageManager };
|
|
540
|
+
if (preset === "custom") {
|
|
541
|
+
optionSeed = await promptCustomOptions(optionSeed);
|
|
542
|
+
} else {
|
|
543
|
+
p.note(describePreset(preset), `Preset: ${preset}`);
|
|
544
|
+
const tweak = assertNotCancel(
|
|
545
|
+
await p.confirm({
|
|
546
|
+
message: "Customize this preset further?",
|
|
547
|
+
initialValue: false
|
|
548
|
+
})
|
|
549
|
+
);
|
|
550
|
+
if (tweak) {
|
|
551
|
+
optionSeed = await promptCustomOptions(optionSeed);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
const draft = normalizeOptions({
|
|
555
|
+
name: name.trim(),
|
|
556
|
+
slug,
|
|
557
|
+
...optionSeed,
|
|
558
|
+
packageManager,
|
|
559
|
+
noInstall: false
|
|
560
|
+
});
|
|
561
|
+
const warnings = collectOptionWarnings(draft);
|
|
562
|
+
if (warnings.length) {
|
|
563
|
+
p.log.warn(warnings.join("\n"));
|
|
564
|
+
}
|
|
565
|
+
const confirmed = assertNotCancel(
|
|
566
|
+
await p.confirm({
|
|
567
|
+
message: `Create ${color.cyan(name)} (${color.dim(slug)})?`,
|
|
568
|
+
initialValue: true
|
|
569
|
+
})
|
|
570
|
+
);
|
|
571
|
+
if (!confirmed) {
|
|
572
|
+
p.cancel("Scaffold cancelled.");
|
|
573
|
+
process.exit(0);
|
|
574
|
+
}
|
|
575
|
+
return draft;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
// src/scaffold.ts
|
|
579
|
+
import fs27 from "fs-extra";
|
|
580
|
+
import path28 from "path";
|
|
581
|
+
import * as p2 from "@clack/prompts";
|
|
582
|
+
import { execSync } from "child_process";
|
|
583
|
+
import color2 from "picocolors";
|
|
584
|
+
|
|
585
|
+
// src/helpers/replace-tokens.ts
|
|
586
|
+
import fs from "fs-extra";
|
|
587
|
+
import path2 from "path";
|
|
588
|
+
var TEXT_EXT = /* @__PURE__ */ new Set([
|
|
589
|
+
".ts",
|
|
590
|
+
".tsx",
|
|
591
|
+
".js",
|
|
592
|
+
".jsx",
|
|
593
|
+
".mjs",
|
|
594
|
+
".cjs",
|
|
595
|
+
".json",
|
|
596
|
+
".md",
|
|
597
|
+
".mdc",
|
|
598
|
+
".css",
|
|
599
|
+
".scss",
|
|
600
|
+
".html",
|
|
601
|
+
".svg",
|
|
602
|
+
".yml",
|
|
603
|
+
".yaml",
|
|
604
|
+
".toml",
|
|
605
|
+
".env",
|
|
606
|
+
".example",
|
|
607
|
+
".gitignore",
|
|
608
|
+
".npmrc"
|
|
609
|
+
]);
|
|
610
|
+
function shouldRewrite(filePath) {
|
|
611
|
+
const base = path2.basename(filePath);
|
|
612
|
+
if (base === ".env.example" || base === "LICENSE") return true;
|
|
613
|
+
const ext = path2.extname(filePath);
|
|
614
|
+
return TEXT_EXT.has(ext) || base.startsWith(".");
|
|
615
|
+
}
|
|
616
|
+
function applyTokens(contents, tokens) {
|
|
617
|
+
let out = contents;
|
|
618
|
+
for (const [key, value] of Object.entries(tokens)) {
|
|
619
|
+
out = out.split(key).join(value);
|
|
620
|
+
}
|
|
621
|
+
return out;
|
|
622
|
+
}
|
|
623
|
+
async function replaceTokensInTree(root, tokens) {
|
|
624
|
+
const entries = await fs.readdir(root, { withFileTypes: true });
|
|
625
|
+
for (const entry of entries) {
|
|
626
|
+
const full = path2.join(root, entry.name);
|
|
627
|
+
if (entry.isDirectory()) {
|
|
628
|
+
if (entry.name === "node_modules" || entry.name === ".git") continue;
|
|
629
|
+
await replaceTokensInTree(full, tokens);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
if (!shouldRewrite(full)) continue;
|
|
633
|
+
const raw = await fs.readFile(full, "utf8");
|
|
634
|
+
const next = applyTokens(raw, tokens);
|
|
635
|
+
if (next !== raw) {
|
|
636
|
+
await fs.writeFile(full, next, "utf8");
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// src/installers/api-envelope.ts
|
|
642
|
+
import fs2 from "fs-extra";
|
|
643
|
+
import path3 from "path";
|
|
644
|
+
var HTTP_ENVELOPE = `import { env } from "@/lib/env";
|
|
645
|
+
|
|
646
|
+
type ApiEnvelope<T> = {
|
|
647
|
+
result?: T;
|
|
648
|
+
status?: number;
|
|
649
|
+
success?: boolean;
|
|
650
|
+
messages?: unknown;
|
|
651
|
+
};
|
|
652
|
+
|
|
653
|
+
function unwrapEnvelope<T>(data: unknown): T {
|
|
654
|
+
if (data && typeof data === "object" && "result" in data) {
|
|
655
|
+
const result = (data as ApiEnvelope<T>).result;
|
|
656
|
+
if (result !== undefined && result !== null) {
|
|
657
|
+
return result;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
return data as T;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
/**
|
|
664
|
+
* JSON fetch helper that unwraps \`{ result, status, success, messages }\` envelopes.
|
|
665
|
+
*/
|
|
666
|
+
export async function apiFetch<T>(
|
|
667
|
+
path: string,
|
|
668
|
+
init?: RequestInit,
|
|
669
|
+
): Promise<T> {
|
|
670
|
+
const url = path.startsWith("http")
|
|
671
|
+
? path
|
|
672
|
+
: \`\${env.NEXT_PUBLIC_API_URL.replace(/\\/$/, "")}/\${path.replace(/^\\//, "")}\`;
|
|
673
|
+
|
|
674
|
+
const response = await fetch(url, {
|
|
675
|
+
...init,
|
|
676
|
+
headers: {
|
|
677
|
+
Accept: "application/json",
|
|
678
|
+
...(init?.headers ?? {}),
|
|
679
|
+
},
|
|
680
|
+
});
|
|
681
|
+
|
|
682
|
+
if (!response.ok) {
|
|
683
|
+
throw new Error(\`API \${response.status}: \${response.statusText}\`);
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const json: unknown = await response.json();
|
|
687
|
+
return unwrapEnvelope<T>(json);
|
|
688
|
+
}
|
|
689
|
+
`;
|
|
690
|
+
var RULE = `---
|
|
691
|
+
description: API envelope unwrap for apiFetch
|
|
692
|
+
globs: src/lib/http.ts
|
|
693
|
+
alwaysApply: false
|
|
694
|
+
---
|
|
695
|
+
|
|
696
|
+
# API envelope
|
|
697
|
+
|
|
698
|
+
\`apiFetch\` unwraps \`{ result, status, success, messages }\` when present.
|
|
699
|
+
Prefer generated \`src/endpoints/*\` + axios when endpoints codegen is enabled.
|
|
700
|
+
`;
|
|
701
|
+
var apiEnvelopeInstaller = {
|
|
702
|
+
id: "api-envelope",
|
|
703
|
+
active: (o) => o.apiEnvelope,
|
|
704
|
+
async run({ projectDir, options }) {
|
|
705
|
+
await fs2.writeFile(
|
|
706
|
+
path3.join(projectDir, "src", "lib", "http.ts"),
|
|
707
|
+
HTTP_ENVELOPE,
|
|
708
|
+
"utf8"
|
|
709
|
+
);
|
|
710
|
+
await fs2.writeFile(
|
|
711
|
+
path3.join(projectDir, ".cursor", "rules", "api-envelope.mdc"),
|
|
712
|
+
RULE,
|
|
713
|
+
"utf8"
|
|
714
|
+
);
|
|
715
|
+
await fs2.outputFile(
|
|
716
|
+
path3.join(projectDir, "docs", "style-guide", "http", "envelope.md"),
|
|
717
|
+
`# API envelope
|
|
718
|
+
|
|
719
|
+
\`src/lib/http.ts\` \u2192 \`apiFetch\` unwraps \`result\` from standard envelopes.
|
|
720
|
+
${options.endpoints ? "Endpoints/axios: use `unwrapApiResult` from `@/utils/api-result`.\n" : ""}
|
|
721
|
+
`
|
|
722
|
+
);
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
// src/helpers/app-providers.ts
|
|
727
|
+
import fs3 from "fs-extra";
|
|
728
|
+
import path4 from "path";
|
|
729
|
+
function needsAppProviders(options) {
|
|
730
|
+
return options.data === "tanstack-query" || options.store === "redux" || options.styling === "mui" || options.i18n || options.darkMode;
|
|
731
|
+
}
|
|
732
|
+
function generateAppProvidersSource(options) {
|
|
733
|
+
const imports = [
|
|
734
|
+
`"use client";`,
|
|
735
|
+
``,
|
|
736
|
+
`import type { ReactNode } from "react";`
|
|
737
|
+
];
|
|
738
|
+
const props = [`children: ReactNode`];
|
|
739
|
+
if (options.i18n) {
|
|
740
|
+
imports.push(`import { NextIntlClientProvider } from "next-intl";`);
|
|
741
|
+
props.push(`messages: Record<string, unknown>`);
|
|
742
|
+
}
|
|
743
|
+
if (options.darkMode) {
|
|
744
|
+
imports.push(`import { ThemeProvider } from "@/providers/theme-provider";`);
|
|
745
|
+
}
|
|
746
|
+
if (options.styling === "mui") {
|
|
747
|
+
imports.push(`import { MuiProvider } from "@/providers/mui-provider";`);
|
|
748
|
+
}
|
|
749
|
+
if (options.store === "redux") {
|
|
750
|
+
imports.push(`import { StoreProvider } from "@/providers/store-provider";`);
|
|
751
|
+
}
|
|
752
|
+
if (options.data === "tanstack-query") {
|
|
753
|
+
imports.push(`import { QueryProvider } from "@/providers/query-provider";`);
|
|
754
|
+
}
|
|
755
|
+
const openers = [];
|
|
756
|
+
const closers = [];
|
|
757
|
+
if (options.i18n) {
|
|
758
|
+
openers.push(` <NextIntlClientProvider messages={messages}>`);
|
|
759
|
+
closers.unshift(` </NextIntlClientProvider>`);
|
|
760
|
+
}
|
|
761
|
+
if (options.darkMode) {
|
|
762
|
+
openers.push(` <ThemeProvider>`);
|
|
763
|
+
closers.unshift(` </ThemeProvider>`);
|
|
764
|
+
}
|
|
765
|
+
if (options.styling === "mui") {
|
|
766
|
+
openers.push(` <MuiProvider>`);
|
|
767
|
+
closers.unshift(` </MuiProvider>`);
|
|
768
|
+
}
|
|
769
|
+
if (options.store === "redux") {
|
|
770
|
+
openers.push(` <StoreProvider>`);
|
|
771
|
+
closers.unshift(` </StoreProvider>`);
|
|
772
|
+
}
|
|
773
|
+
if (options.data === "tanstack-query") {
|
|
774
|
+
openers.push(` <QueryProvider>`);
|
|
775
|
+
closers.unshift(` </QueryProvider>`);
|
|
776
|
+
}
|
|
777
|
+
return `${imports.join("\n")}
|
|
778
|
+
|
|
779
|
+
type AppProvidersProps = {
|
|
780
|
+
${props.join(";\n ")};
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Single composition root for client providers.
|
|
785
|
+
* Feature installers add leaf providers; this file wraps them in a stable order.
|
|
786
|
+
*/
|
|
787
|
+
export function AppProviders({ children${options.i18n ? ", messages" : ""} }: AppProvidersProps) {
|
|
788
|
+
return (
|
|
789
|
+
${openers.join("\n")}
|
|
790
|
+
{children}
|
|
791
|
+
${closers.join("\n")}
|
|
792
|
+
);
|
|
793
|
+
}
|
|
794
|
+
`;
|
|
795
|
+
}
|
|
796
|
+
async function writeAppProviders(projectDir, options) {
|
|
797
|
+
if (!needsAppProviders(options)) return;
|
|
798
|
+
const providersDir = path4.join(projectDir, "src", "providers");
|
|
799
|
+
await fs3.ensureDir(providersDir);
|
|
800
|
+
await fs3.remove(path4.join(providersDir, ".gitkeep"));
|
|
801
|
+
await fs3.writeFile(
|
|
802
|
+
path4.join(providersDir, "app-providers.tsx"),
|
|
803
|
+
generateAppProvidersSource(options),
|
|
804
|
+
"utf8"
|
|
805
|
+
);
|
|
806
|
+
}
|
|
807
|
+
async function wireRootLayout(projectDir, options) {
|
|
808
|
+
const layoutPath = path4.join(projectDir, "src", "app", "layout.tsx");
|
|
809
|
+
const useProviders = needsAppProviders(options);
|
|
810
|
+
const dark = options.darkMode;
|
|
811
|
+
const i18n = options.i18n;
|
|
812
|
+
const htmlLang = i18n ? "{locale}" : `"en"`;
|
|
813
|
+
const htmlExtra = dark ? " suppressHydrationWarning" : "";
|
|
814
|
+
const imports = [
|
|
815
|
+
`import type { Metadata } from "next";`,
|
|
816
|
+
``,
|
|
817
|
+
`import "./globals.css";`
|
|
818
|
+
];
|
|
819
|
+
if (useProviders) {
|
|
820
|
+
imports.push(
|
|
821
|
+
``,
|
|
822
|
+
`import { AppProviders } from "@/providers/app-providers";`
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
if (i18n) {
|
|
826
|
+
imports.push(
|
|
827
|
+
`import { getLocale, getMessages } from "next-intl/server";`
|
|
828
|
+
);
|
|
829
|
+
}
|
|
830
|
+
const bodyInner = useProviders ? i18n ? ` <AppProviders messages={messages}>
|
|
831
|
+
<main>{children}</main>
|
|
832
|
+
</AppProviders>` : ` <AppProviders>
|
|
833
|
+
<main>{children}</main>
|
|
834
|
+
</AppProviders>` : ` <main>{children}</main>`;
|
|
835
|
+
const fnBody = i18n ? ` const locale = await getLocale();
|
|
836
|
+
const messages = await getMessages();
|
|
837
|
+
|
|
838
|
+
return (
|
|
839
|
+
<html lang=${htmlLang}${htmlExtra}>
|
|
840
|
+
<body>
|
|
841
|
+
${bodyInner}
|
|
842
|
+
</body>
|
|
843
|
+
</html>
|
|
844
|
+
);` : ` return (
|
|
845
|
+
<html lang=${htmlLang}${htmlExtra}>
|
|
846
|
+
<body>
|
|
847
|
+
${bodyInner}
|
|
848
|
+
</body>
|
|
849
|
+
</html>
|
|
850
|
+
);`;
|
|
851
|
+
const source = `${imports.join("\n")}
|
|
852
|
+
|
|
853
|
+
export const metadata: Metadata = {
|
|
854
|
+
title: "__PROJECT_NAME__",
|
|
855
|
+
description: "Generated with next-style-guide",
|
|
856
|
+
};
|
|
857
|
+
|
|
858
|
+
export default ${i18n ? "async " : ""}function RootLayout({
|
|
859
|
+
children,
|
|
860
|
+
}: Readonly<{
|
|
861
|
+
children: React.ReactNode;
|
|
862
|
+
}>) {
|
|
863
|
+
${fnBody}
|
|
864
|
+
}
|
|
865
|
+
`;
|
|
866
|
+
let final = source;
|
|
867
|
+
if (await fs3.pathExists(layoutPath)) {
|
|
868
|
+
const existing = await fs3.readFile(layoutPath, "utf8");
|
|
869
|
+
const titleMatch = existing.match(/title:\s*"([^"]+)"/);
|
|
870
|
+
if (titleMatch?.[1] && titleMatch[1] !== "__PROJECT_NAME__") {
|
|
871
|
+
final = final.replace(
|
|
872
|
+
'title: "__PROJECT_NAME__"',
|
|
873
|
+
`title: "${titleMatch[1]}"`
|
|
874
|
+
);
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
await fs3.writeFile(layoutPath, final, "utf8");
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
// src/installers/app-providers.ts
|
|
881
|
+
var appProvidersInstaller = {
|
|
882
|
+
id: "app-providers",
|
|
883
|
+
active: (o) => needsAppProviders(o) || o.i18n || o.darkMode,
|
|
884
|
+
async run({ projectDir, options }) {
|
|
885
|
+
await writeAppProviders(projectDir, options);
|
|
886
|
+
await wireRootLayout(projectDir, options);
|
|
887
|
+
}
|
|
888
|
+
};
|
|
889
|
+
|
|
890
|
+
// src/installers/auth-authjs.ts
|
|
891
|
+
import fs5 from "fs-extra";
|
|
892
|
+
import path6 from "path";
|
|
893
|
+
|
|
894
|
+
// src/helpers/package-json.ts
|
|
895
|
+
import fs4 from "fs-extra";
|
|
896
|
+
import path5 from "path";
|
|
897
|
+
async function readPkg(projectDir) {
|
|
898
|
+
const pkgPath = path5.join(projectDir, "package.json");
|
|
899
|
+
const pkg = await fs4.readJson(pkgPath);
|
|
900
|
+
return { pkgPath, pkg };
|
|
901
|
+
}
|
|
902
|
+
function sortRecord(record) {
|
|
903
|
+
return Object.fromEntries(
|
|
904
|
+
Object.entries(record).sort(([a], [b]) => a.localeCompare(b))
|
|
905
|
+
);
|
|
906
|
+
}
|
|
907
|
+
async function addDependencies(projectDir, deps, kind = "dependencies") {
|
|
908
|
+
const { pkgPath, pkg } = await readPkg(projectDir);
|
|
909
|
+
pkg[kind] = sortRecord({
|
|
910
|
+
...pkg[kind] ?? {},
|
|
911
|
+
...deps
|
|
912
|
+
});
|
|
913
|
+
await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
914
|
+
}
|
|
915
|
+
async function addScripts(projectDir, scripts) {
|
|
916
|
+
const { pkgPath, pkg } = await readPkg(projectDir);
|
|
917
|
+
pkg.scripts = {
|
|
918
|
+
...pkg.scripts ?? {},
|
|
919
|
+
...scripts
|
|
920
|
+
};
|
|
921
|
+
await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
922
|
+
}
|
|
923
|
+
async function mergePackageField(projectDir, field, value) {
|
|
924
|
+
const { pkgPath, pkg } = await readPkg(projectDir);
|
|
925
|
+
pkg[field] = value;
|
|
926
|
+
await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
927
|
+
}
|
|
928
|
+
async function removeDependencies(projectDir, names, kind = "dependencies") {
|
|
929
|
+
const { pkgPath, pkg } = await readPkg(projectDir);
|
|
930
|
+
if (!pkg[kind]) return;
|
|
931
|
+
for (const name of names) {
|
|
932
|
+
delete pkg[kind][name];
|
|
933
|
+
}
|
|
934
|
+
pkg[kind] = sortRecord(pkg[kind]);
|
|
935
|
+
await fs4.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// src/installers/auth-authjs.ts
|
|
939
|
+
var AUTH_TS = `import NextAuth from "next-auth";
|
|
940
|
+
import Credentials from "next-auth/providers/credentials";
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Auth.js \u2014 Credentials demo provider.
|
|
944
|
+
* Replace \`authorize\` with your API/DB lookup. Set \`AUTH_SECRET\` in env.
|
|
945
|
+
*/
|
|
946
|
+
export const { handlers, auth, signIn, signOut } = NextAuth({
|
|
947
|
+
providers: [
|
|
948
|
+
Credentials({
|
|
949
|
+
name: "Credentials",
|
|
950
|
+
credentials: {
|
|
951
|
+
email: { label: "Email", type: "email" },
|
|
952
|
+
password: { label: "Password", type: "password" },
|
|
953
|
+
},
|
|
954
|
+
async authorize(credentials) {
|
|
955
|
+
if (!credentials?.email || !credentials?.password) {
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
// Demo only \u2014 wire to your backend.
|
|
959
|
+
return {
|
|
960
|
+
id: "1",
|
|
961
|
+
email: String(credentials.email),
|
|
962
|
+
name: "Demo User",
|
|
963
|
+
};
|
|
964
|
+
},
|
|
965
|
+
}),
|
|
966
|
+
],
|
|
967
|
+
pages: {
|
|
968
|
+
signIn: "/login",
|
|
969
|
+
},
|
|
970
|
+
session: { strategy: "jwt" },
|
|
971
|
+
trustHost: true,
|
|
972
|
+
});
|
|
973
|
+
`;
|
|
974
|
+
var ROUTE = `import { handlers } from "@/lib/auth";
|
|
975
|
+
|
|
976
|
+
export const { GET, POST } = handlers;
|
|
977
|
+
`;
|
|
978
|
+
var MIDDLEWARE = `import { auth } from "@/lib/auth";
|
|
979
|
+
import { NextResponse } from "next/server";
|
|
980
|
+
|
|
981
|
+
const publicPaths = ["/login", "/api/auth"];
|
|
982
|
+
|
|
983
|
+
export default auth((req) => {
|
|
984
|
+
const { pathname } = req.nextUrl;
|
|
985
|
+
const isPublic = publicPaths.some(
|
|
986
|
+
(p) => pathname === p || pathname.startsWith(\`\${p}/\`),
|
|
987
|
+
);
|
|
988
|
+
|
|
989
|
+
if (!req.auth && !isPublic) {
|
|
990
|
+
const url = new URL("/login", req.nextUrl.origin);
|
|
991
|
+
url.searchParams.set("callbackUrl", pathname);
|
|
992
|
+
return NextResponse.redirect(url);
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
if (req.auth && pathname === "/login") {
|
|
996
|
+
return NextResponse.redirect(new URL("/", req.nextUrl.origin));
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
return NextResponse.next();
|
|
1000
|
+
});
|
|
1001
|
+
|
|
1002
|
+
export const config = {
|
|
1003
|
+
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
|
1004
|
+
};
|
|
1005
|
+
`;
|
|
1006
|
+
var LOGIN_PAGE = `import { Suspense } from "react";
|
|
1007
|
+
|
|
1008
|
+
import { LoginForm } from "@/app/login/_components/login-form";
|
|
1009
|
+
|
|
1010
|
+
export default function LoginPage() {
|
|
1011
|
+
return (
|
|
1012
|
+
<section className="hero">
|
|
1013
|
+
<h1>Sign in</h1>
|
|
1014
|
+
<p>Auth.js Credentials demo \u2014 replace authorize with your API.</p>
|
|
1015
|
+
<Suspense fallback={<p>Loading\u2026</p>}>
|
|
1016
|
+
<LoginForm />
|
|
1017
|
+
</Suspense>
|
|
1018
|
+
</section>
|
|
1019
|
+
);
|
|
1020
|
+
}
|
|
1021
|
+
`;
|
|
1022
|
+
var LOGIN_FORM = `"use client";
|
|
1023
|
+
|
|
1024
|
+
import { useState, type FormEvent } from "react";
|
|
1025
|
+
import { signIn } from "next-auth/react";
|
|
1026
|
+
import { useRouter, useSearchParams } from "next/navigation";
|
|
1027
|
+
|
|
1028
|
+
export function LoginForm() {
|
|
1029
|
+
const router = useRouter();
|
|
1030
|
+
const searchParams = useSearchParams();
|
|
1031
|
+
const callbackUrl = searchParams.get("callbackUrl") ?? "/";
|
|
1032
|
+
const [error, setError] = useState<string | null>(null);
|
|
1033
|
+
const [pending, setPending] = useState(false);
|
|
1034
|
+
|
|
1035
|
+
async function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
1036
|
+
event.preventDefault();
|
|
1037
|
+
setPending(true);
|
|
1038
|
+
setError(null);
|
|
1039
|
+
const form = new FormData(event.currentTarget);
|
|
1040
|
+
const result = await signIn("credentials", {
|
|
1041
|
+
email: String(form.get("email") ?? ""),
|
|
1042
|
+
password: String(form.get("password") ?? ""),
|
|
1043
|
+
redirect: false,
|
|
1044
|
+
});
|
|
1045
|
+
setPending(false);
|
|
1046
|
+
if (result?.error) {
|
|
1047
|
+
setError("Invalid credentials");
|
|
1048
|
+
return;
|
|
1049
|
+
}
|
|
1050
|
+
router.push(callbackUrl);
|
|
1051
|
+
router.refresh();
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
return (
|
|
1055
|
+
<form onSubmit={onSubmit} className="card" style={{ marginTop: "1.5rem" }}>
|
|
1056
|
+
<label style={{ display: "grid", gap: "0.35rem", marginBottom: "0.75rem" }}>
|
|
1057
|
+
Email
|
|
1058
|
+
<input name="email" type="email" required autoComplete="email" />
|
|
1059
|
+
</label>
|
|
1060
|
+
<label style={{ display: "grid", gap: "0.35rem", marginBottom: "0.75rem" }}>
|
|
1061
|
+
Password
|
|
1062
|
+
<input
|
|
1063
|
+
name="password"
|
|
1064
|
+
type="password"
|
|
1065
|
+
required
|
|
1066
|
+
autoComplete="current-password"
|
|
1067
|
+
/>
|
|
1068
|
+
</label>
|
|
1069
|
+
{error ? <p style={{ color: "crimson" }}>{error}</p> : null}
|
|
1070
|
+
<button type="submit" disabled={pending}>
|
|
1071
|
+
{pending ? "Signing in\u2026" : "Sign in"}
|
|
1072
|
+
</button>
|
|
1073
|
+
</form>
|
|
1074
|
+
);
|
|
1075
|
+
}
|
|
1076
|
+
`;
|
|
1077
|
+
var RULE2 = `---
|
|
1078
|
+
description: Auth.js (NextAuth) conventions
|
|
1079
|
+
globs: src/lib/auth.ts,src/app/api/auth/**/*,src/middleware.ts,src/app/login/**
|
|
1080
|
+
alwaysApply: false
|
|
1081
|
+
---
|
|
1082
|
+
|
|
1083
|
+
# Auth.js
|
|
1084
|
+
|
|
1085
|
+
- Config: \`src/lib/auth.ts\` (Credentials demo \u2014 replace \`authorize\`).
|
|
1086
|
+
- Route handlers: \`src/app/api/auth/[...nextauth]/route.ts\`.
|
|
1087
|
+
- Middleware: \`src/middleware.ts\` protects non-public routes; public: \`/login\`, \`/api/auth\`.
|
|
1088
|
+
- Login UI: \`src/app/login/\`.
|
|
1089
|
+
- Env: \`AUTH_SECRET\` (required in production).
|
|
1090
|
+
- Prefer \`auth()\` in Server Components; \`signIn\` / \`signOut\` on the client.
|
|
1091
|
+
`;
|
|
1092
|
+
var authAuthjsInstaller = {
|
|
1093
|
+
id: "auth-authjs",
|
|
1094
|
+
active: (o) => o.auth === "authjs",
|
|
1095
|
+
async run({ projectDir }) {
|
|
1096
|
+
await fs5.writeFile(
|
|
1097
|
+
path6.join(projectDir, "src", "lib", "auth.ts"),
|
|
1098
|
+
AUTH_TS,
|
|
1099
|
+
"utf8"
|
|
1100
|
+
);
|
|
1101
|
+
await fs5.outputFile(
|
|
1102
|
+
path6.join(
|
|
1103
|
+
projectDir,
|
|
1104
|
+
"src",
|
|
1105
|
+
"app",
|
|
1106
|
+
"api",
|
|
1107
|
+
"auth",
|
|
1108
|
+
"[...nextauth]",
|
|
1109
|
+
"route.ts"
|
|
1110
|
+
),
|
|
1111
|
+
ROUTE
|
|
1112
|
+
);
|
|
1113
|
+
await fs5.writeFile(
|
|
1114
|
+
path6.join(projectDir, "src", "middleware.ts"),
|
|
1115
|
+
MIDDLEWARE,
|
|
1116
|
+
"utf8"
|
|
1117
|
+
);
|
|
1118
|
+
await fs5.outputFile(
|
|
1119
|
+
path6.join(projectDir, "src", "app", "login", "page.tsx"),
|
|
1120
|
+
LOGIN_PAGE
|
|
1121
|
+
);
|
|
1122
|
+
await fs5.outputFile(
|
|
1123
|
+
path6.join(
|
|
1124
|
+
projectDir,
|
|
1125
|
+
"src",
|
|
1126
|
+
"app",
|
|
1127
|
+
"login",
|
|
1128
|
+
"_components",
|
|
1129
|
+
"login-form",
|
|
1130
|
+
"index.tsx"
|
|
1131
|
+
),
|
|
1132
|
+
LOGIN_FORM
|
|
1133
|
+
);
|
|
1134
|
+
await fs5.writeFile(
|
|
1135
|
+
path6.join(projectDir, ".cursor", "rules", "auth.mdc"),
|
|
1136
|
+
RULE2,
|
|
1137
|
+
"utf8"
|
|
1138
|
+
);
|
|
1139
|
+
await fs5.outputFile(
|
|
1140
|
+
path6.join(projectDir, "docs", "style-guide", "auth", "authjs.md"),
|
|
1141
|
+
`# Auth.js
|
|
1142
|
+
|
|
1143
|
+
- \`src/lib/auth.ts\` \u2014 Credentials demo (replace \`authorize\`)
|
|
1144
|
+
- \`src/app/api/auth/[...nextauth]/route.ts\`
|
|
1145
|
+
- \`src/middleware.ts\` \u2014 protect routes; public \`/login\` + \`/api/auth\`
|
|
1146
|
+
- \`src/app/login/\` \u2014 sample login page
|
|
1147
|
+
- Env: \`AUTH_SECRET\`
|
|
1148
|
+
`
|
|
1149
|
+
);
|
|
1150
|
+
const envPath = path6.join(projectDir, ".env.example");
|
|
1151
|
+
if (await fs5.pathExists(envPath)) {
|
|
1152
|
+
let env = await fs5.readFile(envPath, "utf8");
|
|
1153
|
+
if (!env.includes("AUTH_SECRET")) {
|
|
1154
|
+
env += `
|
|
1155
|
+
# Auth.js \u2014 generate with: openssl rand -base64 32
|
|
1156
|
+
AUTH_SECRET=
|
|
1157
|
+
`;
|
|
1158
|
+
await fs5.writeFile(envPath, env, "utf8");
|
|
1159
|
+
}
|
|
1160
|
+
}
|
|
1161
|
+
await addDependencies(projectDir, {
|
|
1162
|
+
"next-auth": "^5.0.0-beta.25"
|
|
1163
|
+
});
|
|
1164
|
+
}
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
// src/installers/auth-jwt.ts
|
|
1168
|
+
import fs6 from "fs-extra";
|
|
1169
|
+
import path7 from "path";
|
|
1170
|
+
function tokenStorage(slug) {
|
|
1171
|
+
const accessKey = `${slug}.accessToken`;
|
|
1172
|
+
const refreshKey = `${slug}.refreshToken`;
|
|
1173
|
+
return `const ACCESS_TOKEN_KEY = "${accessKey}";
|
|
1174
|
+
const REFRESH_TOKEN_KEY = "${refreshKey}";
|
|
1175
|
+
|
|
1176
|
+
export type AuthTokens = {
|
|
1177
|
+
access: string;
|
|
1178
|
+
refresh: string;
|
|
1179
|
+
};
|
|
1180
|
+
|
|
1181
|
+
export function getAccessToken(): string | null {
|
|
1182
|
+
if (typeof window === "undefined") {
|
|
1183
|
+
return null;
|
|
1184
|
+
}
|
|
1185
|
+
return window.localStorage.getItem(ACCESS_TOKEN_KEY);
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
export function getRefreshToken(): string | null {
|
|
1189
|
+
if (typeof window === "undefined") {
|
|
1190
|
+
return null;
|
|
1191
|
+
}
|
|
1192
|
+
return window.localStorage.getItem(REFRESH_TOKEN_KEY);
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
export function setTokens({ access, refresh }: AuthTokens) {
|
|
1196
|
+
window.localStorage.setItem(ACCESS_TOKEN_KEY, access);
|
|
1197
|
+
window.localStorage.setItem(REFRESH_TOKEN_KEY, refresh);
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
export function clearTokens() {
|
|
1201
|
+
if (typeof window === "undefined") {
|
|
1202
|
+
return;
|
|
1203
|
+
}
|
|
1204
|
+
window.localStorage.removeItem(ACCESS_TOKEN_KEY);
|
|
1205
|
+
window.localStorage.removeItem(REFRESH_TOKEN_KEY);
|
|
1206
|
+
}
|
|
1207
|
+
`;
|
|
1208
|
+
}
|
|
1209
|
+
var AXIOS_JWT = `import axios, { type AxiosError, type InternalAxiosRequestConfig } from "axios";
|
|
1210
|
+
|
|
1211
|
+
import { unwrapApiResult } from "@/utils/api-result";
|
|
1212
|
+
import {
|
|
1213
|
+
clearTokens,
|
|
1214
|
+
getAccessToken,
|
|
1215
|
+
getRefreshToken,
|
|
1216
|
+
setTokens,
|
|
1217
|
+
} from "@/utils/token-storage";
|
|
1218
|
+
|
|
1219
|
+
type RetryConfig = InternalAxiosRequestConfig & { _retry?: boolean };
|
|
1220
|
+
|
|
1221
|
+
const baseURL = process.env.NEXT_PUBLIC_API_URL ?? "";
|
|
1222
|
+
|
|
1223
|
+
/** Shared Axios client for \`src/endpoints/*\`. Do not call from UI directly. */
|
|
1224
|
+
export const api = axios.create({
|
|
1225
|
+
baseURL,
|
|
1226
|
+
headers: {
|
|
1227
|
+
Accept: "application/json",
|
|
1228
|
+
"Content-Type": "application/json",
|
|
1229
|
+
},
|
|
1230
|
+
});
|
|
1231
|
+
|
|
1232
|
+
api.interceptors.request.use((config) => {
|
|
1233
|
+
const accessToken = getAccessToken();
|
|
1234
|
+
if (accessToken) {
|
|
1235
|
+
config.headers.setAuthorization(\`Bearer \${accessToken}\`);
|
|
1236
|
+
}
|
|
1237
|
+
if (typeof FormData !== "undefined" && config.data instanceof FormData) {
|
|
1238
|
+
config.headers.delete("Content-Type");
|
|
1239
|
+
}
|
|
1240
|
+
return config;
|
|
1241
|
+
});
|
|
1242
|
+
|
|
1243
|
+
let refreshPromise: Promise<string | null> | null = null;
|
|
1244
|
+
|
|
1245
|
+
async function refreshAccessToken(): Promise<string | null> {
|
|
1246
|
+
const refresh = getRefreshToken();
|
|
1247
|
+
if (!refresh || !baseURL) {
|
|
1248
|
+
return null;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
try {
|
|
1252
|
+
const { data } = await axios.post(\`\${baseURL}/auth/refresh/\`, { refresh });
|
|
1253
|
+
const payload = unwrapApiResult<{ access?: string; refresh?: string }>(data);
|
|
1254
|
+
const access = payload.access;
|
|
1255
|
+
if (!access) {
|
|
1256
|
+
clearTokens();
|
|
1257
|
+
return null;
|
|
1258
|
+
}
|
|
1259
|
+
setTokens({ access, refresh: payload.refresh ?? refresh });
|
|
1260
|
+
return access;
|
|
1261
|
+
} catch {
|
|
1262
|
+
clearTokens();
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function shouldSkipRefresh(url: string | undefined) {
|
|
1268
|
+
if (!url) return false;
|
|
1269
|
+
return (
|
|
1270
|
+
url.includes("/auth/login") ||
|
|
1271
|
+
url.includes("/auth/refresh") ||
|
|
1272
|
+
url.includes("/auth/logout")
|
|
1273
|
+
);
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
api.interceptors.response.use(
|
|
1277
|
+
(response) => response,
|
|
1278
|
+
async (error: AxiosError) => {
|
|
1279
|
+
const original = error.config as RetryConfig | undefined;
|
|
1280
|
+
|
|
1281
|
+
if (
|
|
1282
|
+
!original ||
|
|
1283
|
+
error.response?.status !== 401 ||
|
|
1284
|
+
original._retry ||
|
|
1285
|
+
shouldSkipRefresh(original.url)
|
|
1286
|
+
) {
|
|
1287
|
+
return Promise.reject(error);
|
|
1288
|
+
}
|
|
1289
|
+
|
|
1290
|
+
original._retry = true;
|
|
1291
|
+
refreshPromise ??= refreshAccessToken().finally(() => {
|
|
1292
|
+
refreshPromise = null;
|
|
1293
|
+
});
|
|
1294
|
+
|
|
1295
|
+
const access = await refreshPromise;
|
|
1296
|
+
if (!access) {
|
|
1297
|
+
return Promise.reject(error);
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
original.headers.setAuthorization(\`Bearer \${access}\`);
|
|
1301
|
+
return api(original);
|
|
1302
|
+
},
|
|
1303
|
+
);
|
|
1304
|
+
`;
|
|
1305
|
+
var RULE3 = `---
|
|
1306
|
+
description: JWT auth (Bearer + refresh) helpers
|
|
1307
|
+
globs: src/utils/token-storage.ts,src/utils/axios-instance.ts,src/**/*auth*.{ts,tsx}
|
|
1308
|
+
alwaysApply: false
|
|
1309
|
+
---
|
|
1310
|
+
|
|
1311
|
+
# Auth (JWT)
|
|
1312
|
+
|
|
1313
|
+
- Tokens: \`src/utils/token-storage.ts\` (access + refresh in \`localStorage\`).
|
|
1314
|
+
- Axios: Bearer on requests; 401 \u2192 \`/auth/refresh/\` once, then retry.
|
|
1315
|
+
- Do not store tokens in cookies unless you intentionally change the strategy.
|
|
1316
|
+
- Login/logout UI should call \`setTokens\` / \`clearTokens\` \u2014 never invent a second storage key.
|
|
1317
|
+
`;
|
|
1318
|
+
var authJwtInstaller = {
|
|
1319
|
+
id: "auth-jwt",
|
|
1320
|
+
active: (o) => o.auth === "jwt",
|
|
1321
|
+
async run({ projectDir, options }) {
|
|
1322
|
+
await fs6.ensureDir(path7.join(projectDir, "src", "utils"));
|
|
1323
|
+
await fs6.remove(path7.join(projectDir, "src", "utils", ".gitkeep"));
|
|
1324
|
+
await fs6.writeFile(
|
|
1325
|
+
path7.join(projectDir, "src", "utils", "token-storage.ts"),
|
|
1326
|
+
tokenStorage(options.slug),
|
|
1327
|
+
"utf8"
|
|
1328
|
+
);
|
|
1329
|
+
if (options.endpoints) {
|
|
1330
|
+
await fs6.writeFile(
|
|
1331
|
+
path7.join(projectDir, "src", "utils", "axios-instance.ts"),
|
|
1332
|
+
AXIOS_JWT,
|
|
1333
|
+
"utf8"
|
|
1334
|
+
);
|
|
1335
|
+
}
|
|
1336
|
+
await fs6.writeFile(
|
|
1337
|
+
path7.join(projectDir, ".cursor", "rules", "auth.mdc"),
|
|
1338
|
+
RULE3,
|
|
1339
|
+
"utf8"
|
|
1340
|
+
);
|
|
1341
|
+
await fs6.outputFile(
|
|
1342
|
+
path7.join(projectDir, "docs", "style-guide", "auth", "jwt.md"),
|
|
1343
|
+
`# JWT auth
|
|
1344
|
+
|
|
1345
|
+
- \`src/utils/token-storage.ts\` \u2014 access/refresh in localStorage
|
|
1346
|
+
- When endpoints are enabled, \`axios-instance\` attaches Bearer and refreshes on 401 via \`POST /auth/refresh/\`
|
|
1347
|
+
`
|
|
1348
|
+
);
|
|
1349
|
+
}
|
|
1350
|
+
};
|
|
1351
|
+
|
|
1352
|
+
// src/installers/ci.ts
|
|
1353
|
+
import fs7 from "fs-extra";
|
|
1354
|
+
import path8 from "path";
|
|
1355
|
+
function workflowYaml(options) {
|
|
1356
|
+
const pm = getPackageManager(options.packageManager);
|
|
1357
|
+
const run = (script) => pm.runScript(script);
|
|
1358
|
+
const setupSteps = [
|
|
1359
|
+
` - name: Checkout
|
|
1360
|
+
uses: actions/checkout@v4
|
|
1361
|
+
`
|
|
1362
|
+
];
|
|
1363
|
+
if (options.packageManager === "pnpm") {
|
|
1364
|
+
setupSteps.push(` - name: Setup pnpm
|
|
1365
|
+
uses: pnpm/action-setup@v4
|
|
1366
|
+
`);
|
|
1367
|
+
}
|
|
1368
|
+
if (options.packageManager === "yarn") {
|
|
1369
|
+
setupSteps.push(` - name: Enable Corepack
|
|
1370
|
+
run: corepack enable
|
|
1371
|
+
`);
|
|
1372
|
+
}
|
|
1373
|
+
setupSteps.push(` - name: Setup Node
|
|
1374
|
+
uses: actions/setup-node@v4
|
|
1375
|
+
with:
|
|
1376
|
+
node-version: 22
|
|
1377
|
+
cache: ${options.packageManager}
|
|
1378
|
+
`);
|
|
1379
|
+
const steps = [
|
|
1380
|
+
...setupSteps,
|
|
1381
|
+
` - name: Install
|
|
1382
|
+
run: ${ciInstallCommand(options.packageManager)}
|
|
1383
|
+
|
|
1384
|
+
- name: Typecheck
|
|
1385
|
+
run: ${run("typecheck")}`
|
|
1386
|
+
];
|
|
1387
|
+
if (options.eslint) {
|
|
1388
|
+
steps.push(`
|
|
1389
|
+
- name: Lint
|
|
1390
|
+
run: ${run("lint")}`);
|
|
1391
|
+
}
|
|
1392
|
+
if (options.prettier) {
|
|
1393
|
+
steps.push(`
|
|
1394
|
+
- name: Format check
|
|
1395
|
+
run: ${run("format:check")}`);
|
|
1396
|
+
}
|
|
1397
|
+
if (options.testing) {
|
|
1398
|
+
steps.push(`
|
|
1399
|
+
- name: Test
|
|
1400
|
+
run: ${run("test")}`);
|
|
1401
|
+
}
|
|
1402
|
+
steps.push(`
|
|
1403
|
+
- name: Build
|
|
1404
|
+
run: ${run("build")}
|
|
1405
|
+
env:
|
|
1406
|
+
NEXT_PUBLIC_APP_URL: http://localhost:3000
|
|
1407
|
+
NEXT_PUBLIC_API_URL: http://localhost:8000`);
|
|
1408
|
+
return `name: CI
|
|
1409
|
+
|
|
1410
|
+
on:
|
|
1411
|
+
push:
|
|
1412
|
+
branches: [main, master]
|
|
1413
|
+
pull_request:
|
|
1414
|
+
|
|
1415
|
+
concurrency:
|
|
1416
|
+
group: ci-\${{ github.workflow }}-\${{ github.ref }}
|
|
1417
|
+
cancel-in-progress: true
|
|
1418
|
+
|
|
1419
|
+
jobs:
|
|
1420
|
+
checks:
|
|
1421
|
+
name: Checks
|
|
1422
|
+
runs-on: ubuntu-latest
|
|
1423
|
+
timeout-minutes: 20
|
|
1424
|
+
steps:
|
|
1425
|
+
${steps.join("\n")}
|
|
1426
|
+
`;
|
|
1427
|
+
}
|
|
1428
|
+
var ciInstaller = {
|
|
1429
|
+
id: "ci",
|
|
1430
|
+
active: (o) => o.ci,
|
|
1431
|
+
async run({ projectDir, options }) {
|
|
1432
|
+
const addon = path8.join(ADDONS_DIR, "ci");
|
|
1433
|
+
await fs7.outputFile(
|
|
1434
|
+
path8.join(projectDir, ".github", "workflows", "ci.yml"),
|
|
1435
|
+
workflowYaml(options)
|
|
1436
|
+
);
|
|
1437
|
+
await fs7.copy(
|
|
1438
|
+
path8.join(addon, ".cursor", "rules", "ci.mdc"),
|
|
1439
|
+
path8.join(projectDir, ".cursor", "rules", "ci.mdc")
|
|
1440
|
+
);
|
|
1441
|
+
await fs7.copy(
|
|
1442
|
+
path8.join(addon, "docs", "style-guide", "ops", "ci.md"),
|
|
1443
|
+
path8.join(projectDir, "docs", "style-guide", "ops", "ci.md")
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
};
|
|
1447
|
+
|
|
1448
|
+
// src/installers/dark-mode.ts
|
|
1449
|
+
import fs8 from "fs-extra";
|
|
1450
|
+
import path9 from "path";
|
|
1451
|
+
var THEME_PROVIDER = `"use client";
|
|
1452
|
+
|
|
1453
|
+
import { ThemeProvider as NextThemesProvider } from "next-themes";
|
|
1454
|
+
import type { ReactNode } from "react";
|
|
1455
|
+
|
|
1456
|
+
export function ThemeProvider({ children }: { children: ReactNode }) {
|
|
1457
|
+
return (
|
|
1458
|
+
<NextThemesProvider attribute="class" defaultTheme="system" enableSystem>
|
|
1459
|
+
{children}
|
|
1460
|
+
</NextThemesProvider>
|
|
1461
|
+
);
|
|
1462
|
+
}
|
|
1463
|
+
`;
|
|
1464
|
+
var RULE4 = `---
|
|
1465
|
+
description: Dark mode (next-themes) with Tailwind
|
|
1466
|
+
globs: src/providers/theme-provider.tsx,src/app/globals.css
|
|
1467
|
+
alwaysApply: false
|
|
1468
|
+
---
|
|
1469
|
+
|
|
1470
|
+
# Dark mode
|
|
1471
|
+
|
|
1472
|
+
- \`ThemeProvider\` in \`src/providers/theme-provider.tsx\` (composed via \`AppProviders\`).
|
|
1473
|
+
- Tailwind: use \`dark:\` variants; \`html\` gets \`class="dark"\` from next-themes.
|
|
1474
|
+
- Prefer \`defaultTheme="system"\`.
|
|
1475
|
+
`;
|
|
1476
|
+
var darkModeInstaller = {
|
|
1477
|
+
id: "dark-mode",
|
|
1478
|
+
active: (o) => o.darkMode && o.styling === "tailwind",
|
|
1479
|
+
async run({ projectDir }) {
|
|
1480
|
+
const providersDir = path9.join(projectDir, "src", "providers");
|
|
1481
|
+
await fs8.ensureDir(providersDir);
|
|
1482
|
+
await fs8.remove(path9.join(providersDir, ".gitkeep"));
|
|
1483
|
+
await fs8.writeFile(
|
|
1484
|
+
path9.join(providersDir, "theme-provider.tsx"),
|
|
1485
|
+
THEME_PROVIDER,
|
|
1486
|
+
"utf8"
|
|
1487
|
+
);
|
|
1488
|
+
await fs8.writeFile(
|
|
1489
|
+
path9.join(projectDir, ".cursor", "rules", "dark-mode.mdc"),
|
|
1490
|
+
RULE4,
|
|
1491
|
+
"utf8"
|
|
1492
|
+
);
|
|
1493
|
+
await fs8.outputFile(
|
|
1494
|
+
path9.join(projectDir, "docs", "style-guide", "ui", "dark-mode.md"),
|
|
1495
|
+
`# Dark mode
|
|
1496
|
+
|
|
1497
|
+
next-themes + Tailwind \`dark:\` variants.
|
|
1498
|
+
Provider: \`src/providers/theme-provider.tsx\` (via \`AppProviders\`).
|
|
1499
|
+
`
|
|
1500
|
+
);
|
|
1501
|
+
const globalsPath = path9.join(projectDir, "src", "app", "globals.css");
|
|
1502
|
+
if (await fs8.pathExists(globalsPath)) {
|
|
1503
|
+
let css = await fs8.readFile(globalsPath, "utf8");
|
|
1504
|
+
if (!css.includes("@custom-variant dark")) {
|
|
1505
|
+
css = css.replace(
|
|
1506
|
+
`@import "tailwindcss";
|
|
1507
|
+
`,
|
|
1508
|
+
`@import "tailwindcss";
|
|
1509
|
+
|
|
1510
|
+
@custom-variant dark (&:where(.dark, .dark *));
|
|
1511
|
+
`
|
|
1512
|
+
);
|
|
1513
|
+
await fs8.writeFile(globalsPath, css, "utf8");
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
await addDependencies(projectDir, {
|
|
1517
|
+
"next-themes": "^0.4.4"
|
|
1518
|
+
});
|
|
1519
|
+
}
|
|
1520
|
+
};
|
|
1521
|
+
|
|
1522
|
+
// src/installers/docker-compose.ts
|
|
1523
|
+
import fs9 from "fs-extra";
|
|
1524
|
+
import path10 from "path";
|
|
1525
|
+
var COMPOSE = `services:
|
|
1526
|
+
api:
|
|
1527
|
+
image: nginx:alpine
|
|
1528
|
+
ports:
|
|
1529
|
+
- "8000:80"
|
|
1530
|
+
volumes:
|
|
1531
|
+
- ./docker/api-mock:/usr/share/nginx/html:ro
|
|
1532
|
+
# Replace this stub with your real API image when ready.
|
|
1533
|
+
# JWT apps expect NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
1534
|
+
|
|
1535
|
+
# Example (uncomment when you have a backend image):
|
|
1536
|
+
# api:
|
|
1537
|
+
# image: your-org/api:latest
|
|
1538
|
+
# ports:
|
|
1539
|
+
# - "8000:8000"
|
|
1540
|
+
# environment:
|
|
1541
|
+
# DATABASE_URL: postgres://...
|
|
1542
|
+
`;
|
|
1543
|
+
var MOCK_README = `# Local API stub
|
|
1544
|
+
|
|
1545
|
+
Placeholder for Docker Compose. Point \`NEXT_PUBLIC_API_URL\` at \`http://localhost:8000\`.
|
|
1546
|
+
|
|
1547
|
+
Replace the nginx stub in \`docker-compose.yml\` with your real API service.
|
|
1548
|
+
`;
|
|
1549
|
+
var RULE5 = `---
|
|
1550
|
+
description: Local API via Docker Compose (JWT + endpoints)
|
|
1551
|
+
globs: docker-compose.yml,docker/api-mock/**
|
|
1552
|
+
alwaysApply: false
|
|
1553
|
+
---
|
|
1554
|
+
|
|
1555
|
+
# Docker Compose (local API)
|
|
1556
|
+
|
|
1557
|
+
- \`docker-compose.yml\` \u2014 stub API on port 8000 for JWT + endpoints apps.
|
|
1558
|
+
- \`docker compose up\` then set \`NEXT_PUBLIC_API_URL=http://localhost:8000\`.
|
|
1559
|
+
- Swap the stub service for your real backend image before production work.
|
|
1560
|
+
`;
|
|
1561
|
+
var dockerComposeInstaller = {
|
|
1562
|
+
id: "docker-compose",
|
|
1563
|
+
active: (o) => o.auth === "jwt" && o.endpoints,
|
|
1564
|
+
async run({ projectDir }) {
|
|
1565
|
+
await fs9.writeFile(
|
|
1566
|
+
path10.join(projectDir, "docker-compose.yml"),
|
|
1567
|
+
COMPOSE,
|
|
1568
|
+
"utf8"
|
|
1569
|
+
);
|
|
1570
|
+
await fs9.outputFile(
|
|
1571
|
+
path10.join(projectDir, "docker", "api-mock", "README.md"),
|
|
1572
|
+
MOCK_README
|
|
1573
|
+
);
|
|
1574
|
+
await fs9.writeFile(
|
|
1575
|
+
path10.join(projectDir, ".cursor", "rules", "docker-compose.mdc"),
|
|
1576
|
+
RULE5,
|
|
1577
|
+
"utf8"
|
|
1578
|
+
);
|
|
1579
|
+
await fs9.outputFile(
|
|
1580
|
+
path10.join(projectDir, "docs", "style-guide", "ops", "docker-compose.md"),
|
|
1581
|
+
`# Docker Compose (local API)
|
|
1582
|
+
|
|
1583
|
+
\`\`\`bash
|
|
1584
|
+
docker compose up
|
|
1585
|
+
\`\`\`
|
|
1586
|
+
|
|
1587
|
+
Stub listens on \`http://localhost:8000\`. Align with \`NEXT_PUBLIC_API_URL\`.
|
|
1588
|
+
`
|
|
1589
|
+
);
|
|
1590
|
+
}
|
|
1591
|
+
};
|
|
1592
|
+
|
|
1593
|
+
// src/installers/docker.ts
|
|
1594
|
+
import fs10 from "fs-extra";
|
|
1595
|
+
import path11 from "path";
|
|
1596
|
+
var STANDALONE_CONFIG = `import type { NextConfig } from "next";
|
|
1597
|
+
|
|
1598
|
+
const nextConfig: NextConfig = {
|
|
1599
|
+
reactStrictMode: true,
|
|
1600
|
+
output: "standalone",
|
|
1601
|
+
};
|
|
1602
|
+
|
|
1603
|
+
export default nextConfig;
|
|
1604
|
+
`;
|
|
1605
|
+
function dockerfileFor(pm) {
|
|
1606
|
+
if (pm === "npm") {
|
|
1607
|
+
return `# ---------------------------------------------------------------------------- #
|
|
1608
|
+
# Stage 1: Base & Dependencies #
|
|
1609
|
+
# ---------------------------------------------------------------------------- #
|
|
1610
|
+
FROM node:22-alpine AS builder
|
|
1611
|
+
|
|
1612
|
+
RUN apk add --no-cache libc6-compat
|
|
1613
|
+
|
|
1614
|
+
WORKDIR /app
|
|
1615
|
+
|
|
1616
|
+
ENV HUSKY=0 \\
|
|
1617
|
+
NEXT_TELEMETRY_DISABLED=1
|
|
1618
|
+
|
|
1619
|
+
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
1620
|
+
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
1621
|
+
|
|
1622
|
+
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \\
|
|
1623
|
+
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
|
1624
|
+
|
|
1625
|
+
COPY package.json package-lock.json .npmrc* ./
|
|
1626
|
+
RUN --mount=type=cache,id=npm,target=/root/.npm \\
|
|
1627
|
+
npm ci
|
|
1628
|
+
|
|
1629
|
+
COPY . /app/
|
|
1630
|
+
|
|
1631
|
+
RUN npm run build
|
|
1632
|
+
|
|
1633
|
+
# ---------------------------------------------------------------------------- #
|
|
1634
|
+
# Stage 2: Production Runner #
|
|
1635
|
+
# ---------------------------------------------------------------------------- #
|
|
1636
|
+
FROM node:22-alpine AS runner
|
|
1637
|
+
|
|
1638
|
+
RUN apk add --no-cache curl
|
|
1639
|
+
|
|
1640
|
+
WORKDIR /app
|
|
1641
|
+
|
|
1642
|
+
ENV NODE_ENV=production \\
|
|
1643
|
+
NEXT_TELEMETRY_DISABLED=1 \\
|
|
1644
|
+
HOSTNAME=0.0.0.0 \\
|
|
1645
|
+
PORT=3000
|
|
1646
|
+
|
|
1647
|
+
COPY --from=builder /app/public ./public
|
|
1648
|
+
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
|
1649
|
+
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
|
1650
|
+
|
|
1651
|
+
USER node
|
|
1652
|
+
|
|
1653
|
+
EXPOSE 3000
|
|
1654
|
+
|
|
1655
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \\
|
|
1656
|
+
CMD curl -fsS http://127.0.0.1:3000/ || exit 1
|
|
1657
|
+
|
|
1658
|
+
CMD ["node", "server.js"]
|
|
1659
|
+
`;
|
|
1660
|
+
}
|
|
1661
|
+
if (pm === "yarn") {
|
|
1662
|
+
return `# ---------------------------------------------------------------------------- #
|
|
1663
|
+
# Stage 1: Base & Dependencies #
|
|
1664
|
+
# ---------------------------------------------------------------------------- #
|
|
1665
|
+
FROM node:22-alpine AS builder
|
|
1666
|
+
|
|
1667
|
+
RUN apk add --no-cache libc6-compat
|
|
1668
|
+
RUN corepack enable && corepack prepare yarn@1.22.22 --activate
|
|
1669
|
+
|
|
1670
|
+
WORKDIR /app
|
|
1671
|
+
|
|
1672
|
+
ENV HUSKY=0 \\
|
|
1673
|
+
NEXT_TELEMETRY_DISABLED=1
|
|
1674
|
+
|
|
1675
|
+
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
1676
|
+
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
1677
|
+
|
|
1678
|
+
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \\
|
|
1679
|
+
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
|
1680
|
+
|
|
1681
|
+
COPY package.json yarn.lock .npmrc* ./
|
|
1682
|
+
RUN --mount=type=cache,id=yarn,target=/usr/local/share/.cache/yarn \\
|
|
1683
|
+
yarn install --frozen-lockfile
|
|
1684
|
+
|
|
1685
|
+
COPY . /app/
|
|
1686
|
+
|
|
1687
|
+
RUN yarn build
|
|
1688
|
+
|
|
1689
|
+
# ---------------------------------------------------------------------------- #
|
|
1690
|
+
# Stage 2: Production Runner #
|
|
1691
|
+
# ---------------------------------------------------------------------------- #
|
|
1692
|
+
FROM node:22-alpine AS runner
|
|
1693
|
+
|
|
1694
|
+
RUN apk add --no-cache curl
|
|
1695
|
+
|
|
1696
|
+
WORKDIR /app
|
|
1697
|
+
|
|
1698
|
+
ENV NODE_ENV=production \\
|
|
1699
|
+
NEXT_TELEMETRY_DISABLED=1 \\
|
|
1700
|
+
HOSTNAME=0.0.0.0 \\
|
|
1701
|
+
PORT=3000
|
|
1702
|
+
|
|
1703
|
+
COPY --from=builder /app/public ./public
|
|
1704
|
+
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
|
1705
|
+
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
|
1706
|
+
|
|
1707
|
+
USER node
|
|
1708
|
+
|
|
1709
|
+
EXPOSE 3000
|
|
1710
|
+
|
|
1711
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \\
|
|
1712
|
+
CMD curl -fsS http://127.0.0.1:3000/ || exit 1
|
|
1713
|
+
|
|
1714
|
+
CMD ["node", "server.js"]
|
|
1715
|
+
`;
|
|
1716
|
+
}
|
|
1717
|
+
return `# ---------------------------------------------------------------------------- #
|
|
1718
|
+
# Stage 1: Base & Dependencies #
|
|
1719
|
+
# ---------------------------------------------------------------------------- #
|
|
1720
|
+
FROM node:22-alpine AS builder
|
|
1721
|
+
|
|
1722
|
+
RUN apk add --no-cache libc6-compat
|
|
1723
|
+
RUN corepack enable && corepack prepare pnpm@9.15.0 --activate
|
|
1724
|
+
|
|
1725
|
+
WORKDIR /app
|
|
1726
|
+
|
|
1727
|
+
ENV HUSKY=0 \\
|
|
1728
|
+
NEXT_TELEMETRY_DISABLED=1
|
|
1729
|
+
|
|
1730
|
+
ARG NEXT_PUBLIC_APP_URL=http://localhost:3000
|
|
1731
|
+
ARG NEXT_PUBLIC_API_URL=http://localhost:8000
|
|
1732
|
+
|
|
1733
|
+
ENV NEXT_PUBLIC_APP_URL=$NEXT_PUBLIC_APP_URL \\
|
|
1734
|
+
NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
|
|
1735
|
+
|
|
1736
|
+
COPY package.json pnpm-lock.yaml .npmrc* ./
|
|
1737
|
+
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \\
|
|
1738
|
+
pnpm install --frozen-lockfile
|
|
1739
|
+
|
|
1740
|
+
COPY . /app/
|
|
1741
|
+
|
|
1742
|
+
RUN pnpm build
|
|
1743
|
+
|
|
1744
|
+
# ---------------------------------------------------------------------------- #
|
|
1745
|
+
# Stage 2: Production Runner #
|
|
1746
|
+
# ---------------------------------------------------------------------------- #
|
|
1747
|
+
FROM node:22-alpine AS runner
|
|
1748
|
+
|
|
1749
|
+
RUN apk add --no-cache curl
|
|
1750
|
+
|
|
1751
|
+
WORKDIR /app
|
|
1752
|
+
|
|
1753
|
+
ENV NODE_ENV=production \\
|
|
1754
|
+
NEXT_TELEMETRY_DISABLED=1 \\
|
|
1755
|
+
HOSTNAME=0.0.0.0 \\
|
|
1756
|
+
PORT=3000
|
|
1757
|
+
|
|
1758
|
+
COPY --from=builder /app/public ./public
|
|
1759
|
+
COPY --from=builder --chown=node:node /app/.next/standalone ./
|
|
1760
|
+
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
|
|
1761
|
+
|
|
1762
|
+
USER node
|
|
1763
|
+
|
|
1764
|
+
EXPOSE 3000
|
|
1765
|
+
|
|
1766
|
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \\
|
|
1767
|
+
CMD curl -fsS http://127.0.0.1:3000/ || exit 1
|
|
1768
|
+
|
|
1769
|
+
CMD ["node", "server.js"]
|
|
1770
|
+
`;
|
|
1771
|
+
}
|
|
1772
|
+
var dockerInstaller = {
|
|
1773
|
+
id: "docker",
|
|
1774
|
+
active: (o) => o.docker,
|
|
1775
|
+
async run({ projectDir, options }) {
|
|
1776
|
+
const addon = path11.join(ADDONS_DIR, "docker");
|
|
1777
|
+
const pm = getPackageManager(options.packageManager);
|
|
1778
|
+
await fs10.ensureDir(path11.join(projectDir, "docker"));
|
|
1779
|
+
await fs10.writeFile(
|
|
1780
|
+
path11.join(projectDir, "docker", "production.Dockerfile"),
|
|
1781
|
+
dockerfileFor(options.packageManager),
|
|
1782
|
+
"utf8"
|
|
1783
|
+
);
|
|
1784
|
+
await fs10.copy(
|
|
1785
|
+
path11.join(addon, ".dockerignore"),
|
|
1786
|
+
path11.join(projectDir, ".dockerignore")
|
|
1787
|
+
);
|
|
1788
|
+
await fs10.copy(
|
|
1789
|
+
path11.join(addon, ".cursor", "rules", "docker.mdc"),
|
|
1790
|
+
path11.join(projectDir, ".cursor", "rules", "docker.mdc")
|
|
1791
|
+
);
|
|
1792
|
+
let doc = await fs10.readFile(
|
|
1793
|
+
path11.join(addon, "docs", "style-guide", "ops", "docker.md"),
|
|
1794
|
+
"utf8"
|
|
1795
|
+
);
|
|
1796
|
+
doc = doc.replaceAll("__PROJECT_SLUG__", options.slug).replaceAll("__PACKAGE_MANAGER__", pm.id);
|
|
1797
|
+
await fs10.outputFile(
|
|
1798
|
+
path11.join(projectDir, "docs", "style-guide", "ops", "docker.md"),
|
|
1799
|
+
doc
|
|
1800
|
+
);
|
|
1801
|
+
await fs10.writeFile(
|
|
1802
|
+
path11.join(projectDir, "next.config.ts"),
|
|
1803
|
+
STANDALONE_CONFIG,
|
|
1804
|
+
"utf8"
|
|
1805
|
+
);
|
|
1806
|
+
}
|
|
1807
|
+
};
|
|
1808
|
+
|
|
1809
|
+
// src/installers/endpoints.ts
|
|
1810
|
+
import fs11 from "fs-extra";
|
|
1811
|
+
import path12 from "path";
|
|
1812
|
+
var endpointsInstaller = {
|
|
1813
|
+
id: "endpoints",
|
|
1814
|
+
active: (o) => o.endpoints,
|
|
1815
|
+
async run({ projectDir }) {
|
|
1816
|
+
const addon = path12.join(ADDONS_DIR, "endpoints");
|
|
1817
|
+
await fs11.copy(
|
|
1818
|
+
path12.join(addon, "scripts"),
|
|
1819
|
+
path12.join(projectDir, "scripts")
|
|
1820
|
+
);
|
|
1821
|
+
await fs11.copy(
|
|
1822
|
+
path12.join(addon, "src", "endpoints"),
|
|
1823
|
+
path12.join(projectDir, "src", "endpoints")
|
|
1824
|
+
);
|
|
1825
|
+
for (const file of [
|
|
1826
|
+
"axios-instance.ts",
|
|
1827
|
+
"api-result.ts",
|
|
1828
|
+
"api-error.ts",
|
|
1829
|
+
"search-params.ts",
|
|
1830
|
+
"form-data.ts"
|
|
1831
|
+
]) {
|
|
1832
|
+
await fs11.copy(
|
|
1833
|
+
path12.join(addon, "src", "utils", file),
|
|
1834
|
+
path12.join(projectDir, "src", "utils", file)
|
|
1835
|
+
);
|
|
1836
|
+
}
|
|
1837
|
+
await fs11.remove(path12.join(projectDir, "src", "utils", ".gitkeep"));
|
|
1838
|
+
await fs11.copy(
|
|
1839
|
+
path12.join(addon, ".cursor", "rules", "endpoints.mdc"),
|
|
1840
|
+
path12.join(projectDir, ".cursor", "rules", "endpoints.mdc")
|
|
1841
|
+
);
|
|
1842
|
+
await fs11.copy(
|
|
1843
|
+
path12.join(addon, "docs", "style-guide", "http", "endpoints.md"),
|
|
1844
|
+
path12.join(projectDir, "docs", "style-guide", "http", "endpoints.md")
|
|
1845
|
+
);
|
|
1846
|
+
await addDependencies(projectDir, { axios: "^1.7.9" });
|
|
1847
|
+
await addDependencies(
|
|
1848
|
+
projectDir,
|
|
1849
|
+
{ tsx: "^4.19.2" },
|
|
1850
|
+
"devDependencies"
|
|
1851
|
+
);
|
|
1852
|
+
await addScripts(projectDir, {
|
|
1853
|
+
"endpoints:generate": "tsx scripts/generate-endpoints.ts"
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
};
|
|
1857
|
+
|
|
1858
|
+
// src/installers/eslint.ts
|
|
1859
|
+
import fs12 from "fs-extra";
|
|
1860
|
+
import path13 from "path";
|
|
1861
|
+
var eslintInstaller = {
|
|
1862
|
+
id: "eslint",
|
|
1863
|
+
active: (o) => o.eslint,
|
|
1864
|
+
async run({ projectDir }) {
|
|
1865
|
+
const addon = path13.join(ADDONS_DIR, "eslint");
|
|
1866
|
+
await fs12.copy(
|
|
1867
|
+
path13.join(addon, "eslint.config.mjs"),
|
|
1868
|
+
path13.join(projectDir, "eslint.config.mjs")
|
|
1869
|
+
);
|
|
1870
|
+
await fs12.copy(
|
|
1871
|
+
path13.join(addon, ".cursor", "rules", "eslint.mdc"),
|
|
1872
|
+
path13.join(projectDir, ".cursor", "rules", "eslint.mdc")
|
|
1873
|
+
);
|
|
1874
|
+
await fs12.copy(
|
|
1875
|
+
path13.join(addon, "docs", "style-guide", "tooling", "eslint.md"),
|
|
1876
|
+
path13.join(projectDir, "docs", "style-guide", "tooling", "eslint.md")
|
|
1877
|
+
);
|
|
1878
|
+
await addDependencies(
|
|
1879
|
+
projectDir,
|
|
1880
|
+
{
|
|
1881
|
+
"eslint-config-prettier": "^10.1.8",
|
|
1882
|
+
"eslint-plugin-import": "^2.32.0",
|
|
1883
|
+
"eslint-plugin-simple-import-sort": "^12.1.1"
|
|
1884
|
+
},
|
|
1885
|
+
"devDependencies"
|
|
1886
|
+
);
|
|
1887
|
+
await addScripts(projectDir, {
|
|
1888
|
+
lint: "eslint .",
|
|
1889
|
+
"lint:fix": "eslint . --fix"
|
|
1890
|
+
});
|
|
1891
|
+
}
|
|
1892
|
+
};
|
|
1893
|
+
|
|
1894
|
+
// src/installers/husky.ts
|
|
1895
|
+
import fs13 from "fs-extra";
|
|
1896
|
+
import path14 from "path";
|
|
1897
|
+
var huskyInstaller = {
|
|
1898
|
+
id: "husky",
|
|
1899
|
+
active: (o) => o.husky,
|
|
1900
|
+
async run({ projectDir, options }) {
|
|
1901
|
+
const addon = path14.join(ADDONS_DIR, "husky");
|
|
1902
|
+
await fs13.copy(path14.join(addon, ".husky"), path14.join(projectDir, ".husky"));
|
|
1903
|
+
await fs13.copy(
|
|
1904
|
+
path14.join(addon, "docs", "style-guide", "tooling", "husky.md"),
|
|
1905
|
+
path14.join(projectDir, "docs", "style-guide", "tooling", "husky.md")
|
|
1906
|
+
);
|
|
1907
|
+
try {
|
|
1908
|
+
await fs13.chmod(path14.join(projectDir, ".husky", "pre-commit"), 493);
|
|
1909
|
+
} catch {
|
|
1910
|
+
}
|
|
1911
|
+
await addDependencies(
|
|
1912
|
+
projectDir,
|
|
1913
|
+
{
|
|
1914
|
+
husky: "^9.1.7",
|
|
1915
|
+
"lint-staged": "^15.2.11"
|
|
1916
|
+
},
|
|
1917
|
+
"devDependencies"
|
|
1918
|
+
);
|
|
1919
|
+
await addScripts(projectDir, { prepare: "husky" });
|
|
1920
|
+
const tsHooks = [];
|
|
1921
|
+
if (options.prettier) tsHooks.push("prettier --write");
|
|
1922
|
+
if (options.eslint) tsHooks.push("eslint --fix");
|
|
1923
|
+
if (tsHooks.length === 0) tsHooks.push("echo 'husky: no lint/format tools'");
|
|
1924
|
+
const lintStaged = {
|
|
1925
|
+
"*.{js,jsx,ts,tsx}": tsHooks
|
|
1926
|
+
};
|
|
1927
|
+
if (options.prettier) {
|
|
1928
|
+
lintStaged["*.{json,css,scss,md,yaml,yml}"] = ["prettier --write"];
|
|
1929
|
+
}
|
|
1930
|
+
await mergePackageField(projectDir, "lint-staged", lintStaged);
|
|
1931
|
+
}
|
|
1932
|
+
};
|
|
1933
|
+
|
|
1934
|
+
// src/installers/i18n.ts
|
|
1935
|
+
import fs14 from "fs-extra";
|
|
1936
|
+
import path15 from "path";
|
|
1937
|
+
var I18N_REQUEST = `import { getRequestConfig } from "next-intl/server";
|
|
1938
|
+
|
|
1939
|
+
export default getRequestConfig(async () => {
|
|
1940
|
+
const locale = "en";
|
|
1941
|
+
return {
|
|
1942
|
+
locale,
|
|
1943
|
+
messages: (await import(\`../../messages/\${locale}.json\`)).default,
|
|
1944
|
+
};
|
|
1945
|
+
});
|
|
1946
|
+
`;
|
|
1947
|
+
var MESSAGES_EN = `{
|
|
1948
|
+
"Home": {
|
|
1949
|
+
"title": "Welcome",
|
|
1950
|
+
"body": "Scaffolded with next-style-guide."
|
|
1951
|
+
}
|
|
1952
|
+
}
|
|
1953
|
+
`;
|
|
1954
|
+
var RULE6 = `---
|
|
1955
|
+
description: next-intl i18n conventions
|
|
1956
|
+
globs: src/i18n/**,messages/**,src/providers/app-providers.tsx
|
|
1957
|
+
alwaysApply: false
|
|
1958
|
+
---
|
|
1959
|
+
|
|
1960
|
+
# i18n (next-intl)
|
|
1961
|
+
|
|
1962
|
+
- Request config: \`src/i18n/request.ts\`.
|
|
1963
|
+
- Messages: \`messages/<locale>.json\`.
|
|
1964
|
+
- Prefer \`useTranslations\` / \`getTranslations\` \u2014 avoid hardcoded copy when a key exists.
|
|
1965
|
+
- \`NextIntlClientProvider\` is composed in \`AppProviders\`; root layout loads locale/messages.
|
|
1966
|
+
`;
|
|
1967
|
+
var i18nInstaller = {
|
|
1968
|
+
id: "i18n",
|
|
1969
|
+
active: (o) => o.i18n,
|
|
1970
|
+
async run({ projectDir }) {
|
|
1971
|
+
await fs14.outputFile(
|
|
1972
|
+
path15.join(projectDir, "src", "i18n", "request.ts"),
|
|
1973
|
+
I18N_REQUEST
|
|
1974
|
+
);
|
|
1975
|
+
await fs14.outputFile(
|
|
1976
|
+
path15.join(projectDir, "messages", "en.json"),
|
|
1977
|
+
MESSAGES_EN
|
|
1978
|
+
);
|
|
1979
|
+
await fs14.writeFile(
|
|
1980
|
+
path15.join(projectDir, ".cursor", "rules", "i18n.mdc"),
|
|
1981
|
+
RULE6,
|
|
1982
|
+
"utf8"
|
|
1983
|
+
);
|
|
1984
|
+
await fs14.outputFile(
|
|
1985
|
+
path15.join(projectDir, "docs", "style-guide", "i18n", "overview.md"),
|
|
1986
|
+
`# i18n
|
|
1987
|
+
|
|
1988
|
+
next-intl: \`src/i18n/request.ts\` + \`messages/en.json\`
|
|
1989
|
+
Plugin wired in \`next.config.ts\`. Provider via \`AppProviders\`.
|
|
1990
|
+
`
|
|
1991
|
+
);
|
|
1992
|
+
const nextConfigPath = path15.join(projectDir, "next.config.ts");
|
|
1993
|
+
if (await fs14.pathExists(nextConfigPath)) {
|
|
1994
|
+
let nextConfig = await fs14.readFile(nextConfigPath, "utf8");
|
|
1995
|
+
if (!nextConfig.includes("next-intl")) {
|
|
1996
|
+
if (!nextConfig.includes("createNextIntlPlugin")) {
|
|
1997
|
+
nextConfig = `import createNextIntlPlugin from "next-intl/plugin";
|
|
1998
|
+
|
|
1999
|
+
const withNextIntl = createNextIntlPlugin("./src/i18n/request.ts");
|
|
2000
|
+
|
|
2001
|
+
${nextConfig}`;
|
|
2002
|
+
}
|
|
2003
|
+
nextConfig = nextConfig.replace(
|
|
2004
|
+
"export default nextConfig;",
|
|
2005
|
+
"export default withNextIntl(nextConfig);"
|
|
2006
|
+
);
|
|
2007
|
+
await fs14.writeFile(nextConfigPath, nextConfig, "utf8");
|
|
2008
|
+
}
|
|
2009
|
+
}
|
|
2010
|
+
await addDependencies(projectDir, {
|
|
2011
|
+
"next-intl": "^3.26.3"
|
|
2012
|
+
});
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
|
|
2016
|
+
// src/installers/icons.ts
|
|
2017
|
+
import fs15 from "fs-extra";
|
|
2018
|
+
import path16 from "path";
|
|
2019
|
+
var RULE7 = `---
|
|
2020
|
+
description: Icon library conventions
|
|
2021
|
+
globs: "src/**/*.{ts,tsx}"
|
|
2022
|
+
alwaysApply: false
|
|
2023
|
+
---
|
|
2024
|
+
|
|
2025
|
+
# Icons
|
|
2026
|
+
|
|
2027
|
+
__ICONS_BODY__
|
|
2028
|
+
`;
|
|
2029
|
+
var BODIES = {
|
|
2030
|
+
lucide: `- Use \`lucide-react\` icons (\`import { IconName } from "lucide-react"\`).
|
|
2031
|
+
- Prefer Lucide when adding shadcn primitives later.`,
|
|
2032
|
+
tabler: `- Use \`@tabler/icons-react\` with the \`Icon*\` prefix (\`import { IconHome } from "@tabler/icons-react"\`).
|
|
2033
|
+
- Do not mix Lucide and Tabler in the same app.`
|
|
2034
|
+
};
|
|
2035
|
+
var iconsInstaller = {
|
|
2036
|
+
id: "icons",
|
|
2037
|
+
active: (o) => o.icons !== "none",
|
|
2038
|
+
async run({ projectDir, options }) {
|
|
2039
|
+
if (options.icons === "none") return;
|
|
2040
|
+
const pkg = ICON_PACKAGES[options.icons];
|
|
2041
|
+
await addDependencies(projectDir, { [pkg.name]: pkg.version });
|
|
2042
|
+
const ruleDir = path16.join(projectDir, ".cursor", "rules");
|
|
2043
|
+
await fs15.ensureDir(ruleDir);
|
|
2044
|
+
await fs15.writeFile(
|
|
2045
|
+
path16.join(ruleDir, "icons.mdc"),
|
|
2046
|
+
RULE7.replace("__ICONS_BODY__", BODIES[options.icons]),
|
|
2047
|
+
"utf8"
|
|
2048
|
+
);
|
|
2049
|
+
}
|
|
2050
|
+
};
|
|
2051
|
+
|
|
2052
|
+
// src/installers/mui.ts
|
|
2053
|
+
import fs16 from "fs-extra";
|
|
2054
|
+
import path17 from "path";
|
|
2055
|
+
var THEME_PROVIDER2 = `"use client";
|
|
2056
|
+
|
|
2057
|
+
import { type ReactNode } from "react";
|
|
2058
|
+
|
|
2059
|
+
import { CssBaseline, ThemeProvider, createTheme } from "@mui/material";
|
|
2060
|
+
import { AppRouterCacheProvider } from "@mui/material-nextjs/v15-appRouter";
|
|
2061
|
+
|
|
2062
|
+
const theme = createTheme({
|
|
2063
|
+
cssVariables: true,
|
|
2064
|
+
palette: {
|
|
2065
|
+
mode: "light",
|
|
2066
|
+
primary: { main: "#0b5fff" },
|
|
2067
|
+
},
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
export function MuiProvider({ children }: { children: ReactNode }) {
|
|
2071
|
+
return (
|
|
2072
|
+
<AppRouterCacheProvider>
|
|
2073
|
+
<ThemeProvider theme={theme}>
|
|
2074
|
+
<CssBaseline />
|
|
2075
|
+
{children}
|
|
2076
|
+
</ThemeProvider>
|
|
2077
|
+
</AppRouterCacheProvider>
|
|
2078
|
+
);
|
|
2079
|
+
}
|
|
2080
|
+
`;
|
|
2081
|
+
var RULE8 = `---
|
|
2082
|
+
description: MUI (Material UI) conventions
|
|
2083
|
+
globs: src/providers/mui-provider.tsx,src/**/*.{ts,tsx}
|
|
2084
|
+
alwaysApply: false
|
|
2085
|
+
---
|
|
2086
|
+
|
|
2087
|
+
# MUI
|
|
2088
|
+
|
|
2089
|
+
- Theme via \`MuiProvider\` (\`src/providers/mui-provider.tsx\`), composed in \`AppProviders\`.
|
|
2090
|
+
- Prefer MUI components (\`Button\`, \`TextField\`, \`Stack\`, \u2026) over ad-hoc CSS.
|
|
2091
|
+
- Do not add shadcn \u2014 incompatible with this styling choice.
|
|
2092
|
+
- Emotion cache: \`@mui/material-nextjs\` App Router provider (already wired).
|
|
2093
|
+
`;
|
|
2094
|
+
var muiInstaller = {
|
|
2095
|
+
id: "mui",
|
|
2096
|
+
active: (o) => o.styling === "mui",
|
|
2097
|
+
async run({ projectDir }) {
|
|
2098
|
+
const providersDir = path17.join(projectDir, "src", "providers");
|
|
2099
|
+
await fs16.ensureDir(providersDir);
|
|
2100
|
+
await fs16.remove(path17.join(providersDir, ".gitkeep"));
|
|
2101
|
+
await fs16.writeFile(
|
|
2102
|
+
path17.join(providersDir, "mui-provider.tsx"),
|
|
2103
|
+
THEME_PROVIDER2,
|
|
2104
|
+
"utf8"
|
|
2105
|
+
);
|
|
2106
|
+
await fs16.writeFile(
|
|
2107
|
+
path17.join(projectDir, ".cursor", "rules", "mui.mdc"),
|
|
2108
|
+
RULE8,
|
|
2109
|
+
"utf8"
|
|
2110
|
+
);
|
|
2111
|
+
await addDependencies(projectDir, {
|
|
2112
|
+
"@mui/material": "^6.4.0",
|
|
2113
|
+
"@mui/material-nextjs": "^6.4.0",
|
|
2114
|
+
"@emotion/react": "^11.14.0",
|
|
2115
|
+
"@emotion/styled": "^11.14.0",
|
|
2116
|
+
"@emotion/cache": "^11.14.0"
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
};
|
|
2120
|
+
|
|
2121
|
+
// src/installers/package-manager.ts
|
|
2122
|
+
import fs17 from "fs-extra";
|
|
2123
|
+
import path18 from "path";
|
|
2124
|
+
var packageManagerInstaller = {
|
|
2125
|
+
id: "package-manager",
|
|
2126
|
+
active: () => true,
|
|
2127
|
+
async run({ projectDir, options }) {
|
|
2128
|
+
const meta = getPackageManager(options.packageManager);
|
|
2129
|
+
const pkgPath = path18.join(projectDir, "package.json");
|
|
2130
|
+
const pkg = await fs17.readJson(pkgPath);
|
|
2131
|
+
if (meta.packageManagerField) {
|
|
2132
|
+
pkg.packageManager = meta.packageManagerField;
|
|
2133
|
+
} else {
|
|
2134
|
+
delete pkg.packageManager;
|
|
2135
|
+
}
|
|
2136
|
+
await fs17.writeJson(pkgPath, pkg, { spaces: 2 });
|
|
2137
|
+
}
|
|
2138
|
+
};
|
|
2139
|
+
|
|
2140
|
+
// src/installers/prettier.ts
|
|
2141
|
+
import fs18 from "fs-extra";
|
|
2142
|
+
import path19 from "path";
|
|
2143
|
+
var prettierInstaller = {
|
|
2144
|
+
id: "prettier",
|
|
2145
|
+
active: (o) => o.prettier,
|
|
2146
|
+
async run({ projectDir }) {
|
|
2147
|
+
const addon = path19.join(ADDONS_DIR, "prettier");
|
|
2148
|
+
await fs18.copy(
|
|
2149
|
+
path19.join(addon, ".prettierrc"),
|
|
2150
|
+
path19.join(projectDir, ".prettierrc")
|
|
2151
|
+
);
|
|
2152
|
+
await fs18.copy(
|
|
2153
|
+
path19.join(addon, ".cursor", "rules", "prettier.mdc"),
|
|
2154
|
+
path19.join(projectDir, ".cursor", "rules", "prettier.mdc")
|
|
2155
|
+
);
|
|
2156
|
+
await fs18.copy(
|
|
2157
|
+
path19.join(addon, "docs", "style-guide", "tooling", "prettier.md"),
|
|
2158
|
+
path19.join(projectDir, "docs", "style-guide", "tooling", "prettier.md")
|
|
2159
|
+
);
|
|
2160
|
+
await addDependencies(
|
|
2161
|
+
projectDir,
|
|
2162
|
+
{ prettier: "^3.6.2" },
|
|
2163
|
+
"devDependencies"
|
|
2164
|
+
);
|
|
2165
|
+
await addScripts(projectDir, {
|
|
2166
|
+
format: "prettier --write .",
|
|
2167
|
+
"format:check": "prettier --check ."
|
|
2168
|
+
});
|
|
2169
|
+
}
|
|
2170
|
+
};
|
|
2171
|
+
|
|
2172
|
+
// src/installers/seo.ts
|
|
2173
|
+
import fs19 from "fs-extra";
|
|
2174
|
+
import path20 from "path";
|
|
2175
|
+
var SEO_TS = `import type { Metadata } from "next";
|
|
2176
|
+
|
|
2177
|
+
import { env } from "@/lib/env";
|
|
2178
|
+
|
|
2179
|
+
export const SITE_NAME = process.env.NEXT_PUBLIC_SITE_NAME?.trim() || "__PROJECT_NAME__";
|
|
2180
|
+
export const DEFAULT_DESCRIPTION =
|
|
2181
|
+
process.env.NEXT_PUBLIC_SITE_DESCRIPTION?.trim() ||
|
|
2182
|
+
"Generated with next-style-guide";
|
|
2183
|
+
|
|
2184
|
+
/** Absolute public origin (no trailing slash). */
|
|
2185
|
+
export function getAppUrl(): string {
|
|
2186
|
+
try {
|
|
2187
|
+
return new URL(env.NEXT_PUBLIC_APP_URL).origin;
|
|
2188
|
+
} catch {
|
|
2189
|
+
return "http://localhost:3000";
|
|
2190
|
+
}
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
export function absoluteUrl(path: string): string {
|
|
2194
|
+
const base = getAppUrl();
|
|
2195
|
+
if (!path || path === "/") return base;
|
|
2196
|
+
return \`\${base}\${path.startsWith("/") ? path : \`/\${path}\`}\`;
|
|
2197
|
+
}
|
|
2198
|
+
|
|
2199
|
+
export type SitemapChangeFrequency =
|
|
2200
|
+
| "always"
|
|
2201
|
+
| "hourly"
|
|
2202
|
+
| "daily"
|
|
2203
|
+
| "weekly"
|
|
2204
|
+
| "monthly"
|
|
2205
|
+
| "yearly"
|
|
2206
|
+
| "never";
|
|
2207
|
+
|
|
2208
|
+
export function buildPublicMetadata(input: {
|
|
2209
|
+
title: string;
|
|
2210
|
+
description: string;
|
|
2211
|
+
path: string;
|
|
2212
|
+
absoluteTitle?: string;
|
|
2213
|
+
noIndex?: boolean;
|
|
2214
|
+
}): Metadata {
|
|
2215
|
+
const url = absoluteUrl(input.path);
|
|
2216
|
+
return {
|
|
2217
|
+
title: input.absoluteTitle ?? input.title,
|
|
2218
|
+
description: input.description,
|
|
2219
|
+
alternates: { canonical: url },
|
|
2220
|
+
openGraph: {
|
|
2221
|
+
title: input.absoluteTitle ?? input.title,
|
|
2222
|
+
description: input.description,
|
|
2223
|
+
url,
|
|
2224
|
+
siteName: SITE_NAME,
|
|
2225
|
+
type: "website",
|
|
2226
|
+
},
|
|
2227
|
+
robots: input.noIndex
|
|
2228
|
+
? { index: false, follow: false }
|
|
2229
|
+
: { index: true, follow: true },
|
|
2230
|
+
};
|
|
2231
|
+
}
|
|
2232
|
+
`;
|
|
2233
|
+
var PUBLIC_ROUTES = `import {
|
|
2234
|
+
buildPublicMetadata,
|
|
2235
|
+
DEFAULT_DESCRIPTION,
|
|
2236
|
+
SITE_NAME,
|
|
2237
|
+
type SitemapChangeFrequency,
|
|
2238
|
+
} from "@/lib/seo";
|
|
2239
|
+
|
|
2240
|
+
/**
|
|
2241
|
+
* Indexable URLs \u2014 single source of truth for sitemap + page metadata.
|
|
2242
|
+
* Add a route under \`src/app/\`, then register it here.
|
|
2243
|
+
*/
|
|
2244
|
+
export type PublicRoute = {
|
|
2245
|
+
path: string;
|
|
2246
|
+
title: string;
|
|
2247
|
+
description: string;
|
|
2248
|
+
absoluteTitle?: string;
|
|
2249
|
+
changeFrequency: SitemapChangeFrequency;
|
|
2250
|
+
priority: number;
|
|
2251
|
+
lastModified?: string;
|
|
2252
|
+
noIndex?: boolean;
|
|
2253
|
+
};
|
|
2254
|
+
|
|
2255
|
+
export const STATIC_PUBLIC_ROUTES: PublicRoute[] = [
|
|
2256
|
+
{
|
|
2257
|
+
path: "/",
|
|
2258
|
+
title: SITE_NAME,
|
|
2259
|
+
description: DEFAULT_DESCRIPTION,
|
|
2260
|
+
changeFrequency: "weekly",
|
|
2261
|
+
priority: 1,
|
|
2262
|
+
lastModified: "2026-01-01",
|
|
2263
|
+
},
|
|
2264
|
+
];
|
|
2265
|
+
|
|
2266
|
+
export function publicPageMetadata(path: string) {
|
|
2267
|
+
const route = STATIC_PUBLIC_ROUTES.find((item) => item.path === path);
|
|
2268
|
+
if (!route) {
|
|
2269
|
+
throw new Error(\`Missing public route registration for \${path}\`);
|
|
2270
|
+
}
|
|
2271
|
+
return buildPublicMetadata(route);
|
|
2272
|
+
}
|
|
2273
|
+
`;
|
|
2274
|
+
var SITEMAP = `import type { MetadataRoute } from "next";
|
|
2275
|
+
|
|
2276
|
+
import { absoluteUrl } from "@/lib/seo";
|
|
2277
|
+
import { STATIC_PUBLIC_ROUTES } from "@/lib/public-routes";
|
|
2278
|
+
|
|
2279
|
+
export default function sitemap(): MetadataRoute.Sitemap {
|
|
2280
|
+
return STATIC_PUBLIC_ROUTES.filter((route) => !route.noIndex).map((route) => ({
|
|
2281
|
+
url: absoluteUrl(route.path),
|
|
2282
|
+
lastModified: route.lastModified,
|
|
2283
|
+
changeFrequency: route.changeFrequency,
|
|
2284
|
+
priority: route.priority,
|
|
2285
|
+
}));
|
|
2286
|
+
}
|
|
2287
|
+
`;
|
|
2288
|
+
var ROBOTS = `import type { MetadataRoute } from "next";
|
|
2289
|
+
|
|
2290
|
+
import { getAppUrl } from "@/lib/seo";
|
|
2291
|
+
|
|
2292
|
+
export default function robots(): MetadataRoute.Robots {
|
|
2293
|
+
const origin = getAppUrl();
|
|
2294
|
+
return {
|
|
2295
|
+
rules: {
|
|
2296
|
+
userAgent: "*",
|
|
2297
|
+
allow: "/",
|
|
2298
|
+
},
|
|
2299
|
+
sitemap: \`\${origin}/sitemap.xml\`,
|
|
2300
|
+
};
|
|
2301
|
+
}
|
|
2302
|
+
`;
|
|
2303
|
+
var RULE9 = `---
|
|
2304
|
+
description: SEO + public routes
|
|
2305
|
+
globs: src/lib/public-routes.ts,src/lib/seo.ts,src/app/sitemap.ts,src/app/robots.ts,src/app/**/page.tsx
|
|
2306
|
+
alwaysApply: false
|
|
2307
|
+
---
|
|
2308
|
+
|
|
2309
|
+
# SEO
|
|
2310
|
+
|
|
2311
|
+
- Register every indexable path in \`STATIC_PUBLIC_ROUTES\` (\`src/lib/public-routes.ts\`).
|
|
2312
|
+
- Page metadata: \`publicPageMetadata("/path")\` \u2014 keep canonical/title/description in sync.
|
|
2313
|
+
- \`sitemap.ts\` / \`robots.ts\` read from that registry \u2014 don't hardcode URLs twice.
|
|
2314
|
+
- Single \`<main>\` in root layout only.
|
|
2315
|
+
- Placeholders / thin pages: \`noIndex: true\` and keep them out of the sitemap.
|
|
2316
|
+
- Prefer static \`lastModified\` dates over \`new Date()\` for every route.
|
|
2317
|
+
`;
|
|
2318
|
+
var seoInstaller = {
|
|
2319
|
+
id: "seo",
|
|
2320
|
+
active: (o) => o.seo,
|
|
2321
|
+
async run({ projectDir, options }) {
|
|
2322
|
+
let seo = SEO_TS.replaceAll("__PROJECT_NAME__", options.name);
|
|
2323
|
+
await fs19.writeFile(path20.join(projectDir, "src", "lib", "seo.ts"), seo, "utf8");
|
|
2324
|
+
await fs19.writeFile(
|
|
2325
|
+
path20.join(projectDir, "src", "lib", "public-routes.ts"),
|
|
2326
|
+
PUBLIC_ROUTES,
|
|
2327
|
+
"utf8"
|
|
2328
|
+
);
|
|
2329
|
+
await fs19.writeFile(
|
|
2330
|
+
path20.join(projectDir, "src", "app", "sitemap.ts"),
|
|
2331
|
+
SITEMAP,
|
|
2332
|
+
"utf8"
|
|
2333
|
+
);
|
|
2334
|
+
await fs19.writeFile(
|
|
2335
|
+
path20.join(projectDir, "src", "app", "robots.ts"),
|
|
2336
|
+
ROBOTS,
|
|
2337
|
+
"utf8"
|
|
2338
|
+
);
|
|
2339
|
+
await fs19.writeFile(
|
|
2340
|
+
path20.join(projectDir, ".cursor", "rules", "seo.mdc"),
|
|
2341
|
+
RULE9,
|
|
2342
|
+
"utf8"
|
|
2343
|
+
);
|
|
2344
|
+
await fs19.outputFile(
|
|
2345
|
+
path20.join(projectDir, "docs", "style-guide", "seo", "overview.md"),
|
|
2346
|
+
`# SEO
|
|
2347
|
+
|
|
2348
|
+
- Registry: \`src/lib/public-routes.ts\`
|
|
2349
|
+
- Helpers: \`src/lib/seo.ts\`
|
|
2350
|
+
- \`sitemap.ts\` / \`robots.ts\` under \`src/app/\`
|
|
2351
|
+
|
|
2352
|
+
Add a page \u2192 register the path \u2192 metadata/sitemap update automatically.
|
|
2353
|
+
`
|
|
2354
|
+
);
|
|
2355
|
+
}
|
|
2356
|
+
};
|
|
2357
|
+
|
|
2358
|
+
// src/installers/shadcn.ts
|
|
2359
|
+
import fs20 from "fs-extra";
|
|
2360
|
+
import path21 from "path";
|
|
2361
|
+
function componentsJson(iconLibrary) {
|
|
2362
|
+
return `${JSON.stringify(
|
|
2363
|
+
{
|
|
2364
|
+
$schema: "https://ui.shadcn.com/schema.json",
|
|
2365
|
+
style: "new-york",
|
|
2366
|
+
rsc: true,
|
|
2367
|
+
tsx: true,
|
|
2368
|
+
tailwind: {
|
|
2369
|
+
config: "",
|
|
2370
|
+
css: "src/app/globals.css",
|
|
2371
|
+
baseColor: "neutral",
|
|
2372
|
+
cssVariables: true,
|
|
2373
|
+
prefix: ""
|
|
2374
|
+
},
|
|
2375
|
+
iconLibrary,
|
|
2376
|
+
aliases: {
|
|
2377
|
+
components: "@/components",
|
|
2378
|
+
utils: "@/lib/utils",
|
|
2379
|
+
ui: "@/components/ui",
|
|
2380
|
+
lib: "@/lib",
|
|
2381
|
+
hooks: "@/hooks"
|
|
2382
|
+
}
|
|
2383
|
+
},
|
|
2384
|
+
null,
|
|
2385
|
+
2
|
|
2386
|
+
)}
|
|
2387
|
+
`;
|
|
2388
|
+
}
|
|
2389
|
+
var BUTTON = `import { cva, type VariantProps } from "class-variance-authority";
|
|
2390
|
+
import type { ComponentProps } from "react";
|
|
2391
|
+
|
|
2392
|
+
import { cn } from "@/lib/utils";
|
|
2393
|
+
|
|
2394
|
+
const buttonVariants = cva(
|
|
2395
|
+
"inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
|
2396
|
+
{
|
|
2397
|
+
variants: {
|
|
2398
|
+
variant: {
|
|
2399
|
+
default: "bg-neutral-900 text-white hover:bg-neutral-800",
|
|
2400
|
+
outline: "border border-neutral-300 bg-transparent hover:bg-neutral-100",
|
|
2401
|
+
ghost: "hover:bg-neutral-100",
|
|
2402
|
+
},
|
|
2403
|
+
size: {
|
|
2404
|
+
default: "h-9 px-4 py-2",
|
|
2405
|
+
sm: "h-8 rounded-md px-3 text-xs",
|
|
2406
|
+
lg: "h-10 rounded-md px-6",
|
|
2407
|
+
},
|
|
2408
|
+
},
|
|
2409
|
+
defaultVariants: {
|
|
2410
|
+
variant: "default",
|
|
2411
|
+
size: "default",
|
|
2412
|
+
},
|
|
2413
|
+
},
|
|
2414
|
+
);
|
|
2415
|
+
|
|
2416
|
+
export function Button({
|
|
2417
|
+
className,
|
|
2418
|
+
variant,
|
|
2419
|
+
size,
|
|
2420
|
+
...props
|
|
2421
|
+
}: ComponentProps<"button"> & VariantProps<typeof buttonVariants>) {
|
|
2422
|
+
return (
|
|
2423
|
+
<button className={cn(buttonVariants({ variant, size, className }))} {...props} />
|
|
2424
|
+
);
|
|
2425
|
+
}
|
|
2426
|
+
`;
|
|
2427
|
+
var RULE10 = `---
|
|
2428
|
+
description: shadcn/ui conventions
|
|
2429
|
+
globs: components.json,src/components/ui/**,src/lib/utils.ts
|
|
2430
|
+
alwaysApply: false
|
|
2431
|
+
---
|
|
2432
|
+
|
|
2433
|
+
# shadcn
|
|
2434
|
+
|
|
2435
|
+
- Primitives live flat in \`src/components/ui/*\` (CLI-compatible).
|
|
2436
|
+
- Starter \`Button\` is included \u2014 add more with \`npx shadcn@latest add \u2026\`.
|
|
2437
|
+
- \`cn()\` from \`@/lib/utils\` for class merging.
|
|
2438
|
+
- Config: \`components.json\`. Requires Tailwind.
|
|
2439
|
+
`;
|
|
2440
|
+
var shadcnInstaller = {
|
|
2441
|
+
id: "shadcn",
|
|
2442
|
+
active: (o) => o.ui === "shadcn" && o.styling === "tailwind",
|
|
2443
|
+
async run({ projectDir, options }) {
|
|
2444
|
+
const iconLibrary = options.icons === "tabler" ? "tabler" : "lucide";
|
|
2445
|
+
await fs20.writeFile(
|
|
2446
|
+
path21.join(projectDir, "components.json"),
|
|
2447
|
+
componentsJson(iconLibrary),
|
|
2448
|
+
"utf8"
|
|
2449
|
+
);
|
|
2450
|
+
await fs20.ensureDir(path21.join(projectDir, "src", "components", "ui"));
|
|
2451
|
+
await fs20.writeFile(
|
|
2452
|
+
path21.join(projectDir, "src", "components", "ui", "button.tsx"),
|
|
2453
|
+
BUTTON,
|
|
2454
|
+
"utf8"
|
|
2455
|
+
);
|
|
2456
|
+
await fs20.remove(path21.join(projectDir, "src", "components", "ui", ".gitkeep"));
|
|
2457
|
+
await fs20.remove(path21.join(projectDir, "src", "components", ".gitkeep"));
|
|
2458
|
+
await fs20.writeFile(
|
|
2459
|
+
path21.join(projectDir, ".cursor", "rules", "shadcn.mdc"),
|
|
2460
|
+
RULE10,
|
|
2461
|
+
"utf8"
|
|
2462
|
+
);
|
|
2463
|
+
if (options.icons === "none") {
|
|
2464
|
+
await addDependencies(projectDir, {
|
|
2465
|
+
"lucide-react": "^0.469.0"
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
await addDependencies(projectDir, {
|
|
2469
|
+
"class-variance-authority": "^0.7.1"
|
|
2470
|
+
});
|
|
2471
|
+
}
|
|
2472
|
+
};
|
|
2473
|
+
|
|
2474
|
+
// src/installers/store.ts
|
|
2475
|
+
import fs21 from "fs-extra";
|
|
2476
|
+
import path22 from "path";
|
|
2477
|
+
var ZUSTAND_STORE = `import { create } from "zustand";
|
|
2478
|
+
|
|
2479
|
+
type AppState = {
|
|
2480
|
+
hydrated: boolean;
|
|
2481
|
+
setHydrated: (value: boolean) => void;
|
|
2482
|
+
};
|
|
2483
|
+
|
|
2484
|
+
/** App-wide client store \u2014 extend slices as the product grows. */
|
|
2485
|
+
export const useAppStore = create<AppState>((set) => ({
|
|
2486
|
+
hydrated: false,
|
|
2487
|
+
setHydrated: (value) => set({ hydrated: value }),
|
|
2488
|
+
}));
|
|
2489
|
+
`;
|
|
2490
|
+
var ZUSTAND_RULE = `---
|
|
2491
|
+
description: Zustand client store conventions
|
|
2492
|
+
globs: src/stores/**
|
|
2493
|
+
alwaysApply: false
|
|
2494
|
+
---
|
|
2495
|
+
|
|
2496
|
+
# Zustand
|
|
2497
|
+
|
|
2498
|
+
- Stores live under \`src/stores/\` (one file per domain slice when possible).
|
|
2499
|
+
- Prefer small selectors: \`useAppStore((s) => s.hydrated)\`.
|
|
2500
|
+
- Do not put server secrets or tokens in the client store.
|
|
2501
|
+
- Server Components stay free of store hooks \u2014 pass data down or use a client leaf.
|
|
2502
|
+
`;
|
|
2503
|
+
var REDUX_STORE = `import { configureStore, createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
|
2504
|
+
|
|
2505
|
+
type AppSliceState = {
|
|
2506
|
+
hydrated: boolean;
|
|
2507
|
+
};
|
|
2508
|
+
|
|
2509
|
+
const initialState: AppSliceState = {
|
|
2510
|
+
hydrated: false,
|
|
2511
|
+
};
|
|
2512
|
+
|
|
2513
|
+
const appSlice = createSlice({
|
|
2514
|
+
name: "app",
|
|
2515
|
+
initialState,
|
|
2516
|
+
reducers: {
|
|
2517
|
+
setHydrated(state, action: PayloadAction<boolean>) {
|
|
2518
|
+
state.hydrated = action.payload;
|
|
2519
|
+
},
|
|
2520
|
+
},
|
|
2521
|
+
});
|
|
2522
|
+
|
|
2523
|
+
export const { setHydrated } = appSlice.actions;
|
|
2524
|
+
|
|
2525
|
+
export const makeStore = () =>
|
|
2526
|
+
configureStore({
|
|
2527
|
+
reducer: {
|
|
2528
|
+
app: appSlice.reducer,
|
|
2529
|
+
},
|
|
2530
|
+
});
|
|
2531
|
+
|
|
2532
|
+
export type AppStore = ReturnType<typeof makeStore>;
|
|
2533
|
+
export type RootState = ReturnType<AppStore["getState"]>;
|
|
2534
|
+
export type AppDispatch = AppStore["dispatch"];
|
|
2535
|
+
`;
|
|
2536
|
+
var REDUX_HOOKS = `import { useDispatch, useSelector, useStore } from "react-redux";
|
|
2537
|
+
|
|
2538
|
+
import type { AppDispatch, AppStore, RootState } from "@/stores/store";
|
|
2539
|
+
|
|
2540
|
+
export const useAppDispatch = useDispatch.withTypes<AppDispatch>();
|
|
2541
|
+
export const useAppSelector = useSelector.withTypes<RootState>();
|
|
2542
|
+
export const useAppStore = useStore.withTypes<AppStore>();
|
|
2543
|
+
`;
|
|
2544
|
+
var REDUX_PROVIDER = `"use client";
|
|
2545
|
+
|
|
2546
|
+
import { type ReactNode, useRef } from "react";
|
|
2547
|
+
import { Provider } from "react-redux";
|
|
2548
|
+
|
|
2549
|
+
import { makeStore, type AppStore } from "@/stores/store";
|
|
2550
|
+
|
|
2551
|
+
export function StoreProvider({ children }: { children: ReactNode }) {
|
|
2552
|
+
const storeRef = useRef<AppStore | null>(null);
|
|
2553
|
+
if (!storeRef.current) {
|
|
2554
|
+
storeRef.current = makeStore();
|
|
2555
|
+
}
|
|
2556
|
+
|
|
2557
|
+
return <Provider store={storeRef.current}>{children}</Provider>;
|
|
2558
|
+
}
|
|
2559
|
+
`;
|
|
2560
|
+
var REDUX_RULE = `---
|
|
2561
|
+
description: Redux Toolkit client store conventions
|
|
2562
|
+
globs: src/stores/**,src/providers/store-provider.tsx
|
|
2563
|
+
alwaysApply: false
|
|
2564
|
+
---
|
|
2565
|
+
|
|
2566
|
+
# Redux Toolkit
|
|
2567
|
+
|
|
2568
|
+
- Store factory: \`src/stores/store.ts\` (\`makeStore\`).
|
|
2569
|
+
- Typed hooks: \`src/stores/hooks.ts\` (\`useAppDispatch\` / \`useAppSelector\`).
|
|
2570
|
+
- Provider: \`src/providers/store-provider.tsx\` (composed via \`AppProviders\`).
|
|
2571
|
+
- Prefer RTK slices; keep server secrets out of the client store.
|
|
2572
|
+
`;
|
|
2573
|
+
var storeInstaller = {
|
|
2574
|
+
id: "store",
|
|
2575
|
+
active: (o) => o.store !== "none",
|
|
2576
|
+
async run({ projectDir, options }) {
|
|
2577
|
+
const storesDir = path22.join(projectDir, "src", "stores");
|
|
2578
|
+
await fs21.ensureDir(storesDir);
|
|
2579
|
+
if (options.store === "zustand") {
|
|
2580
|
+
await fs21.writeFile(
|
|
2581
|
+
path22.join(storesDir, "app-store.ts"),
|
|
2582
|
+
ZUSTAND_STORE,
|
|
2583
|
+
"utf8"
|
|
2584
|
+
);
|
|
2585
|
+
await fs21.writeFile(
|
|
2586
|
+
path22.join(projectDir, ".cursor", "rules", "store.mdc"),
|
|
2587
|
+
ZUSTAND_RULE,
|
|
2588
|
+
"utf8"
|
|
2589
|
+
);
|
|
2590
|
+
await fs21.outputFile(
|
|
2591
|
+
path22.join(projectDir, "docs", "style-guide", "state", "zustand.md"),
|
|
2592
|
+
`# Zustand
|
|
2593
|
+
|
|
2594
|
+
- Store: \`src/stores/app-store.ts\`
|
|
2595
|
+
- Extend with domain slices under \`src/stores/\`
|
|
2596
|
+
`
|
|
2597
|
+
);
|
|
2598
|
+
await addDependencies(projectDir, { zustand: "^5.0.3" });
|
|
2599
|
+
return;
|
|
2600
|
+
}
|
|
2601
|
+
await fs21.writeFile(path22.join(storesDir, "store.ts"), REDUX_STORE, "utf8");
|
|
2602
|
+
await fs21.writeFile(path22.join(storesDir, "hooks.ts"), REDUX_HOOKS, "utf8");
|
|
2603
|
+
const providersDir = path22.join(projectDir, "src", "providers");
|
|
2604
|
+
await fs21.ensureDir(providersDir);
|
|
2605
|
+
await fs21.remove(path22.join(providersDir, ".gitkeep"));
|
|
2606
|
+
await fs21.writeFile(
|
|
2607
|
+
path22.join(providersDir, "store-provider.tsx"),
|
|
2608
|
+
REDUX_PROVIDER,
|
|
2609
|
+
"utf8"
|
|
2610
|
+
);
|
|
2611
|
+
await fs21.writeFile(
|
|
2612
|
+
path22.join(projectDir, ".cursor", "rules", "store.mdc"),
|
|
2613
|
+
REDUX_RULE,
|
|
2614
|
+
"utf8"
|
|
2615
|
+
);
|
|
2616
|
+
await fs21.outputFile(
|
|
2617
|
+
path22.join(projectDir, "docs", "style-guide", "state", "redux.md"),
|
|
2618
|
+
`# Redux Toolkit
|
|
2619
|
+
|
|
2620
|
+
- Store: \`src/stores/store.ts\`
|
|
2621
|
+
- Hooks: \`src/stores/hooks.ts\`
|
|
2622
|
+
- Provider: \`src/providers/store-provider.tsx\` (via \`AppProviders\`)
|
|
2623
|
+
`
|
|
2624
|
+
);
|
|
2625
|
+
await addDependencies(projectDir, {
|
|
2626
|
+
"@reduxjs/toolkit": "^2.5.1",
|
|
2627
|
+
"react-redux": "^9.2.0"
|
|
2628
|
+
});
|
|
2629
|
+
}
|
|
2630
|
+
};
|
|
2631
|
+
|
|
2632
|
+
// src/installers/surface.ts
|
|
2633
|
+
import fs22 from "fs-extra";
|
|
2634
|
+
import path23 from "path";
|
|
2635
|
+
function stackLines(options) {
|
|
2636
|
+
const stylingLabel = options.styling === "tailwind" ? options.ui === "shadcn" ? "Tailwind + shadcn/ui" : "Tailwind CSS" : options.styling === "mui" ? "MUI" : "Plain CSS";
|
|
2637
|
+
return [
|
|
2638
|
+
`Package manager: ${options.packageManager}`,
|
|
2639
|
+
`Styling: ${stylingLabel}`,
|
|
2640
|
+
`Validation: ${options.validation}`,
|
|
2641
|
+
`Auth: ${options.auth}`,
|
|
2642
|
+
options.store === "none" ? "No client store" : `Client store: ${options.store}`,
|
|
2643
|
+
options.darkMode ? "Dark mode (next-themes)" : "No dark mode",
|
|
2644
|
+
options.seo ? "SEO helpers (sitemap/robots)" : "No SEO helpers",
|
|
2645
|
+
options.data === "tanstack-query" ? "TanStack Query enabled" : "RSC / fetch (no TanStack Query)",
|
|
2646
|
+
options.icons === "none" ? "No icon library" : `Icons: ${options.icons}`,
|
|
2647
|
+
options.endpoints ? `OpenAPI endpoints codegen (\`${options.packageManager} endpoints:generate\`)` : "No endpoints codegen",
|
|
2648
|
+
options.apiEnvelope ? "API envelope unwrap" : "No envelope unwrap",
|
|
2649
|
+
options.i18n ? "i18n (next-intl)" : "No i18n",
|
|
2650
|
+
options.testing ? options.playwright ? "Vitest + Playwright" : "Vitest" : "No tests",
|
|
2651
|
+
options.eslint ? "Enhanced ESLint" : "Minimal ESLint",
|
|
2652
|
+
options.prettier ? "Prettier" : "No Prettier",
|
|
2653
|
+
options.husky ? "Husky + lint-staged" : "No git hooks",
|
|
2654
|
+
options.docker ? "Docker standalone image" : "No Docker",
|
|
2655
|
+
options.ci ? "GitHub Actions CI" : "No CI workflow"
|
|
2656
|
+
];
|
|
2657
|
+
}
|
|
2658
|
+
function welcomeCardSource(options) {
|
|
2659
|
+
const items = stackLines(options).map((line) => ` <li>${escapeJsx(line)}</li>`).join("\n");
|
|
2660
|
+
return `/** Homepage sample \u2014 reflects scaffold choices; replace with real UI. */
|
|
2661
|
+
export function WelcomeCard() {
|
|
2662
|
+
return (
|
|
2663
|
+
<div className="card">
|
|
2664
|
+
<h2>Your scaffold</h2>
|
|
2665
|
+
<ul>
|
|
2666
|
+
${items}
|
|
2667
|
+
</ul>
|
|
2668
|
+
<p className="meta" style={{ marginTop: "1rem" }}>
|
|
2669
|
+
Route UI \u2192 <code>src/app/<route>/_components/</code> \xB7 shared \u2192{" "}
|
|
2670
|
+
<code>src/components/</code>
|
|
2671
|
+
</p>
|
|
2672
|
+
</div>
|
|
2673
|
+
);
|
|
2674
|
+
}
|
|
2675
|
+
`;
|
|
2676
|
+
}
|
|
2677
|
+
function escapeJsx(text2) {
|
|
2678
|
+
return text2.replaceAll("`", "\\`");
|
|
2679
|
+
}
|
|
2680
|
+
function homePageSource(options) {
|
|
2681
|
+
const uiBit = options.ui === "shadcn" ? " \xB7 ui=<code>shadcn</code>" : "";
|
|
2682
|
+
return `import { WelcomeCard } from "@/app/_components/welcome-card";
|
|
2683
|
+
|
|
2684
|
+
export default function HomePage() {
|
|
2685
|
+
return (
|
|
2686
|
+
<>
|
|
2687
|
+
<section className="hero">
|
|
2688
|
+
<h1>${options.name}</h1>
|
|
2689
|
+
<p>
|
|
2690
|
+
Scaffolded with <strong>next-style-guide</strong> (Next.js 16).
|
|
2691
|
+
styling=<code>${options.styling}</code>${uiBit} \xB7 validation=
|
|
2692
|
+
<code>${options.validation}</code> \xB7 icons=<code>${options.icons}</code> \xB7
|
|
2693
|
+
data=<code>${options.data}</code>.
|
|
2694
|
+
</p>
|
|
2695
|
+
</section>
|
|
2696
|
+
|
|
2697
|
+
<WelcomeCard />
|
|
2698
|
+
|
|
2699
|
+
<p className="meta">
|
|
2700
|
+
Read <code>AGENTS.md</code> and <code>docs/style-guide/</code> before
|
|
2701
|
+
adding routes. Feature rules in <code>.cursor/rules/</code> load by glob.
|
|
2702
|
+
</p>
|
|
2703
|
+
</>
|
|
2704
|
+
);
|
|
2705
|
+
}
|
|
2706
|
+
`;
|
|
2707
|
+
}
|
|
2708
|
+
function docsIndex(options) {
|
|
2709
|
+
const rows = [
|
|
2710
|
+
[
|
|
2711
|
+
"[Architecture](overview/architecture.md)",
|
|
2712
|
+
"Layers: app / components / lib / utils"
|
|
2713
|
+
],
|
|
2714
|
+
["[Project structure](overview/project-structure.md)", "Folder map"],
|
|
2715
|
+
["[HTTP client](http/client.md)", "`apiFetch` helper"],
|
|
2716
|
+
[
|
|
2717
|
+
`[Validation](validation/${options.validation}.md)`,
|
|
2718
|
+
options.validation.toUpperCase()
|
|
2719
|
+
]
|
|
2720
|
+
];
|
|
2721
|
+
if (options.auth === "jwt") {
|
|
2722
|
+
rows.push(["[JWT auth](auth/jwt.md)", "token-storage + Bearer refresh"]);
|
|
2723
|
+
}
|
|
2724
|
+
if (options.auth === "authjs") {
|
|
2725
|
+
rows.push(["[Auth.js](auth/authjs.md)", "NextAuth stub"]);
|
|
2726
|
+
}
|
|
2727
|
+
if (options.store === "zustand") {
|
|
2728
|
+
rows.push(["[Zustand](state/zustand.md)", "client store"]);
|
|
2729
|
+
}
|
|
2730
|
+
if (options.store === "redux") {
|
|
2731
|
+
rows.push(["[Redux Toolkit](state/redux.md)", "client store"]);
|
|
2732
|
+
}
|
|
2733
|
+
if (options.darkMode) {
|
|
2734
|
+
rows.push(["[Dark mode](ui/dark-mode.md)", "next-themes"]);
|
|
2735
|
+
}
|
|
2736
|
+
if (options.auth === "jwt" && options.endpoints) {
|
|
2737
|
+
rows.push([
|
|
2738
|
+
"[Docker Compose](ops/docker-compose.md)",
|
|
2739
|
+
"local API stub"
|
|
2740
|
+
]);
|
|
2741
|
+
}
|
|
2742
|
+
if (options.seo) {
|
|
2743
|
+
rows.push(["[SEO](seo/overview.md)", "public-routes \xB7 sitemap \xB7 robots"]);
|
|
2744
|
+
}
|
|
2745
|
+
if (options.i18n) {
|
|
2746
|
+
rows.push(["[i18n](i18n/overview.md)", "next-intl"]);
|
|
2747
|
+
}
|
|
2748
|
+
if (options.apiEnvelope) {
|
|
2749
|
+
rows.push(["[API envelope](http/envelope.md)", "unwrap result"]);
|
|
2750
|
+
}
|
|
2751
|
+
if (options.testing) {
|
|
2752
|
+
rows.push(["[Testing](tooling/testing.md)", "Vitest"]);
|
|
2753
|
+
}
|
|
2754
|
+
if (options.styling === "tailwind") {
|
|
2755
|
+
rows.push(["Tailwind", "See `.cursor/rules/tailwind.mdc`"]);
|
|
2756
|
+
}
|
|
2757
|
+
if (options.ui === "shadcn") {
|
|
2758
|
+
rows.push(["shadcn/ui", "See `.cursor/rules/shadcn.mdc` + `components.json`"]);
|
|
2759
|
+
}
|
|
2760
|
+
if (options.styling === "mui") {
|
|
2761
|
+
rows.push(["MUI", "See `.cursor/rules/mui.mdc`"]);
|
|
2762
|
+
}
|
|
2763
|
+
if (options.endpoints) {
|
|
2764
|
+
rows.push([
|
|
2765
|
+
"[OpenAPI endpoints](http/endpoints.md)",
|
|
2766
|
+
`\`${options.packageManager} endpoints:generate\``
|
|
2767
|
+
]);
|
|
2768
|
+
}
|
|
2769
|
+
if (options.data === "tanstack-query") {
|
|
2770
|
+
rows.push([
|
|
2771
|
+
"[TanStack Query](data/tanstack-query.md)",
|
|
2772
|
+
"Client cache + QueryProvider"
|
|
2773
|
+
]);
|
|
2774
|
+
}
|
|
2775
|
+
if (options.icons !== "none") {
|
|
2776
|
+
rows.push(["[Icons](ui/icons.md)", `Icon library (${options.icons})`]);
|
|
2777
|
+
}
|
|
2778
|
+
if (options.eslint) {
|
|
2779
|
+
rows.push(["[ESLint](tooling/eslint.md)", "Import sort + style rules"]);
|
|
2780
|
+
}
|
|
2781
|
+
if (options.prettier) {
|
|
2782
|
+
rows.push(["[Prettier](tooling/prettier.md)", "Formatting"]);
|
|
2783
|
+
}
|
|
2784
|
+
if (options.husky) {
|
|
2785
|
+
rows.push(["[Husky](tooling/husky.md)", "Pre-commit hooks"]);
|
|
2786
|
+
}
|
|
2787
|
+
if (options.docker) {
|
|
2788
|
+
rows.push(["[Docker](ops/docker.md)", "Standalone production image"]);
|
|
2789
|
+
}
|
|
2790
|
+
if (options.ci) {
|
|
2791
|
+
rows.push(["[CI](ops/ci.md)", "GitHub Actions"]);
|
|
2792
|
+
}
|
|
2793
|
+
const table = [
|
|
2794
|
+
"| Page | Topic |",
|
|
2795
|
+
"|------|--------|",
|
|
2796
|
+
...rows.map(([page, topic]) => `| ${page} | ${topic} |`)
|
|
2797
|
+
].join("\n");
|
|
2798
|
+
return `# Style guide \u2014 ${options.name}
|
|
2799
|
+
|
|
2800
|
+
Living conventions for this app. Open one page at a time.
|
|
2801
|
+
|
|
2802
|
+
${table}
|
|
2803
|
+
|
|
2804
|
+
Coding agents: start at [\`AGENTS.md\`](../../AGENTS.md) \u2192 [\`.cursor/rules/\`](../../.cursor/rules/) (glob-scoped).
|
|
2805
|
+
`;
|
|
2806
|
+
}
|
|
2807
|
+
var surfaceInstaller = {
|
|
2808
|
+
id: "surface",
|
|
2809
|
+
active: () => true,
|
|
2810
|
+
async run({ projectDir, options }) {
|
|
2811
|
+
await fs22.writeFile(
|
|
2812
|
+
path23.join(
|
|
2813
|
+
projectDir,
|
|
2814
|
+
"src",
|
|
2815
|
+
"app",
|
|
2816
|
+
"_components",
|
|
2817
|
+
"welcome-card",
|
|
2818
|
+
"index.tsx"
|
|
2819
|
+
),
|
|
2820
|
+
welcomeCardSource(options),
|
|
2821
|
+
"utf8"
|
|
2822
|
+
);
|
|
2823
|
+
await fs22.writeFile(
|
|
2824
|
+
path23.join(projectDir, "src", "app", "page.tsx"),
|
|
2825
|
+
homePageSource(options),
|
|
2826
|
+
"utf8"
|
|
2827
|
+
);
|
|
2828
|
+
if (options.data === "tanstack-query") {
|
|
2829
|
+
await fs22.copy(
|
|
2830
|
+
path23.join(ADDONS_DIR, "docs", "data", "tanstack-query.md"),
|
|
2831
|
+
path23.join(
|
|
2832
|
+
projectDir,
|
|
2833
|
+
"docs",
|
|
2834
|
+
"style-guide",
|
|
2835
|
+
"data",
|
|
2836
|
+
"tanstack-query.md"
|
|
2837
|
+
)
|
|
2838
|
+
);
|
|
2839
|
+
}
|
|
2840
|
+
if (options.icons !== "none") {
|
|
2841
|
+
await fs22.copy(
|
|
2842
|
+
path23.join(ADDONS_DIR, "docs", "ui", "icons.md"),
|
|
2843
|
+
path23.join(projectDir, "docs", "style-guide", "ui", "icons.md")
|
|
2844
|
+
);
|
|
2845
|
+
}
|
|
2846
|
+
if (options.auth === "jwt" && options.endpoints) {
|
|
2847
|
+
const exampleSrc = path23.join(
|
|
2848
|
+
ADDONS_DIR,
|
|
2849
|
+
"docs",
|
|
2850
|
+
"examples",
|
|
2851
|
+
"api-app-crud.md"
|
|
2852
|
+
);
|
|
2853
|
+
if (await fs22.pathExists(exampleSrc)) {
|
|
2854
|
+
await fs22.copy(
|
|
2855
|
+
exampleSrc,
|
|
2856
|
+
path23.join(projectDir, "docs", "examples", "api-app-crud.md")
|
|
2857
|
+
);
|
|
2858
|
+
}
|
|
2859
|
+
}
|
|
2860
|
+
await fs22.writeFile(
|
|
2861
|
+
path23.join(projectDir, "docs", "style-guide", "README.md"),
|
|
2862
|
+
docsIndex(options),
|
|
2863
|
+
"utf8"
|
|
2864
|
+
);
|
|
2865
|
+
}
|
|
2866
|
+
};
|
|
2867
|
+
|
|
2868
|
+
// src/installers/tailwind.ts
|
|
2869
|
+
import fs23 from "fs-extra";
|
|
2870
|
+
import path24 from "path";
|
|
2871
|
+
var POSTCSS = `const config = {
|
|
2872
|
+
plugins: {
|
|
2873
|
+
"@tailwindcss/postcss": {},
|
|
2874
|
+
},
|
|
2875
|
+
};
|
|
2876
|
+
|
|
2877
|
+
export default config;
|
|
2878
|
+
`;
|
|
2879
|
+
var GLOBALS = `@import "tailwindcss";
|
|
2880
|
+
|
|
2881
|
+
:root {
|
|
2882
|
+
color-scheme: light;
|
|
2883
|
+
--background: #f6f4ef;
|
|
2884
|
+
--foreground: #14201b;
|
|
2885
|
+
--muted: #5b6b63;
|
|
2886
|
+
--accent: #0b5fff;
|
|
2887
|
+
--card: #ffffff;
|
|
2888
|
+
--border: #d9e0db;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
@theme inline {
|
|
2892
|
+
--color-background: var(--background);
|
|
2893
|
+
--color-foreground: var(--foreground);
|
|
2894
|
+
--color-muted: var(--muted);
|
|
2895
|
+
--color-accent: var(--accent);
|
|
2896
|
+
--color-card: var(--card);
|
|
2897
|
+
--color-border: var(--border);
|
|
2898
|
+
}
|
|
2899
|
+
|
|
2900
|
+
* {
|
|
2901
|
+
box-sizing: border-box;
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
html,
|
|
2905
|
+
body {
|
|
2906
|
+
margin: 0;
|
|
2907
|
+
min-height: 100%;
|
|
2908
|
+
background: radial-gradient(circle at top left, #eef3ff, transparent 40%),
|
|
2909
|
+
var(--background);
|
|
2910
|
+
color: var(--foreground);
|
|
2911
|
+
font-family: ui-sans-serif, system-ui, sans-serif;
|
|
2912
|
+
}
|
|
2913
|
+
|
|
2914
|
+
a {
|
|
2915
|
+
color: var(--accent);
|
|
2916
|
+
text-decoration: none;
|
|
2917
|
+
}
|
|
2918
|
+
|
|
2919
|
+
a:hover {
|
|
2920
|
+
text-decoration: underline;
|
|
2921
|
+
}
|
|
2922
|
+
|
|
2923
|
+
main {
|
|
2924
|
+
width: min(720px, calc(100% - 2rem));
|
|
2925
|
+
margin: 0 auto;
|
|
2926
|
+
padding: 4rem 0 3rem;
|
|
2927
|
+
}
|
|
2928
|
+
|
|
2929
|
+
.hero {
|
|
2930
|
+
display: grid;
|
|
2931
|
+
gap: 1rem;
|
|
2932
|
+
}
|
|
2933
|
+
|
|
2934
|
+
.hero h1 {
|
|
2935
|
+
margin: 0;
|
|
2936
|
+
font-size: clamp(2rem, 5vw, 3rem);
|
|
2937
|
+
letter-spacing: -0.03em;
|
|
2938
|
+
}
|
|
2939
|
+
|
|
2940
|
+
.hero p {
|
|
2941
|
+
margin: 0;
|
|
2942
|
+
color: var(--muted);
|
|
2943
|
+
line-height: 1.6;
|
|
2944
|
+
max-width: 40rem;
|
|
2945
|
+
}
|
|
2946
|
+
|
|
2947
|
+
.card {
|
|
2948
|
+
margin-top: 2rem;
|
|
2949
|
+
padding: 1.25rem 1.5rem;
|
|
2950
|
+
background: var(--card);
|
|
2951
|
+
border: 1px solid var(--border);
|
|
2952
|
+
border-radius: 12px;
|
|
2953
|
+
}
|
|
2954
|
+
|
|
2955
|
+
.card h2 {
|
|
2956
|
+
margin: 0 0 0.5rem;
|
|
2957
|
+
font-size: 1rem;
|
|
2958
|
+
}
|
|
2959
|
+
|
|
2960
|
+
.card ul {
|
|
2961
|
+
margin: 0;
|
|
2962
|
+
padding-left: 1.1rem;
|
|
2963
|
+
color: var(--muted);
|
|
2964
|
+
line-height: 1.7;
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
.meta {
|
|
2968
|
+
margin-top: 2rem;
|
|
2969
|
+
font-size: 0.875rem;
|
|
2970
|
+
color: var(--muted);
|
|
2971
|
+
}
|
|
2972
|
+
`;
|
|
2973
|
+
var UTILS = `import { type ClassValue, clsx } from "clsx";
|
|
2974
|
+
import { twMerge } from "tailwind-merge";
|
|
2975
|
+
|
|
2976
|
+
export function cn(...inputs: ClassValue[]) {
|
|
2977
|
+
return twMerge(clsx(inputs));
|
|
2978
|
+
}
|
|
2979
|
+
`;
|
|
2980
|
+
var RULE11 = `---
|
|
2981
|
+
description: Tailwind CSS v4 conventions
|
|
2982
|
+
globs: src/app/globals.css,src/**/*.{tsx,css},postcss.config.mjs
|
|
2983
|
+
alwaysApply: false
|
|
2984
|
+
---
|
|
2985
|
+
|
|
2986
|
+
# Tailwind
|
|
2987
|
+
|
|
2988
|
+
- Tailwind v4 via \`@import "tailwindcss"\` in \`globals.css\` + \`@tailwindcss/postcss\`.
|
|
2989
|
+
- Prefer utility classes; keep semantic CSS variables in \`:root\` / \`@theme\`.
|
|
2990
|
+
- Class merging: \`cn()\` from \`@/lib/utils\` (clsx + tailwind-merge).
|
|
2991
|
+
`;
|
|
2992
|
+
var tailwindInstaller = {
|
|
2993
|
+
id: "tailwind",
|
|
2994
|
+
active: (o) => o.styling === "tailwind",
|
|
2995
|
+
async run({ projectDir }) {
|
|
2996
|
+
await fs23.writeFile(
|
|
2997
|
+
path24.join(projectDir, "postcss.config.mjs"),
|
|
2998
|
+
POSTCSS,
|
|
2999
|
+
"utf8"
|
|
3000
|
+
);
|
|
3001
|
+
await fs23.writeFile(
|
|
3002
|
+
path24.join(projectDir, "src", "app", "globals.css"),
|
|
3003
|
+
GLOBALS,
|
|
3004
|
+
"utf8"
|
|
3005
|
+
);
|
|
3006
|
+
await fs23.writeFile(
|
|
3007
|
+
path24.join(projectDir, "src", "lib", "utils.ts"),
|
|
3008
|
+
UTILS,
|
|
3009
|
+
"utf8"
|
|
3010
|
+
);
|
|
3011
|
+
await fs23.writeFile(
|
|
3012
|
+
path24.join(projectDir, ".cursor", "rules", "tailwind.mdc"),
|
|
3013
|
+
RULE11,
|
|
3014
|
+
"utf8"
|
|
3015
|
+
);
|
|
3016
|
+
await addDependencies(projectDir, {
|
|
3017
|
+
clsx: "^2.1.1",
|
|
3018
|
+
"tailwind-merge": "^3.6.0"
|
|
3019
|
+
});
|
|
3020
|
+
await addDependencies(
|
|
3021
|
+
projectDir,
|
|
3022
|
+
{
|
|
3023
|
+
tailwindcss: "^4",
|
|
3024
|
+
"@tailwindcss/postcss": "^4"
|
|
3025
|
+
},
|
|
3026
|
+
"devDependencies"
|
|
3027
|
+
);
|
|
3028
|
+
}
|
|
3029
|
+
};
|
|
3030
|
+
|
|
3031
|
+
// src/installers/tanstack-query.ts
|
|
3032
|
+
import fs24 from "fs-extra";
|
|
3033
|
+
import path25 from "path";
|
|
3034
|
+
var PROVIDER = `"use client";
|
|
3035
|
+
|
|
3036
|
+
import { type ReactNode, useState } from "react";
|
|
3037
|
+
|
|
3038
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3039
|
+
|
|
3040
|
+
function createQueryClient() {
|
|
3041
|
+
return new QueryClient({
|
|
3042
|
+
defaultOptions: {
|
|
3043
|
+
queries: {
|
|
3044
|
+
staleTime: 60_000,
|
|
3045
|
+
refetchOnWindowFocus: false,
|
|
3046
|
+
retry: 1,
|
|
3047
|
+
},
|
|
3048
|
+
mutations: {
|
|
3049
|
+
retry: false,
|
|
3050
|
+
},
|
|
3051
|
+
},
|
|
3052
|
+
});
|
|
3053
|
+
}
|
|
3054
|
+
|
|
3055
|
+
export function QueryProvider({ children }: { children: ReactNode }) {
|
|
3056
|
+
const [client] = useState(createQueryClient);
|
|
3057
|
+
|
|
3058
|
+
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
|
|
3059
|
+
}
|
|
3060
|
+
`;
|
|
3061
|
+
var RULE12 = `---
|
|
3062
|
+
description: TanStack Query conventions
|
|
3063
|
+
globs: src/providers/**,src/hooks/**,src/endpoints/**,src/**/*query*.{ts,tsx},src/**/use-*.{ts,tsx}
|
|
3064
|
+
alwaysApply: false
|
|
3065
|
+
---
|
|
3066
|
+
|
|
3067
|
+
# TanStack Query
|
|
3068
|
+
|
|
3069
|
+
- \`QueryProvider\` (\`src/providers/query-provider.tsx\`) is composed via \`AppProviders\`.
|
|
3070
|
+
- Client data: \`useQuery\` / \`useMutation\`; keep RSC for server-friendly fetches.
|
|
3071
|
+
- Query keys: use generated \`get<EndpointName>QueryKey(...)\` when endpoints codegen is on \u2014 never ad-hoc \`["resource"]\` strings.
|
|
3072
|
+
- Default hook \`params\` to the matching \`*DefaultParams\` when present.
|
|
3073
|
+
- Errors: \`getApiErrorMessage\` / field helpers from \`@/utils/api-error\`.
|
|
3074
|
+
`;
|
|
3075
|
+
var tanstackQueryInstaller = {
|
|
3076
|
+
id: "tanstack-query",
|
|
3077
|
+
active: (o) => o.data === "tanstack-query",
|
|
3078
|
+
async run({ projectDir }) {
|
|
3079
|
+
await addDependencies(projectDir, {
|
|
3080
|
+
"@tanstack/react-query": "^5.62.8"
|
|
3081
|
+
});
|
|
3082
|
+
const providersDir = path25.join(projectDir, "src", "providers");
|
|
3083
|
+
await fs24.ensureDir(providersDir);
|
|
3084
|
+
await fs24.remove(path25.join(providersDir, ".gitkeep"));
|
|
3085
|
+
await fs24.writeFile(
|
|
3086
|
+
path25.join(providersDir, "query-provider.tsx"),
|
|
3087
|
+
PROVIDER,
|
|
3088
|
+
"utf8"
|
|
3089
|
+
);
|
|
3090
|
+
const ruleDir = path25.join(projectDir, ".cursor", "rules");
|
|
3091
|
+
await fs24.ensureDir(ruleDir);
|
|
3092
|
+
await fs24.writeFile(
|
|
3093
|
+
path25.join(ruleDir, "tanstack-query.mdc"),
|
|
3094
|
+
RULE12,
|
|
3095
|
+
"utf8"
|
|
3096
|
+
);
|
|
3097
|
+
}
|
|
3098
|
+
};
|
|
3099
|
+
|
|
3100
|
+
// src/installers/testing.ts
|
|
3101
|
+
import fs25 from "fs-extra";
|
|
3102
|
+
import path26 from "path";
|
|
3103
|
+
var VITEST_CONFIG = `import react from "@vitejs/plugin-react";
|
|
3104
|
+
import { resolve } from "node:path";
|
|
3105
|
+
import { defineConfig } from "vitest/config";
|
|
3106
|
+
|
|
3107
|
+
export default defineConfig({
|
|
3108
|
+
plugins: [react()],
|
|
3109
|
+
test: {
|
|
3110
|
+
environment: "jsdom",
|
|
3111
|
+
globals: true,
|
|
3112
|
+
include: ["tests/**/*.test.{ts,tsx}", "src/**/*.test.{ts,tsx}"],
|
|
3113
|
+
},
|
|
3114
|
+
resolve: {
|
|
3115
|
+
alias: {
|
|
3116
|
+
"@": resolve(import.meta.dirname, "./src"),
|
|
3117
|
+
},
|
|
3118
|
+
},
|
|
3119
|
+
});
|
|
3120
|
+
`;
|
|
3121
|
+
var SAMPLE_TEST = `import { renderToStaticMarkup } from "react-dom/server";
|
|
3122
|
+
import { describe, expect, it } from "vitest";
|
|
3123
|
+
|
|
3124
|
+
import { WelcomeCard } from "@/app/_components/welcome-card";
|
|
3125
|
+
|
|
3126
|
+
describe("WelcomeCard", () => {
|
|
3127
|
+
it("renders scaffold heading", () => {
|
|
3128
|
+
const html = renderToStaticMarkup(<WelcomeCard />);
|
|
3129
|
+
expect(html).toContain("Your scaffold");
|
|
3130
|
+
});
|
|
3131
|
+
});
|
|
3132
|
+
`;
|
|
3133
|
+
function playwrightConfig(devCommand) {
|
|
3134
|
+
return `import { defineConfig, devices } from "@playwright/test";
|
|
3135
|
+
|
|
3136
|
+
export default defineConfig({
|
|
3137
|
+
testDir: "./e2e",
|
|
3138
|
+
fullyParallel: true,
|
|
3139
|
+
forbidOnly: !!process.env.CI,
|
|
3140
|
+
retries: process.env.CI ? 1 : 0,
|
|
3141
|
+
use: {
|
|
3142
|
+
baseURL: "http://127.0.0.1:3000",
|
|
3143
|
+
trace: "on-first-retry",
|
|
3144
|
+
},
|
|
3145
|
+
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
|
3146
|
+
webServer: {
|
|
3147
|
+
command: "${devCommand}",
|
|
3148
|
+
url: "http://127.0.0.1:3000",
|
|
3149
|
+
reuseExistingServer: !process.env.CI,
|
|
3150
|
+
},
|
|
3151
|
+
});
|
|
3152
|
+
`;
|
|
3153
|
+
}
|
|
3154
|
+
var E2E_SMOKE = `import { expect, test } from "@playwright/test";
|
|
3155
|
+
|
|
3156
|
+
test("home loads", async ({ page }) => {
|
|
3157
|
+
await page.goto("/");
|
|
3158
|
+
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
|
3159
|
+
});
|
|
3160
|
+
`;
|
|
3161
|
+
var testingInstaller = {
|
|
3162
|
+
id: "testing",
|
|
3163
|
+
active: (o) => o.testing,
|
|
3164
|
+
async run({ projectDir, options }) {
|
|
3165
|
+
const pm = getPackageManager(options.packageManager);
|
|
3166
|
+
const testCmd = pm.runScript("test");
|
|
3167
|
+
const e2eCmd = pm.runScript("test:e2e");
|
|
3168
|
+
const rule = `---
|
|
3169
|
+
description: Vitest (and optional Playwright) conventions
|
|
3170
|
+
globs: "**/*.test.{ts,tsx}",vitest.config.*,e2e/**,playwright.config.*
|
|
3171
|
+
alwaysApply: false
|
|
3172
|
+
---
|
|
3173
|
+
|
|
3174
|
+
# Testing
|
|
3175
|
+
|
|
3176
|
+
- Vitest: colocated \`<name>.test.tsx\` next to components; also \`tests/**\`.
|
|
3177
|
+
- Prefer \`renderToStaticMarkup\` for simple server component smoke.
|
|
3178
|
+
- Run \`${testCmd}\`. Playwright (if enabled): \`${e2eCmd}\`.
|
|
3179
|
+
`;
|
|
3180
|
+
await fs25.writeFile(
|
|
3181
|
+
path26.join(projectDir, "vitest.config.mts"),
|
|
3182
|
+
VITEST_CONFIG,
|
|
3183
|
+
"utf8"
|
|
3184
|
+
);
|
|
3185
|
+
await fs25.outputFile(
|
|
3186
|
+
path26.join(
|
|
3187
|
+
projectDir,
|
|
3188
|
+
"src",
|
|
3189
|
+
"app",
|
|
3190
|
+
"_components",
|
|
3191
|
+
"welcome-card",
|
|
3192
|
+
"welcome-card.test.tsx"
|
|
3193
|
+
),
|
|
3194
|
+
SAMPLE_TEST
|
|
3195
|
+
);
|
|
3196
|
+
await fs25.writeFile(
|
|
3197
|
+
path26.join(projectDir, ".cursor", "rules", "testing.mdc"),
|
|
3198
|
+
rule,
|
|
3199
|
+
"utf8"
|
|
3200
|
+
);
|
|
3201
|
+
await fs25.outputFile(
|
|
3202
|
+
path26.join(projectDir, "docs", "style-guide", "tooling", "testing.md"),
|
|
3203
|
+
`# Testing
|
|
3204
|
+
|
|
3205
|
+
Vitest: \`${testCmd}\`
|
|
3206
|
+
Config: \`vitest.config.mts\`
|
|
3207
|
+
Colocate \`*.test.tsx\` with components under \`src/\`.
|
|
3208
|
+
${options.playwright ? `
|
|
3209
|
+
Playwright: \`${e2eCmd}\` \xB7 config \`playwright.config.ts\` \xB7 specs in \`e2e/\`.
|
|
3210
|
+
` : ""}
|
|
3211
|
+
`
|
|
3212
|
+
);
|
|
3213
|
+
await addDependencies(
|
|
3214
|
+
projectDir,
|
|
3215
|
+
{
|
|
3216
|
+
vitest: "^3.0.5",
|
|
3217
|
+
"@vitejs/plugin-react": "^4.3.4",
|
|
3218
|
+
jsdom: "^26.0.0"
|
|
3219
|
+
},
|
|
3220
|
+
"devDependencies"
|
|
3221
|
+
);
|
|
3222
|
+
await addScripts(projectDir, {
|
|
3223
|
+
test: "vitest run",
|
|
3224
|
+
"test:watch": "vitest"
|
|
3225
|
+
});
|
|
3226
|
+
if (options.playwright) {
|
|
3227
|
+
await fs25.writeFile(
|
|
3228
|
+
path26.join(projectDir, "playwright.config.ts"),
|
|
3229
|
+
playwrightConfig(pm.runScript("dev")),
|
|
3230
|
+
"utf8"
|
|
3231
|
+
);
|
|
3232
|
+
await fs25.outputFile(path26.join(projectDir, "e2e", "home.spec.ts"), E2E_SMOKE);
|
|
3233
|
+
await addDependencies(
|
|
3234
|
+
projectDir,
|
|
3235
|
+
{ "@playwright/test": "^1.50.1" },
|
|
3236
|
+
"devDependencies"
|
|
3237
|
+
);
|
|
3238
|
+
await addScripts(projectDir, {
|
|
3239
|
+
"test:e2e": "playwright test"
|
|
3240
|
+
});
|
|
3241
|
+
}
|
|
3242
|
+
}
|
|
3243
|
+
};
|
|
3244
|
+
|
|
3245
|
+
// src/installers/validation.ts
|
|
3246
|
+
import fs26 from "fs-extra";
|
|
3247
|
+
import path27 from "path";
|
|
3248
|
+
var ENV_ZOD = `import { z } from "zod";
|
|
3249
|
+
|
|
3250
|
+
/**
|
|
3251
|
+
* Validated public env. Import from Client Components only for NEXT_PUBLIC_*.
|
|
3252
|
+
* Server-only secrets stay out of this schema until needed.
|
|
3253
|
+
*/
|
|
3254
|
+
const publicEnvSchema = z.object({
|
|
3255
|
+
NEXT_PUBLIC_APP_URL: z.string().url().default("http://localhost:3000"),
|
|
3256
|
+
NEXT_PUBLIC_API_URL: z.string().url().default("http://localhost:8000"),
|
|
3257
|
+
});
|
|
3258
|
+
|
|
3259
|
+
export type PublicEnv = z.infer<typeof publicEnvSchema>;
|
|
3260
|
+
|
|
3261
|
+
export const env: PublicEnv = publicEnvSchema.parse({
|
|
3262
|
+
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
|
3263
|
+
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
|
3264
|
+
});
|
|
3265
|
+
`;
|
|
3266
|
+
var ENV_YUP = `import * as yup from "yup";
|
|
3267
|
+
|
|
3268
|
+
/**
|
|
3269
|
+
* Validated public env. Import from Client Components only for NEXT_PUBLIC_*.
|
|
3270
|
+
* Server-only secrets stay out of this schema until needed.
|
|
3271
|
+
*/
|
|
3272
|
+
const publicEnvSchema = yup.object({
|
|
3273
|
+
NEXT_PUBLIC_APP_URL: yup
|
|
3274
|
+
.string()
|
|
3275
|
+
.url()
|
|
3276
|
+
.default("http://localhost:3000"),
|
|
3277
|
+
NEXT_PUBLIC_API_URL: yup
|
|
3278
|
+
.string()
|
|
3279
|
+
.url()
|
|
3280
|
+
.default("http://localhost:8000"),
|
|
3281
|
+
});
|
|
3282
|
+
|
|
3283
|
+
export type PublicEnv = yup.InferType<typeof publicEnvSchema>;
|
|
3284
|
+
|
|
3285
|
+
export const env: PublicEnv = publicEnvSchema.validateSync({
|
|
3286
|
+
NEXT_PUBLIC_APP_URL: process.env.NEXT_PUBLIC_APP_URL,
|
|
3287
|
+
NEXT_PUBLIC_API_URL: process.env.NEXT_PUBLIC_API_URL,
|
|
3288
|
+
});
|
|
3289
|
+
`;
|
|
3290
|
+
function formsRule(validation) {
|
|
3291
|
+
const schemaLine = validation === "zod" ? "- RHF + `Controller` + Zod `zodResolver`. Schema `satisfies z.ZodType<*Body>` when endpoints codegen exists; field names = API keys." : "- RHF + `Controller` + Yup `yupResolver`. Schemas in `src/lib/validations/`; field names = API keys when endpoints codegen exists.";
|
|
3292
|
+
return `---
|
|
3293
|
+
description: RHF + ${validation === "zod" ? "Zod" : "Yup"} forms aligned to API body types
|
|
3294
|
+
globs: src/**/*form*/**,src/**/*-form*/**,src/**/*.form.tsx,src/components/forms/**,src/lib/**/*form*.ts,src/lib/validations/**
|
|
3295
|
+
alwaysApply: false
|
|
3296
|
+
---
|
|
3297
|
+
|
|
3298
|
+
# Forms
|
|
3299
|
+
|
|
3300
|
+
- \`"use client"\` + named exports. \`page.tsx\` stays server when metadata-only.
|
|
3301
|
+
- Placement: shared \u2192 \`src/components/forms/<name>/\`; one route \u2192 \`src/app/<route>/_components/forms/<name>/\`.
|
|
3302
|
+
${schemaLine}
|
|
3303
|
+
- Reuse shared/UI controls \u2014 no ad-hoc inputs.
|
|
3304
|
+
- Required: derive from schema / \`*Body\`; use \`required\` on Field/Label (no handmade \`*\` text).
|
|
3305
|
+
- Invalid: \`data-invalid\`, \`aria-invalid\`, \`FieldError\`.
|
|
3306
|
+
- Server 400 envelope: \`getApiErrorMessage\` / \`extractApiFieldErrors\` (\`@/utils/api-error\`); map to \`setError\` when using RHF.
|
|
3307
|
+
- Never duplicate the same form state/submit logic across pages.
|
|
3308
|
+
`;
|
|
3309
|
+
}
|
|
3310
|
+
function validationRule(validation) {
|
|
3311
|
+
return `---
|
|
3312
|
+
description: ${validation === "zod" ? "Zod" : "Yup"} validation conventions
|
|
3313
|
+
globs: src/lib/env.ts,src/lib/validations/**,src/**/*schema*.{ts,tsx}
|
|
3314
|
+
alwaysApply: false
|
|
3315
|
+
---
|
|
3316
|
+
|
|
3317
|
+
# Validation (${validation})
|
|
3318
|
+
|
|
3319
|
+
- Public env: \`src/lib/env.ts\` (${validation} schema).
|
|
3320
|
+
- Domain schemas: \`src/lib/validations/<name>.ts\`.
|
|
3321
|
+
- Forms: use \`${validation === "zod" ? "zodResolver" : "yupResolver"}\` from \`@hookform/resolvers\`.
|
|
3322
|
+
- Prefer one library \u2014 do not mix Zod and Yup in the same app.
|
|
3323
|
+
`;
|
|
3324
|
+
}
|
|
3325
|
+
var validationInstaller = {
|
|
3326
|
+
id: "validation",
|
|
3327
|
+
active: () => true,
|
|
3328
|
+
async run({ projectDir, options }) {
|
|
3329
|
+
const validationsDir = path27.join(projectDir, "src", "lib", "validations");
|
|
3330
|
+
await fs26.ensureDir(validationsDir);
|
|
3331
|
+
await fs26.writeFile(path27.join(validationsDir, ".gitkeep"), "", "utf8");
|
|
3332
|
+
if (options.validation === "yup") {
|
|
3333
|
+
await fs26.writeFile(
|
|
3334
|
+
path27.join(projectDir, "src", "lib", "env.ts"),
|
|
3335
|
+
ENV_YUP,
|
|
3336
|
+
"utf8"
|
|
3337
|
+
);
|
|
3338
|
+
await removeDependencies(projectDir, ["zod"]);
|
|
3339
|
+
await addDependencies(projectDir, { yup: "^1.6.1" });
|
|
3340
|
+
} else {
|
|
3341
|
+
await fs26.writeFile(
|
|
3342
|
+
path27.join(projectDir, "src", "lib", "env.ts"),
|
|
3343
|
+
ENV_ZOD,
|
|
3344
|
+
"utf8"
|
|
3345
|
+
);
|
|
3346
|
+
await addDependencies(projectDir, { zod: "^3.24.1" });
|
|
3347
|
+
}
|
|
3348
|
+
await fs26.writeFile(
|
|
3349
|
+
path27.join(projectDir, ".cursor", "rules", "forms.mdc"),
|
|
3350
|
+
formsRule(options.validation),
|
|
3351
|
+
"utf8"
|
|
3352
|
+
);
|
|
3353
|
+
await fs26.writeFile(
|
|
3354
|
+
path27.join(projectDir, ".cursor", "rules", "validation.mdc"),
|
|
3355
|
+
validationRule(options.validation),
|
|
3356
|
+
"utf8"
|
|
3357
|
+
);
|
|
3358
|
+
await fs26.outputFile(
|
|
3359
|
+
path27.join(
|
|
3360
|
+
projectDir,
|
|
3361
|
+
"docs",
|
|
3362
|
+
"style-guide",
|
|
3363
|
+
"validation",
|
|
3364
|
+
`${options.validation}.md`
|
|
3365
|
+
),
|
|
3366
|
+
`# Validation \u2014 ${options.validation}
|
|
3367
|
+
|
|
3368
|
+
Env: \`src/lib/env.ts\`
|
|
3369
|
+
Schemas: \`src/lib/validations/\`
|
|
3370
|
+
Forms: \`@hookform/resolvers/${options.validation}\` when using RHF.
|
|
3371
|
+
`
|
|
3372
|
+
);
|
|
3373
|
+
}
|
|
3374
|
+
};
|
|
3375
|
+
|
|
3376
|
+
// src/installers/index.ts
|
|
3377
|
+
var installers = [
|
|
3378
|
+
packageManagerInstaller,
|
|
3379
|
+
iconsInstaller,
|
|
3380
|
+
storeInstaller,
|
|
3381
|
+
tanstackQueryInstaller,
|
|
3382
|
+
endpointsInstaller,
|
|
3383
|
+
apiEnvelopeInstaller,
|
|
3384
|
+
authJwtInstaller,
|
|
3385
|
+
authAuthjsInstaller,
|
|
3386
|
+
tailwindInstaller,
|
|
3387
|
+
darkModeInstaller,
|
|
3388
|
+
muiInstaller,
|
|
3389
|
+
shadcnInstaller,
|
|
3390
|
+
validationInstaller,
|
|
3391
|
+
seoInstaller,
|
|
3392
|
+
i18nInstaller,
|
|
3393
|
+
dockerComposeInstaller,
|
|
3394
|
+
testingInstaller,
|
|
3395
|
+
eslintInstaller,
|
|
3396
|
+
prettierInstaller,
|
|
3397
|
+
huskyInstaller,
|
|
3398
|
+
dockerInstaller,
|
|
3399
|
+
ciInstaller,
|
|
3400
|
+
appProvidersInstaller,
|
|
3401
|
+
surfaceInstaller
|
|
3402
|
+
];
|
|
3403
|
+
async function runInstallers(ctx) {
|
|
3404
|
+
const ran = [];
|
|
3405
|
+
for (const installer of installers) {
|
|
3406
|
+
if (!installer.active(ctx.options)) continue;
|
|
3407
|
+
await installer.run(ctx);
|
|
3408
|
+
ran.push(installer.id);
|
|
3409
|
+
}
|
|
3410
|
+
return ran;
|
|
3411
|
+
}
|
|
3412
|
+
|
|
3413
|
+
// src/scaffold.ts
|
|
3414
|
+
async function scaffold(options) {
|
|
3415
|
+
const projectDir = path28.resolve(process.cwd(), options.name);
|
|
3416
|
+
const pm = getPackageManager(options.packageManager);
|
|
3417
|
+
if (await fs27.pathExists(projectDir)) {
|
|
3418
|
+
const entries = await fs27.readdir(projectDir);
|
|
3419
|
+
if (entries.length > 0) {
|
|
3420
|
+
throw new Error(
|
|
3421
|
+
`Target directory already exists and is not empty: ${projectDir}`
|
|
3422
|
+
);
|
|
3423
|
+
}
|
|
3424
|
+
}
|
|
3425
|
+
const spin = p2.spinner();
|
|
3426
|
+
spin.start("Copying template");
|
|
3427
|
+
if (!await fs27.pathExists(TEMPLATE_DIR)) {
|
|
3428
|
+
spin.stop("Template missing");
|
|
3429
|
+
throw new Error(`Template directory not found at ${TEMPLATE_DIR}`);
|
|
3430
|
+
}
|
|
3431
|
+
await fs27.copy(TEMPLATE_DIR, projectDir, {
|
|
3432
|
+
filter: (src) => {
|
|
3433
|
+
const base = path28.basename(src);
|
|
3434
|
+
return base !== "node_modules" && base !== ".next" && base !== "dist";
|
|
3435
|
+
}
|
|
3436
|
+
});
|
|
3437
|
+
const gitignoreSrc = path28.join(projectDir, "gitignore");
|
|
3438
|
+
const gitignoreDest = path28.join(projectDir, ".gitignore");
|
|
3439
|
+
if (await fs27.pathExists(gitignoreSrc)) {
|
|
3440
|
+
await fs27.move(gitignoreSrc, gitignoreDest, { overwrite: true });
|
|
3441
|
+
}
|
|
3442
|
+
spin.message("Applying project tokens");
|
|
3443
|
+
await replaceTokensInTree(projectDir, {
|
|
3444
|
+
__PROJECT_NAME__: options.name,
|
|
3445
|
+
__PROJECT_SLUG__: options.slug,
|
|
3446
|
+
__PACKAGE_MANAGER__: pm.id
|
|
3447
|
+
});
|
|
3448
|
+
spin.message("Running installers");
|
|
3449
|
+
const ran = await runInstallers({ projectDir, options });
|
|
3450
|
+
if (!options.noInstall) {
|
|
3451
|
+
spin.message(`Installing dependencies (${pm.id})`);
|
|
3452
|
+
try {
|
|
3453
|
+
execSync(`${pm.id} ${pm.installArgs.join(" ")}`, {
|
|
3454
|
+
cwd: projectDir,
|
|
3455
|
+
stdio: "ignore"
|
|
3456
|
+
});
|
|
3457
|
+
} catch {
|
|
3458
|
+
if (pm.id !== "npm") {
|
|
3459
|
+
spin.message(`${pm.id} failed \u2014 trying npm`);
|
|
3460
|
+
execSync("npm install", { cwd: projectDir, stdio: "ignore" });
|
|
3461
|
+
} else {
|
|
3462
|
+
throw new Error("npm install failed");
|
|
3463
|
+
}
|
|
3464
|
+
}
|
|
3465
|
+
}
|
|
3466
|
+
spin.stop("Project ready");
|
|
3467
|
+
if (ran.length) {
|
|
3468
|
+
p2.log.info(`Installers: ${ran.join(", ")}`);
|
|
3469
|
+
} else {
|
|
3470
|
+
p2.log.step(color2.dim("No optional installers (base template only)."));
|
|
3471
|
+
}
|
|
3472
|
+
return projectDir;
|
|
3473
|
+
}
|
|
3474
|
+
|
|
3475
|
+
// src/upgrade.ts
|
|
3476
|
+
import fs28 from "fs-extra";
|
|
3477
|
+
import path29 from "path";
|
|
3478
|
+
import * as p3 from "@clack/prompts";
|
|
3479
|
+
import color3 from "picocolors";
|
|
3480
|
+
var TRACKED = [
|
|
3481
|
+
"AGENTS.md",
|
|
3482
|
+
".cursor/rules/core.mdc",
|
|
3483
|
+
".cursor/rules/components.mdc",
|
|
3484
|
+
".cursor/rules/forms.mdc"
|
|
3485
|
+
];
|
|
3486
|
+
function rel(projectDir, file) {
|
|
3487
|
+
return path29.join(projectDir, file);
|
|
3488
|
+
}
|
|
3489
|
+
function templatePath(file) {
|
|
3490
|
+
return path29.join(TEMPLATE_DIR, file);
|
|
3491
|
+
}
|
|
3492
|
+
async function runUpgrade(options) {
|
|
3493
|
+
const { projectDir, apply } = options;
|
|
3494
|
+
if (!await fs28.pathExists(path29.join(projectDir, "package.json"))) {
|
|
3495
|
+
throw new Error(`Not an app directory (missing package.json): ${projectDir}`);
|
|
3496
|
+
}
|
|
3497
|
+
if (!await fs28.pathExists(TEMPLATE_DIR)) {
|
|
3498
|
+
throw new Error(`Template missing at ${TEMPLATE_DIR}`);
|
|
3499
|
+
}
|
|
3500
|
+
p3.intro(color3.bgCyan(color3.black(" next-style-guide upgrade ")));
|
|
3501
|
+
p3.log.info(
|
|
3502
|
+
apply ? `Applying template agent files \u2192 ${projectDir}` : `Diff only (pass --apply to write). Target: ${projectDir}`
|
|
3503
|
+
);
|
|
3504
|
+
let changed = 0;
|
|
3505
|
+
let missing = 0;
|
|
3506
|
+
let same = 0;
|
|
3507
|
+
for (const file of TRACKED) {
|
|
3508
|
+
const from = templatePath(file);
|
|
3509
|
+
const to = rel(projectDir, file);
|
|
3510
|
+
if (!await fs28.pathExists(from)) {
|
|
3511
|
+
p3.log.warn(`Template missing ${file}`);
|
|
3512
|
+
continue;
|
|
3513
|
+
}
|
|
3514
|
+
const next = await fs28.readFile(from, "utf8");
|
|
3515
|
+
let outgoing = next;
|
|
3516
|
+
if (file === "AGENTS.md" && await fs28.pathExists(to)) {
|
|
3517
|
+
const current2 = await fs28.readFile(to, "utf8");
|
|
3518
|
+
const nameMatch = current2.match(/^# Agents \((.+)\)$/m);
|
|
3519
|
+
if (nameMatch?.[1]) {
|
|
3520
|
+
outgoing = outgoing.replace("__PROJECT_NAME__", nameMatch[1]);
|
|
3521
|
+
}
|
|
3522
|
+
const pmMatch = current2.match(/· ([a-z]+)(\.|$)/m);
|
|
3523
|
+
if (current2.includes("pnpm") || current2.includes("npm") || current2.includes("yarn")) {
|
|
3524
|
+
const stackPm = current2.match(/· (pnpm|npm|yarn)\./);
|
|
3525
|
+
if (stackPm?.[1]) {
|
|
3526
|
+
outgoing = outgoing.replace("__PACKAGE_MANAGER__", stackPm[1]);
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
}
|
|
3530
|
+
outgoing = outgoing.replaceAll("__PROJECT_NAME__", path29.basename(projectDir)).replaceAll("__PACKAGE_MANAGER__", "pnpm").replaceAll("__PROJECT_SLUG__", path29.basename(projectDir));
|
|
3531
|
+
if (!await fs28.pathExists(to)) {
|
|
3532
|
+
missing += 1;
|
|
3533
|
+
p3.log.warn(`missing ${file}`);
|
|
3534
|
+
if (apply) {
|
|
3535
|
+
await fs28.ensureDir(path29.dirname(to));
|
|
3536
|
+
await fs28.writeFile(to, outgoing, "utf8");
|
|
3537
|
+
p3.log.success(`wrote ${file}`);
|
|
3538
|
+
}
|
|
3539
|
+
continue;
|
|
3540
|
+
}
|
|
3541
|
+
const current = await fs28.readFile(to, "utf8");
|
|
3542
|
+
if (current === outgoing) {
|
|
3543
|
+
same += 1;
|
|
3544
|
+
p3.log.step(color3.dim(`same ${file}`));
|
|
3545
|
+
continue;
|
|
3546
|
+
}
|
|
3547
|
+
changed += 1;
|
|
3548
|
+
p3.log.warn(`differs ${file}`);
|
|
3549
|
+
if (apply) {
|
|
3550
|
+
await fs28.writeFile(to, outgoing, "utf8");
|
|
3551
|
+
p3.log.success(`updated ${file}`);
|
|
3552
|
+
}
|
|
3553
|
+
}
|
|
3554
|
+
p3.note(
|
|
3555
|
+
[
|
|
3556
|
+
`same=${same} differs=${changed} missing=${missing}`,
|
|
3557
|
+
apply ? "Applied template agent surface." : "Re-run with --apply to write differs/missing files.",
|
|
3558
|
+
"Feature rules (.cursor/rules/* beyond core/components/forms) are not overwritten \u2014 re-scaffold options or copy manually."
|
|
3559
|
+
].join("\n"),
|
|
3560
|
+
"Summary"
|
|
3561
|
+
);
|
|
3562
|
+
p3.outro(color3.green(apply ? "Upgrade applied." : "Diff complete."));
|
|
3563
|
+
}
|
|
3564
|
+
|
|
3565
|
+
// src/index.ts
|
|
3566
|
+
var ICON_VALUES = /* @__PURE__ */ new Set(["none", "lucide", "tabler"]);
|
|
3567
|
+
var STYLING_VALUES = /* @__PURE__ */ new Set(["none", "tailwind", "mui"]);
|
|
3568
|
+
var VALIDATION_VALUES = /* @__PURE__ */ new Set(["zod", "yup"]);
|
|
3569
|
+
var AUTH_VALUES = /* @__PURE__ */ new Set(["none", "jwt", "authjs"]);
|
|
3570
|
+
var STORE_VALUES = /* @__PURE__ */ new Set(["none", "zustand", "redux"]);
|
|
3571
|
+
var PM_VALUES = /* @__PURE__ */ new Set(["pnpm", "npm", "yarn"]);
|
|
3572
|
+
var PRESET_VALUES = /* @__PURE__ */ new Set([
|
|
3573
|
+
"custom",
|
|
3574
|
+
"minimal",
|
|
3575
|
+
"api-app",
|
|
3576
|
+
"marketing",
|
|
3577
|
+
"dashboard"
|
|
3578
|
+
]);
|
|
3579
|
+
function parseBoolFlag(arg, positive, negative) {
|
|
3580
|
+
if (arg === positive) return true;
|
|
3581
|
+
if (arg === negative) return false;
|
|
3582
|
+
return void 0;
|
|
3583
|
+
}
|
|
3584
|
+
function readChoice(arg, flag, values, args, i) {
|
|
3585
|
+
if (arg.startsWith(`${flag}=`)) {
|
|
3586
|
+
const value = arg.slice(flag.length + 1);
|
|
3587
|
+
if (!values.has(value)) {
|
|
3588
|
+
throw new Error(`Invalid ${flag}=${value}.`);
|
|
3589
|
+
}
|
|
3590
|
+
return { value, nextIndex: i };
|
|
3591
|
+
}
|
|
3592
|
+
if (arg === flag) {
|
|
3593
|
+
const value = args[i + 1];
|
|
3594
|
+
if (!value || !values.has(value)) {
|
|
3595
|
+
throw new Error(`Invalid ${flag}.`);
|
|
3596
|
+
}
|
|
3597
|
+
return { value, nextIndex: i + 1 };
|
|
3598
|
+
}
|
|
3599
|
+
return null;
|
|
3600
|
+
}
|
|
3601
|
+
function parseArgs(argv) {
|
|
3602
|
+
const args = argv.slice(2);
|
|
3603
|
+
const out = {
|
|
3604
|
+
noInstall: false,
|
|
3605
|
+
yes: false,
|
|
3606
|
+
upgrade: false,
|
|
3607
|
+
apply: false
|
|
3608
|
+
};
|
|
3609
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
3610
|
+
const arg = args[i];
|
|
3611
|
+
if (arg === "upgrade") {
|
|
3612
|
+
out.upgrade = true;
|
|
3613
|
+
continue;
|
|
3614
|
+
}
|
|
3615
|
+
if (arg === "--apply") {
|
|
3616
|
+
out.apply = true;
|
|
3617
|
+
continue;
|
|
3618
|
+
}
|
|
3619
|
+
if (arg === "--diff") {
|
|
3620
|
+
out.upgrade = true;
|
|
3621
|
+
out.apply = false;
|
|
3622
|
+
continue;
|
|
3623
|
+
}
|
|
3624
|
+
if (arg === "--no-install") {
|
|
3625
|
+
out.noInstall = true;
|
|
3626
|
+
continue;
|
|
3627
|
+
}
|
|
3628
|
+
if (arg === "--yes" || arg === "-y") {
|
|
3629
|
+
out.yes = true;
|
|
3630
|
+
continue;
|
|
3631
|
+
}
|
|
3632
|
+
if (arg === "--help" || arg === "-h") {
|
|
3633
|
+
printHelp();
|
|
3634
|
+
process.exit(0);
|
|
3635
|
+
}
|
|
3636
|
+
const boolKeys = [
|
|
3637
|
+
["tanstackQuery", "--tanstack-query", "--no-tanstack-query"],
|
|
3638
|
+
["endpoints", "--endpoints", "--no-endpoints"],
|
|
3639
|
+
["apiEnvelope", "--api-envelope", "--no-api-envelope"],
|
|
3640
|
+
["seo", "--seo", "--no-seo"],
|
|
3641
|
+
["i18n", "--i18n", "--no-i18n"],
|
|
3642
|
+
["darkMode", "--dark-mode", "--no-dark-mode"],
|
|
3643
|
+
["testing", "--testing", "--no-testing"],
|
|
3644
|
+
["playwright", "--playwright", "--no-playwright"],
|
|
3645
|
+
["eslint", "--eslint", "--no-eslint"],
|
|
3646
|
+
["prettier", "--prettier", "--no-prettier"],
|
|
3647
|
+
["husky", "--husky", "--no-husky"],
|
|
3648
|
+
["docker", "--docker", "--no-docker"],
|
|
3649
|
+
["ci", "--ci", "--no-ci"],
|
|
3650
|
+
["shadcn", "--shadcn", "--no-shadcn"]
|
|
3651
|
+
];
|
|
3652
|
+
let matched = false;
|
|
3653
|
+
for (const [key, pos, neg] of boolKeys) {
|
|
3654
|
+
const v = parseBoolFlag(arg, pos, neg);
|
|
3655
|
+
if (v !== void 0) {
|
|
3656
|
+
out[key] = v;
|
|
3657
|
+
matched = true;
|
|
3658
|
+
break;
|
|
3659
|
+
}
|
|
3660
|
+
}
|
|
3661
|
+
if (matched) continue;
|
|
3662
|
+
const preset = readChoice(arg, "--preset", PRESET_VALUES, args, i);
|
|
3663
|
+
if (preset) {
|
|
3664
|
+
out.preset = preset.value;
|
|
3665
|
+
i = preset.nextIndex;
|
|
3666
|
+
continue;
|
|
3667
|
+
}
|
|
3668
|
+
const pm = readChoice(arg, "--pm", PM_VALUES, args, i);
|
|
3669
|
+
if (pm) {
|
|
3670
|
+
out.packageManager = pm.value;
|
|
3671
|
+
i = pm.nextIndex;
|
|
3672
|
+
continue;
|
|
3673
|
+
}
|
|
3674
|
+
const pmLong = readChoice(arg, "--package-manager", PM_VALUES, args, i);
|
|
3675
|
+
if (pmLong) {
|
|
3676
|
+
out.packageManager = pmLong.value;
|
|
3677
|
+
i = pmLong.nextIndex;
|
|
3678
|
+
continue;
|
|
3679
|
+
}
|
|
3680
|
+
const store = readChoice(arg, "--store", STORE_VALUES, args, i);
|
|
3681
|
+
if (store) {
|
|
3682
|
+
out.store = store.value;
|
|
3683
|
+
i = store.nextIndex;
|
|
3684
|
+
continue;
|
|
3685
|
+
}
|
|
3686
|
+
const styling = readChoice(arg, "--styling", STYLING_VALUES, args, i);
|
|
3687
|
+
if (styling) {
|
|
3688
|
+
out.styling = styling.value;
|
|
3689
|
+
i = styling.nextIndex;
|
|
3690
|
+
continue;
|
|
3691
|
+
}
|
|
3692
|
+
const validation = readChoice(
|
|
3693
|
+
arg,
|
|
3694
|
+
"--validation",
|
|
3695
|
+
VALIDATION_VALUES,
|
|
3696
|
+
args,
|
|
3697
|
+
i
|
|
3698
|
+
);
|
|
3699
|
+
if (validation) {
|
|
3700
|
+
out.validation = validation.value;
|
|
3701
|
+
i = validation.nextIndex;
|
|
3702
|
+
continue;
|
|
3703
|
+
}
|
|
3704
|
+
const auth = readChoice(arg, "--auth", AUTH_VALUES, args, i);
|
|
3705
|
+
if (auth) {
|
|
3706
|
+
out.auth = auth.value;
|
|
3707
|
+
i = auth.nextIndex;
|
|
3708
|
+
continue;
|
|
3709
|
+
}
|
|
3710
|
+
const icons = readChoice(arg, "--icons", ICON_VALUES, args, i);
|
|
3711
|
+
if (icons) {
|
|
3712
|
+
out.icons = icons.value;
|
|
3713
|
+
i = icons.nextIndex;
|
|
3714
|
+
continue;
|
|
3715
|
+
}
|
|
3716
|
+
if (!arg.startsWith("-") && !out.name) {
|
|
3717
|
+
out.name = arg;
|
|
3718
|
+
}
|
|
3719
|
+
}
|
|
3720
|
+
return out;
|
|
3721
|
+
}
|
|
3722
|
+
function printHelp() {
|
|
3723
|
+
console.log(`
|
|
3724
|
+
${TITLE} \u2014 ${PKG_NAME}
|
|
3725
|
+
|
|
3726
|
+
Usage:
|
|
3727
|
+
npx create-next-style-guide [name] [options]
|
|
3728
|
+
npx create-next-style-guide upgrade [--apply] [dir]
|
|
3729
|
+
npx create-next-style-guide --diff [dir]
|
|
3730
|
+
|
|
3731
|
+
Options:
|
|
3732
|
+
--yes, -y
|
|
3733
|
+
--no-install
|
|
3734
|
+
--preset minimal|api-app|marketing|dashboard|custom
|
|
3735
|
+
--pm, --package-manager pnpm|npm|yarn
|
|
3736
|
+
--store none|zustand|redux
|
|
3737
|
+
--styling none|tailwind|mui
|
|
3738
|
+
--shadcn | --no-shadcn
|
|
3739
|
+
--dark-mode | --no-dark-mode
|
|
3740
|
+
--validation zod|yup
|
|
3741
|
+
--auth none|jwt|authjs
|
|
3742
|
+
--icons none|lucide|tabler
|
|
3743
|
+
--tanstack-query | --no-tanstack-query
|
|
3744
|
+
--endpoints | --no-endpoints
|
|
3745
|
+
--api-envelope | --no-api-envelope
|
|
3746
|
+
--seo | --no-seo
|
|
3747
|
+
--i18n | --no-i18n
|
|
3748
|
+
--testing | --no-testing
|
|
3749
|
+
--playwright | --no-playwright
|
|
3750
|
+
--eslint | --no-eslint
|
|
3751
|
+
--prettier | --no-prettier
|
|
3752
|
+
--husky | --no-husky
|
|
3753
|
+
--docker | --no-docker
|
|
3754
|
+
--ci | --no-ci
|
|
3755
|
+
--help, -h
|
|
3756
|
+
|
|
3757
|
+
Upgrade:
|
|
3758
|
+
upgrade [--apply] Diff (default) or apply AGENTS.md + core .cursor/rules
|
|
3759
|
+
--diff Alias for upgrade without --apply
|
|
3760
|
+
`);
|
|
3761
|
+
}
|
|
3762
|
+
function applyFlagOverrides(options, flags) {
|
|
3763
|
+
let next = { ...options };
|
|
3764
|
+
if (flags.packageManager !== void 0) {
|
|
3765
|
+
next.packageManager = flags.packageManager;
|
|
3766
|
+
}
|
|
3767
|
+
if (flags.icons !== void 0) next.icons = flags.icons;
|
|
3768
|
+
if (flags.styling !== void 0) next.styling = flags.styling;
|
|
3769
|
+
if (flags.shadcn !== void 0) next.ui = flags.shadcn ? "shadcn" : "none";
|
|
3770
|
+
if (flags.validation !== void 0) next.validation = flags.validation;
|
|
3771
|
+
if (flags.auth !== void 0) next.auth = flags.auth;
|
|
3772
|
+
if (flags.store !== void 0) next.store = flags.store;
|
|
3773
|
+
if (flags.tanstackQuery !== void 0) {
|
|
3774
|
+
next.data = flags.tanstackQuery ? "tanstack-query" : "rsc";
|
|
3775
|
+
}
|
|
3776
|
+
if (flags.endpoints !== void 0) next.endpoints = flags.endpoints;
|
|
3777
|
+
if (flags.apiEnvelope !== void 0) next.apiEnvelope = flags.apiEnvelope;
|
|
3778
|
+
if (flags.seo !== void 0) next.seo = flags.seo;
|
|
3779
|
+
if (flags.i18n !== void 0) next.i18n = flags.i18n;
|
|
3780
|
+
if (flags.darkMode !== void 0) next.darkMode = flags.darkMode;
|
|
3781
|
+
if (flags.testing !== void 0) next.testing = flags.testing;
|
|
3782
|
+
if (flags.playwright !== void 0) next.playwright = flags.playwright;
|
|
3783
|
+
if (flags.eslint !== void 0) next.eslint = flags.eslint;
|
|
3784
|
+
if (flags.prettier !== void 0) next.prettier = flags.prettier;
|
|
3785
|
+
if (flags.husky !== void 0) next.husky = flags.husky;
|
|
3786
|
+
if (flags.docker !== void 0) next.docker = flags.docker;
|
|
3787
|
+
if (flags.ci !== void 0) next.ci = flags.ci;
|
|
3788
|
+
return normalizeOptions(next);
|
|
3789
|
+
}
|
|
3790
|
+
async function main() {
|
|
3791
|
+
const flags = parseArgs(process.argv);
|
|
3792
|
+
if (flags.upgrade) {
|
|
3793
|
+
const projectDir2 = path30.resolve(process.cwd(), flags.name ?? ".");
|
|
3794
|
+
await runUpgrade({ projectDir: projectDir2, apply: flags.apply });
|
|
3795
|
+
return;
|
|
3796
|
+
}
|
|
3797
|
+
let options;
|
|
3798
|
+
if (flags.yes) {
|
|
3799
|
+
const name = (flags.name ?? "my-app").trim();
|
|
3800
|
+
const presetSeed = flags.preset && flags.preset !== "custom" ? PRESET_SEEDS[flags.preset] : DEFAULT_OPTIONS;
|
|
3801
|
+
options = applyFlagOverrides(
|
|
3802
|
+
{
|
|
3803
|
+
name,
|
|
3804
|
+
slug: slugify(name),
|
|
3805
|
+
...presetSeed,
|
|
3806
|
+
noInstall: flags.noInstall
|
|
3807
|
+
},
|
|
3808
|
+
flags
|
|
3809
|
+
);
|
|
3810
|
+
p4.intro(color4.bgCyan(color4.black(` ${TITLE} `)));
|
|
3811
|
+
p4.log.info(
|
|
3812
|
+
[
|
|
3813
|
+
`Non-interactive: ${options.name} (${options.slug})`,
|
|
3814
|
+
flags.preset ? `preset=${flags.preset}` : null,
|
|
3815
|
+
`pm=${options.packageManager}`,
|
|
3816
|
+
`styling=${options.styling} ui=${options.ui} auth=${options.auth} store=${options.store}`,
|
|
3817
|
+
`endpoints=${options.endpoints} seo=${options.seo} i18n=${options.i18n} testing=${options.testing}`
|
|
3818
|
+
].filter(Boolean).join(" \xB7 ")
|
|
3819
|
+
);
|
|
3820
|
+
} else {
|
|
3821
|
+
options = await runPrompts(flags.name);
|
|
3822
|
+
options.noInstall = flags.noInstall;
|
|
3823
|
+
options = applyFlagOverrides(options, flags);
|
|
3824
|
+
}
|
|
3825
|
+
const warnings = collectOptionWarnings(options);
|
|
3826
|
+
for (const warning of warnings) {
|
|
3827
|
+
p4.log.warn(warning);
|
|
3828
|
+
}
|
|
3829
|
+
const projectDir = await scaffold(options);
|
|
3830
|
+
const pm = getPackageManager(options.packageManager);
|
|
3831
|
+
const nextSteps = [
|
|
3832
|
+
`cd ${path30.relative(process.cwd(), projectDir) || "."}`,
|
|
3833
|
+
options.noInstall ? `${pm.id} ${pm.installArgs.filter((a) => a !== "--ignore-workspace").join(" ")}`.trim() : null,
|
|
3834
|
+
options.ui === "shadcn" ? "npx shadcn@latest add input # optional" : null,
|
|
3835
|
+
options.endpoints ? `${pm.runScript("endpoints:generate")} # when API schema is up` : null,
|
|
3836
|
+
options.testing ? pm.runScript("test") : null,
|
|
3837
|
+
pm.runScript("dev")
|
|
3838
|
+
].filter(Boolean);
|
|
3839
|
+
p4.note(nextSteps.join("\n"), "Next steps");
|
|
3840
|
+
p4.outro(color4.green("Done. Happy shipping."));
|
|
3841
|
+
}
|
|
3842
|
+
main().catch((error) => {
|
|
3843
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
3844
|
+
p4.log.error(message);
|
|
3845
|
+
p4.cancel("Scaffold failed.");
|
|
3846
|
+
process.exit(1);
|
|
3847
|
+
});
|
|
3848
|
+
//# sourceMappingURL=index.js.map
|