picoblog 0.0.2

A minimalistic static site generator written in Rust.
Documentation
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;

// Import logic from our library
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 {
    /// One or more source directories containing content files.
    #[arg(short, long, required = false, default_value = "sample_content")]
    inputs: Vec<PathBuf>,

    /// The title of the blog.
    #[arg(short, long, default_value = "My PicoBlog")]
    title: String,

    /// A short description (string, or path to .md/.txt file).
    #[arg(short, long, default_value = "A blog generated by picoblog.")]
    description: String,

    /// Define a share provider. Format: "PROVIDER:URL_TEMPLATE".
    /// See --help for available variables ({URL}, {TAGS}, etc).
    #[arg(long, value_name = "PROVIDER:TEMPLATE")]
    share: Vec<String>,

    /// Path to an HTML file to inject at the top of the <head>.
    #[arg(long)]
    elements_top: Option<PathBuf>,

    /// Path to an HTML file to inject at the bottom of the <body>.
    #[arg(long)]
    elements_bottom: Option<PathBuf>,

    /// Path to the output directory.
    #[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();

    // 1. Parse share providers
    let share_providers = parse_share_providers(&cli.share)?;

    // 2. Process the flexible description
    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
        }
    };

    // 3. Find and parse all articles, passing share providers
    let articles = find_and_parse_articles(&cli.inputs, &share_providers)?;

    // 4. Prepare settings for the template
    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);

    // 5. Generate the site
    generate_site(articles, &context, &cli.output)?;

    Ok(())
}