krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
20cb94bb4e278edf6a7e11e47d435b978006954a
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:46:39Z
Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 24 +++++- src/config.rs | 17 +++++ src/main.rs | 13 ++++ src/site.rs | 34 ++++++++- src/template.rs | 31 ++++++++ src/util.rs | 101 +++++++++++++++++++++++++- tests/config.rs | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 9 files changed, 438 insertions(+), 7 deletions(-) @@ -657,7 +657,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.11.0" +version = "0.12.0" dependencies = [ "anyhow", "blake3", @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.11.0" +version = "0.12.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -72,7 +72,7 @@ receive: | Variable | What it is | |---|---| | `body` | the rendered page HTML — use `{{ body \| safe }}` | -| `page` | `.title`, `.url`, `.source`, `.date`, `.tags`, `.keywords` | +| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.keywords` | | `site` | `.title`, `.base_url`, `.description`, `.language` | | `nav` | list of `{title, url}`, relative to this page | | `root` | `../`-prefix back to the site root from this page | @@ -218,6 +218,7 @@ broken. Set `site.base_url` and use the `absolute` filter: |---|---| | `absolute` | site-root-relative path → absolute URL; already-absolute URLs pass through | | `rfc822` | any org or ISO date → the format RSS `pubDate` requires | +| `truncate(n)` | shorten to at most `n` characters on a word boundary, with an ellipsis | Apply `absolute` to the site-root-relative values — `page.url`, `pages[].url`, `group.url` — and not to `nav[].url`, `paginator.*_url`, `stylesheet` or `root`, which @@ -230,6 +231,24 @@ The default layout also emits `<link rel="canonical">` when a base URL is set. Listing pages are cached on the entries they list, so adding a post re-renders that section's index and nothing else. +### Excerpts and drafts + +`page.excerpt` is a page's `#+DESCRIPTION:` when it sets one and its first paragraph +otherwise, so a listing has something to show whether or not the author thought about +summaries. `page.word_count` and `page.reading_time` (minutes at 200 wpm) count prose +only — a post that is mostly a shell transcript should not read as an hour's work. +`truncate` exists because an excerpt is usually a whole paragraph and minijinja has no +such filter. + +`#+DRAFT:` keeps a page out of the build entirely — no page, and absent from listings and +the nav rather than merely unlinked. `--drafts` includes them, which is what you want +under `watch` while writing one. A draft is out of the symbol table too, so a link *to* +one is reported as the dead link it would be once published. + +The keyword is read forgivingly: `t`, `yes`, `1` and a bare `#+DRAFT:` all mean draft, +because writing the keyword at all is the signal. Only an explicit `nil`, `false`, `no`, +`0` or `off` means published. + ### `#+SLUG:` A page's output filename comes from its `#+SLUG:` when it has one, so @@ -303,6 +322,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** | | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** | | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** | +| **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** | ### v0.2 in / out @@ -590,7 +610,7 @@ PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config) ``` cargo build -cargo test # 128 tests +cargo test # 135 tests cargo run -- init my-site # scaffold a new site cargo run -- build fixtures/minimal.org -o minimal.html # single file cargo run -- build fixtures/site -o _site # whole site (incremental) @@ -30,6 +30,7 @@ pub struct Config { pub templates: Templates, pub highlight: Highlight, pub html: HtmlOutput, + pub build: Build, /// Generated listing pages. Each produces one output file that has no source `.org` /// file behind it — a blog index, an archive, a feed. pub collections: Vec<Collection>, @@ -130,6 +131,17 @@ pub enum SortOrder { Asc, } +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Build { + /// Include pages marked `#+DRAFT:` in the build. + /// + /// Off by default, because the point of marking something a draft is that it is not + /// ready to be read. `--drafts` turns it on for a session, which is what you want + /// under `watch` while writing one. + pub drafts: bool, +} + #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(default, deny_unknown_fields)] pub struct HtmlOutput { @@ -400,6 +412,11 @@ expose_page_list = false # base16-eighties.dark, base16-mocha.dark, base16-ocean.light. theme = "InspiredGitHub" +[build] +# Include pages marked `#+DRAFT:`. Off by default — the point of marking a draft is that +# it is not ready to be read. `--drafts` turns it on for one run, handy under `watch`. +drafts = false + [html] # How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>. # The default of 1 matches Emacs, and assumes your layout renders the page title as the @@ -39,6 +39,9 @@ enum Command { /// Config file to use, overriding `org-ssg.toml` in the source directory. #[arg(long, value_name = "FILE")] config: Option<Utf8PathBuf>, + /// Include pages marked `#+DRAFT:`. + #[arg(long)] + drafts: bool, }, /// Watch a source directory and rebuild incrementally on change, driven by OS /// filesystem events. @@ -57,6 +60,9 @@ enum Command { /// Config file to use, overriding `org-ssg.toml` in the source directory. #[arg(long, value_name = "FILE")] config: Option<Utf8PathBuf>, + /// Include pages marked `#+DRAFT:`. Handy while writing one. + #[arg(long)] + drafts: bool, }, /// Remove the build output directory (which holds the cache manifest). Clean { @@ -87,6 +93,7 @@ fn main() -> Result<()> { no_cache, strict, config, + drafts, } => { if input.is_dir() { let out = output @@ -95,6 +102,7 @@ fn main() -> Result<()> { no_cache, strict, config_path: config.clone(), + drafts, }; let report = build_site(&input, &out, &opts)?; println!( @@ -121,6 +129,7 @@ fn main() -> Result<()> { no_cache, strict, config, + drafts, } => org_ssg::watch::run( &input, &output, @@ -128,6 +137,7 @@ fn main() -> Result<()> { no_cache, strict, config_path: config, + drafts, }, ), Command::Audit { input } => { @@ -266,6 +276,9 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { date: None, date_iso: None, tags: Vec::new(), + excerpt: String::new(), + word_count: 0, + reading_time: 0, keywords: Default::default(), }; let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, ""); @@ -32,7 +32,14 @@ use crate::template::{ GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext, Templater, }; -use crate::util::{iso_date, output_path, output_url, relative_root, slugify}; +use crate::util::{ + document_text, first_paragraph, is_draft, iso_date, output_path, output_url, relative_root, + slugify, +}; + +/// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure +/// for prose on screen. +const WORDS_PER_MINUTE: usize = 200; /// A fully built page: source and output paths (relative to their roots) and its /// final templated HTML. @@ -56,6 +63,8 @@ pub struct BuildOptions { pub strict: bool, /// Explicit config file, overriding `org-ssg.toml` in the source directory. pub config_path: Option<Utf8PathBuf>, + /// Include pages marked `#+DRAFT:`, overriding `build.drafts` when set. + pub drafts: bool, } /// Summary of a site build. @@ -468,6 +477,9 @@ fn listing_context(listing: &Listing) -> PageContext { date: None, date_iso: None, tags: Vec::new(), + excerpt: String::new(), + word_count: 0, + reading_time: 0, keywords: Default::default(), } } @@ -517,6 +529,15 @@ fn prepare_pages( }) .collect::<Result<Vec<_>>>()?; + // Drop drafts before anything else sees them. Removing them here rather than at emit + // time means they are absent from listings, the nav and the symbol table too — so a + // link *to* a draft is reported as broken, which is exactly what it would be on the + // published site. + let docs: Vec<Document> = docs + .into_iter() + .filter(|d| config.build.drafts || !is_draft(&d.keywords)) + .collect(); + // INDEX: collect every link target across the corpus. let mut symbols = SymbolTable::new(); for doc in &docs { @@ -692,11 +713,13 @@ pub const SYNTAX_STYLESHEET: &str = "syntax.css"; /// `render_key` changed or that link into a changed file's targets; reuses the on-disk /// output of everything else; persists an updated cache manifest. pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> { - let cfg = match &opts.config_path { + let mut cfg = match &opts.config_path { Some(path) => Config::load_file(path)?, None => Config::load(src)?, }; cfg.validate()?; + // The flag turns drafts on; it never turns off a config that asked for them. + cfg.build.drafts |= opts.drafts; // Create the output directory up front so it can be recognised and excluded when it // lives inside the source tree. @@ -1141,6 +1164,7 @@ fn is_top_level(output: &Utf8Path) -> bool { /// under its lowercased name, so a template can use metadata this crate has never heard /// of without the crate needing a release to support it. fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { + let words = document_text(&doc.root).split_whitespace().count(); let keyword = |name: &str| { doc.keywords .entries @@ -1154,6 +1178,12 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext { source: doc.source_path.to_string(), date_iso: keyword("DATE").as_deref().and_then(iso_date), date: keyword("DATE"), + excerpt: keyword("DESCRIPTION") + .filter(|d| !d.trim().is_empty()) + .or_else(|| first_paragraph(&doc.root)) + .unwrap_or_default(), + word_count: words, + reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)), tags: keyword("FILETAGS") .unwrap_or_default() .split(':') @@ -52,6 +52,14 @@ pub struct PageContext { pub date_iso: Option<String>, /// `#+FILETAGS:` split on `:`. pub tags: Vec<String>, + /// A short summary for listings: `#+DESCRIPTION:` when the page sets one, otherwise + /// its first paragraph. Empty only when the page has neither. + pub excerpt: String, + /// Words of prose, excluding code and example blocks. + pub word_count: usize, + /// Minutes to read at 200 words per minute, rounded up; at least 1 for a page with + /// any prose at all. + pub reading_time: usize, /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use /// project-specific metadata this crate has never heard of. pub keywords: BTreeMap<String, String>, @@ -375,6 +383,10 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html> <li> {%- if post.date_iso %}<time datetime="{{ post.date_iso }}">{{ post.date_iso }}</time> {% endif %} <a href="{{ root }}{{ post.url }}">{{ post.title }}</a> +{%- if post.excerpt %} +<p class="excerpt">{{ post.excerpt | truncate(180) }}</p> +{%- endif %} +<span class="reading-time">{{ post.reading_time }} min read</span> </li> {%- endfor %} </ul> @@ -427,6 +439,25 @@ fn add_filters(env: &mut Environment<'static>, base_url: &str) { }, ); + // `truncate`: shorten to at most N characters, on a word boundary, with an ellipsis. + // + // minijinja ships no truncate, and an excerpt is usually a whole first paragraph — + // so without this the only options in a listing are the full paragraph or nothing. + env.add_filter( + "truncate", + |text: &str, limit: Option<usize>| -> String { + let limit = limit.unwrap_or(160); + if text.chars().count() <= limit { + return text.to_string(); + } + let head: String = text.chars().take(limit).collect(); + // Cut at the last space so a word is never sliced in half; if there is no + // space at all, the hard cut is the only option. + let cut = head.rfind(char::is_whitespace).unwrap_or(head.len()); + format!("{}…", head[..cut].trim_end()) + }, + ); + // `rfc822`: an org or ISO date → the format RSS `pubDate` requires. env.add_filter("rfc822", |raw: &str| -> Result<String, minijinja::Error> { let iso = crate::util::iso_date(raw).ok_or_else(|| { @@ -4,7 +4,7 @@ use camino::{Utf8Path, Utf8PathBuf}; -use crate::model::{Keywords, Object}; +use crate::model::{Element, Keywords, Object, Section, TableRow}; /// The output path for a document, relative to the site root. /// @@ -76,6 +76,105 @@ fn plain_text_into(objs: &[Object], out: &mut String) { } } +/// Is this document marked as a draft? +/// +/// `#+DRAFT:` counts as true by its mere presence — writing the keyword at all is the +/// signal — unless the value explicitly says otherwise. Someone who types `#+DRAFT: t`, +/// `#+DRAFT: yes` or a bare `#+DRAFT:` means the same thing, and publishing an unfinished +/// post because the value was not the expected spelling is the wrong way to be strict. +pub fn is_draft(keywords: &Keywords) -> bool { + keywords + .entries + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case("DRAFT")) + .map(|(_, v)| { + !matches!( + v.trim().to_ascii_lowercase().as_str(), + "nil" | "false" | "no" | "0" | "off" + ) + }) + .unwrap_or(false) +} + +/// The document's prose as plain text, for word counts and excerpts. +/// +/// Source and example blocks are excluded on purpose: a reading-time estimate over a +/// post that is mostly a shell transcript should describe the prose someone reads, not +/// the code they skim. Headings are included — they are read. +pub fn document_text(root: &Section) -> String { + let mut out = String::new(); + section_text(root, &mut out); + out +} + +fn section_text(section: &Section, out: &mut String) { + if let Some(heading) = §ion.heading { + push_words(&plain_text(&heading.title), out); + } + elements_text(§ion.content, out); + for child in §ion.children { + section_text(child, out); + } +} + +fn elements_text(elements: &[Element], out: &mut String) { + for element in elements { + match element { + Element::Paragraph(objs) => push_words(&plain_text(objs), out), + Element::List(list) => { + for item in &list.items { + if let Some(term) = &item.term { + push_words(&plain_text(term), out); + } + elements_text(&item.content, out); + } + } + Element::Table(table) => { + for row in &table.rows { + if let TableRow::Cells(cells) = row { + for cell in cells { + push_words(&plain_text(cell), out); + } + } + } + } + Element::QuoteBlock(inner) | Element::CenterBlock(inner) => elements_text(inner, out), + Element::Figure { caption, .. } => push_words(&plain_text(caption), out), + Element::FootnoteDefinition { content, .. } => elements_text(content, out), + // Code, drawers, comments, keywords and raw export blocks are not prose. + _ => {} + } + } +} + +fn push_words(text: &str, out: &mut String) { + let text = text.trim(); + if text.is_empty() { + return; + } + if !out.is_empty() { + out.push(' '); + } + out.push_str(text); +} + +/// The document's first paragraph as plain text — the fallback excerpt for a page with +/// no `#+DESCRIPTION:`. +pub fn first_paragraph(root: &Section) -> Option<String> { + fn find(section: &Section) -> Option<String> { + for element in §ion.content { + if let Element::Paragraph(objs) = element { + let text = plain_text(objs); + if !text.trim().is_empty() { + return Some(text.trim().to_string()); + } + } + } + section.children.iter().find_map(find) + } + find(root) +} + /// Turn heading text into a URL-safe anchor slug. pub fn slugify(text: &str) -> String { let mut out = String::new(); @@ -1469,3 +1469,224 @@ fn changing_base_url_re_renders_the_site() { assert_eq!(report.rendered.len(), 3, "every page carries the base URL"); assert!(page(&out, "index.html").contains("https://moved.example/index.html")); } + +// --------------------------------------------------------------------------- +// Excerpts, reading metadata, and drafts +// --------------------------------------------------------------------------- + +/// Posts with and without a `#+DESCRIPTION:`, and a template that prints the metadata. +fn write_excerpt_site(src: &Utf8PathBuf, extra_config: &str) { + std::fs::create_dir_all(src.join("blog")).unwrap(); + std::fs::create_dir_all(src.join("templates")).unwrap(); + std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap(); + std::fs::write( + src.join("blog/described.org"), + "#+TITLE: Described\n#+DATE: 2024-02-02\n#+DESCRIPTION: A hand-written summary.\n\n\ + The body's first paragraph, which is not the excerpt here.\n", + ) + .unwrap(); + std::fs::write( + src.join("blog/plain.org"), + "#+TITLE: Plain\n#+DATE: 2024-01-01\n\nThe opening paragraph stands in for a summary.\n\n\ + A second paragraph that should not appear in the excerpt.\n", + ) + .unwrap(); + std::fs::write( + src.join("templates/list.html"), + "<html><body>{% for p in pages %}<li>{{ p.title }}|{{ p.excerpt }}|\ + {{ p.word_count }}|{{ p.reading_time }}</li>{% endfor %}</body></html>", + ) + .unwrap(); + std::fs::write( + src.join("org-ssg.toml"), + format!( + "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\ + template = \"list.html\"\ntitle = \"Blog\"\n{extra_config}" + ), + ) + .unwrap(); +} + +/// A listing of bare titles is thin. 176 of the 179 corpus files set a +/// `#+DESCRIPTION:`, so that is the excerpt when it exists — and the first paragraph +/// when it does not, so a page that never thought about summaries still has one. +#[test] +fn excerpts_prefer_the_description_and_fall_back_to_the_first_paragraph() { + let root = tmpdir("excerpt"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_excerpt_site(&src, ""); + let out = root.join("out"); + build(&src, &out); + + let listing = page(&out, "blog/index.html"); + assert!( + listing.contains("Described|A hand-written summary.|"), + "an explicit description wins:\n{listing}" + ); + assert!( + listing.contains("Plain|The opening paragraph stands in for a summary.|"), + "otherwise the first paragraph:\n{listing}" + ); + assert!( + !listing.contains("A second paragraph"), + "only the *first* paragraph:\n{listing}" + ); +} + +/// Reading time should describe the prose someone reads, not the code they skim. +#[test] +fn word_count_and_reading_time_ignore_code_blocks() { + let root = tmpdir("wordcount"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_excerpt_site(&src, ""); + let prose = "word ".repeat(400); + std::fs::write( + src.join("blog/plain.org"), + format!( + "#+TITLE: Plain\n#+DATE: 2024-01-01\n\n{prose}\n\n\ + #+BEGIN_SRC rust\n{}\n#+END_SRC\n", + "let noise = 1; ".repeat(200) + ), + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let listing = page(&out, "blog/index.html"); + // Exactly the 400 prose words: the 600+ words of code are not prose, and neither is + // `#+TITLE:`, which is metadata the layout renders as chrome rather than body text. + assert!( + listing.contains("|400|2</li>"), + "code must not inflate the count or the estimate:\n{listing}" + ); +} + +/// An excerpt is usually a whole paragraph, and minijinja ships no `truncate`, so +/// without one a listing's only options are the full paragraph or nothing. +#[test] +fn the_truncate_filter_cuts_on_a_word_boundary() { + let root = tmpdir("truncate"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_excerpt_site(&src, ""); + std::fs::write( + src.join("templates/list.html"), + "<html><body>{% for p in pages %}<li>{{ p.excerpt | truncate(20) }}</li>\ + {% endfor %}<x>{{ \"short\" | truncate(20) }}</x></body></html>", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let listing = page(&out, "blog/index.html"); + assert!( + listing.contains("<li>A hand-written…</li>"), + "cut at a space, not mid-word:\n{listing}" + ); + assert!( + listing.contains("<x>short</x>"), + "text under the limit is untouched:\n{listing}" + ); +} + +/// The point of marking something a draft is that it is not ready to be read. +#[test] +fn drafts_are_excluded_from_the_build_by_default() { + let root = tmpdir("draft"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_excerpt_site(&src, ""); + std::fs::write( + src.join("blog/wip.org"), + "#+TITLE: Unfinished\n#+DRAFT: t\n#+DATE: 2024-03-03\n\nNot ready.\n", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + assert!(!out.join("blog/wip.html").exists(), "no page is written"); + assert!( + !page(&out, "blog/index.html").contains("Unfinished"), + "and it is absent from listings, not merely unlinked" + ); +} + +/// `--drafts` is for previewing one while writing it, typically under `watch`. +#[test] +fn the_drafts_flag_includes_them() { + let root = tmpdir("draftflag"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_excerpt_site(&src, ""); + std::fs::write( + src.join("blog/wip.org"), + "#+TITLE: Unfinished\n#+DRAFT: t\n#+DATE: 2024-03-03\n\nNot ready.\n", + ) + .unwrap(); + let out = root.join("out"); + build_site( + &src, + &out, + &BuildOptions { + drafts: true, + ..Default::default() + }, + ) + .expect("build"); + + assert!(out.join("blog/wip.html").exists()); + assert!(page(&out, "blog/index.html").contains("Unfinished")); +} + +/// A draft is absent from the symbol table too, so a link to one is reported as the dead +/// link it would be on the published site — rather than silently pointing at nothing. +#[test] +fn a_link_to_a_draft_is_reported_as_broken() { + let root = tmpdir("draftlink"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_excerpt_site(&src, ""); + std::fs::write( + src.join("blog/wip.org"), + "#+TITLE: Unfinished\n#+DRAFT: t\n\nNot ready.\n", + ) + .unwrap(); + std::fs::write( + src.join("index.org"), + "#+TITLE: Home\n\nSee [[file:blog/wip.org][the draft]].\n", + ) + .unwrap(); + let out = root.join("out"); + let report = build(&src, &out); + + assert!( + report.warnings().iter().any(|w| w.contains("wip.org")), + "linking to a draft must be reported: {:?}", + report.warnings() + ); +} + +/// Writing the keyword at all is the signal. Publishing an unfinished post because the +/// value was not the expected spelling is the wrong way to be strict — but an explicit +/// "no" has to mean no. +#[test] +fn draft_truthiness_is_forgiving_but_respects_an_explicit_negative() { + use org_ssg::model::Keywords; + let draft = |value: &str| { + org_ssg::util::is_draft(&Keywords { + entries: vec![("DRAFT".to_string(), value.to_string())], + }) + }; + for yes in ["t", "true", "yes", "1", "", " ", "anything"] { + assert!(draft(yes), "{yes:?} should mean draft"); + } + for no in ["nil", "false", "no", "0", "off", "NIL"] { + assert!(!draft(no), "{no:?} should mean published"); + } + assert!( + !org_ssg::util::is_draft(&Keywords::default()), + "no keyword at all means published" + ); +}