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 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};
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 // `#+TEMPLATE:` and `[[pages]]` apply here too, so `build one.org` and a whole-site
329 // build put the same page through the same layout.
330 let name = config::page_template(
331 &config,
332 Utf8Path::new(input.file_name().unwrap_or_default()),
333 &resolved.document.keywords,
334 );
335 let page = templater
336 .render(&name, &ctx)
337 .with_context(|| format!("templating {input} through {name}"))?;
338 fs::write(output, page).with_context(|| format!("writing output file {output}"))?;
339
340 let css = output.with_file_name(SYNTAX_STYLESHEET);
341 fs::write(&css, css_text).with_context(|| format!("writing stylesheet {css}"))?;
342 Ok(())
343}