krz/orgo

Lightning fast org-mode static site generator.

clone: git clone https://gitbay.org/krz/orgo.git

20cb94bb4e278edf6a7e11e47d435b978006954a

verified · cmc

author: Christian Cleberg <hello@cleberg.net> · 2026-08-11T05:46:39Z

v0.12: excerpts, reading metadata, and draft pages

Two features chosen by measuring the corpus rather than guessing. 176 of its 179 files
set a #+DESCRIPTION:, which said excerpts matter; planning lines and fixed-width lines
appear in one file each, which said the remaining parser scope items do not.

Excerpts and reading metadata:
- page.excerpt is #+DESCRIPTION: when a page sets one and its first paragraph otherwise,
  so a listing has something to show whether or not the author thought about summaries.
- page.word_count and page.reading_time (200 wpm, rounded up) count prose only. Source
  and example blocks are excluded: a post that is mostly a shell transcript should not
  read as an hour's work. #+TITLE: is metadata the layout renders as chrome, so it is not
  counted either — a test asserted otherwise and the code was right.
- A `truncate(n)` filter, cutting on a word boundary. minijinja ships none, and an
  excerpt is usually a whole paragraph, so without it a listing's only options were the
  full paragraph or nothing.
- The starter listing template now shows excerpt and reading time, since a list of bare
  titles is not much of an index.

Drafts:
- #+DRAFT: keeps a page out of the build entirely. Filtering right after PARSE rather
  than at emit time means a draft is absent from listings, the nav and the symbol table
  alike — so a link *to* a draft is reported as the dead link it would be once published,
  instead of pointing at a file that was never written.
- --drafts includes them, which is what you want under `watch` while writing one. The
  flag turns drafts on and never turns off a config that asked for them.
- The keyword is read forgivingly: t, yes, 1 and a bare #+DRAFT: all mean draft, because
  writing the keyword at all is the signal. Only an explicit nil/false/no/0/off means
  published. Publishing an unfinished post because the value was not the expected
  spelling is the wrong way to be strict.

All 182 incumbent URLs still reproduced.
 Cargo.lock      |   2 +-
 Cargo.toml      |   2 +-
 README.md       |  24 +++++-
 src/config.rs   |  17 +++++
 src/main.rs     |  13 ++++
 src/site.rs     |  34 ++++++++-
 src/template.rs |  31 ++++++++
 src/util.rs     | 101 +++++++++++++++++++++++++-
 tests/config.rs | 221 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 9 files changed, 438 insertions(+), 7 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock
