krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

v0.3.0: src/main.rs · raw

  1//! CLI entry point (spec §3.5): `build`, `watch`, `clean`.
  2
  3use std::fs;
  4
  5use anyhow::{Context, Result};
  6use camino::{Utf8Path, Utf8PathBuf};
  7use clap::{Parser, Subcommand};
  8
  9use org_ssg::parser::parse;
 10use org_ssg::render::{render, Html, SyntectHighlighter};
 11use org_ssg::resolve::ResolvedDoc;
 12use org_ssg::site::{build_site, BuildOptions};
 13use org_ssg::template::Templater;
 14
 15#[derive(Parser)]
 16#[command(name = "org-ssg", about = "Org-mode static site generator")]
 17struct Cli {
 18    #[command(subcommand)]
 19    command: Command,
 20}
 21
 22#[derive(Subcommand)]
 23enum Command {
 24    /// Build a site. If INPUT is a directory, walk it and emit a linked static site
 25    /// to OUTPUT (a directory); if INPUT is a single `.org` file, emit one HTML file.
 26    Build {
 27        /// Input `.org` file, or a source directory for a whole-site build.
 28        input: Utf8PathBuf,
 29        /// Output path: an `.html` file for a single input, or a directory for a site.
 30        #[arg(short, long)]
 31        output: Option<Utf8PathBuf>,
 32        /// Bypass the incremental cache and re-render every page (spec §4.5).
 33        #[arg(long)]
 34        no_cache: bool,
 35        /// Treat broken internal links as errors (spec §4.3.4).
 36        #[arg(long)]
 37        strict: bool,
 38    },
 39    /// Watch a source directory and rebuild incrementally on change (simple poll loop).
 40    Watch {
 41        /// Source directory to watch.
 42        input: Utf8PathBuf,
 43        /// Output directory.
 44        #[arg(short, long)]
 45        output: Utf8PathBuf,
 46    },
 47    /// Remove the build output directory (which holds the cache manifest).
 48    Clean {
 49        /// Output directory to remove.
 50        output: Utf8PathBuf,
 51    },
 52}
 53
 54fn main() -> Result<()> {
 55    let cli = Cli::parse();
 56    match cli.command {
 57        Command::Build {
 58            input,
 59            output,
 60            no_cache,
 61            strict,
 62        } => {
 63            if input.is_dir() {
 64                let out = output
 65                    .context("site build requires an output directory: build <src-dir> -o <out-dir>")?;
 66                let opts = BuildOptions { no_cache, strict };
 67                let report = build_site(&input, &out, &opts)?;
 68                println!(
 69                    "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s))",
 70                    report.pages.len(),
 71                    report.rendered.len(),
 72                    report.skipped.len(),
 73                    report.assets.len(),
 74                    input,
 75                    out,
 76                    report.broken.len()
 77                );
 78            } else {
 79                let output = output.unwrap_or_else(|| input.with_extension("html"));
 80                build_file(&input, &output)?;
 81                println!("built {} -> {}", input, output);
 82            }
 83            Ok(())
 84        }
 85        // Watch is intentionally a minimal poll loop, not an OS file-watch (spec §5 Phase
 86        // 6 lists `watch`; the real fs-notify integration is deferred). It rebuilds
 87        // incrementally whenever any source file's mtime advances.
 88        Command::Watch { input, output } => watch(&input, &output),
 89        Command::Clean { output } => {
 90            if output.exists() {
 91                fs::remove_dir_all(&output)
 92                    .with_context(|| format!("removing output directory {output}"))?;
 93                println!("removed {output}");
 94            } else {
 95                println!("nothing to clean: {output} does not exist");
 96            }
 97            Ok(())
 98        }
 99    }
100}
101
102/// Minimal poll-based watch loop: rebuild incrementally whenever a source file changes.
103/// Not an OS file-watcher (deferred); it snapshots source mtimes every 500ms.
104fn watch(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
105    use std::time::{Duration, SystemTime};
106
107    if !input.is_dir() {
108        anyhow::bail!("watch requires a source directory: watch <src-dir> -o <out-dir>");
109    }
110    let opts = BuildOptions::default();
111
112    let snapshot = |root: &Utf8Path| -> Vec<(Utf8PathBuf, SystemTime)> {
113        let mut v = Vec::new();
114        for entry in walkdir::WalkDir::new(root).sort_by_file_name() {
115            let Ok(entry) = entry else { continue };
116            if !entry.file_type().is_file() {
117                continue;
118            }
119            if let (Ok(path), Ok(meta)) = (
120                Utf8PathBuf::from_path_buf(entry.path().to_owned()),
121                entry.metadata(),
122            ) {
123                let mtime = meta.modified().unwrap_or(SystemTime::UNIX_EPOCH);
124                v.push((path, mtime));
125            }
126        }
127        v
128    };
129
130    let report = build_site(input, output, &opts)?;
131    println!(
132        "watching {input} -> {output}: built {} page(s) ({} rendered). Ctrl-C to stop.",
133        report.pages.len(),
134        report.rendered.len()
135    );
136    let mut last = snapshot(input);
137    loop {
138        std::thread::sleep(Duration::from_millis(500));
139        let now = snapshot(input);
140        if now != last {
141            match build_site(input, output, &opts) {
142                Ok(report) => println!(
143                    "rebuilt: {} rendered, {} cached",
144                    report.rendered.len(),
145                    report.skipped.len()
146                ),
147                Err(e) => eprintln!("build error: {e:#}"),
148            }
149            last = now;
150        }
151    }
152}
153
154/// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link
155/// resolution (there is no corpus to resolve against); links keep their best-effort
156/// URLs. Whole-site link resolution lives in [`build_site`].
157fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
158    let source = fs::read_to_string(input)
159        .with_context(|| format!("reading source file {input}"))?;
160    let document = parse(input, &source).with_context(|| format!("parsing {input}"))?;
161
162    let title = document
163        .keywords
164        .entries
165        .iter()
166        .find(|(k, _)| k.eq_ignore_ascii_case("TITLE"))
167        .map(|(_, v)| v.clone())
168        .unwrap_or_else(|| input.file_stem().unwrap_or("untitled").to_string());
169
170    let resolved = ResolvedDoc { document };
171    let highlighter = SyntectHighlighter;
172    let Html(fragment) = render(&resolved, &highlighter);
173
174    let templater = Templater::new();
175    let page = templater
176        .render_page(&title, &fragment, &[])
177        .with_context(|| format!("templating {input}"))?;
178    fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
179    Ok(())
180}