krz/orgo

Lightning fast org-mode static site generator. fast go org-mode static-site-generator

Commit 768e668f7a

768e668f7aa715bf6d20f284cd6b9591ea029249

parent: bbe31403db

Verified · cmc

cmc <hello@cleberg.net> · 2026-08-11 21:07 UTC

0.19: full-content collections, and a listing that stops showing stale excerpts

`include_content = true` gives a listing template each entry's rendered HTML as
`entry.content`, which is what a feed needs: one that carries whole posts and
then quietly starts carrying excerpts is a downgrade its subscribers notice.

Bodies are rendered when the listing is rendered, not when it is built, so a
cached feed costs nothing — a no-op build of a 196-page site with a full-content
feed still takes 0.14s. The listing's cache key covers its entries' *source*
hashes, so a body edit reaches the feed without a render of every post to find
out whether it should.

Writing that turned up a defect underneath it. A listing's cache key was a
hand-picked set of entry fields, and the excerpt was not among them: rewriting a
post's opening paragraph left the old excerpt on the index until something
unrelated invalidated the page. Entries are now hashed through their
serialization, which cannot drift from what a template can read.

The test that would have caught it asserted the opposite — "editing a post's
body changes no listing metadata, so the index must not churn" — which was true
of the hash and false of the site. It now asserts that a body edit rebuilds the
listing showing its excerpt, and that it rebuilds nothing else.

`page.toc` entries carry `number` too, so a site with section numbering on can
number its contents list to match its headings, which is not something a
template can work out for itself.

Layout: unified · split

