use anyhow::{Context, Result};
use std::{fs, path::Path};
fn write_scaffold_file(
path: &Path,
content: impl AsRef<[u8]>,
label: &str,
) -> Result<()> {
fs::write(path, content).with_context(|| format!("Failed to write {label}"))
}
pub fn scaffold_project(name: &str) -> Result<()> {
let cwd = std::env::current_dir()?;
scaffold_project_at(name, &cwd)
}
pub fn scaffold_project_at(name: &str, base: &Path) -> Result<()> {
let root = base.join(name);
if root.exists() {
anyhow::bail!("Directory '{name}' already exists");
}
create_scaffold_dirs(name, &root)?;
write_config_file(name, &root)?;
write_content_files(name, &root)?;
write_template_files(&root)?;
write_static_assets(&root)?;
write_data_files(&root)?;
println!("Created new project: {name}");
println!(" cd {name}");
println!(" ssg -f config.toml");
Ok(())
}
fn create_scaffold_dirs(name: &str, root: &Path) -> Result<()> {
let dirs = [
"",
"content",
"content/blog",
"templates/tera",
"static/css",
"data",
];
for dir in &dirs {
fail_point!("scaffold::create-dir", |_| {
anyhow::bail!("injected: scaffold::create-dir")
});
fs::create_dir_all(root.join(dir))
.with_context(|| format!("Failed to create {name}/{dir}"))?;
}
Ok(())
}
fn write_config_file(name: &str, root: &Path) -> Result<()> {
fail_point!("scaffold::write-config", |_| {
anyhow::bail!("injected: scaffold::write-config")
});
write_scaffold_file(
&root.join("config.toml"),
format!(
r#"site_name = "{name}"
content_dir = "content"
output_dir = "public"
template_dir = "templates"
base_url = "http://127.0.0.1:8000"
site_title = "{name}"
site_description = "A site built with SSG"
language = "en-GB"
"#
),
"config.toml",
)
}
fn write_content_files(name: &str, root: &Path) -> Result<()> {
fail_point!("scaffold::write-index", |_| {
anyhow::bail!("injected: scaffold::write-index")
});
write_scaffold_file(
&root.join("content/index.md"),
format!(
r"---
title: Welcome to {name}
description: A fast, accessible static site built with SSG
layout: index
---
# Welcome
This is your new SSG site. Edit `content/index.md` to get started.
## Features
- Tera templating with inheritance
- WCAG 2.1 AA accessibility by default
- JSON-LD structured data for SEO
- Syntax highlighting for code blocks
- Responsive image optimisation
- Client-side search
"
),
"content/index.md",
)?;
fail_point!("scaffold::write-about", |_| {
anyhow::bail!("injected: scaffold::write-about")
});
write_scaffold_file(
&root.join("content/about.md"),
r"---
title: About
description: About this site
layout: page
---
# About
This page was generated by [SSG](https://static-site-generator.one).
",
"content/about.md",
)?;
fail_point!("scaffold::write-post", |_| {
anyhow::bail!("injected: scaffold::write-post")
});
write_scaffold_file(
&root.join("content/blog/first-post.md"),
format!(
r#"---
title: First Post
description: My first blog post
layout: post
date: 2026-01-01
author: {name} Team
tags:
- welcome
- getting-started
categories:
- blog
---
# First Post
Welcome to **{name}**! This is your first blog post.
## Code Example
```rust
fn main() {{
println!("Hello from {name}!");
}}
```
{{{{< tip >}}}}
Edit this file at `content/blog/first-post.md`.
{{{{< /tip >}}}}
"#
),
"content/blog/first-post.md",
)
}
fn write_template_files(root: &Path) -> Result<()> {
fail_point!("scaffold::write-base", |_| {
anyhow::bail!("injected: scaffold::write-base")
});
write_scaffold_file(
&root.join("templates/tera/base.html"),
r##"<!DOCTYPE html>
<html lang="{{ site.language | default(value='en') }}">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ page.title | default(value="Untitled") }}{% if site.title %} — {{ site.title }}{% endif %}{% endblock %}</title>
{% if page.description %}<meta name="description" content="{{ page.description }}">{% endif %}
<link rel="stylesheet" href="/css/style.css">
{% block head_extra %}{% endblock %}
</head>
<body>
<a href="#main-content" class="sr-only">Skip to main content</a>
<header role="banner">
<nav aria-label="Main navigation">
<a href="/">{{ site.name | default(value="Home") }}</a>
<a href="/about.html">About</a>
</nav>
</header>
<main id="main-content" role="main">
{% block content %}{% endblock %}
</main>
<footer role="contentinfo">
<p>© {{ site.name | default(value="") }}. Built with <a href="https://static-site-generator.one">SSG</a>.</p>
</footer>
</body>
</html>
"##,
"templates/tera/base.html",
)?;
fail_point!("scaffold::write-page-tpl", |_| {
anyhow::bail!("injected: scaffold::write-page-tpl")
});
write_scaffold_file(
&root.join("templates/tera/page.html"),
r#"{% extends "base.html" %}
{% block content %}{{ page.content | safe }}{% endblock %}
"#,
"templates/tera/page.html",
)?;
fail_point!("scaffold::write-post-tpl", |_| {
anyhow::bail!("injected: scaffold::write-post-tpl")
});
write_scaffold_file(
&root.join("templates/tera/post.html"),
r#"{% extends "base.html" %}
{% block content %}
<article>
<header>
<h1>{{ page.title | default(value="") }}</h1>
{% if page.date %}<time datetime="{{ page.date }}">{{ page.date }}</time>{% endif %}
{% if page.author %}<span class="author">by {{ page.author }}</span>{% endif %}
{% if page.content %}<span class="reading-time">{{ page.content | reading_time }}</span>{% endif %}
</header>
<div class="post-body">{{ page.content | safe }}</div>
{% if page.tags %}
<footer>
<ul class="tags" aria-label="Tags">
{% for tag in page.tags %}<li><a href="/tags/{{ tag | slugify }}/">{{ tag }}</a></li>{% endfor %}
</ul>
</footer>
{% endif %}
</article>
{% endblock %}
"#,
"templates/tera/post.html",
)?;
fail_point!("scaffold::write-index-tpl", |_| {
anyhow::bail!("injected: scaffold::write-index-tpl")
});
write_scaffold_file(
&root.join("templates/tera/index.html"),
r#"{% extends "base.html" %}
{% block title %}{{ site.title | default(value="Home") }}{% endblock %}
{% block content %}
<section>{{ page.content | safe }}</section>
{% endblock %}
"#,
"templates/tera/index.html",
)
}
fn write_static_assets(root: &Path) -> Result<()> {
fail_point!("scaffold::write-css", |_| {
anyhow::bail!("injected: scaffold::write-css")
});
write_scaffold_file(
&root.join("static/css/style.css"),
r#"/* SSG Default Styles */
:root {
--text: #1a1a2e;
--bg: #ffffff;
--accent: #0066cc;
--muted: #6b7280;
--border: #e5e7eb;
--radius: 6px;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #e6edf3;
--bg: #0d1117;
--accent: #58a6ff;
--muted: #8b949e;
--border: #30363d;
}
}
*, *::before, *::after { box-sizing: border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: var(--text);
background: var(--bg);
line-height: 1.6;
max-width: 48rem;
margin: 0 auto;
padding: 1rem 1.5rem;
}
a { color: var(--accent); }
nav { display: flex; gap: 1rem; padding: 1rem 0; border-bottom: 1px solid var(--border); }
main { padding: 2rem 0; }
footer { border-top: 1px solid var(--border); padding: 1rem 0; color: var(--muted); font-size: 0.875rem; }
pre { background: #f6f8fa; border: 1px solid var(--border); border-radius: var(--radius); padding: 1em; overflow-x: auto; }
@media (prefers-color-scheme: dark) { pre { background: #161b22; } }
code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: 0.875em; }
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; }
.admonition { border-left: 4px solid var(--accent); padding: 0.75rem 1rem; margin: 1rem 0; border-radius: var(--radius); }
.admonition-title { font-weight: 600; margin-bottom: 0.25rem; }
.tags { list-style: none; padding: 0; display: flex; gap: 0.5rem; }
.tags li a { background: var(--border); padding: 0.25rem 0.5rem; border-radius: var(--radius); text-decoration: none; font-size: 0.875rem; }
article time, article .author, article .reading-time { color: var(--muted); font-size: 0.875rem; margin-right: 1rem; }
"#,
"static/css/style.css",
)
}
fn write_data_files(root: &Path) -> Result<()> {
fail_point!("scaffold::write-nav", |_| {
anyhow::bail!("injected: scaffold::write-nav")
});
write_scaffold_file(
&root.join("data/nav.toml"),
r#"[[links]]
name = "Home"
url = "/"
[[links]]
name = "About"
url = "/about.html"
[[links]]
name = "Blog"
url = "/blog/"
"#,
"data/nav.toml",
)
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn scaffold_project_at_creates_complete_directory_structure() {
let dir = tempdir().unwrap();
let name = "test-site";
let project = dir.path().join(name);
scaffold_project_at(name, dir.path()).unwrap();
for sub in [
"",
"content",
"content/blog",
"templates/tera",
"static/css",
"data",
] {
assert!(
project.join(sub).exists(),
"subdirectory `{sub}` should have been created"
);
}
}
#[test]
fn scaffold_project_at_writes_all_expected_files() {
let dir = tempdir().unwrap();
let name = "demo";
let project = dir.path().join(name);
scaffold_project_at(name, dir.path()).unwrap();
for file in [
"config.toml",
"content/index.md",
"content/about.md",
"content/blog/first-post.md",
"templates/tera/base.html",
"templates/tera/page.html",
"templates/tera/post.html",
"templates/tera/index.html",
"static/css/style.css",
"data/nav.toml",
] {
assert!(
project.join(file).exists(),
"file `{file}` should have been scaffolded"
);
}
}
#[test]
fn scaffold_project_at_injects_project_name_into_config() {
let dir = tempdir().unwrap();
let name = "my-cool-site";
scaffold_project_at(name, dir.path()).unwrap();
let config =
fs::read_to_string(dir.path().join(name).join("config.toml"))
.unwrap();
assert!(config.contains(&format!(r#"site_name = "{name}""#)));
assert!(config.contains(&format!(r#"site_title = "{name}""#)));
assert!(config.contains(r#"language = "en-GB""#));
}
#[test]
fn scaffold_project_at_injects_project_name_into_index_md() {
let dir = tempdir().unwrap();
let name = "hello";
scaffold_project_at(name, dir.path()).unwrap();
let index =
fs::read_to_string(dir.path().join(name).join("content/index.md"))
.unwrap();
assert!(index.contains(&format!("title: Welcome to {name}")));
assert!(index.contains("layout: index"));
}
#[test]
fn scaffold_project_at_injects_project_name_into_first_post() {
let dir = tempdir().unwrap();
let name = "projectx";
scaffold_project_at(name, dir.path()).unwrap();
let post = fs::read_to_string(
dir.path().join(name).join("content/blog/first-post.md"),
)
.unwrap();
assert!(post.contains(&format!("author: {name} Team")));
assert!(post.contains(&format!("Welcome to **{name}**")));
assert!(post.contains(&format!(r#"println!("Hello from {name}!");"#)));
}
#[test]
fn scaffold_project_at_static_assets_include_dark_mode_block() {
let dir = tempdir().unwrap();
scaffold_project_at("a", dir.path()).unwrap();
let css = fs::read_to_string(
dir.path().join("a").join("static/css/style.css"),
)
.unwrap();
assert!(css.contains("@media (prefers-color-scheme: dark)"));
assert!(css.contains(".sr-only"));
}
#[test]
fn scaffold_project_at_base_template_has_accessibility_landmarks() {
let dir = tempdir().unwrap();
scaffold_project_at("x", dir.path()).unwrap();
let base = fs::read_to_string(
dir.path().join("x").join("templates/tera/base.html"),
)
.unwrap();
assert!(base.contains(r#"role="banner""#));
assert!(base.contains(r#"role="main""#));
assert!(base.contains(r#"role="contentinfo""#));
assert!(base.contains(r#"aria-label="Main navigation""#));
assert!(base.contains(r#"class="sr-only""#));
}
#[test]
fn scaffold_project_at_nav_toml_has_three_default_links() {
let dir = tempdir().unwrap();
scaffold_project_at("y", dir.path()).unwrap();
let nav =
fs::read_to_string(dir.path().join("y").join("data/nav.toml"))
.unwrap();
assert_eq!(nav.matches("[[links]]").count(), 3);
assert!(nav.contains(r#"name = "Home""#));
assert!(nav.contains(r#"name = "About""#));
assert!(nav.contains(r#"name = "Blog""#));
}
#[test]
fn scaffold_project_at_refuses_to_overwrite_existing_directory() {
let dir = tempdir().unwrap();
let name = "existing";
fs::create_dir(dir.path().join(name)).unwrap();
let err = scaffold_project_at(name, dir.path()).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("already exists"),
"error should mention `already exists`: {msg}"
);
}
#[test]
fn scaffold_project_at_refuses_to_overwrite_existing_file() {
let dir = tempdir().unwrap();
let name = "blocker";
fs::write(dir.path().join(name), "i exist").unwrap();
assert!(scaffold_project_at(name, dir.path()).is_err());
}
#[test]
fn scaffold_project_uses_current_working_directory() {
let dir = tempdir().unwrap();
let prev = std::env::current_dir().expect("read current dir");
std::env::set_current_dir(&dir).expect("pushd");
let result = scaffold_project("from-cwd");
std::env::set_current_dir(&prev).expect("popd");
result.expect("scaffold should succeed in a fresh cwd");
assert!(dir.path().join("from-cwd").join("config.toml").exists());
}
#[test]
fn write_scaffold_file_creates_file() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
write_scaffold_file(&path, "hello", "test.txt").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "hello");
}
#[test]
fn write_scaffold_file_overwrites_existing() {
let dir = tempdir().unwrap();
let path = dir.path().join("test.txt");
fs::write(&path, "old").unwrap();
write_scaffold_file(&path, "new", "test.txt").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "new");
}
#[test]
fn write_scaffold_file_error_has_label() {
let err = write_scaffold_file(
Path::new("/no/such/dir/file.txt"),
"x",
"my-label",
)
.unwrap_err();
let msg = format!("{err:#}");
assert!(
msg.contains("my-label"),
"error should include the label: {msg}"
);
}
#[test]
fn write_scaffold_file_empty_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("empty.txt");
write_scaffold_file(&path, "", "empty.txt").unwrap();
assert_eq!(fs::read_to_string(&path).unwrap(), "");
}
#[test]
fn write_scaffold_file_binary_content() {
let dir = tempdir().unwrap();
let path = dir.path().join("bin.dat");
let data: &[u8] = &[0x00, 0xFF, 0xAB, 0xCD];
write_scaffold_file(&path, data, "bin.dat").unwrap();
assert_eq!(fs::read(&path).unwrap(), data);
}
#[test]
fn create_scaffold_dirs_creates_all_expected_dirs() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
create_scaffold_dirs("proj", &root).unwrap();
for sub in [
"",
"content",
"content/blog",
"templates/tera",
"static/css",
"data",
] {
assert!(root.join(sub).exists(), "{sub} should exist");
}
}
#[test]
fn create_scaffold_dirs_idempotent() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
create_scaffold_dirs("proj", &root).unwrap();
create_scaffold_dirs("proj", &root).unwrap();
}
#[test]
fn write_config_file_content() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(&root).unwrap();
write_config_file("my-site", &root).unwrap();
let content = fs::read_to_string(root.join("config.toml")).unwrap();
assert!(content.contains(r#"site_name = "my-site""#));
assert!(content.contains(r#"site_title = "my-site""#));
assert!(content.contains(r#"content_dir = "content""#));
assert!(content.contains(r#"output_dir = "public""#));
assert!(content.contains(r#"template_dir = "templates""#));
assert!(content.contains("http://127.0.0.1:8000"));
assert!(content.contains(r#"language = "en-GB""#));
}
#[test]
fn write_content_files_creates_all_content() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("content/blog")).unwrap();
write_content_files("test-proj", &root).unwrap();
assert!(root.join("content/index.md").exists());
assert!(root.join("content/about.md").exists());
assert!(root.join("content/blog/first-post.md").exists());
}
#[test]
fn write_content_files_about_has_correct_frontmatter() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("content/blog")).unwrap();
write_content_files("test-proj", &root).unwrap();
let about = fs::read_to_string(root.join("content/about.md")).unwrap();
assert!(about.contains("title: About"));
assert!(about.contains("layout: page"));
assert!(about.contains("static-site-generator.one"));
}
#[test]
fn write_content_files_index_has_features_list() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("content/blog")).unwrap();
write_content_files("proj", &root).unwrap();
let index = fs::read_to_string(root.join("content/index.md")).unwrap();
assert!(index.contains("## Features"));
assert!(index.contains("- Tera templating"));
}
#[test]
fn write_content_files_first_post_has_tags_and_code() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("content/blog")).unwrap();
write_content_files("proj", &root).unwrap();
let post = fs::read_to_string(root.join("content/blog/first-post.md"))
.unwrap();
assert!(post.contains("tags:"));
assert!(post.contains("- welcome"));
assert!(post.contains("```rust"));
assert!(post.contains("categories:"));
}
#[test]
fn write_template_files_creates_all_templates() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("templates/tera")).unwrap();
write_template_files(&root).unwrap();
for file in [
"templates/tera/base.html",
"templates/tera/page.html",
"templates/tera/post.html",
"templates/tera/index.html",
] {
assert!(root.join(file).exists(), "{file} should exist");
}
}
#[test]
fn write_template_files_page_extends_base() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("templates/tera")).unwrap();
write_template_files(&root).unwrap();
let page =
fs::read_to_string(root.join("templates/tera/page.html")).unwrap();
assert!(page.contains(r#"extends "base.html""#));
assert!(page.contains("block content"));
}
#[test]
fn write_template_files_post_has_article_structure() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("templates/tera")).unwrap();
write_template_files(&root).unwrap();
let post =
fs::read_to_string(root.join("templates/tera/post.html")).unwrap();
assert!(post.contains("<article>"));
assert!(post.contains("page.title"));
assert!(post.contains("page.date"));
assert!(post.contains("page.tags"));
assert!(post.contains("reading_time"));
}
#[test]
fn write_template_files_index_extends_base() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("templates/tera")).unwrap();
write_template_files(&root).unwrap();
let index =
fs::read_to_string(root.join("templates/tera/index.html")).unwrap();
assert!(index.contains(r#"extends "base.html""#));
assert!(index.contains("site.title"));
}
#[test]
fn write_static_assets_creates_stylesheet() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("static/css")).unwrap();
write_static_assets(&root).unwrap();
let css =
fs::read_to_string(root.join("static/css/style.css")).unwrap();
assert!(css.contains(":root"));
assert!(css.contains("--text:"));
assert!(css.contains("--bg:"));
assert!(css.contains("--accent:"));
assert!(css.contains("box-sizing: border-box"));
}
#[test]
fn write_data_files_creates_nav_toml() {
let dir = tempdir().unwrap();
let root = dir.path().join("proj");
fs::create_dir_all(root.join("data")).unwrap();
write_data_files(&root).unwrap();
let nav = fs::read_to_string(root.join("data/nav.toml")).unwrap();
assert_eq!(nav.matches("[[links]]").count(), 3);
assert!(nav.contains(r#"url = "/""#));
assert!(nav.contains(r#"url = "/about.html""#));
assert!(nav.contains(r#"url = "/blog/""#));
}
#[test]
fn scaffold_project_at_special_chars_in_name() {
let dir = tempdir().unwrap();
let name = "my-cool_site.2026";
scaffold_project_at(name, dir.path()).unwrap();
let config =
fs::read_to_string(dir.path().join(name).join("config.toml"))
.unwrap();
assert!(config.contains(&format!(r#"site_name = "{name}""#)));
}
#[test]
fn scaffold_project_at_single_char_name() {
let dir = tempdir().unwrap();
scaffold_project_at("z", dir.path()).unwrap();
assert!(dir.path().join("z").join("config.toml").exists());
}
#[test]
fn scaffold_project_at_base_template_is_valid_html() {
let dir = tempdir().unwrap();
scaffold_project_at("t", dir.path()).unwrap();
let base = fs::read_to_string(
dir.path().join("t").join("templates/tera/base.html"),
)
.unwrap();
assert!(base.starts_with("<!DOCTYPE html>"));
assert!(base.contains("</html>"));
assert!(base.contains("<head>"));
assert!(base.contains("</head>"));
assert!(base.contains("<body>"));
assert!(base.contains("</body>"));
}
#[test]
fn scaffold_project_at_config_has_all_required_keys() {
let dir = tempdir().unwrap();
scaffold_project_at("k", dir.path()).unwrap();
let config =
fs::read_to_string(dir.path().join("k").join("config.toml"))
.unwrap();
for key in [
"site_name",
"content_dir",
"output_dir",
"template_dir",
"base_url",
"site_title",
"site_description",
"language",
] {
assert!(
config.contains(key),
"config.toml should contain key: {key}"
);
}
}
}