krz/orgo

Lightning fast org-mode static site generator.

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

v0.21.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 orgo::parser::parse;
 10use orgo::config::{self, Config};
 11use orgo::render::{self, render, Html, SyntectHighlighter};
 12use orgo::resolve::ResolvedDoc;
 13use orgo::site::{build_site, BuildOptions, SYNTAX_STYLESHEET, THEME_STYLESHEET};
 14use orgo::template::{PageContext, RenderContext, SiteContext, Templater};
 15
 16#[derive(Parser)]
 17#[command(name = "orgo", version, about = "Org-mode static site generator")]
 18struct Cli {
 19    #[command(subcommand)]
 20    command: Command,
 21}
 22
 23#[derive(Subcommand)]
 24enum Command {
 25    /// Build a site. If INPUT is a directory, walk it and emit a linked static site
 26    /// to OUTPUT (a directory); if INPUT is a single `.org` file, emit one HTML file.
 27    Build {
 28        /// Input `.org` file, or a source directory for a whole-site build.
 29        input: Utf8PathBuf,
 30        /// Output path: an `.html` file for a single input, or a directory for a site.
 31        #[arg(short, long)]
 32        output: Option<Utf8PathBuf>,
 33        /// Bypass the incremental cache and re-render every page (spec §4.5).
 34        #[arg(long)]
 35        no_cache: bool,
 36        /// Treat broken links and parse diagnostics as errors (spec §4.3.4).
 37        #[arg(long)]
 38        strict: bool,
 39        /// Config file to use, overriding `orgo.toml` in the source directory.
 40        #[arg(long, value_name = "FILE")]
 41        config: Option<Utf8PathBuf>,
 42        /// Include pages marked `#+DRAFT:`.
 43        #[arg(long)]
 44        drafts: bool,
 45    },
 46    /// Watch a source directory and rebuild incrementally on change, driven by OS
 47    /// filesystem events.
 48    Watch {
 49        /// Source directory to watch.
 50        input: Utf8PathBuf,
 51        /// Output directory.
 52        #[arg(short, long)]
 53        output: Utf8PathBuf,
 54        /// Bypass the incremental cache on every rebuild.
 55        #[arg(long)]
 56        no_cache: bool,
 57        /// Treat broken links and parse diagnostics as errors.
 58        #[arg(long)]
 59        strict: bool,
 60        /// Config file to use, overriding `orgo.toml` in the source directory.
 61        #[arg(long, value_name = "FILE")]
 62        config: Option<Utf8PathBuf>,
 63        /// Include pages marked `#+DRAFT:`. Handy while writing one.
 64        #[arg(long)]
 65        drafts: bool,
 66    },
 67    /// Serve the built site locally, rebuilding and reloading the browser on change.
 68    Serve {
 69        /// Source directory to build and watch.
 70        input: Utf8PathBuf,
 71        /// Output directory to serve.
 72        #[arg(short, long)]
 73        output: Utf8PathBuf,
 74        /// Port to listen on.
 75        #[arg(short, long, default_value_t = 3000)]
 76        port: u16,
 77        /// Address to bind. Defaults to loopback; set `0.0.0.0` to expose the server to
 78        /// your network, which also exposes any drafts you are building.
 79        #[arg(long, default_value = "127.0.0.1")]
 80        host: String,
 81        /// Include pages marked `#+DRAFT:`.
 82        #[arg(long)]
 83        drafts: bool,
 84        /// Config file to use, overriding `orgo.toml` in the source directory.
 85        #[arg(long, value_name = "FILE")]
 86        config: Option<Utf8PathBuf>,
 87    },
 88    /// Remove the build output directory (which holds the cache manifest).
 89    Clean {
 90        /// Output directory to remove.
 91        output: Utf8PathBuf,
 92    },
 93    /// Audit a corpus: report which org constructs it uses and how they land against
 94    /// the v1 scope line. Reports names, counts and locations — never document text.
 95    Audit {
 96        /// Source directory (or single `.org` file) to audit.
 97        input: Utf8PathBuf,
 98    },
 99    /// Scaffold a new site: config, an editable copy of the default layout, and a page.
100    Init {
101        /// Directory to create the site in (created if missing; defaults to the
102        /// current directory).
103        #[arg(default_value = ".")]
104        directory: Utf8PathBuf,
105    },
106}
107
108fn main() -> Result<()> {
109    let cli = Cli::parse();
110    match cli.command {
111        Command::Build {
112            input,
113            output,
114            no_cache,
115            strict,
116            config,
117            drafts,
118        } => {
119            if input.is_dir() {
120                let out = output
121                    .context("site build requires an output directory: build <src-dir> -o <out-dir>")?;
122                let opts = BuildOptions {
123                    no_cache,
124                    strict,
125                    config_path: config.clone(),
126                    drafts,
127                };
128                let report = build_site(&input, &out, &opts)?;
129                println!(
130                    "built {} page(s) ({} rendered, {} cached), copied {} asset(s) from {} -> {} ({} unresolved link(s), {} diagnostic(s))",
131                    report.pages.len(),
132                    report.rendered.len(),
133                    report.skipped.len(),
134                    report.assets.len(),
135                    input,
136                    out,
137                    report.broken.len(),
138                    report.diagnostics.len()
139                );
140            } else {
141                let output = output.unwrap_or_else(|| input.with_extension("html"));
142                build_file(&input, &output)?;
143                println!("built {} -> {}", input, output);
144            }
145            Ok(())
146        }
147        Command::Watch {
148            input,
149            output,
150            no_cache,
151            strict,
152            config,
153            drafts,
154        } => orgo::watch::run(
155            &input,
156            &output,
157            &BuildOptions {
158                no_cache,
159                strict,
160                config_path: config,
161                drafts,
162            },
163        ),
164        Command::Audit { input } => {
165            let result = orgo::audit::audit(&input)?;
166            print!("{}", orgo::audit::report(&result));
167            Ok(())
168        }
169        Command::Serve {
170            input,
171            output,
172            port,
173            host,
174            drafts,
175            config,
176        } => orgo::serve::run(
177            &input,
178            &output,
179            &BuildOptions {
180                drafts,
181                config_path: config,
182                ..Default::default()
183            },
184            &host,
185            port,
186        ),
187        Command::Init { directory } => init(&directory),
188        Command::Clean { output } => {
189            if output.exists() {
190                fs::remove_dir_all(&output)
191                    .with_context(|| format!("removing output directory {output}"))?;
192                println!("removed {output}");
193            } else {
194                println!("nothing to clean: {output} does not exist");
195            }
196            Ok(())
197        }
198    }
199}
200
201/// Scaffold a working site. Writes only files that do not already exist, so running it
202/// in a directory that has content is safe and additive rather than destructive.
203fn init(dir: &Utf8Path) -> Result<()> {
204    use orgo::config::{CONFIG_FILE, STARTER_CONFIG};
205    use orgo::template::{
206        starter_template, STARTER_FEED_TEMPLATE, STARTER_LIST_TEMPLATE, STARTER_TAGS_TEMPLATE,
207    };
208
209    fs::create_dir_all(dir).with_context(|| format!("creating {dir}"))?;
210    fs::create_dir_all(dir.join("templates")).with_context(|| format!("creating {dir}/templates"))?;
211    fs::create_dir_all(dir.join("blog")).with_context(|| format!("creating {dir}/blog"))?;
212
213    let index = concat!(
214        "#+TITLE: Hello\n",
215        "#+DATE: today\n",
216        "\n",
217        "Welcome to your new site. Edit this file, then run the build again.\n",
218        "\n",
219        "* A heading\n",
220        "\n",
221        "Org markup works as you would expect: *bold*, /italic/, ~code~, and\n",
222        "[[https://orgmode.org][links]].\n",
223        "\n",
224        "#+BEGIN_SRC rust\n",
225        "fn main() {\n",
226        "    println!(\"syntax highlighting is on by default\");\n",
227        "}\n",
228        "#+END_SRC\n",
229    );
230
231    let post = concat!(
232        "#+TITLE: A first post\n",
233        "#+DATE: <2026-01-15 Thu>\n",
234        "#+FILETAGS: :example:\n",
235        "\n",
236        "Posts in this directory are collected into /blog/ by the [[collections]] block\n",
237        "in orgo.toml, newest first.\n",
238    );
239
240    let files: [(Utf8PathBuf, &str); 7] = [
241        (dir.join(CONFIG_FILE), STARTER_CONFIG),
242        (dir.join("templates/base.html"), starter_template()),
243        (dir.join("templates/list.html"), STARTER_LIST_TEMPLATE),
244        (dir.join("templates/tags.html"), STARTER_TAGS_TEMPLATE),
245        (dir.join("templates/feed.xml"), STARTER_FEED_TEMPLATE),
246        (dir.join("index.org"), index),
247        (dir.join("blog/first-post.org"), post),
248    ];
249
250    let mut created = Vec::new();
251    for (path, contents) in &files {
252        if path.exists() {
253            println!("kept existing {path}");
254            continue;
255        }
256        fs::write(path, contents).with_context(|| format!("writing {path}"))?;
257        created.push(path.clone());
258    }
259
260    for path in &created {
261        println!("created {path}");
262    }
263    println!("\nNext: orgo build {dir} -o _site");
264    Ok(())
265}
266
267/// Single-file build: read → PARSE → RENDER → TEMPLATE → write. No cross-file link
268/// resolution (there is no corpus to resolve against); links keep their best-effort
269/// URLs. Whole-site link resolution lives in [`build_site`]. The syntax stylesheet is
270/// written alongside the page, since highlighting emits CSS classes.
271fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
272    let source = fs::read_to_string(input)
273        .with_context(|| format!("reading source file {input}"))?;
274    let document = parse(input, &source).with_context(|| format!("parsing {input}"))?;
275    for d in &document.diagnostics {
276        eprintln!("warning: {input}:{}: {}", d.line, d.message);
277    }
278
279    let title = document
280        .keywords
281        .entries
282        .iter()
283        .find(|(k, _)| k.eq_ignore_ascii_case("TITLE"))
284        .map(|(_, v)| v.clone())
285        .unwrap_or_else(|| input.file_stem().unwrap_or("untitled").to_string());
286
287    let resolved = ResolvedDoc { document };
288    let highlighter = SyntectHighlighter::new();
289    let Html(fragment) = render(&resolved, &highlighter);
290
291    // A single-file build still honours a config beside the source, so `build one.org`
292    // and a whole-site build produce the same-looking page.
293    let dir = input.parent().unwrap_or_else(|| Utf8Path::new("."));
294    let config = Config::load(dir)?;
295    config.validate()?;
296    let templater = Templater::load(Some(&dir.join(&config.templates.dir)), &config.site.base_url)?;
297    let css_text = render::syntax_css(&config.highlight.theme).ok_or_else(|| {
298        anyhow::anyhow!(
299            "unknown highlight.theme {:?}. Available: {}",
300            config.highlight.theme,
301            render::available_themes().join(", ")
302        )
303    })?;
304
305    let site = SiteContext {
306        title: config.site.title.clone(),
307        base_url: config.site.base_url.clone(),
308        description: config.site.description.clone(),
309        language: config.site.language.clone(),
310    };
311    let page_ctx = PageContext {
312        title: title.clone(),
313        url: output.file_name().unwrap_or("index.html").to_string(),
314        source: input.to_string(),
315        date: None,
316        date_iso: None,
317        year: None,
318        tags: Vec::new(),
319        excerpt: String::new(),
320        content: None,
321        word_count: 0,
322        reading_time: 0,
323        keywords: Default::default(),
324        toc: orgo::util::table_of_contents(&resolved.document.root),
325    };
326    let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
327    ctx.body = &fragment;
328    // The page and its stylesheets are written side by side here, so the link is a bare
329    // filename rather than a path back to a site root that does not exist.
330    ctx.theme = if config.site.theme.is_empty() {
331        ""
332    } else {
333        THEME_STYLESHEET
334    };
335    // `#+TEMPLATE:` and `[[pages]]` apply here too, so `build one.org` and a whole-site
336    // build put the same page through the same layout.
337    let name = config::page_template(
338        &config,
339        Utf8Path::new(input.file_name().unwrap_or_default()),
340        &resolved.document.keywords,
341    );
342    let page = templater
343        .render(&name, &ctx)
344        .with_context(|| format!("templating {input} through {name}"))?;
345    fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
346
347    let css = output.with_file_name(SYNTAX_STYLESHEET);
348    fs::write(&css, css_text).with_context(|| format!("writing stylesheet {css}"))?;
349
350    if let Some(theme_css) = orgo::theme::theme_css(&config.site.theme) {
351        let path = output.with_file_name(THEME_STYLESHEET);
352        fs::write(&path, theme_css).with_context(|| format!("writing stylesheet {path}"))?;
353    }
354    Ok(())
355}