1use crate::prompts::InstanceConfig;
2
3pub fn render_env(config: &InstanceConfig) -> String {
4 let mut env = format!(
5 r#"# CommonPub Instance: {name}
6
7# Database (Nuxt reads NUXT_DATABASE_URL for runtimeConfig.databaseUrl)
8NUXT_DATABASE_URL={database_url}
9
10# Redis
11REDIS_URL={redis_url}
12
13# Auth
14AUTH_SECRET=change-me-in-production-min-32-chars
15NUXT_AUTH_ORIGIN=http://{domain}
16
17# Site URL (used for upload URLs and SEO)
18NUXT_PUBLIC_SITE_URL=http://{domain}
19
20# Instance
21INSTANCE_DOMAIN={domain}
22INSTANCE_NAME={name}
23INSTANCE_DESCRIPTION={description}
24
25# Feature Flags
26FEATURE_CONTENT={content}
27FEATURE_SOCIAL={social}
28FEATURE_HUBS={hubs}
29FEATURE_DOCS={docs}
30FEATURE_VIDEO={video}
31FEATURE_CONTESTS={contests}
32FEATURE_LEARNING={learning}
33FEATURE_EXPLAINERS={explainers}
34FEATURE_FEDERATION={federation}
35FEATURE_ADMIN={admin}
36
37# Search
38MEILI_URL=http://localhost:7700
39MEILI_MASTER_KEY=commonpub_dev_key
40
41# Email — "console" (dev), "smtp" (nodemailer), or "resend" (Resend API)
42EMAIL_ADAPTER=console
43# SMTP_HOST=smtp.example.com
44# SMTP_PORT=587
45# SMTP_USER=
46# SMTP_PASS=
47# SMTP_FROM=noreply@{domain}
48# RESEND_API_KEY=re_...
49# RESEND_FROM=noreply@{domain}
50"#,
51 name = config.name,
52 domain = config.domain,
53 description = config.description,
54 database_url = config.database_url,
55 redis_url = config.redis_url,
56 content = config.feature_content,
57 social = config.feature_social,
58 hubs = config.feature_hubs,
59 docs = config.feature_docs,
60 video = config.feature_video,
61 contests = config.feature_contests,
62 learning = config.feature_learning,
63 explainers = config.feature_explainers,
64 federation = config.feature_federation,
65 admin = config.feature_admin,
66 );
67
68 if config.auth_github {
70 env.push_str(
71 "\n# GitHub OAuth\nGITHUB_CLIENT_ID=\nGITHUB_CLIENT_SECRET=\n",
72 );
73 }
74 if config.auth_google {
75 env.push_str(
76 "\n# Google OAuth\nGOOGLE_CLIENT_ID=\nGOOGLE_CLIENT_SECRET=\n",
77 );
78 }
79
80 env.push_str(
82 r#"
83# Storage — set S3_BUCKET to enable S3/DO Spaces, otherwise local ./uploads
84# S3_BUCKET=
85# S3_REGION=us-east-1
86# S3_ENDPOINT=
87# S3_ACCESS_KEY=
88# S3_SECRET_KEY=
89# S3_PUBLIC_URL=
90"#,
91 );
92
93 env
94}
95
96pub fn render_config(config: &InstanceConfig) -> String {
97 let content_types_str = if !config.content_types.is_empty() {
98 let types: Vec<String> = config.content_types.iter().map(|t| format!("'{}'", t)).collect();
99 format!("\n contentTypes: [{}],", types.join(", "))
100 } else {
101 String::new()
102 };
103
104 let contest_creation_str = if config.feature_contests && config.contest_creation != "admin" {
105 format!("\n contestCreation: '{}',", config.contest_creation)
106 } else {
107 if config.feature_contests {
109 format!("\n contestCreation: '{}',", config.contest_creation)
110 } else {
111 String::new()
112 }
113 };
114
115 format!(
116 r#"import {{ defineCommonPubConfig }} from '@commonpub/config';
117
118export default defineCommonPubConfig({{
119 instance: {{
120 name: '{name}',
121 domain: '{domain}',
122 description: '{description}',{content_types_str}{contest_creation_str}
123 }},
124 features: {{
125 content: {content},
126 social: {social},
127 hubs: {hubs},
128 docs: {docs},
129 video: {video},
130 contests: {contests},
131 learning: {learning},
132 explainers: {explainers},
133 federation: {federation},
134 admin: {admin},
135 }},
136 auth: {{
137 emailPassword: {email_password},
138 magicLink: {magic_link},
139 passkeys: {passkeys},
140 }},
141}});
142"#,
143 name = config.name,
144 domain = config.domain,
145 description = config.description,
146 content = config.feature_content,
147 social = config.feature_social,
148 hubs = config.feature_hubs,
149 docs = config.feature_docs,
150 video = config.feature_video,
151 contests = config.feature_contests,
152 learning = config.feature_learning,
153 explainers = config.feature_explainers,
154 federation = config.feature_federation,
155 admin = config.feature_admin,
156 email_password = config.auth_email_password,
157 magic_link = config.auth_magic_link,
158 passkeys = config.auth_passkeys,
159 )
160}
161
162pub fn render_nuxt_config(config: &InstanceConfig) -> String {
163 let theme_css = if config.theme != "base" {
164 format!(
165 "\n '@commonpub/ui/theme/{}.css',",
166 config.theme
167 )
168 } else {
169 String::new()
170 };
171
172 format!(
173 r#"export default defineNuxtConfig({{
174 compatibilityDate: '2024-11-01',
175 devtools: {{ enabled: true }},
176 css: [
177 '@commonpub/ui/theme/base.css',
178 '@commonpub/ui/theme/dark.css',
179 '@commonpub/ui/theme/components.css',
180 '@commonpub/ui/theme/prose.css',
181 '@commonpub/ui/theme/layouts.css',
182 '@commonpub/ui/theme/forms.css',{theme_css}
183 ],
184 modules: [],
185 runtimeConfig: {{
186 databaseUrl: '',
187 authSecret: 'dev-secret-change-me',
188 emailAdapter: 'console',
189 smtpHost: '',
190 smtpPort: '587',
191 smtpUser: '',
192 smtpPass: '',
193 smtpFrom: '',
194 resendApiKey: '',
195 resendFrom: '',
196 s3Bucket: '',
197 s3Region: 'us-east-1',
198 s3Endpoint: '',
199 s3AccessKey: '',
200 s3SecretKey: '',
201 s3PublicUrl: '',
202 uploadDir: './uploads',
203 public: {{
204 siteUrl: 'http://{domain}',
205 domain: '{domain}',
206 siteName: '{name}',
207 siteDescription: '{description}',
208 features: {{
209 content: {content},
210 social: {social},
211 hubs: {hubs},
212 docs: {docs},
213 video: {video},
214 contests: {contests},
215 learning: {learning},
216 explainers: {explainers},
217 federation: {federation},
218 admin: {admin},
219 }},
220 contentTypes: '{content_types}',
221 contestCreation: '{contest_creation}',
222 }},
223 }},
224 nitro: {{
225 preset: 'node-server',
226 publicAssets: [
227 {{
228 dir: '../uploads',
229 baseURL: '/uploads',
230 maxAge: 60 * 60 * 24,
231 }},
232 ],
233 }},
234 vite: {{
235 server: {{
236 fs: {{
237 allow: ['..'],
238 }},
239 }},
240 }},
241}});
242"#,
243 domain = config.domain,
244 name = config.name,
245 description = config.description,
246 content = config.feature_content,
247 social = config.feature_social,
248 hubs = config.feature_hubs,
249 docs = config.feature_docs,
250 video = config.feature_video,
251 contests = config.feature_contests,
252 learning = config.feature_learning,
253 explainers = config.feature_explainers,
254 federation = config.feature_federation,
255 admin = config.feature_admin,
256 content_types = config.content_types.join(","),
257 contest_creation = config.contest_creation,
258 )
259}
260
261pub fn render_package_json(config: &InstanceConfig) -> String {
262 let mut deps = vec![
263 r#" "@commonpub/config": "^0.4.0""#.to_string(),
264 r#" "@commonpub/schema": "^0.4.0""#.to_string(),
265 r#" "@commonpub/auth": "^0.4.0""#.to_string(),
266 r#" "@commonpub/ui": "^0.4.0""#.to_string(),
267 r#" "@commonpub/server": "^0.4.0""#.to_string(),
268 r#" "@commonpub/infra": "^0.4.0""#.to_string(),
269 ];
270
271 if config.feature_content {
272 deps.push(r#" "@commonpub/editor": "^0.4.0""#.to_string());
273 }
274 if config.feature_docs {
275 deps.push(r#" "@commonpub/docs": "^0.4.0""#.to_string());
276 }
277 if config.feature_learning {
278 deps.push(r#" "@commonpub/learning": "^0.4.0""#.to_string());
279 }
280 if config.feature_explainers {
281 deps.push(r#" "@commonpub/explainer": "^0.4.0""#.to_string());
282 }
283 if config.feature_federation {
284 deps.push(r#" "@commonpub/protocol": "^0.4.0""#.to_string());
285 }
286
287 let deps_str = deps.join(",\n");
288
289 format!(
290 r#"{{
291 "name": "{name}",
292 "private": true,
293 "type": "module",
294 "scripts": {{
295 "dev": "nuxt dev",
296 "build": "nuxt build",
297 "preview": "nuxt preview",
298 "postinstall": "nuxt prepare",
299 "db:push": "drizzle-kit push",
300 "db:studio": "drizzle-kit studio"
301 }},
302 "dependencies": {{
303{deps},
304 "nuxt": "^3.16.0",
305 "vue": "^3.4.0",
306 "drizzle-orm": "^0.45.0",
307 "better-auth": "^1.2.0",
308 "pg": "^8.13.0",
309 "zod": "^4.3.6"
310 }},
311 "devDependencies": {{
312 "@types/node": "^22.0.0",
313 "drizzle-kit": "^0.31.0",
314 "typescript": "^5.7.0"
315 }}
316}}
317"#,
318 name = config.name,
319 deps = deps_str,
320 )
321}
322
323pub fn render_tsconfig() -> String {
324 r#"{
325 "extends": "./.nuxt/tsconfig.json"
326}
327"#
328 .to_string()
329}
330
331pub fn render_app_vue(config: &InstanceConfig) -> String {
332 format!(
333 r##"<template>
334 <a href="#main-content" class="cpub-skip-link">Skip to main content</a>
335 <NuxtLoadingIndicator color="#5b9cf6" />
336 <NuxtLayout>
337 <NuxtPage />
338 </NuxtLayout>
339</template>
340
341<script setup lang="ts">
342useHead({{
343 titleTemplate: (title) => title ? `${{title}} — {name}` : '{name}',
344}});
345</script>
346"##,
347 name = config.name,
348 )
349}
350
351pub fn render_server_config() -> String {
354 r#"// Singleton CommonPub config for Nitro server
355import { defineCommonPubConfig, type CommonPubConfig } from '@commonpub/config';
356
357let cachedConfig: CommonPubConfig | null = null;
358
359export function useConfig(): CommonPubConfig {
360 if (cachedConfig) return cachedConfig;
361
362 const runtimeConfig = useRuntimeConfig();
363
364 const { config } = defineCommonPubConfig({
365 instance: {
366 domain: (runtimeConfig.public.domain as string) || 'localhost:3000',
367 name: (runtimeConfig.public.siteName as string) || 'CommonPub',
368 description: (runtimeConfig.public.siteDescription as string) || 'A CommonPub instance',
369 },
370 });
371
372 cachedConfig = config;
373 return config;
374}
375"#
376 .to_string()
377}
378
379pub fn render_server_db() -> String {
380 r#"// Singleton Drizzle DB instance for Nitro server
381import { drizzle } from 'drizzle-orm/node-postgres';
382// @ts-expect-error no types for pg
383import pg from 'pg';
384import * as schema from '@commonpub/schema';
385import type { DB } from '@commonpub/server';
386
387let db: DB | null = null;
388
389export function useDB(): DB {
390 if (db) return db;
391
392 const config = useRuntimeConfig();
393 const databaseUrl = config.databaseUrl as string;
394
395 if (!databaseUrl) {
396 throw new Error('DATABASE_URL is not configured. Set NUXT_DATABASE_URL environment variable.');
397 }
398
399 // Guard against default auth secret in production
400 if (process.env.NODE_ENV === 'production' && config.authSecret === 'dev-secret-change-me') {
401 throw new Error('NUXT_AUTH_SECRET must be set in production. Do not use the default dev secret.');
402 }
403
404 const pool = new pg.Pool({
405 connectionString: databaseUrl,
406 max: 20,
407 idleTimeoutMillis: 30_000,
408 connectionTimeoutMillis: 5_000,
409 });
410 db = drizzle(pool, { schema });
411
412 return db;
413}
414"#
415 .to_string()
416}
417
418pub fn render_server_auth() -> String {
419 r#"// Auth helper — extracts authenticated user from event context
420import type { H3Event } from 'h3';
421
422export interface AuthUser {
423 id: string;
424 username: string;
425 role: string;
426}
427
428export function requireAuth(event: H3Event): AuthUser {
429 const auth = event.context.auth;
430 if (!auth?.user) {
431 const cookie = getRequestHeader(event, 'cookie') || '';
432 const hasSessionCookie = cookie.includes('better-auth.session_token');
433 throw createError({
434 statusCode: 401,
435 statusMessage: hasSessionCookie
436 ? 'Session expired or invalid. Please log in again.'
437 : 'Not logged in. Please log in to continue.',
438 });
439 }
440 return auth.user as AuthUser;
441}
442
443export function requireAdmin(event: H3Event): AuthUser {
444 const user = requireAuth(event);
445 if (user.role !== 'admin') {
446 throw createError({ statusCode: 403, statusMessage: 'Admin access required' });
447 }
448 return user;
449}
450
451export function getOptionalUser(event: H3Event): AuthUser | null {
452 const auth = event.context.auth;
453 return (auth?.user as AuthUser) ?? null;
454}
455"#
456 .to_string()
457}
458
459pub fn render_server_validate() -> String {
460 r#"// API route validation helpers
461import type { H3Event } from 'h3';
462import type { ZodType } from 'zod';
463
464const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
465const SLUG_REGEX = /^[a-z0-9][a-z0-9-]*$/;
466
467type ParamType = 'uuid' | 'slug' | 'string';
468
469/** Parse and validate request body against a Zod schema. Throws 400 on failure. */
470export async function parseBody<T>(event: H3Event, schema: ZodType<T>): Promise<T> {
471 const body = await readBody(event);
472 const parsed = schema.safeParse(body);
473 if (!parsed.success) {
474 throw createError({
475 statusCode: 400,
476 statusMessage: 'Validation failed',
477 data: { errors: parsed.error.flatten().fieldErrors },
478 });
479 }
480 return parsed.data;
481}
482
483/** Parse and validate query string against a Zod schema. Throws 400 on failure. */
484export function parseQueryParams<T>(event: H3Event, schema: ZodType<T>): T {
485 const query = getQuery(event);
486 const parsed = schema.safeParse(query);
487 if (!parsed.success) {
488 throw createError({
489 statusCode: 400,
490 statusMessage: 'Invalid query parameters',
491 data: { errors: parsed.error.flatten().fieldErrors },
492 });
493 }
494 return parsed.data;
495}
496
497/**
498 * Extract and validate route parameters.
499 *
500 * @example
501 * const { id } = parseParams(event, { id: 'uuid' });
502 * const { slug } = parseParams(event, { slug: 'slug' });
503 */
504export function parseParams<T extends Record<string, ParamType>>(
505 event: H3Event,
506 spec: T,
507): { [K in keyof T]: string } {
508 const result = {} as { [K in keyof T]: string };
509
510 for (const [name, type] of Object.entries(spec)) {
511 const value = getRouterParam(event, name);
512 if (!value) {
513 throw createError({ statusCode: 400, statusMessage: `Missing parameter: ${name}` });
514 }
515
516 if (type === 'uuid' && !UUID_REGEX.test(value)) {
517 throw createError({ statusCode: 400, statusMessage: `Invalid ${name} format` });
518 }
519 if (type === 'slug' && !SLUG_REGEX.test(value)) {
520 throw createError({ statusCode: 400, statusMessage: `Invalid ${name} format` });
521 }
522
523 (result as Record<string, string>)[name] = value;
524 }
525
526 return result;
527}
528"#
529 .to_string()
530}
531
532pub fn render_server_errors() -> String {
533 r#"// Consistent error helpers for Nitro API routes
534
535export function validationError(errors: Record<string, string[]>): never {
536 throw createError({
537 statusCode: 400,
538 statusMessage: 'Validation failed',
539 data: { errors },
540 });
541}
542
543export function notFound(entity: string): never {
544 throw createError({
545 statusCode: 404,
546 statusMessage: `${entity} not found`,
547 });
548}
549
550export function forbidden(message = 'Permission denied'): never {
551 throw createError({ statusCode: 403, statusMessage: message });
552}
553
554export function badRequest(message: string): never {
555 throw createError({ statusCode: 400, statusMessage: message });
556}
557"#
558 .to_string()
559}
560
561pub fn render_middleware_auth() -> String {
564 r#"// Nitro middleware — Better Auth integration with configurable email
565import { createAuthMiddleware, type AuthLocals } from '@commonpub/auth';
566import { createAuth } from '@commonpub/auth';
567import { ConsoleEmailAdapter, SmtpEmailAdapter, ResendEmailAdapter, emailTemplates } from '@commonpub/server';
568import type { EmailAdapter } from '@commonpub/server';
569
570let authMiddleware: ReturnType<typeof createAuthMiddleware> | null = null;
571
572function createEmailAdapter(): EmailAdapter {
573 const runtimeConfig = useRuntimeConfig();
574 const adapter = (runtimeConfig.emailAdapter as string) || 'console';
575
576 if (adapter === 'smtp') {
577 const host = runtimeConfig.smtpHost as string;
578 const port = parseInt(runtimeConfig.smtpPort as string, 10) || 587;
579 const user = runtimeConfig.smtpUser as string;
580 const pass = runtimeConfig.smtpPass as string;
581 const from = runtimeConfig.smtpFrom as string;
582
583 if (!host || !user || !pass || !from) {
584 console.warn('[email] SMTP configured but missing credentials — falling back to console');
585 return new ConsoleEmailAdapter();
586 }
587
588 return new SmtpEmailAdapter({ host, port, user, pass, from });
589 }
590
591 if (adapter === 'resend') {
592 const apiKey = runtimeConfig.resendApiKey as string;
593 const from = runtimeConfig.resendFrom as string;
594
595 if (!apiKey || !from) {
596 console.warn('[email] Resend configured but missing API key or from address — falling back to console');
597 return new ConsoleEmailAdapter();
598 }
599
600 return new ResendEmailAdapter({ apiKey, from });
601 }
602
603 return new ConsoleEmailAdapter();
604}
605
606function getAuthMiddleware(): ReturnType<typeof createAuthMiddleware> {
607 if (authMiddleware) return authMiddleware;
608
609 const config = useConfig();
610 const db = useDB();
611 const runtimeConfig = useRuntimeConfig();
612 const siteUrl = (runtimeConfig.public?.siteUrl as string) || `https://${config.instance.domain}`;
613 const siteName = config.instance.name || 'CommonPub';
614
615 const emailAdapter = createEmailAdapter();
616
617 const auth = createAuth({
618 config,
619 db: db as unknown as Parameters<typeof createAuth>[0]['db'],
620 secret: (() => {
621 const s = runtimeConfig.authSecret as string;
622 if (!s && process.env.NODE_ENV === 'production') {
623 throw new Error('AUTH_SECRET must be set in production');
624 }
625 return s || 'dev-secret-change-me';
626 })(),
627 baseURL: siteUrl,
628 emailSender: {
629 async sendResetPasswordEmail(email: string, url: string, _token: string): Promise<void> {
630 const template = emailTemplates.passwordReset(siteName, url);
631 await emailAdapter.send({ ...template, to: email });
632 },
633 async sendVerificationEmail(email: string, url: string, _token: string): Promise<void> {
634 const template = emailTemplates.verification(siteName, url);
635 await emailAdapter.send({ ...template, to: email });
636 },
637 },
638 });
639
640 authMiddleware = createAuthMiddleware({ auth });
641 return authMiddleware;
642}
643
644declare module 'h3' {
645 interface H3EventContext {
646 auth: AuthLocals;
647 }
648}
649
650export default defineEventHandler(async (event) => {
651 const pathname = getRequestURL(event).pathname;
652
653 // Skip auth for non-API routes and static assets
654 if (!pathname.startsWith('/api') && !pathname.startsWith('/_nuxt')) {
655 // Still resolve session for SSR pages
656 try {
657 const middleware = getAuthMiddleware();
658 const headers = getRequestHeaders(event);
659 const webHeaders = new Headers(headers as Record<string, string>);
660 event.context.auth = await middleware.resolveSession(webHeaders);
661 } catch {
662 event.context.auth = { user: null, session: null };
663 }
664 return;
665 }
666
667 let middleware: ReturnType<typeof getAuthMiddleware>;
668 try {
669 middleware = getAuthMiddleware();
670 } catch (err: unknown) {
671 // DB not connected — fail with a clear message
672 if (pathname.startsWith('/api/auth') || pathname.startsWith('/api/')) {
673 throw createError({
674 statusCode: 503,
675 statusMessage: 'Database unavailable. Check that PostgreSQL is running.',
676 });
677 }
678 event.context.auth = { user: null, session: null };
679 return;
680 }
681
682 // Handle auth API routes
683 if (pathname.startsWith('/api/auth')) {
684 try {
685 const response = await middleware.handleAuthRoute(
686 toWebRequest(event),
687 pathname,
688 );
689 if (response) {
690 return sendWebResponse(event, response);
691 }
692 } catch (err: unknown) {
693 console.error('[auth] Route handler error:', err instanceof Error ? err.message : err);
694 throw createError({
695 statusCode: 500,
696 statusMessage: 'Authentication service error',
697 });
698 }
699 }
700
701 // Resolve session for API requests
702 try {
703 const headers = getRequestHeaders(event);
704 const webHeaders = new Headers(headers as Record<string, string>);
705 event.context.auth = await middleware.resolveSession(webHeaders);
706 } catch (err: unknown) {
707 if (pathname.startsWith('/api/')) {
708 console.error('[auth] Session resolution failed:', err instanceof Error ? err.message : err);
709 }
710 event.context.auth = { user: null, session: null };
711 }
712});
713"#
714 .to_string()
715}
716
717pub fn render_middleware_security() -> String {
718 r#"// Security middleware — rate limiting + security headers + CSP
719import { RateLimitStore, checkRateLimit, shouldSkipRateLimit, getSecurityHeaders, buildCspHeader, buildCspDirectives } from '@commonpub/server';
720
721const store = new RateLimitStore();
722const isDev = process.env.NODE_ENV !== 'production';
723
724export default defineEventHandler((event) => {
725 const url = getRequestURL(event);
726 const pathname = url.pathname;
727
728 // Skip rate limiting for static assets
729 if (shouldSkipRateLimit(pathname)) return;
730
731 // Skip rate limiting in development — SSR + HMR + prefetch burns through limits
732 if (!isDev) {
733 const ip = getRequestHeader(event, 'x-forwarded-for')?.split(',')[0]?.trim()
734 || getRequestHeader(event, 'x-real-ip')
735 || 'unknown';
736
737 const userId = event.context.auth?.user?.id as string | undefined;
738 const { result, headers: rlHeaders } = checkRateLimit(store, ip, pathname, userId);
739
740 for (const [key, value] of Object.entries(rlHeaders)) {
741 setResponseHeader(event, key, value);
742 }
743
744 if (!result.allowed) {
745 throw createError({
746 statusCode: 429,
747 statusMessage: 'Too Many Requests',
748 });
749 }
750 }
751
752 // Security headers
753 const headers = getSecurityHeaders(isDev);
754 for (const [key, value] of Object.entries(headers)) {
755 setResponseHeader(event, key, value);
756 }
757
758 // Content Security Policy — skip for API responses (JSON doesn't need CSP)
759 if (!pathname.startsWith('/api/')) {
760 const cspDirectives = buildCspDirectives();
761 if (isDev) {
762 cspDirectives['script-src'] = "'self' 'unsafe-inline' 'unsafe-eval'";
763 cspDirectives['style-src'] = "'self' 'unsafe-inline' https://cdnjs.cloudflare.com";
764 cspDirectives['connect-src'] = "'self' ws: wss:";
765 }
766 setResponseHeader(event, 'Content-Security-Policy', buildCspHeader(cspDirectives));
767 }
768});
769"#
770 .to_string()
771}
772
773pub fn render_plugin_auth() -> String {
776 r#"// Auth plugin — fetches session on app init
777import type { ClientAuthUser, ClientAuthSession } from '~/composables/useAuth';
778
779export default defineNuxtPlugin(async () => {
780 const user = useState<ClientAuthUser | null>('auth-user', () => null);
781 const session = useState<ClientAuthSession | null>('auth-session', () => null);
782
783 if (import.meta.server) {
784 const event = useRequestEvent();
785 const authCtx = (event?.context as any)?.auth as { user?: ClientAuthUser; session?: ClientAuthSession } | undefined;
786 if (authCtx) {
787 user.value = (authCtx.user as ClientAuthUser) ?? null;
788 session.value = (authCtx.session as ClientAuthSession) ?? null;
789 }
790 return;
791 }
792
793 // On client, fetch session from the auth API
794 try {
795 const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/get-session', {
796 credentials: 'include',
797 });
798 user.value = data?.user ?? null;
799 session.value = data?.session ?? null;
800 } catch {
801 user.value = null;
802 session.value = null;
803 }
804});
805"#
806 .to_string()
807}
808
809pub fn render_composable_auth() -> String {
812 r#"// Auth composable — reactive auth state + methods
813
814/** Client-side auth user shape, matching what Better Auth returns */
815export interface ClientAuthUser {
816 id: string;
817 name: string | null;
818 username: string;
819 email: string;
820 role: string;
821 image: string | null;
822 emailVerified: boolean;
823 createdAt: string;
824 updatedAt: string;
825}
826
827export interface ClientAuthSession {
828 id: string;
829 userId: string;
830 token: string;
831 expiresAt: string;
832}
833
834export function useAuth() {
835 const user = useState<ClientAuthUser | null>('auth-user', () => null);
836 const session = useState<ClientAuthSession | null>('auth-session', () => null);
837
838 const isAuthenticated = computed(() => !!user.value);
839 const isAdmin = computed(() => user.value?.role === 'admin');
840
841 async function signIn(email: string, password: string): Promise<void> {
842 const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/sign-in/email', {
843 method: 'POST',
844 body: { email, password },
845 credentials: 'include',
846 });
847 user.value = data?.user ?? null;
848 session.value = data?.session ?? null;
849 }
850
851 async function signUp(email: string, password: string, username: string): Promise<void> {
852 const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>('/api/auth/sign-up/email', {
853 method: 'POST',
854 body: { email, password, name: username, username },
855 credentials: 'include',
856 });
857 user.value = data?.user ?? null;
858 session.value = data?.session ?? null;
859 }
860
861 async function signOut(): Promise<void> {
862 await $fetch('/api/auth/sign-out', { method: 'POST', credentials: 'include' });
863 user.value = null;
864 session.value = null;
865 await navigateTo('/');
866 }
867
868 /** Refresh the session from the server. */
869 async function refreshSession(): Promise<void> {
870 if (import.meta.server) return;
871 try {
872 const data = await $fetch<{ user: ClientAuthUser | null; session: ClientAuthSession | null }>(
873 '/api/auth/get-session',
874 { credentials: 'include' },
875 );
876 user.value = data?.user ?? null;
877 session.value = data?.session ?? null;
878 } catch {
879 user.value = null;
880 session.value = null;
881 }
882 }
883
884 return {
885 user: readonly(user),
886 session: readonly(session),
887 isAuthenticated,
888 isAdmin,
889 signIn,
890 signUp,
891 signOut,
892 refreshSession,
893 };
894}
895"#
896 .to_string()
897}
898
899pub fn render_default_layout(config: &InstanceConfig) -> String {
902 let mut nav_links = vec![("/", "Home")];
903 if config.feature_content { nav_links.push(("/explore", "Explore")); }
904 if config.feature_hubs { nav_links.push(("/hubs", "Hubs")); }
905 if config.feature_contests { nav_links.push(("/contests", "Contests")); }
906 if config.feature_docs { nav_links.push(("/docs", "Docs")); }
907 if config.feature_learning { nav_links.push(("/learn", "Learn")); }
908 if config.feature_admin { nav_links.push(("/admin", "Admin")); }
909
910 let links_html: String = nav_links
911 .iter()
912 .map(|(path, label)| format!(" <NuxtLink to=\"{}\">{}</NuxtLink>", path, label))
913 .collect::<Vec<_>>()
914 .join("\n");
915
916 format!(
917 r#"<template>
918 <div class="cpub-layout">
919 <header class="cpub-header">
920 <nav class="cpub-nav">
921 <NuxtLink to="/" class="cpub-nav-brand">{name}</NuxtLink>
922 <div class="cpub-nav-links">
923{links}
924 </div>
925 </nav>
926 </header>
927 <main id="main-content" class="cpub-main">
928 <slot />
929 </main>
930 <footer class="cpub-footer">
931 <p>Powered by <a href="https://commonpub.dev">CommonPub</a></p>
932 </footer>
933 </div>
934</template>
935"#,
936 name = config.name,
937 links = links_html,
938 )
939}
940
941pub fn render_index_page(config: &InstanceConfig) -> String {
942 let mut enabled: Vec<&str> = Vec::new();
944 if config.feature_content { enabled.push("content"); }
945 if config.feature_hubs { enabled.push("hubs"); }
946 if config.feature_contests { enabled.push("contests"); }
947 if config.feature_docs { enabled.push("docs"); }
948 if config.feature_learning { enabled.push("learning"); }
949
950 let features_text = if enabled.is_empty() {
951 config.description.clone()
952 } else {
953 config.description.clone()
954 };
955
956 format!(
957 r#"<template>
958 <div class="cpub-page-index">
959 <h1>{name}</h1>
960 <p>{description}</p>
961 </div>
962</template>
963
964<script setup lang="ts">
965useHead({{
966 title: 'Home',
967}});
968</script>
969"#,
970 name = config.name,
971 description = features_text,
972 )
973}
974
975fn render_page_stub(class: &str, title: &str, description: &str) -> String {
978 format!(
979 r#"<template>
980 <div class="cpub-page-{class}">
981 <h1>{title}</h1>
982 <p>{description}</p>
983 </div>
984</template>
985
986<script setup lang="ts">
987useHead({{
988 title: '{title}',
989}});
990</script>
991"#,
992 class = class,
993 title = title,
994 description = description,
995 )
996}
997
998pub fn render_explore_page() -> String {
999 render_page_stub("explore", "Explore", "Discover projects and posts from the community.")
1000}
1001
1002pub fn render_hubs_page() -> String {
1003 render_page_stub("hubs", "Hubs", "Browse and join community hubs.")
1004}
1005
1006pub fn render_contests_page() -> String {
1007 render_page_stub("contests", "Contests", "View active and upcoming contests.")
1008}
1009
1010pub fn render_docs_page() -> String {
1011 render_page_stub("docs", "Docs", "Browse documentation sites.")
1012}
1013
1014pub fn render_learning_page() -> String {
1015 render_page_stub("learn", "Learn", "Explore learning paths and courses.")
1016}
1017
1018pub fn render_admin_page() -> String {
1019 render_page_stub("admin", "Admin", "Instance administration.")
1020}
1021
1022pub fn render_drizzle_config(config: &InstanceConfig) -> String {
1025 format!(
1026 r#"import {{ defineConfig }} from 'drizzle-kit';
1027
1028export default defineConfig({{
1029 schema: './node_modules/@commonpub/schema/dist/*.js',
1030 out: './migrations',
1031 dialect: 'postgresql',
1032 dbCredentials: {{
1033 url: process.env.NUXT_DATABASE_URL || process.env.DATABASE_URL || '{database_url}',
1034 }},
1035}});
1036"#,
1037 database_url = config.database_url,
1038 )
1039}
1040
1041pub fn render_gitignore() -> String {
1042 r#"# Dependencies
1043node_modules/
1044
1045# Build
1046.nuxt/
1047.output/
1048dist/
1049.turbo/
1050
1051# Environment
1052.env
1053.env.local
1054
1055# IDE
1056.vscode/
1057.idea/
1058*.swp
1059*.swo
1060
1061# OS
1062.DS_Store
1063Thumbs.db
1064
1065# Uploads (dev)
1066uploads/*
1067!uploads/.gitkeep
1068"#
1069 .to_string()
1070}
1071
1072pub fn render_docker_compose(_config: &InstanceConfig) -> String {
1073 r#"services:
1074 postgres:
1075 image: postgres:16-alpine
1076 restart: unless-stopped
1077 ports:
1078 - '5432:5432'
1079 environment:
1080 POSTGRES_USER: commonpub
1081 POSTGRES_PASSWORD: commonpub_dev
1082 POSTGRES_DB: commonpub
1083 volumes:
1084 - postgres_data:/var/lib/postgresql/data
1085 healthcheck:
1086 test: ['CMD-SHELL', 'pg_isready -U commonpub']
1087 interval: 5s
1088 timeout: 5s
1089 retries: 5
1090
1091 redis:
1092 image: redis:7-alpine
1093 restart: unless-stopped
1094 ports:
1095 - '6379:6379'
1096 volumes:
1097 - redis_data:/data
1098 healthcheck:
1099 test: ['CMD', 'redis-cli', 'ping']
1100 interval: 5s
1101 timeout: 5s
1102 retries: 5
1103
1104 meilisearch:
1105 image: getmeili/meilisearch:v1.12
1106 restart: unless-stopped
1107 ports:
1108 - '7700:7700'
1109 environment:
1110 MEILI_ENV: development
1111 MEILI_MASTER_KEY: commonpub_dev_key
1112 volumes:
1113 - meili_data:/meili_data
1114
1115volumes:
1116 postgres_data:
1117 redis_data:
1118 meili_data:
1119"#
1120 .to_string()
1121}
1122
1123#[cfg(test)]
1124mod tests {
1125 use super::*;
1126 use crate::prompts::InstanceConfig;
1127
1128 fn test_config() -> InstanceConfig {
1129 InstanceConfig::with_defaults("test-instance")
1130 }
1131
1132 #[test]
1135 fn env_contains_database_url() {
1136 let env = render_env(&test_config());
1137 assert!(env.contains("DATABASE_URL="));
1138 assert!(env.contains("postgresql://"));
1139 }
1140
1141 #[test]
1142 fn env_contains_all_feature_flags() {
1143 let env = render_env(&test_config());
1144 assert!(env.contains("FEATURE_CONTENT=true"));
1145 assert!(env.contains("FEATURE_SOCIAL=true"));
1146 assert!(env.contains("FEATURE_HUBS=true"));
1147 assert!(env.contains("FEATURE_DOCS=true"));
1148 assert!(env.contains("FEATURE_VIDEO=true"));
1149 assert!(env.contains("FEATURE_CONTESTS=false"));
1150 assert!(env.contains("FEATURE_LEARNING=true"));
1151 assert!(env.contains("FEATURE_EXPLAINERS=true"));
1152 assert!(env.contains("FEATURE_FEDERATION=false"));
1153 assert!(env.contains("FEATURE_ADMIN=false"));
1154 }
1155
1156 #[test]
1157 fn env_contains_instance_identity() {
1158 let config = test_config();
1159 let env = render_env(&config);
1160 assert!(env.contains("INSTANCE_NAME=test-instance"));
1161 assert!(env.contains("INSTANCE_DOMAIN=test-instance.localhost"));
1162 }
1163
1164 #[test]
1165 fn env_contains_email_config() {
1166 let env = render_env(&test_config());
1167 assert!(env.contains("EMAIL_ADAPTER=console"));
1168 assert!(env.contains("SMTP_HOST"));
1169 assert!(env.contains("SMTP_PORT"));
1170 assert!(env.contains("SMTP_FROM"));
1171 assert!(env.contains("RESEND_API_KEY"));
1172 assert!(env.contains("RESEND_FROM"));
1173 }
1174
1175 #[test]
1176 fn env_includes_github_oauth_when_enabled() {
1177 let mut config = test_config();
1178 config.auth_github = true;
1179 let env = render_env(&config);
1180 assert!(env.contains("GITHUB_CLIENT_ID="));
1181 }
1182
1183 #[test]
1184 fn env_excludes_github_oauth_when_disabled() {
1185 let config = test_config();
1186 let env = render_env(&config);
1187 assert!(!env.contains("GITHUB_CLIENT_ID"));
1188 }
1189
1190 #[test]
1193 fn config_is_valid_typescript_structure() {
1194 let config = render_config(&test_config());
1195 assert!(config.contains("import { defineCommonPubConfig }"));
1196 assert!(config.contains("export default defineCommonPubConfig"));
1197 }
1198
1199 #[test]
1200 fn config_contains_all_feature_flags() {
1201 let config = render_config(&test_config());
1202 assert!(config.contains("content: true"));
1203 assert!(config.contains("social: true"));
1204 assert!(config.contains("hubs: true"));
1205 assert!(config.contains("federation: false"));
1206 assert!(config.contains("contests: false"));
1207 }
1208
1209 #[test]
1210 fn config_contains_auth_settings() {
1211 let config = render_config(&test_config());
1212 assert!(config.contains("emailPassword: true"));
1213 assert!(config.contains("magicLink: false"));
1214 assert!(config.contains("passkeys: false"));
1215 }
1216
1217 #[test]
1218 fn config_includes_contest_creation_when_contests_enabled() {
1219 let mut config = test_config();
1220 config.feature_contests = true;
1221 config.contest_creation = "staff".to_string();
1222 let output = render_config(&config);
1223 assert!(output.contains("contestCreation: 'staff'"));
1224 }
1225
1226 #[test]
1227 fn config_includes_content_types() {
1228 let config = test_config();
1229 let output = render_config(&config);
1230 assert!(output.contains("contentTypes: ['project', 'article', 'blog', 'explainer']"));
1231 }
1232
1233 #[test]
1234 fn config_omits_content_types_when_empty() {
1235 let mut config = test_config();
1236 config.content_types = vec![];
1237 let output = render_config(&config);
1238 assert!(!output.contains("contentTypes"));
1239 }
1240
1241 #[test]
1242 fn config_uses_selected_theme() {
1243 let mut config = test_config();
1244 config.theme = "deepwood".to_string();
1245 let output = render_config(&config);
1246 assert!(!output.contains("theme:")); assert!(output.contains("name: 'test-instance'"));
1248 }
1249
1250 #[test]
1253 fn nuxt_config_has_css_and_runtime() {
1254 let config = render_nuxt_config(&test_config());
1255 assert!(config.contains("@commonpub/ui/theme/base.css"));
1256 assert!(config.contains("nitro:"));
1257 assert!(config.contains("runtimeConfig:"));
1258 assert!(config.contains("test-instance.localhost"));
1259 }
1260
1261 #[test]
1262 fn nuxt_config_has_email_runtime_config() {
1263 let config = render_nuxt_config(&test_config());
1264 assert!(config.contains("emailAdapter:"));
1265 assert!(config.contains("smtpHost:"));
1266 assert!(config.contains("resendApiKey:"));
1267 assert!(config.contains("resendFrom:"));
1268 }
1269
1270 #[test]
1271 fn nuxt_config_has_vite_fs_allow() {
1272 let config = render_nuxt_config(&test_config());
1273 assert!(config.contains("fs:"));
1274 assert!(config.contains("allow:"));
1275 }
1276
1277 #[test]
1278 fn nuxt_config_includes_theme_css_when_non_base() {
1279 let mut config = test_config();
1280 config.theme = "deepwood".to_string();
1281 let output = render_nuxt_config(&config);
1282 assert!(output.contains("deepwood.css"));
1283 }
1284
1285 #[test]
1288 fn package_json_is_nuxt() {
1289 let json = render_package_json(&test_config());
1290 assert!(json.contains("\"name\": \"test-instance\""));
1291 assert!(json.contains("nuxt dev"));
1292 assert!(json.contains("nuxt build"));
1293 assert!(json.contains("\"nuxt\":"));
1294 assert!(json.contains("\"vue\":"));
1295 }
1296
1297 #[test]
1298 fn package_json_has_core_commonpub_deps() {
1299 let json = render_package_json(&test_config());
1300 assert!(json.contains("@commonpub/config"));
1301 assert!(json.contains("@commonpub/schema"));
1302 assert!(json.contains("@commonpub/auth"));
1303 assert!(json.contains("@commonpub/ui"));
1304 assert!(json.contains("@commonpub/server"));
1305 assert!(json.contains("@commonpub/infra"));
1306 }
1307
1308 #[test]
1309 fn package_json_has_pg_and_zod() {
1310 let json = render_package_json(&test_config());
1311 assert!(json.contains("\"pg\":"));
1312 assert!(json.contains("\"zod\":"));
1313 }
1314
1315 #[test]
1316 fn package_json_includes_editor_when_content_enabled() {
1317 let config = test_config(); let json = render_package_json(&config);
1319 assert!(json.contains("@commonpub/editor"));
1320 }
1321
1322 #[test]
1323 fn package_json_excludes_editor_when_content_disabled() {
1324 let mut config = test_config();
1325 config.feature_content = false;
1326 let json = render_package_json(&config);
1327 assert!(!json.contains("@commonpub/editor"));
1328 }
1329
1330 #[test]
1331 fn package_json_includes_optional_deps_when_enabled() {
1332 let config = test_config(); let json = render_package_json(&config);
1334 assert!(json.contains("@commonpub/docs"));
1335 assert!(json.contains("@commonpub/learning"));
1336 assert!(json.contains("@commonpub/explainer"));
1337 assert!(!json.contains("@commonpub/protocol")); }
1339
1340 #[test]
1341 fn package_json_includes_protocol_when_federation_enabled() {
1342 let mut config = test_config();
1343 config.feature_federation = true;
1344 let json = render_package_json(&config);
1345 assert!(json.contains("@commonpub/protocol"));
1346 }
1347
1348 #[test]
1349 fn package_json_excludes_optional_deps_when_disabled() {
1350 let mut config = test_config();
1351 config.feature_docs = false;
1352 config.feature_learning = false;
1353 config.feature_explainers = false;
1354 let json = render_package_json(&config);
1355 assert!(!json.contains("@commonpub/docs"));
1356 assert!(!json.contains("@commonpub/learning"));
1357 assert!(!json.contains("@commonpub/explainer"));
1358 }
1359
1360 #[test]
1363 fn app_vue_has_skip_link_and_layout() {
1364 let vue = render_app_vue(&test_config());
1365 assert!(vue.contains("cpub-skip-link"));
1366 assert!(vue.contains("NuxtLayout"));
1367 assert!(vue.contains("NuxtPage"));
1368 assert!(vue.contains("test-instance"));
1369 }
1370
1371 #[test]
1374 fn server_config_uses_define_commonpub_config() {
1375 let sc = render_server_config();
1376 assert!(sc.contains("defineCommonPubConfig"));
1377 assert!(sc.contains("useConfig"));
1378 assert!(sc.contains("cachedConfig"));
1379 }
1380
1381 #[test]
1382 fn server_db_has_pool_and_singleton() {
1383 let db = render_server_db();
1384 assert!(db.contains("useDB"));
1385 assert!(db.contains("pg.Pool"));
1386 assert!(db.contains("drizzle(pool"));
1387 assert!(db.contains("@commonpub/schema"));
1388 assert!(db.contains("production"));
1389 }
1390
1391 #[test]
1392 fn server_auth_has_require_and_optional() {
1393 let auth = render_server_auth();
1394 assert!(auth.contains("requireAuth"));
1395 assert!(auth.contains("requireAdmin"));
1396 assert!(auth.contains("getOptionalUser"));
1397 assert!(auth.contains("AuthUser"));
1398 }
1399
1400 #[test]
1401 fn server_validate_has_parse_helpers() {
1402 let validate = render_server_validate();
1403 assert!(validate.contains("parseBody"));
1404 assert!(validate.contains("parseQueryParams"));
1405 assert!(validate.contains("parseParams"));
1406 assert!(validate.contains("ZodType"));
1407 }
1408
1409 #[test]
1410 fn server_errors_has_helpers() {
1411 let errors = render_server_errors();
1412 assert!(errors.contains("validationError"));
1413 assert!(errors.contains("notFound"));
1414 assert!(errors.contains("forbidden"));
1415 assert!(errors.contains("badRequest"));
1416 }
1417
1418 #[test]
1421 fn middleware_auth_has_email_adapter_switch() {
1422 let auth = render_middleware_auth();
1423 assert!(auth.contains("createEmailAdapter"));
1424 assert!(auth.contains("SmtpEmailAdapter"));
1425 assert!(auth.contains("ResendEmailAdapter"));
1426 assert!(auth.contains("ConsoleEmailAdapter"));
1427 assert!(auth.contains("emailAdapter"));
1428 assert!(auth.contains("emailTemplates"));
1429 assert!(auth.contains("createAuth"));
1430 }
1431
1432 #[test]
1433 fn middleware_auth_handles_session_resolution() {
1434 let auth = render_middleware_auth();
1435 assert!(auth.contains("resolveSession"));
1436 assert!(auth.contains("handleAuthRoute"));
1437 assert!(auth.contains("/api/auth"));
1438 }
1439
1440 #[test]
1441 fn middleware_security_has_rate_limiting_and_csp() {
1442 let sec = render_middleware_security();
1443 assert!(sec.contains("RateLimitStore"));
1444 assert!(sec.contains("checkRateLimit"));
1445 assert!(sec.contains("getSecurityHeaders"));
1446 assert!(sec.contains("Content-Security-Policy"));
1447 }
1448
1449 #[test]
1452 fn plugin_auth_bridges_ssr_to_client() {
1453 let plugin = render_plugin_auth();
1454 assert!(plugin.contains("defineNuxtPlugin"));
1455 assert!(plugin.contains("import.meta.server"));
1456 assert!(plugin.contains("auth-user"));
1457 assert!(plugin.contains("/api/auth/get-session"));
1458 }
1459
1460 #[test]
1463 fn composable_auth_has_full_api() {
1464 let auth = render_composable_auth();
1465 assert!(auth.contains("useAuth"));
1466 assert!(auth.contains("signIn"));
1467 assert!(auth.contains("signUp"));
1468 assert!(auth.contains("signOut"));
1469 assert!(auth.contains("refreshSession"));
1470 assert!(auth.contains("isAuthenticated"));
1471 assert!(auth.contains("isAdmin"));
1472 assert!(auth.contains("ClientAuthUser"));
1473 }
1474
1475 #[test]
1478 fn docker_compose_has_all_services() {
1479 let compose = render_docker_compose(&test_config());
1480 assert!(compose.contains("postgres:"));
1481 assert!(compose.contains("redis:"));
1482 assert!(compose.contains("meilisearch:"));
1483 }
1484
1485 #[test]
1486 fn docker_compose_has_health_checks() {
1487 let compose = render_docker_compose(&test_config());
1488 assert!(compose.contains("healthcheck:"));
1489 assert!(compose.contains("pg_isready"));
1490 assert!(compose.contains("redis-cli"));
1491 }
1492
1493 #[test]
1496 fn default_config_values_correct() {
1497 let config = InstanceConfig::with_defaults("my-app");
1498 assert_eq!(config.name, "my-app");
1499 assert_eq!(config.domain, "my-app.localhost");
1500 assert_eq!(config.theme, "base");
1501 assert!(config.feature_content);
1502 assert!(config.feature_social);
1503 assert!(config.feature_hubs);
1504 assert!(config.feature_docs);
1505 assert!(config.feature_video);
1506 assert!(!config.feature_contests);
1507 assert!(config.feature_learning);
1508 assert!(config.feature_explainers);
1509 assert!(!config.feature_federation);
1510 assert!(!config.feature_admin);
1511 assert!(config.auth_email_password);
1512 assert!(!config.auth_magic_link);
1513 assert!(!config.auth_passkeys);
1514 assert!(!config.auth_github);
1515 assert!(!config.auth_google);
1516 assert!(config.use_docker);
1517 assert_eq!(config.contest_creation, "admin");
1518 assert_eq!(config.content_types.len(), 4);
1519 }
1520
1521 #[test]
1522 fn gitignore_has_nuxt_entries() {
1523 let gi = render_gitignore();
1524 assert!(gi.contains(".nuxt/"));
1525 assert!(gi.contains(".output/"));
1526 assert!(gi.contains("node_modules/"));
1527 assert!(gi.contains(".env"));
1528 assert!(gi.contains(".turbo/"));
1529 }
1530
1531 #[test]
1532 fn default_layout_has_accessibility() {
1533 let layout = render_default_layout(&test_config());
1534 assert!(layout.contains("cpub-layout"));
1535 assert!(layout.contains("main-content"));
1536 assert!(layout.contains("commonpub.dev"));
1537 }
1538
1539 #[test]
1540 fn default_layout_nav_reflects_features() {
1541 let layout = render_default_layout(&test_config());
1543 assert!(layout.contains("Explore")); assert!(layout.contains("Hubs"));
1545 assert!(layout.contains("Docs"));
1546 assert!(layout.contains("Learn"));
1547 assert!(!layout.contains("Contests")); assert!(!layout.contains("Admin")); }
1550
1551 #[test]
1552 fn minimal_layout_nav_only_has_home() {
1553 let mut config = test_config();
1554 config.feature_content = false;
1555 config.feature_social = false;
1556 config.feature_hubs = false;
1557 config.feature_docs = false;
1558 config.feature_video = false;
1559 config.feature_learning = false;
1560 config.feature_explainers = false;
1561 let layout = render_default_layout(&config);
1562 assert!(layout.contains("Home"));
1563 assert!(!layout.contains("Explore"));
1564 assert!(!layout.contains("Hubs"));
1565 assert!(!layout.contains("Docs"));
1566 }
1567
1568 #[test]
1569 fn contests_layout_nav_shows_contests() {
1570 let mut config = test_config();
1571 config.feature_contests = true;
1572 config.feature_admin = true;
1573 let layout = render_default_layout(&config);
1574 assert!(layout.contains("Contests"));
1575 assert!(layout.contains("Admin"));
1576 }
1577
1578 #[test]
1579 fn page_stubs_have_correct_structure() {
1580 let hubs = render_hubs_page();
1581 assert!(hubs.contains("cpub-page-hubs"));
1582 assert!(hubs.contains("useHead"));
1583 assert!(hubs.contains("Hubs"));
1584
1585 let contests = render_contests_page();
1586 assert!(contests.contains("cpub-page-contests"));
1587 assert!(contests.contains("Contests"));
1588
1589 let admin = render_admin_page();
1590 assert!(admin.contains("cpub-page-admin"));
1591 }
1592
1593 #[test]
1594 fn index_page_has_instance_info() {
1595 let page = render_index_page(&test_config());
1596 assert!(page.contains("test-instance"));
1597 assert!(page.contains("useHead"));
1598 }
1599}