use anyhow::{anyhow, Context, Result};
use clap::Parser;
use pulldown_cmark::{html, Options, Parser as MdParser};
use std::fs;
use std::path::PathBuf;
use tera::Context as TeraContext;
use picoblog::{find_and_parse_articles, generate_site};
const LONG_ABOUT: &str = r#"
picoblog: A minimalistic, single-binary static site generator.
OVERVIEW:
Scans directories for Markdown (.md) and plain text (.txt) files, parses them,
and generates a high-performance, single-page static website. The output includes
client-side full-text search, tag filtering, and auto-generated favicons.
FILE PROCESSING:
- Markdown (.md): Supports YAML frontmatter for metadata (title, tags, etc.).
If no frontmatter is present, it attempts to extract date and title from the
filename (e.g., "2024-10-01-My-Post.md").
- Text (.txt): Treated as simple posts. Metadata is extracted from the filename.
Content is HTML-escaped and line breaks are preserved.
SHARE PROVIDER VARIABLES:
When defining share links via the `--share` argument, you can use the following
variables in your URL templates. They will be URL-encoded automatically.
- {URL} : The absolute, anchor-based link to the specific post.
- {TITLE} : The title of the post.
- {TEXT} : The raw text content of the post.
- {TAGS} : A space-separated list of hashtags .
Spaces within a single tag are replaced with underscores
"#;
#[derive(Parser, Debug)]
#[command(version, about = "picoblog: a lite static site generator", long_about = LONG_ABOUT)]
struct Cli {
#[arg(short, long, required = false, default_value = "sample_content")]
inputs: Vec<PathBuf>,
#[arg(short, long, default_value = "My PicoBlog")]
title: String,
#[arg(short, long, default_value = "A blog generated by picoblog.")]
description: String,
#[arg(long, value_name = "PROVIDER:TEMPLATE")]
share: Vec<String>,
#[arg(long)]
elements_top: Option<PathBuf>,
#[arg(long)]
elements_bottom: Option<PathBuf>,
#[arg(short, long, default_value = "public")]
output: PathBuf,
}
fn read_optional_file(path: Option<PathBuf>) -> Result<String> {
match path {
Some(p) => fs::read_to_string(&p)
.with_context(|| format!("Failed to read optional file: {}", p.display())),
None => Ok(String::new()),
}
}
fn parse_share_providers(providers: &[String]) -> Result<Vec<(String, String)>> {
let mut result = Vec::new();
for p_str in providers {
if let Some((name, template)) = p_str.split_once(':') {
if !name.trim().is_empty() && !template.trim().is_empty() {
result.push((name.to_string(), template.to_string()));
} else {
return Err(anyhow!(
"Invalid share provider format: '{}'. Name and template must not be empty.",
p_str
));
}
} else {
return Err(anyhow!(
"Invalid share provider format: '{}'. Expected 'PROVIDER:URL_TEMPLATE'.",
p_str
));
}
}
Ok(result)
}
fn main() -> Result<()> {
let cli = Cli::parse();
let share_providers = parse_share_providers(&cli.share)?;
let description_text = if PathBuf::from(&cli.description).is_file() {
fs::read_to_string(&cli.description)?
} else {
cli.description.clone()
};
let description_html = {
let desc_path = PathBuf::from(&cli.description);
if desc_path.is_file() {
let content = fs::read_to_string(&desc_path).with_context(|| {
format!("Failed to read description file: {}", desc_path.display())
})?;
if desc_path.extension().and_then(|s| s.to_str()) == Some("md") {
let parser = MdParser::new_ext(&content, Options::empty());
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
} else {
format!("<p>{}</p>", content.replace('\n', "<br>"))
}
} else {
let parser = MdParser::new_ext(&cli.description, Options::empty());
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
html_output
}
};
let articles = find_and_parse_articles(&cli.inputs, &share_providers)?;
let mut context = TeraContext::new();
let mut settings_map = std::collections::HashMap::new();
settings_map.insert("title", cli.title);
settings_map.insert("description_html", description_html);
settings_map.insert("description_text", description_text);
settings_map.insert("elements_top", read_optional_file(cli.elements_top)?);
settings_map.insert("elements_bottom", read_optional_file(cli.elements_bottom)?);
context.insert("settings", &settings_map);
generate_site(articles, &context, &cli.output)?;
Ok(())
}