@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,69 @@
|
|
|
1
|
+
import axios from "axios";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Error envelope the API uses on failure — one `error` object with a stable
|
|
5
|
+
* machine code. Adjust the field names here if your backend words them
|
|
6
|
+
* differently; this is the only place that reads the wire format.
|
|
7
|
+
*/
|
|
8
|
+
type ErrorEnvelope = {
|
|
9
|
+
error?: {
|
|
10
|
+
code?: string;
|
|
11
|
+
message?: string;
|
|
12
|
+
details?: unknown;
|
|
13
|
+
request_id?: string;
|
|
14
|
+
};
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Normalised failure for anything crossing the API boundary. Callers branch on
|
|
19
|
+
* `code`/`status`, never on axios internals — that is the whole point of
|
|
20
|
+
* converting here, in the response interceptor, instead of leaking
|
|
21
|
+
* `AxiosError` into hooks and components.
|
|
22
|
+
*/
|
|
23
|
+
export class ApiError extends Error {
|
|
24
|
+
constructor(
|
|
25
|
+
readonly code: string,
|
|
26
|
+
message: string,
|
|
27
|
+
readonly status: number,
|
|
28
|
+
readonly details?: unknown,
|
|
29
|
+
readonly requestId?: string,
|
|
30
|
+
) {
|
|
31
|
+
super(message);
|
|
32
|
+
this.name = "ApiError";
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per-field messages for a VALIDATION_ERROR. Many backends only send these
|
|
37
|
+
* outside production, so every caller has to cope with this being
|
|
38
|
+
* undefined — never build UI that depends on a field map being present.
|
|
39
|
+
*/
|
|
40
|
+
get fieldErrors(): Record<string, string> | undefined {
|
|
41
|
+
if (this.code !== "VALIDATION_ERROR") return undefined;
|
|
42
|
+
const { details } = this;
|
|
43
|
+
if (!details || typeof details !== "object") return undefined;
|
|
44
|
+
const entries = Object.entries(details as Record<string, unknown>).filter(
|
|
45
|
+
([, value]) => typeof value === "string",
|
|
46
|
+
);
|
|
47
|
+
return entries.length > 0 ? (Object.fromEntries(entries) as Record<string, string>) : undefined;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function toApiError(error: unknown): ApiError {
|
|
52
|
+
if (error instanceof ApiError) return error;
|
|
53
|
+
|
|
54
|
+
if (axios.isAxiosError(error)) {
|
|
55
|
+
const status = error.response?.status ?? 0;
|
|
56
|
+
const envelope = (error.response?.data as ErrorEnvelope | undefined)?.error;
|
|
57
|
+
return new ApiError(
|
|
58
|
+
// status 0 means the request never got an answer — a dead API, DNS, or a
|
|
59
|
+
// CORS rejection all land here, and they read the same to the user.
|
|
60
|
+
envelope?.code ?? (status === 0 ? "NETWORK" : "UNKNOWN"),
|
|
61
|
+
envelope?.message ?? error.message,
|
|
62
|
+
status,
|
|
63
|
+
envelope?.details,
|
|
64
|
+
envelope?.request_id,
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return new ApiError("UNKNOWN", error instanceof Error ? error.message : String(error), 0);
|
|
69
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { AxiosAdapter } from "axios";
|
|
3
|
+
|
|
4
|
+
import { createApiClient } from "./client";
|
|
5
|
+
import { ApiError } from "./api-error";
|
|
6
|
+
import { setAccessToken } from "{{alias}}/shared/auth/access-token";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The two rules in the response interceptor that fail silently in a browser:
|
|
10
|
+
* a refresh storm logs the user out mid-session, and refreshing a login 401
|
|
11
|
+
* spends the cookie of whoever is already signed in. Both are testable only
|
|
12
|
+
* because createApiClient takes config — the adapter below stands in for the
|
|
13
|
+
* network.
|
|
14
|
+
*/
|
|
15
|
+
function stubAdapter(handle: (url: string) => { status: number; data?: unknown }): {
|
|
16
|
+
adapter: AxiosAdapter;
|
|
17
|
+
calls: string[];
|
|
18
|
+
} {
|
|
19
|
+
const calls: string[] = [];
|
|
20
|
+
const adapter: AxiosAdapter = async (config) => {
|
|
21
|
+
const url = config.url ?? "";
|
|
22
|
+
calls.push(url);
|
|
23
|
+
const { status, data } = handle(url);
|
|
24
|
+
const response = { data, status, statusText: "", headers: {}, config } as never;
|
|
25
|
+
if (status >= 400) throw Object.assign(new Error(`HTTP ${status}`), { isAxiosError: true, config, response });
|
|
26
|
+
return response;
|
|
27
|
+
};
|
|
28
|
+
return { adapter, calls };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
describe("api client", () => {
|
|
32
|
+
test("refreshes once for concurrent 401s, then replays both", async () => {
|
|
33
|
+
setAccessToken("stale");
|
|
34
|
+
let refreshed = false;
|
|
35
|
+
const { adapter, calls } = stubAdapter((url) => {
|
|
36
|
+
if (url === "/auth/refresh") {
|
|
37
|
+
refreshed = true;
|
|
38
|
+
return { status: 200, data: { access_token: "fresh" } };
|
|
39
|
+
}
|
|
40
|
+
return refreshed ? { status: 200, data: { ok: true } } : { status: 401 };
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
const { api } = createApiClient({ adapter });
|
|
44
|
+
const results = await Promise.all([api.get("/a"), api.get("/b")]);
|
|
45
|
+
|
|
46
|
+
expect(results.every((response) => response.status === 200)).toBe(true);
|
|
47
|
+
expect(calls.filter((url) => url === "/auth/refresh")).toHaveLength(1);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("does not refresh a 401 from /auth/*", async () => {
|
|
51
|
+
setAccessToken(null);
|
|
52
|
+
const { adapter, calls } = stubAdapter(() => ({ status: 401, data: { error: { code: "AUTH_INVALID_CREDENTIALS" } } }));
|
|
53
|
+
|
|
54
|
+
const { api } = createApiClient({ adapter });
|
|
55
|
+
const error = await api.post("/auth/login", {}).catch((thrown) => thrown);
|
|
56
|
+
|
|
57
|
+
expect(error).toBeInstanceOf(ApiError);
|
|
58
|
+
expect((error as ApiError).code).toBe("AUTH_INVALID_CREDENTIALS");
|
|
59
|
+
expect(calls).toEqual(["/auth/login"]);
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import axios, { type CreateAxiosDefaults, type InternalAxiosRequestConfig } from "axios";
|
|
2
|
+
|
|
3
|
+
import { getAccessToken, setAccessToken } from "{{alias}}/shared/auth/access-token";
|
|
4
|
+
import { API_URL } from "{{alias}}/shared/config";
|
|
5
|
+
|
|
6
|
+
import { toApiError } from "./api-error";
|
|
7
|
+
|
|
8
|
+
const REFRESH_PATH = "/auth/refresh";
|
|
9
|
+
|
|
10
|
+
type RetriableConfig = InternalAxiosRequestConfig & { _retried?: boolean };
|
|
11
|
+
|
|
12
|
+
type AccessTokenResponse = { access_token: string };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Builds the API client: bearer token in, normalised {@link ApiError} out, and
|
|
16
|
+
* one silent refresh attempt per failed request.
|
|
17
|
+
*
|
|
18
|
+
* A factory rather than a bare module instance so a test can pass its own
|
|
19
|
+
* adapter — the refresh handling below is the part worth pinning down.
|
|
20
|
+
*/
|
|
21
|
+
export function createApiClient(defaults: CreateAxiosDefaults = {}) {
|
|
22
|
+
// withCredentials so the browser attaches an httpOnly refresh cookie on the
|
|
23
|
+
// /auth/refresh call. Cross-origin by default (:3000 -> the API), which the
|
|
24
|
+
// API has to allow explicitly in its CORS config.
|
|
25
|
+
const config: CreateAxiosDefaults = { withCredentials: true, ...defaults };
|
|
26
|
+
const api = axios.create(config);
|
|
27
|
+
// Separate instance for the refresh call itself: on `api` it would hit the
|
|
28
|
+
// interceptor below and try to refresh its own 401, recursively.
|
|
29
|
+
const bare = axios.create(config);
|
|
30
|
+
|
|
31
|
+
let inFlight: Promise<string | null> | null = null;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Trades the refresh cookie for a new access token. Single-flight: every
|
|
35
|
+
* request that 401s while one refresh is running awaits that same promise.
|
|
36
|
+
* A backend that rotates the refresh token on use would otherwise see two
|
|
37
|
+
* concurrent calls race, and one would invalidate the other's cookie —
|
|
38
|
+
* logging the user out mid-session.
|
|
39
|
+
*/
|
|
40
|
+
function refreshAccessToken(): Promise<string | null> {
|
|
41
|
+
inFlight ??= bare
|
|
42
|
+
.post<AccessTokenResponse>(REFRESH_PATH)
|
|
43
|
+
.then(
|
|
44
|
+
(response) => {
|
|
45
|
+
const token = response.data.access_token;
|
|
46
|
+
setAccessToken(token);
|
|
47
|
+
return token;
|
|
48
|
+
},
|
|
49
|
+
() => {
|
|
50
|
+
// No usable cookie: drop the stale token so the next request goes
|
|
51
|
+
// out unauthenticated instead of retrying a dead one forever.
|
|
52
|
+
setAccessToken(null);
|
|
53
|
+
return null;
|
|
54
|
+
},
|
|
55
|
+
)
|
|
56
|
+
.finally(() => {
|
|
57
|
+
inFlight = null;
|
|
58
|
+
});
|
|
59
|
+
return inFlight;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
api.interceptors.request.use((request) => {
|
|
63
|
+
const token = getAccessToken();
|
|
64
|
+
if (token) request.headers.Authorization = `Bearer ${token}`;
|
|
65
|
+
return request;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
api.interceptors.response.use(undefined, async (error: unknown) => {
|
|
69
|
+
const failed = axios.isAxiosError(error) ? (error.config as RetriableConfig | undefined) : undefined;
|
|
70
|
+
// A 401 from /auth/* is an answer, not an expired token: wrong password, a
|
|
71
|
+
// spent challenge, a dead reset link. Refreshing on those would spend the
|
|
72
|
+
// cookie of whoever is already signed in on this browser and still
|
|
73
|
+
// surface the same error.
|
|
74
|
+
const fromAuthEndpoint = failed?.url?.startsWith("/auth/") ?? false;
|
|
75
|
+
|
|
76
|
+
if (axios.isAxiosError(error) && error.response?.status === 401 && failed && !failed._retried && !fromAuthEndpoint) {
|
|
77
|
+
failed._retried = true;
|
|
78
|
+
const token = await refreshAccessToken();
|
|
79
|
+
if (token) {
|
|
80
|
+
failed.headers.Authorization = `Bearer ${token}`;
|
|
81
|
+
return api.request(failed);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
throw toApiError(error);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
return { api, refreshAccessToken };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export const { api, refreshAccessToken } = createApiClient({ baseURL: API_URL });
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { API_URL } from "./env";
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A catalog maps the API's stable machine codes onto UI copy. One catalog per
|
|
3
|
+
* domain that owns those codes, never one global map: the domain that raises a
|
|
4
|
+
* code is the only place that knows what it means to a user, and a single map
|
|
5
|
+
* turns into a merge conflict magnet the moment two features grow at once.
|
|
6
|
+
*/
|
|
7
|
+
export type ErrorCatalog = Readonly<Record<string, string>>;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Codes any endpoint can answer with, plus the two `toApiError` mints
|
|
11
|
+
* client-side when the request never reached an answer at all.
|
|
12
|
+
*
|
|
13
|
+
* Every other catalog composes on top of this one, so a domain catalog only
|
|
14
|
+
* has to carry what is genuinely its own — generate one with
|
|
15
|
+
* `nextjs-fsd generate page <name> --errors`.
|
|
16
|
+
*/
|
|
17
|
+
export const commonErrorCatalog: ErrorCatalog = {
|
|
18
|
+
{{commonCatalogEntries}}
|
|
19
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ApiError } from "./api-error";
|
|
2
|
+
import type { ErrorCatalog } from "./error-catalog";
|
|
3
|
+
|
|
4
|
+
export type ResolveApiErrorOptions = {
|
|
5
|
+
/** Searched in order — first hit wins, so put the most specific first. */
|
|
6
|
+
catalogs?: readonly ErrorCatalog[];
|
|
7
|
+
fallback: string;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Turns anything a failed call can throw into one sentence, resolved from the
|
|
12
|
+
* catalogs the calling domain owns.
|
|
13
|
+
*
|
|
14
|
+
* `error.message` is deliberately never used as UI copy: the wording is a
|
|
15
|
+
* backend implementation detail that changes without anyone here noticing, and
|
|
16
|
+
* it is written for a developer reading a log. An unmapped code is a gap in a
|
|
17
|
+
* catalog, and a fallback that names the action that failed
|
|
18
|
+
* ("{{copy.saveFailed}}") is more useful to the user than a literal backend
|
|
19
|
+
* message would have been anyway.
|
|
20
|
+
*/
|
|
21
|
+
export function resolveApiError(error: unknown, { catalogs = [], fallback }: ResolveApiErrorOptions): string {
|
|
22
|
+
if (!(error instanceof ApiError)) return fallback;
|
|
23
|
+
|
|
24
|
+
for (const catalog of catalogs) {
|
|
25
|
+
const message = catalog[error.code];
|
|
26
|
+
if (message) return message;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return fallback;
|
|
30
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { ApiError, commonErrorCatalog, resolveApiError, type ErrorCatalog } from "{{alias}}/shared/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Renders a failed API call: one sentence resolved from the caller's catalogs,
|
|
5
|
+
* plus per-field details when the API sent them (usually dev only, which is
|
|
6
|
+
* why those stay untranslated).
|
|
7
|
+
*
|
|
8
|
+
* This component owns no copy of its own. The domain that raises a code owns
|
|
9
|
+
* the sentence for it — `authErrorCatalogs` from `{{alias}}/shared/auth`, or a
|
|
10
|
+
* `_pages/<page>/model/<page>-errors.ts` — so adding a feature never means
|
|
11
|
+
* editing a component in shared/ui.
|
|
12
|
+
*/
|
|
13
|
+
export function FormError({
|
|
14
|
+
error,
|
|
15
|
+
// Searched in order, so a screen that reads one code differently from the
|
|
16
|
+
// rest of its domain puts its own map first.
|
|
17
|
+
catalogs = [commonErrorCatalog],
|
|
18
|
+
fallback = "{{copy.genericError}}",
|
|
19
|
+
className,
|
|
20
|
+
}: {
|
|
21
|
+
error: unknown;
|
|
22
|
+
catalogs?: readonly ErrorCatalog[];
|
|
23
|
+
fallback?: string;
|
|
24
|
+
className?: string;
|
|
25
|
+
}) {
|
|
26
|
+
if (!error) return null;
|
|
27
|
+
|
|
28
|
+
const fields = error instanceof ApiError ? error.fieldErrors : undefined;
|
|
29
|
+
const message = resolveApiError(error, { catalogs, fallback });
|
|
30
|
+
|
|
31
|
+
return (
|
|
32
|
+
<div
|
|
33
|
+
role="alert"
|
|
34
|
+
className={["rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-700 dark:text-red-400", className]
|
|
35
|
+
.filter(Boolean)
|
|
36
|
+
.join(" ")}
|
|
37
|
+
>
|
|
38
|
+
<p>{message}</p>
|
|
39
|
+
{fields && (
|
|
40
|
+
<ul className="mt-1 list-inside list-disc">
|
|
41
|
+
{Object.entries(fields).map(([field, rule]) => (
|
|
42
|
+
<li key={field}>
|
|
43
|
+
{field}: {rule}
|
|
44
|
+
</li>
|
|
45
|
+
))}
|
|
46
|
+
</ul>
|
|
47
|
+
)}
|
|
48
|
+
</div>
|
|
49
|
+
);
|
|
50
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { ApiError, toApiError } from "./api-error";
|
|
2
|
+
export { commonErrorCatalog, type ErrorCatalog } from "./error-catalog";
|
|
3
|
+
export { resolveApiError, type ResolveApiErrorOptions } from "./error-resolver";
|
|
4
|
+
export { api, createApiClient, refreshAccessToken } from "./client";
|
|
5
|
+
export { makeQueryClient } from "./query-client";
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { QueryClientProvider } from "@tanstack/react-query";
|
|
4
|
+
import { useState, type ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
import { makeQueryClient } from "{{alias}}/shared/api";
|
|
7
|
+
|
|
8
|
+
export function Providers({ children }: { children: ReactNode }) {
|
|
9
|
+
// useState, not a module constant: one client per mount keeps a server
|
|
10
|
+
// render from sharing cache across requests.
|
|
11
|
+
const [queryClient] = useState(makeQueryClient);
|
|
12
|
+
|
|
13
|
+
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
|
|
14
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { QueryClient } from "@tanstack/react-query";
|
|
2
|
+
|
|
3
|
+
import { ApiError } from "./api-error";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* One QueryClient per browser session (see _app/providers) — created through a
|
|
7
|
+
* function, not as a module constant, so a server render never shares cache
|
|
8
|
+
* between two users' requests.
|
|
9
|
+
*/
|
|
10
|
+
export function makeQueryClient(): QueryClient {
|
|
11
|
+
return new QueryClient({
|
|
12
|
+
defaultOptions: {
|
|
13
|
+
queries: {
|
|
14
|
+
staleTime: 30_000,
|
|
15
|
+
// A 4xx is an answer, not a hiccup: retrying a 401/403/404 just delays
|
|
16
|
+
// the error the UI already has to handle. 5xx and network failures
|
|
17
|
+
// (status 0) are worth one more shot.
|
|
18
|
+
retry: (failureCount, error) => {
|
|
19
|
+
const retriable = !(error instanceof ApiError) || error.status === 0 || error.status >= 500;
|
|
20
|
+
return retriable && failureCount < 2;
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
});
|
|
25
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shell for the {{name}} routes.
|
|
5
|
+
*
|
|
6
|
+
* Takes a plain `{ children }` rather than `LayoutProps<...>`: Next emits no
|
|
7
|
+
* route-props type for a route group, since a group contributes nothing to the
|
|
8
|
+
* URL. Change it to `LayoutProps<"/{{name}}">` if you point this at a real path
|
|
9
|
+
* segment instead.
|
|
10
|
+
*
|
|
11
|
+
* A layout is composition, not a page — put anything with its own state or
|
|
12
|
+
* data in a slice and render that here.
|
|
13
|
+
*/
|
|
14
|
+
export function {{pascal}}Layout({ children }: { children: ReactNode }) {
|
|
15
|
+
return (
|
|
16
|
+
<div className="flex min-h-svh flex-col">
|
|
17
|
+
<main className="flex-1 p-6">{children}</main>
|
|
18
|
+
</div>
|
|
19
|
+
);
|
|
20
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { {{pascal}}Layout as default } from "{{alias}}/_app/layouts";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
{{#if auth}}
|
|
3
|
+
|
|
4
|
+
import { useRequireSession } from "{{alias}}/shared/auth";
|
|
5
|
+
{{/if}}
|
|
6
|
+
|
|
7
|
+
export function {{pascal}}Content() {
|
|
8
|
+
{{#if auth}}
|
|
9
|
+
// UX only. The API's own auth middleware is the real gate and it runs on
|
|
10
|
+
// every request no matter what the browser rendered, so never treat a
|
|
11
|
+
// rendered page as proof of a session.
|
|
12
|
+
const session = useRequireSession();
|
|
13
|
+
if (session.isPending || session.isError) return <p className="text-sm">{{copy.loading}}</p>;
|
|
14
|
+
|
|
15
|
+
{{/if}}
|
|
16
|
+
return <p className="text-sm">{{copy.todo}}</p>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { commonErrorCatalog, resolveApiError, type ErrorCatalog } from "{{alias}}/shared/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Machine codes this page's own endpoints answer with, mapped to the sentence
|
|
5
|
+
* a user should read.
|
|
6
|
+
*
|
|
7
|
+
* One catalog per domain that owns the codes, never one global map: the domain
|
|
8
|
+
* that raises a code is the only place that knows what it means to a user, and
|
|
9
|
+
* a single map turns into a merge conflict magnet the moment two features grow
|
|
10
|
+
* at once.
|
|
11
|
+
*/
|
|
12
|
+
export const {{camel}}ErrorCatalog: ErrorCatalog = {
|
|
13
|
+
// "{{screaming}}_NOT_FOUND": "…",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Pass to `<FormError catalogs={...}>`. Most specific first. */
|
|
17
|
+
export const {{camel}}ErrorCatalogs: readonly ErrorCatalog[] = [{{camel}}ErrorCatalog, commonErrorCatalog];
|
|
18
|
+
|
|
19
|
+
/** For copy outside a `<FormError>` — a toast, a heading, a redirect reason. */
|
|
20
|
+
export function resolve{{pascal}}Error(error: unknown, fallback = "{{copy.genericError}}"): string {
|
|
21
|
+
return resolveApiError(error, { catalogs: {{camel}}ErrorCatalogs, fallback });
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { {{pascal}}Page, metadata } from "./ui/{{name}}-page";
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Metadata } from "next";
|
|
2
|
+
{{#if hasContent}}
|
|
3
|
+
import { {{pascal}}Content } from "./{{name}}-content";
|
|
4
|
+
{{/if}}
|
|
5
|
+
|
|
6
|
+
export const metadata: Metadata = { title: "{{title}}" };
|
|
7
|
+
|
|
8
|
+
// Server component: no state, no effects, no browser API. Anything that needs
|
|
9
|
+
// those goes in a "use client" leaf under ui/ so this page's whole tree does
|
|
10
|
+
// not ship to the browser.
|
|
11
|
+
export function {{pascal}}Page() {
|
|
12
|
+
return (
|
|
13
|
+
<div className="flex flex-col gap-6">
|
|
14
|
+
<h1 className="text-2xl font-semibold">{{title}}</h1>
|
|
15
|
+
{{#if hasContent}}
|
|
16
|
+
<{{pascal}}Content />
|
|
17
|
+
{{else}}
|
|
18
|
+
<p className="text-sm">{{copy.todo}}</p>
|
|
19
|
+
{{/if}}
|
|
20
|
+
</div>
|
|
21
|
+
);
|
|
22
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMutation, useQuery, useQueryClient, type UseQueryResult } from "@tanstack/react-query";
|
|
4
|
+
|
|
5
|
+
import { ApiError, api } from "{{alias}}/shared/api";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The record this slice is about. TODO: replace with the shape the API
|
|
9
|
+
* actually answers.
|
|
10
|
+
*
|
|
11
|
+
* Named `{{pascal}}Record` rather than `{{pascal}}` so it can share a public API
|
|
12
|
+
* with the `{{pascal}}` component in ui/ — one `index.ts` cannot re-export two
|
|
13
|
+
* different things under one name, and a type nobody can import through the
|
|
14
|
+
* slice's public API is a type nobody can annotate against without breaking
|
|
15
|
+
* the import boundary.
|
|
16
|
+
*/
|
|
17
|
+
export type {{pascal}}Record = { id: string };
|
|
18
|
+
|
|
19
|
+
export const {{camel}}Key = ["{{name}}"] as const;
|
|
20
|
+
|
|
21
|
+
// Always through `api` — a bare fetch skips the bearer token, the
|
|
22
|
+
// single-flight 401 refresh, and the conversion into ApiError.
|
|
23
|
+
export function use{{pascal}}Query(): UseQueryResult<{{pascal}}Record[], ApiError> {
|
|
24
|
+
return useQuery<{{pascal}}Record[], ApiError>({
|
|
25
|
+
queryKey: {{camel}}Key,
|
|
26
|
+
queryFn: () => api.get<{{pascal}}Record[]>("/{{name}}").then((response) => response.data),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A write invalidates the keys it affected rather than writing the new value
|
|
32
|
+
* into the cache by hand — unless the response actually carries the whole
|
|
33
|
+
* object, the server is still the only thing that knows what got stored
|
|
34
|
+
* (generated ids, defaults, computed fields).
|
|
35
|
+
*/
|
|
36
|
+
export function useCreate{{pascal}}() {
|
|
37
|
+
const queryClient = useQueryClient();
|
|
38
|
+
return useMutation<{{pascal}}Record, ApiError, Omit<{{pascal}}Record, "id">>({
|
|
39
|
+
mutationFn: (input) => api.post<{{pascal}}Record>("/{{name}}", input).then((response) => response.data),
|
|
40
|
+
onSuccess: () => queryClient.invalidateQueries({ queryKey: {{camel}}Key }),
|
|
41
|
+
});
|
|
42
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { commonErrorCatalog, resolveApiError, type ErrorCatalog } from "{{alias}}/shared/api";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Machine codes this page's own endpoints answer with, mapped to the sentence
|
|
5
|
+
* a user should read.
|
|
6
|
+
*
|
|
7
|
+
* One catalog per domain that owns the codes, never one global map: the domain
|
|
8
|
+
* that raises a code is the only place that knows what it means to a user, and
|
|
9
|
+
* a single map turns into a merge conflict magnet the moment two features grow
|
|
10
|
+
* at once.
|
|
11
|
+
*/
|
|
12
|
+
export const {{camel}}ErrorCatalog: ErrorCatalog = {
|
|
13
|
+
// "{{screaming}}_NOT_FOUND": "…",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/** Pass to `<FormError catalogs={...}>`. Most specific first. */
|
|
17
|
+
export const {{camel}}ErrorCatalogs: readonly ErrorCatalog[] = [{{camel}}ErrorCatalog, commonErrorCatalog];
|
|
18
|
+
|
|
19
|
+
/** For copy outside a `<FormError>` — a toast, a heading, a redirect reason. */
|
|
20
|
+
export function resolve{{pascal}}Error(error: unknown, fallback = "{{copy.genericError}}"): string {
|
|
21
|
+
return resolveApiError(error, { catalogs: {{camel}}ErrorCatalogs, fallback });
|
|
22
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{{#if segments.ui}}
|
|
2
|
+
export { {{pascal}} } from "./ui/{{name}}";
|
|
3
|
+
{{/if}}
|
|
4
|
+
{{#if segments.model}}
|
|
5
|
+
export { use{{pascal}} } from "./model/{{name}}";
|
|
6
|
+
{{/if}}
|
|
7
|
+
{{#if segments.api}}
|
|
8
|
+
export { {{camel}}Key, useCreate{{pascal}}, use{{pascal}}Query, type {{pascal}}Record } from "./api/{{name}}";
|
|
9
|
+
{{/if}}
|
|
10
|
+
{{#if segments.lib}}
|
|
11
|
+
export { format{{pascal}} } from "./lib/{{name}}";
|
|
12
|
+
{{/if}}
|
|
13
|
+
{{#if segments.errors}}
|
|
14
|
+
export { {{camel}}ErrorCatalog, {{camel}}ErrorCatalogs, resolve{{pascal}}Error } from "./model/{{name}}-errors";
|
|
15
|
+
{{/if}}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
|
|
5
|
+
/** State this slice owns and that never leaves the browser. Anything that
|
|
6
|
+
* comes from the API belongs in api/ as a TanStack Query hook instead. */
|
|
7
|
+
export function use{{pascal}}() {
|
|
8
|
+
const [state, setState] = useState<string | null>(null);
|
|
9
|
+
return { state, setState };
|
|
10
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{{#if needsClient}}
|
|
2
|
+
"use client";
|
|
3
|
+
|
|
4
|
+
{{/if}}
|
|
5
|
+
{{#if segments.model}}
|
|
6
|
+
import { use{{pascal}} } from "../model/{{name}}";
|
|
7
|
+
|
|
8
|
+
{{/if}}
|
|
9
|
+
export function {{pascal}}() {
|
|
10
|
+
{{#if segments.model}}
|
|
11
|
+
const {{camel}} = use{{pascal}}();
|
|
12
|
+
|
|
13
|
+
return <p className="text-sm">{JSON.stringify({{camel}})}</p>;
|
|
14
|
+
{{else}}
|
|
15
|
+
return <p className="text-sm">{{copy.todo}}</p>;
|
|
16
|
+
{{/if}}
|
|
17
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
|
|
2
|
+
## Frontend architecture — Feature-Sliced Design
|
|
3
|
+
|
|
4
|
+
`{{srcDir}}/` uses FSD v2.1 with the layers `_app`, `_pages`, `shared`
|
|
5
|
+
(`features`/`entities` are added when a second consumer appears). Read
|
|
6
|
+
[docs/fsd.md](docs/fsd.md) before adding a file under `{{srcDir}}/` — it has the
|
|
7
|
+
import boundary, the segment rules, and why `{{appDir}}/` only ever re-exports.
|
|
8
|
+
|
|
9
|
+
Rules that are easy to break without noticing:
|
|
10
|
+
|
|
11
|
+
- imports point downwards only, and a slice is imported through its `index.ts`
|
|
12
|
+
- `{{appDir}}/<route>/page.tsx` re-exports the FSD page **and its `metadata`** — nothing else
|
|
13
|
+
- `"use client"` goes on the leaf that needs it, not on the page
|
|
14
|
+
- `{{lintCommand}}` runs both linters: ESLint (`eslint.fsd.mjs`) flags a wrong-way or slice-internal import per file, steiger (`steiger.config.ts`) flags whole-tree problems like a slice with no consumers
|
|
15
|
+
- editing `eslint.fsd.mjs`: flat config replaces a rule's options when a later block matches the same file, so all of a layer's `no-restricted-imports` patterns must stay in that layer's one block
|
|
16
|
+
|
|
17
|
+
Claude Code also loads this as a skill at
|
|
18
|
+
`.claude/skills/nextjs-fsd/SKILL.md`, which carries the same contract in the
|
|
19
|
+
form it reads first. `docs/fsd.md` is the source of truth if the two disagree.
|
|
20
|
+
|
|
21
|
+
Scaffold with `nextjs-fsd generate page|slice|layout` rather than by hand, so
|
|
22
|
+
slices keep the same shape. Re-running one extends what exists (adds a
|
|
23
|
+
segment, a leaf, an error catalog) and never rewrites a file you already have.
|
|
24
|
+
`shadcn add <name>` is already aimed at `{{srcDir}}/shared/ui` by
|
|
25
|
+
`components.json`.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
@AGENTS.md
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://ui.shadcn.com/schema.json",
|
|
3
|
+
"style": "new-york",
|
|
4
|
+
"rsc": true,
|
|
5
|
+
"tsx": true,
|
|
6
|
+
"tailwind": {
|
|
7
|
+
"config": "",
|
|
8
|
+
"css": "{{srcDir}}/_app/styles/globals.css",
|
|
9
|
+
"baseColor": "neutral",
|
|
10
|
+
"cssVariables": true,
|
|
11
|
+
"prefix": ""
|
|
12
|
+
},
|
|
13
|
+
"iconLibrary": "lucide",
|
|
14
|
+
"aliases": {
|
|
15
|
+
"components": "{{alias}}/shared",
|
|
16
|
+
"ui": "{{alias}}/shared/ui",
|
|
17
|
+
"lib": "{{alias}}/shared/lib",
|
|
18
|
+
"utils": "{{alias}}/shared/lib/utils",
|
|
19
|
+
"hooks": "{{alias}}/shared/lib"
|
|
20
|
+
}
|
|
21
|
+
}
|