@dennisrongo/skills 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 +230 -0
- package/bin/claude-skills.js +189 -0
- package/lib/install.js +111 -0
- package/lib/list.js +63 -0
- package/lib/paths.js +39 -0
- package/lib/remove.js +52 -0
- package/lib/skills.js +121 -0
- package/package.json +48 -0
- package/skills/_template/SKILL.md +34 -0
- package/skills/conventional-commits/SKILL.md +136 -0
- package/skills/diagnose/SKILL.md +140 -0
- package/skills/dotnet-onion-api/SKILL.md +267 -0
- package/skills/dotnet-onion-api/references/anti-patterns.md +155 -0
- package/skills/dotnet-onion-api/references/solution-layout.md +113 -0
- package/skills/dotnet-onion-api/references/templates/appsettings.md +75 -0
- package/skills/dotnet-onion-api/references/templates/base-controller.cs.md +90 -0
- package/skills/dotnet-onion-api/references/templates/csproj-files.md +178 -0
- package/skills/dotnet-onion-api/references/templates/dbcontext.cs.md +149 -0
- package/skills/dotnet-onion-api/references/templates/exception-middleware.cs.md +101 -0
- package/skills/dotnet-onion-api/references/templates/feature-slice.md +349 -0
- package/skills/dotnet-onion-api/references/templates/program-cs.md +171 -0
- package/skills/dotnet-onion-api/references/templates/worker-program.cs.md +166 -0
- package/skills/grill-with-docs/SKILL.md +203 -0
- package/skills/handoff/SKILL.md +155 -0
- package/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +44 -0
- package/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/skills/improve-codebase-architecture/SKILL.md +121 -0
- package/skills/nextjs-app-router/SKILL.md +328 -0
- package/skills/nextjs-app-router/references/anti-patterns.md +203 -0
- package/skills/nextjs-app-router/references/folder-layout.md +151 -0
- package/skills/nextjs-app-router/references/good-patterns.md +212 -0
- package/skills/nextjs-app-router/references/templates/api-base.md +62 -0
- package/skills/nextjs-app-router/references/templates/api-slice.md +75 -0
- package/skills/nextjs-app-router/references/templates/auth-slice.md +95 -0
- package/skills/nextjs-app-router/references/templates/ci-and-hooks.md +94 -0
- package/skills/nextjs-app-router/references/templates/components-json.md +41 -0
- package/skills/nextjs-app-router/references/templates/env-and-utils.md +110 -0
- package/skills/nextjs-app-router/references/templates/eslint-prettier.md +82 -0
- package/skills/nextjs-app-router/references/templates/feature-slice.md +184 -0
- package/skills/nextjs-app-router/references/templates/form-with-zod.md +192 -0
- package/skills/nextjs-app-router/references/templates/middleware.md +88 -0
- package/skills/nextjs-app-router/references/templates/next-config.md +42 -0
- package/skills/nextjs-app-router/references/templates/package.md +99 -0
- package/skills/nextjs-app-router/references/templates/providers-and-store.md +79 -0
- package/skills/nextjs-app-router/references/templates/root-layout.md +146 -0
- package/skills/nextjs-app-router/references/templates/route-group-layouts.md +90 -0
- package/skills/nextjs-app-router/references/templates/tailwind-config.md +89 -0
- package/skills/nextjs-app-router/references/templates/testing.md +137 -0
- package/skills/nextjs-app-router/references/templates/tsconfig.md +40 -0
- package/skills/plan-and-build/SKILL.md +214 -0
- package/skills/pr-review/SKILL.md +132 -0
- package/skills/write-a-skill/SKILL.md +191 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# Canonical Folder Layout
|
|
2
|
+
|
|
3
|
+
This is the source-of-truth tree for a new project. Every file/folder listed here exists in a scaffolded project unless the user opted out. The skill must not reorganize this layout when adding features.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
{{project-name}}/
|
|
7
|
+
├── .env.example # committed; documents required env vars
|
|
8
|
+
├── .eslintrc.json # or eslint.config.mjs — strict TS + a11y + hooks
|
|
9
|
+
├── .gitignore
|
|
10
|
+
├── .husky/
|
|
11
|
+
│ └── pre-commit # runs lint-staged
|
|
12
|
+
├── .prettierrc
|
|
13
|
+
├── README.md
|
|
14
|
+
├── components.json # shadcn config; rsc: true, tsx: true
|
|
15
|
+
├── middleware.ts # AUTH GUARD. Wired via Next.js convention.
|
|
16
|
+
├── next.config.ts
|
|
17
|
+
├── next-env.d.ts # generated; do not edit
|
|
18
|
+
├── package.json # scripts: dev, build, start, lint, typecheck, test, test:e2e
|
|
19
|
+
├── playwright.config.ts
|
|
20
|
+
├── postcss.config.js
|
|
21
|
+
├── tailwind.config.ts
|
|
22
|
+
├── tsconfig.json # strict + @/* alias
|
|
23
|
+
├── vitest.config.ts
|
|
24
|
+
│
|
|
25
|
+
├── .github/
|
|
26
|
+
│ └── workflows/
|
|
27
|
+
│ └── ci.yml # install → lint → typecheck → test → build
|
|
28
|
+
│
|
|
29
|
+
├── public/ # static assets only (favicons, robots.txt, og images)
|
|
30
|
+
│ └── favicon.ico
|
|
31
|
+
│
|
|
32
|
+
├── src/
|
|
33
|
+
│ ├── app/
|
|
34
|
+
│ │ ├── layout.tsx # SERVER. <html><body><Providers>{children}
|
|
35
|
+
│ │ ├── page.tsx # SERVER. redirect('/dashboard') or render landing.
|
|
36
|
+
│ │ ├── error.tsx # global error boundary
|
|
37
|
+
│ │ ├── not-found.tsx
|
|
38
|
+
│ │ ├── globals.css
|
|
39
|
+
│ │ ├── favicon.ico # (optional duplicate of public/)
|
|
40
|
+
│ │ │
|
|
41
|
+
│ │ ├── (public)/ # unauthenticated routes
|
|
42
|
+
│ │ │ ├── layout.tsx # SERVER. minimal chrome (centered card).
|
|
43
|
+
│ │ │ └── auth/
|
|
44
|
+
│ │ │ ├── login/
|
|
45
|
+
│ │ │ │ ├── page.tsx
|
|
46
|
+
│ │ │ │ └── _components/LoginForm.tsx # 'use client'
|
|
47
|
+
│ │ │ ├── signup/
|
|
48
|
+
│ │ │ └── forgot-password/
|
|
49
|
+
│ │ │
|
|
50
|
+
│ │ ├── (app)/ # authenticated routes
|
|
51
|
+
│ │ │ ├── layout.tsx # SERVER. reads session, renders shell.
|
|
52
|
+
│ │ │ ├── loading.tsx
|
|
53
|
+
│ │ │ ├── error.tsx
|
|
54
|
+
│ │ │ ├── _components/
|
|
55
|
+
│ │ │ │ ├── AppShell.tsx # 'use client'; Sidebar + Header
|
|
56
|
+
│ │ │ │ └── Sidebar.tsx
|
|
57
|
+
│ │ │ ├── _hooks/
|
|
58
|
+
│ │ │ │ └── useSessionExpiryWarning.ts
|
|
59
|
+
│ │ │ ├── dashboard/
|
|
60
|
+
│ │ │ │ ├── page.tsx
|
|
61
|
+
│ │ │ │ ├── loading.tsx
|
|
62
|
+
│ │ │ │ └── _components/
|
|
63
|
+
│ │ │ └── <feature>/
|
|
64
|
+
│ │ │ ├── page.tsx
|
|
65
|
+
│ │ │ ├── loading.tsx
|
|
66
|
+
│ │ │ ├── schema.ts
|
|
67
|
+
│ │ │ ├── _components/
|
|
68
|
+
│ │ │ └── _hooks/
|
|
69
|
+
│ │ │
|
|
70
|
+
│ │ └── (admin)/ # OPTIONAL: role-gated. middleware enforces role.
|
|
71
|
+
│ │ └── layout.tsx
|
|
72
|
+
│ │
|
|
73
|
+
│ ├── components/
|
|
74
|
+
│ │ ├── ui/ # shadcn primitives. OWNED by the project.
|
|
75
|
+
│ │ │ ├── button.tsx
|
|
76
|
+
│ │ │ ├── input.tsx
|
|
77
|
+
│ │ │ ├── label.tsx
|
|
78
|
+
│ │ │ ├── form.tsx
|
|
79
|
+
│ │ │ ├── dialog.tsx
|
|
80
|
+
│ │ │ ├── toast.tsx
|
|
81
|
+
│ │ │ ├── toaster.tsx
|
|
82
|
+
│ │ │ └── use-toast.ts
|
|
83
|
+
│ │ ├── forms/ # composed form fields built on ui/form.tsx
|
|
84
|
+
│ │ │ ├── FormInputField.tsx
|
|
85
|
+
│ │ │ ├── FormPasswordField.tsx
|
|
86
|
+
│ │ │ ├── FormSelectField.tsx
|
|
87
|
+
│ │ │ ├── FormDatePickerField.tsx
|
|
88
|
+
│ │ │ └── FormTextareaField.tsx
|
|
89
|
+
│ │ ├── Notifications.tsx # toast-driven notification surface
|
|
90
|
+
│ │ ├── UnsavedChangesWarning.tsx # in-app + beforeunload guard
|
|
91
|
+
│ │ └── AlertMessage.tsx
|
|
92
|
+
│ │
|
|
93
|
+
│ ├── redux/
|
|
94
|
+
│ │ ├── store.ts # typed; combined reducer; logout resets state.
|
|
95
|
+
│ │ ├── providers.tsx # 'use client'; <Provider> + <Toaster> + <ProgressBar>
|
|
96
|
+
│ │ ├── hooks.ts # typed useAppDispatch, useAppSelector
|
|
97
|
+
│ │ ├── api/
|
|
98
|
+
│ │ │ ├── api.ts # base createApi + auth-aware baseQuery
|
|
99
|
+
│ │ │ ├── tags.ts # tag-type union
|
|
100
|
+
│ │ │ ├── authApi.ts # injectEndpoints
|
|
101
|
+
│ │ │ └── <domain>Api.ts
|
|
102
|
+
│ │ └── features/
|
|
103
|
+
│ │ ├── authSlice.ts
|
|
104
|
+
│ │ └── <other>Slice.ts # only for shared async/global state
|
|
105
|
+
│ │
|
|
106
|
+
│ ├── lib/
|
|
107
|
+
│ │ ├── utils.ts # cn() — clsx + tailwind-merge
|
|
108
|
+
│ │ ├── zod-utils.ts # getDefaultValuesFromSchema, unwrapZodEffects, getMaxLengthsFromSchema
|
|
109
|
+
│ │ ├── formatters/
|
|
110
|
+
│ │ │ ├── date.ts # date-fns wrappers ONLY. No moment.
|
|
111
|
+
│ │ │ └── currency.ts
|
|
112
|
+
│ │ └── hooks/
|
|
113
|
+
│ │ └── useDebouncedValue.ts
|
|
114
|
+
│ │
|
|
115
|
+
│ ├── config/
|
|
116
|
+
│ │ └── env.ts # Zod-parsed runtime env. Fail fast at startup.
|
|
117
|
+
│ │
|
|
118
|
+
│ └── types/ # cross-cutting TS types only.
|
|
119
|
+
│ └── api.ts # shared API envelope types if any
|
|
120
|
+
│
|
|
121
|
+
└── tests/
|
|
122
|
+
├── unit/
|
|
123
|
+
│ ├── auth.slice.test.ts
|
|
124
|
+
│ ├── login-form.test.tsx
|
|
125
|
+
│ └── zod-utils.test.ts
|
|
126
|
+
├── e2e/
|
|
127
|
+
│ └── auth.spec.ts
|
|
128
|
+
└── setup.ts # @testing-library/jest-dom, vi globals
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
## Rules that govern the layout
|
|
132
|
+
|
|
133
|
+
1. **Server-by-default in `app/`.** A file gets `'use client'` only if it itself uses a client API. A page can be a server component that renders a `'use client'` child from `_components/`.
|
|
134
|
+
|
|
135
|
+
2. **`_components/` and `_hooks/` are private.** Anything inside `_<name>/` may only be imported by files in the same route folder or its descendants. If two routes need the same component, lift it to `src/components/`.
|
|
136
|
+
|
|
137
|
+
3. **One RTK Query base; many injected slices.** `redux/api/api.ts` is the only file that calls `createApi(...)`. All other `*Api.ts` files import that `api` and call `api.injectEndpoints(...)`. Multiple `createApi` instances are forbidden — they fragment the cache and complicate invalidation.
|
|
138
|
+
|
|
139
|
+
4. **Domain models live with their feature.** A `Customer` type used only by `(app)/customers/` lives in `(app)/customers/types.ts` or is inferred from the Zod schema. Only cross-cutting types belong in `src/types/`. Avoid a global `src/models/` dumping ground.
|
|
140
|
+
|
|
141
|
+
5. **`middleware.ts` is the only auth gate at the network boundary.** No `src/proxy.ts` or `src/middleware/*.ts` standing in for it. Client guards exist only to handle session expiry during an active session.
|
|
142
|
+
|
|
143
|
+
6. **`tests/` mirrors `src/` loosely, not strictly.** Tests are organized by what they test, not by 1:1 directory parity.
|
|
144
|
+
|
|
145
|
+
7. **`public/` holds only static assets.** No code. Image components import from there via the standard `next/image` workflow.
|
|
146
|
+
|
|
147
|
+
8. **No duplicate-by-case paths.** Linux CI will break what works on Windows/macOS. The linter cannot catch this; the scaffolder must.
|
|
148
|
+
|
|
149
|
+
9. **Feature schemas are local.** `(app)/<feature>/schema.ts` exports Zod schemas owned by that feature. They are not re-exported from `src/lib/`.
|
|
150
|
+
|
|
151
|
+
10. **`components/ui/` is owned, not imported.** shadcn-style primitives are generated *into* the project and modified as needed. They are not a node_modules dependency.
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# Good Patterns to Keep
|
|
2
|
+
|
|
3
|
+
These are the patterns the scaffolder must emit by default. Each one is here because it solves a recurring problem in real-world Next.js + Redux + Tailwind projects.
|
|
4
|
+
|
|
5
|
+
## 1. Route groups for auth and role boundaries
|
|
6
|
+
|
|
7
|
+
`app/(public)/`, `app/(app)/`, `app/(admin)/` carve the route tree by who is allowed in. Each group has its own `layout.tsx`, so the chrome (or lack of it) for unauthenticated pages doesn't bleed into authenticated pages. Groups don't appear in URLs — they're purely an organizational tool.
|
|
8
|
+
|
|
9
|
+
**Why it works:** auth boundaries become a property of the file system, not a runtime check sprinkled into every page. The `middleware.ts` `config.matcher` mirrors the group structure and gates on the server.
|
|
10
|
+
|
|
11
|
+
## 2. Server-by-default with `'use client'` islands
|
|
12
|
+
|
|
13
|
+
`app/layout.tsx`, `app/page.tsx`, every group `layout.tsx`, and every feature `page.tsx` is a server component. The interactive parts — forms, table state, sidebars with toggles — are extracted into `_components/Something.tsx` files marked `'use client'` and imported from the server page.
|
|
14
|
+
|
|
15
|
+
**Why it works:** SSR for free, streaming, smaller client JS, `metadata` exports work, server-only data sources (cookies, DB calls) are reachable from the page.
|
|
16
|
+
|
|
17
|
+
## 3. Server `redirect()` and middleware-driven navigation
|
|
18
|
+
|
|
19
|
+
Top-level redirects (e.g. `/` → `/dashboard`) are server calls inside a server `page.tsx`. Auth-conditional redirects live in `middleware.ts`. The client `useRouter().push()` is reserved for in-app navigation triggered by user actions (button clicks, form submissions).
|
|
20
|
+
|
|
21
|
+
## 4. One RTK Query base, many injected slices
|
|
22
|
+
|
|
23
|
+
```ts
|
|
24
|
+
// redux/api/api.ts
|
|
25
|
+
export const api = createApi({
|
|
26
|
+
reducerPath: 'api',
|
|
27
|
+
baseQuery: authAwareBaseQuery,
|
|
28
|
+
tagTypes: TAG_TYPES,
|
|
29
|
+
endpoints: () => ({}),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
// redux/api/customersApi.ts
|
|
33
|
+
export const customersApi = api.injectEndpoints({
|
|
34
|
+
endpoints: (build) => ({
|
|
35
|
+
getCustomers: build.query<Customer[], void>({
|
|
36
|
+
query: () => '/customers',
|
|
37
|
+
providesTags: [{ type: 'Customers', id: 'LIST' }],
|
|
38
|
+
}),
|
|
39
|
+
createCustomer: build.mutation<Customer, NewCustomer>({
|
|
40
|
+
query: (body) => ({ url: '/customers', method: 'POST', body }),
|
|
41
|
+
invalidatesTags: [{ type: 'Customers', id: 'LIST' }],
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
});
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Why it works:** one cache, one middleware, one set of tag types. Cross-domain invalidation just works (a mutation in `invoicesApi` can invalidate `Customers` tags). The base file stays small; domain logic lives next to the domain.
|
|
48
|
+
|
|
49
|
+
## 5. Auth-aware `baseQuery` with clean 401 handling
|
|
50
|
+
|
|
51
|
+
`baseQuery` injects the session into requests (via cookies for `httpOnly` flows, or via a `prepareHeaders` callback that reads from state for bearer flows). On a 401, it dispatches a typed logout action that the root reducer uses to reset state, then signals the router (not `window.location`) to navigate to `/auth/login`.
|
|
52
|
+
|
|
53
|
+
**Why it works:** no hard reloads, no race conditions when multiple in-flight requests 401, and the redirect target stays consistent with whatever `middleware.ts` would have done.
|
|
54
|
+
|
|
55
|
+
## 6. State-reset on logout via wrapped root reducer
|
|
56
|
+
|
|
57
|
+
```ts
|
|
58
|
+
const combinedReducer = combineReducers({ ... });
|
|
59
|
+
|
|
60
|
+
export const store = configureStore({
|
|
61
|
+
reducer: (state, action) => {
|
|
62
|
+
if (authApi.endpoints.logout.matchFulfilled(action)) {
|
|
63
|
+
return combinedReducer(undefined, action);
|
|
64
|
+
}
|
|
65
|
+
return combinedReducer(state, action);
|
|
66
|
+
},
|
|
67
|
+
// ...
|
|
68
|
+
});
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
**Why it works:** logout wipes every slice (including RTK Query cache) in one place. No per-slice `extraReducers` listening for the logout action. Fully typed — no `@ts-ignore`.
|
|
72
|
+
|
|
73
|
+
## 7. Typed Redux hooks
|
|
74
|
+
|
|
75
|
+
`redux/hooks.ts` exports `useAppDispatch` and `useAppSelector` typed against `RootState` and `AppDispatch`. Components never import the raw `useDispatch` / `useSelector` from `react-redux`.
|
|
76
|
+
|
|
77
|
+
## 8. React Hook Form + Zod with schema-introspected defaults
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
const schema = z.object({ name: z.string().min(1), age: z.number().min(0) });
|
|
81
|
+
|
|
82
|
+
const form = useForm<z.infer<typeof schema>>({
|
|
83
|
+
resolver: zodResolver(schema),
|
|
84
|
+
defaultValues: getDefaultValuesFromSchema(unwrapZodEffects(schema)),
|
|
85
|
+
});
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
`getDefaultValuesFromSchema` walks the Zod tree and produces a defaults object that matches the schema shape (empty string for `z.string()`, `null` for nullable, `0` for numeric, etc.). `unwrapZodEffects` peels `.refine()` / `.transform()` wrappers so the introspection works.
|
|
89
|
+
|
|
90
|
+
`getMaxLengthsFromSchema` reads `z.string().max(N)` and feeds the `maxLength` attribute to the rendered `<Input>`.
|
|
91
|
+
|
|
92
|
+
**Why it works:** the schema is the single source of truth for both validation and field configuration. No drift between "what the form accepts" and "what the API will accept."
|
|
93
|
+
|
|
94
|
+
## 9. Composed form fields built on shadcn `<Form>`
|
|
95
|
+
|
|
96
|
+
`components/forms/FormInputField.tsx` and siblings take `form`, `schema`, `fieldName`, `formLabel` props and render a fully wired `<FormField>`, `<FormItem>`, `<FormLabel>`, `<FormControl>`, `<FormMessage>` block. Every form in the app reuses these.
|
|
97
|
+
|
|
98
|
+
**Why it works:** form layout/spacing/error display is consistent across the app, and changes to the shared field components propagate everywhere. New forms are mostly schema + a list of fields.
|
|
99
|
+
|
|
100
|
+
## 10. `UnsavedChangesWarning` tied to RHF `formState.isDirty`
|
|
101
|
+
|
|
102
|
+
A small component that hooks into in-app navigation (Next router events) and `window.beforeunload` to warn when a form has unsaved changes. Used inside every non-trivial form.
|
|
103
|
+
|
|
104
|
+
## 11. `error.tsx`, `loading.tsx`, `not-found.tsx` at meaningful segments
|
|
105
|
+
|
|
106
|
+
At minimum: global ones at `app/`, group-level ones at `(app)/`, and feature-level `loading.tsx` for any feature that fetches data via Suspense.
|
|
107
|
+
|
|
108
|
+
## 12. Runtime env validation
|
|
109
|
+
|
|
110
|
+
```ts
|
|
111
|
+
// src/config/env.ts
|
|
112
|
+
import { z } from 'zod';
|
|
113
|
+
|
|
114
|
+
const envSchema = z.object({
|
|
115
|
+
NEXT_PUBLIC_API_BASE_URL: z.string().url(),
|
|
116
|
+
NEXT_PUBLIC_PUSHER_KEY: z.string().optional(),
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
export const env = envSchema.parse({
|
|
120
|
+
NEXT_PUBLIC_API_BASE_URL: process.env.NEXT_PUBLIC_API_BASE_URL,
|
|
121
|
+
NEXT_PUBLIC_PUSHER_KEY: process.env.NEXT_PUBLIC_PUSHER_KEY,
|
|
122
|
+
});
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Why it works:** misconfiguration crashes the app at startup with a clear error, not deep inside a request handler with a cryptic `undefined` later.
|
|
126
|
+
|
|
127
|
+
## 13. shadcn primitives owned by the project
|
|
128
|
+
|
|
129
|
+
`components/ui/button.tsx`, `input.tsx`, `form.tsx`, etc. live in the repo and are editable. They are not pulled from `node_modules`. When the design system evolves, you edit these files; you don't fork a library.
|
|
130
|
+
|
|
131
|
+
## 14. `cn()` in `lib/utils.ts`
|
|
132
|
+
|
|
133
|
+
```ts
|
|
134
|
+
import { clsx, type ClassValue } from 'clsx';
|
|
135
|
+
import { twMerge } from 'tailwind-merge';
|
|
136
|
+
export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); }
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
Single source of class merging across the app.
|
|
140
|
+
|
|
141
|
+
## 15. Per-feature `_components/` and `_hooks/`
|
|
142
|
+
|
|
143
|
+
Co-locate the implementation details with the route. When a feature is deleted, the entire folder goes with it; no orphaned files in a shared directory. Promote to `src/components/` only when a second feature genuinely needs the same code.
|
|
144
|
+
|
|
145
|
+
## 16. One date library: `date-fns`
|
|
146
|
+
|
|
147
|
+
Tree-shakeable, immutable, ESM-friendly. Format helpers live in `lib/formatters/date.ts` so the rest of the codebase doesn't import `date-fns` directly — the wrapper makes it easy to change the underlying library later.
|
|
148
|
+
|
|
149
|
+
## 17. Strict TypeScript
|
|
150
|
+
|
|
151
|
+
`tsconfig.json` with `"strict": true`, `"noUncheckedIndexedAccess": true`, `"forceConsistentCasingInFileNames": true`. The last one catches the duplicate-by-case folder bug at compile time, which is the only place to catch it before Linux production.
|
|
152
|
+
|
|
153
|
+
## 18. Strict ESLint
|
|
154
|
+
|
|
155
|
+
`@typescript-eslint/recommended`, `plugin:react-hooks/recommended`, `plugin:jsx-a11y/recommended`, plus `no-console: warn`, `no-explicit-any: error`, `exhaustive-deps: error`. Pre-commit hook runs `eslint --fix` so the rules actually get enforced.
|
|
156
|
+
|
|
157
|
+
## 19. `middleware.ts` as the auth gate
|
|
158
|
+
|
|
159
|
+
```ts
|
|
160
|
+
// middleware.ts
|
|
161
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
162
|
+
|
|
163
|
+
export function middleware(req: NextRequest) {
|
|
164
|
+
const session = req.cookies.get('session')?.value;
|
|
165
|
+
if (!session && req.nextUrl.pathname.startsWith('/app')) {
|
|
166
|
+
const url = req.nextUrl.clone();
|
|
167
|
+
url.pathname = '/auth/login';
|
|
168
|
+
url.searchParams.set('returnTo', req.nextUrl.pathname);
|
|
169
|
+
return NextResponse.redirect(url);
|
|
170
|
+
}
|
|
171
|
+
return NextResponse.next();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const config = {
|
|
175
|
+
matcher: ['/((?!_next/static|_next/image|favicon.ico|api/public).*)'],
|
|
176
|
+
};
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
(The matcher syntax here is illustrative — the scaffolder picks one that mirrors the project's actual route groups.)
|
|
180
|
+
|
|
181
|
+
## 20. Vitest + RTL for unit, Playwright for E2E
|
|
182
|
+
|
|
183
|
+
A new project gets at least:
|
|
184
|
+
- One reducer/slice test (catches accidental shape changes).
|
|
185
|
+
- One form-renders-and-submits test (catches RHF/zod wiring regressions).
|
|
186
|
+
- One Playwright spec that boots the app, logs in, and asserts the dashboard renders (catches middleware and provider regressions).
|
|
187
|
+
|
|
188
|
+
Three tests is enough to make CI meaningful.
|
|
189
|
+
|
|
190
|
+
## 21. Husky + lint-staged
|
|
191
|
+
|
|
192
|
+
```jsonc
|
|
193
|
+
// package.json
|
|
194
|
+
"lint-staged": {
|
|
195
|
+
"*.{ts,tsx}": ["prettier --write", "eslint --fix"],
|
|
196
|
+
"*.{json,md,css}": ["prettier --write"]
|
|
197
|
+
}
|
|
198
|
+
```
|
|
199
|
+
|
|
200
|
+
Pre-commit hook keeps formatting and lint clean without anyone having to remember.
|
|
201
|
+
|
|
202
|
+
## 22. GitHub Actions CI
|
|
203
|
+
|
|
204
|
+
A single `ci.yml` that runs install → lint → typecheck → test → build on every PR. Build catches RSC/server-component boundary errors that `tsc --noEmit` misses.
|
|
205
|
+
|
|
206
|
+
## 23. `next-nprogress-bar` for top loading bar
|
|
207
|
+
|
|
208
|
+
One small, well-maintained library that hooks into Next.js's router events. Configured once in `providers.tsx`.
|
|
209
|
+
|
|
210
|
+
## 24. Toaster via shadcn `use-toast` (or `react-toastify`, not both)
|
|
211
|
+
|
|
212
|
+
Pick one toast library and wrap it. Composed `Notifications.tsx` is what features call; the underlying library can change without touching feature code.
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# Base RTK Query API and tag-types
|
|
2
|
+
|
|
3
|
+
## `src/redux/api/tags.ts`
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
// Single source of truth for cache tags. Every domain adds to this union.
|
|
7
|
+
export const TAG_TYPES = [
|
|
8
|
+
'Auth',
|
|
9
|
+
// Add domain tags here as features are added, e.g. 'Customers', 'Customer', 'Invoices', 'Invoice'.
|
|
10
|
+
] as const;
|
|
11
|
+
|
|
12
|
+
export type TagType = (typeof TAG_TYPES)[number];
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## `src/redux/api/api.ts`
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { createApi, fetchBaseQuery, type BaseQueryFn, type FetchArgs, type FetchBaseQueryError } from '@reduxjs/toolkit/query/react';
|
|
19
|
+
import { env } from '@/config/env';
|
|
20
|
+
import { TAG_TYPES } from './tags';
|
|
21
|
+
import { logout } from '@/redux/features/authSlice';
|
|
22
|
+
|
|
23
|
+
const rawBaseQuery = fetchBaseQuery({
|
|
24
|
+
baseUrl: env.NEXT_PUBLIC_API_BASE_URL,
|
|
25
|
+
// For httpOnly cookie auth, this is mandatory. Drop it only for bearer-token-only flows.
|
|
26
|
+
credentials: 'include',
|
|
27
|
+
prepareHeaders: (headers, { getState }) => {
|
|
28
|
+
// If the project uses a JS-readable bearer token, attach it here.
|
|
29
|
+
// For httpOnly cookie flows, leave this empty — the browser sends the cookie.
|
|
30
|
+
const token = (getState() as { auth?: { token?: string } }).auth?.token;
|
|
31
|
+
if (token) headers.set('Authorization', `Bearer ${token}`);
|
|
32
|
+
return headers;
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
export const baseQueryWithAuth: BaseQueryFn<string | FetchArgs, unknown, FetchBaseQueryError> = async (
|
|
37
|
+
args,
|
|
38
|
+
api,
|
|
39
|
+
extraOptions,
|
|
40
|
+
) => {
|
|
41
|
+
const result = await rawBaseQuery(args, api, extraOptions);
|
|
42
|
+
|
|
43
|
+
if (result.error?.status === 401) {
|
|
44
|
+
// Clean logout: dispatch action → root reducer resets state → middleware/router handles redirect.
|
|
45
|
+
api.dispatch(logout());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export const api = createApi({
|
|
52
|
+
reducerPath: 'api',
|
|
53
|
+
baseQuery: baseQueryWithAuth,
|
|
54
|
+
tagTypes: TAG_TYPES,
|
|
55
|
+
endpoints: () => ({}),
|
|
56
|
+
});
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
**Notes:**
|
|
60
|
+
- `createApi` is called **once** in this file. Every other `*Api.ts` file uses `api.injectEndpoints(...)`.
|
|
61
|
+
- The 401 handler dispatches a plain action; it does **not** call `window.location.href = '...'`. Navigation is handled by middleware on the next request or by an in-app guard listening to the auth state.
|
|
62
|
+
- `tagTypes` reads from the shared `TAG_TYPES` constant — domains add to that one array, not here.
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# Domain API slice template (injected endpoints)
|
|
2
|
+
|
|
3
|
+
One file per domain. Imports the shared `api` and calls `api.injectEndpoints(...)`. Never calls `createApi`.
|
|
4
|
+
|
|
5
|
+
## Example: `src/redux/api/customersApi.ts`
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
import { api } from './api';
|
|
9
|
+
|
|
10
|
+
export interface Customer {
|
|
11
|
+
id: string;
|
|
12
|
+
name: string;
|
|
13
|
+
email: string;
|
|
14
|
+
createdAt: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export type NewCustomer = Omit<Customer, 'id' | 'createdAt'>;
|
|
18
|
+
export type UpdateCustomer = Partial<NewCustomer>;
|
|
19
|
+
|
|
20
|
+
export const customersApi = api.injectEndpoints({
|
|
21
|
+
endpoints: (build) => ({
|
|
22
|
+
getCustomers: build.query<Customer[], void>({
|
|
23
|
+
query: () => '/customers',
|
|
24
|
+
providesTags: (result) =>
|
|
25
|
+
result
|
|
26
|
+
? [
|
|
27
|
+
...result.map(({ id }) => ({ type: 'Customer' as const, id })),
|
|
28
|
+
{ type: 'Customers' as const, id: 'LIST' },
|
|
29
|
+
]
|
|
30
|
+
: [{ type: 'Customers' as const, id: 'LIST' }],
|
|
31
|
+
}),
|
|
32
|
+
|
|
33
|
+
getCustomer: build.query<Customer, string>({
|
|
34
|
+
query: (id) => `/customers/${id}`,
|
|
35
|
+
providesTags: (_result, _err, id) => [{ type: 'Customer', id }],
|
|
36
|
+
}),
|
|
37
|
+
|
|
38
|
+
createCustomer: build.mutation<Customer, NewCustomer>({
|
|
39
|
+
query: (body) => ({ url: '/customers', method: 'POST', body }),
|
|
40
|
+
invalidatesTags: [{ type: 'Customers', id: 'LIST' }],
|
|
41
|
+
}),
|
|
42
|
+
|
|
43
|
+
updateCustomer: build.mutation<Customer, { id: string; patch: UpdateCustomer }>({
|
|
44
|
+
query: ({ id, patch }) => ({ url: `/customers/${id}`, method: 'PATCH', body: patch }),
|
|
45
|
+
invalidatesTags: (_result, _err, { id }) => [
|
|
46
|
+
{ type: 'Customer', id },
|
|
47
|
+
{ type: 'Customers', id: 'LIST' },
|
|
48
|
+
],
|
|
49
|
+
}),
|
|
50
|
+
|
|
51
|
+
deleteCustomer: build.mutation<void, string>({
|
|
52
|
+
query: (id) => ({ url: `/customers/${id}`, method: 'DELETE' }),
|
|
53
|
+
invalidatesTags: (_result, _err, id) => [
|
|
54
|
+
{ type: 'Customer', id },
|
|
55
|
+
{ type: 'Customers', id: 'LIST' },
|
|
56
|
+
],
|
|
57
|
+
}),
|
|
58
|
+
}),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const {
|
|
62
|
+
useGetCustomersQuery,
|
|
63
|
+
useGetCustomerQuery,
|
|
64
|
+
useCreateCustomerMutation,
|
|
65
|
+
useUpdateCustomerMutation,
|
|
66
|
+
useDeleteCustomerMutation,
|
|
67
|
+
} = customersApi;
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
**Don't forget:** add `'Customer'` and `'Customers'` to `TAG_TYPES` in `src/redux/api/tags.ts`. TypeScript will error on the `providesTags` / `invalidatesTags` lines until you do.
|
|
71
|
+
|
|
72
|
+
**Forbidden:**
|
|
73
|
+
- `createApi(...)` in this file — only the base does that.
|
|
74
|
+
- `any` in the generic slots (`build.query<any, any>`). Type both sides.
|
|
75
|
+
- `transformResponse` that does data mangling that should be the backend's job. Use it only when the contract truly demands it (renaming a field that the backend can't change, unwrapping a `{ data: ... }` envelope, etc.).
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Auth slice and auth API template
|
|
2
|
+
|
|
3
|
+
## `src/redux/features/authSlice.ts`
|
|
4
|
+
|
|
5
|
+
```ts
|
|
6
|
+
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
|
7
|
+
import { authApi } from '@/redux/api/authApi';
|
|
8
|
+
|
|
9
|
+
export interface AuthUser {
|
|
10
|
+
id: string;
|
|
11
|
+
email: string;
|
|
12
|
+
name: string;
|
|
13
|
+
roles: string[];
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface AuthState {
|
|
17
|
+
user: AuthUser | null;
|
|
18
|
+
token: string | null; // only populated for bearer-flow projects; null for httpOnly-cookie flows
|
|
19
|
+
expiresAt: number | null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const initialState: AuthState = {
|
|
23
|
+
user: null,
|
|
24
|
+
token: null,
|
|
25
|
+
expiresAt: null,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const authSlice = createSlice({
|
|
29
|
+
name: 'auth',
|
|
30
|
+
initialState,
|
|
31
|
+
reducers: {
|
|
32
|
+
logout: () => initialState,
|
|
33
|
+
setUser: (state, action: PayloadAction<AuthUser>) => {
|
|
34
|
+
state.user = action.payload;
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
extraReducers: (builder) => {
|
|
38
|
+
builder.addMatcher(authApi.endpoints.login.matchFulfilled, (state, { payload }) => {
|
|
39
|
+
state.user = payload.user;
|
|
40
|
+
state.token = payload.token ?? null;
|
|
41
|
+
state.expiresAt = payload.expiresAt ?? null;
|
|
42
|
+
});
|
|
43
|
+
builder.addMatcher(authApi.endpoints.getMe.matchFulfilled, (state, { payload }) => {
|
|
44
|
+
state.user = payload;
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
export const { logout, setUser } = authSlice.actions;
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
**Note:** the store's `rootReducer` already wipes the entire store on `authApi.endpoints.logout.matchFulfilled` — so a `logout()` action dispatched here from the 401 handler still triggers a full state reset via the wrapped reducer in `store.ts`. The slice's own `logout` reducer is there for cases where the user explicitly clicks "Sign out" without hitting the logout endpoint.
|
|
53
|
+
|
|
54
|
+
## `src/redux/api/authApi.ts`
|
|
55
|
+
|
|
56
|
+
```ts
|
|
57
|
+
import { api } from './api';
|
|
58
|
+
import type { AuthUser } from '@/redux/features/authSlice';
|
|
59
|
+
|
|
60
|
+
export interface LoginRequest {
|
|
61
|
+
email: string;
|
|
62
|
+
password: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LoginResponse {
|
|
66
|
+
user: AuthUser;
|
|
67
|
+
/** Populated only for bearer-token flows. httpOnly-cookie flows leave this undefined. */
|
|
68
|
+
token?: string;
|
|
69
|
+
expiresAt?: number;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export const authApi = api.injectEndpoints({
|
|
73
|
+
endpoints: (build) => ({
|
|
74
|
+
login: build.mutation<LoginResponse, LoginRequest>({
|
|
75
|
+
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
|
|
76
|
+
invalidatesTags: ['Auth'],
|
|
77
|
+
}),
|
|
78
|
+
logout: build.mutation<void, void>({
|
|
79
|
+
query: () => ({ url: '/auth/logout', method: 'POST' }),
|
|
80
|
+
invalidatesTags: ['Auth'],
|
|
81
|
+
}),
|
|
82
|
+
getMe: build.query<AuthUser, void>({
|
|
83
|
+
query: () => '/auth/me',
|
|
84
|
+
providesTags: ['Auth'],
|
|
85
|
+
}),
|
|
86
|
+
}),
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
export const { useLoginMutation, useLogoutMutation, useGetMeQuery } = authApi;
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
**Forbidden:**
|
|
93
|
+
- Storing the token via `js-cookie` inside the slice (`Cookies.set('token', ...)`). For `httpOnly` flows, the backend sets the cookie; for bearer flows, the token lives in Redux state and the `prepareHeaders` callback attaches it. Never both.
|
|
94
|
+
- Base64-encoding the user and writing it to a cookie (`Cookies.set('user', btoa(JSON.stringify(...)))`). It's not encryption, it's obfuscation. If the server needs the user, the server reads it from the session cookie / database.
|
|
95
|
+
- Decoding the JWT on the client to drive auth decisions. Decode only for non-sensitive display (e.g. showing the user's name pulled from a claim) — never as the gate. The gate is the server.
|