krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
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}