Skip to main content

create_commonpub/
scaffold.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use include_dir::{include_dir, Dir};
5
6use crate::prompts::InstanceConfig;
7use crate::template;
8
9/// The entire reference app, embedded at compile time.
10/// Excludes node_modules, .nuxt, .output, uploads, .env via .gitignore + include_dir filtering.
11static REFERENCE_APP: Dir = include_dir!("$CARGO_MANIFEST_DIR/reference-app");
12
13/// Files and directories to skip when copying the reference app
14const SKIP_NAMES: &[&str] = &[
15    "node_modules",
16    ".nuxt",
17    ".output",
18    ".stryker-tmp",
19    "uploads",
20    ".env",
21    "vitest.config.ts",
22    "__tests__",
23    "e2e",
24    "scripts",
25];
26
27fn should_skip(name: &str) -> bool {
28    SKIP_NAMES.contains(&name)
29}
30
31pub fn create_instance(name: &str, config: &InstanceConfig) -> Result<PathBuf, Box<dyn std::error::Error>> {
32    let path = PathBuf::from(name);
33    if path.exists() {
34        return Err(format!("Directory '{}' already exists", name).into());
35    }
36    fs::create_dir_all(&path)?;
37    write_reference_app(&path, config)?;
38    Ok(fs::canonicalize(&path)?)
39}
40
41pub fn create_instance_at(base: &Path, name: &str, config: &InstanceConfig) -> Result<PathBuf, Box<dyn std::error::Error>> {
42    let path = base.join(name);
43    if path.exists() {
44        return Err(format!("Directory '{}' already exists", path.display()).into());
45    }
46    fs::create_dir_all(&path)?;
47    write_reference_app(&path, config)?;
48    Ok(fs::canonicalize(&path)?)
49}
50
51pub fn init_instance(config: &InstanceConfig) -> Result<PathBuf, Box<dyn std::error::Error>> {
52    let path = PathBuf::from(".");
53    write_reference_app(&path, config)?;
54    Ok(fs::canonicalize(&path)?)
55}
56
57pub fn init_instance_at(dir: &Path, config: &InstanceConfig) -> Result<PathBuf, Box<dyn std::error::Error>> {
58    write_reference_app(dir, config)?;
59    Ok(fs::canonicalize(dir)?)
60}
61
62fn write_reference_app(dir: &Path, config: &InstanceConfig) -> Result<(), Box<dyn std::error::Error>> {
63    // 1. Copy the entire embedded reference app
64    copy_dir_recursive(&REFERENCE_APP, dir)?;
65
66    // 2. Patch package.json — workspace:* → npm versions, rename, add @types/node
67    patch_package_json(dir, config)?;
68
69    // 3. Patch nuxt.config.ts — replace monorepo theme paths with npm package paths
70    patch_nuxt_config(dir, config)?;
71
72    // 4. Write .env from config
73    let env_content = template::render_env(config);
74    fs::write(dir.join(".env"), env_content)?;
75
76    // 5. Write commonpub.config.ts from config
77    let config_content = template::render_config(config);
78    fs::write(dir.join("commonpub.config.ts"), config_content)?;
79
80    // 6. Write docker-compose.yml if requested
81    if config.use_docker {
82        let compose = template::render_docker_compose(config);
83        fs::write(dir.join("docker-compose.yml"), compose)?;
84    }
85
86    // 7. Write drizzle.config.ts (standalone version, not monorepo)
87    let drizzle_config = template::render_drizzle_config(config);
88    fs::write(dir.join("drizzle.config.ts"), drizzle_config)?;
89
90    // 8. Ensure uploads directory exists
91    fs::create_dir_all(dir.join("uploads"))?;
92    fs::write(dir.join("uploads/.gitkeep"), "")?;
93
94    // 9. Write .gitignore
95    fs::write(dir.join(".gitignore"), template::render_gitignore())?;
96
97    // 10. Write README
98    let readme = format!(
99        "# {}\n\n{}\n\nBuilt with [CommonPub](https://commonpub.dev).\n",
100        config.name, config.description
101    );
102    fs::write(dir.join("README.md"), readme)?;
103
104    Ok(())
105}
106
107/// Recursively copy an embedded directory to disk, skipping excluded paths
108fn copy_dir_recursive(embedded: &Dir, target: &Path) -> Result<(), Box<dyn std::error::Error>> {
109    for file in embedded.files() {
110        let name = file.path().file_name().and_then(|n| n.to_str()).unwrap_or("");
111        if should_skip(name) {
112            continue;
113        }
114
115        let dest = target.join(file.path());
116        if let Some(parent) = dest.parent() {
117            fs::create_dir_all(parent)?;
118        }
119        fs::write(&dest, file.contents())?;
120    }
121
122    for sub_dir in embedded.dirs() {
123        let name = sub_dir.path().file_name().and_then(|n| n.to_str()).unwrap_or("");
124        if should_skip(name) {
125            continue;
126        }
127        copy_dir_recursive(sub_dir, target)?;
128    }
129
130    Ok(())
131}
132
133/// Patch package.json: replace workspace:* with published versions, rename, adjust scripts
134fn patch_package_json(dir: &Path, config: &InstanceConfig) -> Result<(), Box<dyn std::error::Error>> {
135    let pkg_path = dir.join("package.json");
136    let content = fs::read_to_string(&pkg_path)?;
137
138    let patched = content
139        // Replace workspace references with published versions
140        .replace("\"workspace:*\"", "\"^0.4.0\"")
141        // Rename package
142        .replace("\"@commonpub/reference\"", &format!("\"{}\"", config.name))
143        // Remove monorepo-only scripts
144        .replace("\"generate\": \"nuxt generate\",\n    ", "")
145        .replace("\"test\": \"vitest run\",\n    ", "")
146        .replace("\"lint\": \"eslint .\",\n    ", "")
147        .replace("\"typecheck\": \"nuxt typecheck\",\n    ", "")
148        .replace("\"clean\": \"rm -rf .nuxt .output\",\n    ", "")
149        .replace("\"seed\": \"tsx scripts/seed.ts\"\n", "\"db:push\": \"drizzle-kit push\",\n    \"db:studio\": \"drizzle-kit studio\"\n");
150
151    // Add @types/node and drizzle-kit to devDependencies if not present
152    let patched = if !patched.contains("@types/node") {
153        patched.replace(
154            "\"devDependencies\": {",
155            "\"devDependencies\": {\n    \"@types/node\": \"^22.0.0\",\n    \"drizzle-kit\": \"^0.31.0\","
156        )
157    } else {
158        patched
159    };
160
161    fs::write(&pkg_path, patched)?;
162    Ok(())
163}
164
165/// Patch nuxt.config.ts: replace monorepo theme paths with npm package paths
166fn patch_nuxt_config(dir: &Path, config: &InstanceConfig) -> Result<(), Box<dyn std::error::Error>> {
167    let config_path = dir.join("nuxt.config.ts");
168    let content = fs::read_to_string(&config_path)?;
169
170    let patched = content
171        // Remove monorepo path resolver
172        .replace("import { fileURLToPath } from 'node:url';\n", "")
173        .replace("import { resolve, dirname } from 'node:path';\n", "")
174        .replace("\nconst __dirname = dirname(fileURLToPath(import.meta.url));\n", "")
175        .replace("const uiTheme = (file: string) => resolve(__dirname, '../../packages/ui/theme', file);\n", "")
176        // Replace uiTheme() calls with direct npm package paths
177        .replace("uiTheme('base.css')", "'@commonpub/ui/theme/base.css'")
178        .replace("uiTheme('dark.css')", "'@commonpub/ui/theme/dark.css'")
179        .replace("uiTheme('components.css')", "'@commonpub/ui/theme/components.css'")
180        .replace("uiTheme('prose.css')", "'@commonpub/ui/theme/prose.css'")
181        .replace("uiTheme('layouts.css')", "'@commonpub/ui/theme/layouts.css'")
182        .replace("uiTheme('forms.css')", "'@commonpub/ui/theme/forms.css'")
183        .replace("uiTheme('editor-panels.css')", "'@commonpub/ui/theme/editor-panels.css'")
184        // Replace monorepo vite fs.allow
185        .replace("allow: ['../..']", "allow: ['..']")
186        // Update default site URL/domain/name
187        .replace("siteUrl: 'http://localhost:3000'", &format!("siteUrl: 'http://{}'", config.domain))
188        .replace("domain: 'localhost:3000'", &format!("domain: '{}'", config.domain))
189        .replace("siteName: 'CommonPub'", &format!("siteName: '{}'", config.name))
190        .replace("siteDescription: 'A CommonPub instance'", &format!("siteDescription: '{}'", config.description))
191        // Patch feature flag defaults in runtimeConfig.public.features
192        .replace("content: true,\n        social: true,\n        hubs: true,\n        docs: true,\n        video: true,\n        contests: false,\n        learning: true,\n        explainers: true,\n        federation: false,\n        admin: false,",
193            &format!("content: {},\n        social: {},\n        hubs: {},\n        docs: {},\n        video: {},\n        contests: {},\n        learning: {},\n        explainers: {},\n        federation: {},\n        admin: {},",
194                config.feature_content, config.feature_social, config.feature_hubs,
195                config.feature_docs, config.feature_video, config.feature_contests,
196                config.feature_learning, config.feature_explainers, config.feature_federation,
197                config.feature_admin))
198        // Patch content types
199        .replace("contentTypes: 'project,article,blog,explainer'", &format!("contentTypes: '{}'", config.content_types.join(",")))
200        // Patch contest creation
201        .replace("contestCreation: 'admin'", &format!("contestCreation: '{}'", config.contest_creation));
202
203    fs::write(&config_path, patched)?;
204    Ok(())
205}