#![allow(clippy::unwrap_used, clippy::expect_used)]
use anyhow::{Context, Result};
use http_handle::Server;
use ssg::{
cmd::SsgConfig,
execute_build_pipeline,
plugin::{PluginContext, PluginManager},
Paths,
};
use std::{
fs::{self, File},
io::Write,
path::PathBuf,
time::Instant,
};
struct SiteGenerator {
config: SsgConfig,
paths: Paths,
log_file: File,
}
impl SiteGenerator {
fn new(_site_name: &str, _base_url: &str) -> Result<Self> {
let site_name = "heron-coffee";
let base_url = "http://127.0.0.1:3007";
let log_file = File::create("site_generation.log")
.context("Failed to create log file")?;
let base_dir = PathBuf::from("examples").join("quickstart");
let content_dir = base_dir.join("content");
let output_dir = base_dir.join("build");
let template_dir = PathBuf::from("examples").join("templates");
let site_dir = base_dir.join("public");
fs::create_dir_all(&content_dir)
.context("Failed to create content directory")?;
fs::create_dir_all(&output_dir)
.context("Failed to create output directory")?;
fs::create_dir_all(&template_dir)
.context("Failed to create template directory")?;
let content_dir = fs::canonicalize(content_dir)?;
let output_dir = fs::canonicalize(output_dir)?;
let template_dir = fs::canonicalize(template_dir)?;
let site_dir = fs::canonicalize(site_dir.clone()).unwrap_or(site_dir);
let config = SsgConfig::builder()
.site_name(site_name.to_string())
.base_url(base_url.to_string())
.content_dir(content_dir.clone())
.output_dir(output_dir.clone())
.template_dir(template_dir.clone())
.site_title(
"Heron Coffee — single-origin roaster, Bermondsey".to_string(),
)
.site_description(
"Heron Coffee — single-origin coffee roasted on Druid \
Street every Tuesday morning"
.to_string(),
)
.language("en-GB".to_string())
.build()
.context("Failed to build configuration")?;
let paths = Paths {
content: content_dir,
build: output_dir,
site: site_dir,
template: template_dir,
};
Ok(Self {
config,
paths,
log_file,
})
}
fn prepare_directories(&self) -> Result<()> {
for (name, path) in [
("content", &self.config.content_dir),
("build", &self.config.output_dir),
("site", &self.paths.site),
("template", &self.config.template_dir),
] {
fs::create_dir_all(path).with_context(|| {
format!("Failed to create {name} directory")
})?;
self.log_message(&format!(
"Ensured {} directory at: {}",
name,
path.display()
))?;
}
Ok(())
}
fn log_message(&self, message: &str) -> Result<()> {
let date = ssg::now_iso();
writeln!(&self.log_file, "[{date}] INFO process: {message}")
.context("Failed to write to log file")?;
println!("{message}");
Ok(())
}
fn generate(&self) -> Result<()> {
self.log_message(&format!(
"Starting generation for site: {}",
self.config.site_name
))?;
self.prepare_directories()?;
let ctx = PluginContext::with_config(
&self.config.content_dir,
&self.config.output_dir,
&self.paths.site,
&self.config.template_dir,
self.config.clone(),
);
let mut plugins = PluginManager::new();
plugins.register(ssg::shortcodes::ShortcodePlugin);
#[cfg(feature = "templates")]
plugins.register(
ssg::template_plugin::TemplatePlugin::from_template_dir(
&self.config.template_dir,
),
);
plugins.register(ssg::postprocess::SitemapFixPlugin);
plugins.register(ssg::postprocess::NewsSitemapFixPlugin);
plugins.register(ssg::postprocess::RssAggregatePlugin);
plugins.register(ssg::postprocess::AtomFeedPlugin);
plugins.register(ssg::postprocess::ManifestFixPlugin);
plugins.register(ssg::postprocess::HtmlFixPlugin);
plugins.register(ssg::highlight::HighlightPlugin::default());
plugins.register(ssg::seo::SeoPlugin);
plugins.register(ssg::seo::JsonLdPlugin::from_site(
&self.config.base_url,
&self.config.site_name,
));
plugins.register(ssg::seo::CanonicalPlugin::new(
self.config.base_url.clone(),
));
plugins.register(ssg::seo::RobotsPlugin::new(
self.config.base_url.clone(),
));
plugins.register(ssg::search::SearchPlugin);
plugins.register(ssg::accessibility::AccessibilityPlugin);
#[cfg(feature = "image-optimization")]
plugins.register(ssg::image_plugin::ImageOptimizationPlugin::default());
plugins.register(ssg::ai::AiPlugin);
plugins.register(ssg::taxonomy::TaxonomyPlugin);
plugins.register(ssg::pagination::PaginationPlugin::default());
plugins.register(ssg::drafts::DraftPlugin::new(true));
plugins.register(ssg::assets::FingerprintPlugin);
plugins.register(ssg::csp::CspPlugin);
plugins.register(ssg::islands::IslandPlugin);
plugins.register(ssg::plugins::MinifyPlugin);
self.log_message("Compiling site with full plugin pipeline...")?;
let start = Instant::now();
execute_build_pipeline(
&plugins,
&ctx,
&self.config.output_dir,
&self.config.content_dir,
&self.paths.site,
&self.config.template_dir,
false,
)?;
println!(
" 📦 Streaming: {} MB memory budget available",
ssg::streaming::DEFAULT_MEMORY_BUDGET_MB
);
let elapsed = start.elapsed();
println!(" \u{26a1} Built in {elapsed:.0?}");
println!(
" \u{1f4e6} Streaming I/O buffer: {} KB",
ssg::stream::STREAM_BUFFER_SIZE / 1024
);
self.log_message(&format!(
"Site generated successfully at: {}",
self.paths.site.display()
))?;
Ok(())
}
fn serve(&self) -> Result<()> {
self.log_message(
"Starting development server at http://127.0.0.1:3007",
)?;
let example_root: String = self
.paths
.site
.to_str()
.context("Failed to convert site path to string")?
.to_string();
let server = Server::builder()
.address("127.0.0.1:3007")
.document_root(example_root.as_str())
.custom_header("Permissions-Policy", "browsing-topics=()")
.build()
.map_err(|e| anyhow::anyhow!("{e}"))?;
server.start().context("Failed to start dev server")?;
Ok(())
}
}
fn hide_language_icon(site_dir: &std::path::Path) -> Result<()> {
const MARKER: &str = "/* ssg-single-locale: hide lang */";
const STYLE: &str =
"<style>/* ssg-single-locale: hide lang */.lang-btn,.lang-dropdown,.mobile-lang{display:none!important}</style>";
fn walk(dir: &std::path::Path, marker: &str, style: &str) -> Result<()> {
for entry in fs::read_dir(dir)? {
let entry = entry?;
let path = entry.path();
if path.is_dir() {
walk(&path, marker, style)?;
} else if path.extension().is_some_and(|e| e == "html") {
let html = fs::read_to_string(&path)?;
if html.contains(marker) {
continue;
}
if let Some(pos) = html.find("</head>") {
let new_html =
format!("{}{}{}", &html[..pos], style, &html[pos..]);
fs::write(&path, new_html)?;
}
}
}
Ok(())
}
walk(site_dir, MARKER, STYLE)
}
fn main() -> Result<()> {
let generator =
SiteGenerator::new("heron-coffee", "http://127.0.0.1:3007")?;
generator.generate()?;
hide_language_icon(&generator.paths.site)?;
println!(
" \u{1f441} Watch: .css → {:?}, .md → {:?}",
ssg::watch::classify_change(std::path::Path::new("x.css")),
ssg::watch::classify_change(std::path::Path::new("x.md"))
);
generator.serve()?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_site_generator_creation() -> Result<()> {
let generator =
SiteGenerator::new("test-site", "http://127.0.0.1:3007")?;
assert_eq!(generator.config.site_name, "test-site");
assert_eq!(generator.config.base_url, "http://127.0.0.1:3007");
Ok(())
}
#[test]
fn test_directory_preparation() -> Result<()> {
let generator =
SiteGenerator::new("test-site", "http://127.0.0.1:3007")?;
generator.prepare_directories()?;
assert!(generator.config.content_dir.exists());
assert!(generator.config.output_dir.exists());
assert!(generator.config.template_dir.exists());
assert!(generator.paths.site.exists());
Ok(())
}
}