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