@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
package/dist/index.js
ADDED
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
4
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
5
|
+
};
|
|
6
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
7
|
+
const commander_1 = require("commander");
|
|
8
|
+
const picocolors_1 = __importDefault(require("picocolors"));
|
|
9
|
+
const prompts_1 = require("./prompts");
|
|
10
|
+
const init_1 = require("./commands/init");
|
|
11
|
+
const generate_1 = require("./commands/generate");
|
|
12
|
+
const add_1 = require("./commands/add");
|
|
13
|
+
const config_1 = require("./commands/config");
|
|
14
|
+
const config_2 = require("./utils/config");
|
|
15
|
+
const copy_1 = require("./utils/copy");
|
|
16
|
+
const version_1 = require("./utils/version");
|
|
17
|
+
// fail is every command's catch, in one place so the two non-obvious cases stay
|
|
18
|
+
// consistent. @inquirer/prompts throws ExitPromptError on Ctrl-C and its raw
|
|
19
|
+
// message ("User force closed the prompt with 0 null") tells a user nothing.
|
|
20
|
+
// The no-TTY case normally never reaches here — prompts.ts rejects before a
|
|
21
|
+
// prompt starts — but stdin can also close mid-prompt, which arrives as the
|
|
22
|
+
// same error and deserves the same advice rather than "aborted".
|
|
23
|
+
function fail(err) {
|
|
24
|
+
const message = err.message ?? String(err);
|
|
25
|
+
if (err.name === "ExitPromptError") {
|
|
26
|
+
console.error(picocolors_1.default.red(process.stdin.isTTY ? "aborted" : prompts_1.NO_TTY_MESSAGE));
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
console.error(picocolors_1.default.red(message));
|
|
30
|
+
}
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
const program = new commander_1.Command();
|
|
34
|
+
program
|
|
35
|
+
.name("nextjs-fsd")
|
|
36
|
+
.description("Keep a Next.js App Router project on Feature-Sliced Design.\n\n" +
|
|
37
|
+
"Next.js creates the app (`create-next-app`); this only shapes what is inside it: `init` once, " +
|
|
38
|
+
"then `generate` for slices and `add` for the API error handling and auth wiring.\n\n" +
|
|
39
|
+
"Run `nextjs-fsd` with no arguments to pick what to do from a menu. Commands ask for whatever you omit; " +
|
|
40
|
+
"`--defaults` answers every question for CI.")
|
|
41
|
+
.version((0, version_1.cliVersion)());
|
|
42
|
+
program
|
|
43
|
+
.command("init")
|
|
44
|
+
.description("shape an existing Next.js App Router project into FSD layers (run this once, after create-next-app)")
|
|
45
|
+
.option("--locale <locale>", 'language for the generated user-facing copy: "th" (default) or "en"')
|
|
46
|
+
.option("--no-install", "write the files but do not run the package manager")
|
|
47
|
+
.option("--defaults", "skip every question; Thai copy, and no confirmation")
|
|
48
|
+
.option("-y, --yes", "skip only the confirmation summary")
|
|
49
|
+
.action(async (opts) => {
|
|
50
|
+
try {
|
|
51
|
+
await (0, init_1.initProject)(process.cwd(), {
|
|
52
|
+
locale: (0, copy_1.parseLocale)(opts.locale),
|
|
53
|
+
install: opts.install,
|
|
54
|
+
defaults: opts.defaults,
|
|
55
|
+
yes: opts.yes || opts.defaults,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
fail(err);
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
async function runGenerateWizard() {
|
|
63
|
+
const target = await (0, prompts_1.select)({
|
|
64
|
+
message: "What do you want to generate?",
|
|
65
|
+
choices: [
|
|
66
|
+
{ name: "Page (a _pages slice plus its route file)", value: "page" },
|
|
67
|
+
{ name: "Slice (features / entities / widgets)", value: "slice" },
|
|
68
|
+
{ name: "Layout (shared chrome for a group of routes)", value: "layout" },
|
|
69
|
+
],
|
|
70
|
+
});
|
|
71
|
+
if (target === "page")
|
|
72
|
+
await (0, generate_1.generatePage)(undefined, {});
|
|
73
|
+
else if (target === "slice")
|
|
74
|
+
await (0, generate_1.generateSlice)(undefined, undefined, {});
|
|
75
|
+
else
|
|
76
|
+
await (0, generate_1.generateLayout)(undefined, {});
|
|
77
|
+
}
|
|
78
|
+
const generate = program
|
|
79
|
+
.command("generate")
|
|
80
|
+
.alias("g")
|
|
81
|
+
.description("add a page or slice; bare `generate` opens a target wizard")
|
|
82
|
+
.action(async () => {
|
|
83
|
+
try {
|
|
84
|
+
await runGenerateWizard();
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
fail(err);
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
generate
|
|
91
|
+
.command("page [name]")
|
|
92
|
+
.alias("p")
|
|
93
|
+
.description("scaffold a _pages slice and the thin route file that re-exports it")
|
|
94
|
+
.option("--title <title>", "heading and browser title; defaults to the Title Case of the page name")
|
|
95
|
+
.option("--route <path>", 'App Router path; defaults to the page name. Route groups and dynamic segments work: "(admin)/dashboard", "loans/[id]"')
|
|
96
|
+
.option("--no-route", "write the slice only, no route file")
|
|
97
|
+
.option("--client", 'also create a "use client" leaf component')
|
|
98
|
+
.option("--auth", "the client leaf sits behind useRequireSession (needs `add auth`)")
|
|
99
|
+
.option("--errors", "add model/<name>-errors.ts, this page's own error catalog (needs `add error-handling`)")
|
|
100
|
+
.option("--defaults", "skip every question; server component only, route = the page name")
|
|
101
|
+
.action(async (name, opts) => {
|
|
102
|
+
try {
|
|
103
|
+
// commander folds --no-route into the same `route` key: false when it
|
|
104
|
+
// was passed, a string when --route was, undefined when neither.
|
|
105
|
+
const noRoute = opts.route === false;
|
|
106
|
+
await (0, generate_1.generatePage)(name, {
|
|
107
|
+
title: opts.title,
|
|
108
|
+
route: noRoute ? undefined : opts.route,
|
|
109
|
+
routeFile: noRoute ? false : undefined,
|
|
110
|
+
client: opts.client,
|
|
111
|
+
auth: opts.auth,
|
|
112
|
+
errors: opts.errors,
|
|
113
|
+
defaults: opts.defaults,
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
fail(err);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
generate
|
|
121
|
+
.command("slice [layer] [name]")
|
|
122
|
+
.alias("s")
|
|
123
|
+
.description("scaffold a features/entities/widgets slice with only the segments it needs")
|
|
124
|
+
.option("--segments <list>", "comma-separated: ui,model,api,lib (default ui)")
|
|
125
|
+
.option("--errors", "add model/<name>-errors.ts, this slice's own error catalog (needs `add error-handling`)")
|
|
126
|
+
.option("--defaults", "skip every question; ui segment only")
|
|
127
|
+
.action(async (layer, name, opts) => {
|
|
128
|
+
try {
|
|
129
|
+
await (0, generate_1.generateSlice)(layer, name, {
|
|
130
|
+
segments: opts.segments,
|
|
131
|
+
errors: opts.errors,
|
|
132
|
+
defaults: opts.defaults,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
catch (err) {
|
|
136
|
+
fail(err);
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
generate
|
|
140
|
+
.command("layout [name]")
|
|
141
|
+
.alias("l")
|
|
142
|
+
.description("scaffold a shared route shell in _app/layouts plus the layout.tsx that re-exports it")
|
|
143
|
+
.option("--route <path>", 'where it applies; defaults to the route group "(<name>)". A real segment works too: "admin"')
|
|
144
|
+
.option("--no-route", "write the component only, no layout.tsx")
|
|
145
|
+
.option("--defaults", "skip every question; route = the (<name>) group")
|
|
146
|
+
.action(async (name, opts) => {
|
|
147
|
+
try {
|
|
148
|
+
const noRoute = opts.route === false;
|
|
149
|
+
await (0, generate_1.generateLayout)(name, {
|
|
150
|
+
route: noRoute ? undefined : opts.route,
|
|
151
|
+
routeFile: noRoute ? false : undefined,
|
|
152
|
+
defaults: opts.defaults,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
catch (err) {
|
|
156
|
+
fail(err);
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
async function runAddWizard() {
|
|
160
|
+
// Read once, up front: the menu should say what is already installed rather
|
|
161
|
+
// than letting someone walk a confirmation to reach "already installed".
|
|
162
|
+
const config = (0, config_2.readConfig)(process.cwd());
|
|
163
|
+
const target = await (0, prompts_1.select)({
|
|
164
|
+
message: "What do you want to add?",
|
|
165
|
+
choices: [
|
|
166
|
+
{
|
|
167
|
+
name: "Error handling (ApiError, per-domain catalogs, axios client with a 401 refresh, QueryClient)",
|
|
168
|
+
value: "errors",
|
|
169
|
+
disabled: config.features.errorHandling ? "— already installed" : false,
|
|
170
|
+
},
|
|
171
|
+
{
|
|
172
|
+
name: "Auth (access token in memory, session hooks, route guard, login page)",
|
|
173
|
+
value: "auth",
|
|
174
|
+
disabled: config.features.auth ? "— already installed" : false,
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
});
|
|
178
|
+
if (target === "errors")
|
|
179
|
+
await (0, add_1.addErrorHandling)({});
|
|
180
|
+
else
|
|
181
|
+
await (0, add_1.addAuth)({});
|
|
182
|
+
}
|
|
183
|
+
const add = program
|
|
184
|
+
.command("add")
|
|
185
|
+
.description("add shared infrastructure; bare `add` opens an error-handling/auth wizard")
|
|
186
|
+
.action(async () => {
|
|
187
|
+
try {
|
|
188
|
+
await runAddWizard();
|
|
189
|
+
}
|
|
190
|
+
catch (err) {
|
|
191
|
+
fail(err);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
add
|
|
195
|
+
.command("error-handling")
|
|
196
|
+
.alias("errors")
|
|
197
|
+
.description("add shared/api: ApiError, per-domain error catalogs, a resolver, an axios client with a single-flight 401 refresh, and a QueryClient")
|
|
198
|
+
.option("--no-install", "write the files but do not run the package manager")
|
|
199
|
+
.option("-y, --yes", "skip the confirmation summary")
|
|
200
|
+
.action(async (opts) => {
|
|
201
|
+
try {
|
|
202
|
+
await (0, add_1.addErrorHandling)({ install: opts.install, yes: opts.yes });
|
|
203
|
+
}
|
|
204
|
+
catch (err) {
|
|
205
|
+
fail(err);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
add
|
|
209
|
+
.command("auth")
|
|
210
|
+
.description("add shared/auth and a login page: access token in memory, session hooks, useRequireSession (installs error handling first if missing)")
|
|
211
|
+
.option("--no-install", "write the files but do not run the package manager")
|
|
212
|
+
.option("-y, --yes", "skip the confirmation summary")
|
|
213
|
+
.action(async (opts) => {
|
|
214
|
+
try {
|
|
215
|
+
await (0, add_1.addAuth)({ install: opts.install, yes: opts.yes });
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
fail(err);
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
const config = program
|
|
222
|
+
.command("config")
|
|
223
|
+
.description("inspect the project config")
|
|
224
|
+
.action(() => {
|
|
225
|
+
try {
|
|
226
|
+
(0, config_1.showProjectConfig)();
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
fail(err);
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
config
|
|
233
|
+
.command("set <key> <value>")
|
|
234
|
+
.description('change a project setting; only `locale` (th|en) is settable, and it affects future generation only')
|
|
235
|
+
.action((key, value) => {
|
|
236
|
+
try {
|
|
237
|
+
if (key !== "locale") {
|
|
238
|
+
throw new Error(`unknown setting "${key}" — only \`locale\` can be set (th or en)`);
|
|
239
|
+
}
|
|
240
|
+
(0, config_1.setProjectLocale)(value);
|
|
241
|
+
}
|
|
242
|
+
catch (err) {
|
|
243
|
+
fail(err);
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
config
|
|
247
|
+
.command("show")
|
|
248
|
+
.description("print the resolved project config and which features are installed")
|
|
249
|
+
.action(() => {
|
|
250
|
+
try {
|
|
251
|
+
(0, config_1.showProjectConfig)();
|
|
252
|
+
}
|
|
253
|
+
catch (err) {
|
|
254
|
+
fail(err);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
// runTopMenu is bare `nextjs-fsd` — the command name is the one thing people
|
|
258
|
+
// remember, so give the same "ask, then delegate" menu the subcommands give
|
|
259
|
+
// when run bare, instead of Commander's static help (which lists commands but
|
|
260
|
+
// never lets you act on one).
|
|
261
|
+
//
|
|
262
|
+
// Deliberately NOT a .action() on the root: giving the root an action makes it
|
|
263
|
+
// callable, which turns a mistyped subcommand into "too many arguments"
|
|
264
|
+
// instead of Commander's "unknown command 'ad' (Did you mean add?)".
|
|
265
|
+
async function runTopMenu() {
|
|
266
|
+
if (!(0, config_2.isProjectDir)(process.cwd())) {
|
|
267
|
+
console.log(picocolors_1.default.dim(`${process.cwd()} isn't a nextjs-fsd project yet — only "init" can run here.\n`));
|
|
268
|
+
await (0, init_1.initProject)(process.cwd(), {});
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
const target = await (0, prompts_1.select)({
|
|
272
|
+
message: "What do you want to do?",
|
|
273
|
+
choices: [
|
|
274
|
+
{ name: "Generate (a page or a features/entities slice)", value: "generate" },
|
|
275
|
+
{ name: "Add (error handling / auth)", value: "add" },
|
|
276
|
+
{ name: "Show the project config", value: "config" },
|
|
277
|
+
],
|
|
278
|
+
});
|
|
279
|
+
if (target === "generate")
|
|
280
|
+
await runGenerateWizard();
|
|
281
|
+
else if (target === "add")
|
|
282
|
+
await runAddWizard();
|
|
283
|
+
else
|
|
284
|
+
(0, config_1.showProjectConfig)();
|
|
285
|
+
}
|
|
286
|
+
if (process.argv.length <= 2) {
|
|
287
|
+
runTopMenu().catch(fail);
|
|
288
|
+
}
|
|
289
|
+
else {
|
|
290
|
+
program.parseAsync(process.argv);
|
|
291
|
+
}
|
package/dist/prompts.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.checkbox = exports.select = exports.input = exports.confirm = exports.NO_TTY_MESSAGE = void 0;
|
|
4
|
+
exports.assertInteractive = assertInteractive;
|
|
5
|
+
const prompts_1 = require("@inquirer/prompts");
|
|
6
|
+
exports.NO_TTY_MESSAGE = "no interactive terminal to prompt on — pass every value as an argument/flag (see --help), or add --defaults";
|
|
7
|
+
// @inquirer/prompts does reject on a non-TTY stdin, but only after writing the
|
|
8
|
+
// question and its cursor escapes, so a CI log ends with an unanswerable
|
|
9
|
+
// question followed by raw ANSI followed by the real error. Answering the
|
|
10
|
+
// no-TTY case before the prompt starts leaves only the line that says what to
|
|
11
|
+
// do instead.
|
|
12
|
+
//
|
|
13
|
+
// Every prompt in this CLI comes from here rather than @inquirer/prompts
|
|
14
|
+
// directly, so a prompt added later gets this for free.
|
|
15
|
+
function assertInteractive() {
|
|
16
|
+
if (!process.stdin.isTTY)
|
|
17
|
+
throw new Error(exports.NO_TTY_MESSAGE);
|
|
18
|
+
}
|
|
19
|
+
const confirm = (...args) => {
|
|
20
|
+
assertInteractive();
|
|
21
|
+
return (0, prompts_1.confirm)(...args);
|
|
22
|
+
};
|
|
23
|
+
exports.confirm = confirm;
|
|
24
|
+
const input = (...args) => {
|
|
25
|
+
assertInteractive();
|
|
26
|
+
return (0, prompts_1.input)(...args);
|
|
27
|
+
};
|
|
28
|
+
exports.input = input;
|
|
29
|
+
const select = (...args) => {
|
|
30
|
+
assertInteractive();
|
|
31
|
+
return (0, prompts_1.select)(...args);
|
|
32
|
+
};
|
|
33
|
+
exports.select = select;
|
|
34
|
+
const checkbox = (...args) => {
|
|
35
|
+
assertInteractive();
|
|
36
|
+
return (0, prompts_1.checkbox)(...args);
|
|
37
|
+
};
|
|
38
|
+
exports.checkbox = checkbox;
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
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.CONFIG_SCHEMA_VERSION = void 0;
|
|
7
|
+
exports.configPath = configPath;
|
|
8
|
+
exports.isProjectDir = isProjectDir;
|
|
9
|
+
exports.writeConfig = writeConfig;
|
|
10
|
+
exports.detectFeatures = detectFeatures;
|
|
11
|
+
exports.readConfig = readConfig;
|
|
12
|
+
exports.setFeature = setFeature;
|
|
13
|
+
const path_1 = __importDefault(require("path"));
|
|
14
|
+
const fs_extra_1 = __importDefault(require("fs-extra"));
|
|
15
|
+
const project_1 = require("./project");
|
|
16
|
+
const CONFIG_FILE = "nextjs-fsd.config.json";
|
|
17
|
+
exports.CONFIG_SCHEMA_VERSION = 1;
|
|
18
|
+
function configPath(projectDir) {
|
|
19
|
+
return path_1.default.join(projectDir, CONFIG_FILE);
|
|
20
|
+
}
|
|
21
|
+
function isProjectDir(projectDir) {
|
|
22
|
+
return fs_extra_1.default.existsSync(configPath(projectDir));
|
|
23
|
+
}
|
|
24
|
+
function writeConfig(projectDir, config) {
|
|
25
|
+
fs_extra_1.default.writeJsonSync(configPath(projectDir), config, { spaces: 2 });
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Detects which features are actually on disk.
|
|
29
|
+
*
|
|
30
|
+
* Every command reads the config file, but a key the file does not define used
|
|
31
|
+
* to mean "false" to every caller — so `add auth` would re-install a
|
|
32
|
+
* shared/api that was already there and clobber the catalog someone had
|
|
33
|
+
* filled in. The tree always knew the answer; this stops the guessing. The
|
|
34
|
+
* file still wins wherever it has a value: an explicit `false` is an answer,
|
|
35
|
+
* not a hole.
|
|
36
|
+
*/
|
|
37
|
+
function detectFeatures(projectDir, srcDir = "src") {
|
|
38
|
+
const has = (relative) => fs_extra_1.default.existsSync(path_1.default.join(projectDir, srcDir, relative));
|
|
39
|
+
return {
|
|
40
|
+
errorHandling: has("shared/api/client.ts"),
|
|
41
|
+
auth: has("shared/auth/session.ts"),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
function readConfig(projectDir) {
|
|
45
|
+
const file = configPath(projectDir);
|
|
46
|
+
if (!fs_extra_1.default.existsSync(file)) {
|
|
47
|
+
throw new Error(`${projectDir} isn't a nextjs-fsd project — no ${CONFIG_FILE}.\n` +
|
|
48
|
+
"Run `nextjs-fsd init` in a Next.js App Router project first.");
|
|
49
|
+
}
|
|
50
|
+
const raw = fs_extra_1.default.readJsonSync(file);
|
|
51
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
52
|
+
throw new Error(`${CONFIG_FILE} must contain a JSON object`);
|
|
53
|
+
}
|
|
54
|
+
const schemaVersion = raw.schemaVersion ?? exports.CONFIG_SCHEMA_VERSION;
|
|
55
|
+
if (!Number.isInteger(schemaVersion) || schemaVersion < 1) {
|
|
56
|
+
throw new Error(`${CONFIG_FILE} has invalid schemaVersion "${String(schemaVersion)}" — expected a positive integer`);
|
|
57
|
+
}
|
|
58
|
+
if (schemaVersion > exports.CONFIG_SCHEMA_VERSION) {
|
|
59
|
+
throw new Error(`${CONFIG_FILE} uses schemaVersion ${schemaVersion}, but this CLI supports up to ${exports.CONFIG_SCHEMA_VERSION} — upgrade nextjs-fsd first`);
|
|
60
|
+
}
|
|
61
|
+
const srcDir = raw.srcDir ?? "src";
|
|
62
|
+
const appDir = raw.appDir ?? "app";
|
|
63
|
+
if (!fs_extra_1.default.existsSync(path_1.default.join(projectDir, appDir))) {
|
|
64
|
+
throw new Error(`${CONFIG_FILE} points appDir at "${appDir}", which doesn't exist — fix the path, or re-run \`nextjs-fsd init\``);
|
|
65
|
+
}
|
|
66
|
+
const detected = detectFeatures(projectDir, srcDir);
|
|
67
|
+
const features = { ...(raw.features ?? {}) };
|
|
68
|
+
for (const key of Object.keys(detected)) {
|
|
69
|
+
if (typeof features[key] !== "boolean")
|
|
70
|
+
features[key] = detected[key];
|
|
71
|
+
}
|
|
72
|
+
return {
|
|
73
|
+
schemaVersion,
|
|
74
|
+
locale: raw.locale ?? "th",
|
|
75
|
+
srcDir,
|
|
76
|
+
appDir,
|
|
77
|
+
alias: raw.alias ?? "@",
|
|
78
|
+
packageManager: raw.packageManager ?? (0, project_1.detectPackageManager)(projectDir),
|
|
79
|
+
features: features,
|
|
80
|
+
...(raw.scaffoldVersion ? { scaffoldVersion: raw.scaffoldVersion } : {}),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
function setFeature(projectDir, feature, value) {
|
|
84
|
+
const config = readConfig(projectDir);
|
|
85
|
+
writeConfig(projectDir, { ...config, features: { ...config.features, [feature]: value } });
|
|
86
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.copyFor = copyFor;
|
|
4
|
+
exports.parseLocale = parseLocale;
|
|
5
|
+
exports.asCatalogEntries = asCatalogEntries;
|
|
6
|
+
const th = {
|
|
7
|
+
loading: "กำลังโหลด…",
|
|
8
|
+
todo: "TODO: ยังไม่มีเนื้อหา",
|
|
9
|
+
genericError: "เกิดข้อผิดพลาด กรุณาลองอีกครั้ง",
|
|
10
|
+
saveFailed: "บันทึกไม่สำเร็จ",
|
|
11
|
+
signIn: "เข้าสู่ระบบ",
|
|
12
|
+
signInSubtitle: "กรอกอีเมลและรหัสผ่านเพื่อเข้าใช้งาน",
|
|
13
|
+
signingIn: "กำลังเข้าสู่ระบบ…",
|
|
14
|
+
signInFailed: "เข้าสู่ระบบไม่สำเร็จ",
|
|
15
|
+
signOut: "ออกจากระบบ",
|
|
16
|
+
email: "อีเมล",
|
|
17
|
+
password: "รหัสผ่าน",
|
|
18
|
+
common: {
|
|
19
|
+
VALIDATION_ERROR: "ข้อมูลที่กรอกไม่ถูกต้อง กรุณาตรวจสอบอีกครั้ง",
|
|
20
|
+
UNAUTHORIZED: "กรุณาเข้าสู่ระบบก่อนใช้งาน",
|
|
21
|
+
FORBIDDEN: "บัญชีนี้ไม่มีสิทธิ์ใช้งานส่วนนี้",
|
|
22
|
+
NOT_FOUND: "ไม่พบข้อมูลที่ต้องการ",
|
|
23
|
+
CONFLICT: "ข้อมูลขัดแย้งกับข้อมูลปัจจุบัน กรุณาโหลดใหม่",
|
|
24
|
+
RATE_LIMITED: "ส่งคำขอถี่เกินไป กรุณารอสักครู่",
|
|
25
|
+
INTERNAL: "ระบบขัดข้อง กรุณาลองใหม่อีกครั้ง",
|
|
26
|
+
NETWORK: "เชื่อมต่อเซิร์ฟเวอร์ไม่ได้ กรุณาตรวจสอบอินเทอร์เน็ตแล้วลองใหม่",
|
|
27
|
+
UNKNOWN: "เกิดข้อผิดพลาดที่ไม่รู้จัก กรุณาลองใหม่",
|
|
28
|
+
},
|
|
29
|
+
auth: {
|
|
30
|
+
AUTH_INVALID_CREDENTIALS: "อีเมลหรือรหัสผ่านไม่ถูกต้อง",
|
|
31
|
+
AUTH_INVALID_TOKEN: "เซสชันหมดอายุ กรุณาเข้าสู่ระบบอีกครั้ง",
|
|
32
|
+
AUTH_INVALID_PASSWORD: "รหัสผ่านต้องยาว 8–72 ตัวอักษร",
|
|
33
|
+
AUTH_TOO_MANY_ATTEMPTS: "ลองเข้าสู่ระบบผิดหลายครั้งเกินไป กรุณารอสักครู่แล้วลองใหม่",
|
|
34
|
+
USER_EMAIL_TAKEN: "อีเมลนี้ถูกใช้ไปแล้ว",
|
|
35
|
+
USER_NOT_FOUND: "ไม่พบผู้ใช้",
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
const en = {
|
|
39
|
+
loading: "Loading…",
|
|
40
|
+
todo: "TODO: nothing here yet",
|
|
41
|
+
genericError: "Something went wrong. Please try again.",
|
|
42
|
+
saveFailed: "Could not save.",
|
|
43
|
+
signIn: "Sign in",
|
|
44
|
+
signInSubtitle: "Enter your email and password to continue.",
|
|
45
|
+
signingIn: "Signing in…",
|
|
46
|
+
signInFailed: "Could not sign in.",
|
|
47
|
+
signOut: "Sign out",
|
|
48
|
+
email: "Email",
|
|
49
|
+
password: "Password",
|
|
50
|
+
common: {
|
|
51
|
+
VALIDATION_ERROR: "Some fields are invalid. Please check and try again.",
|
|
52
|
+
UNAUTHORIZED: "Please sign in to continue.",
|
|
53
|
+
FORBIDDEN: "This account may not use that.",
|
|
54
|
+
NOT_FOUND: "Not found.",
|
|
55
|
+
CONFLICT: "This conflicts with the current data. Please reload.",
|
|
56
|
+
RATE_LIMITED: "Too many requests. Please wait a moment.",
|
|
57
|
+
INTERNAL: "The server had a problem. Please try again.",
|
|
58
|
+
NETWORK: "Cannot reach the server. Check your connection and try again.",
|
|
59
|
+
UNKNOWN: "An unknown error occurred. Please try again.",
|
|
60
|
+
},
|
|
61
|
+
auth: {
|
|
62
|
+
AUTH_INVALID_CREDENTIALS: "Wrong email or password.",
|
|
63
|
+
AUTH_INVALID_TOKEN: "Your session expired. Please sign in again.",
|
|
64
|
+
AUTH_INVALID_PASSWORD: "The password must be 8–72 characters.",
|
|
65
|
+
AUTH_TOO_MANY_ATTEMPTS: "Too many failed attempts. Please wait and try again.",
|
|
66
|
+
USER_EMAIL_TAKEN: "That email is already in use.",
|
|
67
|
+
USER_NOT_FOUND: "User not found.",
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
function copyFor(locale) {
|
|
71
|
+
return locale === "en" ? en : th;
|
|
72
|
+
}
|
|
73
|
+
function parseLocale(value) {
|
|
74
|
+
if (value === undefined)
|
|
75
|
+
return undefined;
|
|
76
|
+
const normalized = value.trim().toLowerCase();
|
|
77
|
+
if (normalized !== "th" && normalized !== "en") {
|
|
78
|
+
throw new Error(`unknown --locale ${value} — use "th" or "en"`);
|
|
79
|
+
}
|
|
80
|
+
return normalized;
|
|
81
|
+
}
|
|
82
|
+
/** Renders a `Record<string, string>` as the body of a TS object literal. */
|
|
83
|
+
function asCatalogEntries(entries, indent = " ") {
|
|
84
|
+
return Object.entries(entries)
|
|
85
|
+
.map(([code, message]) => `${indent}${code}: ${JSON.stringify(message)},`)
|
|
86
|
+
.join("\n");
|
|
87
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.toKebabCase = toKebabCase;
|
|
4
|
+
exports.toPascalCase = toPascalCase;
|
|
5
|
+
exports.toCamelCase = toCamelCase;
|
|
6
|
+
exports.validateSliceName = validateSliceName;
|
|
7
|
+
exports.resolveNaming = resolveNaming;
|
|
8
|
+
exports.normalizeRoute = normalizeRoute;
|
|
9
|
+
exports.validateRoute = validateRoute;
|
|
10
|
+
function toKebabCase(value) {
|
|
11
|
+
return value
|
|
12
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
13
|
+
.replace(/[_\s]+/g, "-")
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.replace(/[^a-z0-9-]+/g, "-")
|
|
16
|
+
.replace(/^-+|-+$/g, "")
|
|
17
|
+
.replace(/-{2,}/g, "-");
|
|
18
|
+
}
|
|
19
|
+
function toPascalCase(value) {
|
|
20
|
+
return toKebabCase(value)
|
|
21
|
+
.split("-")
|
|
22
|
+
.filter(Boolean)
|
|
23
|
+
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
|
|
24
|
+
.join("");
|
|
25
|
+
}
|
|
26
|
+
function toCamelCase(value) {
|
|
27
|
+
const pascal = toPascalCase(value);
|
|
28
|
+
return pascal.charAt(0).toLowerCase() + pascal.slice(1);
|
|
29
|
+
}
|
|
30
|
+
// A slice name becomes three things at once: a directory, a file prefix, and a
|
|
31
|
+
// PascalCase component identifier. Only the identifier can be illegal, and it
|
|
32
|
+
// fails at type-check time in a file nobody wrote by hand — so reject it here,
|
|
33
|
+
// where the message can still name the input.
|
|
34
|
+
function validateSliceName(raw) {
|
|
35
|
+
const kebab = toKebabCase(raw);
|
|
36
|
+
if (!kebab)
|
|
37
|
+
return `invalid name "${raw}" — use letters and numbers, e.g. "reset-password"`;
|
|
38
|
+
if (/^[0-9]/.test(kebab)) {
|
|
39
|
+
return `"${kebab}" starts with a digit — the component would be \`export function ${toPascalCase(raw)}Page\`, which is not a valid identifier; pick another name`;
|
|
40
|
+
}
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
function resolveNaming(raw) {
|
|
44
|
+
const check = validateSliceName(raw);
|
|
45
|
+
if (check !== true)
|
|
46
|
+
throw new Error(check);
|
|
47
|
+
const name = toKebabCase(raw);
|
|
48
|
+
return {
|
|
49
|
+
name,
|
|
50
|
+
pascal: toPascalCase(name),
|
|
51
|
+
camel: toCamelCase(name),
|
|
52
|
+
screaming: name.replace(/-/g, "_").toUpperCase(),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
// App Router path for a page slice. Route groups stay verbatim — "(admin)"
|
|
56
|
+
// is a real directory Next.js reads and strips from the URL, so a caller
|
|
57
|
+
// passing `--route "(admin)/dashboard"` means exactly that.
|
|
58
|
+
function normalizeRoute(raw) {
|
|
59
|
+
const trimmed = raw.trim().replace(/^\/+|\/+$/g, "");
|
|
60
|
+
return trimmed;
|
|
61
|
+
}
|
|
62
|
+
const ROUTE_SEGMENT = /^(\([^()/]+\)|\[\[?\.{3}[A-Za-z][A-Za-z0-9_]*\]?\]|\[[A-Za-z][A-Za-z0-9_]*\]|@[a-z0-9-]+|[a-z0-9][a-z0-9._-]*)$/;
|
|
63
|
+
// Every segment either is a literal path piece or one of the App Router's own
|
|
64
|
+
// bracket forms. Validated because the segments become real directories: a
|
|
65
|
+
// stray "/" or space produces a route that never matches and a directory
|
|
66
|
+
// nobody expected.
|
|
67
|
+
function validateRoute(raw) {
|
|
68
|
+
const route = normalizeRoute(raw);
|
|
69
|
+
if (route === "")
|
|
70
|
+
return true; // "" means the root route, app/page.tsx
|
|
71
|
+
const bad = route.split("/").find((segment) => !ROUTE_SEGMENT.test(segment));
|
|
72
|
+
if (bad !== undefined) {
|
|
73
|
+
return `invalid route segment "${bad}" in "${route}" — use lowercase path segments, a route group "(admin)", a dynamic "[id]", or a catch-all "[...slug]"`;
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|