create-reactor 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +123 -0
- package/create-app.mjs +712 -0
- package/lib/build.mjs +434 -0
- package/lib/pm.mjs +85 -0
- package/lib/presets.mjs +122 -0
- package/lib/templates/ai-docs.mjs +80 -0
- package/lib/templates/app.mjs +961 -0
- package/lib/templates/backend.mjs +715 -0
- package/lib/templates/base.mjs +671 -0
- package/lib/templates/biome.mjs +107 -0
- package/lib/templates/extras.mjs +360 -0
- package/lib/templates/features.mjs +463 -0
- package/lib/templates/quality.mjs +159 -0
- package/lib/templates/readme.mjs +351 -0
- package/lib/templates/security.mjs +70 -0
- package/lib/templates/server.mjs +141 -0
- package/lib/templates/state.mjs +192 -0
- package/package.json +52 -0
|
@@ -0,0 +1,463 @@
|
|
|
1
|
+
// Feature extras: Stripe, Resend/React Email, PostHog, i18n, PWA, deploy configs,
|
|
2
|
+
// Recharts, GSAP, Tiptap, Leaflet.
|
|
3
|
+
|
|
4
|
+
// ---------------------------------------------------------------------------
|
|
5
|
+
// Stripe (payments)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
export function stripeLib() {
|
|
9
|
+
return `import { loadStripe } from "@stripe/stripe-js";
|
|
10
|
+
|
|
11
|
+
// Stripe.js client — safe for the browser (publishable key only).
|
|
12
|
+
// Server-side operations (checkout sessions, webhooks) need STRIPE_SECRET_KEY
|
|
13
|
+
// and must run on your backend. See the README's Stripe section.
|
|
14
|
+
export const stripePromise = loadStripe(
|
|
15
|
+
import.meta.env.VITE_STRIPE_PUBLISHABLE_KEY,
|
|
16
|
+
);
|
|
17
|
+
`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Resend + React Email
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
export function welcomeEmail(c) {
|
|
25
|
+
return `import {
|
|
26
|
+
Body,
|
|
27
|
+
Button,
|
|
28
|
+
Container,
|
|
29
|
+
Head,
|
|
30
|
+
Heading,
|
|
31
|
+
Html,
|
|
32
|
+
Preview,
|
|
33
|
+
Text,
|
|
34
|
+
} from "@react-email/components";
|
|
35
|
+
|
|
36
|
+
// Edit this template, then send it server-side with Resend:
|
|
37
|
+
//
|
|
38
|
+
// import { Resend } from "resend";
|
|
39
|
+
// const resend = new Resend(process.env.RESEND_API_KEY);
|
|
40
|
+
// await resend.emails.send({
|
|
41
|
+
// from: "you@yourdomain.com",
|
|
42
|
+
// to: user.email,
|
|
43
|
+
// subject: "Welcome!",
|
|
44
|
+
// react: <WelcomeEmail name={user.name} />,
|
|
45
|
+
// });
|
|
46
|
+
export function WelcomeEmail({ name = "there" }: { name?: string }) {
|
|
47
|
+
return (
|
|
48
|
+
<Html>
|
|
49
|
+
<Head />
|
|
50
|
+
<Preview>Welcome to ${c.name}</Preview>
|
|
51
|
+
<Body style={{ backgroundColor: "#f4f4f5", fontFamily: "sans-serif" }}>
|
|
52
|
+
<Container
|
|
53
|
+
style={{
|
|
54
|
+
backgroundColor: "#ffffff",
|
|
55
|
+
borderRadius: "8px",
|
|
56
|
+
margin: "40px auto",
|
|
57
|
+
maxWidth: "480px",
|
|
58
|
+
padding: "32px",
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
<Heading style={{ fontSize: "22px" }}>Welcome, {name}!</Heading>
|
|
62
|
+
<Text style={{ color: "#52525b" }}>
|
|
63
|
+
Your ${c.name} account is ready. Jump back in to get started.
|
|
64
|
+
</Text>
|
|
65
|
+
<Button
|
|
66
|
+
href="http://localhost:5173"
|
|
67
|
+
style={{
|
|
68
|
+
backgroundColor: "#18181b",
|
|
69
|
+
borderRadius: "6px",
|
|
70
|
+
color: "#ffffff",
|
|
71
|
+
display: "inline-block",
|
|
72
|
+
padding: "12px 20px",
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
Open the app
|
|
76
|
+
</Button>
|
|
77
|
+
</Container>
|
|
78
|
+
</Body>
|
|
79
|
+
</Html>
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
// PostHog (product analytics)
|
|
87
|
+
// ---------------------------------------------------------------------------
|
|
88
|
+
|
|
89
|
+
export function posthogInit() {
|
|
90
|
+
return `import posthog from "posthog-js";
|
|
91
|
+
|
|
92
|
+
const key = import.meta.env.VITE_POSTHOG_KEY;
|
|
93
|
+
|
|
94
|
+
// Only initialize when a key is configured (so local dev without PostHog works).
|
|
95
|
+
if (key) {
|
|
96
|
+
posthog.init(key, {
|
|
97
|
+
api_host: import.meta.env.VITE_POSTHOG_HOST || "https://us.i.posthog.com",
|
|
98
|
+
defaults: "2025-05-24",
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export { posthog };
|
|
103
|
+
`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// i18n (react-i18next)
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
export function i18nSetup() {
|
|
111
|
+
return `import i18n from "i18next";
|
|
112
|
+
import { initReactI18next } from "react-i18next";
|
|
113
|
+
|
|
114
|
+
// Add languages and translations here. Usage in components:
|
|
115
|
+
// const { t, i18n } = useTranslation();
|
|
116
|
+
// <h1>{t("home.title")}</h1>
|
|
117
|
+
// i18n.changeLanguage("es")
|
|
118
|
+
const resources = {
|
|
119
|
+
en: {
|
|
120
|
+
translation: {
|
|
121
|
+
home: {
|
|
122
|
+
title: "Welcome",
|
|
123
|
+
subtitle: "Your stack is wired up and ready to build.",
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
es: {
|
|
128
|
+
translation: {
|
|
129
|
+
home: {
|
|
130
|
+
title: "Bienvenido",
|
|
131
|
+
subtitle: "Tu stack está configurado y listo para construir.",
|
|
132
|
+
},
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
void i18n.use(initReactI18next).init({
|
|
138
|
+
resources,
|
|
139
|
+
lng: "en",
|
|
140
|
+
fallbackLng: "en",
|
|
141
|
+
interpolation: {
|
|
142
|
+
escapeValue: false, // React already escapes
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
export default i18n;
|
|
147
|
+
`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Deployment configs
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
export function dockerfile(c) {
|
|
155
|
+
const build = {
|
|
156
|
+
bun: `FROM oven/bun:1 AS build
|
|
157
|
+
WORKDIR /app
|
|
158
|
+
COPY package.json bun.lock* ./
|
|
159
|
+
RUN bun install
|
|
160
|
+
COPY . .
|
|
161
|
+
RUN bun run build`,
|
|
162
|
+
pnpm: `FROM node:22-alpine AS build
|
|
163
|
+
WORKDIR /app
|
|
164
|
+
RUN corepack enable
|
|
165
|
+
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* ./
|
|
166
|
+
RUN pnpm install
|
|
167
|
+
COPY . .
|
|
168
|
+
RUN pnpm run build`,
|
|
169
|
+
npm: `FROM node:22-alpine AS build
|
|
170
|
+
WORKDIR /app
|
|
171
|
+
COPY package.json package-lock.json* .npmrc* ./
|
|
172
|
+
RUN npm install
|
|
173
|
+
COPY . .
|
|
174
|
+
RUN npm run build`,
|
|
175
|
+
}[c.pm];
|
|
176
|
+
|
|
177
|
+
return `# Multi-stage build: compile the app, then serve the static files with nginx.
|
|
178
|
+
# docker build -t ${c.name} .
|
|
179
|
+
# docker run -p 8080:80 ${c.name}
|
|
180
|
+
${build}
|
|
181
|
+
|
|
182
|
+
FROM nginx:alpine
|
|
183
|
+
COPY --from=build /app/dist /usr/share/nginx/html
|
|
184
|
+
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
185
|
+
EXPOSE 80
|
|
186
|
+
CMD ["nginx", "-g", "daemon off;"]
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export function nginxConf() {
|
|
191
|
+
return `server {
|
|
192
|
+
listen 80;
|
|
193
|
+
root /usr/share/nginx/html;
|
|
194
|
+
index index.html;
|
|
195
|
+
|
|
196
|
+
# Single-page app: route everything that isn't a file to index.html
|
|
197
|
+
location / {
|
|
198
|
+
try_files $uri $uri/ /index.html;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
# Cache hashed assets aggressively
|
|
202
|
+
location /assets/ {
|
|
203
|
+
expires 1y;
|
|
204
|
+
add_header Cache-Control "public, immutable";
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export function dockerignore() {
|
|
211
|
+
return `node_modules
|
|
212
|
+
dist
|
|
213
|
+
.git
|
|
214
|
+
.env
|
|
215
|
+
.env.*
|
|
216
|
+
*.log
|
|
217
|
+
e2e
|
|
218
|
+
test-results
|
|
219
|
+
playwright-report
|
|
220
|
+
`;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function vercelJson() {
|
|
224
|
+
return JSON.stringify(
|
|
225
|
+
{
|
|
226
|
+
$schema: "https://openapi.vercel.sh/vercel.json",
|
|
227
|
+
rewrites: [{ source: "/(.*)", destination: "/index.html" }],
|
|
228
|
+
},
|
|
229
|
+
null,
|
|
230
|
+
2,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function netlifyToml() {
|
|
235
|
+
return `[build]
|
|
236
|
+
publish = "dist"
|
|
237
|
+
|
|
238
|
+
# Single-page app: route everything to index.html
|
|
239
|
+
[[redirects]]
|
|
240
|
+
from = "/*"
|
|
241
|
+
to = "/index.html"
|
|
242
|
+
status = 200
|
|
243
|
+
`;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------------------
|
|
247
|
+
// Recharts (charts demo)
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
export function chartDemo() {
|
|
251
|
+
return `import {
|
|
252
|
+
Area,
|
|
253
|
+
AreaChart,
|
|
254
|
+
CartesianGrid,
|
|
255
|
+
ResponsiveContainer,
|
|
256
|
+
Tooltip,
|
|
257
|
+
XAxis,
|
|
258
|
+
YAxis,
|
|
259
|
+
} from "recharts";
|
|
260
|
+
import {
|
|
261
|
+
Card,
|
|
262
|
+
CardContent,
|
|
263
|
+
CardDescription,
|
|
264
|
+
CardHeader,
|
|
265
|
+
CardTitle,
|
|
266
|
+
} from "@/components/ui/card";
|
|
267
|
+
|
|
268
|
+
const data = [
|
|
269
|
+
{ month: "Jan", users: 120 },
|
|
270
|
+
{ month: "Feb", users: 240 },
|
|
271
|
+
{ month: "Mar", users: 380 },
|
|
272
|
+
{ month: "Apr", users: 470 },
|
|
273
|
+
{ month: "May", users: 690 },
|
|
274
|
+
{ month: "Jun", users: 940 },
|
|
275
|
+
];
|
|
276
|
+
|
|
277
|
+
export function ChartDemo() {
|
|
278
|
+
return (
|
|
279
|
+
<Card>
|
|
280
|
+
<CardHeader>
|
|
281
|
+
<CardTitle>Recharts</CardTitle>
|
|
282
|
+
<CardDescription>
|
|
283
|
+
Edit <code>src/components/chart-demo.tsx</code> to chart your own data.
|
|
284
|
+
</CardDescription>
|
|
285
|
+
</CardHeader>
|
|
286
|
+
<CardContent>
|
|
287
|
+
<div className="h-64 w-full">
|
|
288
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
289
|
+
<AreaChart data={data} margin={{ top: 4, right: 4, bottom: 0, left: -20 }}>
|
|
290
|
+
<CartesianGrid strokeDasharray="3 3" className="stroke-border" />
|
|
291
|
+
<XAxis dataKey="month" fontSize={12} tickLine={false} axisLine={false} />
|
|
292
|
+
<YAxis fontSize={12} tickLine={false} axisLine={false} />
|
|
293
|
+
<Tooltip />
|
|
294
|
+
<Area
|
|
295
|
+
type="monotone"
|
|
296
|
+
dataKey="users"
|
|
297
|
+
stroke="var(--color-primary)"
|
|
298
|
+
fill="var(--color-primary)"
|
|
299
|
+
fillOpacity={0.15}
|
|
300
|
+
/>
|
|
301
|
+
</AreaChart>
|
|
302
|
+
</ResponsiveContainer>
|
|
303
|
+
</div>
|
|
304
|
+
</CardContent>
|
|
305
|
+
</Card>
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
`;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// ---------------------------------------------------------------------------
|
|
312
|
+
// GSAP (animation demo)
|
|
313
|
+
// ---------------------------------------------------------------------------
|
|
314
|
+
|
|
315
|
+
export function gsapDemo() {
|
|
316
|
+
return `import { useRef } from "react";
|
|
317
|
+
import gsap from "gsap";
|
|
318
|
+
import { useGSAP } from "@gsap/react";
|
|
319
|
+
import { Button } from "@/components/ui/button";
|
|
320
|
+
import {
|
|
321
|
+
Card,
|
|
322
|
+
CardContent,
|
|
323
|
+
CardDescription,
|
|
324
|
+
CardHeader,
|
|
325
|
+
CardTitle,
|
|
326
|
+
} from "@/components/ui/card";
|
|
327
|
+
|
|
328
|
+
gsap.registerPlugin(useGSAP);
|
|
329
|
+
|
|
330
|
+
export function GsapDemo() {
|
|
331
|
+
const container = useRef<HTMLDivElement>(null);
|
|
332
|
+
|
|
333
|
+
const { contextSafe } = useGSAP({ scope: container });
|
|
334
|
+
|
|
335
|
+
const replay = contextSafe(() => {
|
|
336
|
+
gsap.fromTo(
|
|
337
|
+
".gsap-box",
|
|
338
|
+
{ y: 24, opacity: 0, scale: 0.8 },
|
|
339
|
+
{ y: 0, opacity: 1, scale: 1, stagger: 0.08, ease: "back.out(1.7)" },
|
|
340
|
+
);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
useGSAP(
|
|
344
|
+
() => {
|
|
345
|
+
replay();
|
|
346
|
+
},
|
|
347
|
+
{ scope: container },
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
return (
|
|
351
|
+
<Card>
|
|
352
|
+
<CardHeader>
|
|
353
|
+
<CardTitle>GSAP</CardTitle>
|
|
354
|
+
<CardDescription>
|
|
355
|
+
Staggered entrance animation — see <code>src/components/gsap-demo.tsx</code>.
|
|
356
|
+
</CardDescription>
|
|
357
|
+
</CardHeader>
|
|
358
|
+
<CardContent ref={container} className="flex items-center gap-4">
|
|
359
|
+
<div className="flex gap-2">
|
|
360
|
+
{[0, 1, 2, 3, 4].map((i) => (
|
|
361
|
+
<div key={i} className="gsap-box bg-primary size-8 rounded-md" />
|
|
362
|
+
))}
|
|
363
|
+
</div>
|
|
364
|
+
<Button variant="outline" size="sm" onClick={replay}>
|
|
365
|
+
Replay
|
|
366
|
+
</Button>
|
|
367
|
+
</CardContent>
|
|
368
|
+
</Card>
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
`;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// Tiptap (rich text editor demo)
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
export function editorDemo() {
|
|
379
|
+
return `import { EditorContent, useEditor } from "@tiptap/react";
|
|
380
|
+
import StarterKit from "@tiptap/starter-kit";
|
|
381
|
+
import {
|
|
382
|
+
Card,
|
|
383
|
+
CardContent,
|
|
384
|
+
CardDescription,
|
|
385
|
+
CardHeader,
|
|
386
|
+
CardTitle,
|
|
387
|
+
} from "@/components/ui/card";
|
|
388
|
+
|
|
389
|
+
export function EditorDemo() {
|
|
390
|
+
const editor = useEditor({
|
|
391
|
+
extensions: [StarterKit],
|
|
392
|
+
content: "<p>A rich text editor powered by <strong>Tiptap</strong>. Try <em>bold</em>, lists, headings…</p>",
|
|
393
|
+
editorProps: {
|
|
394
|
+
attributes: {
|
|
395
|
+
class:
|
|
396
|
+
"min-h-28 rounded-md border px-3 py-2 text-sm focus:outline-none [&_p]:my-2 [&_h1]:text-xl [&_h1]:font-bold [&_h2]:text-lg [&_h2]:font-semibold [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:list-decimal [&_ol]:pl-5 [&_blockquote]:border-l-2 [&_blockquote]:pl-3",
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
return (
|
|
402
|
+
<Card>
|
|
403
|
+
<CardHeader>
|
|
404
|
+
<CardTitle>Tiptap editor</CardTitle>
|
|
405
|
+
<CardDescription>
|
|
406
|
+
Headless rich text editing — see <code>src/components/editor-demo.tsx</code>.
|
|
407
|
+
</CardDescription>
|
|
408
|
+
</CardHeader>
|
|
409
|
+
<CardContent>
|
|
410
|
+
<EditorContent editor={editor} />
|
|
411
|
+
</CardContent>
|
|
412
|
+
</Card>
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
`;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// ---------------------------------------------------------------------------
|
|
419
|
+
// Leaflet (maps demo)
|
|
420
|
+
// ---------------------------------------------------------------------------
|
|
421
|
+
|
|
422
|
+
export function mapDemo() {
|
|
423
|
+
return `import "leaflet/dist/leaflet.css";
|
|
424
|
+
import { CircleMarker, MapContainer, Popup, TileLayer } from "react-leaflet";
|
|
425
|
+
import {
|
|
426
|
+
Card,
|
|
427
|
+
CardContent,
|
|
428
|
+
CardDescription,
|
|
429
|
+
CardHeader,
|
|
430
|
+
CardTitle,
|
|
431
|
+
} from "@/components/ui/card";
|
|
432
|
+
|
|
433
|
+
export function MapDemo() {
|
|
434
|
+
return (
|
|
435
|
+
<Card>
|
|
436
|
+
<CardHeader>
|
|
437
|
+
<CardTitle>Leaflet map</CardTitle>
|
|
438
|
+
<CardDescription>
|
|
439
|
+
OpenStreetMap tiles, no API key needed — see{" "}
|
|
440
|
+
<code>src/components/map-demo.tsx</code>.
|
|
441
|
+
</CardDescription>
|
|
442
|
+
</CardHeader>
|
|
443
|
+
<CardContent>
|
|
444
|
+
<MapContainer
|
|
445
|
+
center={[51.505, -0.09]}
|
|
446
|
+
zoom={13}
|
|
447
|
+
scrollWheelZoom={false}
|
|
448
|
+
className="h-64 w-full rounded-md"
|
|
449
|
+
>
|
|
450
|
+
<TileLayer
|
|
451
|
+
attribution='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
|
|
452
|
+
url="https://tile.openstreetmap.org/{z}/{x}/{y}.png"
|
|
453
|
+
/>
|
|
454
|
+
<CircleMarker center={[51.505, -0.09]} radius={10}>
|
|
455
|
+
<Popup>Hello from Leaflet!</Popup>
|
|
456
|
+
</CircleMarker>
|
|
457
|
+
</MapContainer>
|
|
458
|
+
</CardContent>
|
|
459
|
+
</Card>
|
|
460
|
+
);
|
|
461
|
+
}
|
|
462
|
+
`;
|
|
463
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// Quality tooling: Playwright E2E, MSW API mocking, Fallow codebase intelligence.
|
|
2
|
+
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
// Fallow (Rust-native codebase intelligence: dead code, duplication,
|
|
5
|
+
// complexity, health score, PR audit — supersedes Knip)
|
|
6
|
+
// ---------------------------------------------------------------------------
|
|
7
|
+
|
|
8
|
+
/** .fallowrc.json — tuned so a fresh starter passes its own quality gate. */
|
|
9
|
+
export function fallowConfig(c) {
|
|
10
|
+
// Generated files: never analyze
|
|
11
|
+
const ignorePatterns = [];
|
|
12
|
+
if (c.router === "tanstack") ignorePatterns.push("src/routeTree.gen.ts");
|
|
13
|
+
if (c.backend === "convex") ignorePatterns.push("convex/_generated/**");
|
|
14
|
+
if (c.orm === "prisma") ignorePatterns.push("src/generated/**");
|
|
15
|
+
// shadcn/ui is a vendored component library — its exports are intentional surface
|
|
16
|
+
ignorePatterns.push("src/components/ui/**");
|
|
17
|
+
|
|
18
|
+
// Scaffolding the user imports later + files loaded by tools (not import graphs)
|
|
19
|
+
const entry = [];
|
|
20
|
+
if (c.orm === "drizzle") entry.push("db/index.ts");
|
|
21
|
+
if (c.extras.includes("redis")) entry.push("db/redis.ts", "db/ratelimit.ts");
|
|
22
|
+
if (c.extras.includes("testing")) entry.push("src/test/setup.ts");
|
|
23
|
+
if (c.extras.includes("resend")) entry.push("src/emails/*.tsx");
|
|
24
|
+
if (c.extras.includes("motion")) entry.push("src/components/fade-in.tsx");
|
|
25
|
+
|
|
26
|
+
// Install-only extras: deps the user will import once they build features
|
|
27
|
+
const ignoreDependencies = [];
|
|
28
|
+
if (c.extras.includes("forms")) ignoreDependencies.push("react-hook-form", "@hookform/resolvers");
|
|
29
|
+
if (c.extras.includes("dates")) ignoreDependencies.push("date-fns");
|
|
30
|
+
if (c.extras.includes("stripe")) ignoreDependencies.push("@stripe/stripe-js");
|
|
31
|
+
if (c.extras.includes("resend")) ignoreDependencies.push("resend");
|
|
32
|
+
|
|
33
|
+
const jsonList = (arr) => arr.map((p) => JSON.stringify(p)).join(", ");
|
|
34
|
+
|
|
35
|
+
return `{
|
|
36
|
+
"$schema": "https://raw.githubusercontent.com/fallow-rs/fallow/main/schema.json",
|
|
37
|
+
"entry": [${jsonList(entry)}],
|
|
38
|
+
"ignorePatterns": [${jsonList(ignorePatterns)}],
|
|
39
|
+
"ignoreDependencies": [${jsonList(ignoreDependencies)}],
|
|
40
|
+
"rules": {
|
|
41
|
+
"unused-files": "error",
|
|
42
|
+
"unused-exports": "warn",
|
|
43
|
+
"unused-dependencies": "warn"
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Extra job appended to the CI workflow when Fallow + CI are both selected. */
|
|
50
|
+
export function fallowCiJob() {
|
|
51
|
+
return `
|
|
52
|
+
|
|
53
|
+
quality:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
permissions:
|
|
56
|
+
contents: read
|
|
57
|
+
pull-requests: write
|
|
58
|
+
steps:
|
|
59
|
+
- uses: actions/checkout@v4
|
|
60
|
+
with:
|
|
61
|
+
fetch-depth: 0 # full history for changed-code attribution
|
|
62
|
+
|
|
63
|
+
# Fallow: dead code, duplication, complexity and PR risk audit.
|
|
64
|
+
# Posts a sticky summary comment + inline review comments on PRs.
|
|
65
|
+
- uses: fallow-rs/fallow@v2
|
|
66
|
+
with:
|
|
67
|
+
command: audit
|
|
68
|
+
comment: true
|
|
69
|
+
review-comments: true`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
// Playwright (E2E)
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
export function playwrightConfig(c) {
|
|
77
|
+
// For Convex/Hono backends the dev script starts multiple servers;
|
|
78
|
+
// E2E only needs the frontend.
|
|
79
|
+
const devScript =
|
|
80
|
+
c.backend === "convex" ? "dev:frontend" : c.backend === "hono" ? "dev:web" : "dev";
|
|
81
|
+
return `import { defineConfig, devices } from "@playwright/test";
|
|
82
|
+
|
|
83
|
+
export default defineConfig({
|
|
84
|
+
testDir: "./e2e",
|
|
85
|
+
fullyParallel: true,
|
|
86
|
+
forbidOnly: !!process.env.CI,
|
|
87
|
+
retries: process.env.CI ? 2 : 0,
|
|
88
|
+
reporter: "html",
|
|
89
|
+
use: {
|
|
90
|
+
baseURL: "http://localhost:5173",
|
|
91
|
+
trace: "on-first-retry",
|
|
92
|
+
},
|
|
93
|
+
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
|
94
|
+
webServer: {
|
|
95
|
+
command: "${c.pmRunLabel(devScript)}",
|
|
96
|
+
url: "http://localhost:5173",
|
|
97
|
+
reuseExistingServer: !process.env.CI,
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function playwrightExampleTest(c) {
|
|
104
|
+
return `import { expect, test } from "@playwright/test";
|
|
105
|
+
|
|
106
|
+
test("home page renders the app", async ({ page }) => {
|
|
107
|
+
await page.goto("/");
|
|
108
|
+
await expect(page.getByRole("heading", { level: 1 })).toContainText("${c.name}");
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("theme toggle switches dark mode", async ({ page }) => {
|
|
112
|
+
await page.goto("/");
|
|
113
|
+
await page.getByRole("button", { name: "Toggle theme" }).click();
|
|
114
|
+
await expect(page.locator("html")).toHaveClass(/dark/);
|
|
115
|
+
});
|
|
116
|
+
`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ---------------------------------------------------------------------------
|
|
120
|
+
// MSW (Mock Service Worker)
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
export function mswHandlers() {
|
|
124
|
+
return `import { HttpResponse, http } from "msw";
|
|
125
|
+
|
|
126
|
+
// Add request handlers here — they intercept fetch/XHR in tests.
|
|
127
|
+
// Docs: https://mswjs.io/docs/basics/mocking-responses
|
|
128
|
+
export const handlers = [
|
|
129
|
+
http.get("/api/example", () => {
|
|
130
|
+
return HttpResponse.json({ message: "Hello from MSW!" });
|
|
131
|
+
}),
|
|
132
|
+
];
|
|
133
|
+
`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function mswServer() {
|
|
137
|
+
return `import { setupServer } from "msw/node";
|
|
138
|
+
import { handlers } from "./handlers";
|
|
139
|
+
|
|
140
|
+
export const server = setupServer(...handlers);
|
|
141
|
+
`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** src/test/setup.ts — composed based on whether MSW is enabled. */
|
|
145
|
+
export function testSetup(c) {
|
|
146
|
+
if (!c.extras.includes("msw")) {
|
|
147
|
+
return `import "@testing-library/jest-dom/vitest";
|
|
148
|
+
`;
|
|
149
|
+
}
|
|
150
|
+
return `import "@testing-library/jest-dom/vitest";
|
|
151
|
+
import { afterAll, afterEach, beforeAll } from "vitest";
|
|
152
|
+
import { server } from "./mocks/server";
|
|
153
|
+
|
|
154
|
+
// Start MSW so tests can mock network requests (see src/test/mocks/handlers.ts)
|
|
155
|
+
beforeAll(() => server.listen({ onUnhandledRequest: "bypass" }));
|
|
156
|
+
afterEach(() => server.resetHandlers());
|
|
157
|
+
afterAll(() => server.close());
|
|
158
|
+
`;
|
|
159
|
+
}
|