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:
1515Versions follow the compatibility promise in the README: config keys, template variables,
1616CLI flags and URLs are the stable surface.
1717
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
1832## 0.18.0
1933
2034Release engineering, so that a version number is worth reading.
Cargo.lock +1 −1
@@ -675,7 +675,7 @@ dependencies = [
675675
676676[[package]]
677677name = "org-ssg"
678version = "0.18.0"
678version = "0.19.0"
679679dependencies = [
680680 "anyhow",
681681 "blake3",
Cargo.toml +1 −1
@@ -1,6 +1,6 @@
11[package]
22name = "org-ssg"
3version = "0.18.0"
3version = "0.19.0"
44edition = "2021"
55description = "Org-mode static site generator that renders the org element tree straight to HTML"
66license = "MIT"
README.md +1 −1
@@ -87,7 +87,7 @@ receive:
8787| Variable | What it is |
8888|---|---|
8989| `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` |
9191| `site` | `.title`, `.base_url`, `.description`, `.language` |
9292| `nav` | list of `{title, url}`, relative to this page |
9393| `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
7878covers an attribute that is *missing* and not one that is null — an undated page has a
7979=year=, and it is =none=.
8080
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
81107** nav = true
82108
83109The 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=.
142142The page's headings as a *tree* — a table of contents is one, and rebuilding a tree from
143143a flat list of levels inside a template is what Jinja is worst at.
144144
145Each entry has =title=, =anchor=, =level= and =children=:
145Each entry has =title=, =anchor=, =level=, =number= and =children=:
146146
147147#+BEGIN_SRC html
148148{% macro toc_list(entries) %}
149149<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>
151151 {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li>
152152{% endfor %}</ul>
153153{% endmacro %}
@@ -155,6 +155,10 @@ Each entry has =title=, =anchor=, =level= and =children=:
155155{% if page.toc | length > 1 %}{{ toc_list(page.toc) }}{% endif %}
156156#+END_SRC
157157
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
158162Anchors come from the same function that emits heading =id= attributes, so a TOC link
159163cannot drift from the heading it points at. The tree is empty when the page has no
160164headings, when =[html] toc = false=, or when the document says =#+OPTIONS: toc:nil=.
src/config.rs +8
@@ -141,6 +141,13 @@ pub struct Collection {
141141 /// `{tag}` as well when the collection is grouped — otherwise page 2 of one group
142142 /// would overwrite page 2 of another.
143143 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,
144151 /// Add this listing page to the site navigation. This is how a section landing page
145152 /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages.
146153 pub nav: bool,
@@ -161,6 +168,7 @@ impl Default for Collection {
161168 order: SortOrder::default(),
162169 paginate: 0,
163170 paginate_output: Utf8PathBuf::new(),
171 include_content: false,
164172 nav: false,
165173 }
166174 }
src/main.rs +1
@@ -317,6 +317,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
317317 year: None,
318318 tags: Vec::new(),
319319 excerpt: String::new(),
320 content: None,
320321 word_count: 0,
321322 reading_time: 0,
322323 keywords: Default::default(),
src/site.rs +92 −11
@@ -135,6 +135,13 @@ struct Listing {
135135 groups: Vec<GroupContext>,
136136 /// Set when this is one page of a paginated listing.
137137 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>,
138145}
139146
140147/// Split one listing's entries across numbered pages, appending each as its own
@@ -144,12 +151,14 @@ struct Listing {
144151/// changes — only pages 2..N are named by `paginate_output`. An empty listing still
145152/// emits page 1, because a section that exists but has nothing in it should be a page
146153/// saying so rather than a 404.
154#[allow(clippy::too_many_arguments)]
147155fn push_paginated(
148156 listings: &mut Vec<Listing>,
149157 collection: &config::Collection,
150158 output: Utf8PathBuf,
151159 title: String,
152160 entries: Vec<PageContext>,
161 entry_hashes: Vec<ContentHash>,
153162 group: Option<GroupContext>,
154163 groups: Vec<GroupContext>,
155164) {
@@ -163,6 +172,8 @@ fn push_paginated(
163172 group,
164173 groups,
165174 paginator: None,
175 include_content: collection.include_content,
176 entry_hashes: entry_hashes.clone(),
166177 });
167178 return;
168179 }
@@ -195,6 +206,8 @@ fn push_paginated(
195206 listings.push(Listing {
196207 output: here.clone(),
197208 template: collection.template.clone(),
209 include_content: collection.include_content,
210 entry_hashes: entry_hashes.clone(),
198211 title: title.clone(),
199212 entries: chunk.to_vec(),
200213 group: group.clone(),
@@ -223,6 +236,16 @@ fn push_paginated(
223236/// Build the listing pages a config asks for, each with its entries sorted.
224237fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
225238 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 };
226249 for collection in &config.collections {
227250 let mut entries: Vec<PageContext> = preps
228251 .iter()
@@ -265,12 +288,14 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
265288 }
266289
267290 if collection.group_by.is_empty() {
291 let entry_hashes = hashes_of(&entries);
268292 push_paginated(
269293 &mut listings,
270294 collection,
271295 collection.output.clone(),
272296 collection.title.clone(),
273297 entries,
298 entry_hashes,
274299 None,
275300 Vec::new(),
276301 );
@@ -330,6 +355,8 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
330355
331356 if !collection.output.as_str().is_empty() {
332357 for group in &groups {
358 let members_of = members.get(&group.name).cloned().unwrap_or_default();
359 let entry_hashes = hashes_of(&members_of);
333360 push_paginated(
334361 &mut listings,
335362 collection,
@@ -337,7 +364,8 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> {
337364 collection
338365 .title
339366 .replace(config::GROUP_PLACEHOLDER, &group.name),
340 members.get(&group.name).cloned().unwrap_or_default(),
367 members_of,
368 entry_hashes,
341369 Some(group.clone()),
342370 // Deliberately not the whole group list. A page that can see every
343371 // 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>> {
357385 group: None,
358386 groups: groups.clone(),
359387 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(),
360391 });
361392 }
362393 }
@@ -409,18 +440,20 @@ fn group_terms(page: &PageContext, group_by: &str) -> Vec<String> {
409440
410441/// Everything a listing template can see about its entries, hashed. This is the listing
411442/// 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.
412448fn listing_entries_hash(listing: &Listing) -> Hash {
413 let fields: Vec<(String, String)> = listing
449 let mut fields: Vec<(String, String)> = listing
414450 .entries
415451 .iter()
416 .flat_map(|e| {
417 [
418 (e.url.clone(), e.title.clone()),
419 (
420 e.date.clone().unwrap_or_default(),
421 e.tags.join(",") + "\u{0}" + &e.keywords.len().to_string(),
422 ),
423 ]
452 .map(|e| {
453 (
454 e.url.clone(),
455 serde_json::to_string(e).unwrap_or_else(|_| e.title.clone()),
456 )
424457 })
425458 // A group index has no entries at all — its content *is* the group list, so the
426459 // 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 {
438471 }))
439472 .chain([(listing.title.clone(), listing.template.clone())])
440473 .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
441487 // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence
442488 // rather than a set: a re-ordering is a real change to the page.
443489 site_structure_hash_ordered(&fields)
444490}
445491
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
446520/// The nav a listing page shows: whatever the site's nav is, relativized to this
447521/// listing's own location.
448522fn listing_nav(preps: &[PagePrep], output: &Utf8Path) -> Vec<NavItem> {
@@ -494,6 +568,7 @@ fn listing_context(listing: &Listing) -> PageContext {
494568 year: None,
495569 tags: Vec::new(),
496570 excerpt: String::new(),
571 content: None,
497572 word_count: 0,
498573 reading_time: 0,
499574 keywords: Default::default(),
@@ -1008,8 +1083,13 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result
10081083 let stylesheet = format!("{root}{SYNTAX_STYLESHEET}");
10091084 let nav = listing_nav(&preps, &listing.output);
10101085 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));
10111091 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));
10131093 ctx.group = listing.group.as_ref();
10141094 ctx.groups = &listing.groups;
10151095 ctx.paginator = listing.paginator.as_ref();
@@ -1377,6 +1457,7 @@ fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageConte
13771457 .filter(|d| !d.trim().is_empty())
13781458 .or_else(|| first_paragraph(&doc.root))
13791459 .unwrap_or_default(),
1460 content: None,
13801461 word_count: words,
13811462 reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)),
13821463 toc: if option_enabled(&doc.keywords, "toc", config.html.toc) {
src/template.rs +4
@@ -66,6 +66,10 @@ pub struct PageContext {
6666 /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use
6767 /// project-specific metadata this crate has never heard of.
6868 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>,
6973 /// The page's headings as a tree. Empty when the page has none, when the site turns
7074 /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`.
7175 pub toc: Vec<crate::util::TocEntry>,
src/util.rs +22 −9
@@ -98,6 +98,11 @@ pub struct TocEntry {
9898 pub anchor: String,
9999 /// Org heading level, 1-based, before any `heading_offset` is applied.
100100 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,
101106 pub children: Vec<TocEntry>,
102107}
103108
@@ -106,17 +111,25 @@ pub struct TocEntry {
106111/// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from
107112/// a flat list of levels inside a template is the kind of thing Jinja is bad at.
108113pub fn table_of_contents(root: &Section) -> Vec<TocEntry> {
109 root.children.iter().map(toc_entry).collect()
114 numbered_entries(&root.children, "")
110115}
111116
112fn toc_entry(section: &Section) -> TocEntry {
113 let heading = section.heading.as_ref();
114 TocEntry {
115 title: heading.map(|h| plain_text(&h.title)).unwrap_or_default(),
116 anchor: heading.map(heading_anchor).unwrap_or_default(),
117 level: heading.map(|h| h.level).unwrap_or(1),
118 children: section.children.iter().map(toc_entry).collect(),
119 }
117fn numbered_entries(sections: &[Section], prefix: &str) -> Vec<TocEntry> {
118 sections
119 .iter()
120 .enumerate()
121 .map(|(i, section)| {
122 let number = format!("{prefix}{}.", i + 1);
123 let heading = section.heading.as_ref();
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()
120133}
121134
122135/// 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() {
673673 );
674674}
675675
676/// Editing a post's body changes no listing metadata, so the index must not churn.
677#[test]
678fn editing_a_post_body_does_not_rebuild_the_listing() {
676/// Editing a post's body reaches its listing, because a listing shows things derived
677/// from the body: the excerpt is its first paragraph, and the reading time is its length.
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() {
679684 let root = tmpdir("listbody");
680685 let src = root.join("src");
681686 std::fs::create_dir_all(&src).unwrap();
682687 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();
683693 let out = root.join("out");
684694 build(&src, &out);
685695
686696 std::fs::write(
687697 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",
689699 )
690700 .unwrap();
691701 let report = build(&src, &out);
692702
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 );
693713 assert_eq!(
694 report.rendered,
695 vec![Utf8PathBuf::from("blog/mid.html")],
696 "only the post itself; the listing shows unchanged metadata"
714 report.rendered.len(),
715 2,
716 "the post and its listing, and nothing else: {:?}",
717 report.rendered
697718 );
698719}
699720
@@ -2289,3 +2310,71 @@ fn a_missing_asset_root_is_an_error() {
22892310 .expect_err("a missing asset root must fail");
22902311 assert!(format!("{err:#}").contains("nope"), "names it: {err:#}");
22912312}
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}