krz/orgo
Lightning fast org-mode static site generator.
clone: git clone https://gitbay.org/krz/orgo.git
768e668f7aa715bf6d20f284cd6b9591ea029249
verified · cmc
author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T21:07:02Z
CHANGELOG.md | 14 ++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- README.md | 2 +- docs/guide/03-collections.org | 26 +++++++++++ docs/guide/04-templates.org | 8 +++- src/config.rs | 8 ++++ src/main.rs | 1 + src/site.rs | 103 +++++++++++++++++++++++++++++++++++++----- src/template.rs | 4 ++ src/util.rs | 31 +++++++++---- tests/config.rs | 103 +++++++++++++++++++++++++++++++++++++++--- 12 files changed, 272 insertions(+), 32 deletions(-) @@ -15,6 +15,20 @@ Two conventions worth knowing before reading: Versions follow the compatibility promise in the README: config keys, template variables, CLI flags and URLs are the stable surface. +## 0.19.0 + +- **Full-content collections.** `include_content = true` gives a listing template each + entry's rendered HTML as `entry.content` — a feed that carries whole posts rather than + excerpts. Rendered only when the listing is actually rebuilt, so a cached feed costs + nothing. +- **Fixed: a listing could show a stale excerpt.** Its cache key covered a hand-picked set + of fields, and the excerpt was not among them, so rewriting a post's first paragraph + left the old text on the index until something unrelated invalidated it. Entries are now + hashed through their serialization, which cannot drift from what a template can read. + Editing a post's body now rebuilds the listings that show it. +- `page.toc` entries carry `number`, so a site with section numbering on can number its + contents list to match its headings. + ## 0.18.0 Release engineering, so that a version number is worth reading. @@ -675,7 +675,7 @@ dependencies = [ [[package]] name = "org-ssg" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "blake3", @@ -1,6 +1,6 @@ [package] name = "org-ssg" -version = "0.18.0" +version = "0.19.0" edition = "2021" description = "Org-mode static site generator that renders the org element tree straight to HTML" license = "MIT" @@ -87,7 +87,7 @@ receive: | Variable | What it is | |---|---| | `body` | the rendered page HTML — use `{{ body \| safe }}` | -| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.year`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` | +| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.year`, `.tags`, `.content`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.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 | @@ -78,6 +78,32 @@ Name that group in the template rather than with =groupby='s =default== argument covers an attribute that is *missing* and not one that is null — an undated page has a =year=, and it is =none=. +** Full-content feeds + +A feed usually carries whole posts, and a subscriber handed excerpts instead has lost +something. =include_content= gives the template each entry's rendered HTML as +=entry.content=: + +#+BEGIN_SRC toml +[[collections]] +source = "blog" +output = "feed.xml" +template = "feed.xml" +include_content = true +#+END_SRC + +#+BEGIN_SRC html +<description><![CDATA[{{ post.content | safe }}]]></description> +#+END_SRC + +Off by default, because it costs a render of every listed page each time the listing is +rebuilt. That cost is only paid when the listing is *not* cached, and the listing's cache +key covers its entries' content — so a body edit reaches the feed, and an unchanged site +pays nothing. + +Everywhere else =entry.content= is =none=, since carrying every page's body in every +listing context would be most of a site's memory for nothing. + ** nav = true The listing page joins the site navigation. This is how a section landing page — =/blog/=, @@ -142,12 +142,12 @@ available on every page when =[templates] expose_page_list = true=. The page's headings as a *tree* — a table of contents is one, and rebuilding a tree from a flat list of levels inside a template is what Jinja is worst at. -Each entry has =title=, =anchor=, =level= and =children=: +Each entry has =title=, =anchor=, =level=, =number= and =children=: #+BEGIN_SRC html {% macro toc_list(entries) %} <ul>{% for e in entries %} - <li><a href="#{{ e.anchor }}">{{ e.title }}</a> + <li><a href="#{{ e.anchor }}">{{ e.number }} {{ e.title }}</a> {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li> {% endfor %}</ul> {% endmacro %} @@ -155,6 +155,10 @@ Each entry has =title=, =anchor=, =level= and =children=: {% if page.toc | length > 1 %}{{ toc_list(page.toc) }}{% endif %} #+END_SRC +=number= is the section number — =1.=, =3.1.= — always computed and printed only if you +ask for it. Print it when =[html] section_numbers= is on, or the contents will number what +the headings do not. + Anchors come from the same function that emits heading =id= attributes, so a TOC link cannot drift from the heading it points at. The tree is empty when the page has no headings, when =[html] toc = false=, or when the document says =#+OPTIONS: toc:nil=. @@ -141,6 +141,13 @@ pub struct Collection { /// `{tag}` as well when the collection is grouped — otherwise page 2 of one group /// would overwrite page 2 of another. pub paginate_output: Utf8PathBuf, + /// Give the template each entry's rendered HTML as `entry.content`. + /// + /// Off by default, and only worth turning on for a feed: it renders every listed + /// page's body whenever the listing is rebuilt. A reader subscribed to a + /// full-content feed and then handed excerpts has lost something, which is the one + /// case where that cost is the right trade. + pub include_content: bool, /// Add this listing page to the site navigation. This is how a section landing page /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages. pub nav: bool, @@ -161,6 +168,7 @@ impl Default for Collection { order: SortOrder::default(), paginate: 0, paginate_output: Utf8PathBuf::new(), + include_content: false, nav: false, } } @@ -317,6 +317,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { year: None, tags: Vec::new(), excerpt: String::new(), + content: None, word_count: 0, reading_time: 0, keywords: Default::default(), @@ -135,6 +135,13 @@ struct Listing { groups: Vec<GroupContext>, /// Set when this is one page of a paginated listing. paginator: Option<Paginator>, + /// Render each entry's body into `entry.content` (see + /// [`Collection::include_content`](crate::config::Collection::include_content)). + include_content: bool, + /// Content hash of each entry's source, in `entries` order. Not shown to templates — + /// it is how a content-carrying listing notices that a body it embeds has changed, + /// without rendering every body to find out. + entry_hashes: Vec<ContentHash>, } /// Split one listing's entries across numbered pages, appending each as its own @@ -144,12 +151,14 @@ struct Listing { /// changes — only pages 2..N are named by `paginate_output`. An empty listing still /// emits page 1, because a section that exists but has nothing in it should be a page /// saying so rather than a 404. +#[allow(clippy::too_many_arguments)] fn push_paginated( listings: &mut Vec<Listing>, collection: &config::Collection, output: Utf8PathBuf, title: String, entries: Vec<PageContext>, + entry_hashes: Vec<ContentHash>, group: Option<GroupContext>, groups: Vec<GroupContext>, ) { @@ -163,6 +172,8 @@ fn push_paginated( group, groups, paginator: None, + include_content: collection.include_content, + entry_hashes: entry_hashes.clone(), }); return; } @@ -195,6 +206,8 @@ fn push_paginated( listings.push(Listing { output: here.clone(), template: collection.template.clone(), + include_content: collection.include_content, + entry_hashes: entry_hashes.clone(), title: title.clone(), entries: chunk.to_vec(), group: group.clone(), @@ -223,6 +236,16 @@ fn push_paginated( /// Build the listing pages a config asks for, each with its entries sorted. fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { let mut listings = Vec::new(); + let hashes: HashMap<&str, ContentHash> = preps + .iter() + .map(|p| (p.source.as_str(), p.content_hash)) + .collect(); + let hashes_of = |entries: &[PageContext]| -> Vec<ContentHash> { + entries + .iter() + .filter_map(|e| hashes.get(e.source.as_str()).copied()) + .collect() + }; for collection in &config.collections { let mut entries: Vec<PageContext> = preps .iter() @@ -265,12 +288,14 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { } if collection.group_by.is_empty() { + let entry_hashes = hashes_of(&entries); push_paginated( &mut listings, collection, collection.output.clone(), collection.title.clone(), entries, + entry_hashes, None, Vec::new(), ); @@ -330,6 +355,8 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { if !collection.output.as_str().is_empty() { for group in &groups { + let members_of = members.get(&group.name).cloned().unwrap_or_default(); + let entry_hashes = hashes_of(&members_of); push_paginated( &mut listings, collection, @@ -337,7 +364,8 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { collection .title .replace(config::GROUP_PLACEHOLDER, &group.name), - members.get(&group.name).cloned().unwrap_or_default(), + members_of, + entry_hashes, Some(group.clone()), // Deliberately not the whole group list. A page that can see every // group depends on every group, so one new post would re-render every @@ -357,6 +385,9 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { group: None, groups: groups.clone(), paginator: None, + // A group index lists groups, not pages; there are no bodies to carry. + include_content: false, + entry_hashes: Vec::new(), }); } } @@ -409,18 +440,20 @@ fn group_terms(page: &PageContext, group_by: &str) -> Vec<String> { /// Everything a listing template can see about its entries, hashed. This is the listing /// page's whole dependency: if none of these change, its output cannot have changed. +/// +/// Entries are hashed through their *serialization* rather than a hand-picked set of +/// fields. Picking fields means the hash drifts from what a template can read the moment +/// one is added — which it had: the excerpt was missing, so rewriting a post's first +/// paragraph left the old excerpt on the index until something else invalidated it. fn listing_entries_hash(listing: &Listing) -> Hash { - let fields: Vec<(String, String)> = listing + let mut fields: Vec<(String, String)> = listing .entries .iter() - .flat_map(|e| { - [ - (e.url.clone(), e.title.clone()), - ( - e.date.clone().unwrap_or_default(), - e.tags.join(",") + "\u{0}" + &e.keywords.len().to_string(), - ), - ] + .map(|e| { + ( + e.url.clone(), + serde_json::to_string(e).unwrap_or_else(|_| e.title.clone()), + ) }) // A group index has no entries at all — its content *is* the group list, so the // groups have to be in the hash or a tag index would never notice a new tag. @@ -438,11 +471,52 @@ fn listing_entries_hash(listing: &Listing) -> Hash { })) .chain([(listing.title.clone(), listing.template.clone())]) .collect(); + + // A listing that embeds its entries' bodies depends on those bodies. The source hash + // stands in for the rendered HTML, so noticing a change does not cost a render of + // every page listed. + if listing.include_content { + fields.extend( + listing + .entry_hashes + .iter() + .map(|h| ("content".to_string(), format!("{h:?}"))), + ); + } + // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence // rather than a set: a re-ordering is a real change to the page. site_structure_hash_ordered(&fields) } +/// A listing's entries with their rendered bodies attached, for a template that asked +/// for them — a full-content feed being the case that needs it. +/// +/// An entry whose source is not among the prepared pages keeps `content: none` rather +/// than failing: the listing is still a valid page, and a feed item without a body is a +/// better outcome than no feed. +fn entries_with_content( + entries: &[PageContext], + preps: &[PagePrep], + highlighter: &SyntectHighlighter, + config: &Config, +) -> Vec<PageContext> { + let by_source: HashMap<&str, &PagePrep> = + preps.iter().map(|p| (p.source.as_str(), p)).collect(); + let opts = render_options(config); + entries + .par_iter() + .map(|entry| { + let mut entry = entry.clone(); + if let Some(prep) = by_source.get(entry.source.as_str()) { + let Html(html) = render_with(&prep.resolved, highlighter, &opts); + entry.content = Some(html); + } + entry + }) + .collect() +} + /// The nav a listing page shows: whatever the site's nav is, relativized to this /// listing's own location. fn listing_nav(preps: &[PagePrep], output: &Utf8Path) -> Vec<NavItem> { @@ -494,6 +568,7 @@ fn listing_context(listing: &Listing) -> PageContext { year: None, tags: Vec::new(), excerpt: String::new(), + content: None, word_count: 0, reading_time: 0, keywords: Default::default(), @@ -1008,8 +1083,13 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); let nav = listing_nav(&preps, &listing.output); let page_ctx = listing_context(listing); + // Bodies are rendered here rather than when the listing was built, so a + // cached feed costs nothing. This is the only place a page is rendered twice. + let with_content = listing + .include_content + .then(|| entries_with_content(&listing.entries, &preps, &highlighter, &cfg)); let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root); - ctx.pages = Some(&listing.entries); + ctx.pages = Some(with_content.as_deref().unwrap_or(&listing.entries)); ctx.group = listing.group.as_ref(); ctx.groups = &listing.groups; ctx.paginator = listing.paginator.as_ref(); @@ -1377,6 +1457,7 @@ fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageConte .filter(|d| !d.trim().is_empty()) .or_else(|| first_paragraph(&doc.root)) .unwrap_or_default(), + content: None, word_count: words, reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)), toc: if option_enabled(&doc.keywords, "toc", config.html.toc) { @@ -66,6 +66,10 @@ pub struct PageContext { /// 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>, + /// The page's rendered HTML, when a collection asked for it with + /// `include_content`. `none` everywhere else, because carrying every page's body in + /// every listing context would be most of a site's memory for nothing. + pub content: Option<String>, /// The page's headings as a tree. Empty when the page has none, when the site turns /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`. pub toc: Vec<crate::util::TocEntry>, @@ -98,6 +98,11 @@ pub struct TocEntry { pub anchor: String, /// Org heading level, 1-based, before any `heading_offset` is applied. pub level: u8, + /// This entry's section number — `1.`, `3.1.` — always computed, printed only by a + /// template that wants it. A site with `section_numbers` on and an unnumbered + /// contents list reads as a mistake, and the numbers cannot be derived in Jinja + /// without rebuilding the tree walk that produced them. + pub number: String, pub children: Vec<TocEntry>, } @@ -106,17 +111,25 @@ pub struct TocEntry { /// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from /// a flat list of levels inside a template is the kind of thing Jinja is bad at. pub fn table_of_contents(root: &Section) -> Vec<TocEntry> { - root.children.iter().map(toc_entry).collect() + numbered_entries(&root.children, "") } -fn toc_entry(section: &Section) -> TocEntry { - let heading = section.heading.as_ref(); - TocEntry { - title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(), - anchor: heading.map(heading_anchor).unwrap_or_default(), - level: heading.map(|h| h.level).unwrap_or(1), - children: section.children.iter().map(toc_entry).collect(), - } +fn numbered_entries(sections: &[Section], prefix: &str) -> Vec<TocEntry> { + sections + .iter() + .enumerate() + .map(|(i, section)| { + let number = format!("{prefix}{}.", i + 1); + let heading = section.heading.as_ref(); + TocEntry { + title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(), + anchor: heading.map(heading_anchor).unwrap_or_default(), + level: heading.map(|h| h.level).unwrap_or(1), + children: numbered_entries(§ion.children, &format!("{prefix}{}.", i + 1)), + number, + } + }) + .collect() } /// Parse `#+OPTIONS:` into its `key:value` switches. @@ -673,27 +673,48 @@ fn adding_a_post_rebuilds_only_the_listing_and_the_post() { ); } -/// Editing a post's body changes no listing metadata, so the index must not churn. -#[test] -fn editing_a_post_body_does_not_rebuild_the_listing() { +/// Editing a post's body reaches its listing, because a listing shows things derived +/// from the body: the excerpt is its first paragraph, and the reading time is its length. +/// +/// This test used to assert the opposite, and the site was wrong for it — rewriting a +/// post's opening paragraph left the old excerpt on the index until something unrelated +/// invalidated it. A listing depends on everything its template can read. +#[test] +fn editing_a_post_body_rebuilds_the_listing_that_shows_its_excerpt() { let root = tmpdir("listbody"); let src = root.join("src"); std::fs::create_dir_all(&src).unwrap(); write_blog(&src, ""); + std::fs::write( + src.join("templates/list.html"), + "<html><body>{% for p in pages %}<li>{{ p.excerpt }}</li>{% endfor %}</body></html>", + ) + .unwrap(); let out = root.join("out"); build(&src, &out); std::fs::write( src.join("blog/mid.org"), - "#+TITLE: Middle Post\n#+DATE: 2024-08-05\n\nEdited body.\n", + "#+TITLE: Middle Post\n#+DATE: 2024-08-05\n\nA completely different opening.\n", ) .unwrap(); let report = build(&src, &out); + assert!( + report.rendered.contains(&Utf8PathBuf::from("blog/index.html")), + "the listing rebuilt: {:?}", + report.rendered + ); + assert!( + page(&out, "blog/index.html").contains("A completely different opening."), + "and shows the new excerpt:\n{}", + page(&out, "blog/index.html") + ); assert_eq!( - report.rendered, - vec![Utf8PathBuf::from("blog/mid.html")], - "only the post itself; the listing shows unchanged metadata" + report.rendered.len(), + 2, + "the post and its listing, and nothing else: {:?}", + report.rendered ); } @@ -2289,3 +2310,71 @@ fn a_missing_asset_root_is_an_error() { .expect_err("a missing asset root must fail"); assert!(format!("{err:#}").contains("nope"), "names it: {err:#}"); } + +/// A feed that carries excerpts where it used to carry whole posts is a downgrade its +/// subscribers notice. `include_content` gives the template each entry's rendered HTML. +#[test] +fn a_collection_can_carry_its_entries_rendered_bodies() { + let root = tmpdir("feedcontent"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + std::fs::write( + src.join("blog/new.org"), + "#+TITLE: Newer Post\n#+DATE: [2025-06-30 Mon 09:15:00]\n\nBody with *emphasis*.\n", + ) + .unwrap(); + std::fs::write( + src.join("templates/feed.xml"), + "<rss>{% for p in pages %}<item><body>{{ p.content }}</body></item>{% endfor %}</rss>", + ) + .unwrap(); + std::fs::write( + src.join("org-ssg.toml"), + "[[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\ + template = \"feed.xml\"\ntitle = \"Feed\"\ninclude_content = true\n", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let feed = page(&out, "feed.xml"); + assert!( + feed.contains("<strong>emphasis</strong>"), + "the rendered body reaches the template, escaped as XML text:\n{feed}" + ); + + // And it stays current: a body edit must reach a feed that embeds bodies, even when + // no metadata moved. + std::fs::write( + src.join("blog/new.org"), + "#+TITLE: Newer Post\n#+DATE: [2025-06-30 Mon 09:15:00]\n\nBody with *emphasis*.\n\nA second paragraph.\n", + ) + .unwrap(); + build(&src, &out); + assert!( + page(&out, "feed.xml").contains("A second paragraph."), + "the feed followed the edit:\n{}", + page(&out, "feed.xml") + ); +} + +/// Bodies cost a render each, so a listing that does not ask for them must not pay — and +/// must not carry them into the template either. +#[test] +fn entries_carry_no_content_unless_asked() { + let root = tmpdir("nocontent"); + let src = root.join("src"); + std::fs::create_dir_all(&src).unwrap(); + write_blog(&src, ""); + std::fs::write( + src.join("templates/list.html"), + "<html><body>{% for p in pages %}<li>{{ p.content is none }}</li>{% endfor %}</body></html>", + ) + .unwrap(); + let out = root.join("out"); + build(&src, &out); + + let html = page(&out, "blog/index.html"); + assert!(!html.contains("false"), "no entry carries a body:\n{html}"); +}