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

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.
 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(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index dca9f6f..6f3a002 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/Cargo.lock b/Cargo.lock
index d633d27..889e229 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -675,7 +675,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.18.0"
+version = "0.19.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index 0c322d9..3ea7be8 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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"
diff --git a/README.md b/README.md
index 8884351..6a45805 100644
--- a/README.md
+++ b/README.md
@@ -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 |
diff --git a/docs/guide/03-collections.org b/docs/guide/03-collections.org
index bf4bcc4..d45d6bb 100644
--- a/docs/guide/03-collections.org
+++ b/docs/guide/03-collections.org
@@ -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/=,
diff --git a/docs/guide/04-templates.org b/docs/guide/04-templates.org
index f6ecc36..afb9197 100644
--- a/docs/guide/04-templates.org
+++ b/docs/guide/04-templates.org
@@ -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=.
diff --git a/src/config.rs b/src/config.rs
index c68c93f..e826737 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -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,
         }
     }
diff --git a/src/main.rs b/src/main.rs
index 2b2caed..7f49f9d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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(),
diff --git a/src/site.rs b/src/site.rs
index cee1dd9..84aced3 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -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) {
diff --git a/src/template.rs b/src/template.rs
index 3ed8046..541b33e 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -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>,
diff --git a/src/util.rs b/src/util.rs
index fd7b4e4..e0008c2 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -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(&section.children, &format!("{prefix}{}.", i + 1)),
+                number,
+            }
+        })
+        .collect()
 }
 
 /// Parse `#+OPTIONS:` into its `key:value` switches.
diff --git a/tests/config.rs b/tests/config.rs
index b3ee741..b190932 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -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("&lt;strong&gt;emphasis&lt;/strong&gt;"),
+        "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}");
+}