#![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 PortfolioSiteGenerator {
config: SsgConfig,
paths: Paths,
log_file: File,
}
impl PortfolioSiteGenerator {
fn new() -> Result<Self> {
let log_file = File::create("portfolio_generation.log")
.context("Failed to create log file")?;
let base_dir = PathBuf::from("examples");
let content_dir = base_dir.join("portfolio").join("content");
let output_dir = base_dir.join("portfolio").join("build");
let template_dir = base_dir.join("templates");
let site_dir = base_dir.join("portfolio").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(&site_dir)
.context("Failed to create site 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("maya-okafor".to_string())
.base_url("http://127.0.0.1:3006".to_string())
.content_dir(content_dir.clone())
.output_dir(output_dir.clone())
.template_dir(template_dir.clone())
.site_title("Maya Okafor — UX Research & Design".to_string())
.site_description(
"Independent UX researcher and product designer working with \
founders, charities, and small product teams"
.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")
})?;
}
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("Starting portfolio site generation...")?;
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(false));
plugins.register(ssg::assets::FingerprintPlugin);
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,
)?;
let elapsed = start.elapsed();
println!(" \u{26a1} Built in {elapsed:.0?}");
self.log_message(&format!(
"Site generated at: {}",
self.paths.site.display()
))?;
Ok(())
}
fn print_structured_data(&self) -> Result<()> {
let index_path = self.paths.site.join("index.html");
if index_path.exists() {
let html = fs::read_to_string(&index_path)?;
let mut found_jsonld = false;
for line in html.lines() {
if line.contains("application/ld+json") {
found_jsonld = true;
}
if found_jsonld && line.contains("\"@type\"") {
let trimmed = line.trim();
println!("JSON-LD: {trimmed}");
}
}
if found_jsonld {
println!("Structured data: JSON-LD entity found in index.html");
} else {
println!(
"Structured data: no JSON-LD block found in index.html"
);
}
} else {
println!("index.html not found in site output");
}
Ok(())
}
fn verify_atom_feed(&self) -> Result<()> {
let atom_path = self.paths.site.join("atom.xml");
if atom_path.exists() {
let xml = fs::read_to_string(&atom_path)?;
let entries = xml.matches("<entry>").count();
println!("Atom feed: {entries} entries");
} else {
println!("Atom feed: not generated");
}
Ok(())
}
fn serve(&self) -> Result<()> {
self.log_message(
"Starting development server at http://127.0.0.1:3006",
)?;
let 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:3006")
.document_root(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 = PortfolioSiteGenerator::new()?;
generator.generate()?;
hide_language_icon(&generator.paths.site)?;
println!("\n--- Post-Build Analysis ---");
generator.print_structured_data()?;
generator.verify_atom_feed()?;
println!("---\n");
generator.serve()?;
Ok(())
}