index 63348e4..cc5df9f 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -657,7 +657,7 @@ dependencies = [
 
 [[package]]
 name = "org-ssg"
-version = "0.11.0"
+version = "0.12.0"
 dependencies = [
  "anyhow",
  "blake3",
diff --git a/Cargo.toml b/Cargo.toml
index bd5dabc..30f8023 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,6 +1,6 @@
 [package]
 name = "org-ssg"
-version = "0.11.0"
+version = "0.12.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 4498347..5ec98a5 100644
--- a/README.md
+++ b/README.md
@@ -72,7 +72,7 @@ receive:
 | Variable | What it is |
 |---|---|
 | `body` | the rendered page HTML — use `{{ body \| safe }}` |
-| `page` | `.title`, `.url`, `.source`, `.date`, `.tags`, `.keywords` |
+| `page` | `.title`, `.url`, `.source`, `.date`, `.date_iso`, `.tags`, `.excerpt`, `.word_count`, `.reading_time`, `.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 |
@@ -218,6 +218,7 @@ broken. Set `site.base_url` and use the `absolute` filter:
 |---|---|
 | `absolute` | site-root-relative path → absolute URL; already-absolute URLs pass through |
 | `rfc822` | any org or ISO date → the format RSS `pubDate` requires |
+| `truncate(n)` | shorten to at most `n` characters on a word boundary, with an ellipsis |
 
 Apply `absolute` to the site-root-relative values — `page.url`, `pages[].url`,
 `group.url` — and not to `nav[].url`, `paginator.*_url`, `stylesheet` or `root`, which
@@ -230,6 +231,24 @@ The default layout also emits `<link rel="canonical">` when a base URL is set.
 Listing pages are cached on the entries they list, so adding a post re-renders that
 section's index and nothing else.
 
+### Excerpts and drafts
+
+`page.excerpt` is a page's `#+DESCRIPTION:` when it sets one and its first paragraph
+otherwise, so a listing has something to show whether or not the author thought about
+summaries. `page.word_count` and `page.reading_time` (minutes at 200 wpm) count prose
+only — a post that is mostly a shell transcript should not read as an hour's work.
+`truncate` exists because an excerpt is usually a whole paragraph and minijinja has no
+such filter.
+
+`#+DRAFT:` keeps a page out of the build entirely — no page, and absent from listings and
+the nav rather than merely unlinked. `--drafts` includes them, which is what you want
+under `watch` while writing one. A draft is out of the symbol table too, so a link *to*
+one is reported as the dead link it would be once published.
+
+The keyword is read forgivingly: `t`, `yes`, `1` and a bare `#+DRAFT:` all mean draft,
+because writing the keyword at all is the signal. Only an explicit `nil`, `false`, `no`,
+`0` or `off` means published.
+
 ### `#+SLUG:`
 
 A page's output filename comes from its `#+SLUG:` when it has one, so
@@ -303,6 +322,7 @@ all-of-org. Phase 0 checked this line against a real 179-file corpus and found i
 | **11** | **Pagination: numbered pages with a `paginator` context, composing with grouping** | **done** |
 | **12** | **`base_url`: `absolute`/`rfc822` filters, a valid RSS feed in the scaffold, canonical links** | **done** |
 | **13** | **`watch` on OS filesystem events, debounced, with the feedback loop closed** | **done** |
+| **14** | **Authoring: excerpts, word count, reading time, `truncate`, and draft pages** | **done** |
 
 ### v0.2 in / out
 
@@ -590,7 +610,7 @@ PARSE/RESOLVE/RENDER), `notify` (filesystem events for `watch`), `toml` (config)
 
 ```
 cargo build
-cargo test                                                # 128 tests
+cargo test                                                # 135 tests
 cargo run -- init my-site                                 # scaffold a new site
 cargo run -- build fixtures/minimal.org -o minimal.html   # single file
 cargo run -- build fixtures/site -o _site                 # whole site (incremental)
diff --git a/src/config.rs b/src/config.rs
index 8b6e4ce..ce41598 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -30,6 +30,7 @@ pub struct Config {
     pub templates: Templates,
     pub highlight: Highlight,
     pub html: HtmlOutput,
+    pub build: Build,
     /// Generated listing pages. Each produces one output file that has no source `.org`
     /// file behind it — a blog index, an archive, a feed.
     pub collections: Vec<Collection>,
@@ -130,6 +131,17 @@ pub enum SortOrder {
     Asc,
 }
 
+#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
+#[serde(default, deny_unknown_fields)]
+pub struct Build {
+    /// Include pages marked `#+DRAFT:` in the build.
+    ///
+    /// Off by default, because the point of marking something a draft is that it is not
+    /// ready to be read. `--drafts` turns it on for a session, which is what you want
+    /// under `watch` while writing one.
+    pub drafts: bool,
+}
+
 #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
 #[serde(default, deny_unknown_fields)]
 pub struct HtmlOutput {
@@ -400,6 +412,11 @@ expose_page_list = false
 # base16-eighties.dark, base16-mocha.dark, base16-ocean.light.
 theme = "InspiredGitHub"
 
+[build]
+# Include pages marked `#+DRAFT:`. Off by default — the point of marking a draft is that
+# it is not ready to be read. `--drafts` turns it on for one run, handy under `watch`.
+drafts = false
+
 [html]
 # How far to push heading levels down: a level-1 org heading becomes <h(1 + offset)>.
 # The default of 1 matches Emacs, and assumes your layout renders the page title as the
diff --git a/src/main.rs b/src/main.rs
index 897e008..ff4fcf1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -39,6 +39,9 @@ enum Command {
         /// Config file to use, overriding `org-ssg.toml` in the source directory.
         #[arg(long, value_name = "FILE")]
         config: Option<Utf8PathBuf>,
+        /// Include pages marked `#+DRAFT:`.
+        #[arg(long)]
+        drafts: bool,
     },
     /// Watch a source directory and rebuild incrementally on change, driven by OS
     /// filesystem events.
@@ -57,6 +60,9 @@ enum Command {
         /// Config file to use, overriding `org-ssg.toml` in the source directory.
         #[arg(long, value_name = "FILE")]
         config: Option<Utf8PathBuf>,
+        /// Include pages marked `#+DRAFT:`. Handy while writing one.
+        #[arg(long)]
+        drafts: bool,
     },
     /// Remove the build output directory (which holds the cache manifest).
     Clean {
@@ -87,6 +93,7 @@ fn main() -> Result<()> {
             no_cache,
             strict,
             config,
+            drafts,
         } => {
             if input.is_dir() {
                 let out = output
@@ -95,6 +102,7 @@ fn main() -> Result<()> {
                     no_cache,
                     strict,
                     config_path: config.clone(),
+                    drafts,
                 };
                 let report = build_site(&input, &out, &opts)?;
                 println!(
@@ -121,6 +129,7 @@ fn main() -> Result<()> {
             no_cache,
             strict,
             config,
+            drafts,
         } => org_ssg::watch::run(
             &input,
             &output,
@@ -128,6 +137,7 @@ fn main() -> Result<()> {
                 no_cache,
                 strict,
                 config_path: config,
+                drafts,
             },
         ),
         Command::Audit { input } => {
@@ -266,6 +276,9 @@ fn build_file(input: &Utf8Path, output: &Utf8Path) -> Result<()> {
         date: None,
         date_iso: None,
         tags: Vec::new(),
+        excerpt: String::new(),
+        word_count: 0,
+        reading_time: 0,
         keywords: Default::default(),
     };
     let mut ctx = RenderContext::new(&site, &page_ctx, &[], SYNTAX_STYLESHEET, "");
diff --git a/src/site.rs b/src/site.rs
index c1a7e65..5b9fdef 100644
--- a/src/site.rs
+++ b/src/site.rs
@@ -32,7 +32,14 @@ use crate::template::{
     GroupContext, NavItem, PageContext, Paginator, PaginatorPage, RenderContext, SiteContext,
     Templater,
 };
-use crate::util::{iso_date, output_path, output_url, relative_root, slugify};
+use crate::util::{
+    document_text, first_paragraph, is_draft, iso_date, output_path, output_url, relative_root,
+    slugify,
+};
+
+/// Reading speed for [`PageContext::reading_time`]. 200 wpm is the conventional figure
+/// for prose on screen.
+const WORDS_PER_MINUTE: usize = 200;
 
 /// A fully built page: source and output paths (relative to their roots) and its
 /// final templated HTML.
@@ -56,6 +63,8 @@ pub struct BuildOptions {
     pub strict: bool,
     /// Explicit config file, overriding `org-ssg.toml` in the source directory.
     pub config_path: Option<Utf8PathBuf>,
+    /// Include pages marked `#+DRAFT:`, overriding `build.drafts` when set.
+    pub drafts: bool,
 }
 
 /// Summary of a site build.
@@ -468,6 +477,9 @@ fn listing_context(listing: &Listing) -> PageContext {
         date: None,
         date_iso: None,
         tags: Vec::new(),
+        excerpt: String::new(),
+        word_count: 0,
+        reading_time: 0,
         keywords: Default::default(),
     }
 }
@@ -517,6 +529,15 @@ fn prepare_pages(
         })
         .collect::<Result<Vec<_>>>()?;
 
+    // Drop drafts before anything else sees them. Removing them here rather than at emit
+    // time means they are absent from listings, the nav and the symbol table too — so a
+    // link *to* a draft is reported as broken, which is exactly what it would be on the
+    // published site.
+    let docs: Vec<Document> = docs
+        .into_iter()
+        .filter(|d| config.build.drafts || !is_draft(&d.keywords))
+        .collect();
+
     // INDEX: collect every link target across the corpus.
     let mut symbols = SymbolTable::new();
     for doc in &docs {
@@ -692,11 +713,13 @@ pub const SYNTAX_STYLESHEET: &str = "syntax.css";
 /// `render_key` changed or that link into a changed file's targets; reuses the on-disk
 /// output of everything else; persists an updated cache manifest.
 pub fn build_site(src: &Utf8Path, out: &Utf8Path, opts: &BuildOptions) -> Result<SiteReport> {
-    let cfg = match &opts.config_path {
+    let mut cfg = match &opts.config_path {
         Some(path) => Config::load_file(path)?,
         None => Config::load(src)?,
     };
     cfg.validate()?;
+    // The flag turns drafts on; it never turns off a config that asked for them.
+    cfg.build.drafts |= opts.drafts;
 
     // Create the output directory up front so it can be recognised and excluded when it
     // lives inside the source tree.
@@ -1141,6 +1164,7 @@ fn is_top_level(output: &Utf8Path) -> bool {
 /// under its lowercased name, so a template can use metadata this crate has never heard
 /// of without the crate needing a release to support it.
 fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
+    let words = document_text(&doc.root).split_whitespace().count();
     let keyword = |name: &str| {
         doc.keywords
             .entries
@@ -1154,6 +1178,12 @@ fn page_context(doc: &Document, output: &Utf8Path) -> PageContext {
         source: doc.source_path.to_string(),
         date_iso: keyword("DATE").as_deref().and_then(iso_date),
         date: keyword("DATE"),
+        excerpt: keyword("DESCRIPTION")
+            .filter(|d| !d.trim().is_empty())
+            .or_else(|| first_paragraph(&doc.root))
+            .unwrap_or_default(),
+        word_count: words,
+        reading_time: words.div_ceil(WORDS_PER_MINUTE).max(usize::from(words > 0)),
         tags: keyword("FILETAGS")
             .unwrap_or_default()
             .split(':')
diff --git a/src/template.rs b/src/template.rs
index 889362b..15258ce 100644
--- a/src/template.rs
+++ b/src/template.rs
@@ -52,6 +52,14 @@ pub struct PageContext {
     pub date_iso: Option<String>,
     /// `#+FILETAGS:` split on `:`.
     pub tags: Vec<String>,
+    /// A short summary for listings: `#+DESCRIPTION:` when the page sets one, otherwise
+    /// its first paragraph. Empty only when the page has neither.
+    pub excerpt: String,
+    /// Words of prose, excluding code and example blocks.
+    pub word_count: usize,
+    /// Minutes to read at 200 words per minute, rounded up; at least 1 for a page with
+    /// any prose at all.
+    pub reading_time: usize,
     /// 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>,
@@ -375,6 +383,10 @@ pub const STARTER_LIST_TEMPLATE: &str = r#"<!DOCTYPE html>
 <li>
 {%- if post.date_iso %}<time datetime="{{ post.date_iso }}">{{ post.date_iso }}</time> {% endif %}
 <a href="{{ root }}{{ post.url }}">{{ post.title }}</a>
+{%- if post.excerpt %}
+<p class="excerpt">{{ post.excerpt | truncate(180) }}</p>
+{%- endif %}
+<span class="reading-time">{{ post.reading_time }} min read</span>
 </li>
 {%- endfor %}
 </ul>
@@ -427,6 +439,25 @@ fn add_filters(env: &mut Environment<'static>, base_url: &str) {
         },
     );
 
+    // `truncate`: shorten to at most N characters, on a word boundary, with an ellipsis.
+    //
+    // minijinja ships no truncate, and an excerpt is usually a whole first paragraph —
+    // so without this the only options in a listing are the full paragraph or nothing.
+    env.add_filter(
+        "truncate",
+        |text: &str, limit: Option<usize>| -> String {
+            let limit = limit.unwrap_or(160);
+            if text.chars().count() <= limit {
+                return text.to_string();
+            }
+            let head: String = text.chars().take(limit).collect();
+            // Cut at the last space so a word is never sliced in half; if there is no
+            // space at all, the hard cut is the only option.
+            let cut = head.rfind(char::is_whitespace).unwrap_or(head.len());
+            format!("{}…", head[..cut].trim_end())
+        },
+    );
+
     // `rfc822`: an org or ISO date → the format RSS `pubDate` requires.
     env.add_filter("rfc822", |raw: &str| -> Result<String, minijinja::Error> {
         let iso = crate::util::iso_date(raw).ok_or_else(|| {
diff --git a/src/util.rs b/src/util.rs
index 2d974bc..9ad8cf4 100644
--- a/src/util.rs
+++ b/src/util.rs
@@ -4,7 +4,7 @@
 
 use camino::{Utf8Path, Utf8PathBuf};
 
-use crate::model::{Keywords, Object};
+use crate::model::{Element, Keywords, Object, Section, TableRow};
 
 /// The output path for a document, relative to the site root.
 ///
@@ -76,6 +76,105 @@ fn plain_text_into(objs: &[Object], out: &mut String) {
     }
 }
 
+/// Is this document marked as a draft?
+///
+/// `#+DRAFT:` counts as true by its mere presence — writing the keyword at all is the
+/// signal — unless the value explicitly says otherwise. Someone who types `#+DRAFT: t`,
+/// `#+DRAFT: yes` or a bare `#+DRAFT:` means the same thing, and publishing an unfinished
+/// post because the value was not the expected spelling is the wrong way to be strict.
+pub fn is_draft(keywords: &Keywords) -> bool {
+    keywords
+        .entries
+        .iter()
+        .find(|(k, _)| k.eq_ignore_ascii_case("DRAFT"))
+        .map(|(_, v)| {
+            !matches!(
+                v.trim().to_ascii_lowercase().as_str(),
+                "nil" | "false" | "no" | "0" | "off"
+            )
+        })
+        .unwrap_or(false)
+}
+
+/// The document's prose as plain text, for word counts and excerpts.
+///
+/// Source and example blocks are excluded on purpose: a reading-time estimate over a
+/// post that is mostly a shell transcript should describe the prose someone reads, not
+/// the code they skim. Headings are included — they are read.
+pub fn document_text(root: &Section) -> String {
+    let mut out = String::new();
+    section_text(root, &mut out);
+    out
+}
+
+fn section_text(section: &Section, out: &mut String) {
+    if let Some(heading) = &section.heading {
+        push_words(&plain_text(&heading.title), out);
+    }
+    elements_text(&section.content, out);
+    for child in &section.children {
+        section_text(child, out);
+    }
+}
+
+fn elements_text(elements: &[Element], out: &mut String) {
+    for element in elements {
+        match element {
+            Element::Paragraph(objs) => push_words(&plain_text(objs), out),
+            Element::List(list) => {
+                for item in &list.items {
+                    if let Some(term) = &item.term {
+                        push_words(&plain_text(term), out);
+                    }
+                    elements_text(&item.content, out);
+                }
+            }
+            Element::Table(table) => {
+                for row in &table.rows {
+                    if let TableRow::Cells(cells) = row {
+                        for cell in cells {
+                            push_words(&plain_text(cell), out);
+                        }
+                    }
+                }
+            }
+            Element::QuoteBlock(inner) | Element::CenterBlock(inner) => elements_text(inner, out),
+            Element::Figure { caption, .. } => push_words(&plain_text(caption), out),
+            Element::FootnoteDefinition { content, .. } => elements_text(content, out),
+            // Code, drawers, comments, keywords and raw export blocks are not prose.
+            _ => {}
+        }
+    }
+}
+
+fn push_words(text: &str, out: &mut String) {
+    let text = text.trim();
+    if text.is_empty() {
+        return;
+    }
+    if !out.is_empty() {
+        out.push(' ');
+    }
+    out.push_str(text);
+}
+
+/// The document's first paragraph as plain text — the fallback excerpt for a page with
+/// no `#+DESCRIPTION:`.
+pub fn first_paragraph(root: &Section) -> Option<String> {
+    fn find(section: &Section) -> Option<String> {
+        for element in &section.content {
+            if let Element::Paragraph(objs) = element {
+                let text = plain_text(objs);
+                if !text.trim().is_empty() {
+                    return Some(text.trim().to_string());
+                }
+            }
+        }
+        section.children.iter().find_map(find)
+    }
+    find(root)
+}
+
 /// Turn heading text into a URL-safe anchor slug.
 pub fn slugify(text: &str) -> String {
     let mut out = String::new();
diff --git a/tests/config.rs b/tests/config.rs
index 1614682..0644ae3 100644
--- a/tests/config.rs
+++ b/tests/config.rs
@@ -1469,3 +1469,224 @@ fn changing_base_url_re_renders_the_site() {
     assert_eq!(report.rendered.len(), 3, "every page carries the base URL");
     assert!(page(&out, "index.html").contains("https://moved.example/index.html"));
 }
+
+// ---------------------------------------------------------------------------
+// Excerpts, reading metadata, and drafts
+// ---------------------------------------------------------------------------
+
+/// Posts with and without a `#+DESCRIPTION:`, and a template that prints the metadata.
+fn write_excerpt_site(src: &Utf8PathBuf, extra_config: &str) {
+    std::fs::create_dir_all(src.join("blog")).unwrap();
+    std::fs::create_dir_all(src.join("templates")).unwrap();
+    std::fs::write(src.join("index.org"), "#+TITLE: Home\n\nWelcome.\n").unwrap();
+    std::fs::write(
+        src.join("blog/described.org"),
+        "#+TITLE: Described\n#+DATE: 2024-02-02\n#+DESCRIPTION: A hand-written summary.\n\n\
+         The body's first paragraph, which is not the excerpt here.\n",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("blog/plain.org"),
+        "#+TITLE: Plain\n#+DATE: 2024-01-01\n\nThe opening paragraph stands in for a summary.\n\n\
+         A second paragraph that should not appear in the excerpt.\n",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("templates/list.html"),
+        "<html><body>{% for p in pages %}<li>{{ p.title }}|{{ p.excerpt }}|\
+         {{ p.word_count }}|{{ p.reading_time }}</li>{% endfor %}</body></html>",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("org-ssg.toml"),
+        format!(
+            "[[collections]]\nsource = \"blog\"\noutput = \"blog/index.html\"\n\
+             template = \"list.html\"\ntitle = \"Blog\"\n{extra_config}"
+        ),
+    )
+    .unwrap();
+}
+
+/// A listing of bare titles is thin. 176 of the 179 corpus files set a
+/// `#+DESCRIPTION:`, so that is the excerpt when it exists — and the first paragraph
+/// when it does not, so a page that never thought about summaries still has one.
+#[test]
+fn excerpts_prefer_the_description_and_fall_back_to_the_first_paragraph() {
+    let root = tmpdir("excerpt");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_excerpt_site(&src, "");
+    let out = root.join("out");
+    build(&src, &out);
+
+    let listing = page(&out, "blog/index.html");
+    assert!(
+        listing.contains("Described|A hand-written summary.|"),
+        "an explicit description wins:\n{listing}"
+    );
+    assert!(
+        listing.contains("Plain|The opening paragraph stands in for a summary.|"),
+        "otherwise the first paragraph:\n{listing}"
+    );
+    assert!(
+        !listing.contains("A second paragraph"),
+        "only the *first* paragraph:\n{listing}"
+    );
+}
+
+/// Reading time should describe the prose someone reads, not the code they skim.
+#[test]
+fn word_count_and_reading_time_ignore_code_blocks() {
+    let root = tmpdir("wordcount");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_excerpt_site(&src, "");
+    let prose = "word ".repeat(400);
+    std::fs::write(
+        src.join("blog/plain.org"),
+        format!(
+            "#+TITLE: Plain\n#+DATE: 2024-01-01\n\n{prose}\n\n\
+             #+BEGIN_SRC rust\n{}\n#+END_SRC\n",
+            "let noise = 1; ".repeat(200)
+        ),
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let listing = page(&out, "blog/index.html");
+    // Exactly the 400 prose words: the 600+ words of code are not prose, and neither is
+    // `#+TITLE:`, which is metadata the layout renders as chrome rather than body text.
+    assert!(
+        listing.contains("|400|2</li>"),
+        "code must not inflate the count or the estimate:\n{listing}"
+    );
+}
+
+/// An excerpt is usually a whole paragraph, and minijinja ships no `truncate`, so
+/// without one a listing's only options are the full paragraph or nothing.
+#[test]
+fn the_truncate_filter_cuts_on_a_word_boundary() {
+    let root = tmpdir("truncate");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_excerpt_site(&src, "");
+    std::fs::write(
+        src.join("templates/list.html"),
+        "<html><body>{% for p in pages %}<li>{{ p.excerpt | truncate(20) }}</li>\
+         {% endfor %}<x>{{ \"short\" | truncate(20) }}</x></body></html>",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    let listing = page(&out, "blog/index.html");
+    assert!(
+        listing.contains("<li>A hand-written…</li>"),
+        "cut at a space, not mid-word:\n{listing}"
+    );
+    assert!(
+        listing.contains("<x>short</x>"),
+        "text under the limit is untouched:\n{listing}"
+    );
+}
+
+/// The point of marking something a draft is that it is not ready to be read.
+#[test]
+fn drafts_are_excluded_from_the_build_by_default() {
+    let root = tmpdir("draft");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_excerpt_site(&src, "");
+    std::fs::write(
+        src.join("blog/wip.org"),
+        "#+TITLE: Unfinished\n#+DRAFT: t\n#+DATE: 2024-03-03\n\nNot ready.\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build(&src, &out);
+
+    assert!(!out.join("blog/wip.html").exists(), "no page is written");
+    assert!(
+        !page(&out, "blog/index.html").contains("Unfinished"),
+        "and it is absent from listings, not merely unlinked"
+    );
+}
+
+/// `--drafts` is for previewing one while writing it, typically under `watch`.
+#[test]
+fn the_drafts_flag_includes_them() {
+    let root = tmpdir("draftflag");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_excerpt_site(&src, "");
+    std::fs::write(
+        src.join("blog/wip.org"),
+        "#+TITLE: Unfinished\n#+DRAFT: t\n#+DATE: 2024-03-03\n\nNot ready.\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    build_site(
+        &src,
+        &out,
+        &BuildOptions {
+            drafts: true,
+            ..Default::default()
+        },
+    )
+    .expect("build");
+
+    assert!(out.join("blog/wip.html").exists());
+    assert!(page(&out, "blog/index.html").contains("Unfinished"));
+}
+
+/// A draft is absent from the symbol table too, so a link to one is reported as the dead
+/// link it would be on the published site — rather than silently pointing at nothing.
+#[test]
+fn a_link_to_a_draft_is_reported_as_broken() {
+    let root = tmpdir("draftlink");
+    let src = root.join("src");
+    std::fs::create_dir_all(&src).unwrap();
+    write_excerpt_site(&src, "");
+    std::fs::write(
+        src.join("blog/wip.org"),
+        "#+TITLE: Unfinished\n#+DRAFT: t\n\nNot ready.\n",
+    )
+    .unwrap();
+    std::fs::write(
+        src.join("index.org"),
+        "#+TITLE: Home\n\nSee [[file:blog/wip.org][the draft]].\n",
+    )
+    .unwrap();
+    let out = root.join("out");
+    let report = build(&src, &out);
+
+    assert!(
+        report.warnings().iter().any(|w| w.contains("wip.org")),
+        "linking to a draft must be reported: {:?}",
+        report.warnings()
+    );
+}
+
+/// Writing the keyword at all is the signal. Publishing an unfinished post because the
+/// value was not the expected spelling is the wrong way to be strict — but an explicit
+/// "no" has to mean no.
+#[test]
+fn draft_truthiness_is_forgiving_but_respects_an_explicit_negative() {
+    use org_ssg::model::Keywords;
+    let draft = |value: &str| {
+        org_ssg::util::is_draft(&Keywords {
+            entries: vec![("DRAFT".to_string(), value.to_string())],
+        })
+    };
+    for yes in ["t", "true", "yes", "1", "", "  ", "anything"] {
+        assert!(draft(yes), "{yes:?} should mean draft");
+    }
+    for no in ["nil", "false", "no", "0", "off", "NIL"] {
+        assert!(!draft(no), "{no:?} should mean published");
+    }
+    assert!(
+        !org_ssg::util::is_draft(&Keywords::default()),
+        "no keyword at all means published"
+    );
+}