@nakedev/nextjs-fsd 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +165 -0
  3. package/bin/nextjs-fsd.js +2 -0
  4. package/dist/commands/add.js +182 -0
  5. package/dist/commands/config.js +53 -0
  6. package/dist/commands/generate.js +403 -0
  7. package/dist/commands/init.js +229 -0
  8. package/dist/index.js +291 -0
  9. package/dist/prompts.js +38 -0
  10. package/dist/types.js +5 -0
  11. package/dist/utils/config.js +86 -0
  12. package/dist/utils/copy.js +87 -0
  13. package/dist/utils/naming.js +76 -0
  14. package/dist/utils/project.js +288 -0
  15. package/dist/utils/render.js +66 -0
  16. package/dist/utils/version.js +23 -0
  17. package/package.json +66 -0
  18. package/templates/add/auth/auth-errors.ts.hbs +22 -0
  19. package/templates/add/auth/index.ts.hbs +4 -0
  20. package/templates/add/auth/login-form.tsx.hbs +48 -0
  21. package/templates/add/auth/login-index.ts.hbs +1 -0
  22. package/templates/add/auth/login-page.tsx.hbs +18 -0
  23. package/templates/add/auth/require-session.ts.hbs +29 -0
  24. package/templates/add/auth/session.ts.hbs +75 -0
  25. package/templates/add/errors/access-token.ts.hbs +19 -0
  26. package/templates/add/errors/api-error.ts.hbs +69 -0
  27. package/templates/add/errors/client.test.ts.hbs +61 -0
  28. package/templates/add/errors/client.ts.hbs +91 -0
  29. package/templates/add/errors/config-index.ts.hbs +1 -0
  30. package/templates/add/errors/env.ts.hbs +3 -0
  31. package/templates/add/errors/error-catalog.ts.hbs +19 -0
  32. package/templates/add/errors/error-resolver.ts.hbs +30 -0
  33. package/templates/add/errors/form-error.tsx.hbs +50 -0
  34. package/templates/add/errors/index.ts.hbs +5 -0
  35. package/templates/add/errors/providers.tsx.hbs +14 -0
  36. package/templates/add/errors/query-client.ts.hbs +25 -0
  37. package/templates/generate/layout/layout.tsx.hbs +20 -0
  38. package/templates/generate/layout/route.tsx.hbs +1 -0
  39. package/templates/generate/page/content.tsx.hbs +17 -0
  40. package/templates/generate/page/errors.ts.hbs +22 -0
  41. package/templates/generate/page/index.ts.hbs +1 -0
  42. package/templates/generate/page/page.tsx.hbs +22 -0
  43. package/templates/generate/page/route.tsx.hbs +3 -0
  44. package/templates/generate/slice/api.ts.hbs +42 -0
  45. package/templates/generate/slice/errors.ts.hbs +22 -0
  46. package/templates/generate/slice/index.ts.hbs +15 -0
  47. package/templates/generate/slice/lib.ts.hbs +4 -0
  48. package/templates/generate/slice/model.ts.hbs +10 -0
  49. package/templates/generate/slice/ui.tsx.hbs +17 -0
  50. package/templates/init/agents-section.md.hbs +25 -0
  51. package/templates/init/claude.md.hbs +1 -0
  52. package/templates/init/components.json.hbs +21 -0
  53. package/templates/init/eslint.fsd.mjs.hbs +91 -0
  54. package/templates/init/fsd.md.hbs +133 -0
  55. package/templates/init/globals.css.hbs +31 -0
  56. package/templates/init/skill.md.hbs +167 -0
  57. package/templates/init/steiger.config.ts.hbs +28 -0
