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 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 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 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
348pub 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
558pub 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
770pub 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
806pub 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
896pub 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 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
972fn 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
1019pub 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 #[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 #[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:")); assert!(output.contains("name: 'test-instance'"));
1245 }
1246
1247 #[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 #[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(); 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(); 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")); }
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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 let layout = render_default_layout(&test_config());
1540 assert!(layout.contains("Explore")); assert!(layout.contains("Hubs"));
1542 assert!(layout.contains("Docs"));
1543 assert!(layout.contains("Learn"));
1544 assert!(!layout.contains("Contests")); assert!(!layout.contains("Admin")); }
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}