blacksmith-cli 0.1.1
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/README.md +210 -0
- package/bin/blacksmith.js +20 -0
- package/dist/index.js +4404 -0
- package/dist/index.js.map +1 -0
- package/package.json +51 -0
- package/src/templates/backend/.env.example.hbs +10 -0
- package/src/templates/backend/apps/__init__.py.hbs +0 -0
- package/src/templates/backend/apps/users/__init__.py.hbs +0 -0
- package/src/templates/backend/apps/users/admin.py.hbs +26 -0
- package/src/templates/backend/apps/users/managers.py.hbs +25 -0
- package/src/templates/backend/apps/users/models.py.hbs +25 -0
- package/src/templates/backend/apps/users/serializers.py.hbs +94 -0
- package/src/templates/backend/apps/users/tests.py.hbs +47 -0
- package/src/templates/backend/apps/users/urls.py.hbs +10 -0
- package/src/templates/backend/apps/users/views.py.hbs +175 -0
- package/src/templates/backend/config/__init__.py.hbs +0 -0
- package/src/templates/backend/config/asgi.py.hbs +9 -0
- package/src/templates/backend/config/settings/__init__.py.hbs +13 -0
- package/src/templates/backend/config/settings/base.py.hbs +117 -0
- package/src/templates/backend/config/settings/development.py.hbs +19 -0
- package/src/templates/backend/config/settings/production.py.hbs +31 -0
- package/src/templates/backend/config/urls.py.hbs +26 -0
- package/src/templates/backend/config/wsgi.py.hbs +9 -0
- package/src/templates/backend/manage.py.hbs +22 -0
- package/src/templates/backend/requirements.txt.hbs +7 -0
- package/src/templates/frontend/.env.hbs +1 -0
- package/src/templates/frontend/index.html.hbs +13 -0
- package/src/templates/frontend/openapi-ts.config.ts.hbs +29 -0
- package/src/templates/frontend/package.json.hbs +44 -0
- package/src/templates/frontend/postcss.config.js.hbs +6 -0
- package/src/templates/frontend/src/api/client.ts.hbs +110 -0
- package/src/templates/frontend/src/api/generated/.gitkeep +0 -0
- package/src/templates/frontend/src/api/generated/client.gen.ts +13 -0
- package/src/templates/frontend/src/api/query-client.ts.hbs +22 -0
- package/src/templates/frontend/src/app.tsx.hbs +30 -0
- package/src/templates/frontend/src/features/auth/adapter.ts.hbs +198 -0
- package/src/templates/frontend/src/features/auth/components/auth-provider.tsx.hbs +32 -0
- package/src/templates/frontend/src/features/auth/hooks/use-auth.ts.hbs +27 -0
- package/src/templates/frontend/src/features/auth/index.ts.hbs +3 -0
- package/src/templates/frontend/src/features/auth/pages/forgot-password-page.tsx.hbs +37 -0
- package/src/templates/frontend/src/features/auth/pages/login-page.tsx.hbs +36 -0
- package/src/templates/frontend/src/features/auth/pages/register-page.tsx.hbs +36 -0
- package/src/templates/frontend/src/features/auth/pages/reset-password-page.tsx.hbs +41 -0
- package/src/templates/frontend/src/features/auth/routes.tsx.hbs +13 -0
- package/src/templates/frontend/src/main.tsx.hbs +10 -0
- package/src/templates/frontend/src/pages/dashboard/components/quick-start-card.tsx.hbs +36 -0
- package/src/templates/frontend/src/pages/dashboard/components/stack-cards.tsx.hbs +69 -0
- package/src/templates/frontend/src/pages/dashboard/components/welcome-header.tsx.hbs +14 -0
- package/src/templates/frontend/src/pages/dashboard/dashboard.tsx.hbs +21 -0
- package/src/templates/frontend/src/pages/dashboard/index.ts.hbs +1 -0
- package/src/templates/frontend/src/pages/dashboard/routes.tsx.hbs +7 -0
- package/src/templates/frontend/src/pages/home/components/features-grid.tsx.hbs +88 -0
- package/src/templates/frontend/src/pages/home/components/getting-started.tsx.hbs +88 -0
- package/src/templates/frontend/src/pages/home/components/hero-section.tsx.hbs +47 -0
- package/src/templates/frontend/src/pages/home/components/resources-section.tsx.hbs +34 -0
- package/src/templates/frontend/src/pages/home/home.tsx.hbs +20 -0
- package/src/templates/frontend/src/pages/home/index.ts.hbs +1 -0
- package/src/templates/frontend/src/pages/home/routes.tsx.hbs +7 -0
- package/src/templates/frontend/src/router/auth-guard.tsx.hbs +57 -0
- package/src/templates/frontend/src/router/error-boundary.tsx.hbs +61 -0
- package/src/templates/frontend/src/router/index.tsx.hbs +12 -0
- package/src/templates/frontend/src/router/layouts/auth-layout.tsx.hbs +68 -0
- package/src/templates/frontend/src/router/layouts/main-layout.tsx.hbs +137 -0
- package/src/templates/frontend/src/router/paths.ts.hbs +38 -0
- package/src/templates/frontend/src/router/routes.tsx.hbs +64 -0
- package/src/templates/frontend/src/shared/components/loading-spinner.tsx.hbs +20 -0
- package/src/templates/frontend/src/shared/components/not-found-page.tsx.hbs +31 -0
- package/src/templates/frontend/src/shared/hooks/api-error.ts.hbs +147 -0
- package/src/templates/frontend/src/shared/hooks/use-api-mutation.ts.hbs +88 -0
- package/src/templates/frontend/src/shared/hooks/use-api-query.ts.hbs +66 -0
- package/src/templates/frontend/src/shared/hooks/use-debounce.ts.hbs +10 -0
- package/src/templates/frontend/src/styles/globals.css.hbs +62 -0
- package/src/templates/frontend/src/vite-env.d.ts.hbs +1 -0
- package/src/templates/frontend/tailwind.config.js.hbs +73 -0
- package/src/templates/frontend/tsconfig.app.json.hbs +25 -0
- package/src/templates/frontend/tsconfig.json.hbs +7 -0
- package/src/templates/frontend/tsconfig.node.json.hbs +18 -0
- package/src/templates/frontend/vite.config.ts.hbs +21 -0
- package/src/templates/resource/backend/__init__.py.hbs +0 -0
- package/src/templates/resource/backend/admin.py.hbs +10 -0
- package/src/templates/resource/backend/models.py.hbs +24 -0
- package/src/templates/resource/backend/serializers.py.hbs +21 -0
- package/src/templates/resource/backend/tests.py.hbs +35 -0
- package/src/templates/resource/backend/urls.py.hbs +10 -0
- package/src/templates/resource/backend/views.py.hbs +32 -0
- package/src/templates/resource/frontend/components/{{kebab}}-card.tsx.hbs +39 -0
- package/src/templates/resource/frontend/components/{{kebab}}-form.tsx.hbs +106 -0
- package/src/templates/resource/frontend/components/{{kebab}}-list.tsx.hbs +49 -0
- package/src/templates/resource/frontend/hooks/use-{{kebabs}}-query.ts.hbs +35 -0
- package/src/templates/resource/frontend/hooks/use-{{kebab}}-mutations.ts.hbs +39 -0
- package/src/templates/resource/frontend/index.ts.hbs +6 -0
- package/src/templates/resource/frontend/pages/{{kebabs}}-page.tsx.hbs +33 -0
- package/src/templates/resource/frontend/pages/{{kebab}}-detail-page.tsx.hbs +96 -0
- package/src/templates/resource/frontend/routes.tsx.hbs +15 -0
- package/src/templates/resource/pages/components/{{kebab}}-card.tsx.hbs +39 -0
- package/src/templates/resource/pages/components/{{kebab}}-form.tsx.hbs +106 -0
- package/src/templates/resource/pages/components/{{kebab}}-list.tsx.hbs +49 -0
- package/src/templates/resource/pages/hooks/use-{{kebabs}}-query.ts.hbs +35 -0
- package/src/templates/resource/pages/hooks/use-{{kebab}}-mutations.ts.hbs +39 -0
- package/src/templates/resource/pages/index.ts.hbs +6 -0
- package/src/templates/resource/pages/routes.tsx.hbs +15 -0
- package/src/templates/resource/pages/{{kebabs}}-page.tsx.hbs +33 -0
- package/src/templates/resource/pages/{{kebab}}-detail-page.tsx.hbs +96 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,4404 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { Command } from "commander";
|
|
3
|
+
|
|
4
|
+
// src/utils/logger.ts
|
|
5
|
+
import chalk from "chalk";
|
|
6
|
+
import ora from "ora";
|
|
7
|
+
import { createInterface } from "readline";
|
|
8
|
+
var log = {
|
|
9
|
+
info: (msg) => console.log(chalk.blue("\u2139"), msg),
|
|
10
|
+
success: (msg) => console.log(chalk.green("\u2713"), msg),
|
|
11
|
+
warn: (msg) => console.log(chalk.yellow("\u26A0"), msg),
|
|
12
|
+
error: (msg) => console.log(chalk.red("\u2717"), msg),
|
|
13
|
+
step: (msg) => console.log(chalk.cyan("\u2192"), msg),
|
|
14
|
+
blank: () => console.log()
|
|
15
|
+
};
|
|
16
|
+
function promptText(label, defaultValue) {
|
|
17
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
18
|
+
const def = defaultValue ? chalk.dim(` (${defaultValue})`) : "";
|
|
19
|
+
const question = ` ${chalk.cyan("?")} ${chalk.bold(label)}${def}${chalk.dim(":")} `;
|
|
20
|
+
return new Promise((resolve) => {
|
|
21
|
+
rl.question(question, (answer) => {
|
|
22
|
+
rl.close();
|
|
23
|
+
resolve(answer.trim() || defaultValue || "");
|
|
24
|
+
});
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
function promptYesNo(label, defaultValue = false) {
|
|
28
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
29
|
+
const hint = defaultValue ? chalk.dim(" (Y/n)") : chalk.dim(" (y/N)");
|
|
30
|
+
const question = ` ${chalk.cyan("?")} ${chalk.bold(label)}${hint}${chalk.dim(":")} `;
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
rl.question(question, (answer) => {
|
|
33
|
+
rl.close();
|
|
34
|
+
const val = answer.trim().toLowerCase();
|
|
35
|
+
if (!val) return resolve(defaultValue);
|
|
36
|
+
resolve(["y", "yes"].includes(val));
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function promptSelect(label, options, defaultValue) {
|
|
41
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
42
|
+
const optionList = options.map((opt, i) => `${chalk.dim(` ${i + 1}.`)} ${opt}`).join("\n");
|
|
43
|
+
const def = defaultValue ? chalk.dim(` (${defaultValue})`) : "";
|
|
44
|
+
const question = ` ${chalk.cyan("?")} ${chalk.bold(label)}${def}
|
|
45
|
+
${optionList}
|
|
46
|
+
${chalk.dim("Choice:")} `;
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
rl.question(question, (answer) => {
|
|
49
|
+
rl.close();
|
|
50
|
+
const trimmed = answer.trim();
|
|
51
|
+
if (!trimmed && defaultValue) return resolve(defaultValue);
|
|
52
|
+
const index = parseInt(trimmed, 10);
|
|
53
|
+
if (index >= 1 && index <= options.length) return resolve(options[index - 1]);
|
|
54
|
+
const match = options.find((opt) => opt.toLowerCase() === trimmed.toLowerCase());
|
|
55
|
+
resolve(match || defaultValue || options[0]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
function printConfig(config) {
|
|
60
|
+
const bar = chalk.dim("\u2502");
|
|
61
|
+
console.log();
|
|
62
|
+
console.log(` ${chalk.dim("\u250C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2510")}`);
|
|
63
|
+
console.log(` ${bar} ${chalk.bold.white("Configuration")}${" ".repeat(23)}${bar}`);
|
|
64
|
+
console.log(` ${chalk.dim("\u251C\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2524")}`);
|
|
65
|
+
for (const [key, value] of Object.entries(config)) {
|
|
66
|
+
const padded = `${chalk.dim(key + ":")} ${chalk.white(value)}`;
|
|
67
|
+
const rawLen = `${key}: ${value}`.length;
|
|
68
|
+
const padding = " ".repeat(Math.max(1, 36 - rawLen));
|
|
69
|
+
console.log(` ${bar} ${padded}${padding}${bar}`);
|
|
70
|
+
}
|
|
71
|
+
console.log(` ${chalk.dim("\u2514\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518")}`);
|
|
72
|
+
console.log();
|
|
73
|
+
}
|
|
74
|
+
function spinner(text) {
|
|
75
|
+
return ora({ text, color: "cyan" }).start();
|
|
76
|
+
}
|
|
77
|
+
function banner() {
|
|
78
|
+
const logo = [
|
|
79
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557",
|
|
80
|
+
" \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2554\u255D",
|
|
81
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2554\u255D ",
|
|
82
|
+
" \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2588\u2588\u2557 ",
|
|
83
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2551\u255A\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2557",
|
|
84
|
+
" \u255A\u2550\u2550\u2550\u2550\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D",
|
|
85
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2557\u2588\u2588\u2557\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2557 \u2588\u2588\u2557",
|
|
86
|
+
" \u2588\u2588\u2554\u2550\u2550\u2550\u2550\u255D\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2550\u2550\u2588\u2588\u2554\u2550\u2550\u255D\u2588\u2588\u2551 \u2588\u2588\u2551",
|
|
87
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2557\u2588\u2588\u2554\u2588\u2588\u2588\u2588\u2554\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551",
|
|
88
|
+
" \u255A\u2550\u2550\u2550\u2550\u2588\u2588\u2551\u2588\u2588\u2551\u255A\u2588\u2588\u2554\u255D\u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551",
|
|
89
|
+
" \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551\u2588\u2588\u2551 \u255A\u2550\u255D \u2588\u2588\u2551\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551",
|
|
90
|
+
" \u255A\u2550\u2550\u2550\u2550\u2550\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D\u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D"
|
|
91
|
+
];
|
|
92
|
+
console.log();
|
|
93
|
+
for (const line of logo) {
|
|
94
|
+
console.log(chalk.cyan(line));
|
|
95
|
+
}
|
|
96
|
+
console.log();
|
|
97
|
+
console.log(chalk.dim(" Welcome to Blacksmith \u2014 forge fullstack apps with one command."));
|
|
98
|
+
console.log();
|
|
99
|
+
}
|
|
100
|
+
function printNextSteps(projectName, backendPort = 8e3, frontendPort = 5173) {
|
|
101
|
+
log.blank();
|
|
102
|
+
log.success("Project created successfully!");
|
|
103
|
+
log.blank();
|
|
104
|
+
console.log(chalk.bold(" Next steps:"));
|
|
105
|
+
console.log();
|
|
106
|
+
console.log(` ${chalk.cyan("cd")} ${projectName}`);
|
|
107
|
+
console.log(` ${chalk.cyan("blacksmith dev")} ${chalk.dim("# Start development servers")}`);
|
|
108
|
+
console.log();
|
|
109
|
+
console.log(chalk.dim(` Django: http://localhost:${backendPort}`));
|
|
110
|
+
console.log(chalk.dim(` React: http://localhost:${frontendPort}`));
|
|
111
|
+
console.log(chalk.dim(` Swagger: http://localhost:${backendPort}/api/docs/`));
|
|
112
|
+
console.log(chalk.dim(` ReDoc: http://localhost:${backendPort}/api/redoc/`));
|
|
113
|
+
log.blank();
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// src/commands/init.ts
|
|
117
|
+
import path4 from "path";
|
|
118
|
+
import fs4 from "fs";
|
|
119
|
+
import { spawn } from "child_process";
|
|
120
|
+
|
|
121
|
+
// src/utils/template.ts
|
|
122
|
+
import fs from "fs";
|
|
123
|
+
import path from "path";
|
|
124
|
+
import Handlebars from "handlebars";
|
|
125
|
+
Handlebars.registerHelper("eq", (a, b) => a === b);
|
|
126
|
+
Handlebars.registerHelper("ne", (a, b) => a !== b);
|
|
127
|
+
Handlebars.registerHelper("upper", (str) => str?.toUpperCase());
|
|
128
|
+
Handlebars.registerHelper("lower", (str) => str?.toLowerCase());
|
|
129
|
+
function renderTemplate(templateStr, context) {
|
|
130
|
+
let safeStr = templateStr.replace(/\{(\s*)(?=\{\{[^{])/g, "BLACKSMITH_OB$1").replace(/([^}]\}\})(\s*)\}/g, "$1$2BLACKSMITH_CB");
|
|
131
|
+
const template = Handlebars.compile(safeStr, { noEscape: true });
|
|
132
|
+
const rendered = template(context);
|
|
133
|
+
return rendered.replace(/BLACKSMITH_OB/g, "{").replace(/BLACKSMITH_CB/g, "}");
|
|
134
|
+
}
|
|
135
|
+
function renderTemplateFile(templatePath, context) {
|
|
136
|
+
const templateStr = fs.readFileSync(templatePath, "utf-8");
|
|
137
|
+
return renderTemplate(templateStr, context);
|
|
138
|
+
}
|
|
139
|
+
function renderToFile(templatePath, destPath, context) {
|
|
140
|
+
const rendered = renderTemplateFile(templatePath, context);
|
|
141
|
+
const destDir = path.dirname(destPath);
|
|
142
|
+
if (!fs.existsSync(destDir)) {
|
|
143
|
+
fs.mkdirSync(destDir, { recursive: true });
|
|
144
|
+
}
|
|
145
|
+
fs.writeFileSync(destPath, rendered, "utf-8");
|
|
146
|
+
}
|
|
147
|
+
function renderDirectory(srcDir, destDir, context) {
|
|
148
|
+
if (!fs.existsSync(srcDir)) {
|
|
149
|
+
throw new Error(`Template directory not found: ${srcDir}`);
|
|
150
|
+
}
|
|
151
|
+
const entries = fs.readdirSync(srcDir, { withFileTypes: true });
|
|
152
|
+
for (const entry of entries) {
|
|
153
|
+
const renderedName = renderTemplate(entry.name, context);
|
|
154
|
+
const srcPath = path.join(srcDir, entry.name);
|
|
155
|
+
if (entry.isDirectory()) {
|
|
156
|
+
const destSubDir = path.join(destDir, renderedName);
|
|
157
|
+
renderDirectory(srcPath, destSubDir, context);
|
|
158
|
+
} else if (entry.name.endsWith(".hbs")) {
|
|
159
|
+
const outputName = renderedName.replace(/\.hbs$/, "");
|
|
160
|
+
const destPath = path.join(destDir, outputName);
|
|
161
|
+
renderToFile(srcPath, destPath, context);
|
|
162
|
+
} else {
|
|
163
|
+
const destPath = path.join(destDir, renderedName);
|
|
164
|
+
const destDirPath = path.dirname(destPath);
|
|
165
|
+
if (!fs.existsSync(destDirPath)) {
|
|
166
|
+
fs.mkdirSync(destDirPath, { recursive: true });
|
|
167
|
+
}
|
|
168
|
+
fs.copyFileSync(srcPath, destPath);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
function appendAfterMarker(filePath, marker, content) {
|
|
173
|
+
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
174
|
+
const lines = fileContent.split("\n");
|
|
175
|
+
const markerIndex = lines.findIndex((line) => line.includes(marker));
|
|
176
|
+
if (markerIndex === -1) {
|
|
177
|
+
throw new Error(`Marker "${marker}" not found in ${filePath}`);
|
|
178
|
+
}
|
|
179
|
+
lines.splice(markerIndex + 1, 0, content);
|
|
180
|
+
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
|
|
181
|
+
}
|
|
182
|
+
function insertBeforeMarker(filePath, marker, content) {
|
|
183
|
+
const fileContent = fs.readFileSync(filePath, "utf-8");
|
|
184
|
+
const lines = fileContent.split("\n");
|
|
185
|
+
const markerIndex = lines.findIndex((line) => line.includes(marker));
|
|
186
|
+
if (markerIndex === -1) {
|
|
187
|
+
throw new Error(`Marker "${marker}" not found in ${filePath}`);
|
|
188
|
+
}
|
|
189
|
+
lines.splice(markerIndex, 0, content);
|
|
190
|
+
fs.writeFileSync(filePath, lines.join("\n"), "utf-8");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/utils/exec.ts
|
|
194
|
+
import { execa } from "execa";
|
|
195
|
+
async function exec(command, args, options = {}) {
|
|
196
|
+
const { cwd, silent = false, env } = options;
|
|
197
|
+
try {
|
|
198
|
+
const result = await execa(command, args, {
|
|
199
|
+
cwd,
|
|
200
|
+
env: { ...process.env, ...env },
|
|
201
|
+
stdio: silent ? "pipe" : "inherit"
|
|
202
|
+
});
|
|
203
|
+
return result;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!silent) {
|
|
206
|
+
log.error(`Command failed: ${command} ${args.join(" ")}`);
|
|
207
|
+
if (error.stderr) {
|
|
208
|
+
log.error(error.stderr);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
async function commandExists(command) {
|
|
215
|
+
try {
|
|
216
|
+
await execa("which", [command], { stdio: "pipe" });
|
|
217
|
+
return true;
|
|
218
|
+
} catch {
|
|
219
|
+
return false;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
async function execPython(args, cwd, silent = false) {
|
|
223
|
+
const venvPython = `${cwd}/venv/bin/python`;
|
|
224
|
+
return exec(venvPython, args, { cwd, silent });
|
|
225
|
+
}
|
|
226
|
+
async function execPip(args, cwd, silent = false) {
|
|
227
|
+
const venvPip = `${cwd}/venv/bin/pip`;
|
|
228
|
+
return exec(venvPip, args, { cwd, silent });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// src/utils/paths.ts
|
|
232
|
+
import path2 from "path";
|
|
233
|
+
import fs2 from "fs";
|
|
234
|
+
import { fileURLToPath } from "url";
|
|
235
|
+
var __filename2 = fileURLToPath(import.meta.url);
|
|
236
|
+
var __dirname2 = path2.dirname(__filename2);
|
|
237
|
+
function getTemplatesDir() {
|
|
238
|
+
const devPath = path2.resolve(__dirname2, "..", "templates");
|
|
239
|
+
const prodPath = path2.resolve(__dirname2, "..", "src", "templates");
|
|
240
|
+
if (fs2.existsSync(devPath)) return devPath;
|
|
241
|
+
if (fs2.existsSync(prodPath)) return prodPath;
|
|
242
|
+
throw new Error("Templates directory not found. Make sure the CLI is properly installed.");
|
|
243
|
+
}
|
|
244
|
+
function findProjectRoot(startDir) {
|
|
245
|
+
let dir = startDir || process.cwd();
|
|
246
|
+
while (dir !== path2.dirname(dir)) {
|
|
247
|
+
if (fs2.existsSync(path2.join(dir, "blacksmith.config.json"))) {
|
|
248
|
+
return dir;
|
|
249
|
+
}
|
|
250
|
+
dir = path2.dirname(dir);
|
|
251
|
+
}
|
|
252
|
+
throw new Error(
|
|
253
|
+
'Not inside a Blacksmith project. Run "blacksmith init <name>" to create one, or navigate to an existing Blacksmith project.'
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
function getBackendDir(projectRoot) {
|
|
257
|
+
const root = projectRoot || findProjectRoot();
|
|
258
|
+
return path2.join(root, "backend");
|
|
259
|
+
}
|
|
260
|
+
function getFrontendDir(projectRoot) {
|
|
261
|
+
const root = projectRoot || findProjectRoot();
|
|
262
|
+
return path2.join(root, "frontend");
|
|
263
|
+
}
|
|
264
|
+
function loadConfig(projectRoot) {
|
|
265
|
+
const root = projectRoot || findProjectRoot();
|
|
266
|
+
const configPath = path2.join(root, "blacksmith.config.json");
|
|
267
|
+
return JSON.parse(fs2.readFileSync(configPath, "utf-8"));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// src/commands/ai-setup.ts
|
|
271
|
+
import path3 from "path";
|
|
272
|
+
import fs3 from "fs";
|
|
273
|
+
|
|
274
|
+
// src/skills/core-rules.ts
|
|
275
|
+
var coreRulesSkill = {
|
|
276
|
+
id: "core-rules",
|
|
277
|
+
// No `name` → content is inlined directly into CLAUDE.md, not a separate file
|
|
278
|
+
render(_ctx) {
|
|
279
|
+
return `## Critical Rules
|
|
280
|
+
|
|
281
|
+
> **These rules are mandatory. Violating them produces broken, inconsistent code.**
|
|
282
|
+
|
|
283
|
+
### 1. Use \`@blacksmith-ui/react\` for ALL UI
|
|
284
|
+
- **Layout**: Use \`Stack\`, \`Flex\`, \`Grid\`, \`Box\`, \`Container\` \u2014 NEVER \`<div className="flex ...">\` or \`<div className="grid ...">\`
|
|
285
|
+
- **Typography**: Use \`Typography\` and \`Text\` \u2014 NEVER raw \`<h1>\`\u2013\`<h6>\`, \`<p>\`, or \`<span>\` with text classes
|
|
286
|
+
- **Separators**: Use \`Divider\` \u2014 NEVER \`<hr>\` or \`<Separator>\`
|
|
287
|
+
- **Everything else**: \`Button\`, \`Card\`, \`Badge\`, \`Input\`, \`Table\`, \`Dialog\`, \`Alert\`, \`Skeleton\`, \`EmptyState\`, \`StatCard\`, etc.
|
|
288
|
+
- See the \`blacksmith-ui-react\` skill for the full 60+ component list
|
|
289
|
+
|
|
290
|
+
### 2. Pages Are Thin Orchestrators
|
|
291
|
+
- A page file should be ~20-30 lines: import components, call hooks, compose JSX
|
|
292
|
+
- Break every page into child components in a \`components/\` folder
|
|
293
|
+
- See the \`page-structure\` skill for the full pattern with examples
|
|
294
|
+
|
|
295
|
+
### 3. Components Render, Hooks Think
|
|
296
|
+
- Extract ALL logic into hooks in a \`hooks/\` folder \u2014 API calls, mutations, form setup, filtering, pagination, debouncing, computed state
|
|
297
|
+
- Components should contain only JSX composition, prop passing, and simple event handler wiring
|
|
298
|
+
- The only \`useState\` acceptable inline in a component is a simple UI toggle (e.g. modal open/close)
|
|
299
|
+
- If a component has more than one \`useState\`, one \`useEffect\`, or any \`useApiQuery\`/\`useApiMutation\` \u2014 extract to a hook
|
|
300
|
+
|
|
301
|
+
### 4. Use the \`Path\` Enum \u2014 Never Hardcode Paths
|
|
302
|
+
- All route paths are in \`src/router/paths.ts\` as a \`Path\` enum
|
|
303
|
+
- Use \`Path.Login\`, \`Path.Dashboard\`, etc. in \`navigate()\`, \`<Link to={}>\`, and route definitions
|
|
304
|
+
- When adding a new page, add its path to the enum before \`// blacksmith:path\`
|
|
305
|
+
- Use \`buildPath(Path.ResetPassword, { token })\` for dynamic segments
|
|
306
|
+
|
|
307
|
+
### 5. Follow the Page/Feature Folder Structure
|
|
308
|
+
\`\`\`
|
|
309
|
+
pages/<page>/
|
|
310
|
+
\u251C\u2500\u2500 <page>.tsx # Thin orchestrator (default export)
|
|
311
|
+
\u251C\u2500\u2500 routes.tsx # RouteObject[] using Path enum
|
|
312
|
+
\u251C\u2500\u2500 index.ts # Re-exports public API
|
|
313
|
+
\u251C\u2500\u2500 components/ # Child components
|
|
314
|
+
\u2514\u2500\u2500 hooks/ # Data hooks
|
|
315
|
+
\`\`\`
|
|
316
|
+
- See the \`page-structure\` skill for full conventions
|
|
317
|
+
`;
|
|
318
|
+
}
|
|
319
|
+
};
|
|
320
|
+
|
|
321
|
+
// src/skills/project-overview.ts
|
|
322
|
+
var projectOverviewSkill = {
|
|
323
|
+
id: "project-overview",
|
|
324
|
+
name: "Project Overview",
|
|
325
|
+
description: "Overview of the project structure, commands, and development workflow.",
|
|
326
|
+
render(ctx) {
|
|
327
|
+
return `# ${ctx.projectName}
|
|
328
|
+
|
|
329
|
+
A fullstack web application built with **Django** (backend) and **React** (frontend), scaffolded by **Blacksmith CLI**.
|
|
330
|
+
|
|
331
|
+
## Project Structure
|
|
332
|
+
|
|
333
|
+
\`\`\`
|
|
334
|
+
${ctx.projectName}/
|
|
335
|
+
\u251C\u2500\u2500 backend/ # Django project
|
|
336
|
+
\u2502 \u251C\u2500\u2500 apps/ # Django apps (one per resource)
|
|
337
|
+
\u2502 \u2502 \u2514\u2500\u2500 users/ # Built-in user app
|
|
338
|
+
\u2502 \u251C\u2500\u2500 config/ # Django settings, urls, wsgi/asgi
|
|
339
|
+
\u2502 \u2502 \u2514\u2500\u2500 settings/ # Split settings (base, development, production)
|
|
340
|
+
\u2502 \u251C\u2500\u2500 manage.py
|
|
341
|
+
\u2502 \u251C\u2500\u2500 requirements.txt
|
|
342
|
+
\u2502 \u2514\u2500\u2500 venv/ # Python virtual environment
|
|
343
|
+
\u251C\u2500\u2500 frontend/ # React + Vite project
|
|
344
|
+
\u2502 \u251C\u2500\u2500 src/
|
|
345
|
+
\u2502 \u2502 \u251C\u2500\u2500 api/ # API client (auto-generated from OpenAPI)
|
|
346
|
+
\u2502 \u2502 \u251C\u2500\u2500 features/ # Feature modules (auth, etc.)
|
|
347
|
+
\u2502 \u2502 \u251C\u2500\u2500 pages/ # Top-level pages
|
|
348
|
+
\u2502 \u2502 \u251C\u2500\u2500 router/ # React Router setup with guards
|
|
349
|
+
\u2502 \u2502 \u251C\u2500\u2500 shared/ # Shared components and hooks
|
|
350
|
+
\u2502 \u2502 \u2514\u2500\u2500 styles/ # Global styles (Tailwind)
|
|
351
|
+
\u2502 \u251C\u2500\u2500 package.json
|
|
352
|
+
\u2502 \u2514\u2500\u2500 tailwind.config.js
|
|
353
|
+
\u251C\u2500\u2500 blacksmith.config.json
|
|
354
|
+
\u2514\u2500\u2500 CLAUDE.md # This file
|
|
355
|
+
\`\`\`
|
|
356
|
+
|
|
357
|
+
## Commands
|
|
358
|
+
|
|
359
|
+
- \`blacksmith dev\` \u2014 Start Django + Vite + OpenAPI sync in parallel
|
|
360
|
+
- \`blacksmith sync\` \u2014 Regenerate frontend API types from Django OpenAPI schema
|
|
361
|
+
- \`blacksmith make:resource <Name>\` \u2014 Scaffold a full resource (model, serializer, viewset, hooks, pages)
|
|
362
|
+
- \`blacksmith build\` \u2014 Production build (frontend + collectstatic)
|
|
363
|
+
- \`blacksmith eject\` \u2014 Remove Blacksmith, keep a clean Django + React project
|
|
364
|
+
|
|
365
|
+
## Development Workflow
|
|
366
|
+
|
|
367
|
+
1. Define models in \`backend/apps/<app>/models.py\`
|
|
368
|
+
2. Create serializers in \`backend/apps/<app>/serializers.py\`
|
|
369
|
+
3. Add viewsets in \`backend/apps/<app>/views.py\` and register URLs in \`backend/apps/<app>/urls.py\`
|
|
370
|
+
4. Run \`blacksmith sync\` to generate TypeScript types and API client
|
|
371
|
+
5. Build frontend features using generated hooks in \`frontend/src/features/\`
|
|
372
|
+
`;
|
|
373
|
+
}
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
// src/skills/django.ts
|
|
377
|
+
var djangoSkill = {
|
|
378
|
+
id: "django",
|
|
379
|
+
name: "Django Backend Conventions",
|
|
380
|
+
description: "Models, serializers, views, URLs, settings, migrations, and testing patterns for the Django backend.",
|
|
381
|
+
render(_ctx) {
|
|
382
|
+
return `## Django Backend Conventions
|
|
383
|
+
|
|
384
|
+
### Models
|
|
385
|
+
- Models live in \`backend/apps/<app>/models.py\`
|
|
386
|
+
- Use Django's ORM. Inherit from \`models.Model\`
|
|
387
|
+
- Use \`TimeStampedModel\` pattern: add \`created_at\` and \`updated_at\` fields with \`auto_now_add\` and \`auto_now\`
|
|
388
|
+
- Register models in \`backend/apps/<app>/admin.py\` for Django admin
|
|
389
|
+
- Use descriptive \`verbose_name\` and \`verbose_name_plural\` in \`Meta\`
|
|
390
|
+
- Define \`__str__\` on every model for readable admin and debugging output
|
|
391
|
+
- Use \`related_name\` on all ForeignKey and ManyToManyField declarations
|
|
392
|
+
- Prefer \`TextField\` over \`CharField\` when there is no strict max length requirement
|
|
393
|
+
|
|
394
|
+
### Serializers
|
|
395
|
+
- Use Django REST Framework serializers in \`backend/apps/<app>/serializers.py\`
|
|
396
|
+
- Prefer \`ModelSerializer\` for standard CRUD operations
|
|
397
|
+
- Use \`serializers.Serializer\` for custom input/output that does not map to a model
|
|
398
|
+
- Add per-field validation via \`validate_<field>(self, value)\` methods
|
|
399
|
+
- Add cross-field validation via \`validate(self, attrs)\`
|
|
400
|
+
- Use \`SerializerMethodField\` for computed read-only fields
|
|
401
|
+
- Nest related serializers for read endpoints; use PrimaryKeyRelatedField for write endpoints
|
|
402
|
+
- Keep serializers thin \u2014 move business logic to model methods or service functions
|
|
403
|
+
|
|
404
|
+
### Views
|
|
405
|
+
- Use DRF \`ModelViewSet\` for standard CRUD endpoints
|
|
406
|
+
- Use \`@action(detail=True|False)\` decorator for custom non-CRUD endpoints
|
|
407
|
+
- Apply permissions with \`permission_classes\` at the class or action level
|
|
408
|
+
- Use \`@extend_schema\` from \`drf-spectacular\` to document every endpoint \u2014 this powers the OpenAPI sync that generates frontend types
|
|
409
|
+
- Use \`filterset_fields\`, \`search_fields\`, and \`ordering_fields\` for queryable list endpoints
|
|
410
|
+
- Override \`get_queryset()\` to scope data to the current user when needed
|
|
411
|
+
- Override \`perform_create()\` to inject \`request.user\` or other context into the serializer save
|
|
412
|
+
|
|
413
|
+
### URLs
|
|
414
|
+
- Each app has its own \`urls.py\` with a \`DefaultRouter\`
|
|
415
|
+
- Register viewsets on the router: \`router.register('resources', ResourceViewSet)\`
|
|
416
|
+
- App URLs are included in \`backend/config/urls.py\` under \`/api/\`
|
|
417
|
+
- URL pattern: \`/api/<resource>/\` (list/create), \`/api/<resource>/<id>/\` (retrieve/update/delete)
|
|
418
|
+
|
|
419
|
+
### Settings
|
|
420
|
+
- Split settings: \`base.py\` (shared), \`development.py\` (local dev), \`production.py\` (deployment)
|
|
421
|
+
- Environment variables loaded from \`.env\` via \`django-environ\`
|
|
422
|
+
- Database: SQLite in development, configurable in production via \`DATABASE_URL\`
|
|
423
|
+
- \`INSTALLED_APPS\` is declared in \`base.py\` \u2014 add new apps there
|
|
424
|
+
- CORS, allowed hosts, and debug flags are environment-specific
|
|
425
|
+
|
|
426
|
+
### Migrations
|
|
427
|
+
- Run \`./venv/bin/python manage.py makemigrations <app>\` after model changes
|
|
428
|
+
- Run \`./venv/bin/python manage.py migrate\` to apply
|
|
429
|
+
- Never edit auto-generated migration files unless resolving a conflict
|
|
430
|
+
- Use \`RunPython\` in data migrations for one-time data transformations
|
|
431
|
+
|
|
432
|
+
### Testing
|
|
433
|
+
- Tests live in \`backend/apps/<app>/tests.py\` (or a \`tests/\` package for larger apps)
|
|
434
|
+
- Use \`APITestCase\` from DRF for API endpoint tests
|
|
435
|
+
- Use \`APIClient\` with \`force_authenticate(user)\` for authenticated requests
|
|
436
|
+
- Test both success and error paths (400, 401, 403, 404)
|
|
437
|
+
- Run all tests: \`cd backend && ./venv/bin/python manage.py test\`
|
|
438
|
+
- Run a single app: \`cd backend && ./venv/bin/python manage.py test apps.<app>\`
|
|
439
|
+
|
|
440
|
+
### Adding a New App Manually
|
|
441
|
+
1. Create the app directory under \`backend/apps/\` with \`__init__.py\`, \`models.py\`, \`views.py\`, \`serializers.py\`, \`urls.py\`, \`admin.py\`, \`tests.py\`
|
|
442
|
+
2. Add \`'apps.<app>'\` to \`INSTALLED_APPS\` in \`backend/config/settings/base.py\`
|
|
443
|
+
3. Include URLs in \`backend/config/urls.py\`: \`path('api/<app>/', include('apps.<app>.urls'))\`
|
|
444
|
+
4. Run \`makemigrations\` and \`migrate\`
|
|
445
|
+
5. Run \`blacksmith sync\` to update frontend types
|
|
446
|
+
|
|
447
|
+
### Common Patterns
|
|
448
|
+
- **Soft delete**: Add an \`is_active\` BooleanField and override \`get_queryset()\` to filter
|
|
449
|
+
- **Pagination**: Configured globally in \`REST_FRAMEWORK\` settings \u2014 default is \`PageNumberPagination\`
|
|
450
|
+
- **Permissions**: Use \`IsAuthenticated\` as default; create custom permissions in \`permissions.py\`
|
|
451
|
+
- **Signals**: Use sparingly; prefer explicit calls in serializer/view logic
|
|
452
|
+
- **Management commands**: Place in \`backend/apps/<app>/management/commands/\` for CLI tasks
|
|
453
|
+
`;
|
|
454
|
+
}
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
// src/skills/django-rest-advanced.ts
|
|
458
|
+
var djangoRestAdvancedSkill = {
|
|
459
|
+
id: "django-rest-advanced",
|
|
460
|
+
name: "Advanced Django REST Framework",
|
|
461
|
+
description: "Senior-level DRF patterns: service layer, query optimization, custom permissions, filters, caching, and testing.",
|
|
462
|
+
render(_ctx) {
|
|
463
|
+
return `## Advanced Django REST Framework \u2014 Senior-Level Patterns
|
|
464
|
+
|
|
465
|
+
> **RULE: Follow these patterns for production-grade, scalable, and maintainable DRF APIs.**
|
|
466
|
+
> These build on top of the base Django conventions. Apply them when building non-trivial features.
|
|
467
|
+
|
|
468
|
+
### Architecture: Service Layer Pattern
|
|
469
|
+
|
|
470
|
+
Keep views and serializers thin. Extract business logic into service modules.
|
|
471
|
+
|
|
472
|
+
\`\`\`
|
|
473
|
+
backend/apps/<app>/
|
|
474
|
+
\u251C\u2500\u2500 models.py # Data + model-level methods only
|
|
475
|
+
\u251C\u2500\u2500 serializers.py # Validation + representation only
|
|
476
|
+
\u251C\u2500\u2500 views.py # HTTP glue + permissions only
|
|
477
|
+
\u251C\u2500\u2500 services.py # Business logic lives here
|
|
478
|
+
\u251C\u2500\u2500 selectors.py # Complex read queries
|
|
479
|
+
\u251C\u2500\u2500 permissions.py # Custom permission classes
|
|
480
|
+
\u251C\u2500\u2500 filters.py # Custom filter backends
|
|
481
|
+
\u251C\u2500\u2500 signals.py # Signal handlers (use sparingly)
|
|
482
|
+
\u251C\u2500\u2500 tasks.py # Celery/background tasks
|
|
483
|
+
\u2514\u2500\u2500 tests/
|
|
484
|
+
\u251C\u2500\u2500 test_views.py
|
|
485
|
+
\u251C\u2500\u2500 test_services.py
|
|
486
|
+
\u2514\u2500\u2500 test_selectors.py
|
|
487
|
+
\`\`\`
|
|
488
|
+
|
|
489
|
+
\`\`\`python
|
|
490
|
+
# services.py \u2014 Business logic
|
|
491
|
+
from django.db import transaction
|
|
492
|
+
|
|
493
|
+
class OrderService:
|
|
494
|
+
@staticmethod
|
|
495
|
+
@transaction.atomic
|
|
496
|
+
def place_order(*, user, items, shipping_address):
|
|
497
|
+
"""Place an order with inventory validation and payment."""
|
|
498
|
+
order = Order.objects.create(user=user, shipping_address=shipping_address)
|
|
499
|
+
for item in items:
|
|
500
|
+
if item['product'].stock < item['quantity']:
|
|
501
|
+
raise ValidationError(f"Insufficient stock for {item['product'].name}")
|
|
502
|
+
OrderItem.objects.create(order=order, **item)
|
|
503
|
+
item['product'].stock -= item['quantity']
|
|
504
|
+
item['product'].save(update_fields=['stock'])
|
|
505
|
+
PaymentService.charge(user=user, amount=order.total)
|
|
506
|
+
return order
|
|
507
|
+
\`\`\`
|
|
508
|
+
|
|
509
|
+
\`\`\`python
|
|
510
|
+
# selectors.py \u2014 Complex read queries
|
|
511
|
+
from django.db.models import Q, Count, Prefetch
|
|
512
|
+
|
|
513
|
+
class OrderSelector:
|
|
514
|
+
@staticmethod
|
|
515
|
+
def list_for_user(*, user, status=None, search=None):
|
|
516
|
+
qs = (
|
|
517
|
+
Order.objects
|
|
518
|
+
.filter(user=user)
|
|
519
|
+
.select_related('user', 'shipping_address')
|
|
520
|
+
.prefetch_related(
|
|
521
|
+
Prefetch('items', queryset=OrderItem.objects.select_related('product'))
|
|
522
|
+
)
|
|
523
|
+
.annotate(item_count=Count('items'))
|
|
524
|
+
)
|
|
525
|
+
if status:
|
|
526
|
+
qs = qs.filter(status=status)
|
|
527
|
+
if search:
|
|
528
|
+
qs = qs.filter(Q(id__icontains=search) | Q(items__product__name__icontains=search))
|
|
529
|
+
return qs.distinct().order_by('-created_at')
|
|
530
|
+
\`\`\`
|
|
531
|
+
|
|
532
|
+
### Serializers: Advanced Patterns
|
|
533
|
+
|
|
534
|
+
**Separate read and write serializers:**
|
|
535
|
+
\`\`\`python
|
|
536
|
+
class OrderListSerializer(serializers.ModelSerializer):
|
|
537
|
+
"""Lightweight serializer for list endpoints."""
|
|
538
|
+
item_count = serializers.IntegerField(read_only=True)
|
|
539
|
+
user = UserMinimalSerializer(read_only=True)
|
|
540
|
+
|
|
541
|
+
class Meta:
|
|
542
|
+
model = Order
|
|
543
|
+
fields = ['id', 'status', 'total', 'item_count', 'user', 'created_at']
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
class OrderDetailSerializer(serializers.ModelSerializer):
|
|
547
|
+
"""Full serializer for retrieve endpoints."""
|
|
548
|
+
items = OrderItemSerializer(many=True, read_only=True)
|
|
549
|
+
user = UserSerializer(read_only=True)
|
|
550
|
+
shipping_address = AddressSerializer(read_only=True)
|
|
551
|
+
|
|
552
|
+
class Meta:
|
|
553
|
+
model = Order
|
|
554
|
+
fields = ['id', 'status', 'total', 'items', 'user', 'shipping_address', 'created_at', 'updated_at']
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
class OrderCreateSerializer(serializers.Serializer):
|
|
558
|
+
"""Write serializer \u2014 validates input, delegates to service."""
|
|
559
|
+
items = OrderItemInputSerializer(many=True)
|
|
560
|
+
shipping_address_id = serializers.PrimaryKeyRelatedField(queryset=Address.objects.all())
|
|
561
|
+
|
|
562
|
+
def create(self, validated_data):
|
|
563
|
+
return OrderService.place_order(
|
|
564
|
+
user=self.context['request'].user,
|
|
565
|
+
items=validated_data['items'],
|
|
566
|
+
shipping_address=validated_data['shipping_address_id'],
|
|
567
|
+
)
|
|
568
|
+
\`\`\`
|
|
569
|
+
|
|
570
|
+
**Writable nested serializers:**
|
|
571
|
+
\`\`\`python
|
|
572
|
+
class ProjectSerializer(serializers.ModelSerializer):
|
|
573
|
+
tags = TagSerializer(many=True, required=False)
|
|
574
|
+
|
|
575
|
+
class Meta:
|
|
576
|
+
model = Project
|
|
577
|
+
fields = ['id', 'name', 'description', 'tags']
|
|
578
|
+
|
|
579
|
+
def create(self, validated_data):
|
|
580
|
+
tags_data = validated_data.pop('tags', [])
|
|
581
|
+
project = Project.objects.create(**validated_data)
|
|
582
|
+
for tag_data in tags_data:
|
|
583
|
+
tag, _ = Tag.objects.get_or_create(**tag_data)
|
|
584
|
+
project.tags.add(tag)
|
|
585
|
+
return project
|
|
586
|
+
|
|
587
|
+
def update(self, instance, validated_data):
|
|
588
|
+
tags_data = validated_data.pop('tags', None)
|
|
589
|
+
instance = super().update(instance, validated_data)
|
|
590
|
+
if tags_data is not None:
|
|
591
|
+
instance.tags.clear()
|
|
592
|
+
for tag_data in tags_data:
|
|
593
|
+
tag, _ = Tag.objects.get_or_create(**tag_data)
|
|
594
|
+
instance.tags.add(tag)
|
|
595
|
+
return instance
|
|
596
|
+
\`\`\`
|
|
597
|
+
|
|
598
|
+
**Dynamic field serializers:**
|
|
599
|
+
\`\`\`python
|
|
600
|
+
class DynamicFieldsSerializer(serializers.ModelSerializer):
|
|
601
|
+
"""Pass ?fields=id,name,email to limit response fields."""
|
|
602
|
+
def __init__(self, *args, **kwargs):
|
|
603
|
+
fields = kwargs.pop('fields', None)
|
|
604
|
+
super().__init__(*args, **kwargs)
|
|
605
|
+
if fields is not None:
|
|
606
|
+
allowed = set(fields)
|
|
607
|
+
for field_name in set(self.fields) - allowed:
|
|
608
|
+
self.fields.pop(field_name)
|
|
609
|
+
\`\`\`
|
|
610
|
+
|
|
611
|
+
### ViewSets: Advanced Patterns
|
|
612
|
+
|
|
613
|
+
**Use \`get_serializer_class()\` for action-specific serializers:**
|
|
614
|
+
\`\`\`python
|
|
615
|
+
class OrderViewSet(ModelViewSet):
|
|
616
|
+
permission_classes = [IsAuthenticated]
|
|
617
|
+
filterset_class = OrderFilterSet
|
|
618
|
+
search_fields = ['items__product__name']
|
|
619
|
+
ordering_fields = ['created_at', 'total']
|
|
620
|
+
ordering = ['-created_at']
|
|
621
|
+
|
|
622
|
+
def get_queryset(self):
|
|
623
|
+
return OrderSelector.list_for_user(user=self.request.user)
|
|
624
|
+
|
|
625
|
+
def get_serializer_class(self):
|
|
626
|
+
if self.action == 'list':
|
|
627
|
+
return OrderListSerializer
|
|
628
|
+
if self.action == 'retrieve':
|
|
629
|
+
return OrderDetailSerializer
|
|
630
|
+
if self.action in ('create',):
|
|
631
|
+
return OrderCreateSerializer
|
|
632
|
+
return OrderUpdateSerializer
|
|
633
|
+
|
|
634
|
+
def perform_create(self, serializer):
|
|
635
|
+
serializer.save() # Service called inside serializer.create()
|
|
636
|
+
|
|
637
|
+
@extend_schema(request=None, responses={200: OrderDetailSerializer})
|
|
638
|
+
@action(detail=True, methods=['post'])
|
|
639
|
+
def cancel(self, request, pk=None):
|
|
640
|
+
order = self.get_object()
|
|
641
|
+
OrderService.cancel_order(order=order, user=request.user)
|
|
642
|
+
return Response(OrderDetailSerializer(order).data)
|
|
643
|
+
\`\`\`
|
|
644
|
+
|
|
645
|
+
**Bulk operations:**
|
|
646
|
+
\`\`\`python
|
|
647
|
+
class BulkActionSerializer(serializers.Serializer):
|
|
648
|
+
ids = serializers.ListField(child=serializers.IntegerField(), min_length=1, max_length=100)
|
|
649
|
+
action = serializers.ChoiceField(choices=['archive', 'delete', 'export'])
|
|
650
|
+
|
|
651
|
+
@extend_schema(request=BulkActionSerializer, responses={200: None})
|
|
652
|
+
@action(detail=False, methods=['post'])
|
|
653
|
+
def bulk_action(self, request):
|
|
654
|
+
serializer = BulkActionSerializer(data=request.data)
|
|
655
|
+
serializer.is_valid(raise_exception=True)
|
|
656
|
+
qs = self.get_queryset().filter(id__in=serializer.validated_data['ids'])
|
|
657
|
+
action = serializer.validated_data['action']
|
|
658
|
+
if action == 'archive':
|
|
659
|
+
qs.update(status='archived')
|
|
660
|
+
elif action == 'delete':
|
|
661
|
+
qs.delete()
|
|
662
|
+
return Response(status=status.HTTP_200_OK)
|
|
663
|
+
\`\`\`
|
|
664
|
+
|
|
665
|
+
### QuerySet Optimization
|
|
666
|
+
|
|
667
|
+
**ALWAYS optimize queries. N+1 queries are unacceptable.**
|
|
668
|
+
|
|
669
|
+
\`\`\`python
|
|
670
|
+
# BAD \u2014 N+1 queries
|
|
671
|
+
orders = Order.objects.all()
|
|
672
|
+
for order in orders:
|
|
673
|
+
print(order.user.email) # 1 query per order
|
|
674
|
+
for item in order.items.all(): # 1 query per order
|
|
675
|
+
print(item.product.name) # 1 query per item
|
|
676
|
+
|
|
677
|
+
# GOOD \u2014 3 queries total
|
|
678
|
+
orders = (
|
|
679
|
+
Order.objects
|
|
680
|
+
.select_related('user')
|
|
681
|
+
.prefetch_related(
|
|
682
|
+
Prefetch('items', queryset=OrderItem.objects.select_related('product'))
|
|
683
|
+
)
|
|
684
|
+
)
|
|
685
|
+
\`\`\`
|
|
686
|
+
|
|
687
|
+
**Use \`only()\` / \`defer()\` for large tables:**
|
|
688
|
+
\`\`\`python
|
|
689
|
+
# Only load fields you need for list views
|
|
690
|
+
Product.objects.only('id', 'name', 'price', 'thumbnail').filter(is_active=True)
|
|
691
|
+
\`\`\`
|
|
692
|
+
|
|
693
|
+
**Use \`Subquery\` and \`OuterRef\` for correlated queries:**
|
|
694
|
+
\`\`\`python
|
|
695
|
+
from django.db.models import Subquery, OuterRef
|
|
696
|
+
|
|
697
|
+
latest_comment = Comment.objects.filter(
|
|
698
|
+
post=OuterRef('pk')
|
|
699
|
+
).order_by('-created_at')
|
|
700
|
+
|
|
701
|
+
posts = Post.objects.annotate(
|
|
702
|
+
latest_comment_text=Subquery(latest_comment.values('text')[:1])
|
|
703
|
+
)
|
|
704
|
+
\`\`\`
|
|
705
|
+
|
|
706
|
+
### Custom Permissions
|
|
707
|
+
|
|
708
|
+
\`\`\`python
|
|
709
|
+
# permissions.py
|
|
710
|
+
from rest_framework.permissions import BasePermission
|
|
711
|
+
|
|
712
|
+
class IsOwner(BasePermission):
|
|
713
|
+
"""Object-level permission: only the owner can modify."""
|
|
714
|
+
def has_object_permission(self, request, view, obj):
|
|
715
|
+
return obj.user == request.user
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
class IsAdminOrReadOnly(BasePermission):
|
|
719
|
+
def has_permission(self, request, view):
|
|
720
|
+
if request.method in ('GET', 'HEAD', 'OPTIONS'):
|
|
721
|
+
return True
|
|
722
|
+
return request.user and request.user.is_staff
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
class HasRole(BasePermission):
|
|
726
|
+
"""Usage: permission_classes = [HasRole('manager')]"""
|
|
727
|
+
def __init__(self, role):
|
|
728
|
+
self.role = role
|
|
729
|
+
|
|
730
|
+
def has_permission(self, request, view):
|
|
731
|
+
return hasattr(request.user, 'role') and request.user.role == self.role
|
|
732
|
+
\`\`\`
|
|
733
|
+
|
|
734
|
+
**Combine permissions per action:**
|
|
735
|
+
\`\`\`python
|
|
736
|
+
class ProjectViewSet(ModelViewSet):
|
|
737
|
+
def get_permissions(self):
|
|
738
|
+
if self.action in ('update', 'partial_update', 'destroy'):
|
|
739
|
+
return [IsAuthenticated(), IsOwner()]
|
|
740
|
+
if self.action == 'create':
|
|
741
|
+
return [IsAuthenticated()]
|
|
742
|
+
return [AllowAny()]
|
|
743
|
+
\`\`\`
|
|
744
|
+
|
|
745
|
+
### Custom Filters with django-filter
|
|
746
|
+
|
|
747
|
+
\`\`\`python
|
|
748
|
+
# filters.py
|
|
749
|
+
import django_filters
|
|
750
|
+
from .models import Order
|
|
751
|
+
|
|
752
|
+
class OrderFilterSet(django_filters.FilterSet):
|
|
753
|
+
min_total = django_filters.NumberFilter(field_name='total', lookup_expr='gte')
|
|
754
|
+
max_total = django_filters.NumberFilter(field_name='total', lookup_expr='lte')
|
|
755
|
+
created_after = django_filters.DateFilter(field_name='created_at', lookup_expr='gte')
|
|
756
|
+
created_before = django_filters.DateFilter(field_name='created_at', lookup_expr='lte')
|
|
757
|
+
status = django_filters.MultipleChoiceFilter(choices=Order.STATUS_CHOICES)
|
|
758
|
+
|
|
759
|
+
class Meta:
|
|
760
|
+
model = Order
|
|
761
|
+
fields = ['status', 'min_total', 'max_total', 'created_after', 'created_before']
|
|
762
|
+
\`\`\`
|
|
763
|
+
|
|
764
|
+
### Pagination: Cursor-Based for Large Datasets
|
|
765
|
+
|
|
766
|
+
\`\`\`python
|
|
767
|
+
# pagination.py
|
|
768
|
+
from rest_framework.pagination import CursorPagination
|
|
769
|
+
|
|
770
|
+
class TimelinePagination(CursorPagination):
|
|
771
|
+
page_size = 50
|
|
772
|
+
ordering = '-created_at'
|
|
773
|
+
cursor_query_param = 'cursor'
|
|
774
|
+
\`\`\`
|
|
775
|
+
|
|
776
|
+
Use in viewset: \`pagination_class = TimelinePagination\`
|
|
777
|
+
|
|
778
|
+
### Throttling
|
|
779
|
+
|
|
780
|
+
\`\`\`python
|
|
781
|
+
# In settings
|
|
782
|
+
REST_FRAMEWORK = {
|
|
783
|
+
'DEFAULT_THROTTLE_CLASSES': ['rest_framework.throttling.ScopedRateThrottle'],
|
|
784
|
+
'DEFAULT_THROTTLE_RATES': {
|
|
785
|
+
'auth': '5/min',
|
|
786
|
+
'uploads': '20/hour',
|
|
787
|
+
'burst': '60/min',
|
|
788
|
+
},
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
# In view
|
|
792
|
+
class LoginView(APIView):
|
|
793
|
+
throttle_scope = 'auth'
|
|
794
|
+
\`\`\`
|
|
795
|
+
|
|
796
|
+
### Caching
|
|
797
|
+
|
|
798
|
+
\`\`\`python
|
|
799
|
+
from django.views.decorators.cache import cache_page
|
|
800
|
+
from django.utils.decorators import method_decorator
|
|
801
|
+
|
|
802
|
+
class ProductViewSet(ModelViewSet):
|
|
803
|
+
@method_decorator(cache_page(60 * 15)) # 15 min cache
|
|
804
|
+
def list(self, request, *args, **kwargs):
|
|
805
|
+
return super().list(request, *args, **kwargs)
|
|
806
|
+
\`\`\`
|
|
807
|
+
|
|
808
|
+
**Conditional caching with ETags:**
|
|
809
|
+
\`\`\`python
|
|
810
|
+
from rest_framework_condition import condition
|
|
811
|
+
from hashlib import md5
|
|
812
|
+
|
|
813
|
+
def product_etag(request, pk=None):
|
|
814
|
+
product = Product.objects.only('updated_at').get(pk=pk)
|
|
815
|
+
return md5(str(product.updated_at).encode()).hexdigest()
|
|
816
|
+
|
|
817
|
+
class ProductViewSet(ModelViewSet):
|
|
818
|
+
@condition(etag_func=product_etag)
|
|
819
|
+
def retrieve(self, request, *args, **kwargs):
|
|
820
|
+
return super().retrieve(request, *args, **kwargs)
|
|
821
|
+
\`\`\`
|
|
822
|
+
|
|
823
|
+
### Error Handling
|
|
824
|
+
|
|
825
|
+
\`\`\`python
|
|
826
|
+
# exceptions.py
|
|
827
|
+
from rest_framework.views import exception_handler
|
|
828
|
+
from rest_framework.response import Response
|
|
829
|
+
|
|
830
|
+
def custom_exception_handler(exc, context):
|
|
831
|
+
response = exception_handler(exc, context)
|
|
832
|
+
if response is not None:
|
|
833
|
+
response.data = {
|
|
834
|
+
'error': {
|
|
835
|
+
'code': response.status_code,
|
|
836
|
+
'message': response.data.get('detail', response.data),
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
return response
|
|
840
|
+
\`\`\`
|
|
841
|
+
|
|
842
|
+
Register in settings: \`'EXCEPTION_HANDLER': 'config.exceptions.custom_exception_handler'\`
|
|
843
|
+
|
|
844
|
+
### Versioning
|
|
845
|
+
|
|
846
|
+
\`\`\`python
|
|
847
|
+
# settings
|
|
848
|
+
REST_FRAMEWORK = {
|
|
849
|
+
'DEFAULT_VERSIONING_CLASS': 'rest_framework.versioning.URLPathVersioning',
|
|
850
|
+
'ALLOWED_VERSIONS': ['v1', 'v2'],
|
|
851
|
+
'DEFAULT_VERSION': 'v1',
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
# urls.py
|
|
855
|
+
urlpatterns = [
|
|
856
|
+
path('api/<version>/', include('apps.core.urls')),
|
|
857
|
+
]
|
|
858
|
+
|
|
859
|
+
# views.py \u2014 Version-specific behavior
|
|
860
|
+
class UserViewSet(ModelViewSet):
|
|
861
|
+
def get_serializer_class(self):
|
|
862
|
+
if self.request.version == 'v2':
|
|
863
|
+
return UserV2Serializer
|
|
864
|
+
return UserV1Serializer
|
|
865
|
+
\`\`\`
|
|
866
|
+
|
|
867
|
+
### Signals \u2014 Use Responsibly
|
|
868
|
+
|
|
869
|
+
\`\`\`python
|
|
870
|
+
# signals.py \u2014 Only for cross-cutting concerns (audit logs, cache invalidation)
|
|
871
|
+
from django.db.models.signals import post_save
|
|
872
|
+
from django.dispatch import receiver
|
|
873
|
+
|
|
874
|
+
@receiver(post_save, sender=Order)
|
|
875
|
+
def notify_on_order_placed(sender, instance, created, **kwargs):
|
|
876
|
+
if created:
|
|
877
|
+
NotificationService.send_order_confirmation(order=instance)
|
|
878
|
+
\`\`\`
|
|
879
|
+
|
|
880
|
+
Register in \`apps.py\`:
|
|
881
|
+
\`\`\`python
|
|
882
|
+
class OrdersConfig(AppConfig):
|
|
883
|
+
def ready(self):
|
|
884
|
+
import apps.orders.signals # noqa: F401
|
|
885
|
+
\`\`\`
|
|
886
|
+
|
|
887
|
+
> **Prefer explicit service calls over signals for business logic.** Signals make flow hard to trace.
|
|
888
|
+
|
|
889
|
+
### Testing: Senior-Level Patterns
|
|
890
|
+
|
|
891
|
+
\`\`\`python
|
|
892
|
+
import factory
|
|
893
|
+
from rest_framework.test import APITestCase, APIClient
|
|
894
|
+
|
|
895
|
+
# factories.py \u2014 Use factory_boy for test data
|
|
896
|
+
class UserFactory(factory.django.DjangoModelFactory):
|
|
897
|
+
class Meta:
|
|
898
|
+
model = User
|
|
899
|
+
email = factory.Sequence(lambda n: f'user{n}@example.com')
|
|
900
|
+
password = factory.PostGenerationMethodCall('set_password', 'testpass123')
|
|
901
|
+
|
|
902
|
+
|
|
903
|
+
class OrderFactory(factory.django.DjangoModelFactory):
|
|
904
|
+
class Meta:
|
|
905
|
+
model = Order
|
|
906
|
+
user = factory.SubFactory(UserFactory)
|
|
907
|
+
status = 'pending'
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
# test_views.py
|
|
911
|
+
class OrderViewSetTest(APITestCase):
|
|
912
|
+
def setUp(self):
|
|
913
|
+
self.user = UserFactory()
|
|
914
|
+
self.client = APIClient()
|
|
915
|
+
self.client.force_authenticate(self.user)
|
|
916
|
+
|
|
917
|
+
def test_list_returns_only_own_orders(self):
|
|
918
|
+
OrderFactory.create_batch(3, user=self.user)
|
|
919
|
+
OrderFactory.create_batch(2) # Other user's orders
|
|
920
|
+
response = self.client.get('/api/orders/')
|
|
921
|
+
self.assertEqual(response.status_code, 200)
|
|
922
|
+
self.assertEqual(len(response.data['results']), 3)
|
|
923
|
+
|
|
924
|
+
def test_create_validates_stock(self):
|
|
925
|
+
product = ProductFactory(stock=0)
|
|
926
|
+
response = self.client.post('/api/orders/', {
|
|
927
|
+
'items': [{'product_id': product.id, 'quantity': 1}],
|
|
928
|
+
'shipping_address_id': AddressFactory(user=self.user).id,
|
|
929
|
+
}, format='json')
|
|
930
|
+
self.assertEqual(response.status_code, 400)
|
|
931
|
+
|
|
932
|
+
def test_cancel_forbidden_for_non_owner(self):
|
|
933
|
+
order = OrderFactory() # Different user
|
|
934
|
+
response = self.client.post(f'/api/orders/{order.id}/cancel/')
|
|
935
|
+
self.assertEqual(response.status_code, 403)
|
|
936
|
+
\`\`\`
|
|
937
|
+
|
|
938
|
+
**Test query count to prevent N+1 regressions:**
|
|
939
|
+
\`\`\`python
|
|
940
|
+
from django.test.utils import override_settings
|
|
941
|
+
|
|
942
|
+
def test_list_query_count(self):
|
|
943
|
+
OrderFactory.create_batch(10, user=self.user)
|
|
944
|
+
with self.assertNumQueries(3): # 1 count + 1 orders + 1 prefetch items
|
|
945
|
+
self.client.get('/api/orders/')
|
|
946
|
+
\`\`\`
|
|
947
|
+
|
|
948
|
+
### API Documentation with drf-spectacular
|
|
949
|
+
|
|
950
|
+
\`\`\`python
|
|
951
|
+
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample
|
|
952
|
+
|
|
953
|
+
@extend_schema_view(
|
|
954
|
+
list=extend_schema(
|
|
955
|
+
summary="List orders",
|
|
956
|
+
parameters=[
|
|
957
|
+
OpenApiParameter('status', str, description='Filter by status'),
|
|
958
|
+
OpenApiParameter('search', str, description='Search by product name'),
|
|
959
|
+
],
|
|
960
|
+
),
|
|
961
|
+
create=extend_schema(summary="Place a new order"),
|
|
962
|
+
cancel=extend_schema(summary="Cancel an order", responses={200: OrderDetailSerializer}),
|
|
963
|
+
)
|
|
964
|
+
class OrderViewSet(ModelViewSet):
|
|
965
|
+
...
|
|
966
|
+
\`\`\`
|
|
967
|
+
|
|
968
|
+
### Key Principles
|
|
969
|
+
|
|
970
|
+
1. **Fat services, thin views** \u2014 Views handle HTTP; services handle logic
|
|
971
|
+
2. **Optimize every queryset** \u2014 Use \`select_related\`, \`prefetch_related\`, \`only\`, \`annotate\`
|
|
972
|
+
3. **Separate read/write serializers** \u2014 List views are lightweight, detail views are rich, write views validate input
|
|
973
|
+
4. **Test behavior, not implementation** \u2014 Test API contracts, permissions, and edge cases
|
|
974
|
+
5. **Use \`transaction.atomic\`** \u2014 Wrap multi-step mutations to prevent partial writes
|
|
975
|
+
6. **Document with \`extend_schema\`** \u2014 Every endpoint needs OpenAPI docs for frontend type generation
|
|
976
|
+
7. **Scope querysets to user** \u2014 Never return data the user shouldn't see
|
|
977
|
+
8. **Use cursor pagination for large datasets** \u2014 Offset pagination degrades at scale
|
|
978
|
+
9. **Throttle sensitive endpoints** \u2014 Auth, uploads, and expensive operations
|
|
979
|
+
10. **Version your API** \u2014 Plan for breaking changes from the start
|
|
980
|
+
`;
|
|
981
|
+
}
|
|
982
|
+
};
|
|
983
|
+
|
|
984
|
+
// src/skills/api-documentation.ts
|
|
985
|
+
var apiDocumentationSkill = {
|
|
986
|
+
id: "api-documentation",
|
|
987
|
+
name: "API Documentation",
|
|
988
|
+
description: "drf-spectacular OpenAPI/Swagger documentation conventions for all API endpoints.",
|
|
989
|
+
render(_ctx) {
|
|
990
|
+
return `## API Documentation \u2014 drf-spectacular (OpenAPI / Swagger)
|
|
991
|
+
|
|
992
|
+
> **RULE: Every API endpoint MUST be documented with \`@extend_schema\` from \`drf-spectacular\`.**
|
|
993
|
+
> Undocumented endpoints break the frontend type generation pipeline (\`blacksmith sync\`).
|
|
994
|
+
> The OpenAPI schema powers auto-generated TypeScript types \u2014 accurate docs = accurate frontend types.
|
|
995
|
+
|
|
996
|
+
### Setup
|
|
997
|
+
|
|
998
|
+
drf-spectacular is already configured in \`backend/config/settings/base.py\`:
|
|
999
|
+
|
|
1000
|
+
\`\`\`python
|
|
1001
|
+
INSTALLED_APPS = [
|
|
1002
|
+
...
|
|
1003
|
+
'drf_spectacular',
|
|
1004
|
+
]
|
|
1005
|
+
|
|
1006
|
+
REST_FRAMEWORK = {
|
|
1007
|
+
'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema',
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
SPECTACULAR_SETTINGS = {
|
|
1011
|
+
'TITLE': 'API',
|
|
1012
|
+
'DESCRIPTION': 'API documentation',
|
|
1013
|
+
'VERSION': '1.0.0',
|
|
1014
|
+
'SERVE_INCLUDE_SCHEMA': False,
|
|
1015
|
+
}
|
|
1016
|
+
\`\`\`
|
|
1017
|
+
|
|
1018
|
+
Docs URLs in \`backend/config/urls.py\`:
|
|
1019
|
+
\`\`\`python
|
|
1020
|
+
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView, SpectacularRedocView
|
|
1021
|
+
|
|
1022
|
+
urlpatterns = [
|
|
1023
|
+
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
|
|
1024
|
+
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
|
|
1025
|
+
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
|
|
1026
|
+
]
|
|
1027
|
+
\`\`\`
|
|
1028
|
+
|
|
1029
|
+
### Decorating ViewSets \u2014 MANDATORY
|
|
1030
|
+
|
|
1031
|
+
**Use \`@extend_schema_view\` on every ViewSet:**
|
|
1032
|
+
\`\`\`python
|
|
1033
|
+
from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter, OpenApiExample, OpenApiResponse
|
|
1034
|
+
|
|
1035
|
+
@extend_schema_view(
|
|
1036
|
+
list=extend_schema(
|
|
1037
|
+
summary="List projects",
|
|
1038
|
+
description="Returns paginated list of projects for the authenticated user.",
|
|
1039
|
+
parameters=[
|
|
1040
|
+
OpenApiParameter('status', str, enum=['active', 'archived'], description='Filter by status'),
|
|
1041
|
+
OpenApiParameter('search', str, description='Search by name or description'),
|
|
1042
|
+
OpenApiParameter('ordering', str, description='Sort field (prefix with - for desc)', enum=['created_at', '-created_at', 'name', '-name']),
|
|
1043
|
+
],
|
|
1044
|
+
responses={200: ProjectListSerializer},
|
|
1045
|
+
),
|
|
1046
|
+
retrieve=extend_schema(
|
|
1047
|
+
summary="Get project details",
|
|
1048
|
+
responses={200: ProjectDetailSerializer},
|
|
1049
|
+
),
|
|
1050
|
+
create=extend_schema(
|
|
1051
|
+
summary="Create a project",
|
|
1052
|
+
request=ProjectCreateSerializer,
|
|
1053
|
+
responses={201: ProjectDetailSerializer},
|
|
1054
|
+
examples=[
|
|
1055
|
+
OpenApiExample(
|
|
1056
|
+
'Create project',
|
|
1057
|
+
value={'name': 'My Project', 'description': 'A new project'},
|
|
1058
|
+
request_only=True,
|
|
1059
|
+
),
|
|
1060
|
+
],
|
|
1061
|
+
),
|
|
1062
|
+
update=extend_schema(
|
|
1063
|
+
summary="Update a project",
|
|
1064
|
+
request=ProjectUpdateSerializer,
|
|
1065
|
+
responses={200: ProjectDetailSerializer},
|
|
1066
|
+
),
|
|
1067
|
+
partial_update=extend_schema(
|
|
1068
|
+
summary="Partially update a project",
|
|
1069
|
+
request=ProjectUpdateSerializer,
|
|
1070
|
+
responses={200: ProjectDetailSerializer},
|
|
1071
|
+
),
|
|
1072
|
+
destroy=extend_schema(
|
|
1073
|
+
summary="Delete a project",
|
|
1074
|
+
responses={204: None},
|
|
1075
|
+
),
|
|
1076
|
+
)
|
|
1077
|
+
class ProjectViewSet(ModelViewSet):
|
|
1078
|
+
...
|
|
1079
|
+
\`\`\`
|
|
1080
|
+
|
|
1081
|
+
**Custom actions MUST also be decorated:**
|
|
1082
|
+
\`\`\`python
|
|
1083
|
+
@extend_schema(
|
|
1084
|
+
summary="Archive a project",
|
|
1085
|
+
request=None,
|
|
1086
|
+
responses={200: ProjectDetailSerializer},
|
|
1087
|
+
)
|
|
1088
|
+
@action(detail=True, methods=['post'])
|
|
1089
|
+
def archive(self, request, pk=None):
|
|
1090
|
+
project = self.get_object()
|
|
1091
|
+
ProjectService.archive(project=project)
|
|
1092
|
+
return Response(ProjectDetailSerializer(project).data)
|
|
1093
|
+
|
|
1094
|
+
|
|
1095
|
+
@extend_schema(
|
|
1096
|
+
summary="Bulk delete projects",
|
|
1097
|
+
request=BulkDeleteSerializer,
|
|
1098
|
+
responses={204: None},
|
|
1099
|
+
)
|
|
1100
|
+
@action(detail=False, methods=['post'])
|
|
1101
|
+
def bulk_delete(self, request):
|
|
1102
|
+
...
|
|
1103
|
+
\`\`\`
|
|
1104
|
+
|
|
1105
|
+
### Decorating APIViews
|
|
1106
|
+
|
|
1107
|
+
\`\`\`python
|
|
1108
|
+
class DashboardStatsView(APIView):
|
|
1109
|
+
@extend_schema(
|
|
1110
|
+
summary="Get dashboard statistics",
|
|
1111
|
+
responses={200: DashboardStatsSerializer},
|
|
1112
|
+
)
|
|
1113
|
+
def get(self, request):
|
|
1114
|
+
stats = DashboardSelector.get_stats(user=request.user)
|
|
1115
|
+
return Response(DashboardStatsSerializer(stats).data)
|
|
1116
|
+
\`\`\`
|
|
1117
|
+
|
|
1118
|
+
### Serializer Documentation
|
|
1119
|
+
|
|
1120
|
+
**Use \`help_text\` on serializer fields \u2014 these become field descriptions in the schema:**
|
|
1121
|
+
\`\`\`python
|
|
1122
|
+
class ProjectCreateSerializer(serializers.Serializer):
|
|
1123
|
+
name = serializers.CharField(max_length=255, help_text="The project name. Must be unique per user.")
|
|
1124
|
+
description = serializers.CharField(required=False, help_text="Optional project description.")
|
|
1125
|
+
status = serializers.ChoiceField(
|
|
1126
|
+
choices=['active', 'archived'],
|
|
1127
|
+
default='active',
|
|
1128
|
+
help_text="Initial project status.",
|
|
1129
|
+
)
|
|
1130
|
+
tags = serializers.ListField(
|
|
1131
|
+
child=serializers.CharField(),
|
|
1132
|
+
required=False,
|
|
1133
|
+
help_text="List of tag names to attach.",
|
|
1134
|
+
)
|
|
1135
|
+
\`\`\`
|
|
1136
|
+
|
|
1137
|
+
**Use \`@extend_schema_serializer\` for serializer-level docs:**
|
|
1138
|
+
\`\`\`python
|
|
1139
|
+
from drf_spectacular.utils import extend_schema_serializer, OpenApiExample
|
|
1140
|
+
|
|
1141
|
+
@extend_schema_serializer(
|
|
1142
|
+
examples=[
|
|
1143
|
+
OpenApiExample(
|
|
1144
|
+
'Project response',
|
|
1145
|
+
value={
|
|
1146
|
+
'id': 1,
|
|
1147
|
+
'name': 'My Project',
|
|
1148
|
+
'status': 'active',
|
|
1149
|
+
'created_at': '2025-01-15T10:30:00Z',
|
|
1150
|
+
},
|
|
1151
|
+
response_only=True,
|
|
1152
|
+
),
|
|
1153
|
+
]
|
|
1154
|
+
)
|
|
1155
|
+
class ProjectDetailSerializer(serializers.ModelSerializer):
|
|
1156
|
+
class Meta:
|
|
1157
|
+
model = Project
|
|
1158
|
+
fields = ['id', 'name', 'description', 'status', 'created_at', 'updated_at']
|
|
1159
|
+
\`\`\`
|
|
1160
|
+
|
|
1161
|
+
### Response Types
|
|
1162
|
+
|
|
1163
|
+
**Explicitly declare all possible response codes:**
|
|
1164
|
+
\`\`\`python
|
|
1165
|
+
@extend_schema(
|
|
1166
|
+
summary="Place an order",
|
|
1167
|
+
request=OrderCreateSerializer,
|
|
1168
|
+
responses={
|
|
1169
|
+
201: OrderDetailSerializer,
|
|
1170
|
+
400: OpenApiResponse(description="Validation error (insufficient stock, invalid address, etc.)"),
|
|
1171
|
+
401: OpenApiResponse(description="Authentication required"),
|
|
1172
|
+
403: OpenApiResponse(description="Insufficient permissions"),
|
|
1173
|
+
},
|
|
1174
|
+
)
|
|
1175
|
+
def create(self, request, *args, **kwargs):
|
|
1176
|
+
...
|
|
1177
|
+
\`\`\`
|
|
1178
|
+
|
|
1179
|
+
### Enum and Choice Fields
|
|
1180
|
+
|
|
1181
|
+
**Use \`@extend_schema_field\` for custom field types:**
|
|
1182
|
+
\`\`\`python
|
|
1183
|
+
from drf_spectacular.utils import extend_schema_field
|
|
1184
|
+
from drf_spectacular.types import OpenApiTypes
|
|
1185
|
+
|
|
1186
|
+
@extend_schema_field(OpenApiTypes.STR)
|
|
1187
|
+
class ColorField(serializers.Field):
|
|
1188
|
+
...
|
|
1189
|
+
\`\`\`
|
|
1190
|
+
|
|
1191
|
+
### Polymorphic / Union Responses
|
|
1192
|
+
|
|
1193
|
+
\`\`\`python
|
|
1194
|
+
from drf_spectacular.utils import PolymorphicProxySerializer
|
|
1195
|
+
|
|
1196
|
+
@extend_schema(
|
|
1197
|
+
responses=PolymorphicProxySerializer(
|
|
1198
|
+
component_name='Notification',
|
|
1199
|
+
serializers={
|
|
1200
|
+
'email': EmailNotificationSerializer,
|
|
1201
|
+
'sms': SmsNotificationSerializer,
|
|
1202
|
+
'push': PushNotificationSerializer,
|
|
1203
|
+
},
|
|
1204
|
+
resource_type_field_name='type',
|
|
1205
|
+
)
|
|
1206
|
+
)
|
|
1207
|
+
def list(self, request):
|
|
1208
|
+
...
|
|
1209
|
+
\`\`\`
|
|
1210
|
+
|
|
1211
|
+
### Pagination in Schema
|
|
1212
|
+
|
|
1213
|
+
drf-spectacular auto-wraps list responses with pagination. If using custom pagination:
|
|
1214
|
+
\`\`\`python
|
|
1215
|
+
from drf_spectacular.utils import extend_schema
|
|
1216
|
+
|
|
1217
|
+
@extend_schema(
|
|
1218
|
+
summary="List items",
|
|
1219
|
+
responses=ItemSerializer(many=True), # Pagination wrapper is auto-applied
|
|
1220
|
+
)
|
|
1221
|
+
def list(self, request, *args, **kwargs):
|
|
1222
|
+
...
|
|
1223
|
+
\`\`\`
|
|
1224
|
+
|
|
1225
|
+
### Tags for Grouping
|
|
1226
|
+
|
|
1227
|
+
**Group endpoints by feature using tags:**
|
|
1228
|
+
\`\`\`python
|
|
1229
|
+
@extend_schema_view(
|
|
1230
|
+
list=extend_schema(tags=['Orders']),
|
|
1231
|
+
create=extend_schema(tags=['Orders']),
|
|
1232
|
+
retrieve=extend_schema(tags=['Orders']),
|
|
1233
|
+
)
|
|
1234
|
+
class OrderViewSet(ModelViewSet):
|
|
1235
|
+
...
|
|
1236
|
+
\`\`\`
|
|
1237
|
+
|
|
1238
|
+
Or set a default tag via \`SPECTACULAR_SETTINGS\`:
|
|
1239
|
+
\`\`\`python
|
|
1240
|
+
SPECTACULAR_SETTINGS = {
|
|
1241
|
+
'TAGS': [
|
|
1242
|
+
{'name': 'Auth', 'description': 'Authentication endpoints'},
|
|
1243
|
+
{'name': 'Orders', 'description': 'Order management'},
|
|
1244
|
+
{'name': 'Products', 'description': 'Product catalog'},
|
|
1245
|
+
],
|
|
1246
|
+
}
|
|
1247
|
+
\`\`\`
|
|
1248
|
+
|
|
1249
|
+
### Authentication in Schema
|
|
1250
|
+
|
|
1251
|
+
\`\`\`python
|
|
1252
|
+
SPECTACULAR_SETTINGS = {
|
|
1253
|
+
'SECURITY': [{'jwtAuth': []}],
|
|
1254
|
+
'APPEND_COMPONENTS': {
|
|
1255
|
+
'securitySchemes': {
|
|
1256
|
+
'jwtAuth': {
|
|
1257
|
+
'type': 'http',
|
|
1258
|
+
'scheme': 'bearer',
|
|
1259
|
+
'bearerFormat': 'JWT',
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
},
|
|
1263
|
+
}
|
|
1264
|
+
\`\`\`
|
|
1265
|
+
|
|
1266
|
+
### Excluding Endpoints
|
|
1267
|
+
|
|
1268
|
+
\`\`\`python
|
|
1269
|
+
@extend_schema(exclude=True)
|
|
1270
|
+
@action(detail=False, methods=['get'])
|
|
1271
|
+
def internal_health_check(self, request):
|
|
1272
|
+
...
|
|
1273
|
+
\`\`\`
|
|
1274
|
+
|
|
1275
|
+
### Generating and Validating the Schema
|
|
1276
|
+
|
|
1277
|
+
\`\`\`bash
|
|
1278
|
+
# Generate schema file
|
|
1279
|
+
./venv/bin/python manage.py spectacular --file schema.yml
|
|
1280
|
+
|
|
1281
|
+
# Validate schema for errors
|
|
1282
|
+
./venv/bin/python manage.py spectacular --validate
|
|
1283
|
+
\`\`\`
|
|
1284
|
+
|
|
1285
|
+
> **Always run \`--validate\` after adding new endpoints.** Fix any warnings before committing.
|
|
1286
|
+
|
|
1287
|
+
### Rules
|
|
1288
|
+
|
|
1289
|
+
1. **Every ViewSet** must have \`@extend_schema_view\` with summaries for all actions
|
|
1290
|
+
2. **Every custom \`@action\`** must have its own \`@extend_schema\` decorator
|
|
1291
|
+
3. **Every serializer field** that isn't self-explanatory must have \`help_text\`
|
|
1292
|
+
4. **Request and response serializers** must be explicitly declared \u2014 do not rely on auto-detection for non-trivial endpoints
|
|
1293
|
+
5. **All error responses** (400, 401, 403, 404) should be documented with \`OpenApiResponse\`
|
|
1294
|
+
6. **Run \`manage.py spectacular --validate\`** before committing to catch schema issues early
|
|
1295
|
+
7. **Use examples** (\`OpenApiExample\`) for complex request/response bodies
|
|
1296
|
+
8. **Group endpoints with tags** to keep Swagger UI organized
|
|
1297
|
+
`;
|
|
1298
|
+
}
|
|
1299
|
+
};
|
|
1300
|
+
|
|
1301
|
+
// src/skills/react.ts
|
|
1302
|
+
var reactSkill = {
|
|
1303
|
+
id: "react",
|
|
1304
|
+
name: "React Frontend Conventions",
|
|
1305
|
+
description: "Tech stack, project structure, state management, component patterns, styling, and testing for the React frontend.",
|
|
1306
|
+
render(_ctx) {
|
|
1307
|
+
return `## React Frontend Conventions
|
|
1308
|
+
|
|
1309
|
+
### Tech Stack
|
|
1310
|
+
- React 19 + TypeScript (strict mode)
|
|
1311
|
+
- Vite for bundling and dev server
|
|
1312
|
+
- TanStack React Query for server state management
|
|
1313
|
+
- React Router v7 for client-side routing
|
|
1314
|
+
- React Hook Form + Zod for forms and validation
|
|
1315
|
+
- Tailwind CSS for styling
|
|
1316
|
+
- \`@hey-api/openapi-ts\` for auto-generating API client from Django's OpenAPI schema
|
|
1317
|
+
- \`lucide-react\` for icons
|
|
1318
|
+
|
|
1319
|
+
### API Layer
|
|
1320
|
+
- Auto-generated client in \`frontend/src/api/generated/\` \u2014 **never edit these files manually**
|
|
1321
|
+
- Custom API configuration (base URL, interceptors, auth headers) in \`frontend/src/api/client.ts\`
|
|
1322
|
+
- Query client setup and default options in \`frontend/src/api/query-client.ts\`
|
|
1323
|
+
- After any backend API change, run \`blacksmith sync\` to regenerate the client
|
|
1324
|
+
|
|
1325
|
+
### Project Structure
|
|
1326
|
+
- See the \`page-structure\` skill for page folders, feature modules, routing, and route composition conventions
|
|
1327
|
+
- Shared, cross-feature code lives in \`frontend/src/shared/\`
|
|
1328
|
+
|
|
1329
|
+
### State Management
|
|
1330
|
+
- **Server state**: TanStack React Query \u2014 see the \`react-query\` skill for full conventions on \`useApiQuery\` and \`useApiMutation\`
|
|
1331
|
+
- **Form state**: React Hook Form \u2014 manages form values, validation, submission
|
|
1332
|
+
- **Local UI state**: React \`useState\` / \`useReducer\` for component-scoped state
|
|
1333
|
+
- Avoid global state libraries unless there is a clear cross-cutting concern not covered by React Query
|
|
1334
|
+
|
|
1335
|
+
### Component Patterns
|
|
1336
|
+
- Use functional components with named exports (not default exports for components)
|
|
1337
|
+
- Co-locate component, hook, and type in the same feature directory
|
|
1338
|
+
- Keep components focused \u2014 extract sub-components when a file exceeds ~150 lines
|
|
1339
|
+
- Use custom hooks to encapsulate data fetching and mutation logic
|
|
1340
|
+
- Prefer composition over prop drilling \u2014 use context for deeply shared state
|
|
1341
|
+
- **Pages must be thin orchestrators** \u2014 break into child components in \`components/\`, extract logic into \`hooks/\`. See the \`page-structure\` skill for the full pattern
|
|
1342
|
+
|
|
1343
|
+
### UI Components
|
|
1344
|
+
- **All UI must use \`@blacksmith-ui/react\` components** \u2014 see the \`blacksmith-ui-react\` skill for the full component list
|
|
1345
|
+
- Use \`Stack\`, \`Flex\`, \`Grid\`, \`Box\` for layout \u2014 never raw \`<div>\` with flex/grid classes
|
|
1346
|
+
- Use \`Typography\` and \`Text\` for headings and text \u2014 never raw \`<h1>\`\u2013\`<h6>\` or \`<p>\`
|
|
1347
|
+
- Use \`Divider\` instead of \`<Separator>\` or \`<hr>\`
|
|
1348
|
+
- Use \`StatCard\`, \`EmptyState\`, \`Skeleton\` instead of building custom equivalents
|
|
1349
|
+
|
|
1350
|
+
### Route Paths
|
|
1351
|
+
- All route paths live in the \`Path\` enum at \`src/router/paths.ts\` \u2014 **never hardcode path strings**
|
|
1352
|
+
- Use \`Path\` in route definitions, \`navigate()\`, and \`<Link to={}>\`
|
|
1353
|
+
- Use \`buildPath()\` for dynamic segments \u2014 see the \`page-structure\` skill for details
|
|
1354
|
+
|
|
1355
|
+
### Styling
|
|
1356
|
+
- Use Tailwind CSS utility classes for all styling
|
|
1357
|
+
- Use the \`cn()\` helper (from \`clsx\` + \`tailwind-merge\`) for conditional and merged classes
|
|
1358
|
+
- Theming via HSL CSS variables defined in \`frontend/src/styles/globals.css\`
|
|
1359
|
+
- Dark mode is supported via the \`class\` strategy on \`<html>\`
|
|
1360
|
+
- Use responsive prefixes (\`sm:\`, \`md:\`, \`lg:\`) for responsive layouts
|
|
1361
|
+
- Avoid inline \`style\` attributes \u2014 use Tailwind classes instead
|
|
1362
|
+
|
|
1363
|
+
### Path Aliases
|
|
1364
|
+
- \`@/\` maps to \`frontend/src/\`
|
|
1365
|
+
- Always use the alias for imports: \`import { useAuth } from '@/features/auth'\`
|
|
1366
|
+
- Never use relative paths that go up more than one level (\`../../\`)
|
|
1367
|
+
|
|
1368
|
+
### Error Handling
|
|
1369
|
+
- Use React Error Boundary (\`frontend/src/router/error-boundary.tsx\`) for render errors
|
|
1370
|
+
- API errors are handled by \`useApiQuery\` / \`useApiMutation\` \u2014 see the \`react-query\` skill for error display patterns
|
|
1371
|
+
- Display user-facing errors using the project's feedback components (Alert, Toast)
|
|
1372
|
+
|
|
1373
|
+
### Testing
|
|
1374
|
+
- Run all tests: \`cd frontend && npm test\`
|
|
1375
|
+
- Run a specific test: \`cd frontend && npm test -- --grep "test name"\`
|
|
1376
|
+
- Test files live alongside the code they test (\`component.test.tsx\`)
|
|
1377
|
+
`;
|
|
1378
|
+
}
|
|
1379
|
+
};
|
|
1380
|
+
|
|
1381
|
+
// src/skills/react-query.ts
|
|
1382
|
+
var reactQuerySkill = {
|
|
1383
|
+
id: "react-query",
|
|
1384
|
+
name: "TanStack React Query",
|
|
1385
|
+
description: "API data fetching conventions using useApiQuery and useApiMutation wrappers.",
|
|
1386
|
+
render(_ctx) {
|
|
1387
|
+
return `## TanStack React Query \u2014 API Data Fetching
|
|
1388
|
+
|
|
1389
|
+
> **RULE: Always use \`useApiQuery\` and \`useApiMutation\` instead of raw \`useQuery\` / \`useMutation\`.**
|
|
1390
|
+
> These wrappers live in \`@/shared/hooks/\` and handle DRF error parsing, smart retry, and cache invalidation automatically.
|
|
1391
|
+
|
|
1392
|
+
### Queries \u2014 \`useApiQuery\`
|
|
1393
|
+
|
|
1394
|
+
Import: \`import { useApiQuery } from '@/shared/hooks/use-api-query'\`
|
|
1395
|
+
|
|
1396
|
+
Wraps \`useQuery\` with:
|
|
1397
|
+
- **Smart retry** \u2014 skips 400, 401, 403, 404, 405, 409, 422 (retries others up to 2 times)
|
|
1398
|
+
- **Parsed errors** \u2014 \`errorMessage\` (string) and \`apiError\` (structured) derived from \`result.error\`
|
|
1399
|
+
|
|
1400
|
+
\`\`\`tsx
|
|
1401
|
+
// Basic list query using generated options
|
|
1402
|
+
import { postsListOptions } from '@/api/generated/@tanstack/react-query.gen'
|
|
1403
|
+
|
|
1404
|
+
const { data, isLoading, errorMessage } = useApiQuery({
|
|
1405
|
+
...postsListOptions({ query: { page: 1 } }),
|
|
1406
|
+
})
|
|
1407
|
+
|
|
1408
|
+
// With select to transform data
|
|
1409
|
+
const { data, errorMessage } = useApiQuery({
|
|
1410
|
+
...postsListOptions({ query: { page: 1 } }),
|
|
1411
|
+
select: (data: any) => ({
|
|
1412
|
+
posts: data.results ?? [],
|
|
1413
|
+
total: data.count ?? 0,
|
|
1414
|
+
}),
|
|
1415
|
+
})
|
|
1416
|
+
|
|
1417
|
+
// Detail query
|
|
1418
|
+
import { postsRetrieveOptions } from '@/api/generated/@tanstack/react-query.gen'
|
|
1419
|
+
|
|
1420
|
+
const { data: post, isLoading, errorMessage } = useApiQuery({
|
|
1421
|
+
...postsRetrieveOptions({ path: { id: Number(id) } }),
|
|
1422
|
+
})
|
|
1423
|
+
|
|
1424
|
+
// Conditional query (skip until id is available)
|
|
1425
|
+
const { data } = useApiQuery({
|
|
1426
|
+
...postsRetrieveOptions({ path: { id: Number(id) } }),
|
|
1427
|
+
enabled: !!id,
|
|
1428
|
+
})
|
|
1429
|
+
\`\`\`
|
|
1430
|
+
|
|
1431
|
+
**Return type extends \`UseQueryResult\` with:**
|
|
1432
|
+
| Field | Type | Description |
|
|
1433
|
+
|-------|------|-------------|
|
|
1434
|
+
| \`errorMessage\` | \`string \\| null\` | Human-readable error message |
|
|
1435
|
+
| \`apiError\` | \`ApiError \\| null\` | Structured error with \`status\`, \`message\`, \`fieldErrors\` |
|
|
1436
|
+
|
|
1437
|
+
### Mutations \u2014 \`useApiMutation\`
|
|
1438
|
+
|
|
1439
|
+
Import: \`import { useApiMutation } from '@/shared/hooks/use-api-mutation'\`
|
|
1440
|
+
|
|
1441
|
+
Wraps \`useMutation\` with:
|
|
1442
|
+
- **DRF error parsing** \u2014 \`fieldErrors\`, \`errorMessage\`, \`apiError\` derived from \`mutation.error\` (no local state)
|
|
1443
|
+
- **Cache invalidation** \u2014 pass \`invalidateKeys\` to auto-invalidate queries on success
|
|
1444
|
+
|
|
1445
|
+
\`\`\`tsx
|
|
1446
|
+
// Create mutation with cache invalidation
|
|
1447
|
+
import {
|
|
1448
|
+
postsCreateMutation,
|
|
1449
|
+
postsListQueryKey,
|
|
1450
|
+
} from '@/api/generated/@tanstack/react-query.gen'
|
|
1451
|
+
|
|
1452
|
+
const createPost = useApiMutation({
|
|
1453
|
+
...postsCreateMutation(),
|
|
1454
|
+
invalidateKeys: [postsListQueryKey()],
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
// Trigger the mutation
|
|
1458
|
+
createPost.mutate({ body: { title: 'Hello', content: '...' } })
|
|
1459
|
+
|
|
1460
|
+
// Update mutation \u2014 invalidate both list and detail caches
|
|
1461
|
+
import {
|
|
1462
|
+
postsUpdateMutation,
|
|
1463
|
+
postsRetrieveQueryKey,
|
|
1464
|
+
} from '@/api/generated/@tanstack/react-query.gen'
|
|
1465
|
+
|
|
1466
|
+
const updatePost = useApiMutation({
|
|
1467
|
+
...postsUpdateMutation(),
|
|
1468
|
+
invalidateKeys: [
|
|
1469
|
+
postsListQueryKey(),
|
|
1470
|
+
postsRetrieveQueryKey({ path: { id } }),
|
|
1471
|
+
],
|
|
1472
|
+
})
|
|
1473
|
+
|
|
1474
|
+
// Delete with async/await
|
|
1475
|
+
const deletePost = useApiMutation({
|
|
1476
|
+
...postsDestroyMutation(),
|
|
1477
|
+
invalidateKeys: [postsListQueryKey()],
|
|
1478
|
+
})
|
|
1479
|
+
|
|
1480
|
+
const handleDelete = async () => {
|
|
1481
|
+
await deletePost.mutateAsync({ path: { id: Number(id) } })
|
|
1482
|
+
navigate('/posts')
|
|
1483
|
+
}
|
|
1484
|
+
\`\`\`
|
|
1485
|
+
|
|
1486
|
+
**Return type extends \`UseMutationResult\` with:**
|
|
1487
|
+
| Field | Type | Description |
|
|
1488
|
+
|-------|------|-------------|
|
|
1489
|
+
| \`fieldErrors\` | \`Record<string, string[]>\` | Per-field validation errors from DRF |
|
|
1490
|
+
| \`errorMessage\` | \`string \\| null\` | General error message |
|
|
1491
|
+
| \`apiError\` | \`ApiError \\| null\` | Full structured error |
|
|
1492
|
+
|
|
1493
|
+
### Error Display Patterns
|
|
1494
|
+
|
|
1495
|
+
\`\`\`tsx
|
|
1496
|
+
// General error banner
|
|
1497
|
+
{mutation.errorMessage && (
|
|
1498
|
+
<Alert variant="destructive">
|
|
1499
|
+
<AlertDescription>{mutation.errorMessage}</AlertDescription>
|
|
1500
|
+
</Alert>
|
|
1501
|
+
)}
|
|
1502
|
+
|
|
1503
|
+
// Inline field errors in forms
|
|
1504
|
+
const getFieldError = (field: string): string | undefined => {
|
|
1505
|
+
// Client-side (react-hook-form) errors take priority
|
|
1506
|
+
const clientError = form.formState.errors[field]?.message
|
|
1507
|
+
if (clientError) return clientError
|
|
1508
|
+
// Fall back to server-side field errors
|
|
1509
|
+
return mutation.fieldErrors[field]?.[0]
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
// Query error on a page
|
|
1513
|
+
const { data, isLoading, errorMessage } = useApiQuery({ ... })
|
|
1514
|
+
|
|
1515
|
+
if (errorMessage) {
|
|
1516
|
+
return (
|
|
1517
|
+
<Alert variant="destructive">
|
|
1518
|
+
<AlertDescription>{errorMessage}</AlertDescription>
|
|
1519
|
+
</Alert>
|
|
1520
|
+
)
|
|
1521
|
+
}
|
|
1522
|
+
\`\`\`
|
|
1523
|
+
|
|
1524
|
+
### Creating Resource Hook Files
|
|
1525
|
+
|
|
1526
|
+
When building hooks for a resource, create two files:
|
|
1527
|
+
|
|
1528
|
+
**\`use-<resources>.ts\`** \u2014 List query hook:
|
|
1529
|
+
\`\`\`tsx
|
|
1530
|
+
import { useApiQuery } from '@/shared/hooks/use-api-query'
|
|
1531
|
+
import { postsListOptions } from '@/api/generated/@tanstack/react-query.gen'
|
|
1532
|
+
|
|
1533
|
+
interface UsePostsParams {
|
|
1534
|
+
page?: number
|
|
1535
|
+
search?: string
|
|
1536
|
+
ordering?: string
|
|
1537
|
+
}
|
|
1538
|
+
|
|
1539
|
+
export function usePosts(params: UsePostsParams = {}) {
|
|
1540
|
+
return useApiQuery({
|
|
1541
|
+
...postsListOptions({
|
|
1542
|
+
query: {
|
|
1543
|
+
page: params.page ?? 1,
|
|
1544
|
+
search: params.search,
|
|
1545
|
+
ordering: params.ordering ?? '-created_at',
|
|
1546
|
+
},
|
|
1547
|
+
}),
|
|
1548
|
+
select: (data: any) => ({
|
|
1549
|
+
posts: data.results ?? [],
|
|
1550
|
+
total: data.count ?? 0,
|
|
1551
|
+
hasNext: !!data.next,
|
|
1552
|
+
hasPrev: !!data.previous,
|
|
1553
|
+
}),
|
|
1554
|
+
})
|
|
1555
|
+
}
|
|
1556
|
+
\`\`\`
|
|
1557
|
+
|
|
1558
|
+
**\`use-<resource>-mutations.ts\`** \u2014 Create/update/delete hooks:
|
|
1559
|
+
\`\`\`tsx
|
|
1560
|
+
import { useApiMutation } from '@/shared/hooks/use-api-mutation'
|
|
1561
|
+
import {
|
|
1562
|
+
postsCreateMutation,
|
|
1563
|
+
postsUpdateMutation,
|
|
1564
|
+
postsDestroyMutation,
|
|
1565
|
+
postsListQueryKey,
|
|
1566
|
+
postsRetrieveQueryKey,
|
|
1567
|
+
} from '@/api/generated/@tanstack/react-query.gen'
|
|
1568
|
+
|
|
1569
|
+
export function useCreatePost() {
|
|
1570
|
+
return useApiMutation({
|
|
1571
|
+
...postsCreateMutation(),
|
|
1572
|
+
invalidateKeys: [postsListQueryKey()],
|
|
1573
|
+
})
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
export function useUpdatePost(id: number) {
|
|
1577
|
+
return useApiMutation({
|
|
1578
|
+
...postsUpdateMutation(),
|
|
1579
|
+
invalidateKeys: [
|
|
1580
|
+
postsListQueryKey(),
|
|
1581
|
+
postsRetrieveQueryKey({ path: { id } }),
|
|
1582
|
+
],
|
|
1583
|
+
})
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
export function useDeletePost() {
|
|
1587
|
+
return useApiMutation({
|
|
1588
|
+
...postsDestroyMutation(),
|
|
1589
|
+
invalidateKeys: [postsListQueryKey()],
|
|
1590
|
+
})
|
|
1591
|
+
}
|
|
1592
|
+
\`\`\`
|
|
1593
|
+
|
|
1594
|
+
### Key Rules
|
|
1595
|
+
|
|
1596
|
+
1. **Never use raw \`useQuery\` or \`useMutation\`** \u2014 always go through \`useApiQuery\` / \`useApiMutation\`
|
|
1597
|
+
2. **Never manage API error state with \`useState\`** \u2014 error state is derived from TanStack Query's \`error\` field
|
|
1598
|
+
3. **Always pass \`invalidateKeys\`** on mutations that modify data \u2014 ensures the UI stays in sync
|
|
1599
|
+
4. **Use generated options/mutations** from \`@/api/generated/@tanstack/react-query.gen\` \u2014 never write \`queryFn\` manually
|
|
1600
|
+
5. **Use \`select\`** to reshape API responses at the hook level, not in components
|
|
1601
|
+
6. **Use \`enabled\`** for conditional queries (e.g. waiting for an ID from URL params)
|
|
1602
|
+
7. **Spread generated options first** (\`...postsListOptions()\`), then add overrides \u2014 this preserves the generated \`queryKey\` and \`queryFn\`
|
|
1603
|
+
`;
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
|
|
1607
|
+
// src/skills/page-structure.ts
|
|
1608
|
+
var pageStructureSkill = {
|
|
1609
|
+
id: "page-structure",
|
|
1610
|
+
name: "Page & Route Structure",
|
|
1611
|
+
description: "Page folders, feature modules, routing conventions, and route composition patterns.",
|
|
1612
|
+
render(_ctx) {
|
|
1613
|
+
return `## Page & Route Structure
|
|
1614
|
+
|
|
1615
|
+
> **RULE: Every page and feature owns its own \`routes.tsx\`. The central router only composes them \u2014 never import page components directly into \`routes.tsx\`.**
|
|
1616
|
+
|
|
1617
|
+
### Standalone Pages (\`src/pages/\`)
|
|
1618
|
+
|
|
1619
|
+
Each page gets its own folder:
|
|
1620
|
+
|
|
1621
|
+
\`\`\`
|
|
1622
|
+
pages/<page>/
|
|
1623
|
+
\u251C\u2500\u2500 <page>.tsx # Page component (default export)
|
|
1624
|
+
\u251C\u2500\u2500 routes.tsx # Exports RouteObject[] for this page
|
|
1625
|
+
\u251C\u2500\u2500 index.ts # Re-exports public members (routes)
|
|
1626
|
+
\u251C\u2500\u2500 components/ # Components private to this page (optional)
|
|
1627
|
+
\u2514\u2500\u2500 hooks/ # Hooks private to this page (optional)
|
|
1628
|
+
\`\`\`
|
|
1629
|
+
|
|
1630
|
+
**\`routes.tsx\`** \u2014 defines the route config using the \`Path\` enum:
|
|
1631
|
+
\`\`\`tsx
|
|
1632
|
+
import type { RouteObject } from 'react-router-dom'
|
|
1633
|
+
import { Path } from '@/router/paths'
|
|
1634
|
+
import SettingsPage from './settings'
|
|
1635
|
+
|
|
1636
|
+
export const settingsRoutes: RouteObject[] = [
|
|
1637
|
+
{ path: Path.Settings, element: <SettingsPage /> },
|
|
1638
|
+
]
|
|
1639
|
+
\`\`\`
|
|
1640
|
+
|
|
1641
|
+
**\`index.ts\`** \u2014 re-exports only public members:
|
|
1642
|
+
\`\`\`ts
|
|
1643
|
+
export { settingsRoutes } from './routes'
|
|
1644
|
+
\`\`\`
|
|
1645
|
+
|
|
1646
|
+
### Feature Pages (\`src/features/\`)
|
|
1647
|
+
|
|
1648
|
+
Features that have pages include a \`routes.tsx\` at the feature root:
|
|
1649
|
+
|
|
1650
|
+
\`\`\`
|
|
1651
|
+
features/<feature>/
|
|
1652
|
+
\u251C\u2500\u2500 components/ # UI components scoped to this feature
|
|
1653
|
+
\u251C\u2500\u2500 hooks/ # Custom hooks (queries, mutations, logic)
|
|
1654
|
+
\u251C\u2500\u2500 pages/ # Page components (default exports)
|
|
1655
|
+
\u251C\u2500\u2500 routes.tsx # RouteObject[] for all pages in this feature
|
|
1656
|
+
\u2514\u2500\u2500 index.ts # Re-exports routes + public API
|
|
1657
|
+
\`\`\`
|
|
1658
|
+
|
|
1659
|
+
**\`routes.tsx\`** \u2014 groups related routes using the \`Path\` enum:
|
|
1660
|
+
\`\`\`tsx
|
|
1661
|
+
import { Outlet, type RouteObject } from 'react-router-dom'
|
|
1662
|
+
import { Path } from '@/router/paths'
|
|
1663
|
+
import PostsPage from './pages/posts-page'
|
|
1664
|
+
import PostDetailPage from './pages/post-detail-page'
|
|
1665
|
+
|
|
1666
|
+
export const postsRoutes: RouteObject[] = [
|
|
1667
|
+
{
|
|
1668
|
+
path: Path.Posts,
|
|
1669
|
+
element: <Outlet />,
|
|
1670
|
+
children: [
|
|
1671
|
+
{ index: true, element: <PostsPage /> },
|
|
1672
|
+
{ path: ':id', element: <PostDetailPage /> },
|
|
1673
|
+
],
|
|
1674
|
+
},
|
|
1675
|
+
]
|
|
1676
|
+
\`\`\`
|
|
1677
|
+
|
|
1678
|
+
**\`index.ts\`** \u2014 exports routes first:
|
|
1679
|
+
\`\`\`ts
|
|
1680
|
+
export { postsRoutes } from './routes'
|
|
1681
|
+
export { usePosts } from './hooks/use-posts'
|
|
1682
|
+
export { useCreatePost, useUpdatePost, useDeletePost } from './hooks/use-post-mutations'
|
|
1683
|
+
\`\`\`
|
|
1684
|
+
|
|
1685
|
+
### Route Paths (\`src/router/paths.ts\`)
|
|
1686
|
+
|
|
1687
|
+
All route paths are defined in a central \`Path\` enum \u2014 **never use hardcoded path strings**:
|
|
1688
|
+
|
|
1689
|
+
\`\`\`ts
|
|
1690
|
+
export enum Path {
|
|
1691
|
+
Home = '/',
|
|
1692
|
+
Login = '/login',
|
|
1693
|
+
Register = '/register',
|
|
1694
|
+
ForgotPassword = '/forgot-password',
|
|
1695
|
+
ResetPassword = '/reset-password/:token',
|
|
1696
|
+
Dashboard = '/dashboard',
|
|
1697
|
+
// blacksmith:path
|
|
1698
|
+
}
|
|
1699
|
+
\`\`\`
|
|
1700
|
+
|
|
1701
|
+
Use \`Path\` everywhere \u2014 in route definitions, \`navigate()\`, \`<Link to={}\`\`, etc.:
|
|
1702
|
+
\`\`\`tsx
|
|
1703
|
+
import { Path } from '@/router/paths'
|
|
1704
|
+
|
|
1705
|
+
// In routes
|
|
1706
|
+
{ path: Path.Dashboard, element: <DashboardPage /> }
|
|
1707
|
+
|
|
1708
|
+
// In navigation
|
|
1709
|
+
navigate(Path.Login)
|
|
1710
|
+
<Link to={Path.Home}>Home</Link>
|
|
1711
|
+
\`\`\`
|
|
1712
|
+
|
|
1713
|
+
For dynamic paths, use the \`buildPath\` helper:
|
|
1714
|
+
\`\`\`ts
|
|
1715
|
+
import { Path, buildPath } from '@/router/paths'
|
|
1716
|
+
|
|
1717
|
+
buildPath(Path.ResetPassword, { token: 'abc123' })
|
|
1718
|
+
// => '/reset-password/abc123'
|
|
1719
|
+
\`\`\`
|
|
1720
|
+
|
|
1721
|
+
The \`Path\` enum is re-exported from \`@/router\` along with \`buildPath\`.
|
|
1722
|
+
|
|
1723
|
+
### Central Router (\`src/router/routes.tsx\`)
|
|
1724
|
+
|
|
1725
|
+
The central router imports and spreads route arrays \u2014 it never imports page components directly:
|
|
1726
|
+
|
|
1727
|
+
\`\`\`tsx
|
|
1728
|
+
import { homeRoutes } from '@/pages/home'
|
|
1729
|
+
import { dashboardRoutes } from '@/pages/dashboard'
|
|
1730
|
+
import { authRoutes } from '@/features/auth'
|
|
1731
|
+
import { postsRoutes } from '@/features/posts'
|
|
1732
|
+
// blacksmith:import
|
|
1733
|
+
|
|
1734
|
+
const publicRoutes: RouteObject[] = [
|
|
1735
|
+
...homeRoutes,
|
|
1736
|
+
]
|
|
1737
|
+
|
|
1738
|
+
const privateRoutes: RouteObject[] = [
|
|
1739
|
+
...dashboardRoutes,
|
|
1740
|
+
...postsRoutes,
|
|
1741
|
+
// blacksmith:routes
|
|
1742
|
+
]
|
|
1743
|
+
\`\`\`
|
|
1744
|
+
|
|
1745
|
+
### Auto-Registration
|
|
1746
|
+
|
|
1747
|
+
\`blacksmith make:resource\` automatically registers routes using marker comments:
|
|
1748
|
+
- \`// blacksmith:path\` \u2014 new \`Path\` enum entry is inserted above this marker in \`paths.ts\`
|
|
1749
|
+
- \`// blacksmith:import\` \u2014 new import line is inserted above this marker in \`routes.tsx\`
|
|
1750
|
+
- \`// blacksmith:routes\` \u2014 new spread line is inserted above this marker in \`routes.tsx\`
|
|
1751
|
+
|
|
1752
|
+
Never remove these markers. They must stay in the \`Path\` enum, \`privateRoutes\` array, and import block.
|
|
1753
|
+
|
|
1754
|
+
### When to Use Pages vs Features
|
|
1755
|
+
|
|
1756
|
+
| Use \`pages/<page>/\` | Use \`features/<feature>/\` |
|
|
1757
|
+
|---|---|
|
|
1758
|
+
| Standalone pages (home, dashboard, settings) | CRUD resources with multiple pages |
|
|
1759
|
+
| No shared hooks or components | Has hooks, components, and pages that belong together |
|
|
1760
|
+
| Single route | Multiple related routes (list + detail + edit) |
|
|
1761
|
+
|
|
1762
|
+
### Component Decomposition
|
|
1763
|
+
|
|
1764
|
+
> **RULE: Pages are orchestrators, not monoliths. Break every page into small, focused child components stored in \`components/\`.**
|
|
1765
|
+
|
|
1766
|
+
A page component should read data (via hooks), pass it down as props, and compose child components. It should contain minimal JSX itself.
|
|
1767
|
+
|
|
1768
|
+
\`\`\`
|
|
1769
|
+
pages/dashboard/
|
|
1770
|
+
\u251C\u2500\u2500 dashboard.tsx # Page: composes children, calls hooks
|
|
1771
|
+
\u251C\u2500\u2500 components/
|
|
1772
|
+
\u2502 \u251C\u2500\u2500 stats-cards.tsx # Renders the stats grid
|
|
1773
|
+
\u2502 \u251C\u2500\u2500 recent-activity.tsx # Renders activity feed
|
|
1774
|
+
\u2502 \u2514\u2500\u2500 quick-actions.tsx # Renders action buttons
|
|
1775
|
+
\u251C\u2500\u2500 hooks/
|
|
1776
|
+
\u2502 \u2514\u2500\u2500 use-dashboard-data.ts # All data fetching for this page
|
|
1777
|
+
\u251C\u2500\u2500 routes.tsx
|
|
1778
|
+
\u2514\u2500\u2500 index.ts
|
|
1779
|
+
\`\`\`
|
|
1780
|
+
|
|
1781
|
+
\`\`\`tsx
|
|
1782
|
+
// dashboard.tsx \u2014 thin orchestrator using @blacksmith-ui/react layout
|
|
1783
|
+
import { Stack, Grid, Divider } from '@blacksmith-ui/react'
|
|
1784
|
+
import { StatsCards } from './components/stats-cards'
|
|
1785
|
+
import { RecentActivity } from './components/recent-activity'
|
|
1786
|
+
import { QuickActions } from './components/quick-actions'
|
|
1787
|
+
import { useDashboardData } from './hooks/use-dashboard-data'
|
|
1788
|
+
|
|
1789
|
+
export default function DashboardPage() {
|
|
1790
|
+
const { stats, activity, isLoading } = useDashboardData()
|
|
1791
|
+
|
|
1792
|
+
return (
|
|
1793
|
+
<Stack gap={6}>
|
|
1794
|
+
<StatsCards stats={stats} isLoading={isLoading} />
|
|
1795
|
+
<Divider />
|
|
1796
|
+
<Grid columns={{ base: 1, lg: 3 }} gap={6}>
|
|
1797
|
+
<RecentActivity items={activity} isLoading={isLoading} className="lg:col-span-2" />
|
|
1798
|
+
<QuickActions />
|
|
1799
|
+
</Grid>
|
|
1800
|
+
</Stack>
|
|
1801
|
+
)
|
|
1802
|
+
}
|
|
1803
|
+
\`\`\`
|
|
1804
|
+
|
|
1805
|
+
**When to extract a child component:**
|
|
1806
|
+
- A section of JSX exceeds ~30 lines
|
|
1807
|
+
- A block has its own loading/error state
|
|
1808
|
+
- A block is logically independent (e.g. a table, a form, a sidebar)
|
|
1809
|
+
- A block could be reused on another page (move to \`shared/\` or the feature's \`components/\`)
|
|
1810
|
+
|
|
1811
|
+
**When NOT to extract:**
|
|
1812
|
+
- A few lines of simple, static markup (headings, wrappers)
|
|
1813
|
+
- Extracting would just move props through another layer with no clarity gain
|
|
1814
|
+
|
|
1815
|
+
### Separating Logic into Hooks
|
|
1816
|
+
|
|
1817
|
+
> **RULE: Components render. Hooks think. Never mix data fetching, transformations, or complex state logic into component bodies.**
|
|
1818
|
+
|
|
1819
|
+
Extract logic into hooks in the \`hooks/\` folder co-located with the page or feature:
|
|
1820
|
+
|
|
1821
|
+
\`\`\`tsx
|
|
1822
|
+
// BAD \u2014 logic mixed into the component
|
|
1823
|
+
export default function OrdersPage() {
|
|
1824
|
+
const [page, setPage] = useState(1)
|
|
1825
|
+
const [search, setSearch] = useState('')
|
|
1826
|
+
const debouncedSearch = useDebounce(search, 300)
|
|
1827
|
+
const { data, isLoading } = useApiQuery({
|
|
1828
|
+
...ordersListOptions({ query: { page, search: debouncedSearch } }),
|
|
1829
|
+
select: (d: any) => ({ orders: d.results ?? [], total: d.count ?? 0 }),
|
|
1830
|
+
})
|
|
1831
|
+
|
|
1832
|
+
const deleteOrder = useApiMutation({
|
|
1833
|
+
...ordersDestroyMutation(),
|
|
1834
|
+
invalidateKeys: [ordersListQueryKey()],
|
|
1835
|
+
})
|
|
1836
|
+
|
|
1837
|
+
return ( /* 200 lines of JSX using all of the above */ )
|
|
1838
|
+
}
|
|
1839
|
+
\`\`\`
|
|
1840
|
+
|
|
1841
|
+
\`\`\`tsx
|
|
1842
|
+
// GOOD \u2014 logic in a hook, component just renders
|
|
1843
|
+
// hooks/use-orders-page.ts
|
|
1844
|
+
export function useOrdersPage() {
|
|
1845
|
+
const [page, setPage] = useState(1)
|
|
1846
|
+
const [search, setSearch] = useState('')
|
|
1847
|
+
const debouncedSearch = useDebounce(search, 300)
|
|
1848
|
+
|
|
1849
|
+
const { data, isLoading, errorMessage } = useOrders({
|
|
1850
|
+
page,
|
|
1851
|
+
search: debouncedSearch,
|
|
1852
|
+
})
|
|
1853
|
+
|
|
1854
|
+
const deleteOrder = useDeleteOrder()
|
|
1855
|
+
|
|
1856
|
+
return { orders: data?.orders ?? [], total: data?.total ?? 0, isLoading, errorMessage, page, setPage, search, setSearch, deleteOrder }
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
// orders-page.tsx
|
|
1860
|
+
import { Stack } from '@blacksmith-ui/react'
|
|
1861
|
+
import { useOrdersPage } from './hooks/use-orders-page'
|
|
1862
|
+
import { OrdersTable } from './components/orders-table'
|
|
1863
|
+
import { OrdersToolbar } from './components/orders-toolbar'
|
|
1864
|
+
|
|
1865
|
+
export default function OrdersPage() {
|
|
1866
|
+
const { orders, total, isLoading, page, setPage, search, setSearch, deleteOrder } = useOrdersPage()
|
|
1867
|
+
|
|
1868
|
+
return (
|
|
1869
|
+
<Stack gap={4}>
|
|
1870
|
+
<OrdersToolbar search={search} onSearchChange={setSearch} />
|
|
1871
|
+
<OrdersTable orders={orders} isLoading={isLoading} onDelete={(id) => deleteOrder.mutate({ path: { id } })} />
|
|
1872
|
+
</Stack>
|
|
1873
|
+
)
|
|
1874
|
+
}
|
|
1875
|
+
\`\`\`
|
|
1876
|
+
|
|
1877
|
+
**What goes into a hook:**
|
|
1878
|
+
- API queries and mutations
|
|
1879
|
+
- Derived/computed state
|
|
1880
|
+
- Debouncing, pagination, filtering logic
|
|
1881
|
+
- Form setup (\`useForm\`, schema, submit handler)
|
|
1882
|
+
- Any \`useEffect\` or \`useState\` beyond a simple UI toggle
|
|
1883
|
+
|
|
1884
|
+
**What stays in the component:**
|
|
1885
|
+
- Simple UI toggles (\`useState\` for a modal open/close is fine inline)
|
|
1886
|
+
- JSX composition and prop passing
|
|
1887
|
+
- Event handler wiring (calling \`hook.mutate()\`, \`navigate()\`, etc.)
|
|
1888
|
+
|
|
1889
|
+
### Key Rules
|
|
1890
|
+
|
|
1891
|
+
1. **Every page/feature owns its routes** \u2014 the route config lives next to the page, not in the central router
|
|
1892
|
+
2. **Use the \`Path\` enum for all route paths** \u2014 never hardcode path strings; import \`Path\` from \`@/router/paths\`
|
|
1893
|
+
3. **\`index.ts\` is the public API** \u2014 only export what other modules need (routes, hooks, components)
|
|
1894
|
+
4. **Page components use default exports** \u2014 this is the one exception to the named-export convention
|
|
1895
|
+
5. **Routes use named exports** \u2014 \`export const settingsRoutes\` not \`export default\`
|
|
1896
|
+
6. **Private components/hooks stay in the page folder** \u2014 if only one page uses it, co-locate it there
|
|
1897
|
+
7. **Never import across page folders** \u2014 if something is shared, move it to \`shared/\` or a feature
|
|
1898
|
+
8. **Keep marker comments intact** \u2014 \`// blacksmith:path\`, \`// blacksmith:import\`, and \`// blacksmith:routes\` are required for auto-registration
|
|
1899
|
+
9. **Pages are orchestrators** \u2014 break pages into child components in \`components/\`, extract logic into hooks in \`hooks/\`
|
|
1900
|
+
10. **Components render, hooks think** \u2014 never put data fetching, transformations, or complex state directly in component bodies
|
|
1901
|
+
`;
|
|
1902
|
+
}
|
|
1903
|
+
};
|
|
1904
|
+
|
|
1905
|
+
// src/skills/blacksmith-ui-react.ts
|
|
1906
|
+
var blacksmithUiReactSkill = {
|
|
1907
|
+
id: "blacksmith-ui-react",
|
|
1908
|
+
name: "@blacksmith-ui/react",
|
|
1909
|
+
description: "Core UI component library \u2014 60+ components for layout, typography, inputs, data display, overlays, feedback, media, and navigation.",
|
|
1910
|
+
render(_ctx) {
|
|
1911
|
+
return `## @blacksmith-ui/react \u2014 Core UI Components (60+)
|
|
1912
|
+
|
|
1913
|
+
> **CRITICAL RULE: Every UI element MUST be built using \`@blacksmith-ui/react\` components \u2014 including layout and typography.**
|
|
1914
|
+
> Do NOT use raw HTML elements when a Blacksmith-UI component exists for that purpose.
|
|
1915
|
+
> This includes layout: use \`Flex\`, \`Stack\`, \`Grid\`, \`Box\`, \`Container\` instead of \`<div>\` with flex/grid classes.
|
|
1916
|
+
> This includes typography: use \`Text\` and \`Typography\` instead of raw \`<h1>\`\u2013\`<h6>\`, \`<p>\`, \`<span>\`.
|
|
1917
|
+
|
|
1918
|
+
### Layout
|
|
1919
|
+
|
|
1920
|
+
| Component | Use instead of | Description |
|
|
1921
|
+
|-----------|---------------|-------------|
|
|
1922
|
+
| \`Box\` | \`<div>\` | Base layout primitive with style props |
|
|
1923
|
+
| \`Flex\` | \`<div className="flex ...">\` | Flexbox container with style props (\`direction\`, \`align\`, \`justify\`, \`gap\`, \`wrap\`) |
|
|
1924
|
+
| \`Grid\` | \`<div className="grid ...">\` | CSS Grid container (\`columns\`, \`rows\`, \`gap\`) |
|
|
1925
|
+
| \`Stack\` | \`<div className="flex flex-col gap-...">\` | Vertical/horizontal stack (\`direction\`, \`gap\`) |
|
|
1926
|
+
| \`Container\` | \`<div className="max-w-7xl mx-auto px-...">\` | Max-width centered container |
|
|
1927
|
+
| \`Divider\` | \`<hr>\` or border hacks | Visual separator (horizontal/vertical) |
|
|
1928
|
+
| \`AspectRatio\` | padding-bottom trick | Maintain aspect ratio for content |
|
|
1929
|
+
| \`Resizable\` | custom resize logic | Resizable panel groups |
|
|
1930
|
+
| \`ScrollArea\` | \`overflow-auto\` divs | Custom scrollbar container |
|
|
1931
|
+
|
|
1932
|
+
### Typography
|
|
1933
|
+
|
|
1934
|
+
| Component | Use instead of | Description |
|
|
1935
|
+
|-----------|---------------|-------------|
|
|
1936
|
+
| \`Text\` | \`<p>\`, \`<span>\` | Text display with style props (\`size\`, \`weight\`, \`color\`, \`align\`) |
|
|
1937
|
+
| \`Typography\` | \`<h1>\`\u2013\`<h6>\`, \`<p>\` | Semantic heading/paragraph elements (\`variant\`: h1\u2013h6, p, lead, muted, etc.) |
|
|
1938
|
+
| \`Label\` | \`<label>\` | Form label with accessibility support |
|
|
1939
|
+
|
|
1940
|
+
### Cards & Containers
|
|
1941
|
+
|
|
1942
|
+
- \`Card\`, \`CardHeader\`, \`CardTitle\`, \`CardDescription\`, \`CardContent\`, \`CardFooter\` \u2014 Use instead of styled \`<div>\` containers
|
|
1943
|
+
- \`StatCard\` \u2014 Use for metric/stat display (value, label, trend)
|
|
1944
|
+
- \`EmptyState\` \u2014 Use for empty content placeholders instead of custom empty divs
|
|
1945
|
+
|
|
1946
|
+
### Actions
|
|
1947
|
+
|
|
1948
|
+
- \`Button\` \u2014 Use instead of \`<button>\` or \`<a>\` styled as buttons
|
|
1949
|
+
- Variants: \`default\`, \`secondary\`, \`destructive\`, \`outline\`, \`ghost\`, \`link\`
|
|
1950
|
+
- Sizes: \`sm\`, \`default\`, \`lg\`, \`icon\`
|
|
1951
|
+
- \`Toggle\`, \`ToggleGroup\` \u2014 Use for toggle buttons
|
|
1952
|
+
- \`DropdownMenu\`, \`DropdownMenuTrigger\`, \`DropdownMenuContent\`, \`DropdownMenuItem\`, \`DropdownMenuSeparator\`, \`DropdownMenuLabel\` \u2014 Use for action menus
|
|
1953
|
+
- \`ContextMenu\` \u2014 Use for right-click menus
|
|
1954
|
+
- \`Menubar\` \u2014 Use for application menu bars
|
|
1955
|
+
- \`AlertDialog\`, \`AlertDialogTrigger\`, \`AlertDialogContent\`, \`AlertDialogAction\`, \`AlertDialogCancel\` \u2014 Use for destructive action confirmations
|
|
1956
|
+
|
|
1957
|
+
### Data Entry
|
|
1958
|
+
|
|
1959
|
+
- \`Input\` \u2014 Use instead of \`<input>\`
|
|
1960
|
+
- \`SearchInput\` \u2014 Use for search fields (has built-in search icon)
|
|
1961
|
+
- \`Textarea\` \u2014 Use instead of \`<textarea>\`
|
|
1962
|
+
- \`NumberInput\` \u2014 Use for numeric inputs with increment/decrement
|
|
1963
|
+
- \`Select\`, \`SelectTrigger\`, \`SelectContent\`, \`SelectItem\`, \`SelectValue\` \u2014 Use instead of \`<select>\`
|
|
1964
|
+
- \`Checkbox\` \u2014 Use instead of \`<input type="checkbox">\`
|
|
1965
|
+
- \`RadioGroup\`, \`RadioGroupItem\` \u2014 Use instead of \`<input type="radio">\`
|
|
1966
|
+
- \`Switch\` \u2014 Use for toggle switches
|
|
1967
|
+
- \`Slider\` \u2014 Use for single range inputs
|
|
1968
|
+
- \`RangeSlider\` \u2014 Use for dual-handle range selection
|
|
1969
|
+
- \`DatePicker\` \u2014 Use for date selection with calendar popup
|
|
1970
|
+
- \`PinInput\` / \`InputOTP\` \u2014 Use for PIN/OTP code entry
|
|
1971
|
+
- \`ColorPicker\` \u2014 Use for color selection
|
|
1972
|
+
- \`FileUpload\` \u2014 Use for file upload with drag & drop
|
|
1973
|
+
- \`TagInput\` \u2014 Use for tag/chip input with add/remove
|
|
1974
|
+
- \`Rating\` \u2014 Use for star/icon rating selection
|
|
1975
|
+
- \`Label\` \u2014 Use instead of \`<label>\`
|
|
1976
|
+
|
|
1977
|
+
### Data Display
|
|
1978
|
+
|
|
1979
|
+
- \`Table\`, \`TableHeader\`, \`TableBody\`, \`TableRow\`, \`TableHead\`, \`TableCell\` \u2014 Use instead of \`<table>\` elements
|
|
1980
|
+
- \`DataTable\` \u2014 Use for feature-rich tables with sorting, filtering, and pagination
|
|
1981
|
+
- \`Badge\` \u2014 Use for status indicators, tags, counts (variants: \`default\`, \`secondary\`, \`destructive\`, \`outline\`)
|
|
1982
|
+
- \`Avatar\`, \`AvatarImage\`, \`AvatarFallback\` \u2014 Use for user profile images
|
|
1983
|
+
- \`Tooltip\`, \`TooltipTrigger\`, \`TooltipContent\`, \`TooltipProvider\` \u2014 Use for hover hints
|
|
1984
|
+
- \`HoverCard\` \u2014 Use for rich hover content
|
|
1985
|
+
- \`Calendar\` \u2014 Use for full calendar display
|
|
1986
|
+
- \`Chart\` \u2014 Use for data visualization (powered by Recharts)
|
|
1987
|
+
- \`Timeline\` \u2014 Use for chronological event display
|
|
1988
|
+
- \`Tree\` \u2014 Use for hierarchical tree views
|
|
1989
|
+
- \`List\` \u2014 Use for structured list display instead of \`<ul>\`/\`<ol>\`
|
|
1990
|
+
- \`Skeleton\` \u2014 Use for loading placeholders
|
|
1991
|
+
- \`Spinner\` \u2014 Use for loading indicators
|
|
1992
|
+
- \`Progress\` \u2014 Use for progress bars
|
|
1993
|
+
- \`Pagination\`, \`PaginationContent\`, \`PaginationItem\`, \`PaginationLink\`, \`PaginationNext\`, \`PaginationPrevious\` \u2014 Use for paginated lists
|
|
1994
|
+
|
|
1995
|
+
### Tabs & Accordion
|
|
1996
|
+
|
|
1997
|
+
- \`Tabs\`, \`TabsList\`, \`TabsTrigger\`, \`TabsContent\` \u2014 Use for tabbed interfaces
|
|
1998
|
+
- \`Accordion\`, \`AccordionItem\`, \`AccordionTrigger\`, \`AccordionContent\` \u2014 Use for collapsible sections
|
|
1999
|
+
|
|
2000
|
+
### Overlays
|
|
2001
|
+
|
|
2002
|
+
- \`Dialog\`, \`DialogTrigger\`, \`DialogContent\`, \`DialogHeader\`, \`DialogTitle\`, \`DialogDescription\`, \`DialogFooter\` \u2014 Use for modals
|
|
2003
|
+
- \`AlertDialog\` \u2014 Use for confirmation dialogs
|
|
2004
|
+
- \`Drawer\` / \`Sheet\`, \`SheetTrigger\`, \`SheetContent\`, \`SheetHeader\`, \`SheetTitle\`, \`SheetDescription\` \u2014 Use for slide-out panels
|
|
2005
|
+
- \`Popover\` \u2014 Use for floating content panels
|
|
2006
|
+
- \`CommandPalette\` \u2014 Use for searchable command menus (cmdk-based)
|
|
2007
|
+
|
|
2008
|
+
### Navigation
|
|
2009
|
+
|
|
2010
|
+
- \`Breadcrumb\`, \`BreadcrumbList\`, \`BreadcrumbItem\`, \`BreadcrumbLink\`, \`BreadcrumbSeparator\` \u2014 Use for breadcrumb trails
|
|
2011
|
+
- \`NavigationMenu\`, \`NavigationMenuList\`, \`NavigationMenuItem\`, \`NavigationMenuTrigger\`, \`NavigationMenuContent\` \u2014 Use for site navigation
|
|
2012
|
+
- \`Sidebar\` \u2014 Use for app sidebars
|
|
2013
|
+
- \`Dock\` \u2014 Use for macOS-style dock navigation
|
|
2014
|
+
- \`BackToTop\` \u2014 Use for scroll-to-top buttons
|
|
2015
|
+
|
|
2016
|
+
### Feedback
|
|
2017
|
+
|
|
2018
|
+
- \`Alert\`, \`AlertTitle\`, \`AlertDescription\` \u2014 Use for inline messages/warnings
|
|
2019
|
+
- \`AlertBanner\` \u2014 Use for full-width alert banners
|
|
2020
|
+
- \`Toast\` / \`Toaster\` / \`useToast\` \u2014 Use for transient notifications
|
|
2021
|
+
- \`SonnerToaster\` \u2014 Sonner-based toast notifications
|
|
2022
|
+
|
|
2023
|
+
### Media
|
|
2024
|
+
|
|
2025
|
+
- \`Image\` \u2014 Use instead of \`<img>\` for optimized image display
|
|
2026
|
+
- \`VideoPlayer\` \u2014 Use for video playback
|
|
2027
|
+
- \`CodeBlock\` \u2014 Use for syntax-highlighted code (Shiki-powered)
|
|
2028
|
+
- \`Carousel\` \u2014 Use for image/content carousels
|
|
2029
|
+
- \`Lightbox\` \u2014 Use for full-screen media viewers
|
|
2030
|
+
|
|
2031
|
+
### Specialized
|
|
2032
|
+
|
|
2033
|
+
- \`Stepper\` / \`Wizard\` \u2014 Use for multi-step workflows
|
|
2034
|
+
- \`NotificationCenter\` / \`useNotificationCenter\` \u2014 Use for notification management
|
|
2035
|
+
- \`SpotlightTour\` \u2014 Use for guided feature tours
|
|
2036
|
+
|
|
2037
|
+
### Utilities & Hooks
|
|
2038
|
+
|
|
2039
|
+
- \`cn()\` \u2014 Merge class names (clsx + tailwind-merge)
|
|
2040
|
+
- \`useToast()\` \u2014 Programmatic toast notifications
|
|
2041
|
+
- \`useMobile()\` \u2014 Responsive breakpoint detection
|
|
2042
|
+
- \`useDarkMode()\` \u2014 Dark mode toggle. Returns \`{ isDark, toggle }\`
|
|
2043
|
+
|
|
2044
|
+
---
|
|
2045
|
+
|
|
2046
|
+
### Component-First Rules
|
|
2047
|
+
|
|
2048
|
+
1. **Layout**: NEVER use \`<div className="flex ...">\` or \`<div className="grid ...">\`. Use \`<Flex>\`, \`<Grid>\`, \`<Stack>\`, \`<Box>\` from \`@blacksmith-ui/react\`.
|
|
2049
|
+
2. **Centering/max-width**: NEVER use \`<div className="max-w-7xl mx-auto px-...">\`. Use \`<Container>\`.
|
|
2050
|
+
3. **Typography**: NEVER use raw \`<h1>\`\u2013\`<h6>\` or \`<p>\` with Tailwind text classes. Use \`<Typography variant="h2">\` or \`<Text>\`.
|
|
2051
|
+
4. **Separators**: NEVER use \`<hr>\` or border hacks. Use \`<Divider>\`.
|
|
2052
|
+
5. **Images**: NEVER use raw \`<img>\`. Use \`<Image>\` from \`@blacksmith-ui/react\` (use \`Avatar\` for profile pictures).
|
|
2053
|
+
6. **Lists**: NEVER use \`<ul>\`/\`<ol>\` for structured display lists. Use \`<List>\` from \`@blacksmith-ui/react\`. Plain \`<ul>\`/\`<ol>\` is only acceptable for simple inline content lists.
|
|
2054
|
+
7. **Buttons**: NEVER use \`<button>\` or \`<a>\` styled as a button. Use \`<Button>\`.
|
|
2055
|
+
8. **Inputs**: NEVER use \`<input>\`, \`<textarea>\`, \`<select>\` directly. Use the Blacksmith-UI equivalents.
|
|
2056
|
+
9. **Cards**: NEVER use a styled \`<div>\` as a card. Use \`Card\` + sub-components.
|
|
2057
|
+
10. **Tables**: NEVER use raw \`<table>\` HTML. Use \`Table\` or \`DataTable\`.
|
|
2058
|
+
11. **Loading**: NEVER use custom \`animate-pulse\` divs. Use \`Skeleton\` or \`Spinner\`.
|
|
2059
|
+
12. **Modals**: NEVER build custom modals. Use \`Dialog\`, \`AlertDialog\`, \`Drawer\`, or \`Sheet\`.
|
|
2060
|
+
13. **Feedback**: NEVER use plain styled text for errors/warnings. Use \`Alert\` or \`useToast\`.
|
|
2061
|
+
14. **Empty states**: NEVER build custom empty-state UIs. Use \`EmptyState\`.
|
|
2062
|
+
15. **Metrics**: NEVER build custom stat/metric cards. Use \`StatCard\`.
|
|
2063
|
+
|
|
2064
|
+
### When Raw HTML IS Acceptable
|
|
2065
|
+
|
|
2066
|
+
- \`<main>\`, \`<section>\`, \`<header>\`, \`<footer>\`, \`<nav>\`, \`<article>\`, \`<aside>\` \u2014 semantic HTML landmarks for page structure (but use \`Flex\`/\`Stack\`/\`Grid\` inside them for layout)
|
|
2067
|
+
- \`<Link>\` from react-router-dom \u2014 for page navigation (use \`<Button asChild><Link>...</Link></Button>\` if it needs button styling)
|
|
2068
|
+
- Icon components from \`lucide-react\`
|
|
2069
|
+
- \`<form>\` element when used with React Hook Form (but use \`@blacksmith-ui/forms\` components inside)
|
|
2070
|
+
|
|
2071
|
+
### Design Tokens & Theming
|
|
2072
|
+
|
|
2073
|
+
- \`ThemeProvider\` \u2014 Wrap app to apply preset or custom theme
|
|
2074
|
+
- Built-in presets: \`default\`, \`blue\`, \`green\`, \`violet\`, \`red\`, \`neutral\`
|
|
2075
|
+
- All components use HSL CSS variables (\`--background\`, \`--foreground\`, \`--primary\`, etc.)
|
|
2076
|
+
- Dark mode: \`.dark\` class strategy on \`<html>\`, or \`<ThemeProvider mode="dark">\`
|
|
2077
|
+
- Border radius: controlled by \`--radius\` CSS variable
|
|
2078
|
+
- Extend with \`className\` prop + \`cn()\` utility for custom styles
|
|
2079
|
+
- Global styles: \`@import '@blacksmith-ui/react/styles.css'\` in app entry
|
|
2080
|
+
|
|
2081
|
+
### Example: HowItWorks Section (Correct Way)
|
|
2082
|
+
|
|
2083
|
+
\`\`\`tsx
|
|
2084
|
+
import { Container, Stack, Flex, Grid, Text, Typography, Image } from '@blacksmith-ui/react'
|
|
2085
|
+
import { howItWorksSteps } from '../data'
|
|
2086
|
+
|
|
2087
|
+
export function HowItWorks() {
|
|
2088
|
+
return (
|
|
2089
|
+
<Box as="section" className="py-16 sm:py-20">
|
|
2090
|
+
<Container>
|
|
2091
|
+
<Stack gap={3} align="center" className="mb-12">
|
|
2092
|
+
<Typography variant="h2">How It Works</Typography>
|
|
2093
|
+
<Text color="muted">Book your stay in three simple steps</Text>
|
|
2094
|
+
</Stack>
|
|
2095
|
+
|
|
2096
|
+
<Grid columns={{ base: 1, md: 3 }} gap={8} className="max-w-4xl mx-auto">
|
|
2097
|
+
{howItWorksSteps.map((item) => (
|
|
2098
|
+
<Stack key={item.step} align="center" gap={4}>
|
|
2099
|
+
<Box className="relative">
|
|
2100
|
+
<Flex align="center" justify="center" className="h-16 w-16 rounded-full bg-primary text-primary-foreground shadow-lg shadow-primary/30">
|
|
2101
|
+
<item.icon className="h-7 w-7" />
|
|
2102
|
+
</Flex>
|
|
2103
|
+
<Flex align="center" justify="center" className="absolute -top-1 -right-1 h-6 w-6 rounded-full bg-background border-2 border-primary">
|
|
2104
|
+
<Text size="xs" weight="bold" color="primary">{item.step}</Text>
|
|
2105
|
+
</Flex>
|
|
2106
|
+
</Box>
|
|
2107
|
+
<Stack gap={2} align="center">
|
|
2108
|
+
<Text size="lg" weight="bold">{item.title}</Text>
|
|
2109
|
+
<Text size="sm" color="muted" align="center" className="max-w-xs">
|
|
2110
|
+
{item.description}
|
|
2111
|
+
</Text>
|
|
2112
|
+
</Stack>
|
|
2113
|
+
</Stack>
|
|
2114
|
+
))}
|
|
2115
|
+
</Grid>
|
|
2116
|
+
</Container>
|
|
2117
|
+
</Box>
|
|
2118
|
+
)
|
|
2119
|
+
}
|
|
2120
|
+
\`\`\`
|
|
2121
|
+
|
|
2122
|
+
### Example: Resource List Page (Correct Way)
|
|
2123
|
+
|
|
2124
|
+
\`\`\`tsx
|
|
2125
|
+
import {
|
|
2126
|
+
Stack, Flex,
|
|
2127
|
+
Card, CardHeader, CardTitle, CardContent,
|
|
2128
|
+
Button, Badge, Skeleton,
|
|
2129
|
+
Table, TableHeader, TableBody, TableRow, TableHead, TableCell,
|
|
2130
|
+
DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem,
|
|
2131
|
+
AlertDialog, AlertDialogTrigger, AlertDialogContent,
|
|
2132
|
+
AlertDialogAction, AlertDialogCancel,
|
|
2133
|
+
} from '@blacksmith-ui/react'
|
|
2134
|
+
import { MoreHorizontal, Plus, Trash2, Edit } from 'lucide-react'
|
|
2135
|
+
import { Link } from 'react-router-dom'
|
|
2136
|
+
|
|
2137
|
+
function ResourceListPage({ resources, isLoading, onDelete }) {
|
|
2138
|
+
if (isLoading) {
|
|
2139
|
+
return (
|
|
2140
|
+
<Card>
|
|
2141
|
+
<CardContent className="p-6">
|
|
2142
|
+
<Stack gap={4}>
|
|
2143
|
+
{Array.from({ length: 5 }).map((_, i) => (
|
|
2144
|
+
<Skeleton key={i} className="h-12 w-full" />
|
|
2145
|
+
))}
|
|
2146
|
+
</Stack>
|
|
2147
|
+
</CardContent>
|
|
2148
|
+
</Card>
|
|
2149
|
+
)
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
return (
|
|
2153
|
+
<Card>
|
|
2154
|
+
<CardHeader>
|
|
2155
|
+
<Flex align="center" justify="between">
|
|
2156
|
+
<CardTitle>Resources</CardTitle>
|
|
2157
|
+
<Button asChild>
|
|
2158
|
+
<Link to="/resources/new"><Plus className="mr-2 h-4 w-4" /> Create</Link>
|
|
2159
|
+
</Button>
|
|
2160
|
+
</Flex>
|
|
2161
|
+
</CardHeader>
|
|
2162
|
+
<CardContent>
|
|
2163
|
+
<Table>
|
|
2164
|
+
<TableHeader>
|
|
2165
|
+
<TableRow>
|
|
2166
|
+
<TableHead>Title</TableHead>
|
|
2167
|
+
<TableHead>Status</TableHead>
|
|
2168
|
+
<TableHead className="w-12" />
|
|
2169
|
+
</TableRow>
|
|
2170
|
+
</TableHeader>
|
|
2171
|
+
<TableBody>
|
|
2172
|
+
{resources.map((r) => (
|
|
2173
|
+
<TableRow key={r.id}>
|
|
2174
|
+
<TableCell>{r.title}</TableCell>
|
|
2175
|
+
<TableCell><Badge variant="outline">{r.status}</Badge></TableCell>
|
|
2176
|
+
<TableCell>
|
|
2177
|
+
<DropdownMenu>
|
|
2178
|
+
<DropdownMenuTrigger asChild>
|
|
2179
|
+
<Button variant="ghost" size="icon">
|
|
2180
|
+
<MoreHorizontal className="h-4 w-4" />
|
|
2181
|
+
</Button>
|
|
2182
|
+
</DropdownMenuTrigger>
|
|
2183
|
+
<DropdownMenuContent>
|
|
2184
|
+
<DropdownMenuItem asChild>
|
|
2185
|
+
<Link to={\`/resources/\${r.id}/edit\`}>
|
|
2186
|
+
<Edit className="mr-2 h-4 w-4" /> Edit
|
|
2187
|
+
</Link>
|
|
2188
|
+
</DropdownMenuItem>
|
|
2189
|
+
<AlertDialog>
|
|
2190
|
+
<AlertDialogTrigger asChild>
|
|
2191
|
+
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
|
|
2192
|
+
<Trash2 className="mr-2 h-4 w-4" /> Delete
|
|
2193
|
+
</DropdownMenuItem>
|
|
2194
|
+
</AlertDialogTrigger>
|
|
2195
|
+
<AlertDialogContent>
|
|
2196
|
+
<AlertDialogAction onClick={() => onDelete(r.id)}>
|
|
2197
|
+
Delete
|
|
2198
|
+
</AlertDialogAction>
|
|
2199
|
+
<AlertDialogCancel>Cancel</AlertDialogCancel>
|
|
2200
|
+
</AlertDialogContent>
|
|
2201
|
+
</AlertDialog>
|
|
2202
|
+
</DropdownMenuContent>
|
|
2203
|
+
</DropdownMenu>
|
|
2204
|
+
</TableCell>
|
|
2205
|
+
</TableRow>
|
|
2206
|
+
))}
|
|
2207
|
+
</TableBody>
|
|
2208
|
+
</Table>
|
|
2209
|
+
</CardContent>
|
|
2210
|
+
</Card>
|
|
2211
|
+
)
|
|
2212
|
+
}
|
|
2213
|
+
\`\`\`
|
|
2214
|
+
`;
|
|
2215
|
+
}
|
|
2216
|
+
};
|
|
2217
|
+
|
|
2218
|
+
// src/skills/blacksmith-ui-forms.ts
|
|
2219
|
+
var blacksmithUiFormsSkill = {
|
|
2220
|
+
id: "blacksmith-ui-forms",
|
|
2221
|
+
name: "@blacksmith-ui/forms",
|
|
2222
|
+
description: "Form components using React Hook Form + Zod for validation and submission.",
|
|
2223
|
+
render(_ctx) {
|
|
2224
|
+
return `## @blacksmith-ui/forms \u2014 Form Components (React Hook Form + Zod)
|
|
2225
|
+
|
|
2226
|
+
> **RULE: ALWAYS use these for forms.** Do NOT build forms with raw \`<form>\`, \`<input>\`, \`<label>\`, or manual error display.
|
|
2227
|
+
|
|
2228
|
+
\`\`\`tsx
|
|
2229
|
+
import { Form, FormField, FormInput, FormTextarea, FormSelect } from '@blacksmith-ui/forms'
|
|
2230
|
+
\`\`\`
|
|
2231
|
+
|
|
2232
|
+
### Components
|
|
2233
|
+
|
|
2234
|
+
- \`Form\` \u2014 Wraps the entire form. Props: \`form\` (useForm instance), \`onSubmit\`
|
|
2235
|
+
- \`FormField\` \u2014 Wraps each field. Props: \`name\`, \`label\`, \`description?\`
|
|
2236
|
+
- \`FormInput\` \u2014 Text input within FormField. Props: \`type\`, \`placeholder\`
|
|
2237
|
+
- \`FormTextarea\` \u2014 Textarea within FormField. Props: \`rows\`, \`placeholder\`
|
|
2238
|
+
- \`FormSelect\` \u2014 Select within FormField. Props: \`options\`, \`placeholder\`
|
|
2239
|
+
- \`FormCheckbox\` \u2014 Checkbox within FormField
|
|
2240
|
+
- \`FormSwitch\` \u2014 Toggle switch within FormField
|
|
2241
|
+
- \`FormRadioGroup\` \u2014 Radio group within FormField. Props: \`options\`
|
|
2242
|
+
- \`FormDatePicker\` \u2014 Date picker within FormField
|
|
2243
|
+
- \`FormError\` \u2014 Displays field-level validation error (auto-handled by FormField)
|
|
2244
|
+
- \`FormDescription\` \u2014 Displays helper text below a field
|
|
2245
|
+
|
|
2246
|
+
### Rules
|
|
2247
|
+
- NEVER use raw \`<form>\` with manual \`<label>\` and error \`<p>\` tags. Always use \`Form\` + \`FormField\`.
|
|
2248
|
+
- NEVER use \`<input>\`, \`<textarea>\`, \`<select>\` inside forms. Use \`FormInput\`, \`FormTextarea\`, \`FormSelect\`.
|
|
2249
|
+
|
|
2250
|
+
### Form Pattern \u2014 ALWAYS follow this:
|
|
2251
|
+
\`\`\`tsx
|
|
2252
|
+
import { Form, FormField, FormInput, FormTextarea, FormSelect } from '@blacksmith-ui/forms'
|
|
2253
|
+
import { Button } from '@blacksmith-ui/react'
|
|
2254
|
+
import { useForm } from 'react-hook-form'
|
|
2255
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2256
|
+
import { z } from 'zod'
|
|
2257
|
+
|
|
2258
|
+
const schema = z.object({
|
|
2259
|
+
title: z.string().min(1, 'Title is required'),
|
|
2260
|
+
description: z.string().optional(),
|
|
2261
|
+
status: z.enum(['draft', 'published']),
|
|
2262
|
+
})
|
|
2263
|
+
|
|
2264
|
+
type FormData = z.infer<typeof schema>
|
|
2265
|
+
|
|
2266
|
+
function ResourceForm({ defaultValues, onSubmit, isSubmitting }: Props) {
|
|
2267
|
+
const form = useForm<FormData>({
|
|
2268
|
+
resolver: zodResolver(schema),
|
|
2269
|
+
defaultValues: { title: '', description: '', status: 'draft', ...defaultValues },
|
|
2270
|
+
})
|
|
2271
|
+
|
|
2272
|
+
return (
|
|
2273
|
+
<Form form={form} onSubmit={onSubmit}>
|
|
2274
|
+
<FormField name="title" label="Title">
|
|
2275
|
+
<FormInput placeholder="Enter title" />
|
|
2276
|
+
</FormField>
|
|
2277
|
+
<FormField name="description" label="Description">
|
|
2278
|
+
<FormTextarea rows={4} placeholder="Enter description" />
|
|
2279
|
+
</FormField>
|
|
2280
|
+
<FormField name="status" label="Status">
|
|
2281
|
+
<FormSelect options={[
|
|
2282
|
+
{ label: 'Draft', value: 'draft' },
|
|
2283
|
+
{ label: 'Published', value: 'published' },
|
|
2284
|
+
]} />
|
|
2285
|
+
</FormField>
|
|
2286
|
+
<Button type="submit" disabled={isSubmitting}>
|
|
2287
|
+
{isSubmitting ? 'Saving...' : 'Save'}
|
|
2288
|
+
</Button>
|
|
2289
|
+
</Form>
|
|
2290
|
+
)
|
|
2291
|
+
}
|
|
2292
|
+
\`\`\`
|
|
2293
|
+
|
|
2294
|
+
### Example: Detail Page with Edit Dialog
|
|
2295
|
+
\`\`\`tsx
|
|
2296
|
+
import {
|
|
2297
|
+
Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter,
|
|
2298
|
+
Button, Badge, Separator,
|
|
2299
|
+
Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
|
2300
|
+
Alert, AlertTitle, AlertDescription,
|
|
2301
|
+
} from '@blacksmith-ui/react'
|
|
2302
|
+
import { Form, FormField, FormInput, FormTextarea } from '@blacksmith-ui/forms'
|
|
2303
|
+
import { useForm } from 'react-hook-form'
|
|
2304
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
2305
|
+
import { z } from 'zod'
|
|
2306
|
+
import { Edit, ArrowLeft } from 'lucide-react'
|
|
2307
|
+
import { Link } from 'react-router-dom'
|
|
2308
|
+
|
|
2309
|
+
const editSchema = z.object({
|
|
2310
|
+
title: z.string().min(1, 'Required'),
|
|
2311
|
+
description: z.string().optional(),
|
|
2312
|
+
})
|
|
2313
|
+
|
|
2314
|
+
function ResourceDetailPage({ resource, onUpdate, error }) {
|
|
2315
|
+
const form = useForm({
|
|
2316
|
+
resolver: zodResolver(editSchema),
|
|
2317
|
+
defaultValues: { title: resource.title, description: resource.description },
|
|
2318
|
+
})
|
|
2319
|
+
|
|
2320
|
+
return (
|
|
2321
|
+
<Card>
|
|
2322
|
+
<CardHeader>
|
|
2323
|
+
<div className="flex items-center justify-between">
|
|
2324
|
+
<div>
|
|
2325
|
+
<CardTitle>{resource.title}</CardTitle>
|
|
2326
|
+
<CardDescription>Created {new Date(resource.created_at).toLocaleDateString()}</CardDescription>
|
|
2327
|
+
</div>
|
|
2328
|
+
<div className="flex gap-2">
|
|
2329
|
+
<Button variant="outline" asChild>
|
|
2330
|
+
<Link to="/resources"><ArrowLeft className="mr-2 h-4 w-4" /> Back</Link>
|
|
2331
|
+
</Button>
|
|
2332
|
+
<Dialog>
|
|
2333
|
+
<DialogTrigger asChild>
|
|
2334
|
+
<Button><Edit className="mr-2 h-4 w-4" /> Edit</Button>
|
|
2335
|
+
</DialogTrigger>
|
|
2336
|
+
<DialogContent>
|
|
2337
|
+
<DialogHeader>
|
|
2338
|
+
<DialogTitle>Edit Resource</DialogTitle>
|
|
2339
|
+
</DialogHeader>
|
|
2340
|
+
<Form form={form} onSubmit={onUpdate}>
|
|
2341
|
+
<FormField name="title" label="Title">
|
|
2342
|
+
<FormInput />
|
|
2343
|
+
</FormField>
|
|
2344
|
+
<FormField name="description" label="Description">
|
|
2345
|
+
<FormTextarea rows={4} />
|
|
2346
|
+
</FormField>
|
|
2347
|
+
<DialogFooter>
|
|
2348
|
+
<Button type="submit">Save Changes</Button>
|
|
2349
|
+
</DialogFooter>
|
|
2350
|
+
</Form>
|
|
2351
|
+
</DialogContent>
|
|
2352
|
+
</Dialog>
|
|
2353
|
+
</div>
|
|
2354
|
+
</div>
|
|
2355
|
+
</CardHeader>
|
|
2356
|
+
<Separator />
|
|
2357
|
+
<CardContent className="pt-6">
|
|
2358
|
+
{error && (
|
|
2359
|
+
<Alert variant="destructive" className="mb-4">
|
|
2360
|
+
<AlertTitle>Error</AlertTitle>
|
|
2361
|
+
<AlertDescription>{error}</AlertDescription>
|
|
2362
|
+
</Alert>
|
|
2363
|
+
)}
|
|
2364
|
+
<p>{resource.description || 'No description provided.'}</p>
|
|
2365
|
+
</CardContent>
|
|
2366
|
+
<CardFooter>
|
|
2367
|
+
<Badge>{resource.status}</Badge>
|
|
2368
|
+
</CardFooter>
|
|
2369
|
+
</Card>
|
|
2370
|
+
)
|
|
2371
|
+
}
|
|
2372
|
+
\`\`\`
|
|
2373
|
+
`;
|
|
2374
|
+
}
|
|
2375
|
+
};
|
|
2376
|
+
|
|
2377
|
+
// src/skills/blacksmith-ui-auth.ts
|
|
2378
|
+
var blacksmithUiAuthSkill = {
|
|
2379
|
+
id: "blacksmith-ui-auth",
|
|
2380
|
+
name: "@blacksmith-ui/auth",
|
|
2381
|
+
description: "Authentication UI components and hooks for login, registration, and password reset.",
|
|
2382
|
+
render(_ctx) {
|
|
2383
|
+
return `## @blacksmith-ui/auth \u2014 Authentication UI
|
|
2384
|
+
|
|
2385
|
+
> **RULE: ALWAYS use these for auth pages.** Do NOT build custom login/register forms.
|
|
2386
|
+
|
|
2387
|
+
\`\`\`tsx
|
|
2388
|
+
import { AuthProvider, LoginForm, RegisterForm, useAuth } from '@blacksmith-ui/auth'
|
|
2389
|
+
\`\`\`
|
|
2390
|
+
|
|
2391
|
+
### Components
|
|
2392
|
+
|
|
2393
|
+
- \`AuthProvider\` \u2014 Context provider wrapping the app. Props: \`config: { adapter, socialProviders? }\`
|
|
2394
|
+
- \`LoginForm\` \u2014 Complete login form with email/password fields, validation, and links
|
|
2395
|
+
- Props: \`onSubmit: (data: { email, password }) => void\`, \`onRegisterClick\`, \`onForgotPasswordClick\`, \`error\`, \`loading\`
|
|
2396
|
+
- \`RegisterForm\` \u2014 Registration form with email, password, and display name
|
|
2397
|
+
- Props: \`onSubmit: (data: { email, password, displayName }) => void\`, \`onLoginClick\`, \`error\`, \`loading\`
|
|
2398
|
+
- \`ForgotPasswordForm\` \u2014 Password reset email request
|
|
2399
|
+
- Props: \`onSubmit: (data: { email }) => void\`, \`onLoginClick\`, \`error\`, \`loading\`
|
|
2400
|
+
- \`ResetPasswordForm\` \u2014 Set new password form
|
|
2401
|
+
- Props: \`onSubmit: (data: { password, code }) => void\`, \`code\`, \`onLoginClick\`, \`error\`, \`loading\`
|
|
2402
|
+
|
|
2403
|
+
### Hooks
|
|
2404
|
+
|
|
2405
|
+
- \`useAuth\` \u2014 Hook for auth state and actions
|
|
2406
|
+
- Returns: \`user\`, \`loading\`, \`error\`, \`signInWithEmail(email, password)\`, \`signUpWithEmail(email, password, displayName?)\`, \`signOut()\`, \`sendPasswordResetEmail(email)\`, \`confirmPasswordReset(code, newPassword)\`, \`socialProviders\`
|
|
2407
|
+
|
|
2408
|
+
### Adapter
|
|
2409
|
+
|
|
2410
|
+
- \`AuthAdapter\` \u2014 Interface for custom auth backends (Django JWT adapter already configured in \`frontend/src/features/auth/adapter.ts\`)
|
|
2411
|
+
|
|
2412
|
+
### Rules
|
|
2413
|
+
- NEVER build custom login/register forms. Use \`LoginForm\`, \`RegisterForm\`, etc. from \`@blacksmith-ui/auth\`.
|
|
2414
|
+
- NEVER manage auth state manually. Use \`useAuth\` hook.
|
|
2415
|
+
`;
|
|
2416
|
+
}
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
// src/skills/blacksmith-hooks.ts
|
|
2420
|
+
var blacksmithHooksSkill = {
|
|
2421
|
+
id: "blacksmith-hooks",
|
|
2422
|
+
name: "@blacksmith-ui/hooks",
|
|
2423
|
+
description: "74 production-ready React hooks for state, DOM, timers, async, browser APIs, and layout.",
|
|
2424
|
+
render(_ctx) {
|
|
2425
|
+
return `## @blacksmith-ui/hooks \u2014 React Hooks Library
|
|
2426
|
+
|
|
2427
|
+
A collection of 74 production-ready React hooks. SSR-safe, fully typed, zero dependencies, tree-shakeable.
|
|
2428
|
+
|
|
2429
|
+
> **RULE: Use \`@blacksmith-ui/hooks\` instead of writing custom hooks when one exists for that purpose.**
|
|
2430
|
+
> Before creating a new hook, check if one already exists below.
|
|
2431
|
+
|
|
2432
|
+
\`\`\`tsx
|
|
2433
|
+
import { useToggle, useLocalStorage, useDebounce, useClickOutside } from '@blacksmith-ui/hooks'
|
|
2434
|
+
\`\`\`
|
|
2435
|
+
|
|
2436
|
+
### State & Data
|
|
2437
|
+
|
|
2438
|
+
| Hook | Description |
|
|
2439
|
+
|------|-------------|
|
|
2440
|
+
| \`useToggle\` | Boolean state with \`toggle\`, \`on\`, \`off\` actions |
|
|
2441
|
+
| \`useDisclosure\` | Open/close/toggle state for modals, drawers, etc. |
|
|
2442
|
+
| \`useCounter\` | Numeric counter with optional min/max clamping |
|
|
2443
|
+
| \`useList\` | Array state with push, remove, update, insert, filter, clear |
|
|
2444
|
+
| \`useMap\` | Map state with set, remove, clear helpers |
|
|
2445
|
+
| \`useSet\` | Set state with add, remove, toggle, has, clear helpers |
|
|
2446
|
+
| \`useHistoryState\` | State with undo/redo history |
|
|
2447
|
+
| \`useDefault\` | State that falls back to a default when set to null/undefined |
|
|
2448
|
+
| \`useQueue\` | FIFO queue data structure |
|
|
2449
|
+
| \`useStack\` | LIFO stack data structure |
|
|
2450
|
+
| \`useLocalStorage\` | Persist state to localStorage with JSON serialization |
|
|
2451
|
+
| \`useSessionStorage\` | Persist state to sessionStorage with JSON serialization |
|
|
2452
|
+
| \`useUncontrolled\` | Controlled/uncontrolled component pattern helper |
|
|
2453
|
+
|
|
2454
|
+
### Values & Memoization
|
|
2455
|
+
|
|
2456
|
+
| Hook | Description |
|
|
2457
|
+
|------|-------------|
|
|
2458
|
+
| \`useDebounce\` | Debounce a value with configurable delay |
|
|
2459
|
+
| \`useDebouncedCallback\` | Debounce a callback function |
|
|
2460
|
+
| \`useThrottle\` | Throttle a value with configurable interval |
|
|
2461
|
+
| \`useThrottledCallback\` | Throttle a callback function |
|
|
2462
|
+
| \`usePrevious\` | Track the previous value of a variable |
|
|
2463
|
+
| \`useLatest\` | Ref that always points to the latest value |
|
|
2464
|
+
| \`useConst\` | Compute a value once and return it on every render |
|
|
2465
|
+
| \`useSyncedRef\` | Keep a ref synchronized with the latest value |
|
|
2466
|
+
|
|
2467
|
+
### DOM & Browser
|
|
2468
|
+
|
|
2469
|
+
| Hook | Description |
|
|
2470
|
+
|------|-------------|
|
|
2471
|
+
| \`useClickOutside\` | Detect clicks outside a ref element |
|
|
2472
|
+
| \`useEventListener\` | Attach event listeners to window or elements |
|
|
2473
|
+
| \`useElementSize\` | Track element width/height via ResizeObserver |
|
|
2474
|
+
| \`useHover\` | Track mouse hover state |
|
|
2475
|
+
| \`useKeyPress\` | Listen for a specific key press |
|
|
2476
|
+
| \`useKeyCombo\` | Listen for key + modifier combinations |
|
|
2477
|
+
| \`useLongPress\` | Detect long press gestures |
|
|
2478
|
+
| \`useFullscreen\` | Manage the Fullscreen API |
|
|
2479
|
+
| \`useTextSelection\` | Track currently selected text |
|
|
2480
|
+
| \`useFocusWithin\` | Track whether focus is inside a container |
|
|
2481
|
+
| \`useFocusTrap\` | Trap Tab/Shift+Tab focus within a container |
|
|
2482
|
+
| \`useBoundingClientRect\` | Track element bounding rect via ResizeObserver |
|
|
2483
|
+
| \`useSwipe\` | Detect touch swipe direction |
|
|
2484
|
+
| \`useDrag\` | Track mouse drag with position and delta |
|
|
2485
|
+
| \`useElementVisibility\` | Check if an element is in the viewport |
|
|
2486
|
+
| \`useScrollPosition\` | Track window scroll position |
|
|
2487
|
+
| \`useScrollLock\` | Lock/unlock body scroll |
|
|
2488
|
+
| \`useMutationObserver\` | Observe DOM mutations |
|
|
2489
|
+
| \`useIntersectionObserver\` | Observe element intersection with viewport |
|
|
2490
|
+
|
|
2491
|
+
### Timers & Lifecycle
|
|
2492
|
+
|
|
2493
|
+
| Hook | Description |
|
|
2494
|
+
|------|-------------|
|
|
2495
|
+
| \`useInterval\` | setInterval wrapper with pause support |
|
|
2496
|
+
| \`useTimeout\` | setTimeout wrapper with manual clear |
|
|
2497
|
+
| \`useCountdown\` | Countdown timer with start/pause/reset |
|
|
2498
|
+
| \`useStopwatch\` | Stopwatch with lap support |
|
|
2499
|
+
| \`useIdleTimer\` | Detect user idle time |
|
|
2500
|
+
| \`useUpdateEffect\` | useEffect that skips the initial render |
|
|
2501
|
+
| \`useIsomorphicLayoutEffect\` | SSR-safe useLayoutEffect |
|
|
2502
|
+
| \`useIsMounted\` | Check if component is currently mounted |
|
|
2503
|
+
| \`useIsFirstRender\` | Check if this is the first render |
|
|
2504
|
+
|
|
2505
|
+
### Async & Network
|
|
2506
|
+
|
|
2507
|
+
| Hook | Description |
|
|
2508
|
+
|------|-------------|
|
|
2509
|
+
| \`useFetch\` | Declarative data fetching with loading/error states (use for external URLs; use TanStack Query for API calls) |
|
|
2510
|
+
| \`useAsync\` | Execute async functions with status tracking |
|
|
2511
|
+
| \`useScript\` | Dynamically load external scripts |
|
|
2512
|
+
| \`useWebSocket\` | WebSocket connection with auto-reconnect |
|
|
2513
|
+
| \`useSSE\` | Server-Sent Events (EventSource) wrapper |
|
|
2514
|
+
| \`usePolling\` | Poll an async function at a fixed interval |
|
|
2515
|
+
| \`useAbortController\` | Manage AbortController lifecycle |
|
|
2516
|
+
| \`useRetry\` | Retry async operations with exponential backoff |
|
|
2517
|
+
| \`useSearch\` | Filter arrays with debounced search |
|
|
2518
|
+
|
|
2519
|
+
### Browser APIs
|
|
2520
|
+
|
|
2521
|
+
| Hook | Description |
|
|
2522
|
+
|------|-------------|
|
|
2523
|
+
| \`useMediaQuery\` | Reactive CSS media query matching |
|
|
2524
|
+
| \`useColorScheme\` | Detect system color scheme preference |
|
|
2525
|
+
| \`useCopyToClipboard\` | Copy text to clipboard with status feedback |
|
|
2526
|
+
| \`useOnline\` | Track network connectivity |
|
|
2527
|
+
| \`useWindowSize\` | Track window dimensions |
|
|
2528
|
+
| \`usePageVisibility\` | Detect page visibility state |
|
|
2529
|
+
| \`usePageLeave\` | Detect when the user leaves the page |
|
|
2530
|
+
| \`useFavicon\` | Dynamically change the favicon |
|
|
2531
|
+
| \`useReducedMotion\` | Respect prefers-reduced-motion |
|
|
2532
|
+
| \`useBreakpoint\` | Responsive breakpoint detection |
|
|
2533
|
+
| \`useIsClient\` | SSR-safe client-side detection |
|
|
2534
|
+
|
|
2535
|
+
### Layout & UI
|
|
2536
|
+
|
|
2537
|
+
| Hook | Description |
|
|
2538
|
+
|------|-------------|
|
|
2539
|
+
| \`useStickyHeader\` | Detect when header should be sticky |
|
|
2540
|
+
| \`useVirtualList\` | Virtualized list rendering for large datasets |
|
|
2541
|
+
| \`useInfiniteScroll\` | Infinite scroll with threshold detection |
|
|
2542
|
+
| \`useCollapse\` | Collapse/expand animation with prop getters |
|
|
2543
|
+
| \`useSteps\` | Multi-step flow navigation |
|
|
2544
|
+
|
|
2545
|
+
### Common Patterns
|
|
2546
|
+
|
|
2547
|
+
**Modal with click-outside dismiss:**
|
|
2548
|
+
\`\`\`tsx
|
|
2549
|
+
import { useDisclosure, useClickOutside } from '@blacksmith-ui/hooks'
|
|
2550
|
+
|
|
2551
|
+
function MyComponent() {
|
|
2552
|
+
const [opened, { open, close }] = useDisclosure(false)
|
|
2553
|
+
const ref = useClickOutside<HTMLDivElement>(close)
|
|
2554
|
+
|
|
2555
|
+
return (
|
|
2556
|
+
<>
|
|
2557
|
+
<Button onClick={open}>Open</Button>
|
|
2558
|
+
{opened && <div ref={ref}>Modal content</div>}
|
|
2559
|
+
</>
|
|
2560
|
+
)
|
|
2561
|
+
}
|
|
2562
|
+
\`\`\`
|
|
2563
|
+
|
|
2564
|
+
**Debounced search:**
|
|
2565
|
+
\`\`\`tsx
|
|
2566
|
+
import { useDebounce, useSearch } from '@blacksmith-ui/hooks'
|
|
2567
|
+
|
|
2568
|
+
function SearchPage({ items }) {
|
|
2569
|
+
const [query, setQuery] = useState('')
|
|
2570
|
+
const debouncedQuery = useDebounce(query, 300)
|
|
2571
|
+
const results = useSearch(items, debouncedQuery, ['title', 'description'])
|
|
2572
|
+
|
|
2573
|
+
return (
|
|
2574
|
+
<>
|
|
2575
|
+
<Input value={query} onChange={(e) => setQuery(e.target.value)} />
|
|
2576
|
+
{results.map(item => <div key={item.id}>{item.title}</div>)}
|
|
2577
|
+
</>
|
|
2578
|
+
)
|
|
2579
|
+
}
|
|
2580
|
+
\`\`\`
|
|
2581
|
+
|
|
2582
|
+
**Persisted state with undo:**
|
|
2583
|
+
\`\`\`tsx
|
|
2584
|
+
import { useLocalStorage, useHistoryState } from '@blacksmith-ui/hooks'
|
|
2585
|
+
|
|
2586
|
+
function Editor() {
|
|
2587
|
+
const [saved, setSaved] = useLocalStorage('draft', '')
|
|
2588
|
+
const [content, { set, undo, redo, canUndo, canRedo }] = useHistoryState(saved)
|
|
2589
|
+
|
|
2590
|
+
const handleSave = () => setSaved(content)
|
|
2591
|
+
}
|
|
2592
|
+
\`\`\`
|
|
2593
|
+
|
|
2594
|
+
**Responsive layout:**
|
|
2595
|
+
\`\`\`tsx
|
|
2596
|
+
import { useBreakpoint, useWindowSize } from '@blacksmith-ui/hooks'
|
|
2597
|
+
|
|
2598
|
+
function Layout({ children }) {
|
|
2599
|
+
const breakpoint = useBreakpoint({ sm: 640, md: 768, lg: 1024 })
|
|
2600
|
+
const isMobile = breakpoint === 'sm'
|
|
2601
|
+
|
|
2602
|
+
return isMobile ? <MobileLayout>{children}</MobileLayout> : <DesktopLayout>{children}</DesktopLayout>
|
|
2603
|
+
}
|
|
2604
|
+
\`\`\`
|
|
2605
|
+
`;
|
|
2606
|
+
}
|
|
2607
|
+
};
|
|
2608
|
+
|
|
2609
|
+
// src/skills/blacksmith-cli.ts
|
|
2610
|
+
var blacksmithCliSkill = {
|
|
2611
|
+
id: "blacksmith-cli",
|
|
2612
|
+
name: "Blacksmith CLI",
|
|
2613
|
+
description: "CLI commands, configuration, and workflows for project scaffolding and management.",
|
|
2614
|
+
render(_ctx) {
|
|
2615
|
+
return `## Blacksmith CLI
|
|
2616
|
+
|
|
2617
|
+
Blacksmith is the CLI that scaffolded and manages this project. It lives outside the project directory as a globally installed npm package.
|
|
2618
|
+
|
|
2619
|
+
### Commands Reference
|
|
2620
|
+
|
|
2621
|
+
| Command | Description |
|
|
2622
|
+
|---|---|
|
|
2623
|
+
| \`blacksmith init [name]\` | Create a new project (interactive prompts if no flags) |
|
|
2624
|
+
| \`blacksmith dev\` | Start Django + Vite + OpenAPI watcher in parallel |
|
|
2625
|
+
| \`blacksmith sync\` | Regenerate frontend API client from Django OpenAPI schema |
|
|
2626
|
+
| \`blacksmith make:resource <Name>\` | Scaffold a full CRUD resource across backend and frontend |
|
|
2627
|
+
| \`blacksmith build\` | Production build (Vite build + Django collectstatic) |
|
|
2628
|
+
| \`blacksmith eject\` | Remove Blacksmith dependency, keep a clean project |
|
|
2629
|
+
| \`blacksmith setup:ai\` | Generate CLAUDE.md with AI development skills |
|
|
2630
|
+
| \`blacksmith skills\` | List all available AI development skills |
|
|
2631
|
+
|
|
2632
|
+
### Configuration
|
|
2633
|
+
|
|
2634
|
+
Project settings are stored in \`blacksmith.config.json\` at the project root:
|
|
2635
|
+
|
|
2636
|
+
\`\`\`json
|
|
2637
|
+
{
|
|
2638
|
+
"name": "my-app",
|
|
2639
|
+
"version": "0.1.0",
|
|
2640
|
+
"backend": { "port": 8000 },
|
|
2641
|
+
"frontend": { "port": 5173 }
|
|
2642
|
+
}
|
|
2643
|
+
\`\`\`
|
|
2644
|
+
|
|
2645
|
+
- **Ports** are read by \`blacksmith dev\` and \`blacksmith sync\` \u2014 change them here, not in code
|
|
2646
|
+
- The CLI finds the project root by walking up directories looking for this file
|
|
2647
|
+
|
|
2648
|
+
### How \`blacksmith dev\` Works
|
|
2649
|
+
|
|
2650
|
+
Runs three concurrent processes:
|
|
2651
|
+
1. **Django** \u2014 \`./venv/bin/python manage.py runserver 0.0.0.0:<backend-port>\`
|
|
2652
|
+
2. **Vite** \u2014 \`npm run dev\` in the frontend directory
|
|
2653
|
+
3. **OpenAPI watcher** \u2014 watches \`.py\` files in backend, runs \`npx openapi-ts\` on changes (2s debounce)
|
|
2654
|
+
|
|
2655
|
+
All three are managed by \`concurrently\` and stop together on Ctrl+C.
|
|
2656
|
+
|
|
2657
|
+
### How \`blacksmith make:resource\` Works
|
|
2658
|
+
|
|
2659
|
+
Given a PascalCase name (e.g. \`BlogPost\`), it generates:
|
|
2660
|
+
|
|
2661
|
+
**Backend:**
|
|
2662
|
+
- \`backend/apps/blog_posts/models.py\` \u2014 Django model with timestamps
|
|
2663
|
+
- \`backend/apps/blog_posts/serializers.py\` \u2014 DRF ModelSerializer
|
|
2664
|
+
- \`backend/apps/blog_posts/views.py\` \u2014 DRF ModelViewSet with drf-spectacular schemas
|
|
2665
|
+
- \`backend/apps/blog_posts/urls.py\` \u2014 DefaultRouter registration
|
|
2666
|
+
- \`backend/apps/blog_posts/admin.py\` \u2014 Admin registration
|
|
2667
|
+
- Wires the app into \`INSTALLED_APPS\` and \`config/urls.py\`
|
|
2668
|
+
- Runs \`makemigrations\` and \`migrate\`
|
|
2669
|
+
|
|
2670
|
+
**Frontend:**
|
|
2671
|
+
- \`frontend/src/features/blog-posts/\` \u2014 Feature module with hooks and components
|
|
2672
|
+
- \`frontend/src/pages/blog-posts/\` \u2014 List and detail pages
|
|
2673
|
+
- Registers route path in \`frontend/src/router/paths.ts\` (\`Path\` enum)
|
|
2674
|
+
- Registers routes in \`frontend/src/router/routes.tsx\`
|
|
2675
|
+
|
|
2676
|
+
Then runs \`blacksmith sync\` to generate the TypeScript API client.
|
|
2677
|
+
|
|
2678
|
+
### How \`blacksmith sync\` Works
|
|
2679
|
+
|
|
2680
|
+
1. Fetches the OpenAPI schema from \`http://localhost:<backend-port>/api/schema/\`
|
|
2681
|
+
2. Runs \`openapi-ts\` to generate TypeScript types, Zod schemas, SDK functions, and TanStack Query hooks
|
|
2682
|
+
3. Output goes to \`frontend/src/api/generated/\` \u2014 never edit these files manually
|
|
2683
|
+
|
|
2684
|
+
### Init Flags
|
|
2685
|
+
|
|
2686
|
+
\`blacksmith init\` supports both interactive prompts and CLI flags:
|
|
2687
|
+
|
|
2688
|
+
\`\`\`bash
|
|
2689
|
+
# Fully interactive
|
|
2690
|
+
blacksmith init
|
|
2691
|
+
|
|
2692
|
+
# Skip prompts with flags
|
|
2693
|
+
blacksmith init my-app -b 9000 -f 3000 --ai
|
|
2694
|
+
\`\`\`
|
|
2695
|
+
|
|
2696
|
+
| Flag | Description |
|
|
2697
|
+
|---|---|
|
|
2698
|
+
| \`-b, --backend-port <port>\` | Django port (default: 8000) |
|
|
2699
|
+
| \`-f, --frontend-port <port>\` | Vite port (default: 5173) |
|
|
2700
|
+
| \`--ai\` | Generate CLAUDE.md with project skills |
|
|
2701
|
+
| \`--no-blacksmith-ui-skill\` | Exclude blacksmith-ui skill from CLAUDE.md |
|
|
2702
|
+
`;
|
|
2703
|
+
}
|
|
2704
|
+
};
|
|
2705
|
+
|
|
2706
|
+
// src/skills/ui-design.ts
|
|
2707
|
+
var uiDesignSkill = {
|
|
2708
|
+
id: "ui-design",
|
|
2709
|
+
name: "UI/UX Design System",
|
|
2710
|
+
description: "Modern flat design principles, spacing, typography, color, layout patterns, and interaction guidelines aligned with the BlacksmithUI design language.",
|
|
2711
|
+
render(_ctx) {
|
|
2712
|
+
return `## UI/UX Design System \u2014 Modern Flat Design
|
|
2713
|
+
|
|
2714
|
+
> **Design philosophy: Clean, flat, content-first.**
|
|
2715
|
+
> BlacksmithUI follows the same design language as Anthropic, Apple, Linear, Vercel, and OpenAI \u2014 minimal chrome, generous whitespace, subtle depth, and purposeful motion. Every UI you build must conform to this standard.
|
|
2716
|
+
|
|
2717
|
+
### Core Principles
|
|
2718
|
+
|
|
2719
|
+
1. **Flat over skeuomorphic** \u2014 No gradients on surfaces, no heavy drop shadows, no bevels. Use solid colors, subtle borders, and minimal \`shadow-sm\` / \`shadow-md\` only where elevation is meaningful (cards, dropdowns, modals).
|
|
2720
|
+
2. **Content over decoration** \u2014 UI exists to present content, not to look busy. Remove any element that doesn't serve the user. If a section looks empty, the content is the problem \u2014 not the lack of decorative elements.
|
|
2721
|
+
3. **Whitespace is a feature** \u2014 Generous padding and margins create hierarchy and breathing room. Cramped UIs feel cheap. When in doubt, add more space.
|
|
2722
|
+
4. **Consistency over creativity** \u2014 Every page should feel like part of the same app. Use the same spacing scale, the same component patterns, the same interaction behaviors everywhere.
|
|
2723
|
+
5. **Progressive disclosure** \u2014 Show only what's needed at each level. Use expandable sections, tabs, dialogs, and drill-down navigation to manage complexity. Don't overwhelm with everything at once.
|
|
2724
|
+
|
|
2725
|
+
### Spacing System
|
|
2726
|
+
|
|
2727
|
+
Use Tailwind's spacing scale consistently. Do NOT use arbitrary values (\`p-[13px]\`) \u2014 stick to the system.
|
|
2728
|
+
|
|
2729
|
+
| Scale | Value | Use for |
|
|
2730
|
+
|-------|-------|---------|
|
|
2731
|
+
| \`1\`\u2013\`2\` | 4\u20138px | Inline gaps, icon-to-text spacing, tight badge padding |
|
|
2732
|
+
| \`3\`\u2013\`4\` | 12\u201316px | Inner component padding, gap between related items |
|
|
2733
|
+
| \`5\`\u2013\`6\` | 20\u201324px | Card padding, section inner spacing |
|
|
2734
|
+
| \`8\` | 32px | Gap between sections within a page |
|
|
2735
|
+
| \`10\`\u2013\`12\` | 40\u201348px | Gap between major page sections |
|
|
2736
|
+
| \`16\`\u2013\`20\` | 64\u201380px | Page-level vertical padding (hero, landing sections) |
|
|
2737
|
+
|
|
2738
|
+
**Rules:**
|
|
2739
|
+
- Use \`gap\` (via \`Flex\`, \`Stack\`, \`Grid\`) for spacing between siblings \u2014 not margin on individual items
|
|
2740
|
+
- Use \`Stack gap={...}\` for vertical rhythm within a section
|
|
2741
|
+
- Page content padding: \`px-4 sm:px-6 lg:px-8\` (use \`Container\` which handles this)
|
|
2742
|
+
- Card body padding: \`p-6\` standard, \`p-4\` for compact cards
|
|
2743
|
+
- Never mix spacing approaches in the same context \u2014 pick gap OR margin, not both
|
|
2744
|
+
|
|
2745
|
+
### Typography
|
|
2746
|
+
|
|
2747
|
+
Use \`Typography\` and \`Text\` components from \`@blacksmith-ui/react\`. Do NOT style raw HTML headings.
|
|
2748
|
+
|
|
2749
|
+
**Hierarchy:**
|
|
2750
|
+
| Level | Component | Use for |
|
|
2751
|
+
|-------|-----------|---------|
|
|
2752
|
+
| Page title | \`<Typography variant="h1">\` | One per page. The main heading. |
|
|
2753
|
+
| Section title | \`<Typography variant="h2">\` | Major sections within a page |
|
|
2754
|
+
| Sub-section | \`<Typography variant="h3">\` | Groups within a section |
|
|
2755
|
+
| Card title | \`<Typography variant="h4">\` or \`CardTitle\` | Card headings |
|
|
2756
|
+
| Body | \`<Text>\` | Paragraphs, descriptions |
|
|
2757
|
+
| Caption/label | \`<Text size="sm" color="muted">\` | Secondary info, metadata, timestamps |
|
|
2758
|
+
| Overline | \`<Text size="xs" weight="medium" className="uppercase tracking-wide">\` | Category labels, section overlines |
|
|
2759
|
+
|
|
2760
|
+
**Rules:**
|
|
2761
|
+
- One \`h1\` per page \u2014 it's the page title
|
|
2762
|
+
- Headings should never skip levels (h1 \u2192 h3 without h2)
|
|
2763
|
+
- Body text: \`text-sm\` (14px) for dense UIs (tables, sidebars), \`text-base\` (16px) for reading content
|
|
2764
|
+
- Line height: use Tailwind defaults (\`leading-relaxed\` for body copy, \`leading-tight\` for headings)
|
|
2765
|
+
- Max reading width: \`max-w-prose\` (~65ch) for long-form text. Never let paragraphs stretch full-width
|
|
2766
|
+
- Use \`text-muted-foreground\` for secondary text, never gray hardcoded values
|
|
2767
|
+
- Font weight: \`font-medium\` (500) for labels and emphasis, \`font-semibold\` (600) for headings, \`font-bold\` (700) sparingly
|
|
2768
|
+
|
|
2769
|
+
### Color
|
|
2770
|
+
|
|
2771
|
+
Use design tokens (CSS variables), never hardcoded colors.
|
|
2772
|
+
|
|
2773
|
+
**Semantic palette:**
|
|
2774
|
+
| Token | Usage |
|
|
2775
|
+
|-------|-------|
|
|
2776
|
+
| \`primary\` | Primary actions (buttons, links, active states) |
|
|
2777
|
+
| \`secondary\` | Secondary actions, subtle backgrounds |
|
|
2778
|
+
| \`destructive\` | Delete, error, danger states |
|
|
2779
|
+
| \`muted\` | Backgrounds for subtle sections, disabled states |
|
|
2780
|
+
| \`accent\` | Highlights, hover states, focus rings |
|
|
2781
|
+
| \`foreground\` | Primary text |
|
|
2782
|
+
| \`muted-foreground\` | Secondary/helper text |
|
|
2783
|
+
| \`border\` | Borders, dividers |
|
|
2784
|
+
| \`card\` | Card backgrounds |
|
|
2785
|
+
| \`background\` | Page background |
|
|
2786
|
+
|
|
2787
|
+
**Rules:**
|
|
2788
|
+
- NEVER use Tailwind color literals (\`text-gray-500\`, \`bg-blue-600\`, \`border-slate-200\`, \`bg-white\`, \`bg-black\`). Always use semantic tokens (\`text-muted-foreground\`, \`bg-primary\`, \`border-border\`, \`bg-background\`). This is non-negotiable \u2014 hardcoded colors break dark mode.
|
|
2789
|
+
- Status colors: use \`Badge\` variants (\`default\`, \`secondary\`, \`destructive\`, \`outline\`) \u2014 don't hand-roll colored pills.
|
|
2790
|
+
- Maximum 2\u20133 colors visible at any time (primary + foreground + muted). Colorful UIs feel noisy.
|
|
2791
|
+
- Every UI must render correctly in both light and dark mode. See the Dark Mode section below for the full rules.
|
|
2792
|
+
|
|
2793
|
+
### Layout Patterns
|
|
2794
|
+
|
|
2795
|
+
**Page layout:**
|
|
2796
|
+
\`\`\`tsx
|
|
2797
|
+
<Box as="main">
|
|
2798
|
+
<Container>
|
|
2799
|
+
<Stack gap={8}>
|
|
2800
|
+
{/* Page header */}
|
|
2801
|
+
<Flex align="center" justify="between">
|
|
2802
|
+
<Stack gap={1}>
|
|
2803
|
+
<Typography variant="h1">Page Title</Typography>
|
|
2804
|
+
<Text color="muted">Brief description of this page</Text>
|
|
2805
|
+
</Stack>
|
|
2806
|
+
<Button>Primary Action</Button>
|
|
2807
|
+
</Flex>
|
|
2808
|
+
|
|
2809
|
+
{/* Page content sections */}
|
|
2810
|
+
<Stack gap={6}>
|
|
2811
|
+
{/* ... */}
|
|
2812
|
+
</Stack>
|
|
2813
|
+
</Stack>
|
|
2814
|
+
</Container>
|
|
2815
|
+
</Box>
|
|
2816
|
+
\`\`\`
|
|
2817
|
+
|
|
2818
|
+
**Card-based content:**
|
|
2819
|
+
\`\`\`tsx
|
|
2820
|
+
<Grid columns={{ base: 1, md: 2, lg: 3 }} gap={6}>
|
|
2821
|
+
{items.map((item) => (
|
|
2822
|
+
<Card key={item.id}>
|
|
2823
|
+
<CardHeader>
|
|
2824
|
+
<CardTitle>{item.title}</CardTitle>
|
|
2825
|
+
<CardDescription>{item.description}</CardDescription>
|
|
2826
|
+
</CardHeader>
|
|
2827
|
+
<CardContent>
|
|
2828
|
+
{/* Content */}
|
|
2829
|
+
</CardContent>
|
|
2830
|
+
</Card>
|
|
2831
|
+
))}
|
|
2832
|
+
</Grid>
|
|
2833
|
+
\`\`\`
|
|
2834
|
+
|
|
2835
|
+
**Sidebar + main content:**
|
|
2836
|
+
\`\`\`tsx
|
|
2837
|
+
<Flex className="min-h-screen">
|
|
2838
|
+
<Sidebar>{/* Nav items */}</Sidebar>
|
|
2839
|
+
<Box as="main" className="flex-1">
|
|
2840
|
+
<Container>{/* Page content */}</Container>
|
|
2841
|
+
</Box>
|
|
2842
|
+
</Flex>
|
|
2843
|
+
\`\`\`
|
|
2844
|
+
|
|
2845
|
+
**Section with centered content (landing pages):**
|
|
2846
|
+
\`\`\`tsx
|
|
2847
|
+
<Box as="section" className="py-16 sm:py-20">
|
|
2848
|
+
<Container>
|
|
2849
|
+
<Stack gap={4} align="center" className="text-center">
|
|
2850
|
+
<Typography variant="h2">Section Title</Typography>
|
|
2851
|
+
<Text color="muted" className="max-w-2xl">
|
|
2852
|
+
A concise description that explains the value proposition.
|
|
2853
|
+
</Text>
|
|
2854
|
+
</Stack>
|
|
2855
|
+
<Grid columns={{ base: 1, md: 3 }} gap={8} className="mt-12">
|
|
2856
|
+
{/* Feature cards or content */}
|
|
2857
|
+
</Grid>
|
|
2858
|
+
</Container>
|
|
2859
|
+
</Box>
|
|
2860
|
+
\`\`\`
|
|
2861
|
+
|
|
2862
|
+
### Component Patterns
|
|
2863
|
+
|
|
2864
|
+
**Empty states:**
|
|
2865
|
+
\`\`\`tsx
|
|
2866
|
+
// GOOD \u2014 uses EmptyState component
|
|
2867
|
+
<EmptyState
|
|
2868
|
+
icon={Inbox}
|
|
2869
|
+
title="No messages yet"
|
|
2870
|
+
description="Messages from your team will appear here."
|
|
2871
|
+
action={<Button>Send a message</Button>}
|
|
2872
|
+
/>
|
|
2873
|
+
|
|
2874
|
+
// BAD \u2014 hand-rolled empty state
|
|
2875
|
+
<div className="flex flex-col items-center justify-center py-12 text-center">
|
|
2876
|
+
<Inbox className="h-12 w-12 text-gray-400 mb-4" />
|
|
2877
|
+
<h3 className="text-lg font-medium">No messages yet</h3>
|
|
2878
|
+
<p className="text-gray-500 mt-1">Messages from your team will appear here.</p>
|
|
2879
|
+
</div>
|
|
2880
|
+
\`\`\`
|
|
2881
|
+
|
|
2882
|
+
**Stats/metrics:**
|
|
2883
|
+
\`\`\`tsx
|
|
2884
|
+
// GOOD \u2014 uses StatCard
|
|
2885
|
+
<Grid columns={{ base: 1, sm: 2, lg: 4 }} gap={4}>
|
|
2886
|
+
<StatCard label="Total Users" value="2,847" trend="+12%" />
|
|
2887
|
+
<StatCard label="Revenue" value="$48,290" trend="+8%" />
|
|
2888
|
+
</Grid>
|
|
2889
|
+
|
|
2890
|
+
// BAD \u2014 hand-rolled stat cards
|
|
2891
|
+
<div className="grid grid-cols-4 gap-4">
|
|
2892
|
+
<div className="bg-white rounded-lg p-6 shadow">
|
|
2893
|
+
<p className="text-sm text-gray-500">Total Users</p>
|
|
2894
|
+
<p className="text-2xl font-bold">2,847</p>
|
|
2895
|
+
</div>
|
|
2896
|
+
</div>
|
|
2897
|
+
\`\`\`
|
|
2898
|
+
|
|
2899
|
+
**Loading states:**
|
|
2900
|
+
\`\`\`tsx
|
|
2901
|
+
// GOOD \u2014 Skeleton matches the layout structure
|
|
2902
|
+
<Stack gap={4}>
|
|
2903
|
+
<Skeleton className="h-8 w-48" /> {/* Title */}
|
|
2904
|
+
<Skeleton className="h-4 w-96" /> {/* Description */}
|
|
2905
|
+
<Grid columns={3} gap={4}>
|
|
2906
|
+
{Array.from({ length: 3 }).map((_, i) => (
|
|
2907
|
+
<Skeleton key={i} className="h-32" />
|
|
2908
|
+
))}
|
|
2909
|
+
</Grid>
|
|
2910
|
+
</Stack>
|
|
2911
|
+
|
|
2912
|
+
// BAD \u2014 generic spinner with no layout hint
|
|
2913
|
+
<div className="flex justify-center py-12">
|
|
2914
|
+
<div className="animate-spin h-8 w-8 border-2 border-blue-500 rounded-full" />
|
|
2915
|
+
</div>
|
|
2916
|
+
\`\`\`
|
|
2917
|
+
|
|
2918
|
+
### Dark Mode & Light Mode
|
|
2919
|
+
|
|
2920
|
+
> **CRITICAL: Every screen, component, and custom style MUST look correct in both light and dark mode. No exceptions.**
|
|
2921
|
+
|
|
2922
|
+
BlacksmithUI uses the \`.dark\` class strategy on \`<html>\`. All semantic CSS variables automatically switch between light and dark values. Your job is to never break this.
|
|
2923
|
+
|
|
2924
|
+
**Rules:**
|
|
2925
|
+
- NEVER hardcode colors. \`text-gray-500\`, \`bg-white\`, \`bg-slate-900\`, \`border-gray-200\` \u2014 all of these break in one mode or the other. Use semantic tokens: \`text-muted-foreground\`, \`bg-background\`, \`bg-card\`, \`border-border\`.
|
|
2926
|
+
- NEVER use \`bg-white\` or \`bg-black\`. Use \`bg-background\` (page), \`bg-card\` (elevated surfaces), \`bg-muted\` (subtle sections).
|
|
2927
|
+
- NEVER use \`text-black\` or \`text-white\`. Use \`text-foreground\` (primary text), \`text-muted-foreground\` (secondary), \`text-primary-foreground\` (text on primary-colored backgrounds).
|
|
2928
|
+
- NEVER use hardcoded shadows like \`shadow-[0_2px_8px_rgba(0,0,0,0.1)]\`. Use Tailwind shadow utilities (\`shadow-sm\`, \`shadow-md\`) which respect the theme.
|
|
2929
|
+
- NEVER use opacity-based overlays with hardcoded colors (\`bg-black/50\`). Use \`bg-background/80\` or let overlay components (\`Dialog\`, \`Sheet\`) handle it.
|
|
2930
|
+
- SVG fills and strokes: use \`currentColor\` or \`fill-foreground\` / \`stroke-border\` \u2014 never \`fill-black\` or \`stroke-gray-300\`.
|
|
2931
|
+
- Image assets: if you use decorative images or illustrations, ensure they work on both backgrounds or use \`dark:hidden\` / \`hidden dark:block\` to swap variants.
|
|
2932
|
+
|
|
2933
|
+
**Safe color tokens (always use these):**
|
|
2934
|
+
| Need | Light mode maps to | Dark mode maps to | Use |
|
|
2935
|
+
|------|----|----|-----|
|
|
2936
|
+
| Page background | white/light gray | near-black | \`bg-background\` |
|
|
2937
|
+
| Card/surface | white | dark gray | \`bg-card\` |
|
|
2938
|
+
| Subtle background | light gray | darker gray | \`bg-muted\` |
|
|
2939
|
+
| Primary text | near-black | near-white | \`text-foreground\` |
|
|
2940
|
+
| Secondary text | medium gray | lighter gray | \`text-muted-foreground\` |
|
|
2941
|
+
| Borders | light gray | dark gray | \`border-border\` |
|
|
2942
|
+
| Input borders | light gray | dark gray | \`border-input\` |
|
|
2943
|
+
| Focus ring | brand color | brand color | \`ring-ring\` |
|
|
2944
|
+
| Primary action | brand color | brand color | \`bg-primary text-primary-foreground\` |
|
|
2945
|
+
| Destructive | red | red | \`bg-destructive text-destructive-foreground\` |
|
|
2946
|
+
|
|
2947
|
+
**Testing checklist (mental model):**
|
|
2948
|
+
Before considering any UI complete, verify these in your head:
|
|
2949
|
+
1. Does every text element use \`foreground\`, \`muted-foreground\`, or \`*-foreground\` tokens?
|
|
2950
|
+
2. Does every background use \`background\`, \`card\`, \`muted\`, or \`primary\`/\`secondary\`/\`accent\` tokens?
|
|
2951
|
+
3. Does every border use \`border\`, \`input\`, or \`ring\` tokens?
|
|
2952
|
+
4. Are there ANY hex values, rgb values, or Tailwind color names (gray, slate, blue, etc.) in the code? If yes, replace them.
|
|
2953
|
+
5. Do hover/focus/active states also use semantic tokens? (\`hover:bg-muted\` not \`hover:bg-gray-100\`)
|
|
2954
|
+
|
|
2955
|
+
### Interactions & Feedback
|
|
2956
|
+
|
|
2957
|
+
- **Hover states**: Subtle background change (\`hover:bg-muted\`) \u2014 not color shifts or scale transforms
|
|
2958
|
+
- **Focus**: Use focus-visible ring (\`focus-visible:ring-2 ring-ring\`). BlacksmithUI components handle this automatically
|
|
2959
|
+
- **Transitions**: \`transition-colors duration-150\` for color changes. No bounces, no springs, no dramatic animations
|
|
2960
|
+
- **Click feedback**: Use \`active:scale-[0.98]\` only on buttons and interactive cards, never on text or static elements
|
|
2961
|
+
- **Loading feedback**: Show \`Spinner\` on buttons during async actions. Use \`Skeleton\` for content areas. Never leave the user without feedback during loading
|
|
2962
|
+
- **Success/error feedback**: Use \`useToast()\` for transient confirmations. Use \`Alert\` for persistent messages. Never use \`window.alert()\`
|
|
2963
|
+
- **Confirmation before destructive actions**: Always use \`AlertDialog\` for delete/remove actions. Never delete on single click
|
|
2964
|
+
|
|
2965
|
+
### Responsive Design
|
|
2966
|
+
|
|
2967
|
+
- **Mobile-first**: Write base styles for mobile, add \`sm:\`/\`md:\`/\`lg:\` for larger screens
|
|
2968
|
+
- **Breakpoints**: \`sm\` (640px), \`md\` (768px), \`lg\` (1024px), \`xl\` (1280px)
|
|
2969
|
+
- **Grid collapse**: \`Grid columns={{ base: 1, md: 2, lg: 3 }}\` \u2014 single column on mobile, expand on larger screens
|
|
2970
|
+
- **Hide/show**: Use \`hidden md:block\` / \`md:hidden\` to toggle elements across breakpoints
|
|
2971
|
+
- **Touch targets**: Minimum 44\xD744px for interactive elements on mobile. Use \`Button size="lg"\` and adequate padding
|
|
2972
|
+
- **Stack on mobile, row on desktop**: Use \`Flex direction={{ base: 'column', md: 'row' }}\` or \`Stack\` that switches direction
|
|
2973
|
+
- **Container**: Always wrap page content in \`<Container>\` \u2014 it handles responsive horizontal padding
|
|
2974
|
+
|
|
2975
|
+
### Anti-Patterns \u2014 NEVER Do These
|
|
2976
|
+
|
|
2977
|
+
| Anti-pattern | What to do instead |
|
|
2978
|
+
|---|---|
|
|
2979
|
+
| Hardcoded colors (\`text-gray-500\`, \`bg-blue-600\`) | Use semantic tokens (\`text-muted-foreground\`, \`bg-primary\`) |
|
|
2980
|
+
| Heavy box shadows (\`shadow-xl\`, \`shadow-2xl\`) | Use \`shadow-sm\` on cards, \`shadow-md\` on elevated overlays only |
|
|
2981
|
+
| Rounded pill shapes (\`rounded-full\`) on cards/containers | Use \`rounded-lg\` or \`rounded-md\` (controlled by \`--radius\`) |
|
|
2982
|
+
| Gradient backgrounds on surfaces | Use solid \`bg-card\` or \`bg-background\` |
|
|
2983
|
+
| Decorative borders (\`border-l-4 border-blue-500\`) | Use \`Divider\` or \`border-border\` |
|
|
2984
|
+
| Custom scrollbars with CSS hacks | Use \`ScrollArea\` |
|
|
2985
|
+
| Animated entrances (fade-in, slide-up on mount) | Content should appear instantly. Only animate user-triggered changes |
|
|
2986
|
+
| Centering with \`absolute inset-0 flex items-center\` | Use \`Flex align="center" justify="center"\` |
|
|
2987
|
+
| Using \`<br />\` for spacing | Use \`Stack gap={...}\` or margin utilities |
|
|
2988
|
+
| Multiple font sizes in close proximity | Keep nearby text within 1\u20132 size steps |
|
|
2989
|
+
| Dense walls of text | Break into sections with headings, cards, or spacing |
|
|
2990
|
+
| Colored backgrounds on every section | Use \`bg-background\` as default, \`bg-muted\` sparingly for contrast |
|
|
2991
|
+
| Over-using badges/tags on everything | Badges are for status and categories, not decoration |
|
|
2992
|
+
| Inline styles (\`style={{ ... }}\`) | Use Tailwind classes via \`className\` |
|
|
2993
|
+
| \`bg-white\` / \`bg-black\` / \`bg-slate-*\` | Use \`bg-background\`, \`bg-card\`, \`bg-muted\` |
|
|
2994
|
+
| \`text-black\` / \`text-white\` / \`text-gray-*\` | Use \`text-foreground\`, \`text-muted-foreground\` |
|
|
2995
|
+
| \`border-gray-*\` / \`border-slate-*\` | Use \`border-border\`, \`border-input\` |
|
|
2996
|
+
| Hex/rgb values in className or style | Use CSS variable tokens exclusively |
|
|
2997
|
+
| UI that only looks right in light mode | Always verify both modes \u2014 use semantic tokens throughout |
|
|
2998
|
+
`;
|
|
2999
|
+
}
|
|
3000
|
+
};
|
|
3001
|
+
|
|
3002
|
+
// src/skills/programming-paradigms.ts
|
|
3003
|
+
var programmingParadigmsSkill = {
|
|
3004
|
+
id: "programming-paradigms",
|
|
3005
|
+
name: "Programming Paradigms",
|
|
3006
|
+
description: "Functional programming for React frontend development, OOP + functional patterns for Django backend development.",
|
|
3007
|
+
render(_ctx) {
|
|
3008
|
+
return `## Programming Paradigms
|
|
3009
|
+
|
|
3010
|
+
> **Frontend (React/TypeScript): Functional programming.** Pure functions, immutability, composition, declarative UI.
|
|
3011
|
+
> **Backend (Django/Python): OOP with functional touches.** Classes for structure, pure functions for logic, no mutation where avoidable.
|
|
3012
|
+
|
|
3013
|
+
---
|
|
3014
|
+
|
|
3015
|
+
## Frontend \u2014 Functional Programming
|
|
3016
|
+
|
|
3017
|
+
React is a functional framework. Write it functionally. No classes, no imperative mutation, no object-oriented patterns.
|
|
3018
|
+
|
|
3019
|
+
### Core Rules
|
|
3020
|
+
|
|
3021
|
+
1. **Functions, not classes** \u2014 Every component is a function. Every hook is a function. Every utility is a function. Never use \`class\` in frontend code.
|
|
3022
|
+
2. **Pure by default** \u2014 A component given the same props should render the same output. A utility given the same arguments should return the same result. Side effects belong in hooks (\`useEffect\`, \`useMutation\`), never in render logic.
|
|
3023
|
+
3. **Immutable data** \u2014 Never mutate state, props, or variables. Always return new values.
|
|
3024
|
+
4. **Declarative over imperative** \u2014 Describe *what* to render, not *how* to render it. Use \`map\`, \`filter\`, ternaries, and composition \u2014 not \`for\` loops, \`if/else\` chains, or DOM manipulation.
|
|
3025
|
+
5. **Composition over inheritance** \u2014 Build complex behavior by composing small functions and components, not by extending base classes.
|
|
3026
|
+
|
|
3027
|
+
### Immutability
|
|
3028
|
+
|
|
3029
|
+
\`\`\`tsx
|
|
3030
|
+
// BAD \u2014 mutation
|
|
3031
|
+
const handleAdd = (item) => {
|
|
3032
|
+
items.push(item) // mutates array
|
|
3033
|
+
setItems(items) // React won't re-render (same reference)
|
|
3034
|
+
}
|
|
3035
|
+
|
|
3036
|
+
user.name = 'New Name' // mutates object
|
|
3037
|
+
setUser(user)
|
|
3038
|
+
|
|
3039
|
+
// GOOD \u2014 immutable updates
|
|
3040
|
+
const handleAdd = (item) => {
|
|
3041
|
+
setItems((prev) => [...prev, item])
|
|
3042
|
+
}
|
|
3043
|
+
|
|
3044
|
+
setUser((prev) => ({ ...prev, name: 'New Name' }))
|
|
3045
|
+
|
|
3046
|
+
// GOOD \u2014 immutable array operations
|
|
3047
|
+
const removeItem = (id) => setItems((prev) => prev.filter((i) => i.id !== id))
|
|
3048
|
+
const updateItem = (id, data) => setItems((prev) =>
|
|
3049
|
+
prev.map((i) => (i.id === id ? { ...i, ...data } : i))
|
|
3050
|
+
)
|
|
3051
|
+
\`\`\`
|
|
3052
|
+
|
|
3053
|
+
### Pure Functions & Composition
|
|
3054
|
+
|
|
3055
|
+
\`\`\`tsx
|
|
3056
|
+
// BAD \u2014 impure, relies on external state
|
|
3057
|
+
let taxRate = 0.1
|
|
3058
|
+
const calculateTotal = (price) => price * (1 + taxRate)
|
|
3059
|
+
|
|
3060
|
+
// GOOD \u2014 pure, all inputs explicit
|
|
3061
|
+
const calculateTotal = (price: number, taxRate: number) => price * (1 + taxRate)
|
|
3062
|
+
|
|
3063
|
+
// GOOD \u2014 compose small functions
|
|
3064
|
+
const formatCurrency = (amount: number) => \`$\${amount.toFixed(2)}\`
|
|
3065
|
+
const calculateTax = (price: number, rate: number) => price * rate
|
|
3066
|
+
const formatPriceWithTax = (price: number, rate: number) =>
|
|
3067
|
+
formatCurrency(price + calculateTax(price, rate))
|
|
3068
|
+
\`\`\`
|
|
3069
|
+
|
|
3070
|
+
### Declarative UI Patterns
|
|
3071
|
+
|
|
3072
|
+
\`\`\`tsx
|
|
3073
|
+
// BAD \u2014 imperative rendering
|
|
3074
|
+
function UserList({ users }) {
|
|
3075
|
+
const items = []
|
|
3076
|
+
for (let i = 0; i < users.length; i++) {
|
|
3077
|
+
if (users[i].isActive) {
|
|
3078
|
+
items.push(<UserCard key={users[i].id} user={users[i]} />)
|
|
3079
|
+
}
|
|
3080
|
+
}
|
|
3081
|
+
return <div>{items}</div>
|
|
3082
|
+
}
|
|
3083
|
+
|
|
3084
|
+
// GOOD \u2014 declarative rendering
|
|
3085
|
+
function UserList({ users }) {
|
|
3086
|
+
return (
|
|
3087
|
+
<Stack gap={4}>
|
|
3088
|
+
{users
|
|
3089
|
+
.filter((user) => user.isActive)
|
|
3090
|
+
.map((user) => <UserCard key={user.id} user={user} />)
|
|
3091
|
+
}
|
|
3092
|
+
</Stack>
|
|
3093
|
+
)
|
|
3094
|
+
}
|
|
3095
|
+
|
|
3096
|
+
// BAD \u2014 imperative conditional
|
|
3097
|
+
function Status({ isOnline }) {
|
|
3098
|
+
let badge
|
|
3099
|
+
if (isOnline) {
|
|
3100
|
+
badge = <Badge>Online</Badge>
|
|
3101
|
+
} else {
|
|
3102
|
+
badge = <Badge variant="secondary">Offline</Badge>
|
|
3103
|
+
}
|
|
3104
|
+
return badge
|
|
3105
|
+
}
|
|
3106
|
+
|
|
3107
|
+
// GOOD \u2014 declarative conditional
|
|
3108
|
+
function Status({ isOnline }) {
|
|
3109
|
+
return isOnline
|
|
3110
|
+
? <Badge>Online</Badge>
|
|
3111
|
+
: <Badge variant="secondary">Offline</Badge>
|
|
3112
|
+
}
|
|
3113
|
+
\`\`\`
|
|
3114
|
+
|
|
3115
|
+
### Hooks as Functional Composition
|
|
3116
|
+
|
|
3117
|
+
\`\`\`tsx
|
|
3118
|
+
// BAD \u2014 logic in component body
|
|
3119
|
+
function OrdersPage() {
|
|
3120
|
+
const [page, setPage] = useState(1)
|
|
3121
|
+
const [search, setSearch] = useState('')
|
|
3122
|
+
const debounced = useDebounce(search, 300)
|
|
3123
|
+
const { data } = useApiQuery(ordersListOptions({ query: { page, search: debounced } }))
|
|
3124
|
+
const deleteOrder = useApiMutation(ordersDestroyMutation())
|
|
3125
|
+
|
|
3126
|
+
// ... 20 lines of derived state and handlers
|
|
3127
|
+
|
|
3128
|
+
return ( /* JSX */ )
|
|
3129
|
+
}
|
|
3130
|
+
|
|
3131
|
+
// GOOD \u2014 compose hooks, component just renders
|
|
3132
|
+
function OrdersPage() {
|
|
3133
|
+
const { orders, pagination, search, deleteOrder } = useOrdersPage()
|
|
3134
|
+
|
|
3135
|
+
return (
|
|
3136
|
+
<Stack gap={4}>
|
|
3137
|
+
<OrdersToolbar search={search} />
|
|
3138
|
+
<OrdersTable orders={orders} onDelete={deleteOrder} />
|
|
3139
|
+
<Pagination {...pagination} />
|
|
3140
|
+
</Stack>
|
|
3141
|
+
)
|
|
3142
|
+
}
|
|
3143
|
+
|
|
3144
|
+
// The hook composes smaller hooks
|
|
3145
|
+
function useOrdersPage() {
|
|
3146
|
+
const search = useSearchFilter()
|
|
3147
|
+
const pagination = usePagination()
|
|
3148
|
+
const { data } = useOrders({ page: pagination.page, search: search.debounced })
|
|
3149
|
+
const deleteOrder = useDeleteOrder()
|
|
3150
|
+
|
|
3151
|
+
return {
|
|
3152
|
+
orders: data?.results ?? [],
|
|
3153
|
+
pagination: { ...pagination, total: data?.count ?? 0 },
|
|
3154
|
+
search,
|
|
3155
|
+
deleteOrder: (id: number) => deleteOrder.mutate({ path: { id } }),
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
\`\`\`
|
|
3159
|
+
|
|
3160
|
+
### Data Transformation \u2014 Functional Style
|
|
3161
|
+
|
|
3162
|
+
\`\`\`tsx
|
|
3163
|
+
// BAD \u2014 imperative transformation
|
|
3164
|
+
function getActiveUserNames(users) {
|
|
3165
|
+
const result = []
|
|
3166
|
+
for (const user of users) {
|
|
3167
|
+
if (user.isActive) {
|
|
3168
|
+
result.push(user.name.toUpperCase())
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
return result
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
// GOOD \u2014 functional pipeline
|
|
3175
|
+
const getActiveUserNames = (users: User[]) =>
|
|
3176
|
+
users
|
|
3177
|
+
.filter((u) => u.isActive)
|
|
3178
|
+
.map((u) => u.name.toUpperCase())
|
|
3179
|
+
|
|
3180
|
+
// GOOD \u2014 derive state without mutation
|
|
3181
|
+
const sortedItems = useMemo(
|
|
3182
|
+
() => [...items].sort((a, b) => a.name.localeCompare(b.name)),
|
|
3183
|
+
[items]
|
|
3184
|
+
)
|
|
3185
|
+
|
|
3186
|
+
const groupedByStatus = useMemo(
|
|
3187
|
+
() => items.reduce<Record<string, Item[]>>((acc, item) => ({
|
|
3188
|
+
...acc,
|
|
3189
|
+
[item.status]: [...(acc[item.status] ?? []), item],
|
|
3190
|
+
}), {}),
|
|
3191
|
+
[items]
|
|
3192
|
+
)
|
|
3193
|
+
\`\`\`
|
|
3194
|
+
|
|
3195
|
+
### What to Avoid in Frontend Code
|
|
3196
|
+
|
|
3197
|
+
| Anti-pattern | Functional alternative |
|
|
3198
|
+
|---|---|
|
|
3199
|
+
| \`class MyComponent extends React.Component\` | \`function MyComponent()\` |
|
|
3200
|
+
| \`this.state\`, \`this.setState\` | \`useState\`, \`useReducer\` |
|
|
3201
|
+
| \`array.push()\`, \`object.key = value\` | Spread: \`[...arr, item]\`, \`{ ...obj, key: value }\` |
|
|
3202
|
+
| \`for\` / \`while\` loops in render | \`.map()\`, \`.filter()\`, \`.reduce()\` |
|
|
3203
|
+
| \`let\` for derived values | \`const\` + \`useMemo\` or inline computation |
|
|
3204
|
+
| Mutable ref for state (\`useRef\` to track values) | \`useState\` or \`useReducer\` |
|
|
3205
|
+
| HOCs (\`withAuth\`, \`withTheme\`) | Custom hooks (\`useAuth\`, \`useTheme\`) |
|
|
3206
|
+
| Render props for logic sharing | Custom hooks |
|
|
3207
|
+
| \`if/else\` chains for rendering | Ternaries, \`&&\`, early returns, lookup objects |
|
|
3208
|
+
| Singleton services / global mutable state | Context + hooks, React Query for server state |
|
|
3209
|
+
|
|
3210
|
+
---
|
|
3211
|
+
|
|
3212
|
+
## Backend \u2014 OOP with Functional Patterns
|
|
3213
|
+
|
|
3214
|
+
Django is object-oriented by design. Lean into it for structure (models, views, serializers, services), but use functional patterns for pure logic and data transformations.
|
|
3215
|
+
|
|
3216
|
+
### OOP for Structure
|
|
3217
|
+
|
|
3218
|
+
**Models** \u2014 Encapsulate data and behavior together:
|
|
3219
|
+
\`\`\`python
|
|
3220
|
+
class Order(TimeStampedModel):
|
|
3221
|
+
user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='orders')
|
|
3222
|
+
status = models.CharField(max_length=20, choices=STATUS_CHOICES, default='pending')
|
|
3223
|
+
total = models.DecimalField(max_digits=10, decimal_places=2, default=0)
|
|
3224
|
+
|
|
3225
|
+
class Meta:
|
|
3226
|
+
ordering = ['-created_at']
|
|
3227
|
+
|
|
3228
|
+
def __str__(self):
|
|
3229
|
+
return f"Order #{self.pk} \u2014 {self.user}"
|
|
3230
|
+
|
|
3231
|
+
@property
|
|
3232
|
+
def is_cancellable(self):
|
|
3233
|
+
return self.status in ('pending', 'confirmed')
|
|
3234
|
+
|
|
3235
|
+
def recalculate_total(self):
|
|
3236
|
+
self.total = self.items.aggregate(
|
|
3237
|
+
total=Sum(F('quantity') * F('unit_price'))
|
|
3238
|
+
)['total'] or 0
|
|
3239
|
+
self.save(update_fields=['total'])
|
|
3240
|
+
\`\`\`
|
|
3241
|
+
|
|
3242
|
+
**Services** \u2014 Classes for complex business operations with multiple related methods:
|
|
3243
|
+
\`\`\`python
|
|
3244
|
+
class OrderService:
|
|
3245
|
+
@staticmethod
|
|
3246
|
+
@transaction.atomic
|
|
3247
|
+
def place_order(*, user, items, shipping_address):
|
|
3248
|
+
order = Order.objects.create(user=user, shipping_address=shipping_address)
|
|
3249
|
+
for item_data in items:
|
|
3250
|
+
OrderItem.objects.create(order=order, **item_data)
|
|
3251
|
+
order.recalculate_total()
|
|
3252
|
+
NotificationService.send_order_confirmation(order=order)
|
|
3253
|
+
return order
|
|
3254
|
+
|
|
3255
|
+
@staticmethod
|
|
3256
|
+
@transaction.atomic
|
|
3257
|
+
def cancel_order(*, order, user):
|
|
3258
|
+
if not order.is_cancellable:
|
|
3259
|
+
raise ValidationError("Order cannot be cancelled in its current state.")
|
|
3260
|
+
if order.user != user:
|
|
3261
|
+
raise PermissionDenied("You can only cancel your own orders.")
|
|
3262
|
+
order.status = 'cancelled'
|
|
3263
|
+
order.save(update_fields=['status'])
|
|
3264
|
+
InventoryService.restore_stock(order=order)
|
|
3265
|
+
return order
|
|
3266
|
+
\`\`\`
|
|
3267
|
+
|
|
3268
|
+
**ViewSets** \u2014 Inherit, extend, override:
|
|
3269
|
+
\`\`\`python
|
|
3270
|
+
class OrderViewSet(ModelViewSet):
|
|
3271
|
+
permission_classes = [IsAuthenticated]
|
|
3272
|
+
|
|
3273
|
+
def get_queryset(self):
|
|
3274
|
+
return OrderSelector.list_for_user(user=self.request.user)
|
|
3275
|
+
|
|
3276
|
+
def get_serializer_class(self):
|
|
3277
|
+
return {
|
|
3278
|
+
'list': OrderListSerializer,
|
|
3279
|
+
'retrieve': OrderDetailSerializer,
|
|
3280
|
+
'create': OrderCreateSerializer,
|
|
3281
|
+
}.get(self.action, OrderUpdateSerializer)
|
|
3282
|
+
|
|
3283
|
+
def perform_create(self, serializer):
|
|
3284
|
+
serializer.save()
|
|
3285
|
+
\`\`\`
|
|
3286
|
+
|
|
3287
|
+
**Custom permissions, filters, pagination** \u2014 All class-based, inheriting from DRF base classes:
|
|
3288
|
+
\`\`\`python
|
|
3289
|
+
class IsOwner(BasePermission):
|
|
3290
|
+
def has_object_permission(self, request, view, obj):
|
|
3291
|
+
return obj.user == request.user
|
|
3292
|
+
|
|
3293
|
+
class OrderFilterSet(django_filters.FilterSet):
|
|
3294
|
+
class Meta:
|
|
3295
|
+
model = Order
|
|
3296
|
+
fields = ['status', 'created_at']
|
|
3297
|
+
\`\`\`
|
|
3298
|
+
|
|
3299
|
+
### Functional Patterns in Python
|
|
3300
|
+
|
|
3301
|
+
Use functional style for pure logic, data transformation, and utilities \u2014 anywhere you don't need state or inheritance.
|
|
3302
|
+
|
|
3303
|
+
**Selectors** \u2014 Pure query builders (can be functions or static methods):
|
|
3304
|
+
\`\`\`python
|
|
3305
|
+
# selectors.py \u2014 pure functions that build querysets
|
|
3306
|
+
def get_active_orders(*, user, status=None):
|
|
3307
|
+
qs = (
|
|
3308
|
+
Order.objects
|
|
3309
|
+
.filter(user=user, is_active=True)
|
|
3310
|
+
.select_related('user')
|
|
3311
|
+
.prefetch_related('items__product')
|
|
3312
|
+
)
|
|
3313
|
+
if status:
|
|
3314
|
+
qs = qs.filter(status=status)
|
|
3315
|
+
return qs.order_by('-created_at')
|
|
3316
|
+
|
|
3317
|
+
def get_order_summary(*, user):
|
|
3318
|
+
return (
|
|
3319
|
+
Order.objects
|
|
3320
|
+
.filter(user=user)
|
|
3321
|
+
.values('status')
|
|
3322
|
+
.annotate(count=Count('id'), total=Sum('total'))
|
|
3323
|
+
)
|
|
3324
|
+
\`\`\`
|
|
3325
|
+
|
|
3326
|
+
**Data transformations** \u2014 Use comprehensions, \`map\`, pure functions:
|
|
3327
|
+
\`\`\`python
|
|
3328
|
+
# BAD \u2014 imperative mutation
|
|
3329
|
+
def format_export_data(orders):
|
|
3330
|
+
result = []
|
|
3331
|
+
for order in orders:
|
|
3332
|
+
row = {}
|
|
3333
|
+
row['id'] = order.id
|
|
3334
|
+
row['total'] = str(order.total)
|
|
3335
|
+
row['items'] = ', '.join([i.product.name for i in order.items.all()])
|
|
3336
|
+
result.append(row)
|
|
3337
|
+
return result
|
|
3338
|
+
|
|
3339
|
+
# GOOD \u2014 functional transformation
|
|
3340
|
+
def format_export_data(orders):
|
|
3341
|
+
return [
|
|
3342
|
+
{
|
|
3343
|
+
'id': order.id,
|
|
3344
|
+
'total': str(order.total),
|
|
3345
|
+
'items': ', '.join(i.product.name for i in order.items.all()),
|
|
3346
|
+
}
|
|
3347
|
+
for order in orders
|
|
3348
|
+
]
|
|
3349
|
+
\`\`\`
|
|
3350
|
+
|
|
3351
|
+
**Utility functions** \u2014 Pure, no side effects:
|
|
3352
|
+
\`\`\`python
|
|
3353
|
+
# utils.py \u2014 all pure functions
|
|
3354
|
+
def calculate_discount(price: Decimal, percentage: int) -> Decimal:
|
|
3355
|
+
return price * (Decimal(percentage) / 100)
|
|
3356
|
+
|
|
3357
|
+
def slugify_unique(name: str, existing_slugs: set[str]) -> str:
|
|
3358
|
+
base = slugify(name)
|
|
3359
|
+
slug = base
|
|
3360
|
+
counter = 1
|
|
3361
|
+
while slug in existing_slugs:
|
|
3362
|
+
slug = f"{base}-{counter}"
|
|
3363
|
+
counter += 1
|
|
3364
|
+
return slug
|
|
3365
|
+
|
|
3366
|
+
def paginate_list(items: list, page: int, page_size: int = 20) -> list:
|
|
3367
|
+
start = (page - 1) * page_size
|
|
3368
|
+
return items[start:start + page_size]
|
|
3369
|
+
\`\`\`
|
|
3370
|
+
|
|
3371
|
+
**Decorators** \u2014 Functional composition for cross-cutting concerns:
|
|
3372
|
+
\`\`\`python
|
|
3373
|
+
import functools
|
|
3374
|
+
import logging
|
|
3375
|
+
|
|
3376
|
+
logger = logging.getLogger(__name__)
|
|
3377
|
+
|
|
3378
|
+
def log_service_call(func):
|
|
3379
|
+
@functools.wraps(func)
|
|
3380
|
+
def wrapper(*args, **kwargs):
|
|
3381
|
+
logger.info(f"Calling {func.__name__} with kwargs={kwargs}")
|
|
3382
|
+
result = func(*args, **kwargs)
|
|
3383
|
+
logger.info(f"{func.__name__} completed successfully")
|
|
3384
|
+
return result
|
|
3385
|
+
return wrapper
|
|
3386
|
+
|
|
3387
|
+
# Usage
|
|
3388
|
+
class OrderService:
|
|
3389
|
+
@staticmethod
|
|
3390
|
+
@log_service_call
|
|
3391
|
+
@transaction.atomic
|
|
3392
|
+
def place_order(*, user, items, shipping_address):
|
|
3393
|
+
...
|
|
3394
|
+
\`\`\`
|
|
3395
|
+
|
|
3396
|
+
### When to Use What
|
|
3397
|
+
|
|
3398
|
+
| Pattern | Use OOP (class) | Use Functional (function) |
|
|
3399
|
+
|---------|-----------------|---------------------------|
|
|
3400
|
+
| **Models** | Always \u2014 Django models are classes | Model methods can be property-style pure computations |
|
|
3401
|
+
| **Views** | Always \u2014 ViewSets, APIViews | \u2014 |
|
|
3402
|
+
| **Serializers** | Always \u2014 DRF serializers are classes | \u2014 |
|
|
3403
|
+
| **Services** | Business logic with multiple related operations | Single-purpose operations can be standalone functions |
|
|
3404
|
+
| **Selectors** | Either \u2014 class with static methods or module-level functions | Preferred \u2014 pure functions that return querysets |
|
|
3405
|
+
| **Permissions** | Always \u2014 DRF permissions are class-based | \u2014 |
|
|
3406
|
+
| **Filters** | Always \u2014 django-filter uses classes | \u2014 |
|
|
3407
|
+
| **Utilities** | Never \u2014 don't wrap utilities in classes | Always \u2014 pure functions |
|
|
3408
|
+
| **Data transforms** | Never | Always \u2014 comprehensions, map, pure functions |
|
|
3409
|
+
| **Validators** | DRF validator classes for reusable validation | Simple validation functions for one-off checks |
|
|
3410
|
+
| **Signals** | Receiver functions (decorated functions) | \u2014 |
|
|
3411
|
+
| **Tests** | Test classes inheriting APITestCase | Individual test functions with pytest are also fine |
|
|
3412
|
+
|
|
3413
|
+
### Backend Anti-Patterns
|
|
3414
|
+
|
|
3415
|
+
| Anti-pattern | Correct approach |
|
|
3416
|
+
|---|---|
|
|
3417
|
+
| God class with 20+ methods | Split into focused Service + Selector + utils |
|
|
3418
|
+
| Utility class with only static methods | Use module-level functions instead |
|
|
3419
|
+
| Mixin soup (\`class View(A, B, C, D, E)\`) | Compose with max 1-2 mixins, prefer explicit overrides |
|
|
3420
|
+
| Business logic in views | Move to services |
|
|
3421
|
+
| Business logic in serializers | Serializers validate, services execute |
|
|
3422
|
+
| Mutable default arguments (\`def f(items=[])\`) | Use \`None\` default: \`def f(items=None)\` \u2192 \`items = items or []\` |
|
|
3423
|
+
| Nested \`for\` loops for data building | List/dict comprehensions |
|
|
3424
|
+
| Raw SQL for simple queries | Django ORM with \`annotate\`, \`Subquery\`, \`F\` expressions |
|
|
3425
|
+
| Global mutable state | Pass dependencies explicitly, use Django settings for config |
|
|
3426
|
+
| Deep inheritance chains | Prefer composition, keep inheritance to 1-2 levels |
|
|
3427
|
+
`;
|
|
3428
|
+
}
|
|
3429
|
+
};
|
|
3430
|
+
|
|
3431
|
+
// src/skills/clean-code.ts
|
|
3432
|
+
var cleanCodeSkill = {
|
|
3433
|
+
id: "clean-code",
|
|
3434
|
+
name: "Clean Code Principles",
|
|
3435
|
+
description: "Naming, functions, components, file organization, conditionals, error handling, and DRY guidelines.",
|
|
3436
|
+
render(_ctx) {
|
|
3437
|
+
return `## Clean Code Principles
|
|
3438
|
+
|
|
3439
|
+
Write code that is easy to read, easy to change, and easy to delete. Treat clarity as a feature.
|
|
3440
|
+
|
|
3441
|
+
### Naming
|
|
3442
|
+
- Names should reveal intent. A reader should understand what a variable, function, or class does without reading its implementation
|
|
3443
|
+
- Booleans: prefix with \`is\`, \`has\`, \`can\`, \`should\` \u2014 e.g. \`isLoading\`, \`hasPermission\`, \`canEdit\`
|
|
3444
|
+
- Functions: use verb phrases that describe the action \u2014 e.g. \`fetchUsers\`, \`createOrder\`, \`validateEmail\`
|
|
3445
|
+
- Event handlers: prefix with \`handle\` in components, \`on\` in props \u2014 e.g. \`handleSubmit\`, \`onSubmit\`
|
|
3446
|
+
- Collections: use plural nouns \u2014 e.g. \`users\`, \`orderItems\`, not \`userList\` or \`data\`
|
|
3447
|
+
- Avoid abbreviations. \`transaction\` not \`txn\`, \`button\` not \`btn\`, \`message\` not \`msg\`
|
|
3448
|
+
- Avoid generic names like \`data\`, \`info\`, \`item\`, \`result\`, \`value\` unless the scope is trivially small (e.g. a one-line callback)
|
|
3449
|
+
|
|
3450
|
+
### Functions
|
|
3451
|
+
- A function should do one thing. If you can describe what it does with "and", split it
|
|
3452
|
+
- Keep functions short \u2014 aim for under 20 lines. If a function is longer, look for sections you can extract
|
|
3453
|
+
- Prefer early returns to reduce nesting. Guard clauses at the top, happy path at the bottom
|
|
3454
|
+
- Limit parameters to 3. Beyond that, pass an options object
|
|
3455
|
+
- Pure functions are easier to test, reason about, and reuse. Prefer them where possible
|
|
3456
|
+
- Don't use flags (boolean parameters) to make a function do two different things \u2014 write two functions instead
|
|
3457
|
+
|
|
3458
|
+
### Components (React-specific)
|
|
3459
|
+
- One component per file. The file name should match the component name
|
|
3460
|
+
- Keep components under 100 lines of JSX. Extract sub-components when they grow beyond this
|
|
3461
|
+
- Separate data logic (hooks) from presentation (components). A component should mostly be JSX, not logic
|
|
3462
|
+
- **Page components are orchestrators** \u2014 they should be ~20-30 lines, composing child components from \`components/\` and calling hooks from \`hooks/\`. Never build a 200-line page monolith
|
|
3463
|
+
- Props interfaces should be explicit and narrow \u2014 accept only what the component needs, not entire objects
|
|
3464
|
+
- Avoid prop drilling beyond 2 levels \u2014 use context or restructure the component tree
|
|
3465
|
+
- Destructure props in the function signature for clarity
|
|
3466
|
+
- Use \`@blacksmith-ui/react\` layout components (\`Stack\`, \`Flex\`, \`Grid\`) \u2014 never raw \`<div>\` with flex/grid classes
|
|
3467
|
+
|
|
3468
|
+
### File Organization
|
|
3469
|
+
- Keep files short. If a file exceeds 200 lines, it is likely doing too much \u2014 split it
|
|
3470
|
+
- Group by feature, not by type. \`features/orders/\` is better than \`components/\`, \`hooks/\`, \`utils/\` at the top level
|
|
3471
|
+
- Co-locate related code. A component's hook, types, and test should live next to it
|
|
3472
|
+
- One export per file for components and hooks. Use \`index.ts\` barrel files only at the feature boundary
|
|
3473
|
+
|
|
3474
|
+
### Conditionals & Logic
|
|
3475
|
+
- Prefer positive conditionals: \`if (isValid)\` over \`if (!isInvalid)\`
|
|
3476
|
+
- Extract complex conditions into well-named variables or functions:
|
|
3477
|
+
\`\`\`ts
|
|
3478
|
+
// Bad
|
|
3479
|
+
if (user.role === 'admin' && user.isActive && !user.isSuspended) { ... }
|
|
3480
|
+
|
|
3481
|
+
// Good
|
|
3482
|
+
const canAccessAdminPanel = user.role === 'admin' && user.isActive && !user.isSuspended
|
|
3483
|
+
if (canAccessAdminPanel) { ... }
|
|
3484
|
+
\`\`\`
|
|
3485
|
+
- Avoid deeply nested if/else trees. Use early returns, guard clauses, or lookup objects
|
|
3486
|
+
- Prefer \`switch\` or object maps over long \`if/else if\` chains:
|
|
3487
|
+
\`\`\`ts
|
|
3488
|
+
// Bad
|
|
3489
|
+
if (status === 'active') return 'green'
|
|
3490
|
+
else if (status === 'pending') return 'yellow'
|
|
3491
|
+
else if (status === 'inactive') return 'gray'
|
|
3492
|
+
|
|
3493
|
+
// Good
|
|
3494
|
+
const statusColor = { active: 'green', pending: 'yellow', inactive: 'gray' }
|
|
3495
|
+
return statusColor[status]
|
|
3496
|
+
\`\`\`
|
|
3497
|
+
|
|
3498
|
+
### Error Handling
|
|
3499
|
+
- Handle errors at the right level \u2014 close to where they occur and where you can do something meaningful
|
|
3500
|
+
- Provide useful error messages that help the developer (or user) understand what went wrong and what to do
|
|
3501
|
+
- Don't swallow errors silently. If you catch, log or handle. Never write empty \`catch {}\` blocks
|
|
3502
|
+
- Use typed errors. In Python, raise specific exceptions. In TypeScript, return discriminated unions or throw typed errors
|
|
3503
|
+
|
|
3504
|
+
### Comments
|
|
3505
|
+
- Don't comment what the code does \u2014 make the code readable enough to not need it
|
|
3506
|
+
- Do comment why \u2014 explain business decisions, workarounds, non-obvious constraints
|
|
3507
|
+
- Delete commented-out code. Version control remembers it
|
|
3508
|
+
- TODOs are acceptable but should include context: \`// TODO(auth): rate-limit login attempts after v1 launch\`
|
|
3509
|
+
|
|
3510
|
+
### DRY Without Overengineering
|
|
3511
|
+
- Don't repeat the same logic in multiple places \u2014 extract it once you see the third occurrence
|
|
3512
|
+
- But don't over-abstract. Two similar blocks of code are fine if they serve different purposes and are likely to diverge
|
|
3513
|
+
- Premature abstraction is worse than duplication. Wait for patterns to emerge before creating shared utilities
|
|
3514
|
+
- Helper functions should be genuinely reusable. A "helper" called from one place is just indirection
|
|
3515
|
+
|
|
3516
|
+
### Python-Specific (Django)
|
|
3517
|
+
- Use \`f-strings\` for string formatting, not \`.format()\` or \`%\`
|
|
3518
|
+
- Use list/dict/set comprehensions when they are clearer than loops \u2014 but don't nest them
|
|
3519
|
+
- Use \`dataclasses\` or typed dicts for structured data outside of Django models
|
|
3520
|
+
- Keep view methods thin \u2014 push business logic into model methods, serializer validation, or service functions
|
|
3521
|
+
- Use \`get_object_or_404\` instead of manual \`try/except DoesNotExist\`
|
|
3522
|
+
|
|
3523
|
+
### TypeScript-Specific (React)
|
|
3524
|
+
- Use strict TypeScript. Don't use \`any\` \u2014 use \`unknown\` and narrow, or define a proper type
|
|
3525
|
+
- Define interfaces for component props, API responses, and form schemas
|
|
3526
|
+
- Use \`const\` by default. Only use \`let\` when reassignment is necessary. Never use \`var\`
|
|
3527
|
+
- Prefer \`map\`, \`filter\`, \`reduce\` over imperative loops for data transformation
|
|
3528
|
+
- Use optional chaining (\`?.\`) and nullish coalescing (\`??\`) instead of manual null checks
|
|
3529
|
+
- Keep type definitions close to where they are used. Don't create a global \`types.ts\` file
|
|
3530
|
+
`;
|
|
3531
|
+
}
|
|
3532
|
+
};
|
|
3533
|
+
|
|
3534
|
+
// src/skills/ai-guidelines.ts
|
|
3535
|
+
var aiGuidelinesSkill = {
|
|
3536
|
+
id: "ai-guidelines",
|
|
3537
|
+
name: "AI Development Guidelines",
|
|
3538
|
+
description: "Guidelines for developing the project using AI, including when to use code generation, code style, environment setup, and a checklist before finishing tasks.",
|
|
3539
|
+
render(_ctx) {
|
|
3540
|
+
return `## AI Development Guidelines
|
|
3541
|
+
|
|
3542
|
+
### When Adding Features
|
|
3543
|
+
1. Use \`blacksmith make:resource <Name>\` for new CRUD resources \u2014 it scaffolds model, serializer, viewset, URLs, hooks, components, and pages across both backend and frontend
|
|
3544
|
+
2. After any backend API change (new endpoint, changed schema, new field), run \`blacksmith sync\` to regenerate the frontend API client and types
|
|
3545
|
+
3. Never manually edit files in \`frontend/src/api/generated/\` \u2014 they are overwritten on every sync
|
|
3546
|
+
|
|
3547
|
+
### Code Style
|
|
3548
|
+
- **Backend**: Follow PEP 8. Use Django and DRF conventions. Docstrings on models, serializers, and non-obvious view methods
|
|
3549
|
+
- **Frontend**: TypeScript strict mode. Functional components. Named exports (not default, except for page components used in routes). Descriptive variable names
|
|
3550
|
+
- Use existing patterns in the codebase as reference before inventing new ones
|
|
3551
|
+
|
|
3552
|
+
### Frontend Architecture (Mandatory)
|
|
3553
|
+
- **Use \`@blacksmith-ui/react\` for ALL UI** \u2014 \`Stack\`, \`Flex\`, \`Grid\` for layout; \`Typography\`, \`Text\` for text; \`Card\`, \`Button\`, \`Badge\`, etc. for all elements. Never use raw HTML (\`<div>\`, \`<h1>\`, \`<p>\`, \`<button>\`) when a Blacksmith-UI component exists
|
|
3554
|
+
- **Pages are thin orchestrators** \u2014 compose child components from \`components/\`, extract logic into \`hooks/\`. A page file should be ~20-30 lines, not a monolith
|
|
3555
|
+
- **Use the \`Path\` enum** \u2014 all route paths come from \`src/router/paths.ts\`. Never hardcode path strings like \`'/login'\` or \`'/dashboard'\`
|
|
3556
|
+
- **Add new paths to the enum** \u2014 when creating a new page, add its path to the \`Path\` enum before the \`// blacksmith:path\` marker
|
|
3557
|
+
|
|
3558
|
+
### Environment
|
|
3559
|
+
- Backend: \`http://localhost:8000\`
|
|
3560
|
+
- Frontend: \`http://localhost:5173\`
|
|
3561
|
+
- API docs: \`http://localhost:8000/api/docs/\` (Swagger UI) or \`/api/redoc/\` (ReDoc)
|
|
3562
|
+
- Python venv: \`backend/venv/\` \u2014 always use \`./venv/bin/python\` or \`./venv/bin/pip\`
|
|
3563
|
+
- Start everything: \`blacksmith dev\`
|
|
3564
|
+
|
|
3565
|
+
### Checklist Before Finishing a Task
|
|
3566
|
+
1. Backend tests pass: \`cd backend && ./venv/bin/python manage.py test\`
|
|
3567
|
+
2. Frontend builds: \`cd frontend && npm run build\`
|
|
3568
|
+
3. API types are in sync: \`blacksmith sync\`
|
|
3569
|
+
4. No lint errors in modified files
|
|
3570
|
+
5. All UI uses \`@blacksmith-ui/react\` components \u2014 no raw \`<div>\` for layout, no raw \`<h1>\`-\`<h6>\` for text
|
|
3571
|
+
6. Pages are modular \u2014 page file is a thin orchestrator, sections are in \`components/\`, logic in \`hooks/\`
|
|
3572
|
+
7. Logic is in hooks \u2014 no \`useApiQuery\`, \`useApiMutation\`, \`useEffect\`, or multi-\`useState\` in component bodies
|
|
3573
|
+
8. No hardcoded route paths \u2014 all paths use the \`Path\` enum from \`@/router/paths\`
|
|
3574
|
+
9. New routes have a corresponding \`Path\` enum entry
|
|
3575
|
+
`;
|
|
3576
|
+
}
|
|
3577
|
+
};
|
|
3578
|
+
|
|
3579
|
+
// src/commands/ai-setup.ts
|
|
3580
|
+
async function setupAiDev({ projectDir, projectName, includeBlacksmithUiSkill }) {
|
|
3581
|
+
const aiSpinner = spinner("Setting up AI development environment...");
|
|
3582
|
+
try {
|
|
3583
|
+
const skills = [
|
|
3584
|
+
coreRulesSkill,
|
|
3585
|
+
projectOverviewSkill,
|
|
3586
|
+
djangoSkill,
|
|
3587
|
+
djangoRestAdvancedSkill,
|
|
3588
|
+
apiDocumentationSkill,
|
|
3589
|
+
reactSkill,
|
|
3590
|
+
reactQuerySkill,
|
|
3591
|
+
pageStructureSkill
|
|
3592
|
+
];
|
|
3593
|
+
if (includeBlacksmithUiSkill) {
|
|
3594
|
+
skills.push(blacksmithUiReactSkill);
|
|
3595
|
+
skills.push(blacksmithUiFormsSkill);
|
|
3596
|
+
skills.push(blacksmithUiAuthSkill);
|
|
3597
|
+
skills.push(blacksmithHooksSkill);
|
|
3598
|
+
skills.push(uiDesignSkill);
|
|
3599
|
+
}
|
|
3600
|
+
skills.push(blacksmithCliSkill);
|
|
3601
|
+
skills.push(programmingParadigmsSkill);
|
|
3602
|
+
skills.push(cleanCodeSkill);
|
|
3603
|
+
skills.push(aiGuidelinesSkill);
|
|
3604
|
+
const ctx = { projectName };
|
|
3605
|
+
const inlineSkills = skills.filter((s) => !s.name);
|
|
3606
|
+
const fileSkills = skills.filter((s) => s.name);
|
|
3607
|
+
const skillsDir = path3.join(projectDir, ".claude", "skills");
|
|
3608
|
+
if (fs3.existsSync(skillsDir)) {
|
|
3609
|
+
for (const entry of fs3.readdirSync(skillsDir)) {
|
|
3610
|
+
const entryPath = path3.join(skillsDir, entry);
|
|
3611
|
+
const stat = fs3.statSync(entryPath);
|
|
3612
|
+
if (stat.isDirectory()) {
|
|
3613
|
+
fs3.rmSync(entryPath, { recursive: true });
|
|
3614
|
+
} else if (entry.endsWith(".md")) {
|
|
3615
|
+
fs3.unlinkSync(entryPath);
|
|
3616
|
+
}
|
|
3617
|
+
}
|
|
3618
|
+
}
|
|
3619
|
+
fs3.mkdirSync(skillsDir, { recursive: true });
|
|
3620
|
+
for (const skill of fileSkills) {
|
|
3621
|
+
const skillDir = path3.join(skillsDir, skill.id);
|
|
3622
|
+
fs3.mkdirSync(skillDir, { recursive: true });
|
|
3623
|
+
const frontmatter = `---
|
|
3624
|
+
name: ${skill.name}
|
|
3625
|
+
description: ${skill.description}
|
|
3626
|
+
---
|
|
3627
|
+
|
|
3628
|
+
`;
|
|
3629
|
+
const content = skill.render(ctx).trim();
|
|
3630
|
+
fs3.writeFileSync(path3.join(skillDir, "SKILL.md"), frontmatter + content + "\n", "utf-8");
|
|
3631
|
+
}
|
|
3632
|
+
const inlineContent = inlineSkills.map((s) => s.render(ctx)).join("\n");
|
|
3633
|
+
const skillsList = fileSkills.map((s) => `- \`.claude/skills/${s.id}/SKILL.md\` \u2014 ${s.name}`).join("\n");
|
|
3634
|
+
const claudeMd = [
|
|
3635
|
+
inlineContent.trim(),
|
|
3636
|
+
"",
|
|
3637
|
+
"## AI Skills",
|
|
3638
|
+
"",
|
|
3639
|
+
"Detailed skills and conventions are in `.claude/skills/`:",
|
|
3640
|
+
"",
|
|
3641
|
+
skillsList,
|
|
3642
|
+
"",
|
|
3643
|
+
"These files are auto-loaded by Claude Code. Run `blacksmith setup:ai` to regenerate.",
|
|
3644
|
+
""
|
|
3645
|
+
].join("\n");
|
|
3646
|
+
fs3.writeFileSync(path3.join(projectDir, "CLAUDE.md"), claudeMd, "utf-8");
|
|
3647
|
+
const skillNames = skills.filter((s) => s.id !== "project-overview" && s.id !== "ai-guidelines").map((s) => s.id).join(" + ");
|
|
3648
|
+
aiSpinner.succeed(`AI dev environment ready (${skillNames} skills)`);
|
|
3649
|
+
} catch (error) {
|
|
3650
|
+
aiSpinner.fail("Failed to set up AI development environment");
|
|
3651
|
+
log.error(error.message);
|
|
3652
|
+
}
|
|
3653
|
+
}
|
|
3654
|
+
|
|
3655
|
+
// src/commands/init.ts
|
|
3656
|
+
function parsePort(value, label) {
|
|
3657
|
+
const port = parseInt(value, 10);
|
|
3658
|
+
if (isNaN(port) || port < 1 || port > 65535) {
|
|
3659
|
+
log.error(`Invalid ${label} port: ${value}`);
|
|
3660
|
+
process.exit(1);
|
|
3661
|
+
}
|
|
3662
|
+
return port;
|
|
3663
|
+
}
|
|
3664
|
+
var THEME_PRESETS = ["default", "blue", "green", "violet", "red", "neutral"];
|
|
3665
|
+
async function init(name, options) {
|
|
3666
|
+
if (!name) {
|
|
3667
|
+
name = await promptText("Project name");
|
|
3668
|
+
if (!name) {
|
|
3669
|
+
log.error("Project name is required.");
|
|
3670
|
+
process.exit(1);
|
|
3671
|
+
}
|
|
3672
|
+
}
|
|
3673
|
+
if (!options.backendPort) {
|
|
3674
|
+
options.backendPort = await promptText("Backend port", "8000");
|
|
3675
|
+
}
|
|
3676
|
+
if (!options.frontendPort) {
|
|
3677
|
+
options.frontendPort = await promptText("Frontend port", "5173");
|
|
3678
|
+
}
|
|
3679
|
+
if (!options.themeColor) {
|
|
3680
|
+
options.themeColor = await promptSelect("Theme preset", THEME_PRESETS, "default");
|
|
3681
|
+
}
|
|
3682
|
+
if (options.ai === void 0) {
|
|
3683
|
+
options.ai = await promptYesNo("Set up AI coding support");
|
|
3684
|
+
}
|
|
3685
|
+
const backendPort = parsePort(options.backendPort, "backend");
|
|
3686
|
+
const frontendPort = parsePort(options.frontendPort, "frontend");
|
|
3687
|
+
const themePreset = THEME_PRESETS.includes(options.themeColor) ? options.themeColor : "default";
|
|
3688
|
+
printConfig({
|
|
3689
|
+
"Project": name,
|
|
3690
|
+
"Backend": `Django on :${backendPort}`,
|
|
3691
|
+
"Frontend": `React on :${frontendPort}`,
|
|
3692
|
+
"Theme": themePreset,
|
|
3693
|
+
"AI support": options.ai ? "Yes" : "No"
|
|
3694
|
+
});
|
|
3695
|
+
const projectDir = path4.resolve(process.cwd(), name);
|
|
3696
|
+
const backendDir = path4.join(projectDir, "backend");
|
|
3697
|
+
const frontendDir = path4.join(projectDir, "frontend");
|
|
3698
|
+
const templatesDir = getTemplatesDir();
|
|
3699
|
+
if (fs4.existsSync(projectDir)) {
|
|
3700
|
+
log.error(`Directory "${name}" already exists.`);
|
|
3701
|
+
process.exit(1);
|
|
3702
|
+
}
|
|
3703
|
+
const checkSpinner = spinner("Checking prerequisites...");
|
|
3704
|
+
const hasPython = await commandExists("python3");
|
|
3705
|
+
const hasNode = await commandExists("node");
|
|
3706
|
+
const hasNpm = await commandExists("npm");
|
|
3707
|
+
if (!hasPython) {
|
|
3708
|
+
checkSpinner.fail("Python 3 is required but not found. Install it from https://python.org");
|
|
3709
|
+
process.exit(1);
|
|
3710
|
+
}
|
|
3711
|
+
if (!hasNode || !hasNpm) {
|
|
3712
|
+
checkSpinner.fail("Node.js and npm are required but not found. Install from https://nodejs.org");
|
|
3713
|
+
process.exit(1);
|
|
3714
|
+
}
|
|
3715
|
+
checkSpinner.succeed("Prerequisites OK (Python 3, Node.js, npm)");
|
|
3716
|
+
const context = {
|
|
3717
|
+
projectName: name,
|
|
3718
|
+
backendPort,
|
|
3719
|
+
frontendPort,
|
|
3720
|
+
themePreset
|
|
3721
|
+
};
|
|
3722
|
+
fs4.mkdirSync(projectDir, { recursive: true });
|
|
3723
|
+
fs4.writeFileSync(
|
|
3724
|
+
path4.join(projectDir, "blacksmith.config.json"),
|
|
3725
|
+
JSON.stringify(
|
|
3726
|
+
{
|
|
3727
|
+
name,
|
|
3728
|
+
version: "0.1.0",
|
|
3729
|
+
backend: { port: backendPort },
|
|
3730
|
+
frontend: { port: frontendPort }
|
|
3731
|
+
},
|
|
3732
|
+
null,
|
|
3733
|
+
2
|
|
3734
|
+
)
|
|
3735
|
+
);
|
|
3736
|
+
const backendSpinner = spinner("Generating Django backend...");
|
|
3737
|
+
try {
|
|
3738
|
+
renderDirectory(
|
|
3739
|
+
path4.join(templatesDir, "backend"),
|
|
3740
|
+
backendDir,
|
|
3741
|
+
context
|
|
3742
|
+
);
|
|
3743
|
+
fs4.copyFileSync(
|
|
3744
|
+
path4.join(backendDir, ".env.example"),
|
|
3745
|
+
path4.join(backendDir, ".env")
|
|
3746
|
+
);
|
|
3747
|
+
backendSpinner.succeed("Django backend generated");
|
|
3748
|
+
} catch (error) {
|
|
3749
|
+
backendSpinner.fail("Failed to generate backend");
|
|
3750
|
+
log.error(error.message);
|
|
3751
|
+
process.exit(1);
|
|
3752
|
+
}
|
|
3753
|
+
const venvSpinner = spinner("Creating Python virtual environment...");
|
|
3754
|
+
try {
|
|
3755
|
+
await exec("python3", ["-m", "venv", "venv"], { cwd: backendDir, silent: true });
|
|
3756
|
+
venvSpinner.succeed("Virtual environment created");
|
|
3757
|
+
} catch (error) {
|
|
3758
|
+
venvSpinner.fail("Failed to create virtual environment");
|
|
3759
|
+
log.error(error.message);
|
|
3760
|
+
process.exit(1);
|
|
3761
|
+
}
|
|
3762
|
+
const pipSpinner = spinner("Installing Python dependencies...");
|
|
3763
|
+
try {
|
|
3764
|
+
await execPip(
|
|
3765
|
+
["install", "-r", "requirements.txt"],
|
|
3766
|
+
backendDir,
|
|
3767
|
+
true
|
|
3768
|
+
);
|
|
3769
|
+
pipSpinner.succeed("Python dependencies installed");
|
|
3770
|
+
} catch (error) {
|
|
3771
|
+
pipSpinner.fail("Failed to install Python dependencies");
|
|
3772
|
+
log.error(error.message);
|
|
3773
|
+
process.exit(1);
|
|
3774
|
+
}
|
|
3775
|
+
const migrateSpinner = spinner("Running initial migrations...");
|
|
3776
|
+
try {
|
|
3777
|
+
await execPython(["manage.py", "makemigrations", "users"], backendDir, true);
|
|
3778
|
+
await execPython(["manage.py", "migrate"], backendDir, true);
|
|
3779
|
+
migrateSpinner.succeed("Database migrated");
|
|
3780
|
+
} catch (error) {
|
|
3781
|
+
migrateSpinner.fail("Failed to run migrations");
|
|
3782
|
+
log.error(error.message);
|
|
3783
|
+
process.exit(1);
|
|
3784
|
+
}
|
|
3785
|
+
const frontendSpinner = spinner("Generating React frontend...");
|
|
3786
|
+
try {
|
|
3787
|
+
renderDirectory(
|
|
3788
|
+
path4.join(templatesDir, "frontend"),
|
|
3789
|
+
frontendDir,
|
|
3790
|
+
context
|
|
3791
|
+
);
|
|
3792
|
+
frontendSpinner.succeed("React frontend generated");
|
|
3793
|
+
} catch (error) {
|
|
3794
|
+
frontendSpinner.fail("Failed to generate frontend");
|
|
3795
|
+
log.error(error.message);
|
|
3796
|
+
process.exit(1);
|
|
3797
|
+
}
|
|
3798
|
+
const npmSpinner = spinner("Installing Node.js dependencies...");
|
|
3799
|
+
try {
|
|
3800
|
+
await exec("npm", ["install"], { cwd: frontendDir, silent: true });
|
|
3801
|
+
npmSpinner.succeed("Node.js dependencies installed");
|
|
3802
|
+
} catch (error) {
|
|
3803
|
+
npmSpinner.fail("Failed to install Node.js dependencies");
|
|
3804
|
+
log.error(error.message);
|
|
3805
|
+
process.exit(1);
|
|
3806
|
+
}
|
|
3807
|
+
const syncSpinner = spinner("Running initial OpenAPI sync...");
|
|
3808
|
+
try {
|
|
3809
|
+
const djangoProcess = spawn(
|
|
3810
|
+
"./venv/bin/python",
|
|
3811
|
+
["manage.py", "runserver", `0.0.0.0:${backendPort}`, "--noreload"],
|
|
3812
|
+
{
|
|
3813
|
+
cwd: backendDir,
|
|
3814
|
+
stdio: "ignore",
|
|
3815
|
+
detached: true
|
|
3816
|
+
}
|
|
3817
|
+
);
|
|
3818
|
+
djangoProcess.unref();
|
|
3819
|
+
await new Promise((resolve) => setTimeout(resolve, 4e3));
|
|
3820
|
+
try {
|
|
3821
|
+
await exec(process.execPath, [path4.join(frontendDir, "node_modules", ".bin", "openapi-ts")], { cwd: frontendDir, silent: true });
|
|
3822
|
+
syncSpinner.succeed("OpenAPI types synced");
|
|
3823
|
+
} catch {
|
|
3824
|
+
syncSpinner.warn('OpenAPI sync skipped (run "blacksmith sync" after starting Django)');
|
|
3825
|
+
}
|
|
3826
|
+
try {
|
|
3827
|
+
if (djangoProcess.pid) {
|
|
3828
|
+
process.kill(-djangoProcess.pid);
|
|
3829
|
+
}
|
|
3830
|
+
} catch {
|
|
3831
|
+
}
|
|
3832
|
+
} catch {
|
|
3833
|
+
syncSpinner.warn('OpenAPI sync skipped (run "blacksmith sync" after starting Django)');
|
|
3834
|
+
}
|
|
3835
|
+
const generatedDir = path4.join(frontendDir, "src", "api", "generated");
|
|
3836
|
+
const stubFile = path4.join(generatedDir, "client.gen.ts");
|
|
3837
|
+
if (!fs4.existsSync(stubFile)) {
|
|
3838
|
+
if (!fs4.existsSync(generatedDir)) {
|
|
3839
|
+
fs4.mkdirSync(generatedDir, { recursive: true });
|
|
3840
|
+
}
|
|
3841
|
+
fs4.writeFileSync(
|
|
3842
|
+
stubFile,
|
|
3843
|
+
[
|
|
3844
|
+
"/**",
|
|
3845
|
+
" * Auto-generated API Client",
|
|
3846
|
+
" *",
|
|
3847
|
+
" * This is a stub file that allows the app to boot before",
|
|
3848
|
+
" * the first OpenAPI sync. Run `blacksmith sync` or `blacksmith dev`",
|
|
3849
|
+
" * to generate the real client from your Django API schema.",
|
|
3850
|
+
" *",
|
|
3851
|
+
" * Generated by Blacksmith. This file will be overwritten by openapi-ts.",
|
|
3852
|
+
" */",
|
|
3853
|
+
"",
|
|
3854
|
+
"import { createClient } from '@hey-api/client-fetch'",
|
|
3855
|
+
"",
|
|
3856
|
+
"export const client = createClient()",
|
|
3857
|
+
""
|
|
3858
|
+
].join("\n"),
|
|
3859
|
+
"utf-8"
|
|
3860
|
+
);
|
|
3861
|
+
}
|
|
3862
|
+
if (options.ai) {
|
|
3863
|
+
await setupAiDev({
|
|
3864
|
+
projectDir,
|
|
3865
|
+
projectName: name,
|
|
3866
|
+
includeBlacksmithUiSkill: options.blacksmithUiSkill !== false
|
|
3867
|
+
});
|
|
3868
|
+
}
|
|
3869
|
+
printNextSteps(name, backendPort, frontendPort);
|
|
3870
|
+
}
|
|
3871
|
+
|
|
3872
|
+
// src/commands/dev.ts
|
|
3873
|
+
import net from "net";
|
|
3874
|
+
import concurrently from "concurrently";
|
|
3875
|
+
import path5 from "path";
|
|
3876
|
+
function isPortAvailable(port) {
|
|
3877
|
+
return new Promise((resolve) => {
|
|
3878
|
+
const server = net.createServer();
|
|
3879
|
+
server.once("error", () => resolve(false));
|
|
3880
|
+
server.once("listening", () => {
|
|
3881
|
+
server.close(() => resolve(true));
|
|
3882
|
+
});
|
|
3883
|
+
server.listen(port);
|
|
3884
|
+
});
|
|
3885
|
+
}
|
|
3886
|
+
async function findAvailablePort(startPort) {
|
|
3887
|
+
let port = startPort;
|
|
3888
|
+
while (port < startPort + 100) {
|
|
3889
|
+
if (await isPortAvailable(port)) return port;
|
|
3890
|
+
port++;
|
|
3891
|
+
}
|
|
3892
|
+
throw new Error(`No available port found in range ${startPort}-${port - 1}`);
|
|
3893
|
+
}
|
|
3894
|
+
async function dev() {
|
|
3895
|
+
let root;
|
|
3896
|
+
try {
|
|
3897
|
+
root = findProjectRoot();
|
|
3898
|
+
} catch {
|
|
3899
|
+
log.error('Not inside a Blacksmith project. Run "blacksmith init <name>" first.');
|
|
3900
|
+
process.exit(1);
|
|
3901
|
+
}
|
|
3902
|
+
const config = loadConfig(root);
|
|
3903
|
+
const backendDir = getBackendDir(root);
|
|
3904
|
+
const frontendDir = getFrontendDir(root);
|
|
3905
|
+
let backendPort;
|
|
3906
|
+
let frontendPort;
|
|
3907
|
+
try {
|
|
3908
|
+
;
|
|
3909
|
+
[backendPort, frontendPort] = await Promise.all([
|
|
3910
|
+
findAvailablePort(config.backend.port),
|
|
3911
|
+
findAvailablePort(config.frontend.port)
|
|
3912
|
+
]);
|
|
3913
|
+
} catch (err) {
|
|
3914
|
+
log.error(err.message);
|
|
3915
|
+
process.exit(1);
|
|
3916
|
+
}
|
|
3917
|
+
if (backendPort !== config.backend.port) {
|
|
3918
|
+
log.step(`Backend port ${config.backend.port} in use, using ${backendPort}`);
|
|
3919
|
+
}
|
|
3920
|
+
if (frontendPort !== config.frontend.port) {
|
|
3921
|
+
log.step(`Frontend port ${config.frontend.port} in use, using ${frontendPort}`);
|
|
3922
|
+
}
|
|
3923
|
+
log.info("Starting development servers...");
|
|
3924
|
+
log.blank();
|
|
3925
|
+
log.step(`Django \u2192 http://localhost:${backendPort}`);
|
|
3926
|
+
log.step(`Vite \u2192 http://localhost:${frontendPort}`);
|
|
3927
|
+
log.step(`Swagger \u2192 http://localhost:${backendPort}/api/docs/`);
|
|
3928
|
+
log.step("OpenAPI sync \u2192 watching backend .py files");
|
|
3929
|
+
log.blank();
|
|
3930
|
+
const syncCmd = `${process.execPath} ${path5.join(frontendDir, "node_modules", ".bin", "openapi-ts")}`;
|
|
3931
|
+
const watcherCode = [
|
|
3932
|
+
`const{watch}=require("fs"),{exec}=require("child_process");`,
|
|
3933
|
+
`let t=null,s=false;`,
|
|
3934
|
+
`watch(${JSON.stringify(backendDir)},{recursive:true},(e,f)=>{`,
|
|
3935
|
+
`if(!f||!f.endsWith(".py"))return;`,
|
|
3936
|
+
`if(f.startsWith("venv/")||f.includes("__pycache__")||f.includes("/migrations/"))return;`,
|
|
3937
|
+
`if(t)clearTimeout(t);`,
|
|
3938
|
+
`t=setTimeout(()=>{`,
|
|
3939
|
+
`if(s)return;s=true;`,
|
|
3940
|
+
`console.log("Backend change detected \u2014 syncing OpenAPI types...");`,
|
|
3941
|
+
`exec(${JSON.stringify(syncCmd)},{cwd:${JSON.stringify(frontendDir)}},(err,o,se)=>{`,
|
|
3942
|
+
`s=false;`,
|
|
3943
|
+
`if(err)console.error("Sync failed:",se||err.message);`,
|
|
3944
|
+
`else console.log("OpenAPI types synced");`,
|
|
3945
|
+
`})`,
|
|
3946
|
+
`},2000)});`,
|
|
3947
|
+
`console.log("Watching for .py changes...");`
|
|
3948
|
+
].join("");
|
|
3949
|
+
const { result } = concurrently(
|
|
3950
|
+
[
|
|
3951
|
+
{
|
|
3952
|
+
command: `./venv/bin/python manage.py runserver 0.0.0.0:${backendPort}`,
|
|
3953
|
+
name: "django",
|
|
3954
|
+
cwd: backendDir,
|
|
3955
|
+
prefixColor: "green"
|
|
3956
|
+
},
|
|
3957
|
+
{
|
|
3958
|
+
command: "npm run dev",
|
|
3959
|
+
name: "vite",
|
|
3960
|
+
cwd: frontendDir,
|
|
3961
|
+
prefixColor: "blue"
|
|
3962
|
+
},
|
|
3963
|
+
{
|
|
3964
|
+
command: `node -e '${watcherCode}'`,
|
|
3965
|
+
name: "sync",
|
|
3966
|
+
cwd: frontendDir,
|
|
3967
|
+
prefixColor: "yellow"
|
|
3968
|
+
}
|
|
3969
|
+
],
|
|
3970
|
+
{
|
|
3971
|
+
prefix: "name",
|
|
3972
|
+
killOthers: ["failure"],
|
|
3973
|
+
restartTries: 3
|
|
3974
|
+
}
|
|
3975
|
+
);
|
|
3976
|
+
const shutdown = () => {
|
|
3977
|
+
log.blank();
|
|
3978
|
+
log.info("Development servers stopped.");
|
|
3979
|
+
process.exit(0);
|
|
3980
|
+
};
|
|
3981
|
+
process.on("SIGINT", shutdown);
|
|
3982
|
+
process.on("SIGTERM", shutdown);
|
|
3983
|
+
try {
|
|
3984
|
+
await result;
|
|
3985
|
+
} catch {
|
|
3986
|
+
}
|
|
3987
|
+
}
|
|
3988
|
+
|
|
3989
|
+
// src/commands/sync.ts
|
|
3990
|
+
import path6 from "path";
|
|
3991
|
+
import fs5 from "fs";
|
|
3992
|
+
async function sync() {
|
|
3993
|
+
let root;
|
|
3994
|
+
try {
|
|
3995
|
+
root = findProjectRoot();
|
|
3996
|
+
} catch {
|
|
3997
|
+
log.error('Not inside a Blacksmith project. Run "blacksmith init <name>" first.');
|
|
3998
|
+
process.exit(1);
|
|
3999
|
+
}
|
|
4000
|
+
const backendDir = getBackendDir(root);
|
|
4001
|
+
const frontendDir = getFrontendDir(root);
|
|
4002
|
+
const s = spinner("Syncing OpenAPI schema to frontend...");
|
|
4003
|
+
try {
|
|
4004
|
+
const schemaPath = path6.join(frontendDir, "_schema.yml");
|
|
4005
|
+
await execPython(["manage.py", "spectacular", "--file", schemaPath], backendDir, true);
|
|
4006
|
+
const configPath = path6.join(frontendDir, "openapi-ts.config.ts");
|
|
4007
|
+
const configBackup = fs5.readFileSync(configPath, "utf-8");
|
|
4008
|
+
const configWithFile = configBackup.replace(
|
|
4009
|
+
/path:\s*['"]http[^'"]+['"]/,
|
|
4010
|
+
`path: './_schema.yml'`
|
|
4011
|
+
);
|
|
4012
|
+
fs5.writeFileSync(configPath, configWithFile, "utf-8");
|
|
4013
|
+
try {
|
|
4014
|
+
await exec(process.execPath, [path6.join(frontendDir, "node_modules", ".bin", "openapi-ts")], {
|
|
4015
|
+
cwd: frontendDir,
|
|
4016
|
+
silent: true
|
|
4017
|
+
});
|
|
4018
|
+
} finally {
|
|
4019
|
+
fs5.writeFileSync(configPath, configBackup, "utf-8");
|
|
4020
|
+
if (fs5.existsSync(schemaPath)) fs5.unlinkSync(schemaPath);
|
|
4021
|
+
}
|
|
4022
|
+
s.succeed("Frontend types, schemas, and hooks synced from OpenAPI spec");
|
|
4023
|
+
log.blank();
|
|
4024
|
+
log.step("Generated files in frontend/src/api/generated/:");
|
|
4025
|
+
log.step(" types.gen.ts \u2192 TypeScript interfaces");
|
|
4026
|
+
log.step(" zod.gen.ts \u2192 Zod validation schemas");
|
|
4027
|
+
log.step(" sdk.gen.ts \u2192 API client functions");
|
|
4028
|
+
log.step(" @tanstack/react-query.gen.ts \u2192 TanStack Query hooks");
|
|
4029
|
+
log.blank();
|
|
4030
|
+
} catch (error) {
|
|
4031
|
+
s.fail("Failed to sync OpenAPI schema");
|
|
4032
|
+
log.error(error.message || error);
|
|
4033
|
+
process.exit(1);
|
|
4034
|
+
}
|
|
4035
|
+
}
|
|
4036
|
+
|
|
4037
|
+
// src/commands/make-resource.ts
|
|
4038
|
+
import path7 from "path";
|
|
4039
|
+
import fs6 from "fs";
|
|
4040
|
+
|
|
4041
|
+
// src/utils/names.ts
|
|
4042
|
+
import { pascalCase, snakeCase, kebabCase, camelCase } from "change-case";
|
|
4043
|
+
import pluralize from "pluralize";
|
|
4044
|
+
function generateNames(input) {
|
|
4045
|
+
const singular = pascalCase(input);
|
|
4046
|
+
const plural = pluralize(singular);
|
|
4047
|
+
return {
|
|
4048
|
+
Name: singular,
|
|
4049
|
+
Names: plural,
|
|
4050
|
+
name: camelCase(singular),
|
|
4051
|
+
names: camelCase(plural),
|
|
4052
|
+
snake: snakeCase(singular),
|
|
4053
|
+
snakes: snakeCase(plural),
|
|
4054
|
+
kebab: kebabCase(singular),
|
|
4055
|
+
kebabs: kebabCase(plural),
|
|
4056
|
+
UPPER: snakeCase(singular).toUpperCase(),
|
|
4057
|
+
UPPERS: snakeCase(plural).toUpperCase()
|
|
4058
|
+
};
|
|
4059
|
+
}
|
|
4060
|
+
|
|
4061
|
+
// src/commands/make-resource.ts
|
|
4062
|
+
async function makeResource(name) {
|
|
4063
|
+
let root;
|
|
4064
|
+
try {
|
|
4065
|
+
root = findProjectRoot();
|
|
4066
|
+
} catch {
|
|
4067
|
+
log.error('Not inside a Blacksmith project. Run "blacksmith init <name>" first.');
|
|
4068
|
+
process.exit(1);
|
|
4069
|
+
}
|
|
4070
|
+
const names = generateNames(name);
|
|
4071
|
+
const backendDir = getBackendDir(root);
|
|
4072
|
+
const frontendDir = getFrontendDir(root);
|
|
4073
|
+
const templatesDir = getTemplatesDir();
|
|
4074
|
+
const backendAppDir = path7.join(backendDir, "apps", names.snakes);
|
|
4075
|
+
if (fs6.existsSync(backendAppDir)) {
|
|
4076
|
+
log.error(`Backend app "${names.snakes}" already exists.`);
|
|
4077
|
+
process.exit(1);
|
|
4078
|
+
}
|
|
4079
|
+
const frontendPageDir = path7.join(frontendDir, "src", "pages", names.kebabs);
|
|
4080
|
+
if (fs6.existsSync(frontendPageDir)) {
|
|
4081
|
+
log.error(`Frontend page "${names.kebabs}" already exists.`);
|
|
4082
|
+
process.exit(1);
|
|
4083
|
+
}
|
|
4084
|
+
const context = { ...names, projectName: name };
|
|
4085
|
+
const backendSpinner = spinner(`Creating backend app: apps/${names.snakes}/`);
|
|
4086
|
+
try {
|
|
4087
|
+
renderDirectory(
|
|
4088
|
+
path7.join(templatesDir, "resource", "backend"),
|
|
4089
|
+
backendAppDir,
|
|
4090
|
+
context
|
|
4091
|
+
);
|
|
4092
|
+
backendSpinner.succeed(`Created backend/apps/${names.snakes}/`);
|
|
4093
|
+
} catch (error) {
|
|
4094
|
+
backendSpinner.fail("Failed to create backend app");
|
|
4095
|
+
log.error(error.message);
|
|
4096
|
+
process.exit(1);
|
|
4097
|
+
}
|
|
4098
|
+
const registerSpinner = spinner("Registering app in Django settings...");
|
|
4099
|
+
try {
|
|
4100
|
+
const settingsPath = path7.join(backendDir, "config", "settings", "base.py");
|
|
4101
|
+
appendAfterMarker(
|
|
4102
|
+
settingsPath,
|
|
4103
|
+
"# blacksmith:apps",
|
|
4104
|
+
` 'apps.${names.snakes}',`
|
|
4105
|
+
);
|
|
4106
|
+
registerSpinner.succeed("Registered in INSTALLED_APPS");
|
|
4107
|
+
} catch (error) {
|
|
4108
|
+
registerSpinner.fail("Failed to register app in settings");
|
|
4109
|
+
log.error(error.message);
|
|
4110
|
+
process.exit(1);
|
|
4111
|
+
}
|
|
4112
|
+
const urlSpinner = spinner("Registering API URLs...");
|
|
4113
|
+
try {
|
|
4114
|
+
const urlsPath = path7.join(backendDir, "config", "urls.py");
|
|
4115
|
+
insertBeforeMarker(
|
|
4116
|
+
urlsPath,
|
|
4117
|
+
"# blacksmith:urls",
|
|
4118
|
+
` path('api/${names.snakes}/', include('apps.${names.snakes}.urls')),`
|
|
4119
|
+
);
|
|
4120
|
+
urlSpinner.succeed("Registered API URLs");
|
|
4121
|
+
} catch (error) {
|
|
4122
|
+
urlSpinner.fail("Failed to register URLs");
|
|
4123
|
+
log.error(error.message);
|
|
4124
|
+
process.exit(1);
|
|
4125
|
+
}
|
|
4126
|
+
const migrateSpinner = spinner("Running migrations...");
|
|
4127
|
+
try {
|
|
4128
|
+
await execPython(["manage.py", "makemigrations", names.snakes], backendDir, true);
|
|
4129
|
+
await execPython(["manage.py", "migrate"], backendDir, true);
|
|
4130
|
+
migrateSpinner.succeed("Migrations complete");
|
|
4131
|
+
} catch (error) {
|
|
4132
|
+
migrateSpinner.fail("Migration failed");
|
|
4133
|
+
log.error(error.message);
|
|
4134
|
+
process.exit(1);
|
|
4135
|
+
}
|
|
4136
|
+
const syncSpinner = spinner("Syncing OpenAPI schema...");
|
|
4137
|
+
try {
|
|
4138
|
+
const schemaPath = path7.join(frontendDir, "_schema.yml");
|
|
4139
|
+
await execPython(["manage.py", "spectacular", "--file", schemaPath], backendDir, true);
|
|
4140
|
+
const configPath = path7.join(frontendDir, "openapi-ts.config.ts");
|
|
4141
|
+
const configBackup = fs6.readFileSync(configPath, "utf-8");
|
|
4142
|
+
const configWithFile = configBackup.replace(
|
|
4143
|
+
/path:\s*['"]http[^'"]+['"]/,
|
|
4144
|
+
`path: './_schema.yml'`
|
|
4145
|
+
);
|
|
4146
|
+
fs6.writeFileSync(configPath, configWithFile, "utf-8");
|
|
4147
|
+
try {
|
|
4148
|
+
await exec(process.execPath, [path7.join(frontendDir, "node_modules", ".bin", "openapi-ts")], {
|
|
4149
|
+
cwd: frontendDir,
|
|
4150
|
+
silent: true
|
|
4151
|
+
});
|
|
4152
|
+
} finally {
|
|
4153
|
+
fs6.writeFileSync(configPath, configBackup, "utf-8");
|
|
4154
|
+
if (fs6.existsSync(schemaPath)) fs6.unlinkSync(schemaPath);
|
|
4155
|
+
}
|
|
4156
|
+
syncSpinner.succeed("Frontend types and hooks regenerated");
|
|
4157
|
+
} catch {
|
|
4158
|
+
syncSpinner.warn('Could not sync OpenAPI. Run "blacksmith sync" manually.');
|
|
4159
|
+
}
|
|
4160
|
+
const frontendSpinner = spinner(`Creating frontend page: pages/${names.kebabs}/`);
|
|
4161
|
+
try {
|
|
4162
|
+
renderDirectory(
|
|
4163
|
+
path7.join(templatesDir, "resource", "pages"),
|
|
4164
|
+
frontendPageDir,
|
|
4165
|
+
context
|
|
4166
|
+
);
|
|
4167
|
+
frontendSpinner.succeed(`Created frontend/src/pages/${names.kebabs}/`);
|
|
4168
|
+
} catch (error) {
|
|
4169
|
+
frontendSpinner.fail("Failed to create frontend page");
|
|
4170
|
+
log.error(error.message);
|
|
4171
|
+
process.exit(1);
|
|
4172
|
+
}
|
|
4173
|
+
const pathSpinner = spinner("Registering route path...");
|
|
4174
|
+
try {
|
|
4175
|
+
const pathsFile = path7.join(frontendDir, "src", "router", "paths.ts");
|
|
4176
|
+
insertBeforeMarker(
|
|
4177
|
+
pathsFile,
|
|
4178
|
+
"// blacksmith:path",
|
|
4179
|
+
` ${names.Names} = '/${names.kebabs}',`
|
|
4180
|
+
);
|
|
4181
|
+
pathSpinner.succeed("Registered route path");
|
|
4182
|
+
} catch {
|
|
4183
|
+
pathSpinner.warn("Could not auto-register path. Add it manually to frontend/src/router/paths.ts");
|
|
4184
|
+
}
|
|
4185
|
+
const routeSpinner = spinner("Registering frontend routes...");
|
|
4186
|
+
try {
|
|
4187
|
+
const routesPath = path7.join(frontendDir, "src", "router", "routes.tsx");
|
|
4188
|
+
insertBeforeMarker(
|
|
4189
|
+
routesPath,
|
|
4190
|
+
"// blacksmith:import",
|
|
4191
|
+
`import { ${names.names}Routes } from '@/pages/${names.kebabs}'`
|
|
4192
|
+
);
|
|
4193
|
+
insertBeforeMarker(
|
|
4194
|
+
routesPath,
|
|
4195
|
+
"// blacksmith:routes",
|
|
4196
|
+
` ...${names.names}Routes,`
|
|
4197
|
+
);
|
|
4198
|
+
routeSpinner.succeed("Registered frontend routes");
|
|
4199
|
+
} catch {
|
|
4200
|
+
routeSpinner.warn("Could not auto-register routes. Add them manually to frontend/src/router/routes.tsx");
|
|
4201
|
+
}
|
|
4202
|
+
log.blank();
|
|
4203
|
+
log.success(`Resource "${names.Name}" created successfully!`);
|
|
4204
|
+
log.blank();
|
|
4205
|
+
}
|
|
4206
|
+
|
|
4207
|
+
// src/commands/build.ts
|
|
4208
|
+
async function build() {
|
|
4209
|
+
let root;
|
|
4210
|
+
try {
|
|
4211
|
+
root = findProjectRoot();
|
|
4212
|
+
} catch {
|
|
4213
|
+
log.error('Not inside a Blacksmith project. Run "blacksmith init <name>" first.');
|
|
4214
|
+
process.exit(1);
|
|
4215
|
+
}
|
|
4216
|
+
const backendDir = getBackendDir(root);
|
|
4217
|
+
const frontendDir = getFrontendDir(root);
|
|
4218
|
+
const frontendSpinner = spinner("Building frontend...");
|
|
4219
|
+
try {
|
|
4220
|
+
await exec("npm", ["run", "build"], { cwd: frontendDir, silent: true });
|
|
4221
|
+
frontendSpinner.succeed("Frontend built \u2192 frontend/dist/");
|
|
4222
|
+
} catch (error) {
|
|
4223
|
+
frontendSpinner.fail("Frontend build failed");
|
|
4224
|
+
log.error(error.message || error);
|
|
4225
|
+
process.exit(1);
|
|
4226
|
+
}
|
|
4227
|
+
const backendSpinner = spinner("Collecting static files...");
|
|
4228
|
+
try {
|
|
4229
|
+
await execPython(
|
|
4230
|
+
["manage.py", "collectstatic", "--noinput"],
|
|
4231
|
+
backendDir,
|
|
4232
|
+
true
|
|
4233
|
+
);
|
|
4234
|
+
backendSpinner.succeed("Static files collected");
|
|
4235
|
+
} catch (error) {
|
|
4236
|
+
backendSpinner.fail("Failed to collect static files");
|
|
4237
|
+
log.error(error.message || error);
|
|
4238
|
+
process.exit(1);
|
|
4239
|
+
}
|
|
4240
|
+
log.blank();
|
|
4241
|
+
log.success("Production build complete!");
|
|
4242
|
+
log.blank();
|
|
4243
|
+
log.step("Frontend assets: frontend/dist/");
|
|
4244
|
+
log.step("Backend ready for deployment");
|
|
4245
|
+
log.blank();
|
|
4246
|
+
}
|
|
4247
|
+
|
|
4248
|
+
// src/commands/eject.ts
|
|
4249
|
+
import fs7 from "fs";
|
|
4250
|
+
import path8 from "path";
|
|
4251
|
+
async function eject() {
|
|
4252
|
+
let root;
|
|
4253
|
+
try {
|
|
4254
|
+
root = findProjectRoot();
|
|
4255
|
+
} catch {
|
|
4256
|
+
log.error("Not inside a Blacksmith project.");
|
|
4257
|
+
process.exit(1);
|
|
4258
|
+
}
|
|
4259
|
+
const configPath = path8.join(root, "blacksmith.config.json");
|
|
4260
|
+
if (fs7.existsSync(configPath)) {
|
|
4261
|
+
fs7.unlinkSync(configPath);
|
|
4262
|
+
}
|
|
4263
|
+
log.success("Blacksmith has been ejected.");
|
|
4264
|
+
log.blank();
|
|
4265
|
+
log.step("Your project is now a standard Django + React project.");
|
|
4266
|
+
log.step("All generated code remains in place and is fully owned by you.");
|
|
4267
|
+
log.step("The blacksmith CLI commands will no longer work in this directory.");
|
|
4268
|
+
log.blank();
|
|
4269
|
+
log.info("To continue development without Blacksmith:");
|
|
4270
|
+
log.step("Backend: cd backend && ./venv/bin/python manage.py runserver");
|
|
4271
|
+
log.step("Frontend: cd frontend && npm run dev");
|
|
4272
|
+
log.step("Codegen: cd frontend && npx openapi-ts");
|
|
4273
|
+
log.blank();
|
|
4274
|
+
}
|
|
4275
|
+
|
|
4276
|
+
// src/commands/skills.ts
|
|
4277
|
+
import fs8 from "fs";
|
|
4278
|
+
var allSkills = [
|
|
4279
|
+
projectOverviewSkill,
|
|
4280
|
+
djangoSkill,
|
|
4281
|
+
djangoRestAdvancedSkill,
|
|
4282
|
+
apiDocumentationSkill,
|
|
4283
|
+
reactSkill,
|
|
4284
|
+
blacksmithUiReactSkill,
|
|
4285
|
+
blacksmithUiFormsSkill,
|
|
4286
|
+
blacksmithUiAuthSkill,
|
|
4287
|
+
blacksmithHooksSkill,
|
|
4288
|
+
blacksmithCliSkill,
|
|
4289
|
+
cleanCodeSkill,
|
|
4290
|
+
aiGuidelinesSkill
|
|
4291
|
+
];
|
|
4292
|
+
async function setupSkills(options) {
|
|
4293
|
+
let root;
|
|
4294
|
+
try {
|
|
4295
|
+
root = findProjectRoot();
|
|
4296
|
+
} catch {
|
|
4297
|
+
log.error('Not inside a Blacksmith project. Run "blacksmith init <name>" first.');
|
|
4298
|
+
process.exit(1);
|
|
4299
|
+
}
|
|
4300
|
+
const config = loadConfig(root);
|
|
4301
|
+
await setupAiDev({
|
|
4302
|
+
projectDir: root,
|
|
4303
|
+
projectName: config.name,
|
|
4304
|
+
includeBlacksmithUiSkill: options.blacksmithUiSkill !== false
|
|
4305
|
+
});
|
|
4306
|
+
log.blank();
|
|
4307
|
+
log.success("AI skills generated:");
|
|
4308
|
+
log.step(" CLAUDE.md \u2192 project overview + guidelines");
|
|
4309
|
+
log.step(" .claude/skills/*/SKILL.md \u2192 detailed skill files");
|
|
4310
|
+
}
|
|
4311
|
+
function listSkills() {
|
|
4312
|
+
let root;
|
|
4313
|
+
try {
|
|
4314
|
+
root = findProjectRoot();
|
|
4315
|
+
} catch {
|
|
4316
|
+
log.error('Not inside a Blacksmith project. Run "blacksmith init <name>" first.');
|
|
4317
|
+
process.exit(1);
|
|
4318
|
+
}
|
|
4319
|
+
const hasClaude = fs8.existsSync(`${root}/CLAUDE.md`);
|
|
4320
|
+
const hasSkillsDir = fs8.existsSync(`${root}/.claude/skills`);
|
|
4321
|
+
const inlineSkills = allSkills.filter((s) => !s.name);
|
|
4322
|
+
const fileSkills = allSkills.filter((s) => s.name);
|
|
4323
|
+
log.info("Inline skills (in CLAUDE.md):");
|
|
4324
|
+
for (const skill of inlineSkills) {
|
|
4325
|
+
log.step(` ${skill.id}`);
|
|
4326
|
+
}
|
|
4327
|
+
log.blank();
|
|
4328
|
+
log.info("File-based skills (in .claude/skills/):");
|
|
4329
|
+
for (const skill of fileSkills) {
|
|
4330
|
+
const exists = hasSkillsDir && fs8.existsSync(`${root}/.claude/skills/${skill.id}/SKILL.md`);
|
|
4331
|
+
const status = exists ? "\u2713" : "\u2717";
|
|
4332
|
+
log.step(` ${status} ${skill.id}/SKILL.md \u2014 ${skill.name}`);
|
|
4333
|
+
}
|
|
4334
|
+
log.blank();
|
|
4335
|
+
if (hasClaude && hasSkillsDir) {
|
|
4336
|
+
log.success('AI skills are set up. Run "blacksmith setup:ai" to regenerate.');
|
|
4337
|
+
} else {
|
|
4338
|
+
log.info('Run "blacksmith setup:ai" to generate AI skills.');
|
|
4339
|
+
}
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
// src/commands/backend.ts
|
|
4343
|
+
async function backend(args) {
|
|
4344
|
+
let root;
|
|
4345
|
+
try {
|
|
4346
|
+
root = findProjectRoot();
|
|
4347
|
+
} catch {
|
|
4348
|
+
log.error('Not inside a Blacksmith project. Run "blacksmith init <name>" first.');
|
|
4349
|
+
process.exit(1);
|
|
4350
|
+
}
|
|
4351
|
+
if (args.length === 0) {
|
|
4352
|
+
log.error("Please provide a Django management command.");
|
|
4353
|
+
log.step("Usage: blacksmith backend <command> [args...]");
|
|
4354
|
+
log.step("Example: blacksmith backend createsuperuser");
|
|
4355
|
+
process.exit(1);
|
|
4356
|
+
}
|
|
4357
|
+
const backendDir = getBackendDir(root);
|
|
4358
|
+
try {
|
|
4359
|
+
await execPython(["manage.py", ...args], backendDir);
|
|
4360
|
+
} catch {
|
|
4361
|
+
process.exit(1);
|
|
4362
|
+
}
|
|
4363
|
+
}
|
|
4364
|
+
|
|
4365
|
+
// src/commands/frontend.ts
|
|
4366
|
+
async function frontend(args) {
|
|
4367
|
+
let root;
|
|
4368
|
+
try {
|
|
4369
|
+
root = findProjectRoot();
|
|
4370
|
+
} catch {
|
|
4371
|
+
log.error('Not inside a Blacksmith project. Run "blacksmith init <name>" first.');
|
|
4372
|
+
process.exit(1);
|
|
4373
|
+
}
|
|
4374
|
+
if (args.length === 0) {
|
|
4375
|
+
log.error("Please provide an npm command.");
|
|
4376
|
+
log.step("Usage: blacksmith frontend <command> [args...]");
|
|
4377
|
+
log.step("Example: blacksmith frontend install axios");
|
|
4378
|
+
process.exit(1);
|
|
4379
|
+
}
|
|
4380
|
+
const frontendDir = getFrontendDir(root);
|
|
4381
|
+
try {
|
|
4382
|
+
await exec("npm", args, { cwd: frontendDir });
|
|
4383
|
+
} catch {
|
|
4384
|
+
process.exit(1);
|
|
4385
|
+
}
|
|
4386
|
+
}
|
|
4387
|
+
|
|
4388
|
+
// src/index.ts
|
|
4389
|
+
var program = new Command();
|
|
4390
|
+
program.name("blacksmith").description("Fullstack Django + React framework").version("0.1.0").hook("preAction", () => {
|
|
4391
|
+
banner();
|
|
4392
|
+
});
|
|
4393
|
+
program.command("init").argument("[name]", "Project name").option("--ai", "Set up AI development skills and documentation (CLAUDE.md)").option("--no-blacksmith-ui-skill", "Disable blacksmith-ui skill when using --ai").option("-b, --backend-port <port>", "Django backend port (default: 8000)").option("-f, --frontend-port <port>", "Vite frontend port (default: 5173)").option("-t, --theme-color <color>", "Theme color (zinc, slate, blue, green, orange, red, violet)").description("Create a new Blacksmith project").action(init);
|
|
4394
|
+
program.command("dev").description("Start development servers (Django + Vite + OpenAPI sync)").action(dev);
|
|
4395
|
+
program.command("sync").description("Sync OpenAPI schema to frontend types, schemas, and hooks").action(sync);
|
|
4396
|
+
program.command("make:resource").argument("<name>", "Resource name (PascalCase, e.g. BlogPost)").description("Create a new resource (model, serializer, viewset, hooks, pages)").action(makeResource);
|
|
4397
|
+
program.command("build").description("Build both frontend and backend for production").action(build);
|
|
4398
|
+
program.command("eject").description("Remove Blacksmith, keep a clean Django + React project").action(eject);
|
|
4399
|
+
program.command("setup:ai").description("Generate CLAUDE.md with AI development skills for the project").option("--no-blacksmith-ui-skill", "Exclude blacksmith-ui skill").action(setupSkills);
|
|
4400
|
+
program.command("skills").description("List all available AI development skills").action(listSkills);
|
|
4401
|
+
program.command("backend").argument("[args...]", "Django management command and arguments").description("Run a Django management command (e.g. blacksmith backend createsuperuser)").allowUnknownOption().action(backend);
|
|
4402
|
+
program.command("frontend").argument("[args...]", "npm command and arguments").description("Run an npm command in the frontend (e.g. blacksmith frontend install axios)").allowUnknownOption().action(frontend);
|
|
4403
|
+
program.parse();
|
|
4404
|
+
//# sourceMappingURL=index.js.map
|