@@ -0,0 +1,403 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.generatePage = generatePage;
7
+ exports.generateSlice = generateSlice;
8
+ exports.existingPages = existingPages;
9
+ exports.generateLayout = generateLayout;
10
+ const path_1 = __importDefault(require("path"));
11
+ const fs_extra_1 = __importDefault(require("fs-extra"));
12
+ const picocolors_1 = __importDefault(require("picocolors"));
13
+ const types_1 = require("../types");
14
+ const prompts_1 = require("../prompts");
15
+ const config_1 = require("../utils/config");
16
+ const copy_1 = require("../utils/copy");
17
+ const naming_1 = require("../utils/naming");
18
+ const render_1 = require("../utils/render");
19
+ const project_1 = require("../utils/project");
20
+ const init_1 = require("./init");
21
+ async function generatePage(rawName, opts) {
22
+ const config = (0, config_1.readConfig)(process.cwd());
23
+ assertInputs("page", rawName, opts);
24
+ const name = rawName ??
25
+ (await (0, prompts_1.input)({
26
+ message: "Page name (kebab-case, becomes src/_pages/<name>/):",
27
+ validate: naming_1.validateSliceName,
28
+ }));
29
+ const naming = (0, naming_1.resolveNaming)(name);
30
+ let { client, auth, errors } = opts;
31
+ let title = opts.title?.trim() || undefined;
32
+ if (!opts.defaults) {
33
+ if (auth === undefined && client === undefined) {
34
+ const shape = await (0, prompts_1.select)({
35
+ message: "What shape is this page?",
36
+ choices: [
37
+ { name: "Server component only", value: "server" },
38
+ { name: "Server component + a \"use client\" leaf", value: "client" },
39
+ {
40
+ name: "Client leaf behind a session guard (useRequireSession)",
41
+ value: "auth",
42
+ disabled: config.features.auth ? false : "— needs `add auth` first",
43
+ },
44
+ ],
45
+ });
46
+ client = shape !== "server";
47
+ auth = shape === "auth";
48
+ }
49
+ // Asked for `th`, not for `en`: the derived Title Case of a kebab name is
50
+ // already the right answer in English, while a Thai project would
51
+ // otherwise get an English heading and an English <title> on every page —
52
+ // two hand edits per page, every page.
53
+ if (opts.title === undefined && config.locale !== "en") {
54
+ title = (await (0, prompts_1.input)({
55
+ message: "Page title (shown as the heading and the browser title):",
56
+ default: toTitleCase(naming.name),
57
+ })).trim();
58
+ }
59
+ if (errors === undefined && config.features.errorHandling) {
60
+ errors = await (0, prompts_1.confirm)({
61
+ message: `Add an error catalog (model/${naming.name}-errors.ts)?`,
62
+ default: false,
63
+ });
64
+ }
65
+ }
66
+ if (auth && !config.features.auth) {
67
+ throw new Error("--auth needs the auth feature — run `nextjs-fsd add auth` first");
68
+ }
69
+ if (errors && !config.features.errorHandling) {
70
+ throw new Error("--errors needs the error-handling feature — run `nextjs-fsd add error-handling` first");
71
+ }
72
+ const route = opts.route === undefined ? naming.name : (0, naming_1.normalizeRoute)(opts.route);
73
+ const routeCheck = (0, naming_1.validateRoute)(route);
74
+ if (routeCheck !== true)
75
+ throw new Error(routeCheck);
76
+ const slice = `${config.srcDir}/_pages/${naming.name}`;
77
+ // A page that already exists is being extended, not recreated — `--errors`
78
+ // or `--client` on a slice generated bare earlier is the normal way those
79
+ // get added, so the existing files are not an error.
80
+ const extending = fs_extra_1.default.existsSync(path_1.default.join(process.cwd(), slice));
81
+ // A page being extended may already be routed from somewhere else —
82
+ // `--route "(admin)/dashboard"` the first time round. Writing the default
83
+ // route file now would produce two page.tsx that resolve to the same URL,
84
+ // which Next.js rejects at build time ("two parallel pages that resolve to
85
+ // the same path") — a broken build from a command that printed success.
86
+ const existingRoute = extending
87
+ ? findRouteFor(process.cwd(), config.appDir, config.alias, naming.name)
88
+ : undefined;
89
+ const hasContent = Boolean(client || auth);
90
+ const context = {
91
+ ...naming,
92
+ ...config,
93
+ copy: (0, copy_1.copyFor)(config.locale),
94
+ title: title || toTitleCase(naming.name),
95
+ hasContent,
96
+ auth: Boolean(auth),
97
+ };
98
+ const written = await (0, render_1.applyTemplates)(process.cwd(), [
99
+ { template: "generate/page/index.ts.hbs", output: `${slice}/index.ts` },
100
+ { template: "generate/page/page.tsx.hbs", output: `${slice}/ui/${naming.name}-page.tsx` },
101
+ {
102
+ template: "generate/page/content.tsx.hbs",
103
+ output: `${slice}/ui/${naming.name}-content.tsx`,
104
+ when: () => hasContent,
105
+ },
106
+ {
107
+ template: "generate/page/errors.ts.hbs",
108
+ output: `${slice}/model/${naming.name}-errors.ts`,
109
+ when: () => Boolean(errors),
110
+ },
111
+ {
112
+ template: "generate/page/route.tsx.hbs",
113
+ output: path_1.default.posix.join(config.appDir, route, "page.tsx"),
114
+ when: () => opts.routeFile !== false && existingRoute === undefined,
115
+ },
116
+ ], context, { skipExisting: extending });
117
+ if (extending && written.length === 0) {
118
+ throw new Error(`${slice} already has everything this would write.\n` +
119
+ "Pass --client or --errors to add a leaf component or an error catalog to it.");
120
+ }
121
+ (0, init_1.report)(written);
122
+ if (extending) {
123
+ console.log(picocolors_1.default.dim(`\nextended the existing ${naming.name} page; untouched files were left alone.`));
124
+ // The page component is one of those untouched files, so a leaf added now
125
+ // is not rendered by anything yet. Say the one line that wires it.
126
+ if (written.some((file) => file.endsWith(`${naming.name}-content.tsx`))) {
127
+ console.log(picocolors_1.default.yellow(`ui/${naming.name}-page.tsx does not render it yet — add:`) +
128
+ `\n import { ${naming.pascal}Content } from "./${naming.name}-content";`);
129
+ }
130
+ }
131
+ if (existingRoute !== undefined) {
132
+ console.log(picocolors_1.default.dim(`\nalready routed from ${existingRoute} — left alone rather than adding a second page.tsx for the same URL.`));
133
+ }
134
+ else if (opts.routeFile === false) {
135
+ console.log(picocolors_1.default.yellow(`\nno route file — add one that re-exports the page and its metadata when you want it routable.`));
136
+ }
137
+ else {
138
+ // Route groups are directories Next.js reads and strips from the URL, so
139
+ // printing the path verbatim would name a URL that never exists.
140
+ const url = route
141
+ .split("/")
142
+ .filter((segment) => !segment.startsWith("(") && !segment.startsWith("@"))
143
+ .join("/");
144
+ console.log(`\n${picocolors_1.default.bold("Route:")} /${url}`);
145
+ }
146
+ }
147
+ async function generateSlice(rawLayer, rawName, opts) {
148
+ const config = (0, config_1.readConfig)(process.cwd());
149
+ assertSliceInputs(rawLayer, rawName, opts);
150
+ const layer = parseLayer(rawLayer) ??
151
+ (await (0, prompts_1.select)({
152
+ message: "Which layer?",
153
+ choices: [
154
+ { name: "features — a whole user action, reused by two or more pages", value: "features" },
155
+ { name: "entities — a business object, reused by two or more features", value: "entities" },
156
+ { name: "widgets — a composite UI block (FSD v2.1 discourages this; prefer features)", value: "widgets" },
157
+ ],
158
+ }));
159
+ const name = rawName ??
160
+ (await (0, prompts_1.input)({
161
+ message: `Slice name (kebab-case, becomes ${config.srcDir}/${layer}/<name>/):`,
162
+ validate: naming_1.validateSliceName,
163
+ }));
164
+ const naming = (0, naming_1.resolveNaming)(name);
165
+ const chosen = opts.segments
166
+ ? parseSegments(opts.segments)
167
+ : opts.defaults
168
+ ? ["ui"]
169
+ : (await (0, prompts_1.checkbox)({
170
+ message: "Which segments? (a slice gets only the ones it has code for)",
171
+ choices: [
172
+ { name: "ui — components", value: "ui", checked: true },
173
+ { name: "model — state and hooks", value: "model" },
174
+ {
175
+ name: "api — TanStack Query hooks",
176
+ value: "api",
177
+ disabled: config.features.errorHandling ? false : "— needs `add error-handling` first",
178
+ },
179
+ { name: "lib — pure helpers", value: "lib" },
180
+ ],
181
+ }));
182
+ if (chosen.length === 0) {
183
+ throw new Error("pick at least one segment — a slice with no segments is an empty directory");
184
+ }
185
+ if (chosen.includes("api") && !config.features.errorHandling) {
186
+ throw new Error("the api segment needs the error-handling feature — run `nextjs-fsd add error-handling` first.\n" +
187
+ "A bare fetch skips the bearer token, the single-flight 401 refresh, and the conversion into ApiError.");
188
+ }
189
+ let errors = opts.errors;
190
+ if (errors === undefined) {
191
+ errors = opts.defaults
192
+ ? false
193
+ : config.features.errorHandling &&
194
+ (await (0, prompts_1.confirm)({ message: `Add an error catalog (model/${naming.name}-errors.ts)?`, default: false }));
195
+ }
196
+ if (errors && !config.features.errorHandling) {
197
+ throw new Error("--errors needs the error-handling feature — run `nextjs-fsd add error-handling` first");
198
+ }
199
+ const segments = Object.fromEntries([...types_1.SEGMENTS, "errors"].map((segment) => [
200
+ segment,
201
+ segment === "errors" ? Boolean(errors) : chosen.includes(segment),
202
+ ]));
203
+ // Only the segments that are not on disk yet. Drives both what gets written
204
+ // and which export lines join an existing index.ts, so extending a slice
205
+ // never re-announces a segment it already had.
206
+ const onDisk = existingSegments(process.cwd(), slicePath(config.srcDir, layer, naming.name), naming.name);
207
+ const added = Object.fromEntries(Object.entries(segments).map(([segment, wanted]) => [segment, wanted && !onDisk.includes(segment)]));
208
+ const context = {
209
+ ...naming,
210
+ ...config,
211
+ copy: (0, copy_1.copyFor)(config.locale),
212
+ layer,
213
+ segments,
214
+ // A ui component that calls the slice's own model hook needs the browser.
215
+ needsClient: segments.model,
216
+ };
217
+ const slice = slicePath(config.srcDir, layer, naming.name);
218
+ const extending = fs_extra_1.default.existsSync(path_1.default.join(process.cwd(), slice));
219
+ const entries = [
220
+ // index.ts is handled separately when extending: it has to gain the new
221
+ // segments' exports without losing whatever is already in it (including
222
+ // lines someone edited by hand).
223
+ { template: "generate/slice/index.ts.hbs", output: `${slice}/index.ts`, when: () => !extending },
224
+ { template: "generate/slice/ui.tsx.hbs", output: `${slice}/ui/${naming.name}.tsx`, when: () => segments.ui },
225
+ { template: "generate/slice/model.ts.hbs", output: `${slice}/model/${naming.name}.ts`, when: () => segments.model },
226
+ { template: "generate/slice/api.ts.hbs", output: `${slice}/api/${naming.name}.ts`, when: () => segments.api },
227
+ { template: "generate/slice/lib.ts.hbs", output: `${slice}/lib/${naming.name}.ts`, when: () => segments.lib },
228
+ {
229
+ template: "generate/slice/errors.ts.hbs",
230
+ output: `${slice}/model/${naming.name}-errors.ts`,
231
+ when: () => segments.errors,
232
+ },
233
+ ];
234
+ const written = await (0, render_1.applyTemplates)(process.cwd(), entries, context, { skipExisting: extending });
235
+ if (extending) {
236
+ if (written.length === 0) {
237
+ throw new Error(`${slice} already has every segment this would write.\n` +
238
+ `It currently has: ${onDisk.join(", ") || "nothing"}.`);
239
+ }
240
+ // Rendered from the same template as a fresh index.ts, with only the new
241
+ // segments switched on, so the export lines cannot drift from the files
242
+ // they point at.
243
+ for (const line of (0, render_1.renderTemplate)("generate/slice/index.ts.hbs", { ...context, segments: added }).split("\n")) {
244
+ if (line.trim() !== "" && (0, project_1.appendExport)(process.cwd(), `${slice}/index.ts`, line)) {
245
+ if (!written.includes(`${slice}/index.ts`))
246
+ written.push(`${slice}/index.ts`);
247
+ }
248
+ }
249
+ }
250
+ (0, init_1.report)(written);
251
+ if (extending)
252
+ console.log(picocolors_1.default.dim(`\nextended the existing ${naming.name} slice; untouched files were left alone.`));
253
+ console.log(`\n${picocolors_1.default.dim("imported as")} import { ${naming.pascal} } from "${config.alias}/${layer}/${naming.name}";` +
254
+ `\n${picocolors_1.default.dim("only through that index.ts — reaching into ui/ is the boundary violation steiger reports.")}` +
255
+ `\n${picocolors_1.default.dim("until something imports it, steiger reports fsd/insignificant-slice — that is the linter working, not a mistake.")}`);
256
+ }
257
+ /**
258
+ * The route file that already re-exports this page slice, if any.
259
+ *
260
+ * Found by reading the app directory rather than by guessing the path: a page
261
+ * generated with `--route "(admin)/dashboard"` lives nowhere the slice name
262
+ * would predict, and the whole point is to notice a route that is not where
263
+ * the default would have put it.
264
+ */
265
+ function findRouteFor(projectDir, appDir, alias, name) {
266
+ const root = path_1.default.join(projectDir, appDir);
267
+ if (!fs_extra_1.default.existsSync(root))
268
+ return undefined;
269
+ const marker = `${alias}/_pages/${name}"`;
270
+ for (const entry of fs_extra_1.default.readdirSync(root, { recursive: true, encoding: "utf8" })) {
271
+ if (path_1.default.basename(entry) !== "page.tsx")
272
+ continue;
273
+ const file = path_1.default.join(root, entry);
274
+ if (fs_extra_1.default.readFileSync(file, "utf8").includes(marker)) {
275
+ return path_1.default.posix.join(appDir, entry.split(path_1.default.sep).join("/"));
276
+ }
277
+ }
278
+ return undefined;
279
+ }
280
+ function slicePath(srcDir, layer, name) {
281
+ return `${srcDir}/${layer}/${name}`;
282
+ }
283
+ /** Where each segment's generated file lands, relative to the slice. */
284
+ function segmentFile(segment, name) {
285
+ return segment === "errors" ? `model/${name}-errors.ts` : `${segment}/${name}.${segment === "ui" ? "tsx" : "ts"}`;
286
+ }
287
+ /** Which segments a slice already has on disk. */
288
+ function existingSegments(projectDir, slice, name) {
289
+ return [...types_1.SEGMENTS, "errors"].filter((segment) => fs_extra_1.default.existsSync(path_1.default.join(projectDir, slice, segmentFile(segment, name))));
290
+ }
291
+ /** Lists the page slices that exist, for the wizard-free error message. */
292
+ function existingPages(projectDir, srcDir) {
293
+ const dir = path_1.default.join(projectDir, srcDir, "_pages");
294
+ if (!fs_extra_1.default.existsSync(dir))
295
+ return [];
296
+ return fs_extra_1.default
297
+ .readdirSync(dir, { withFileTypes: true })
298
+ .filter((entry) => entry.isDirectory())
299
+ .map((entry) => entry.name);
300
+ }
301
+ function parseLayer(value) {
302
+ if (value === undefined)
303
+ return undefined;
304
+ const normalized = value.trim().toLowerCase();
305
+ if (!types_1.SLICE_LAYERS.includes(normalized)) {
306
+ throw new Error(`unknown layer "${value}" — use ${types_1.SLICE_LAYERS.join(", ")}.\n` +
307
+ "`_pages` slices come from `generate page`, and `_app`/`shared` are written by `init` and `add`.");
308
+ }
309
+ return normalized;
310
+ }
311
+ function parseSegments(value) {
312
+ const parsed = value
313
+ .split(",")
314
+ .map((segment) => segment.trim().toLowerCase())
315
+ .filter(Boolean);
316
+ const unknown = parsed.filter((segment) => !types_1.SEGMENTS.includes(segment));
317
+ if (unknown.length > 0) {
318
+ throw new Error(`unknown segment${unknown.length > 1 ? "s" : ""} ${unknown.join(", ")} — use ${types_1.SEGMENTS.join(", ")}`);
319
+ }
320
+ return [...new Set(parsed)];
321
+ }
322
+ function toTitleCase(kebab) {
323
+ return kebab
324
+ .split("-")
325
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
326
+ .join(" ");
327
+ }
328
+ // The no-TTY case reaches here only for values a prompt would have asked for.
329
+ // Listing all of them at once beats failing on the first one, then the second.
330
+ function assertInputs(what, name, opts) {
331
+ if (process.stdin.isTTY || name !== undefined)
332
+ return;
333
+ throw new Error(`no interactive terminal to prompt on — \`generate ${what}\` needs <name> as an argument (flags cover the rest; see --help)`);
334
+ }
335
+ function assertSliceInputs(layer, name, opts) {
336
+ if (process.stdin.isTTY)
337
+ return;
338
+ const missing = [];
339
+ if (layer === undefined)
340
+ missing.push("<layer>");
341
+ if (name === undefined)
342
+ missing.push("<name>");
343
+ if (opts.segments === undefined && !opts.defaults)
344
+ missing.push("--segments (or --defaults for ui only)");
345
+ if (missing.length > 0) {
346
+ throw new Error(`no interactive terminal to prompt on — \`generate slice\` is missing: ${missing.join(", ")}. ` +
347
+ "Pass them as arguments/flags, or add --defaults.");
348
+ }
349
+ }
350
+ /**
351
+ * A shared shell for a group of routes: the component in `_app/layouts` plus
352
+ * the `layout.tsx` that re-exports it.
353
+ *
354
+ * `_app`, not `_pages`: a layout is not one route's content, it is what
355
+ * several routes have in common, and the app layer is where cross-page
356
+ * composition lives. The route file defaults to a route group — `(admin)` —
357
+ * because that is a layout's usual reason to exist: shared chrome for a set of
358
+ * pages, contributing nothing to the URL.
359
+ */
360
+ async function generateLayout(rawName, opts) {
361
+ const config = (0, config_1.readConfig)(process.cwd());
362
+ assertInputs("layout", rawName, opts);
363
+ const name = rawName ??
364
+ (await (0, prompts_1.input)({
365
+ message: `Layout name (kebab-case, becomes ${config.srcDir}/_app/layouts/<name>-layout.tsx):`,
366
+ validate: naming_1.validateSliceName,
367
+ }));
368
+ const naming = (0, naming_1.resolveNaming)(name);
369
+ // "(admin)" rather than "admin": a layout's default home is a route group,
370
+ // which shares chrome without adding a URL segment.
371
+ const route = opts.route === undefined ? `(${naming.name})` : (0, naming_1.normalizeRoute)(opts.route);
372
+ const routeCheck = (0, naming_1.validateRoute)(route);
373
+ if (routeCheck !== true)
374
+ throw new Error(routeCheck);
375
+ const context = { ...naming, ...config, copy: (0, copy_1.copyFor)(config.locale) };
376
+ const layouts = `${config.srcDir}/_app/layouts`;
377
+ // Same rule as page and slice: an existing layout is being extended (given a
378
+ // route file it did not have), not recreated.
379
+ const extending = fs_extra_1.default.existsSync(path_1.default.join(process.cwd(), `${layouts}/${naming.name}-layout.tsx`));
380
+ const written = await (0, render_1.applyTemplates)(process.cwd(), [
381
+ { template: "generate/layout/layout.tsx.hbs", output: `${layouts}/${naming.name}-layout.tsx` },
382
+ {
383
+ template: "generate/layout/route.tsx.hbs",
384
+ output: path_1.default.posix.join(config.appDir, route, "layout.tsx"),
385
+ when: () => opts.routeFile !== false,
386
+ },
387
+ ], context, { skipExisting: extending });
388
+ if (extending && written.length === 0) {
389
+ throw new Error(`${layouts}/${naming.name}-layout.tsx already exists and is already applied at ${config.appDir}/${route}/layout.tsx.\n` +
390
+ "Pass --route <path> to apply it somewhere else as well.");
391
+ }
392
+ if ((0, project_1.appendExport)(process.cwd(), `${layouts}/index.ts`, `export { ${naming.pascal}Layout } from "./${naming.name}-layout";`)) {
393
+ written.push(`${layouts}/index.ts`);
394
+ }
395
+ (0, init_1.report)(written);
396
+ if (opts.routeFile === false) {
397
+ console.log(picocolors_1.default.yellow("\nno route file — add a layout.tsx that re-exports it when you want it applied."));
398
+ }
399
+ else {
400
+ console.log(`\n${picocolors_1.default.bold("Applies to:")} every route under ${config.appDir}/${route}/` +
401
+ (route.startsWith("(") ? picocolors_1.default.dim(" (a route group — it adds nothing to the URL)") : ""));
402
+ }
403
+ }
@@ -0,0 +1,229 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.initProject = initProject;
7
+ exports.addTailwindSources = addTailwindSources;
8
+ exports.report = report;
9
+ const path_1 = __importDefault(require("path"));
10
+ const fs_extra_1 = __importDefault(require("fs-extra"));
11
+ const picocolors_1 = __importDefault(require("picocolors"));
12
+ const prompts_1 = require("../prompts");
13
+ const config_1 = require("../utils/config");
14
+ const copy_1 = require("../utils/copy");
15
+ const render_1 = require("../utils/render");
16
+ const project_1 = require("../utils/project");
17
+ const version_1 = require("../utils/version");
18
+ const STEIGER_DEV_DEPS = {
19
+ "@feature-sliced/steiger-plugin": "^0.7.0",
20
+ steiger: "^0.6.0",
21
+ };
22
+ async function initProject(projectDir, opts) {
23
+ if ((0, config_1.isProjectDir)(projectDir)) {
24
+ throw new Error("already a nextjs-fsd project (nextjs-fsd.config.json exists) — use `generate` / `add` from here, or delete the config file to re-init");
25
+ }
26
+ // Fails before anything is written if this is not an App Router project:
27
+ // half-initialising a Pages Router app leaves a src/ tree nothing imports.
28
+ const appDir = (0, project_1.detectAppDir)(projectDir);
29
+ const srcDir = "src";
30
+ const alias = "@";
31
+ const packageManager = (0, project_1.detectPackageManager)(projectDir);
32
+ const locale = opts.locale ??
33
+ (opts.defaults
34
+ ? "th"
35
+ : (await (0, prompts_1.select)({
36
+ message: "Language for the generated user-facing copy?",
37
+ choices: [
38
+ { name: "Thai", value: "th" },
39
+ { name: "English", value: "en" },
40
+ ],
41
+ })));
42
+ // Posix, like every other relative path this CLI reports or stores: joining
43
+ // with `path.join` would print `src\app\globals.css` on Windows inside a
44
+ // file list where every other line uses `/`. `path.join(projectDir, ...)`
45
+ // below still normalises it for the filesystem.
46
+ const stylesheet = `${appDir}/globals.css`;
47
+ const movesStylesheet = fs_extra_1.default.existsSync(path_1.default.join(projectDir, stylesheet));
48
+ if (!(opts.yes || opts.defaults)) {
49
+ console.log(picocolors_1.default.bold("\nThis will:"));
50
+ for (const line of [
51
+ `keep Next.js routing in ${picocolors_1.default.cyan(appDir + "/")} and put the FSD layers in ${picocolors_1.default.cyan(srcDir + "/")} (_app, _pages, shared)`,
52
+ movesStylesheet
53
+ ? `move ${picocolors_1.default.cyan(stylesheet)} to ${picocolors_1.default.cyan(`${srcDir}/_app/styles/globals.css`)} and repoint the import in ${appDir}/layout.tsx`
54
+ : `create ${picocolors_1.default.cyan(`${srcDir}/_app/styles/globals.css`)}`,
55
+ `point the ${picocolors_1.default.cyan(`${alias}/*`)} tsconfig alias at ./${srcDir}/*`,
56
+ `add ${picocolors_1.default.cyan("eslint.fsd.mjs")} — the import boundary as ESLint rules, so a wrong-way import is flagged in your editor (no new dependencies)`,
57
+ `add steiger + the FSD plugin and a steiger.config.ts for the whole-tree checks ESLint cannot make, then chain both into the lint script`,
58
+ `add ${picocolors_1.default.cyan("components.json")} so \`shadcn add\` writes into ${srcDir}/shared/ui instead of ./components/ui`,
59
+ `write ${picocolors_1.default.cyan("docs/fsd.md")}, a ${picocolors_1.default.cyan(".claude/skills/nextjs-fsd")} skill, and point AGENTS.md at both`,
60
+ ]) {
61
+ console.log(` ${picocolors_1.default.dim("•")} ${line}`);
62
+ }
63
+ console.log();
64
+ if (!(await (0, prompts_1.confirm)({ message: "Proceed?", default: true }))) {
65
+ throw new Error("cancelled — nothing was written");
66
+ }
67
+ }
68
+ const context = {
69
+ srcDir,
70
+ appDir,
71
+ alias,
72
+ locale,
73
+ copy: (0, copy_1.copyFor)(locale),
74
+ lintCommand: `${packageManager} run lint`,
75
+ packageManager,
76
+ cssSourceApp: toPosix(path_1.default.relative(path_1.default.join(srcDir, "_app", "styles"), appDir)),
77
+ cssSourceSrc: toPosix(path_1.default.relative(path_1.default.join(srcDir, "_app", "styles"), srcDir)),
78
+ };
79
+ const written = await (0, render_1.applyTemplates)(projectDir, [
80
+ { template: "init/steiger.config.ts.hbs", output: "steiger.config.ts" },
81
+ { template: "init/eslint.fsd.mjs.hbs", output: "eslint.fsd.mjs" },
82
+ { template: "init/fsd.md.hbs", output: "docs/fsd.md" },
83
+ // Same content as the AGENTS.md section, aimed at the tool that reads
84
+ // .claude/skills — an agent's instinct on "add a settings screen" is to
85
+ // hand-write the files, which is exactly what the two linters then report.
86
+ { template: "init/skill.md.hbs", output: ".claude/skills/nextjs-fsd/SKILL.md" },
87
+ {
88
+ template: "init/globals.css.hbs",
89
+ output: `${srcDir}/_app/styles/globals.css`,
90
+ when: () => !movesStylesheet,
91
+ },
92
+ // Written before anyone runs `shadcn init`, because its own defaults put
93
+ // components in ./components/ui and a utils.ts at the project root —
94
+ // outside the layers entirely. The aliases here send them into
95
+ // shared/ui and shared/lib instead. Skipped if the project already has
96
+ // one; that file is the user's decision, not ours.
97
+ {
98
+ template: "init/components.json.hbs",
99
+ output: "components.json",
100
+ when: () => !fs_extra_1.default.existsSync(path_1.default.join(projectDir, "components.json")),
101
+ },
102
+ ], context);
103
+ if (movesStylesheet) {
104
+ // Moved rather than copied: the import in layout.tsx is its only
105
+ // reference, and leaving a second copy behind means the next person edits
106
+ // the one Tailwind no longer reads.
107
+ const destination = `${srcDir}/_app/styles/globals.css`;
108
+ await fs_extra_1.default.move(path_1.default.join(projectDir, stylesheet), path_1.default.join(projectDir, destination));
109
+ addTailwindSources(path_1.default.join(projectDir, destination), context.cssSourceApp, context.cssSourceSrc);
110
+ written.push(`${destination} (moved from ${stylesheet})`);
111
+ if ((0, project_1.patchLayoutStyleImport)(projectDir, appDir, alias))
112
+ written.push(`${appDir}/layout.tsx (stylesheet import)`);
113
+ else
114
+ console.log(picocolors_1.default.yellow(`\ncould not find \`import "./globals.css"\` in ${appDir}/layout.tsx — change it to \`import "${alias}/_app/styles/globals.css"\` by hand.`));
115
+ }
116
+ if ((0, project_1.patchTsconfigPaths)(projectDir, alias, srcDir))
117
+ written.push(`tsconfig.json (${alias}/* alias)`);
118
+ const eslintPatch = (0, project_1.patchEslintConfig)(projectDir);
119
+ if (eslintPatch === "patched")
120
+ written.push("eslint.config.mjs (spreads the FSD boundary rules)");
121
+ if ((0, project_1.appendScript)(projectDir, "lint", `steiger ./${srcDir}`))
122
+ written.push("package.json (lint script)");
123
+ written.push(...writeAgentDocs(projectDir, context));
124
+ if (eslintPatch !== "patched" && eslintPatch !== "already") {
125
+ console.log(picocolors_1.default.yellow(eslintPatch === "missing"
126
+ ? "\nno flat ESLint config found — add one, then spread the generated rules into it:"
127
+ : "\ncould not patch your ESLint config automatically — spread the generated rules into it by hand:") +
128
+ '\n import fsdBoundary from "./eslint.fsd.mjs";' +
129
+ "\n export default [...yourConfig, ...fsdBoundary];");
130
+ }
131
+ const added = (0, project_1.addDependencies)(projectDir, STEIGER_DEV_DEPS, "devDependencies");
132
+ const config = {
133
+ schemaVersion: config_1.CONFIG_SCHEMA_VERSION,
134
+ locale,
135
+ srcDir,
136
+ appDir,
137
+ alias,
138
+ packageManager,
139
+ features: (0, config_1.detectFeatures)(projectDir, srcDir),
140
+ scaffoldVersion: (0, version_1.cliVersion)(),
141
+ };
142
+ (0, config_1.writeConfig)(projectDir, config);
143
+ written.push("nextjs-fsd.config.json");
144
+ report(written, added);
145
+ if (added.length > 0 && opts.install !== false) {
146
+ (0, project_1.installDependencies)(projectDir, packageManager);
147
+ }
148
+ else if (added.length > 0) {
149
+ console.log(picocolors_1.default.yellow(`\nrun \`${packageManager} install\` to install: ${added.join(", ")}`));
150
+ }
151
+ // Only a render shows this one: create-next-app leaves
152
+ // `body { font-family: Arial, Helvetica, sans-serif }` in globals.css, and
153
+ // none of those faces carries Thai. The browser then falls back per glyph,
154
+ // so a Thai UI renders in a face nobody chose — and Thai stacks two marks
155
+ // above a consonant plus a vowel below, which Latin-tuned line heights clip.
156
+ // Not patched automatically: which face to use is the project's call, and
157
+ // rewriting someone's font stack is not what "init the layout" asked for.
158
+ if (locale === "th") {
159
+ console.log(picocolors_1.default.yellow("\nThai copy was generated, but this project's font stack cannot render it.") +
160
+ `\n ${picocolors_1.default.dim("globals.css sets")} body { font-family: Arial, Helvetica, sans-serif } ${picocolors_1.default.dim("— no Thai coverage in any of those.")}` +
161
+ `\n ${picocolors_1.default.dim("Load a Thai face in")} ${appDir}/layout.tsx ${picocolors_1.default.dim("and point --font-sans at it:")}` +
162
+ '\n import { Noto_Sans_Thai } from "next/font/google";' +
163
+ '\n const sans = Noto_Sans_Thai({ subsets: ["thai", "latin"], variable: "--font-sans" });' +
164
+ `\n ${picocolors_1.default.dim("Then drop the Arial rule and loosen the line heights per size — Thai needs the room.")}`);
165
+ }
166
+ console.log(`\n${picocolors_1.default.bold("Next:")} ${picocolors_1.default.cyan("nextjs-fsd generate page <name>")}, ` +
167
+ `${picocolors_1.default.cyan("nextjs-fsd add error-handling")}, ${picocolors_1.default.cyan("nextjs-fsd add auth")}`);
168
+ }
169
+ /**
170
+ * Adds an FSD section to AGENTS.md (creating it if absent) and a CLAUDE.md
171
+ * that includes it.
172
+ *
173
+ * Appended, never rewritten: AGENTS.md is usually already the project's own
174
+ * instructions file, and the FSD conventions are one section of it.
175
+ */
176
+ function writeAgentDocs(projectDir, context) {
177
+ const written = [];
178
+ const section = (0, render_1.renderTemplate)("init/agents-section.md.hbs", context);
179
+ const agents = path_1.default.join(projectDir, "AGENTS.md");
180
+ if (!fs_extra_1.default.existsSync(agents)) {
181
+ fs_extra_1.default.writeFileSync(agents, `# AGENTS.md\n${section}`);
182
+ written.push("AGENTS.md");
183
+ }
184
+ else if (!fs_extra_1.default.readFileSync(agents, "utf8").includes("Feature-Sliced Design")) {
185
+ fs_extra_1.default.appendFileSync(agents, section);
186
+ written.push("AGENTS.md (FSD section appended)");
187
+ }
188
+ const claude = path_1.default.join(projectDir, "CLAUDE.md");
189
+ if (!fs_extra_1.default.existsSync(claude)) {
190
+ fs_extra_1.default.writeFileSync(claude, (0, render_1.renderTemplate)("init/claude.md.hbs", context));
191
+ written.push("CLAUDE.md");
192
+ }
193
+ return written;
194
+ }
195
+ /**
196
+ * Names the trees Tailwind has to scan, because moving the stylesheet out of
197
+ * the route directory moves it out of what auto-detection would have found.
198
+ * Inserted after the last `@import` so it lands below `@import "tailwindcss"`
199
+ * — an `@source` above it is ignored.
200
+ */
201
+ function addTailwindSources(cssFile, appSource, srcSource) {
202
+ const source = fs_extra_1.default.readFileSync(cssFile, "utf8");
203
+ if (source.includes("@source"))
204
+ return;
205
+ const block = `\n/* This file lives in the FSD app layer, not next to the routes, so name the\n` +
206
+ ` trees Tailwind has to scan for class names explicitly instead of relying on\n` +
207
+ ` where auto-detection decides the project root is. */\n` +
208
+ `@source "${appSource}";\n@source "${srcSource}";\n`;
209
+ const imports = [...source.matchAll(/^@import .*$/gm)];
210
+ const last = imports[imports.length - 1];
211
+ // `last.index === 0` is a real position, not "not found" — a stylesheet
212
+ // whose very first line is `@import "tailwindcss"` is the common case.
213
+ if (last?.index === undefined) {
214
+ fs_extra_1.default.writeFileSync(cssFile, block.trimStart() + "\n" + source);
215
+ return;
216
+ }
217
+ const insertAt = source.indexOf("\n", last.index) + 1;
218
+ fs_extra_1.default.writeFileSync(cssFile, source.slice(0, insertAt) + block + source.slice(insertAt));
219
+ }
220
+ function toPosix(value) {
221
+ return value.split(path_1.default.sep).join("/");
222
+ }
223
+ function report(written, added = []) {
224
+ console.log();
225
+ for (const file of written)
226
+ console.log(` ${picocolors_1.default.green("+")} ${file}`);
227
+ for (const dep of added)
228
+ console.log(` ${picocolors_1.default.green("+")} ${picocolors_1.default.dim("package.json:")} ${dep}`);
229
+ }