Commit 768e668f7a
Verified · cmc
Layout: unified · split
CHANGELOG.md +14
| @@ -15,6 +15,20 @@ Two conventions worth knowing before reading: | ||
| 15 | 15 | Versions follow the compatibility promise in the README: config keys, template variables, |
| 16 | 16 | CLI 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 | 32 | ## 0.18.0 |
| 19 | 33 | |
| 20 | 34 | Release engineering, so that a version number is worth reading. |
Cargo.lock +1 −1
| @@ -675,7 +675,7 @@ dependencies = [ | ||
| 675 | 675 | |
| 676 | 676 | [[package]] |
| 677 | 677 | name = "org-ssg" |
| 678 | version = "0.18.0" | |
| 678 | version = "0.19.0" | |
| 679 | 679 | dependencies = [ |
| 680 | 680 | "anyhow", |
| 681 | 681 | "blake3", |
Cargo.toml +1 −1
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | [package] |
| 2 | 2 | name = "org-ssg" |
| 3 | version = "0.18.0" | |
| 3 | version = "0.19.0" | |
| 4 | 4 | edition = "2021" |
| 5 | 5 | description = "Org-mode static site generator that renders the org element tree straight to HTML" |
| 6 | 6 | license = "MIT" |
README.md +1 −1
| @@ -87,7 +87,7 @@ receive: | ||
| 87 | 87 | | Variable | What it is | |
| 88 | 88 | |---|---| |
| 89 | 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 | 91 | | `site` | `.title`, `.base_url`, `.description`, `.language` | |
| 92 | 92 | | `nav` | list of `{title, url}`, relative to this page | |
| 93 | 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 | ||
| 78 | 78 | covers an attribute that is *missing* and not one that is null — an undated page has a |
| 79 | 79 | =year=, and it is =none=. |
| 80 | 80 | |
| 81 | ** Full-content feeds | |
| 82 | ||
| 83 | A feed usually carries whole posts, and a subscriber handed excerpts instead has lost | |
| 84 | something. =include_content= gives the template each entry's rendered HTML as | |
| 85 | =entry.content=: | |
| 86 | ||
| 87 | #+BEGIN_SRC toml | |
| 88 | [[collections]] | |
| 89 | source = "blog" | |
| 90 | output = "feed.xml" | |
| 91 | template = "feed.xml" | |
| 92 | include_content = true | |
| 93 | #+END_SRC | |
| 94 | ||
| 95 | #+BEGIN_SRC html | |
| 96 | <description><![CDATA[{{ post.content | safe }}]]></description> | |
| 97 | #+END_SRC | |
| 98 | ||
| 99 | Off by default, because it costs a render of every listed page each time the listing is | |
| 100 | rebuilt. That cost is only paid when the listing is *not* cached, and the listing's cache | |
| 101 | key covers its entries' content — so a body edit reaches the feed, and an unchanged site | |
| 102 | pays nothing. | |
| 103 | ||
| 104 | Everywhere else =entry.content= is =none=, since carrying every page's body in every | |
| 105 | listing context would be most of a site's memory for nothing. | |
| 106 | ||
| 81 | 107 | ** nav = true |
| 82 | 108 | |
| 83 | 109 | The 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=. | ||
| 142 | 142 | The page's headings as a *tree* — a table of contents is one, and rebuilding a tree from |
| 143 | 143 | a flat list of levels inside a template is what Jinja is worst at. |
| 144 | 144 | |
| 145 | Each entry has =title=, =anchor=, =level= and =children=: | |
| 145 | Each entry has =title=, =anchor=, =level=, =number= and =children=: | |
| 146 | 146 | |
| 147 | 147 | #+BEGIN_SRC html |
| 148 | 148 | {% macro toc_list(entries) %} |
| 149 | 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 | 151 | {%- if e.children %}{{ toc_list(e.children) }}{% endif %}</li> |
| 152 | 152 | {% endfor %}</ul> |
| 153 | 153 | {% endmacro %} |
| @@ -155,6 +155,10 @@ Each entry has =title=, =anchor=, =level= and =children=: | ||
| 155 | 155 | {% if page.toc | length > 1 %}{{ toc_list(page.toc) }}{% endif %} |
| 156 | 156 | #+END_SRC |
| 157 | 157 | |
| 158 | =number= is the section number — =1.=, =3.1.= — always computed and printed only if you | |
| 159 | ask for it. Print it when =[html] section_numbers= is on, or the contents will number what | |
| 160 | the headings do not. | |
| 161 | ||
| 158 | 162 | Anchors come from the same function that emits heading =id= attributes, so a TOC link |
| 159 | 163 | cannot drift from the heading it points at. The tree is empty when the page has no |
| 160 | 164 | headings, when =[html] toc = false=, or when the document says =#+OPTIONS: toc:nil=. |
src/config.rs +8
| @@ -141,6 +141,13 @@ pub struct Collection { | ||
| 141 | 141 | /// `{tag}` as well when the collection is grouped — otherwise page 2 of one group |
| 142 | 142 | /// would overwrite page 2 of another. |
| 143 | 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 | 151 | /// Add this listing page to the site navigation. This is how a section landing page |
| 145 | 152 | /// — `/blog/`, `/notes/` — gets into a nav built from top-level pages. |
| 146 | 153 | pub nav: bool, |
| @@ -161,6 +168,7 @@ impl Default for Collection { | ||
| 161 | 168 | order: SortOrder::default(), |
| 162 | 169 | paginate: 0, |
| 163 | 170 | paginate_output: Utf8PathBuf::new(), |
| 171 | include_content: false, | |
| 164 | 172 | nav: false, |
| 165 | 173 | } |
| 166 | 174 | } |
src/main.rs +1
| @@ -317,6 +317,7 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> { | ||
| 317 | 317 | year: None, |
| 318 | 318 | tags: Vec::new(), |
| 319 | 319 | excerpt: String::new(), |
| 320 | content: None, | |
| 320 | 321 | word_count: 0, |
| 321 | 322 | reading_time: 0, |
| 322 | 323 | keywords: Default::default(), |
src/site.rs +92 −11
| @@ -135,6 +135,13 @@ struct Listing { | ||
| 135 | 135 | groups: Vec<GroupContext>, |
| 136 | 136 | /// Set when this is one page of a paginated listing. |
| 137 | 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 | 147 | /// Split one listing's entries across numbered pages, appending each as its own |
| @@ -144,12 +151,14 @@ struct Listing { | ||
| 144 | 151 | /// changes — only pages 2..N are named by `paginate_output`. An empty listing still |
| 145 | 152 | /// emits page 1, because a section that exists but has nothing in it should be a page |
| 146 | 153 | /// saying so rather than a 404. |
| 154 | #[allow(clippy::too_many_arguments)] | |
| 147 | 155 | fn push_paginated( |
| 148 | 156 | listings: &mut Vec<Listing>, |
| 149 | 157 | collection: &config::Collection, |
| 150 | 158 | output: Utf8PathBuf, |
| 151 | 159 | title: String, |
| 152 | 160 | entries: Vec<PageContext>, |
| 161 | entry_hashes: Vec<ContentHash>, | |
| 153 | 162 | group: Option<GroupContext>, |
| 154 | 163 | groups: Vec<GroupContext>, |
| 155 | 164 | ) { |
| @@ -163,6 +172,8 @@ fn push_paginated( | ||
| 163 | 172 | group, |
| 164 | 173 | groups, |
| 165 | 174 | paginator: None, |
| 175 | include_content: collection.include_content, | |
| 176 | entry_hashes: entry_hashes.clone(), | |
| 166 | 177 | }); |
| 167 | 178 | return; |
| 168 | 179 | } |
| @@ -195,6 +206,8 @@ fn push_paginated( | ||
| 195 | 206 | listings.push(Listing { |
| 196 | 207 | output: here.clone(), |
| 197 | 208 | template: collection.template.clone(), |
| 209 | include_content: collection.include_content, | |
| 210 | entry_hashes: entry_hashes.clone(), | |
| 198 | 211 | title: title.clone(), |
| 199 | 212 | entries: chunk.to_vec(), |
| 200 | 213 | group: group.clone(), |
| @@ -223,6 +236,16 @@ fn push_paginated( | ||
| 223 | 236 | /// Build the listing pages a config asks for, each with its entries sorted. |
| 224 | 237 | fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { |
| 225 | 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 | 249 | for collection in &config.collections { |
| 227 | 250 | let mut entries: Vec<PageContext> = preps |
| 228 | 251 | .iter() |
| @@ -265,12 +288,14 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | ||
| 265 | 288 | } |
| 266 | 289 | |
| 267 | 290 | if collection.group_by.is_empty() { |
| 291 | let entry_hashes = hashes_of(&entries); | |
| 268 | 292 | push_paginated( |
| 269 | 293 | &mut listings, |
| 270 | 294 | collection, |
| 271 | 295 | collection.output.clone(), |
| 272 | 296 | collection.title.clone(), |
| 273 | 297 | entries, |
| 298 | entry_hashes, | |
| 274 | 299 | None, |
| 275 | 300 | Vec::new(), |
| 276 | 301 | ); |
| @@ -330,6 +355,8 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | ||
| 330 | 355 | |
| 331 | 356 | if !collection.output.as_str().is_empty() { |
| 332 | 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 | 360 | push_paginated( |
| 334 | 361 | &mut listings, |
| 335 | 362 | collection, |
| @@ -337,7 +364,8 @@ fn build_listings(config: &Config, preps: &[PagePrep]) -> Result<Vec<Listing>> { | ||
| 337 | 364 | collection |
| 338 | 365 | .title |
| 339 | 366 | .replace(config::GROUP_PLACEHOLDER, &group.name), |
| 340 | members.get(&group.name).cloned().unwrap_or_default(), | |
| 367 | members_of, | |
| 368 | entry_hashes, | |
| 341 | 369 | Some(group.clone()), |
| 342 | 370 | // Deliberately not the whole group list. A page that can see every |
| 343 | 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 | 385 | group: None, |
| 358 | 386 | groups: groups.clone(), |
| 359 | 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 | 441 | /// Everything a listing template can see about its entries, hashed. This is the listing |
| 411 | 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. | |
| 412 | 448 | fn listing_entries_hash(listing: &Listing) -> Hash { |
| 413 | let fields: Vec<(String, String)> = listing | |
| 449 | let mut fields: Vec<(String, String)> = listing | |
| 414 | 450 | .entries |
| 415 | 451 | .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 | ) | |
| 424 | 457 | }) |
| 425 | 458 | // A group index has no entries at all — its content *is* the group list, so the |
| 426 | 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 | 472 | .chain([(listing.title.clone(), listing.template.clone())]) |
| 440 | 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 | 487 | // Entry *order* is meaningful in a listing, so this hashes the sorted-by-us sequence |
| 442 | 488 | // rather than a set: a re-ordering is a real change to the page. |
| 443 | 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. | |
| 498 | fn 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 | 520 | /// The nav a listing page shows: whatever the site's nav is, relativized to this |
| 447 | 521 | /// listing's own location. |
| 448 | 522 | fn listing_nav(preps: &[PagePrep], output: &Utf8Path) -> Vec<NavItem> { |
| @@ -494,6 +568,7 @@ fn listing_context(listing: &Listing) -> PageContext { | ||
| 494 | 568 | year: None, |
| 495 | 569 | tags: Vec::new(), |
| 496 | 570 | excerpt: String::new(), |
| 571 | content: None, | |
| 497 | 572 | word_count: 0, |
| 498 | 573 | reading_time: 0, |
| 499 | 574 | keywords: Default::default(), |
| @@ -1008,8 +1083,13 @@ pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result | ||
| 1008 | 1083 | let stylesheet = format!("{root}{SYNTAX_STYLESHEET}"); |
| 1009 | 1084 | let nav = listing_nav(&preps, &listing.output); |
| 1010 | 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 | 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 | 1093 | ctx.group = listing.group.as_ref(); |
| 1014 | 1094 | ctx.groups = &listing.groups; |
| 1015 | 1095 | ctx.paginator = listing.paginator.as_ref(); |
| @@ -1377,6 +1457,7 @@ fn page_context(doc: &Document, output: &Utf8Path, config: &Config) -> PageConte | ||
| 1377 | 1457 | .filter(|d| !d.trim().is_empty()) |
| 1378 | 1458 | .or_else(|| first_paragraph(&doc.root)) |
| 1379 | 1459 | .unwrap_or_default(), |
| 1460 | content: None, | |
| 1380 | 1461 | word_count: words, |
| 1381 | 1462 | reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)), |
| 1382 | 1463 | toc: if option_enabled(&doc.keywords, "toc", config.html.toc) { |
src/template.rs +4
| @@ -66,6 +66,10 @@ pub struct PageContext { | ||
| 66 | 66 | /// Every `#+KEYWORD:` in the file, keyed by lowercased name, so a template can use |
| 67 | 67 | /// project-specific metadata this crate has never heard of. |
| 68 | 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 | 73 | /// The page's headings as a tree. Empty when the page has none, when the site turns |
| 70 | 74 | /// `html.toc` off, or when the document opts out with `#+OPTIONS: toc:nil`. |
| 71 | 75 | pub toc: Vec<crate::util::TocEntry>, |
src/util.rs +22 −9
| @@ -98,6 +98,11 @@ pub struct TocEntry { | ||
| 98 | 98 | pub anchor: String, |
| 99 | 99 | /// Org heading level, 1-based, before any `heading_offset` is applied. |
| 100 | 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 | 106 | pub children: Vec<TocEntry>, |
| 102 | 107 | } |
| 103 | 108 | |
| @@ -106,17 +111,25 @@ pub struct TocEntry { | ||
| 106 | 111 | /// Nested rather than flat: a table of contents *is* a tree, and reconstructing one from |
| 107 | 112 | /// a flat list of levels inside a template is the kind of thing Jinja is bad at. |
| 108 | 113 | pub 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 | |
| 112 | fn 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 | } | |
| 117 | fn 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(§ion.children, &format!("{prefix}{}.", i + 1)), | |
| 129 | number, | |
| 130 | } | |
| 131 | }) | |
| 132 | .collect() | |
| 120 | 133 | } |
| 121 | 134 | |
| 122 | 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. | |
| 677 | #[test] | |
| 678 | fn 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] | |
| 683 | fn editing_a_post_body_rebuilds_the_listing_that_shows_its_excerpt() { | |
| 679 | 684 | let root = tmpdir("listbody"); |
| 680 | 685 | let src = root.join("src"); |
| 681 | 686 | std::fs::create_dir_all(&src).unwrap(); |
| 682 | 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 | 693 | let out = root.join("out"); |
| 684 | 694 | build(&src, &out); |
| 685 | 695 | |
| 686 | 696 | std::fs::write( |
| 687 | 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 | 700 | .unwrap(); |
| 691 | 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 | 713 | 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 | |
| 697 | 718 | ); |
| 698 | 719 | } |
| 699 | 720 | |
| @@ -2289,3 +2310,71 @@ fn a_missing_asset_root_is_an_error() { | ||
| 2289 | 2310 | .expect_err("a missing asset root must fail"); |
| 2290 | 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] | |
| 2317 | fn 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("<strong>emphasis</strong>"), | |
| 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] | |
| 2365 | fn 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 | } | |