CHANGELOG.md +14
@@ -15,6 +15,20 @@ Two conventions worth knowing before reading:
15Versions follow the compatibility promise in the README: config keys, template variables, 15Versions follow the compatibility promise in the README: config keys, template variables,
16CLI flags and URLs are the stable surface. 16CLI flags and URLs are the stable surface.
17 17
18## 0.19.0
19
20- **Full-content collections.** `include_content = true` gives a listing template each
21 entry's rendered HTML as `entry.content` — a feed that carries whole posts rather than
22 excerpts. Rendered only when the listing is actually rebuilt, so a cached feed costs
23 nothing.
24- **Fixed: a listing could show a stale excerpt.** Its cache key covered a hand-picked set
25 of fields, and the excerpt was not among them, so rewriting a post's first paragraph
26 left the old text on the index until something unrelated invalidated it. Entries are now
27 hashed through their serialization, which cannot drift from what a template can read.
28 Editing a post's body now rebuilds the listings that show it.
29- `page.toc` entries carry `number`, so a site with section numbering on can number its
30 contents list to match its headings.
31
18## 0.18.0 32## 0.18.0
19 33
20Release engineering, so that a version number is worth reading. 34Release engineering, so that a version number is worth reading.
Cargo.lock +1 −1
@@ -675,7 +675,7 @@ dependencies = [
675 675
676[[package]] 676[[package]]
677name = "org-ssg" 677name = "org-ssg"
678version = "0.18.0" 678version = "0.19.0"
679dependencies = [ 679dependencies = [
680 "anyhow", 680 "anyhow",
681 "blake3", 681 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
1[package] 1[package]
2name = "org-ssg" 2name = "org-ssg"
3version = "0.18.0" 3version = "0.19.0"
4edition = "2021" 4edition = "2021"
5description = "Org-mode static site generator that renders the org element tree straight to HTML" 5description = "Org-mode static site generator that renders the org element tree straight to HTML"
6license = "MIT" 6license = "MIT"
README.md +1 −1
@@ -87,7 +87,7 @@ receive:
87| Variable | What it is | 87| Variable | What it is |
88|---|---| 88|---|---|
89| `body` | the rendered page HTML — use `{{ body \| safe }}` | 89| `body` | the rendered page HTML — use `{{ body \| safe }}` |
90| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.year`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` | 90| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.year`, `.tags`, `.content`, `.excerpt`, `.word_count`, `.reading_time`, `.toc`, `.keywords` |
91| `site` | `.title`, `.base_url`, `.description`, `.language` | 91| `site` | `.title`, `.base_url`, `.description`, `.language` |
92| `nav` | list of `{title, url}`, relative to this page | 92| `nav` | list of `{title, url}`, relative to this page |
93| `root` | `../`-prefix back to the site root from this page | 93| `root` | `../`-prefix back to the site root from this page |
docs/guide/03-collections.org +26
@@ -78,6 +78,32 @@ Name that group in the template rather than with =groupby='s =default== argument
78covers an attribute that is *missing* and not one that is null — an undated page has a 78covers an attribute that is *missing* and not one that is null — an undated page has a
79=year=, and it is =none=. 79=year=, and it is =none=.
80 80
81** Full-content feeds
82
83A feed usually carries whole posts, and a subscriber handed excerpts instead has lost
84something. =include_content= gives the template each entry's rendered HTML as
85=entry.content=:
86
87#+BEGIN_SRC toml
88[[collections]]
89source = "blog"
90output = "feed.xml"
91template = "feed.xml"
92include_content = true
93#+END_SRC
94
95#+BEGIN_SRC html
96<description><![CDATA[{{ post.content | safe }}]]></description>
97#+END_SRC
98
99Off by default, because it costs a render of every listed page each time the listing is
100rebuilt. That cost is only paid when the listing is *not* cached, and the listing's cache
101key covers its entries' content — so a body edit reaches the feed, and an unchanged site
102pays nothing.
103
104Everywhere else =entry.content= is =none=, since carrying every page's body in every
105listing context would be most of a site's memory for nothing.
106
81** nav = true 107** nav = true
82 108
83The listing page joins the site navigation. This is how a section landing page — =/blog/=, 109The listing page joins the site navigation. This is how a section landing page — =/blog/=,
docs/guide/04-templates.org +6 −2
@@ -142,12 +142,12 @@ available on every page when =[templates] expose_page_list = true=.
142The page's headings as a *tree* — a table of contents is one, and rebuilding a tree from 142The page's headings as a *tree* — a table of contents is one, and rebuilding a tree from
143a flat list of levels inside a template is what Jinja is worst at. 143a flat list of levels inside a template is what Jinja is worst at.
144 144
145Each entry has =title=, =anchor=, =level= and =children=: 145Each entry has =title=, =anchor=, =level=, =number= and =children=:
146 146
147#+BEGIN_SRC html 147#+BEGIN_SRC html
148{% macro toc_list(entries) %} 148{% macro toc_list(entries) %}
149<ul>{% for e in entries %} 149<ul>{% for e in entries %}
150 <li><a href="#{{ e.anchor }}">{{ e.title }}</a> 150 <li><a href="#{{ e.anchor }}">{{ e.number }} {{ e.title }}</a>
151 {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li> 151 {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li>
152{% endfor %}</ul> 152{% endfor %}</ul>
153{% endmacro %} 153{% endmacro %}
@@ -155,6 +155,10 @@ Each entry has =title=, =anchor=, =level= and =children=:
155{% if page.toc | length > 1 %}{{ toc_list(page.toc) }}{% endif %} 155{% if page.toc | length > 1 %}{{ toc_list(page.toc) }}{% endif %}
156#+END_SRC 156#+END_SRC
157 157
158=number= is the section number — =1.=, =3.1.= — always computed and printed only if you
159ask for it. Print it when =[html] section_numbers= is on, or the contents will number what
160the headings do not.
161
158Anchors come from the same function that emits heading =id= attributes, so a TOC link 162Anchors come from the same function that emits heading =id= attributes, so a TOC link
159cannot drift from the heading it points at. The tree is empty when the page has no 163cannot drift from the heading it points at. The tree is empty when the page has no
160headings, when =[html] toc = false=, or when the document says =#+OPTIONS: toc:nil=. 164headings, when =[html] toc = false=, or when the document says =#+OPTIONS: toc:nil=.
src/config.rs +8
@@ -141,6 +141,13 @@ pub struct Collection {
141 /// `{tag}` as well when the collection is grouped — otherwise page 2 of one group 141 /// `{tag}` as well when the collection is grouped — otherwise page 2 of one group
142 /// would overwrite page 2 of another. 142 /// would overwrite page 2 of another.
143 pub paginate_output: Utf8PathBuf, 143 pub paginate_output: Utf8PathBuf,
144 /// Give the template each entry's rendered HTML as `entry.content`.
145 ///
146 /// Off by default, and only worth turning on for a feed: it renders every listed
147 /// page's body whenever the listing is rebuilt. A reader subscribed to a
148 /// full-content feed and then handed excerpts has lost something, which is the one
149 /// case where that cost is the right trade.
150 pub include_content: bool,
144 /// Add this listing page to the site navigation. This is how a section landing page 151 /// Add this listing page to the site navigation. This is how a section landing page
145 /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages. 152 /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages.
146 pub nav: bool, 153 pub nav: bool,
@@ -161,6 +168,7 @@ impl Default for Collection {
161 order: SortOrder::default(), 168 order: SortOrder::default(),
162 paginate: 0, 169 paginate: 0,
163 paginate_output: Utf8PathBuf::new(), 170 paginate_output: Utf8PathBuf::new(),
171 include_content: false,
164 nav: false, 172 nav: false,
165 } 173 }
166 } 174 }
src/main.rs +1
@@ -317,6 +317,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
317 year: None, 317 year: None,
318 tags: Vec::new(), 318 tags: Vec::new(),
319 excerpt: String::new(), 319 excerpt: String::new(),
320 content: None,
320 word_count: 0, 321 word_count: 0,
321 reading_time: 0, 322 reading_time: 0,
322 keywords: Default::default(), 323 keywords: Default::default(),
src/site.rs +92 −11
@@ -135,6 +135,13 @@ struct Listing {
135 groups: Vec<GroupContext>, 135 groups: Vec<GroupContext>,
136 /// Set when this is one page of a paginated listing. 136 /// Set when this is one page of a paginated listing.
137 paginator: Option<Paginator>, 137 paginator: Option<Paginator>,
138 /// Render each entry's body into `entry.content` (see
139 /// [`Collection::include_content`](crate::config::Collection::include_content)).
140 include_content: bool,
141 /// Content hash of each entry's source, in `entries` order. Not shown to templates —
142 /// it is how a content-carrying listing notices that a body it embeds has changed,
143 /// without rendering every body to find out.
144 entry_hashes: Vec<ContentHash>,
138} 145}
139 146
140/// Split one listing's entries across numbered pages, appending each as its own 147/// Split one listing's entries across numbered pages, appending each as its own
@@ -144,12 +151,14 @@ struct Listing {
144/// changes — only pages 2..N are named by `paginate_output`. An empty listing still 151/// changes — only pages 2..N are named by `paginate_output`. An empty listing still
145/// emits page 1, because a section that exists but has nothing in it should be a page 152/// emits page 1, because a section that exists but has nothing in it should be a page
146/// saying so rather than a 404. 153/// saying so rather than a 404.
154#[allow(clippy::too_many_arguments)]
147fn push_paginated( 155fn push_paginated(
148 listings: &mut Vec<Listing>, 156 listings: &mut Vec<Listing>,
149 collection: &config::Collection, 157 collection: &config::Collection,
150 output: Utf8PathBuf, 158 output: Utf8PathBuf,
151 title: String, 159 title: String,
152 entries: Vec<PageContext>, 160 entries: Vec<PageContext>,
161 entry_hashes: Vec<ContentHash>,
153 group: Option<GroupContext>, 162 group: Option<GroupContext>,
154 groups: Vec<GroupContext>, 163 groups: Vec<GroupContext>,
155) { 164) {
@@ -163,6 +172,8 @@ fn push_paginated(
163 group, 172 group,
164 groups, 173 groups,
165 paginator: None, 174 paginator: None,
175 include_content: collection.include_content,
176 entry_hashes: entry_hashes.clone(),
166 }); 177 });
167 return; 178 return;
168 } 179 }
@@ -195,6 +206,8 @@ fn push_paginated(
195 listings.push(Listing { 206 listings.push(Listing {
196 output: here.clone(), 207 output: here.clone(),
197 template: collection.template.clone(), 208 template: collection.template.clone(),
209 include_content: collection.include_content,
210 entry_hashes: entry_hashes.clone(),
198 title: title.clone(), 211 title: title.clone(),
199 entries: chunk.to_vec(), 212 entries: chunk.to_vec(),
200 group: group.clone(), 213 group: group.clone(),
@@ -223,6 +236,16 @@ fn push_paginated(
223/// Build the listing pages a config asks for, each with its entries sorted. 236/// Build the listing pages a config asks for, each with its entries sorted.
224fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { 237fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
225 let mut listings = Vec::new(); 238 let mut listings = Vec::new();
239 let hashes: HashMap<&str, ContentHash> = preps
240 .iter()
241 .map(|p| (p.source.as_str(), p.content_hash))
242 .collect();
243 let hashes_of = |entries: &[PageContext]| -> Vec<ContentHash> {
244 entries
245 .iter()
246 .filter_map(|e| hashes.get(e.source.as_str()).copied())
247 .collect()
248 };
226 for collection in &config.collections { 249 for collection in &config.collections {
227 let mut entries: Vec<PageContext> = preps 250 let mut entries: Vec<PageContext> = preps
228 .iter() 251 .iter()
@@ -265,12 +288,14 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
265 } 288 }
266 289
267 if collection.group_by.is_empty() { 290 if collection.group_by.is_empty() {
291 let entry_hashes = hashes_of(&entries);
268 push_paginated( 292 push_paginated(
269 &mut listings, 293 &mut listings,
270 collection, 294 collection,
271 collection.output.clone(), 295 collection.output.clone(),
272 collection.title.clone(), 296 collection.title.clone(),
273 entries, 297 entries,
298 entry_hashes,
274 None, 299 None,
275 Vec::new(), 300 Vec::new(),
276 ); 301 );
@@ -330,6 +355,8 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
330 355
331 if !collection.output.as_str().is_empty() { 356 if !collection.output.as_str().is_empty() {
332 for group in &groups { 357 for group in &groups {
358 let members_of = members.get(&group.name).cloned().unwrap_or_default();
359 let entry_hashes = hashes_of(&members_of);
333 push_paginated( 360 push_paginated(
334 &mut listings, 361 &mut listings,
335 collection, 362 collection,
@@ -337,7 +364,8 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
337 collection 364 collection
338 .title 365 .title
339 .replace(config::GROUP_PLACEHOLDER, &group.name), 366 .replace(config::GROUP_PLACEHOLDER, &group.name),
340 members.get(&group.name).cloned().unwrap_or_default(), 367 members_of,
368 entry_hashes,
341 Some(group.clone()), 369 Some(group.clone()),
342 // Deliberately not the whole group list. A page that can see every 370 // Deliberately not the whole group list. A page that can see every
343 // group depends on every group, so one new post would re-render every 371 // 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>> {
357 group: None, 385 group: None,
358 groups: groups.clone(), 386 groups: groups.clone(),
359 paginator: None, 387 paginator: None,
388 // A group index lists groups, not pages; there are no bodies to carry.
389 include_content: false,
390 entry_hashes: Vec::new(),
360 }); 391 });
361 } 392 }
362 } 393 }
@@ -409,18 +440,20 @@ fn group_terms(page: &PageContext, group_by: &str) -> Vec<String> {
409 440
410/// Everything a listing template can see about its entries, hashed. This is the listing 441/// Everything a listing template can see about its entries, hashed. This is the listing
411/// page's whole dependency: if none of these change, its output cannot have changed. 442/// page's whole dependency: if none of these change, its output cannot have changed.
443///
444/// Entries are hashed through their *serialization* rather than a hand-picked set of
445/// fields. Picking fields means the hash drifts from what a template can read the moment
446/// one is added — which it had: the excerpt was missing, so rewriting a post's first
447/// paragraph left the old excerpt on the index until something else invalidated it.
412fn listing_entries_hash(listing: &Listing) -> Hash { 448fn listing_entries_hash(listing: &Listing) -> Hash {
413 let fields: Vec<(String, String)> = listing 449 let mut fields: Vec<(String, String)> = listing
414 .entries 450 .entries
415 .iter() 451 .iter()
416 .flat_map(|e| { 452 .map(|e| {
417 [ 453 (
418 (e.url.clone(), e.title.clone()), 454 e.url.clone(),
419 ( 455 serde_json::to_string(e).unwrap_or_else(|_| e.title.clone()),
420 e.date.clone().unwrap_or_default(), 456 )
421 e.tags.join(",") + "\u{0}" + &e.keywords.len().to_string(),
422 ),
423 ]
424 }) 457 })
425 // A group index has no entries at all — its content *is* the group list, so the 458 // A group index has no entries at all — its content *is* the group list, so the
426 // groups have to be in the hash or a tag index would never notice a new tag. 459 // 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 {
438 })) 471 }))
439 .chain([(listing.title.clone(), listing.template.clone())]) 472 .chain([(listing.title.clone(), listing.template.clone())])
440 .collect(); 473 .collect();
474
475 // A listing that embeds its entries' bodies depends on those bodies. The source hash
476 // stands in for the rendered HTML, so noticing a change does not cost a render of
477 // every page listed.
478 if listing.include_content {
479 fields.extend(
480 listing
481 .entry_hashes
482 .iter()
483 .map(|h| ("content".to_string(), format!("{h:?}"))),
484 );
485 }
486
441 // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence 487 // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence
442 // rather than a set: a re-ordering is a real change to the page. 488 // rather than a set: a re-ordering is a real change to the page.
443 site_structure_hash_ordered(&fields) 489 site_structure_hash_ordered(&fields)
444} 490}
445 491
492/// A listing's entries with their rendered bodies attached, for a template that asked
493/// for them — a full-content feed being the case that needs it.
494///
495/// An entry whose source is not among the prepared pages keeps `content: none` rather
496/// than failing: the listing is still a valid page, and a feed item without a body is a
497/// better outcome than no feed.
498fn entries_with_content(
499 entries: &[PageContext],
500 preps: &[PagePrep],
501 highlighter: &SyntectHighlighter,
502 config: &Config,
503) -> Vec<PageContext> {
504 let by_source: HashMap<&str, &PagePrep> =
505 preps.iter().map(|p| (p.source.as_str(), p)).collect();
506 let opts = render_options(config);
507 entries
508 .par_iter()
509 .map(|entry| {
510 let mut entry = entry.clone();
511 if let Some(prep) = by_source.get(entry.source.as_str()) {
512 let Html(html) = render_with(&prep.resolved, highlighter, &opts);
513 entry.content = Some(html);
514 }
515 entry
516 })
517 .collect()
518}
519
446/// The nav a listing page shows: whatever the site's nav is, relativized to this 520/// The nav a listing page shows: whatever the site's nav is, relativized to this
447/// listing's own location. 521/// listing's own location.
448fn listing_nav(preps: &[PagePrep], output: &Utf8Path) -> Vec<NavItem> { 522fn listing_nav(preps: &[PagePrep], output: &Utf8Path) -> Vec<NavItem> {
@@ -494,6 +568,7 @@ fn listing_context(listing: &Listing) -> PageContext {
494 year: None, 568 year: None,
495 tags: Vec::new(), 569 tags: Vec::new(),
496 excerpt: String::new(), 570 excerpt: String::new(),
571 content: None,
497 word_count: 0, 572 word_count: 0,
498 reading_time: 0, 573 reading_time: 0,
499 keywords: Default::default(), 574 keywords: Default::default(),
@@ -1008,8 +1083,13 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
1008 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); 1083 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
1009 let nav = listing_nav(&preps, &listing.output); 1084 let nav = listing_nav(&preps, &listing.output);
1010 let page_ctx = listing_context(listing); 1085 let page_ctx = listing_context(listing);
1086 // Bodies are rendered here rather than when the listing was built, so a
1087 // cached feed costs nothing. This is the only place a page is rendered twice.
1088 let with_content = listing
1089 .include_content
1090 .then(|| entries_with_content(&listing.entries, &preps, &highlighter, &cfg));
1011 let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root); 1091 let mut ctx = RenderContext::new(&site, &page_ctx, &nav, &stylesheet, &root);
1012 ctx.pages = Some(&listing.entries); 1092 ctx.pages = Some(with_content.as_deref().unwrap_or(&listing.entries));
1013 ctx.group = listing.group.as_ref(); 1093 ctx.group = listing.group.as_ref();
1014 ctx.groups = &listing.groups; 1094 ctx.groups = &listing.groups;
1015 ctx.paginator = listing.paginator.as_ref(); 1095 ctx.paginator = listing.paginator.as_ref();
@@ -1377,6 +1457,7 @@ fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageConte
1377 .filter(|d| !d.trim().is_empty()) 1457 .filter(|d| !d.trim().is_empty())
1378 .or_else(|| first_paragraph(&doc.root)) 1458 .or_else(|| first_paragraph(&doc.root))
1379 .unwrap_or_default(), 1459 .unwrap_or_default(),
1460 content: None,
1380 word_count: words, 1461 word_count: words,
1381 reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)), 1462 reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)),
1382 toc: if option_enabled(&doc.keywords, "toc", config.html.toc) { 1463 toc: if option_enabled(&doc.keywords, "toc", config.html.toc) {
src/template.rs +4
@@ -66,6 +66,10 @@ pub struct PageContext {
66 /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use 66 /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use
67 /// project-specific metadata this crate has never heard of. 67 /// project-specific metadata this crate has never heard of.
68 pub keywords: BTreeMap<String, String>, 68 pub keywords: BTreeMap<String, String>,
69 /// The page's rendered HTML, when a collection asked for it with
70 /// `include_content`. `none` everywhere else, because carrying every page's body in
71 /// every listing context would be most of a site's memory for nothing.
72 pub content: Option<String>,
69 /// The page's headings as a tree. Empty when the page has none, when the site turns 73 /// The page's headings as a tree. Empty when the page has none, when the site turns
70 /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`. 74 /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`.
71 pub toc: Vec<crate::util::TocEntry>, 75 pub toc: Vec<crate::util::TocEntry>,
src/util.rs +22 −9
@@ -98,6 +98,11 @@ pub struct TocEntry {
98 pub anchor: String, 98 pub anchor: String,
99 /// Org heading level, 1-based, before any `heading_offset` is applied. 99 /// Org heading level, 1-based, before any `heading_offset` is applied.
100 pub level: u8, 100 pub level: u8,
101 /// This entry's section number — `1.`, `3.1.` — always computed, printed only by a
102 /// template that wants it. A site with `section_numbers` on and an unnumbered
103 /// contents list reads as a mistake, and the numbers cannot be derived in Jinja
104 /// without rebuilding the tree walk that produced them.
105 pub number: String,
101 pub children: Vec<TocEntry>, 106 pub children: Vec<TocEntry>,
102} 107}
103 108
@@ -106,17 +111,25 @@ pub struct TocEntry {
106/// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from 111/// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from
107/// a flat list of levels inside a template is the kind of thing Jinja is bad at. 112/// a flat list of levels inside a template is the kind of thing Jinja is bad at.
108pub fn table_of_contents(root: &Section) -> Vec<TocEntry> { 113pub fn table_of_contents(root: &Section) -> Vec<TocEntry> {
109 root.children.iter().map(toc_entry).collect() 114 numbered_entries(&root.children, "")
110} 115}
111 116
112fn toc_entry(section: &Section) -> TocEntry { 117fn numbered_entries(sections: &[Section], prefix: &str) -> Vec<TocEntry> {
113 let heading = section.heading.as_ref(); 118 sections
114 TocEntry { 119 .iter()
115 title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(), 120 .enumerate()
116 anchor: heading.map(heading_anchor).unwrap_or_default(), 121 .map(|(i, section)| {
117 level: heading.map(|h| h.level).unwrap_or(1), 122 let number = format!("{prefix}{}.", i + 1);
118 children: section.children.iter().map(toc_entry).collect(), 123 let heading = section.heading.as_ref();
119 } 124 TocEntry {
125 title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(),
126 anchor: heading.map(heading_anchor).unwrap_or_default(),
127 level: heading.map(|h| h.level).unwrap_or(1),
128 children: numbered_entries(&section.children, &format!("{prefix}{}.", i + 1)),
129 number,
130 }
131 })
132 .collect()
120} 133}
121 134
122/// Parse `#+OPTIONS:` into its `key:value` switches. 135/// Parse `#+OPTIONS:` into its `key:value` switches.
tests/config.rs +96 −7
@@ -673,27 +673,48 @@ fn adding_a_post_rebuilds_only_the_listing_and_the_post() {
673 ); 673 );
674} 674}
675 675
676/// Editing a post's body changes no listing metadata, so the index must not churn. 676/// Editing a post's body reaches its listing, because a listing shows things derived
677#[test] 677/// from the body: the excerpt is its first paragraph, and the reading time is its length.
678fn editing_a_post_body_does_not_rebuild_the_listing() { 678///
679/// This test used to assert the opposite, and the site was wrong for it — rewriting a
680/// post's opening paragraph left the old excerpt on the index until something unrelated
681/// invalidated it. A listing depends on everything its template can read.
682#[test]
683fn editing_a_post_body_rebuilds_the_listing_that_shows_its_excerpt() {
679 let root = tmpdir("listbody"); 684 let root = tmpdir("listbody");
680 let src = root.join("src"); 685 let src = root.join("src");
681 std::fs::create_dir_all(&src).unwrap(); 686 std::fs::create_dir_all(&src).unwrap();
682 write_blog(&src, ""); 687 write_blog(&src, "");
688 std::fs::write(
689 src.join("templates/list.html"),
690 "<html><body>{% for p in pages %}<li>{{ p.excerpt }}</li>{% endfor %}</body></html>",
691 )
692 .unwrap();
683 let out = root.join("out"); 693 let out = root.join("out");
684 build(&src, &out); 694 build(&src, &out);
685 695
686 std::fs::write( 696 std::fs::write(
687 src.join("blog/mid.org"), 697 src.join("blog/mid.org"),
688 "#+TITLE: Middle Post\n#+DATE: 2024-08-05\n\nEdited body.\n", 698 "#+TITLE: Middle Post\n#+DATE: 2024-08-05\n\nA completely different opening.\n",
689 ) 699 )
690 .unwrap(); 700 .unwrap();
691 let report = build(&src, &out); 701 let report = build(&src, &out);
692 702
703 assert!(
704 report.rendered.contains(&Utf8PathBuf::from("blog/index.html")),
705 "the listing rebuilt: {:?}",
706 report.rendered
707 );
708 assert!(
709 page(&out, "blog/index.html").contains("A completely different opening."),
710 "and shows the new excerpt:\n{}",
711 page(&out, "blog/index.html")
712 );
693 assert_eq!( 713 assert_eq!(
694 report.rendered, 714 report.rendered.len(),
695 vec![Utf8PathBuf::from("blog/mid.html")], 715 2,
696 "only the post itself; the listing shows unchanged metadata" 716 "the post and its listing, and nothing else: {:?}",
717 report.rendered
697 ); 718 );
698} 719}
699 720
@@ -2289,3 +2310,71 @@ fn a_missing_asset_root_is_an_error() {
2289 .expect_err("a missing asset root must fail"); 2310 .expect_err("a missing asset root must fail");
2290 assert!(format!("{err:#}").contains("nope"), "names it: {err:#}"); 2311 assert!(format!("{err:#}").contains("nope"), "names it: {err:#}");
2291} 2312}
2313
2314/// A feed that carries excerpts where it used to carry whole posts is a downgrade its
2315/// subscribers notice. `include_content` gives the template each entry's rendered HTML.
2316#[test]
2317fn a_collection_can_carry_its_entries_rendered_bodies() {
2318 let root = tmpdir("feedcontent");
2319 let src = root.join("src");
2320 std::fs::create_dir_all(&src).unwrap();
2321 write_blog(&src, "");
2322 std::fs::write(
2323 src.join("blog/new.org"),
2324 "#+TITLE: Newer Post\n#+DATE: [2025-06-30 Mon 09:15:00]\n\nBody with *emphasis*.\n",
2325 )
2326 .unwrap();
2327 std::fs::write(
2328 src.join("templates/feed.xml"),
2329 "<rss>{% for p in pages %}<item><body>{{ p.content }}</body></item>{% endfor %}</rss>",
2330 )
2331 .unwrap();
2332 std::fs::write(
2333 src.join("org-ssg.toml"),
2334 "[[collections]]\nsource = \"blog\"\noutput = \"feed.xml\"\n\
2335 template = \"feed.xml\"\ntitle = \"Feed\"\ninclude_content = true\n",
2336 )
2337 .unwrap();
2338 let out = root.join("out");
2339 build(&src, &out);
2340
2341 let feed = page(&out, "feed.xml");
2342 assert!(
2343 feed.contains("&lt;strong&gt;emphasis&lt;/strong&gt;"),
2344 "the rendered body reaches the template, escaped as XML text:\n{feed}"
2345 );
2346
2347 // And it stays current: a body edit must reach a feed that embeds bodies, even when
2348 // no metadata moved.
2349 std::fs::write(
2350 src.join("blog/new.org"),
2351 "#+TITLE: Newer Post\n#+DATE: [2025-06-30 Mon 09:15:00]\n\nBody with *emphasis*.\n\nA second paragraph.\n",
2352 )
2353 .unwrap();
2354 build(&src, &out);
2355 assert!(
2356 page(&out, "feed.xml").contains("A second paragraph."),
2357 "the feed followed the edit:\n{}",
2358 page(&out, "feed.xml")
2359 );
2360}
2361
2362/// Bodies cost a render each, so a listing that does not ask for them must not pay — and
2363/// must not carry them into the template either.
2364#[test]
2365fn entries_carry_no_content_unless_asked() {
2366 let root = tmpdir("nocontent");
2367 let src = root.join("src");
2368 std::fs::create_dir_all(&src).unwrap();
2369 write_blog(&src, "");
2370 std::fs::write(
2371 src.join("templates/list.html"),
2372 "<html><body>{% for p in pages %}<li>{{ p.content is none }}</li>{% endfor %}</body></html>",
2373 )
2374 .unwrap();
2375 let out = root.join("out");
2376 build(&src, &out);
2377
2378 let html = page(&out, "blog/index.html");
2379 assert!(!html.contains("false"), "no entry carries a body:\n{html}");
2380}