Skip to main content

create_commonpub/
template.rs

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    // OAuth placeholders only if enabled
69    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    // Storage (optional, local by default)
81    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        // admin is the default, only include if non-default or if contests enabled
108        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
351// ── Server utils ──────────────────────────────────────────
352
353pub 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
561// ── Server middleware ─────────────────────────────────────
562
563pub 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
773// ── Plugins ───────────────────────────────────────────────
774
775pub 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
809// ── Composables ───────────────────────────────────────────
810
811pub 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
899// ── Pages & layouts ───────────────────────────────────────
900
901pub 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    // Build a description of enabled features for the index page
943    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
975// ── Feature page stubs ────────────────────────────────────
976
977fn 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
1022// ── Infra files ───────────────────────────────────────────
1023
1024pub 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    // ── .env ──────────────────────────────────────────────
1133
1134    #[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    // ── commonpub.config.ts ───────────────────────────────
1191
1192    #[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:")); // theme is in nuxt.config now
1247        assert!(output.contains("name: 'test-instance'"));
1248    }
1249
1250    // ── nuxt.config.ts ───────────────────────────────────
1251
1252    #[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    // ── package.json ──────────────────────────────────────
1286
1287    #[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(); // content enabled by default
1318        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(); // docs + learning + explainers enabled
1333        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")); // federation off
1338    }
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    // ── app.vue ───────────────────────────────────────────
1361
1362    #[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    // ── Server utils ──────────────────────────────────────
1372
1373    #[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    // ── Middleware ─────────────────────────────────────────
1419
1420    #[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    // ── Plugin ────────────────────────────────────────────
1450
1451    #[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    // ── Composable ────────────────────────────────────────
1461
1462    #[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    // ── Docker ────────────────────────────────────────────
1476
1477    #[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    // ── Defaults ──────────────────────────────────────────
1494
1495    #[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        // Default config has: content, social, hubs, docs, video, learning, explainers ON
1542        let layout = render_default_layout(&test_config());
1543        assert!(layout.contains("Explore")); // content
1544        assert!(layout.contains("Hubs"));
1545        assert!(layout.contains("Docs"));
1546        assert!(layout.contains("Learn"));
1547        assert!(!layout.contains("Contests")); // contests off by default
1548        assert!(!layout.contains("Admin")); // admin off by default
1549    }
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}