@skyf0xx/hedgehog-core-full-stack-app 1.0.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/CLAUDE.core.md +147 -0
- package/LICENSE +21 -0
- package/README.md +41 -0
- package/agents/backend-eng.md +189 -0
- package/agents/front-end-eng.md +176 -0
- package/agents/ux-planner.md +174 -0
- package/hedgehog-core.yaml +20 -0
- package/package.json +41 -0
- package/skills/hedgehog-bootstrap/SKILL.md +324 -0
- package/skills/hedgehog-bootstrap-full-stack-app-core/SKILL.md +411 -0
- package/skills/hedgehog-loop/SKILL.md +643 -0
- package/skills/link-workspace-packages/SKILL.md +41 -0
- package/skills/nx-generate/SKILL.md +165 -0
- package/skills/nx-run-tasks/SKILL.md +52 -0
- package/skills/nx-workspace/SKILL.md +261 -0
- package/skills/nx-workspace/references/AFFECTED.md +27 -0
- package/vendor-skills/GSAP/ATTRIBUTION.md +30 -0
- package/vendor-skills/GSAP/LICENSE +21 -0
- package/vendor-skills/GSAP/gsap-core/SKILL.md +254 -0
- package/vendor-skills/GSAP/gsap-frameworks/SKILL.md +266 -0
- package/vendor-skills/GSAP/gsap-performance/SKILL.md +79 -0
- package/vendor-skills/GSAP/gsap-plugins/SKILL.md +433 -0
- package/vendor-skills/GSAP/gsap-react/SKILL.md +136 -0
- package/vendor-skills/GSAP/gsap-scrolltrigger/SKILL.md +296 -0
- package/vendor-skills/GSAP/gsap-timeline/SKILL.md +107 -0
- package/vendor-skills/GSAP/gsap-utils/SKILL.md +284 -0
- package/vendor-skills/GSAP/llms.txt +39 -0
- package/workspace/.env.example +5 -0
- package/workspace/.github/workflows/phase-gate.yml +17 -0
- package/workspace/.prettierignore +5 -0
- package/workspace/.prettierrc +3 -0
- package/workspace/.vscode/extensions.json +3 -0
- package/workspace/apps/api/eslint.config.mjs +3 -0
- package/workspace/apps/api/package.json +117 -0
- package/workspace/apps/api/src/app/app.module.spec.ts +15 -0
- package/workspace/apps/api/src/app/app.module.ts +15 -0
- package/workspace/apps/api/src/app/feature-modules.ts +8 -0
- package/workspace/apps/api/src/app/health.controller.ts +9 -0
- package/workspace/apps/api/src/assets/.gitkeep +0 -0
- package/workspace/apps/api/src/main.ts +32 -0
- package/workspace/apps/api/tsconfig.app.json +22 -0
- package/workspace/apps/api/tsconfig.json +13 -0
- package/workspace/apps/api/tsconfig.spec.json +36 -0
- package/workspace/apps/api/vitest.config.mts +18 -0
- package/workspace/apps/api/webpack.config.cjs +25 -0
- package/workspace/apps/api-e2e/eslint.config.mjs +3 -0
- package/workspace/apps/api-e2e/package.json +28 -0
- package/workspace/apps/api-e2e/src/api/api.spec.ts +11 -0
- package/workspace/apps/api-e2e/src/support/global-setup.ts +17 -0
- package/workspace/apps/api-e2e/src/support/test-setup.ts +6 -0
- package/workspace/apps/api-e2e/tsconfig.json +10 -0
- package/workspace/apps/api-e2e/tsconfig.spec.json +28 -0
- package/workspace/apps/api-e2e/vitest.config.mts +20 -0
- package/workspace/apps/web/.env.example +12 -0
- package/workspace/apps/web/.prettierrc.js +10 -0
- package/workspace/apps/web/.swcrc +30 -0
- package/workspace/apps/web/components.json +21 -0
- package/workspace/apps/web/eslint.config.mjs +12 -0
- package/workspace/apps/web/index.d.ts +7 -0
- package/workspace/apps/web/next-env.d.ts +7 -0
- package/workspace/apps/web/next.config.js +9 -0
- package/workspace/apps/web/package.json +38 -0
- package/workspace/apps/web/postcss.config.mjs +7 -0
- package/workspace/apps/web/public/.gitkeep +0 -0
- package/workspace/apps/web/public/favicon.ico +0 -0
- package/workspace/apps/web/src/app/global.css +83 -0
- package/workspace/apps/web/src/app/layout.tsx +34 -0
- package/workspace/apps/web/src/app/module-routes.ts +13 -0
- package/workspace/apps/web/src/app/page.tsx +34 -0
- package/workspace/apps/web/src/app/providers.tsx +12 -0
- package/workspace/apps/web/src/components/theme-toggle.spec.tsx +20 -0
- package/workspace/apps/web/src/components/theme-toggle.tsx +32 -0
- package/workspace/apps/web/src/components/ui/button.tsx +60 -0
- package/workspace/apps/web/src/lib/utils.ts +6 -0
- package/workspace/apps/web/src/test-setup.ts +1 -0
- package/workspace/apps/web/tsconfig.json +62 -0
- package/workspace/apps/web/tsconfig.spec.json +37 -0
- package/workspace/apps/web/vitest.config.mts +27 -0
- package/workspace/apps/web-e2e/eslint.config.mjs +12 -0
- package/workspace/apps/web-e2e/package.json +10 -0
- package/workspace/apps/web-e2e/playwright.config.mts +75 -0
- package/workspace/apps/web-e2e/src/example.spec.ts +7 -0
- package/workspace/apps/web-e2e/tsconfig.json +26 -0
- package/workspace/commitlint.config.cjs +3 -0
- package/workspace/core.yaml +68 -0
- package/workspace/docker-compose.yml +14 -0
- package/workspace/eslint.config.mjs +15 -0
- package/workspace/gitignore.template +24 -0
- package/workspace/lefthook.yml +52 -0
- package/workspace/nx.json +90 -0
- package/workspace/package.json +99 -0
- package/workspace/packages/config/eslint-base.js +205 -0
- package/workspace/packages/config/package.json +28 -0
- package/workspace/packages/config/prettier.js +13 -0
- package/workspace/packages/config/src/env.schema.spec.ts +38 -0
- package/workspace/packages/config/src/env.schema.ts +18 -0
- package/workspace/packages/config/src/index.ts +1 -0
- package/workspace/packages/config/tsconfig.json +13 -0
- package/workspace/packages/config/tsconfig.lib.json +27 -0
- package/workspace/packages/config/tsconfig.spec.json +34 -0
- package/workspace/packages/config/vitest.config.mts +18 -0
- package/workspace/packages/db/package.json +27 -0
- package/workspace/packages/db/src/index.ts +2 -0
- package/workspace/packages/db/src/lib/db.spec.ts +48 -0
- package/workspace/packages/db/src/lib/db.ts +54 -0
- package/workspace/packages/db/src/schema/index.ts +4 -0
- package/workspace/packages/db/tsconfig.json +13 -0
- package/workspace/packages/db/tsconfig.lib.json +27 -0
- package/workspace/packages/db/tsconfig.spec.json +34 -0
- package/workspace/packages/db/vitest.config.mts +18 -0
- package/workspace/pnpm-lock.yaml +23719 -0
- package/workspace/pnpm-workspace.yaml +7 -0
- package/workspace/tools/generate-feature-modules.cjs +104 -0
- package/workspace/tools/generate-module-routes.cjs +89 -0
- package/workspace/tools/generators/contract/generator.ts +336 -0
- package/workspace/tools/generators/contract/schema.json +24 -0
- package/workspace/tools/generators/controller/generator.ts +390 -0
- package/workspace/tools/generators/controller/schema.json +24 -0
- package/workspace/tools/generators/fields.ts +160 -0
- package/workspace/tools/generators/generators.json +42 -0
- package/workspace/tools/generators/hook/generator.ts +291 -0
- package/workspace/tools/generators/hook/schema.json +19 -0
- package/workspace/tools/generators/lib-shell.ts +124 -0
- package/workspace/tools/generators/naming.ts +84 -0
- package/workspace/tools/generators/package.json +6 -0
- package/workspace/tools/generators/repository/generator.ts +298 -0
- package/workspace/tools/generators/repository/schema.json +15 -0
- package/workspace/tools/generators/schema/generator.ts +169 -0
- package/workspace/tools/generators/schema/schema.json +20 -0
- package/workspace/tools/generators/screen/generator.ts +218 -0
- package/workspace/tools/generators/screen/schema.json +15 -0
- package/workspace/tools/generators/service/generator.ts +270 -0
- package/workspace/tools/generators/service/schema.json +19 -0
- package/workspace/tools/phase-gate.cjs +77 -0
- package/workspace/tsconfig.base.json +21 -0
- package/workspace/tsconfig.json +25 -0
- package/workspace/vitest.workspace.ts +4 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { formatFiles, Tree } from '@nx/devkit';
|
|
2
|
+
import { appendBarrelExport, generateLibShell } from '../lib-shell';
|
|
3
|
+
import { moduleNames, ModuleNames } from '../naming';
|
|
4
|
+
|
|
5
|
+
interface HookGeneratorOptions {
|
|
6
|
+
module: string;
|
|
7
|
+
toggleField?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const PACKAGE_ROOT = 'packages/hooks';
|
|
11
|
+
|
|
12
|
+
export default async function hookGenerator(
|
|
13
|
+
tree: Tree,
|
|
14
|
+
options: HookGeneratorOptions,
|
|
15
|
+
) {
|
|
16
|
+
const names = moduleNames(options.module);
|
|
17
|
+
|
|
18
|
+
await generateLibShell(tree, {
|
|
19
|
+
directory: PACKAGE_ROOT,
|
|
20
|
+
importName: 'hooks',
|
|
21
|
+
tags: ['scope:hooks', 'type:hook'],
|
|
22
|
+
dependencies: {
|
|
23
|
+
'@tanstack/react-query': '^5.101.4',
|
|
24
|
+
'@ts-rest/core': '3.53.0-rc.1',
|
|
25
|
+
contracts: 'workspace:*',
|
|
26
|
+
},
|
|
27
|
+
references: ['../contracts/tsconfig.lib.json'],
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
writeApiBaseUrl(tree);
|
|
31
|
+
|
|
32
|
+
const dir = `${PACKAGE_ROOT}/src/${names.module}`;
|
|
33
|
+
tree.write(`${dir}/${names.module}.client.ts`, clientFile(names));
|
|
34
|
+
tree.write(`${dir}/${names.module}.keys.ts`, keysFile(names));
|
|
35
|
+
tree.write(
|
|
36
|
+
`${dir}/use-${names.module}.ts`,
|
|
37
|
+
hooksFile(names, options.toggleField),
|
|
38
|
+
);
|
|
39
|
+
tree.write(`${dir}/index.ts`, barrelFile(names));
|
|
40
|
+
tree.write(
|
|
41
|
+
`${dir}/${names.module}.spec.ts`,
|
|
42
|
+
specFile(names, options.toggleField),
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, `./${names.module}/index`);
|
|
46
|
+
|
|
47
|
+
await formatFiles(tree);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read at call time rather than captured at module load: this package is
|
|
52
|
+
* consumed as TypeScript source by both apps/web (where Next inlines
|
|
53
|
+
* NEXT_PUBLIC_API_BASE_URL at build time) and Vitest (where it is absent),
|
|
54
|
+
* and a module-scope read would freeze the test-time fallback into the app.
|
|
55
|
+
*/
|
|
56
|
+
function writeApiBaseUrl(tree: Tree) {
|
|
57
|
+
const path = `${PACKAGE_ROOT}/src/api-base-url.ts`;
|
|
58
|
+
if (tree.exists(path)) return;
|
|
59
|
+
|
|
60
|
+
tree.write(
|
|
61
|
+
path,
|
|
62
|
+
`export function apiBaseUrl(): string {
|
|
63
|
+
return process.env.NEXT_PUBLIC_API_BASE_URL ?? 'http://localhost:3333/api';
|
|
64
|
+
}
|
|
65
|
+
`,
|
|
66
|
+
);
|
|
67
|
+
appendBarrelExport(tree, `${PACKAGE_ROOT}/src/index.ts`, './api-base-url');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function clientFile(names: ModuleNames): string {
|
|
71
|
+
return `import { initClient } from '@ts-rest/core';
|
|
72
|
+
import { ${names.camel}Contract } from 'contracts';
|
|
73
|
+
import { apiBaseUrl } from '../api-base-url';
|
|
74
|
+
|
|
75
|
+
export function ${names.camel}Client() {
|
|
76
|
+
return initClient(${names.camel}Contract, { baseUrl: apiBaseUrl() });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type ${names.pascal}Client = ReturnType<typeof ${names.camel}Client>;
|
|
80
|
+
`;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A factory rather than loose string arrays: every mutation below
|
|
85
|
+
* invalidates through the same keys, so a rename that misses one is a
|
|
86
|
+
* compile error instead of a cache that silently stops refreshing.
|
|
87
|
+
*/
|
|
88
|
+
function keysFile(names: ModuleNames): string {
|
|
89
|
+
return `export const ${names.camel}Keys = {
|
|
90
|
+
all: () => ['${names.module}'] as const,
|
|
91
|
+
lists: () => [...${names.camel}Keys.all(), 'list'] as const,
|
|
92
|
+
details: () => [...${names.camel}Keys.all(), 'detail'] as const,
|
|
93
|
+
detail: (id: string) => [...${names.camel}Keys.details(), id] as const,
|
|
94
|
+
};
|
|
95
|
+
`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function hooksFile(names: ModuleNames, toggleField?: string): string {
|
|
99
|
+
const { camel, pascal, entityPascal } = names;
|
|
100
|
+
|
|
101
|
+
return `import {
|
|
102
|
+
useMutation,
|
|
103
|
+
useQuery,
|
|
104
|
+
useQueryClient,
|
|
105
|
+
} from '@tanstack/react-query';
|
|
106
|
+
import type { Create${entityPascal}, Update${entityPascal} } from 'contracts';
|
|
107
|
+
import { ${camel}Client, type ${pascal}Client } from './${names.module}.client';
|
|
108
|
+
import { ${camel}Keys } from './${names.module}.keys';
|
|
109
|
+
|
|
110
|
+
const client: ${pascal}Client = ${camel}Client();
|
|
111
|
+
|
|
112
|
+
export function use${pascal}() {
|
|
113
|
+
return useQuery({
|
|
114
|
+
queryKey: ${camel}Keys.lists(),
|
|
115
|
+
queryFn: async () => {
|
|
116
|
+
const response = await client.list();
|
|
117
|
+
if (response.status !== 200) {
|
|
118
|
+
throw new Error('Failed to load ${names.module}.');
|
|
119
|
+
}
|
|
120
|
+
return response.body;
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function use${entityPascal}(id: string) {
|
|
126
|
+
return useQuery({
|
|
127
|
+
queryKey: ${camel}Keys.detail(id),
|
|
128
|
+
queryFn: async () => {
|
|
129
|
+
const response = await client.get({ params: { id } });
|
|
130
|
+
if (response.status !== 200) {
|
|
131
|
+
throw new Error(\`Failed to load ${names.entityCamel} \${id}.\`);
|
|
132
|
+
}
|
|
133
|
+
return response.body;
|
|
134
|
+
},
|
|
135
|
+
enabled: id.length > 0,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function useCreate${entityPascal}() {
|
|
140
|
+
const queryClient = useQueryClient();
|
|
141
|
+
|
|
142
|
+
return useMutation({
|
|
143
|
+
mutationFn: async (body: Create${entityPascal}) => {
|
|
144
|
+
const response = await client.create({ body });
|
|
145
|
+
if (response.status !== 201) {
|
|
146
|
+
throw new Error('Failed to create the ${names.entityCamel}.');
|
|
147
|
+
}
|
|
148
|
+
return response.body;
|
|
149
|
+
},
|
|
150
|
+
onSuccess: () => {
|
|
151
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.lists() });
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function useUpdate${entityPascal}() {
|
|
157
|
+
const queryClient = useQueryClient();
|
|
158
|
+
|
|
159
|
+
return useMutation({
|
|
160
|
+
mutationFn: async ({
|
|
161
|
+
id,
|
|
162
|
+
...body
|
|
163
|
+
}: Update${entityPascal} & { id: string }) => {
|
|
164
|
+
const response = await client.update({ params: { id }, body });
|
|
165
|
+
if (response.status !== 200) {
|
|
166
|
+
throw new Error(\`Failed to update ${names.entityCamel} \${id}.\`);
|
|
167
|
+
}
|
|
168
|
+
return response.body;
|
|
169
|
+
},
|
|
170
|
+
onSuccess: (_result, variables) => {
|
|
171
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.lists() });
|
|
172
|
+
queryClient.invalidateQueries({
|
|
173
|
+
queryKey: ${camel}Keys.detail(variables.id),
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function useRemove${entityPascal}() {
|
|
180
|
+
const queryClient = useQueryClient();
|
|
181
|
+
|
|
182
|
+
return useMutation({
|
|
183
|
+
mutationFn: async (id: string) => {
|
|
184
|
+
const response = await client.remove({ params: { id } });
|
|
185
|
+
if (response.status !== 204) {
|
|
186
|
+
throw new Error(\`Failed to delete ${names.entityCamel} \${id}.\`);
|
|
187
|
+
}
|
|
188
|
+
return id;
|
|
189
|
+
},
|
|
190
|
+
onSuccess: (id) => {
|
|
191
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.lists() });
|
|
192
|
+
queryClient.removeQueries({ queryKey: ${camel}Keys.detail(id) });
|
|
193
|
+
},
|
|
194
|
+
});
|
|
195
|
+
}${
|
|
196
|
+
toggleField
|
|
197
|
+
? `
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Sends the id and nothing else. The server reads the current
|
|
201
|
+
* ${toggleField} and flips it inside one transaction, so a stale row in
|
|
202
|
+
* this cache cannot overwrite a newer state — which is what computing
|
|
203
|
+
* the new value here from a cached row would do, losing one of any two
|
|
204
|
+
* flips that raced.
|
|
205
|
+
*/
|
|
206
|
+
export function useToggle${entityPascal}() {
|
|
207
|
+
const queryClient = useQueryClient();
|
|
208
|
+
|
|
209
|
+
return useMutation({
|
|
210
|
+
mutationFn: async (id: string) => {
|
|
211
|
+
const response = await client.toggle({ params: { id } });
|
|
212
|
+
if (response.status !== 200) {
|
|
213
|
+
throw new Error(\`Failed to toggle ${names.entityCamel} \${id}.\`);
|
|
214
|
+
}
|
|
215
|
+
return response.body;
|
|
216
|
+
},
|
|
217
|
+
onSuccess: (_result, id) => {
|
|
218
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.lists() });
|
|
219
|
+
queryClient.invalidateQueries({ queryKey: ${camel}Keys.detail(id) });
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
}`
|
|
223
|
+
: ''
|
|
224
|
+
}
|
|
225
|
+
`;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function barrelFile(names: ModuleNames): string {
|
|
229
|
+
return `export * from './${names.module}.client';
|
|
230
|
+
export * from './${names.module}.keys';
|
|
231
|
+
export * from './use-${names.module}';
|
|
232
|
+
`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function specFile(names: ModuleNames, toggleField?: string): string {
|
|
236
|
+
const { camel, entityPascal } = names;
|
|
237
|
+
|
|
238
|
+
return `import { describe, expect, it } from 'vitest';
|
|
239
|
+
import { ${camel}Keys } from './${names.module}.keys';
|
|
240
|
+
import {
|
|
241
|
+
useCreate${entityPascal},
|
|
242
|
+
useRemove${entityPascal},
|
|
243
|
+
useUpdate${entityPascal},
|
|
244
|
+
use${names.pascal},
|
|
245
|
+
} from './use-${names.module}';
|
|
246
|
+
|
|
247
|
+
describe('${camel} query keys', () => {
|
|
248
|
+
it('nests every key under one invalidation root', () => {
|
|
249
|
+
expect(${camel}Keys.all()).toEqual(['${names.module}']);
|
|
250
|
+
expect(${camel}Keys.lists()).toEqual(['${names.module}', 'list']);
|
|
251
|
+
expect(${camel}Keys.detail('abc')).toEqual([
|
|
252
|
+
'${names.module}',
|
|
253
|
+
'detail',
|
|
254
|
+
'abc',
|
|
255
|
+
]);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
it('keeps a detail key inside the details prefix, so one call clears both', () => {
|
|
259
|
+
const detail = ${camel}Keys.detail('abc');
|
|
260
|
+
|
|
261
|
+
expect(detail.slice(0, 2)).toEqual(${camel}Keys.details());
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
describe('${camel} hooks', () => {
|
|
266
|
+
it('exposes the query and mutation set the screen layer consumes', () => {
|
|
267
|
+
expect(use${names.pascal}).toBeTypeOf('function');
|
|
268
|
+
expect(useCreate${entityPascal}).toBeTypeOf('function');
|
|
269
|
+
expect(useUpdate${entityPascal}).toBeTypeOf('function');
|
|
270
|
+
expect(useRemove${entityPascal}).toBeTypeOf('function');
|
|
271
|
+
});${
|
|
272
|
+
toggleField
|
|
273
|
+
? `
|
|
274
|
+
|
|
275
|
+
it('exposes a dedicated ${toggleField} toggle', async () => {
|
|
276
|
+
const { useToggle${entityPascal} } = await import('./use-${names.module}');
|
|
277
|
+
|
|
278
|
+
expect(useToggle${entityPascal}).toBeTypeOf('function');
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it('routes the toggle at a server endpoint that takes no body', async () => {
|
|
282
|
+
const { ${camel}Contract } = await import('contracts');
|
|
283
|
+
|
|
284
|
+
expect(${camel}Contract.toggle.method).toBe('POST');
|
|
285
|
+
expect(String(${camel}Contract.toggle.body)).toBe('Symbol(ContractNoBody)');
|
|
286
|
+
});`
|
|
287
|
+
: ''
|
|
288
|
+
}
|
|
289
|
+
});
|
|
290
|
+
`;
|
|
291
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/schema",
|
|
3
|
+
"$id": "HedgehogHookLayer",
|
|
4
|
+
"title": "TanStack Query hook set for one domain module",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"module": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"description": "Domain module name, plural kebab-case (e.g. tasks, order-items).",
|
|
10
|
+
"$default": { "$source": "argv", "index": 0 },
|
|
11
|
+
"x-prompt": "Domain module name (plural kebab-case)?"
|
|
12
|
+
},
|
|
13
|
+
"toggleField": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "A boolean field from the schema layer to expose as a server-side toggle. Pass the same value to the schema, contract, service, controller, and hook layers. Omit when the module has none."
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"required": ["module"]
|
|
19
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { libraryGenerator } from '@nx/js';
|
|
2
|
+
import { joinPathFragments, Tree, updateJson } from '@nx/devkit';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `@nx/js:lib` call every first-arrival layer makes, plus the
|
|
6
|
+
* corrections this workspace's conventions need on top of the generator's
|
|
7
|
+
* defaults. Owned here so `contract`, `repository`, `service`, and `hook`
|
|
8
|
+
* cannot drift on the shell they land.
|
|
9
|
+
*
|
|
10
|
+
* Two of those corrections are load-bearing:
|
|
11
|
+
*
|
|
12
|
+
* - `@nx/js:lib` writes `module`/`moduleResolution: "nodenext"` into the
|
|
13
|
+
* generated tsconfigs and a `.js` extension onto the barrel's own
|
|
14
|
+
* relative import. Turbopack cannot resolve a nodenext-style `.js`
|
|
15
|
+
* specifier across a pnpm workspace-package boundary, which is why
|
|
16
|
+
* `tsconfig.base.json` pins esnext/bundler — a per-package nodenext
|
|
17
|
+
* override reintroduces exactly that break for anything `apps/web`
|
|
18
|
+
* imports.
|
|
19
|
+
* - The generated `package.json` carries no `nx.tags`, and
|
|
20
|
+
* `packages/config/eslint-base.js`'s `depConstraints` are keyed
|
|
21
|
+
* entirely on tags: an untagged project satisfies no constraint and
|
|
22
|
+
* fails `@nx/enforce-module-boundaries` on its first workspace import.
|
|
23
|
+
*/
|
|
24
|
+
export interface LibShellOptions {
|
|
25
|
+
directory: string;
|
|
26
|
+
importName: string;
|
|
27
|
+
tags: string[];
|
|
28
|
+
dependencies?: Record<string, string>;
|
|
29
|
+
/** tsconfig.lib.json project references, as paths relative to the lib. */
|
|
30
|
+
references?: string[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function generateLibShell(tree: Tree, options: LibShellOptions) {
|
|
34
|
+
if (tree.exists(joinPathFragments(options.directory, 'package.json'))) {
|
|
35
|
+
applyShellConventions(tree, options);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await libraryGenerator(tree, {
|
|
40
|
+
directory: options.directory,
|
|
41
|
+
name: options.importName,
|
|
42
|
+
bundler: 'none',
|
|
43
|
+
unitTestRunner: 'vitest',
|
|
44
|
+
// `linter: 'eslint'` would land a per-package eslint.config.mjs and a
|
|
45
|
+
// project.json whose empty `tags: []` takes precedence over the
|
|
46
|
+
// package.json tags below — silently emptying the tag set every
|
|
47
|
+
// depConstraint in packages/config/eslint-base.js keys on. The root
|
|
48
|
+
// eslint.config.mjs already covers every project in the workspace.
|
|
49
|
+
linter: 'none',
|
|
50
|
+
skipFormat: true,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// The generator's placeholder lib + README carry no domain content and
|
|
54
|
+
// are replaced wholesale by the layer's own files.
|
|
55
|
+
for (const placeholder of [
|
|
56
|
+
`${options.directory}/src/lib/${options.importName}.ts`,
|
|
57
|
+
`${options.directory}/src/lib/${options.importName}.spec.ts`,
|
|
58
|
+
`${options.directory}/README.md`,
|
|
59
|
+
`${options.directory}/project.json`,
|
|
60
|
+
`${options.directory}/eslint.config.mjs`,
|
|
61
|
+
]) {
|
|
62
|
+
if (tree.exists(placeholder)) tree.delete(placeholder);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
applyShellConventions(tree, options);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function applyShellConventions(tree: Tree, options: LibShellOptions) {
|
|
69
|
+
updateJson(
|
|
70
|
+
tree,
|
|
71
|
+
joinPathFragments(options.directory, 'package.json'),
|
|
72
|
+
(json) => {
|
|
73
|
+
json.nx = { ...(json.nx ?? {}), tags: options.tags };
|
|
74
|
+
if (options.dependencies) {
|
|
75
|
+
json.dependencies = { ...(json.dependencies ?? {}), ...options.dependencies };
|
|
76
|
+
}
|
|
77
|
+
return json;
|
|
78
|
+
},
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
for (const config of ['tsconfig.lib.json', 'tsconfig.spec.json']) {
|
|
82
|
+
const path = joinPathFragments(options.directory, config);
|
|
83
|
+
if (!tree.exists(path)) continue;
|
|
84
|
+
updateJson(tree, path, (json) => {
|
|
85
|
+
delete json.compilerOptions?.module;
|
|
86
|
+
delete json.compilerOptions?.moduleResolution;
|
|
87
|
+
return json;
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options.references?.length) {
|
|
92
|
+
const path = joinPathFragments(options.directory, 'tsconfig.lib.json');
|
|
93
|
+
updateJson(tree, path, (json) => {
|
|
94
|
+
const existing: { path: string }[] = json.references ?? [];
|
|
95
|
+
for (const reference of options.references ?? []) {
|
|
96
|
+
if (!existing.some((entry) => entry.path === reference)) {
|
|
97
|
+
existing.push({ path: reference });
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
json.references = existing;
|
|
101
|
+
return json;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A barrel line free of the `.js` extension `@nx/js:lib`'s own barrel uses
|
|
108
|
+
* (see the nodenext note above).
|
|
109
|
+
*/
|
|
110
|
+
export function appendBarrelExport(tree: Tree, barrelPath: string, target: string) {
|
|
111
|
+
const line = `export * from '${target}';`;
|
|
112
|
+
const current = tree.read(barrelPath, 'utf-8') ?? '';
|
|
113
|
+
if (current.includes(line)) return;
|
|
114
|
+
|
|
115
|
+
const lines = current
|
|
116
|
+
.split('\n')
|
|
117
|
+
.map((entry) => entry.trim())
|
|
118
|
+
.filter((entry) => entry.length > 0 && entry !== 'export {};')
|
|
119
|
+
// The generator's own placeholder barrel line points at a lib file
|
|
120
|
+
// this shell deleted.
|
|
121
|
+
.filter((entry) => !/^export \* from '\.\/lib\//.test(entry));
|
|
122
|
+
|
|
123
|
+
tree.write(barrelPath, `${[...lines, line].sort().join('\n')}\n`);
|
|
124
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every name a layer generator derives from one module name, in one place.
|
|
3
|
+
*
|
|
4
|
+
* A domain module is one table (see `.claude/skills/hedgehog-loop`'s Domain
|
|
5
|
+
* Module Pattern), named in plural kebab-case — `tasks`, `order-items`. The
|
|
6
|
+
* entity names below are its singular form, which is what a type, a domain
|
|
7
|
+
* error, and a service method are named after.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface ModuleNames {
|
|
11
|
+
/** `order-items` — the module name as core.yaml's `{module}` token. */
|
|
12
|
+
module: string;
|
|
13
|
+
/** `orderItems` — the Drizzle table export and query-key root. */
|
|
14
|
+
camel: string;
|
|
15
|
+
/** `OrderItems` — the NestJS module/controller class prefix. */
|
|
16
|
+
pascal: string;
|
|
17
|
+
/** `order-item` — singular kebab, for a singular file name. */
|
|
18
|
+
entityKebab: string;
|
|
19
|
+
/** `orderItem` — singular camel, for a variable or a route param. */
|
|
20
|
+
entityCamel: string;
|
|
21
|
+
/** `OrderItem` — singular pascal, for the entity type and its errors. */
|
|
22
|
+
entityPascal: string;
|
|
23
|
+
/** `order_items` — the Postgres table name. */
|
|
24
|
+
tableName: string;
|
|
25
|
+
/** `ORDER_ITEM` — the singular constant case, for a DI token name. */
|
|
26
|
+
entityConstant: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function moduleNames(module: string): ModuleNames {
|
|
30
|
+
const normalized = module.trim().toLowerCase();
|
|
31
|
+
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)*$/.test(normalized)) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
`Module name "${module}" must be lower kebab-case (e.g. tasks, order-items).`,
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
const segments = normalized.split('-');
|
|
37
|
+
const entitySegments = segments.map((segment, index) =>
|
|
38
|
+
index === segments.length - 1 ? singular(segment) : segment,
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
return {
|
|
42
|
+
module: normalized,
|
|
43
|
+
camel: toCamel(segments),
|
|
44
|
+
pascal: toPascal(segments),
|
|
45
|
+
entityKebab: entitySegments.join('-'),
|
|
46
|
+
entityCamel: toCamel(entitySegments),
|
|
47
|
+
entityPascal: toPascal(entitySegments),
|
|
48
|
+
tableName: segments.join('_'),
|
|
49
|
+
entityConstant: entitySegments.join('_').toUpperCase(),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function toCamel(segments: string[]): string {
|
|
54
|
+
return segments
|
|
55
|
+
.map((segment, index) => (index === 0 ? segment : capitalize(segment)))
|
|
56
|
+
.join('');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toPascal(segments: string[]): string {
|
|
60
|
+
return segments.map(capitalize).join('');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function capitalize(segment: string): string {
|
|
64
|
+
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* English plural-to-singular for the shapes a table name actually takes.
|
|
69
|
+
* A module whose singular this gets wrong is a rename away from correct —
|
|
70
|
+
* the entity name is cosmetic to every gate, unlike the module name, which
|
|
71
|
+
* core.yaml's scope globs key on.
|
|
72
|
+
*/
|
|
73
|
+
function singular(word: string): string {
|
|
74
|
+
if (word.endsWith('ies') && word.length > 3) {
|
|
75
|
+
return `${word.slice(0, -3)}y`;
|
|
76
|
+
}
|
|
77
|
+
if (/(s|x|z|ch|sh)es$/.test(word)) {
|
|
78
|
+
return word.slice(0, -2);
|
|
79
|
+
}
|
|
80
|
+
if (word.endsWith('s') && !word.endsWith('ss')) {
|
|
81
|
+
return word.slice(0, -1);
|
|
82
|
+
}
|
|
83
|
+
return word;
|
|
84
|
+
}
|