use std::fs;
use std::path::{Path, PathBuf};
use chrono::Local;
use crate::Error;
use crate::agent::{self, AgentTool};
use crate::config::Config;
use crate::content::slug::Slug;
use crate::output::OutputWriter;
use crate::render::{BuiltSite, Theme};
struct Scaffold {
dir: PathBuf,
title: String,
}
pub fn new(name: &str, agent: Option<AgentTool>) -> Result<(), Error> {
let dir = PathBuf::from(name);
if dir.exists() {
return Err(Error::Scaffold {
message: format!("directory '{}' already exists", dir.display()),
});
}
let title = title_from_name(dir.file_name().and_then(|n| n.to_str()).unwrap_or(name));
Scaffold::create(dir, title, agent)?;
tracing::info!(name, "created new site");
println!("\n To get started:\n");
println!(" cd {name}");
println!(" aphid serve\n");
print_agent_footer(agent);
Ok(())
}
pub fn init(path: &Path, agent: Option<AgentTool>) -> Result<(), Error> {
if path.join("aphid.toml").exists() {
return Err(Error::Scaffold {
message: format!(
"directory '{}' already contains an aphid.toml",
path.display()
),
});
}
let title = path
.file_name()
.and_then(|n| n.to_str())
.map(title_from_name)
.unwrap_or_else(|| "My Site".to_string());
Scaffold::create(path.to_path_buf(), title, agent)?;
tracing::info!(path = %path.display(), "initialized site");
if path == Path::new(".") {
println!("\n To get started:\n");
println!(" aphid serve\n");
} else {
println!("\n To get started:\n");
println!(" cd {}", path.display());
println!(" aphid serve\n");
}
print_agent_footer(agent);
Ok(())
}
fn print_agent_footer(agent: Option<AgentTool>) {
if let Some(tool) = agent {
println!(" Agent instructions written for {}.\n", tool.label());
}
}
pub fn new_blog_post(config_path: &Path, title: &str) -> Result<(), Error> {
let config = Config::from_path(config_path)?;
let path = write_blog_post(&config.source_dir, title)?;
println!(" Created {}", path.display());
Ok(())
}
pub fn new_wiki_page(config_path: &Path, title: &str) -> Result<(), Error> {
let config = Config::from_path(config_path)?;
let path = write_wiki_page(&config.source_dir, title)?;
println!(" Created {}", path.display());
Ok(())
}
pub fn new_page(config_path: &Path, title: &str) -> Result<(), Error> {
let config = Config::from_path(config_path)?;
let path = write_page(&config.source_dir, title)?;
println!(" Created {}", path.display());
Ok(())
}
fn write_blog_post(content_dir: &Path, title: &str) -> Result<PathBuf, Error> {
let date = Local::now().format("%Y-%m-%d");
let slug = Slug::from(title);
let filename = format!("{date}_{slug}.md");
let content = format!(
"\
---
title: {title}
slug: {slug}
author: Your Name
created: {date}
description: \"\"
tags: []
---
",
);
let path = content_dir.join("blog").join(&filename);
if path.exists() {
return Err(Error::Scaffold {
message: format!("file '{}' already exists", path.display()),
});
}
write_file(&path, &content)?;
Ok(path)
}
fn write_wiki_page(content_dir: &Path, title: &str) -> Result<PathBuf, Error> {
let slug = Slug::from(title);
let filename = format!("{slug}.md");
let content = format!(
"\
---
title: {title}
---
",
);
let path = content_dir.join("wiki").join(&filename);
if path.exists() {
return Err(Error::Scaffold {
message: format!("file '{}' already exists", path.display()),
});
}
write_file(&path, &content)?;
Ok(path)
}
fn write_page(content_dir: &Path, title: &str) -> Result<PathBuf, Error> {
let slug = Slug::from(title);
let filename = format!("{slug}.md");
let content = format!(
"\
---
title: {title}
---
",
);
let path = content_dir.join("pages").join(&filename);
if path.exists() {
return Err(Error::Scaffold {
message: format!("file '{}' already exists", path.display()),
});
}
write_file(&path, &content)?;
Ok(path)
}
fn title_from_name(name: &str) -> String {
name.split(['-', '_'])
.filter(|s| !s.is_empty())
.map(|word| {
let mut chars = word.chars();
match chars.next() {
None => String::new(),
Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
fn write_file(path: &Path, content: &str) -> Result<(), Error> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(path, content)?;
Ok(())
}
impl Scaffold {
fn create(dir: PathBuf, title: String, agent: Option<AgentTool>) -> Result<Self, Error> {
fs::create_dir_all(&dir)?;
let scaffold = Self { dir, title };
scaffold.write_all()?;
scaffold.build_site()?;
if let Some(tool) = agent {
agent::init(tool, &scaffold.dir)?;
tracing::info!(tool = ?tool, "wrote agent instructions");
}
Ok(scaffold)
}
fn write_all(&self) -> Result<(), Error> {
self.write_config()?;
self.write_gitignore()?;
self.write_theme()?;
self.write_initial_blog_post()?;
self.write_initial_wiki_page()?;
self.write_initial_page()?;
self.write_home()?;
self.create_static_dir()?;
Ok(())
}
fn write_config(&self) -> Result<(), Error> {
let content = format!(
"title = \"{}\"\nbase_url = \"https://example.com\"\ntheme_dir = \"theme\"\n",
self.title
);
write_file(&self.dir.join("aphid.toml"), &content)
}
fn write_theme(&self) -> Result<(), Error> {
Theme::write_default_to_dir(&self.dir.join("theme"))
}
fn write_gitignore(&self) -> Result<(), Error> {
write_file(&self.dir.join(".gitignore"), "/dist\n")
}
fn write_initial_blog_post(&self) -> Result<(), Error> {
let content_dir = self.dir.join("content");
write_blog_post(&content_dir, "Hello World")?;
Ok(())
}
fn write_initial_wiki_page(&self) -> Result<(), Error> {
let content_dir = self.dir.join("content");
let slug = Slug::from("Getting Started");
let content = "\
---
title: Getting Started
---
# Writing content
Blog posts live in `content/blog/`, wiki pages in `content/wiki/`, and
standalone pages in `content/pages/`.
# Wiki links
Link between any pages with `[[slug]]` syntax. For example, this page can be
linked from anywhere as `[[getting-started]]`.
# Building
Run `aphid serve` to start the development server, or `aphid build` to render
the site into the `dist/` directory.
";
write_file(
&content_dir.join("wiki").join(format!("{slug}.md")),
content,
)
}
fn write_initial_page(&self) -> Result<(), Error> {
let content = format!(
"\
---
title: About
order: 1
---
This is the about page for {title}. Edit `content/pages/about.md` to update it.
",
title = self.title
);
write_file(&self.dir.join("content/pages/about.md"), &content)
}
fn write_home(&self) -> Result<(), Error> {
let content = format!(
"\
Welcome to **{title}**. This is the home page content.
Edit `content/home.md` to change this text, or delete the file to use the
default home page layout.
",
title = self.title
);
write_file(&self.dir.join("content/home.md"), &content)
}
fn create_static_dir(&self) -> Result<(), Error> {
fs::create_dir_all(self.dir.join("static"))?;
Ok(())
}
fn build_site(&self) -> Result<(), Error> {
let dir = self.dir.canonicalize()?;
let config_path = dir.join("aphid.toml");
let output_dir = dir.join("dist");
let config = Config::from_path(&config_path)?;
let theme = Theme::load(&config)?;
let built = BuiltSite::build(&config, &theme)?;
if !built.diagnostics.is_empty() {
return Err(Error::BrokenWikiLinks(
built.diagnostics.broken_wiki_links.clone(),
));
}
let writer = OutputWriter::new(&output_dir)?;
writer.write(&built, &theme, &config.static_dir)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn title_from_hyphenated_name() {
assert_eq!(title_from_name("my-cool-blog"), "My Cool Blog");
}
#[test]
fn title_from_underscored_name() {
assert_eq!(title_from_name("my_site"), "My Site");
}
#[test]
fn title_from_plain_name() {
assert_eq!(title_from_name("mysite"), "Mysite");
}
#[test]
fn title_from_mixed_separators() {
assert_eq!(title_from_name("my-cool_blog"), "My Cool Blog");
}
#[test]
fn new_creates_complete_site() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = tmp.path().join("test-site");
new(site_dir.to_str().unwrap(), None).unwrap();
assert!(site_dir.join("aphid.toml").exists());
assert!(site_dir.join(".gitignore").exists());
assert!(site_dir.join("content/pages/about.md").exists());
assert!(site_dir.join("content/wiki/getting-started.md").exists());
assert!(site_dir.join("content/home.md").exists());
assert!(site_dir.join("static").is_dir());
let blog_entries: Vec<_> = fs::read_dir(site_dir.join("content/blog"))
.unwrap()
.collect();
assert_eq!(blog_entries.len(), 1);
let config = fs::read_to_string(site_dir.join("aphid.toml")).unwrap();
assert!(config.contains("title = \"Test Site\""));
}
#[test]
fn new_fails_if_directory_exists() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = tmp.path().join("existing");
fs::create_dir(&site_dir).unwrap();
let err = new(site_dir.to_str().unwrap(), None).unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
fn new_with_agent_writes_both() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = tmp.path().join("with-agent");
new(site_dir.to_str().unwrap(), Some(AgentTool::Claude)).unwrap();
assert!(site_dir.join("aphid.toml").exists());
assert!(site_dir.join("CLAUDE.md").exists());
assert!(
site_dir
.join(".claude/skills/aphid-content/SKILL.md")
.exists()
);
}
#[test]
fn init_creates_in_existing_directory() {
let tmp = tempfile::tempdir().unwrap();
init(tmp.path(), None).unwrap();
assert!(tmp.path().join("aphid.toml").exists());
assert!(tmp.path().join(".gitignore").exists());
assert!(tmp.path().join("content/blog").is_dir());
}
#[test]
fn init_fails_if_config_exists() {
let tmp = tempfile::tempdir().unwrap();
fs::write(tmp.path().join("aphid.toml"), "").unwrap();
let err = init(tmp.path(), None).unwrap_err();
assert!(err.to_string().contains("already contains an aphid.toml"));
}
#[test]
fn init_creates_directory_if_missing() {
let tmp = tempfile::tempdir().unwrap();
let nested = tmp.path().join("nested/site");
init(&nested, None).unwrap();
assert!(nested.join("aphid.toml").exists());
}
#[test]
fn init_with_agent_writes_both() {
let tmp = tempfile::tempdir().unwrap();
init(tmp.path(), Some(AgentTool::Codex)).unwrap();
assert!(tmp.path().join("aphid.toml").exists());
assert!(tmp.path().join("AGENTS.md").exists());
assert!(tmp.path().join(".agents/aphid-content.md").exists());
}
fn scaffold_site(tmp: &Path) -> PathBuf {
let site_dir = tmp.join("site");
new(site_dir.to_str().unwrap(), None).unwrap();
site_dir
}
#[test]
fn new_blog_post_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = scaffold_site(tmp.path());
let config_path = site_dir.join("aphid.toml");
new_blog_post(&config_path, "My New Post").unwrap();
let date = Local::now().format("%Y-%m-%d");
let expected = site_dir
.join("content/blog")
.join(format!("{date}_my-new-post.md"));
assert!(expected.exists());
let content = fs::read_to_string(&expected).unwrap();
assert!(content.contains("title: My New Post"));
assert!(content.contains("slug: my-new-post"));
}
#[test]
fn new_blog_post_fails_if_file_exists() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = scaffold_site(tmp.path());
let config_path = site_dir.join("aphid.toml");
new_blog_post(&config_path, "My New Post").unwrap();
let err = new_blog_post(&config_path, "My New Post").unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
fn new_wiki_page_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = scaffold_site(tmp.path());
let config_path = site_dir.join("aphid.toml");
new_wiki_page(&config_path, "My Topic").unwrap();
let expected = site_dir.join("content/wiki/my-topic.md");
assert!(expected.exists());
let content = fs::read_to_string(&expected).unwrap();
assert!(content.contains("title: My Topic"));
}
#[test]
fn new_wiki_page_fails_if_file_exists() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = scaffold_site(tmp.path());
let config_path = site_dir.join("aphid.toml");
new_wiki_page(&config_path, "My Topic").unwrap();
let err = new_wiki_page(&config_path, "My Topic").unwrap_err();
assert!(err.to_string().contains("already exists"));
}
#[test]
fn new_page_creates_file() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = scaffold_site(tmp.path());
let config_path = site_dir.join("aphid.toml");
new_page(&config_path, "Contact").unwrap();
let expected = site_dir.join("content/pages/contact.md");
assert!(expected.exists());
let content = fs::read_to_string(&expected).unwrap();
assert!(content.contains("title: Contact"));
}
#[test]
fn new_page_fails_if_file_exists() {
let tmp = tempfile::tempdir().unwrap();
let site_dir = scaffold_site(tmp.path());
let config_path = site_dir.join("aphid.toml");
new_page(&config_path, "Contact").unwrap();
let err = new_page(&config_path, "Contact").unwrap_err();
assert!(err.to_string().contains("already exists"));
}
}