@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.
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/bin/nextjs-fsd.js +2 -0
- package/dist/commands/add.js +182 -0
- package/dist/commands/config.js +53 -0
- package/dist/commands/generate.js +403 -0
- package/dist/commands/init.js +229 -0
- package/dist/index.js +291 -0
- package/dist/prompts.js +38 -0
- package/dist/types.js +5 -0
- package/dist/utils/config.js +86 -0
- package/dist/utils/copy.js +87 -0
- package/dist/utils/naming.js +76 -0
- package/dist/utils/project.js +288 -0
- package/dist/utils/render.js +66 -0
- package/dist/utils/version.js +23 -0
- package/package.json +66 -0
- package/templates/add/auth/auth-errors.ts.hbs +22 -0
- package/templates/add/auth/index.ts.hbs +4 -0
- package/templates/add/auth/login-form.tsx.hbs +48 -0
- package/templates/add/auth/login-index.ts.hbs +1 -0
- package/templates/add/auth/login-page.tsx.hbs +18 -0
- package/templates/add/auth/require-session.ts.hbs +29 -0
- package/templates/add/auth/session.ts.hbs +75 -0
- package/templates/add/errors/access-token.ts.hbs +19 -0
- package/templates/add/errors/api-error.ts.hbs +69 -0
- package/templates/add/errors/client.test.ts.hbs +61 -0
- package/templates/add/errors/client.ts.hbs +91 -0
- package/templates/add/errors/config-index.ts.hbs +1 -0
- package/templates/add/errors/env.ts.hbs +3 -0
- package/templates/add/errors/error-catalog.ts.hbs +19 -0
- package/templates/add/errors/error-resolver.ts.hbs +30 -0
- package/templates/add/errors/form-error.tsx.hbs +50 -0
- package/templates/add/errors/index.ts.hbs +5 -0
- package/templates/add/errors/providers.tsx.hbs +14 -0
- package/templates/add/errors/query-client.ts.hbs +25 -0
- package/templates/generate/layout/layout.tsx.hbs +20 -0
- package/templates/generate/layout/route.tsx.hbs +1 -0
- package/templates/generate/page/content.tsx.hbs +17 -0
- package/templates/generate/page/errors.ts.hbs +22 -0
- package/templates/generate/page/index.ts.hbs +1 -0
- package/templates/generate/page/page.tsx.hbs +22 -0
- package/templates/generate/page/route.tsx.hbs +3 -0
- package/templates/generate/slice/api.ts.hbs +42 -0
- package/templates/generate/slice/errors.ts.hbs +22 -0
- package/templates/generate/slice/index.ts.hbs +15 -0
- package/templates/generate/slice/lib.ts.hbs +4 -0
- package/templates/generate/slice/model.ts.hbs +10 -0
- package/templates/generate/slice/ui.tsx.hbs +17 -0
- package/templates/init/agents-section.md.hbs +25 -0
- package/templates/init/claude.md.hbs +1 -0
- package/templates/init/components.json.hbs +21 -0
- package/templates/init/eslint.fsd.mjs.hbs +91 -0
- package/templates/init/fsd.md.hbs +133 -0
- package/templates/init/globals.css.hbs +31 -0
- package/templates/init/skill.md.hbs +167 -0
- package/templates/init/steiger.config.ts.hbs +28 -0
|
@@ -0,0 +1,288 @@
|
|
|
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.packageJsonPath = packageJsonPath;
|
|
7
|
+
exports.readPackageJson = readPackageJson;
|
|
8
|
+
exports.detectPackageManager = detectPackageManager;
|
|
9
|
+
exports.detectAppDir = detectAppDir;
|
|
10
|
+
exports.addDependencies = addDependencies;
|
|
11
|
+
exports.installDependencies = installDependencies;
|
|
12
|
+
exports.runCommand = runCommand;
|
|
13
|
+
exports.patchTsconfigPaths = patchTsconfigPaths;
|
|
14
|
+
exports.appendScript = appendScript;
|
|
15
|
+
exports.appendExport = appendExport;
|
|
16
|
+
exports.appendEnvExample = appendEnvExample;
|
|
17
|
+
exports.patchLayoutStyleImport = patchLayoutStyleImport;
|
|
18
|
+
exports.patchLayoutProviders = patchLayoutProviders;
|
|
19
|
+
exports.patchEslintConfig = patchEslintConfig;
|
|
20
|
+
const path_1 = __importDefault(require("path"));
|
|
21
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
22
|
+
const child_process_1 = require("child_process");
|
|
23
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
24
|
+
function packageJsonPath(projectDir) {
|
|
25
|
+
return path_1.default.join(projectDir, "package.json");
|
|
26
|
+
}
|
|
27
|
+
function readPackageJson(projectDir) {
|
|
28
|
+
const file = packageJsonPath(projectDir);
|
|
29
|
+
if (!fs_extra_1.default.existsSync(file)) {
|
|
30
|
+
throw new Error(`no package.json in ${projectDir} — run this inside a Next.js project`);
|
|
31
|
+
}
|
|
32
|
+
return fs_extra_1.default.readJsonSync(file);
|
|
33
|
+
}
|
|
34
|
+
// Lockfile, not the `packageManager` field: the field is often absent and the
|
|
35
|
+
// lockfile is what actually decided which client installed node_modules.
|
|
36
|
+
function detectPackageManager(projectDir) {
|
|
37
|
+
const lockfiles = [
|
|
38
|
+
["bun.lock", "bun"],
|
|
39
|
+
["bun.lockb", "bun"],
|
|
40
|
+
["pnpm-lock.yaml", "pnpm"],
|
|
41
|
+
["yarn.lock", "yarn"],
|
|
42
|
+
["package-lock.json", "npm"],
|
|
43
|
+
];
|
|
44
|
+
for (const [lockfile, manager] of lockfiles) {
|
|
45
|
+
if (fs_extra_1.default.existsSync(path_1.default.join(projectDir, lockfile)))
|
|
46
|
+
return manager;
|
|
47
|
+
}
|
|
48
|
+
return "npm";
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Locates the Next.js App Router directory.
|
|
52
|
+
*
|
|
53
|
+
* Both layouts create-next-app can produce are supported as-is: `app/` at the
|
|
54
|
+
* root, and `src/app/` when "use src directory" was chosen. FSD's own app
|
|
55
|
+
* layer is `src/_app`, so it never collides with either — which is why this
|
|
56
|
+
* detects rather than moves anything.
|
|
57
|
+
*/
|
|
58
|
+
function detectAppDir(projectDir) {
|
|
59
|
+
const pkg = readPackageJson(projectDir);
|
|
60
|
+
const hasNext = Boolean(pkg.dependencies?.next ?? pkg.devDependencies?.next);
|
|
61
|
+
if (!hasNext) {
|
|
62
|
+
throw new Error("this package.json has no `next` dependency — create the app first (`bunx create-next-app@latest`), then run `nextjs-fsd init` inside it");
|
|
63
|
+
}
|
|
64
|
+
// Posix separators, deliberately, even on Windows: this value is not only a
|
|
65
|
+
// path. It is interpolated into ESLint `files` globs, Tailwind `@source`
|
|
66
|
+
// lines and the generated docs, and a glob with a backslash matches nothing
|
|
67
|
+
// — so a project initialised on Windows would silently lose the rule that
|
|
68
|
+
// keeps features out of the routing layer. `path.join` normalises it back
|
|
69
|
+
// for the filesystem calls that need it.
|
|
70
|
+
for (const candidate of ["app", "src/app"]) {
|
|
71
|
+
if (fs_extra_1.default.existsSync(path_1.default.join(projectDir, candidate, "layout.tsx")))
|
|
72
|
+
return candidate;
|
|
73
|
+
}
|
|
74
|
+
throw new Error("no App Router layout found at app/layout.tsx or src/app/layout.tsx — this CLI only supports the App Router, not the legacy pages/ router");
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Adds dependencies that are missing, leaving any already-declared version
|
|
78
|
+
* alone — a project pinned to axios ^1.5 should not get silently bumped
|
|
79
|
+
* because a template happened to be written against a newer one.
|
|
80
|
+
* Returns the names actually added.
|
|
81
|
+
*/
|
|
82
|
+
function addDependencies(projectDir, deps, kind = "dependencies") {
|
|
83
|
+
const file = packageJsonPath(projectDir);
|
|
84
|
+
const pkg = fs_extra_1.default.readJsonSync(file);
|
|
85
|
+
const target = { ...(pkg[kind] ?? {}) };
|
|
86
|
+
const declared = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
87
|
+
const added = [];
|
|
88
|
+
for (const [name, range] of Object.entries(deps)) {
|
|
89
|
+
if (declared[name])
|
|
90
|
+
continue;
|
|
91
|
+
target[name] = range;
|
|
92
|
+
added.push(name);
|
|
93
|
+
}
|
|
94
|
+
if (added.length === 0)
|
|
95
|
+
return [];
|
|
96
|
+
// Sorted so a diff of package.json stays reviewable instead of appending in
|
|
97
|
+
// whatever order a template listed its dependencies.
|
|
98
|
+
pkg[kind] = Object.fromEntries(Object.entries(target).sort(([a], [b]) => a.localeCompare(b)));
|
|
99
|
+
fs_extra_1.default.writeJsonSync(file, pkg, { spaces: 2 });
|
|
100
|
+
return added;
|
|
101
|
+
}
|
|
102
|
+
function installDependencies(projectDir, manager) {
|
|
103
|
+
const command = manager === "npm" ? ["npm", "install"] : [manager, "install"];
|
|
104
|
+
console.log(picocolors_1.default.dim(`> ${command.join(" ")}`));
|
|
105
|
+
(0, child_process_1.execFileSync)(command[0], command.slice(1), { cwd: projectDir, stdio: "inherit" });
|
|
106
|
+
}
|
|
107
|
+
function runCommand(projectDir, manager, args) {
|
|
108
|
+
const runner = manager === "npm" ? "npx" : manager === "yarn" ? "yarn" : manager === "pnpm" ? "pnpm" : "bunx";
|
|
109
|
+
console.log(picocolors_1.default.dim(`> ${runner} ${args.join(" ")}`));
|
|
110
|
+
(0, child_process_1.execFileSync)(runner, args, { cwd: projectDir, stdio: "inherit" });
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Puts `srcDir` first in the tsconfig `alias/*` path, keeping whatever was
|
|
114
|
+
* mapped there as a fallback.
|
|
115
|
+
*
|
|
116
|
+
* create-next-app maps `@/*` to `./*` (the project root) when it does not use
|
|
117
|
+
* a src directory, and TypeScript tries a paths array in order — so prepending
|
|
118
|
+
* makes `@/_pages/login` resolve while an existing `@/app/thing` import in the
|
|
119
|
+
* project keeps resolving too. Replacing the array outright would break those
|
|
120
|
+
* silently, in files this CLI never looked at.
|
|
121
|
+
*
|
|
122
|
+
* Returns false if srcDir was already first.
|
|
123
|
+
*/
|
|
124
|
+
function patchTsconfigPaths(projectDir, alias, srcDir) {
|
|
125
|
+
const file = path_1.default.join(projectDir, "tsconfig.json");
|
|
126
|
+
if (!fs_extra_1.default.existsSync(file))
|
|
127
|
+
throw new Error("no tsconfig.json — expected one in a Next.js TypeScript project");
|
|
128
|
+
// Read as text and parse leniently: create-next-app writes strict JSON, but
|
|
129
|
+
// a hand-edited tsconfig with comments is normal and JSON5 is not worth a
|
|
130
|
+
// dependency here. A parse failure is reported, not swallowed.
|
|
131
|
+
let config;
|
|
132
|
+
try {
|
|
133
|
+
config = fs_extra_1.default.readJsonSync(file);
|
|
134
|
+
}
|
|
135
|
+
catch {
|
|
136
|
+
throw new Error("could not parse tsconfig.json as JSON (comments?) — add this by hand instead:\n" +
|
|
137
|
+
` "paths": { "${alias}/*": ["./${srcDir}/*"] }`);
|
|
138
|
+
}
|
|
139
|
+
const key = `${alias}/*`;
|
|
140
|
+
const first = `./${srcDir}/*`;
|
|
141
|
+
const options = (config.compilerOptions ??= {});
|
|
142
|
+
const paths = (options.paths ??= {});
|
|
143
|
+
const existing = Array.isArray(paths[key]) ? paths[key] : [];
|
|
144
|
+
if (existing[0] === first)
|
|
145
|
+
return false;
|
|
146
|
+
paths[key] = [first, ...existing.filter((entry) => entry !== first)];
|
|
147
|
+
fs_extra_1.default.writeJsonSync(file, config, { spaces: 2 });
|
|
148
|
+
return true;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Adds a step to an npm script, or creates it. Appended with `&&` rather than
|
|
152
|
+
* replaced: `lint` already runs eslint in a fresh Next.js project and both
|
|
153
|
+
* checks matter.
|
|
154
|
+
*/
|
|
155
|
+
function appendScript(projectDir, name, step) {
|
|
156
|
+
const file = packageJsonPath(projectDir);
|
|
157
|
+
const pkg = fs_extra_1.default.readJsonSync(file);
|
|
158
|
+
const scripts = (pkg.scripts ??= {});
|
|
159
|
+
const existing = scripts[name];
|
|
160
|
+
if (existing?.includes(step))
|
|
161
|
+
return false;
|
|
162
|
+
scripts[name] = existing ? `${existing} && ${step}` : step;
|
|
163
|
+
fs_extra_1.default.writeJsonSync(file, pkg, { spaces: 2 });
|
|
164
|
+
return true;
|
|
165
|
+
}
|
|
166
|
+
/** Appends an export line to a barrel, creating it if absent. */
|
|
167
|
+
function appendExport(projectDir, barrel, line) {
|
|
168
|
+
const file = path_1.default.join(projectDir, barrel);
|
|
169
|
+
if (!fs_extra_1.default.existsSync(file)) {
|
|
170
|
+
fs_extra_1.default.ensureDirSync(path_1.default.dirname(file));
|
|
171
|
+
fs_extra_1.default.writeFileSync(file, `${line}\n`);
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const current = fs_extra_1.default.readFileSync(file, "utf8");
|
|
175
|
+
if (current.includes(line))
|
|
176
|
+
return false;
|
|
177
|
+
fs_extra_1.default.writeFileSync(file, current.replace(/\n*$/, "\n") + `${line}\n`);
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
/** Appends a block to .env.example, skipping it if the key is already there. */
|
|
181
|
+
function appendEnvExample(projectDir, key, block) {
|
|
182
|
+
const file = path_1.default.join(projectDir, ".env.example");
|
|
183
|
+
const current = fs_extra_1.default.existsSync(file) ? fs_extra_1.default.readFileSync(file, "utf8") : "";
|
|
184
|
+
if (current.includes(key))
|
|
185
|
+
return false;
|
|
186
|
+
fs_extra_1.default.writeFileSync(file, current === "" ? block : current.replace(/\n*$/, "\n\n") + block);
|
|
187
|
+
return true;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Repoints the root layout's stylesheet import at the FSD app layer.
|
|
191
|
+
*
|
|
192
|
+
* The CSS file moves because Tailwind v4's `@theme` is project-wide
|
|
193
|
+
* configuration, which is app-layer, not route-adjacent — and the import in
|
|
194
|
+
* layout.tsx is the only reference to its old path.
|
|
195
|
+
*/
|
|
196
|
+
function patchLayoutStyleImport(projectDir, appDir, alias) {
|
|
197
|
+
const file = path_1.default.join(projectDir, appDir, "layout.tsx");
|
|
198
|
+
const source = fs_extra_1.default.readFileSync(file, "utf8");
|
|
199
|
+
const target = `import "${alias}/_app/styles/globals.css";`;
|
|
200
|
+
if (source.includes(target))
|
|
201
|
+
return false;
|
|
202
|
+
const patched = source.replace(/^import\s+["']\.\/globals\.css["'];?[ \t]*$/m, target);
|
|
203
|
+
if (patched === source)
|
|
204
|
+
return false;
|
|
205
|
+
fs_extra_1.default.writeFileSync(file, patched);
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Wraps the root layout's JSX `{children}` in `<Providers>`.
|
|
210
|
+
*
|
|
211
|
+
* Anchored on `<body`, because `{ children }` appears twice in every App
|
|
212
|
+
* Router layout and the first one is the destructured parameter —
|
|
213
|
+
* `RootLayout({ children }: LayoutProps<"/">)`. Patching that one produces
|
|
214
|
+
* `RootLayout(<Providers>{children}</Providers>: ...)`, a file that no longer
|
|
215
|
+
* parses, which is why the search starts after the opening body tag instead of
|
|
216
|
+
* at the top of the file.
|
|
217
|
+
*
|
|
218
|
+
* Regex, not an AST: this runs once on a file whose shape create-next-app
|
|
219
|
+
* fixes, and every way it can miss — already wrapped, no <body>, no children —
|
|
220
|
+
* returns without touching the file so the caller can print instructions.
|
|
221
|
+
*/
|
|
222
|
+
function patchLayoutProviders(projectDir, appDir, alias) {
|
|
223
|
+
const file = path_1.default.join(projectDir, appDir, "layout.tsx");
|
|
224
|
+
const source = fs_extra_1.default.readFileSync(file, "utf8");
|
|
225
|
+
if (/<Providers[\s>]/.test(source))
|
|
226
|
+
return "already";
|
|
227
|
+
const bodyAt = source.indexOf("<body");
|
|
228
|
+
if (bodyAt === -1)
|
|
229
|
+
return "manual";
|
|
230
|
+
const children = /\{\s*children\s*\}/.exec(source.slice(bodyAt));
|
|
231
|
+
if (!children)
|
|
232
|
+
return "manual";
|
|
233
|
+
const imports = [...source.matchAll(/^import .*$/gm)];
|
|
234
|
+
const lastImport = imports[imports.length - 1];
|
|
235
|
+
// index 0 is a real position: a layout whose first line is an import.
|
|
236
|
+
if (lastImport?.index === undefined)
|
|
237
|
+
return "manual";
|
|
238
|
+
const importAt = source.indexOf("\n", lastImport.index) + 1;
|
|
239
|
+
// Children first, then the import: both edits are index-based, and the
|
|
240
|
+
// import sits earlier in the file, so doing the later one first keeps the
|
|
241
|
+
// earlier offset valid.
|
|
242
|
+
const childrenAt = bodyAt + children.index;
|
|
243
|
+
const withProvider = source.slice(0, childrenAt) +
|
|
244
|
+
"<Providers>" +
|
|
245
|
+
children[0] +
|
|
246
|
+
"</Providers>" +
|
|
247
|
+
source.slice(childrenAt + children[0].length);
|
|
248
|
+
const importLine = `import { Providers } from "${alias}/_app/providers";\n`;
|
|
249
|
+
fs_extra_1.default.writeFileSync(file, withProvider.slice(0, importAt) + importLine + withProvider.slice(importAt));
|
|
250
|
+
return "patched";
|
|
251
|
+
}
|
|
252
|
+
const ESLINT_CONFIG_FILES = ["eslint.config.mjs", "eslint.config.js", "eslint.config.ts", "eslint.config.cjs"];
|
|
253
|
+
/**
|
|
254
|
+
* Spreads the generated FSD boundary config into the project's flat ESLint
|
|
255
|
+
* config.
|
|
256
|
+
*
|
|
257
|
+
* A separate `eslint.fsd.mjs` plus a two-line patch, rather than injecting the
|
|
258
|
+
* rules into the array literal: create-next-app's config is
|
|
259
|
+
* `export default eslintConfig;` over a `defineConfig([...])` call, and
|
|
260
|
+
* splicing rules into that call means parsing JS to find the right closing
|
|
261
|
+
* bracket. Two anchors — the last import, and the default export — are all
|
|
262
|
+
* this needs, and a config someone has restructured falls through to printed
|
|
263
|
+
* instructions instead of a wrong edit.
|
|
264
|
+
*/
|
|
265
|
+
function patchEslintConfig(projectDir) {
|
|
266
|
+
const file = ESLINT_CONFIG_FILES.map((name) => path_1.default.join(projectDir, name)).find((candidate) => fs_extra_1.default.existsSync(candidate));
|
|
267
|
+
if (!file)
|
|
268
|
+
return "missing";
|
|
269
|
+
const source = fs_extra_1.default.readFileSync(file, "utf8");
|
|
270
|
+
if (source.includes("eslint.fsd.mjs"))
|
|
271
|
+
return "already";
|
|
272
|
+
// `export default <identifier>;` is the shape every create-next-app config
|
|
273
|
+
// has had. An inline array or call expression is left alone.
|
|
274
|
+
const exported = /^export default (\w+);?[ \t]*$/m.exec(source);
|
|
275
|
+
const imports = [...source.matchAll(/^import .*$/gm)];
|
|
276
|
+
const lastImport = imports[imports.length - 1];
|
|
277
|
+
if (!exported || lastImport?.index === undefined)
|
|
278
|
+
return "manual";
|
|
279
|
+
// The export sits after the imports, so replacing it first keeps the
|
|
280
|
+
// import offset computed from the original source valid.
|
|
281
|
+
const importAt = source.indexOf("\n", lastImport.index) + 1;
|
|
282
|
+
// A named const rather than `export default [...]`: eslint-config-next
|
|
283
|
+
// warns on an anonymous default export, and init should not hand someone a
|
|
284
|
+
// config file that lints with a warning.
|
|
285
|
+
const withExport = source.replace(exported[0], `const fsdEslintConfig = [...${exported[1]}, ...fsdBoundary];\nexport default fsdEslintConfig;`);
|
|
286
|
+
fs_extra_1.default.writeFileSync(file, withExport.slice(0, importAt) + 'import fsdBoundary from "./eslint.fsd.mjs";\n' + withExport.slice(importAt));
|
|
287
|
+
return "patched";
|
|
288
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
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.getTemplatesRoot = getTemplatesRoot;
|
|
7
|
+
exports.renderString = renderString;
|
|
8
|
+
exports.renderTemplate = renderTemplate;
|
|
9
|
+
exports.applyTemplates = applyTemplates;
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
13
|
+
const handlebars_1 = __importDefault(require("handlebars"));
|
|
14
|
+
handlebars_1.default.registerHelper("eq", (a, b) => a === b);
|
|
15
|
+
handlebars_1.default.registerHelper("includes", (list, value) => Array.isArray(list) && list.includes(value));
|
|
16
|
+
function getTemplatesRoot() {
|
|
17
|
+
const candidates = [
|
|
18
|
+
path_1.default.join(__dirname, "..", "..", "templates"),
|
|
19
|
+
path_1.default.join(__dirname, "..", "..", "..", "templates"),
|
|
20
|
+
];
|
|
21
|
+
const resolved = candidates.find((candidate) => fs_1.default.existsSync(path_1.default.join(candidate, "init", "steiger.config.ts.hbs")));
|
|
22
|
+
if (!resolved)
|
|
23
|
+
throw new Error("unable to locate templates directory");
|
|
24
|
+
return resolved;
|
|
25
|
+
}
|
|
26
|
+
function renderString(source, context) {
|
|
27
|
+
return handlebars_1.default.compile(source, { noEscape: true })(context);
|
|
28
|
+
}
|
|
29
|
+
function renderTemplate(template, context) {
|
|
30
|
+
const source = fs_1.default.readFileSync(path_1.default.join(getTemplatesRoot(), template), "utf8");
|
|
31
|
+
return renderString(source, context);
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Renders a set of templates, refusing the whole batch if any output already
|
|
35
|
+
* exists.
|
|
36
|
+
*
|
|
37
|
+
* All-or-nothing on purpose: a partial write leaves a slice with two of its
|
|
38
|
+
* four files rendered against a name the other two never saw, and the second
|
|
39
|
+
* run then refuses because of the files the first run made. Reporting every
|
|
40
|
+
* collision up front also means one message instead of one per re-run.
|
|
41
|
+
*/
|
|
42
|
+
async function applyTemplates(projectRoot, entries, context, opts = {}) {
|
|
43
|
+
const root = getTemplatesRoot();
|
|
44
|
+
let planned = entries.filter((entry) => !entry.when || entry.when(context));
|
|
45
|
+
const exists = (entry) => fs_extra_1.default.existsSync(path_1.default.join(projectRoot, entry.output));
|
|
46
|
+
if (opts.skipExisting) {
|
|
47
|
+
planned = planned.filter((entry) => entry.overwrite || !exists(entry));
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
const collisions = planned.filter((entry) => !entry.overwrite && exists(entry)).map((entry) => entry.output);
|
|
51
|
+
if (collisions.length > 0) {
|
|
52
|
+
throw new Error(`refusing to overwrite existing file${collisions.length > 1 ? "s" : ""}:\n` +
|
|
53
|
+
collisions.map((file) => ` ${file}`).join("\n") +
|
|
54
|
+
"\nDelete them first, or generate under a different name.");
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const written = [];
|
|
58
|
+
for (const entry of planned) {
|
|
59
|
+
const source = await fs_extra_1.default.readFile(path_1.default.join(root, entry.template), "utf8");
|
|
60
|
+
const outputPath = path_1.default.join(projectRoot, entry.output);
|
|
61
|
+
await fs_extra_1.default.ensureDir(path_1.default.dirname(outputPath));
|
|
62
|
+
await fs_extra_1.default.writeFile(outputPath, renderString(source, context));
|
|
63
|
+
written.push(entry.output);
|
|
64
|
+
}
|
|
65
|
+
return written;
|
|
66
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
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.cliVersion = cliVersion;
|
|
7
|
+
const path_1 = __importDefault(require("path"));
|
|
8
|
+
const fs_1 = __importDefault(require("fs"));
|
|
9
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
10
|
+
// cliVersion reads this CLI's own version out of its package.json — one source
|
|
11
|
+
// of truth for `--version` and for the stamp written into a project's
|
|
12
|
+
// nextjs-fsd.config.json. Two candidates because __dirname is dist/utils when
|
|
13
|
+
// installed and src/utils when running from a checkout.
|
|
14
|
+
function cliVersion() {
|
|
15
|
+
const candidates = [
|
|
16
|
+
path_1.default.join(__dirname, "..", "..", "package.json"),
|
|
17
|
+
path_1.default.join(__dirname, "..", "..", "..", "package.json"),
|
|
18
|
+
];
|
|
19
|
+
const resolved = candidates.find((candidate) => fs_1.default.existsSync(candidate));
|
|
20
|
+
if (!resolved)
|
|
21
|
+
throw new Error("unable to locate package.json");
|
|
22
|
+
return fs_extra_1.default.readJsonSync(resolved).version;
|
|
23
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@nakedev/nextjs-fsd",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Keep a Next.js App Router project on Feature-Sliced Design: init the layout, generate slices, add auth and API error handling",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "git+https://github.com/NakePranob/nextjs-fsd.git"
|
|
8
|
+
},
|
|
9
|
+
"bin": {
|
|
10
|
+
"nextjs-fsd": "bin/nextjs-fsd.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"templates",
|
|
15
|
+
"bin",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"type": "commonjs",
|
|
19
|
+
"packageManager": "pnpm@11.10.0",
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc",
|
|
22
|
+
"dev": "tsc --watch",
|
|
23
|
+
"test": "node --test tests/*.test.mjs && node scripts/smoke-test.mjs",
|
|
24
|
+
"verify": "pnpm run build && pnpm run test",
|
|
25
|
+
"prepack": "rm -rf dist && pnpm run build",
|
|
26
|
+
"test:smoke": "node scripts/smoke-test.mjs",
|
|
27
|
+
"test:integration": "node scripts/integration-test.mjs",
|
|
28
|
+
"prepublishOnly": "pnpm run verify"
|
|
29
|
+
},
|
|
30
|
+
"keywords": [
|
|
31
|
+
"nextjs",
|
|
32
|
+
"feature-sliced",
|
|
33
|
+
"fsd",
|
|
34
|
+
"scaffold",
|
|
35
|
+
"cli",
|
|
36
|
+
"generator"
|
|
37
|
+
],
|
|
38
|
+
"author": "nakedev",
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"publishConfig": {
|
|
41
|
+
"access": "public"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@inquirer/prompts": "^7.5.1",
|
|
45
|
+
"commander": "^15.0.0",
|
|
46
|
+
"fs-extra": "^11.3.0",
|
|
47
|
+
"handlebars": "^4.7.8",
|
|
48
|
+
"picocolors": "^1.1.1"
|
|
49
|
+
},
|
|
50
|
+
"devDependencies": {
|
|
51
|
+
"@tanstack/react-query": "^5.101.4",
|
|
52
|
+
"@types/bun": "^1.3.14",
|
|
53
|
+
"@types/fs-extra": "^11.0.4",
|
|
54
|
+
"@types/node": "^24.0.0",
|
|
55
|
+
"@types/react": "^19",
|
|
56
|
+
"@types/react-dom": "^19",
|
|
57
|
+
"axios": "^1.19.0",
|
|
58
|
+
"next": "16.3.4",
|
|
59
|
+
"react": "19.2.8",
|
|
60
|
+
"react-dom": "19.2.8",
|
|
61
|
+
"typescript": "^5.7.0"
|
|
62
|
+
},
|
|
63
|
+
"engines": {
|
|
64
|
+
"node": ">=20.9"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { commonErrorCatalog, resolveApiError, type ErrorCatalog } from "{{alias}}/shared/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Codes the auth surface owns. TODO: match the codes your API actually raises
|
|
5
|
+
* — these are the common ones, and an unmapped code falls through to the
|
|
6
|
+
* caller's fallback sentence rather than breaking anything.
|
|
7
|
+
*/
|
|
8
|
+
export const authErrorCatalog: ErrorCatalog = {
|
|
9
|
+
{{authCatalogEntries}}
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Pass this to `<FormError catalogs={...}>` on an auth screen. Auth first,
|
|
14
|
+
* then the codes every endpoint shares — a screen that needs to reword one
|
|
15
|
+
* code puts its own catalog in front.
|
|
16
|
+
*/
|
|
17
|
+
export const authErrorCatalogs: readonly ErrorCatalog[] = [authErrorCatalog, commonErrorCatalog];
|
|
18
|
+
|
|
19
|
+
/** For auth copy outside a `<FormError>` — a toast, a heading, a redirect reason. */
|
|
20
|
+
export function resolveAuthError(error: unknown, fallback = "{{copy.genericError}}"): string {
|
|
21
|
+
return resolveApiError(error, { catalogs: authErrorCatalogs, fallback });
|
|
22
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { getAccessToken, setAccessToken } from "./access-token";
|
|
2
|
+
export { authErrorCatalog, authErrorCatalogs, resolveAuthError } from "./auth-errors";
|
|
3
|
+
export { sessionKey, useLogin, useLogout, useSession, type LoginInput, type Session } from "./session";
|
|
4
|
+
export { useRequireSession } from "./require-session";
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { type FormEvent } from "react";
|
|
5
|
+
|
|
6
|
+
import { authErrorCatalogs, useLogin } from "{{alias}}/shared/auth";
|
|
7
|
+
import { FormError } from "{{alias}}/shared/ui";
|
|
8
|
+
|
|
9
|
+
const field = "rounded-md border border-black/15 px-3 py-2 text-sm dark:border-white/20";
|
|
10
|
+
|
|
11
|
+
export function LoginForm() {
|
|
12
|
+
const login = useLogin();
|
|
13
|
+
const router = useRouter();
|
|
14
|
+
|
|
15
|
+
function onSubmit(event: FormEvent<HTMLFormElement>) {
|
|
16
|
+
event.preventDefault();
|
|
17
|
+
const form = new FormData(event.currentTarget);
|
|
18
|
+
login.mutate(
|
|
19
|
+
{ email: String(form.get("email") ?? ""), password: String(form.get("password") ?? "") },
|
|
20
|
+
{ onSuccess: () => router.replace("/") },
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return (
|
|
25
|
+
// Native HTML validation only: `required`/`type="email"` catch the obvious
|
|
26
|
+
// mistakes before a round trip, and the API re-validates every field
|
|
27
|
+
// anyway. Swap in a schema library once a form here has rules the browser
|
|
28
|
+
// cannot express.
|
|
29
|
+
<form onSubmit={onSubmit} className="flex flex-col gap-4">
|
|
30
|
+
<FormError error={login.error} catalogs={authErrorCatalogs} fallback="{{copy.signInFailed}}" />
|
|
31
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
32
|
+
{{copy.email}}
|
|
33
|
+
<input name="email" type="email" required autoComplete="email" className={field} />
|
|
34
|
+
</label>
|
|
35
|
+
<label className="flex flex-col gap-1.5 text-sm">
|
|
36
|
+
{{copy.password}}
|
|
37
|
+
<input name="password" type="password" required autoComplete="current-password" className={field} />
|
|
38
|
+
</label>
|
|
39
|
+
<button
|
|
40
|
+
type="submit"
|
|
41
|
+
disabled={login.isPending}
|
|
42
|
+
className="mt-2 rounded-md bg-foreground px-3 py-2 text-sm text-background disabled:opacity-60"
|
|
43
|
+
>
|
|
44
|
+
{login.isPending ? "{{copy.signingIn}}" : "{{copy.signIn}}"}
|
|
45
|
+
</button>
|
|
46
|
+
</form>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { LoginPage, metadata } from "./ui/login-page";
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
|
|
3
|
+
import { LoginForm } from "./login-form";
|
|
4
|
+
|
|
5
|
+
export const metadata: Metadata = { title: "{{copy.signIn}}" };
|
|
6
|
+
|
|
7
|
+
// Server component — the form below is the only part that needs the browser.
|
|
8
|
+
export function LoginPage() {
|
|
9
|
+
return (
|
|
10
|
+
<main className="mx-auto flex min-h-svh w-full max-w-sm flex-col justify-center gap-6 p-6">
|
|
11
|
+
<div className="flex flex-col gap-1">
|
|
12
|
+
<h1 className="text-2xl font-semibold">{{copy.signIn}}</h1>
|
|
13
|
+
<p className="text-sm opacity-70">{{copy.signInSubtitle}}</p>
|
|
14
|
+
</div>
|
|
15
|
+
<LoginForm />
|
|
16
|
+
</main>
|
|
17
|
+
);
|
|
18
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useRouter } from "next/navigation";
|
|
4
|
+
import { useEffect } from "react";
|
|
5
|
+
|
|
6
|
+
import { useSession } from "./session";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Session for a page that needs one, sending anonymous visitors to /login.
|
|
10
|
+
*
|
|
11
|
+
* UX only — the real gate is the API's own auth middleware, which every
|
|
12
|
+
* request passes through regardless of what the browser rendered. It cannot
|
|
13
|
+
* move into `proxy.ts` (Next 16's renamed middleware) either: the refresh
|
|
14
|
+
* cookie belongs to the API's origin, so the Next server never sees it and
|
|
15
|
+
* has nothing to check.
|
|
16
|
+
*
|
|
17
|
+
* No return-to-url: add a `?next=` round trip when deep links into protected
|
|
18
|
+
* pages start mattering.
|
|
19
|
+
*/
|
|
20
|
+
export function useRequireSession() {
|
|
21
|
+
const session = useSession();
|
|
22
|
+
const router = useRouter();
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
if (session.isError) router.replace("/login");
|
|
26
|
+
}, [session.isError, router]);
|
|
27
|
+
|
|
28
|
+
return session;
|
|
29
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient, type QueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
import { ApiError, api } from "{{alias}}/shared/api";
|
|
6
|
+
|
|
7
|
+
import { setAccessToken } from "./access-token";
|
|
8
|
+
|
|
9
|
+
/** GET /users/me. TODO: match the fields your API actually returns. */
|
|
10
|
+
export type Session = {
|
|
11
|
+
id: string;
|
|
12
|
+
email: string;
|
|
13
|
+
name: string;
|
|
14
|
+
role: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type LoginInput = { email: string; password: string };
|
|
18
|
+
|
|
19
|
+
/** Body of login/refresh — the refresh token is an httpOnly cookie, not here. */
|
|
20
|
+
type AuthTokens = { access_token: string; token_type: string; expires_in: number };
|
|
21
|
+
|
|
22
|
+
export const sessionKey = ["session"] as const;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The signed-in user, or an error when there is none.
|
|
26
|
+
*
|
|
27
|
+
* On a fresh page load there is no access token yet, so this 401s once and the
|
|
28
|
+
* client's response interceptor spends the refresh cookie before retrying —
|
|
29
|
+
* meaning this hook doubles as the session bootstrap. `retry: false` because
|
|
30
|
+
* that recovery already happened one layer down: a 401 that reaches here means
|
|
31
|
+
* the cookie is gone too, and asking again will not help.
|
|
32
|
+
*/
|
|
33
|
+
export function useSession(): UseQueryResult<Session, ApiError> {
|
|
34
|
+
return useQuery<Session, ApiError>({
|
|
35
|
+
queryKey: sessionKey,
|
|
36
|
+
queryFn: () => api.get<Session>("/users/me").then((response) => response.data),
|
|
37
|
+
retry: false,
|
|
38
|
+
staleTime: 5 * 60_000,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function useLogin() {
|
|
43
|
+
const queryClient = useQueryClient();
|
|
44
|
+
return useMutation<void, ApiError, LoginInput>({
|
|
45
|
+
mutationFn: async (input) => {
|
|
46
|
+
const { data } = await api.post<AuthTokens>("/auth/login", input);
|
|
47
|
+
setAccessToken(data.access_token);
|
|
48
|
+
// Refetch rather than write a session object we do not have: the
|
|
49
|
+
// response carries tokens only, so /users/me stays the single source of
|
|
50
|
+
// the user's name and role.
|
|
51
|
+
await queryClient.invalidateQueries({ queryKey: sessionKey });
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function useLogout() {
|
|
57
|
+
const queryClient = useQueryClient();
|
|
58
|
+
return useMutation<void, ApiError, void>({
|
|
59
|
+
// Server-side too, not just locally: /auth/logout revokes the refresh
|
|
60
|
+
// token and clears the cookie, otherwise the next reload silently signs
|
|
61
|
+
// the user back in.
|
|
62
|
+
mutationFn: () => api.post("/auth/logout").then(() => undefined),
|
|
63
|
+
onSettled: () => endSessionLocally(queryClient),
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Ends the session on this device even when the server call failed — the user
|
|
69
|
+
* asked to leave. `clear()`, not `remove(sessionKey)`: every other cached
|
|
70
|
+
* query holds the previous user's data.
|
|
71
|
+
*/
|
|
72
|
+
function endSessionLocally(queryClient: QueryClient): void {
|
|
73
|
+
setAccessToken(null);
|
|
74
|
+
queryClient.clear();
|
|
75
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The access token lives in a module variable and nowhere else.
|
|
2
|
+
//
|
|
3
|
+
// The API returns it in the login/refresh response body and should keep the
|
|
4
|
+
// refresh token in an httpOnly cookie, so there is nothing to persist here: a
|
|
5
|
+
// reload starts with no token and the first 401 spends the cookie on a new
|
|
6
|
+
// one. localStorage would only make the token readable by any injected script.
|
|
7
|
+
//
|
|
8
|
+
// This file imports nothing on purpose — shared/api reads it inside the
|
|
9
|
+
// request interceptor and shared/auth writes it after login, so keeping it
|
|
10
|
+
// dependency-free is what stops those two from importing each other.
|
|
11
|
+
let accessToken: string | null = null;
|
|
12
|
+
|
|
13
|
+
export function getAccessToken(): string | null {
|
|
14
|
+
return accessToken;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function setAccessToken(token: string | null): void {
|
|
18
|
+
accessToken = token;
|
|
19
|
+
}
|