diff --git a/README.md b/README.md
index 1bed58e..87b1bd0 100644
--- a/README.md
+++ b/README.md
@@ -399,10 +399,32 @@ extraction → prefilter → selection (both the `--skip-llm` route and a
`MockBackend` DeepSeek route) → editorial → both EPUB editions → publish → OPDS
and database rows, with no network access anywhere.
-Layout: `src/pipeline.rs` wires the stages; `src/{dedupe,extract,social,curate,
-comments,world,epub,publish,server}` implement them; `src/auth.rs` owns the rating
-token formula used by both the EPUB writer and the server; `src/types.rs` is the
-contract between stages.
+### Layout
+
+`src/pipeline.rs` wires the stages; `src/types.rs` is the contract between them;
+`src/auth.rs` owns the rating token formula, shared by the EPUB writer and the
+server. The stages themselves:
+
+```text
+miniflux.rs ingest curate/ scoring and selection
+dedupe.rs clustering prefilter, llm, score, select, editorial
+extract.rs body text profile/ the reader's taste profile
+images/ article images comments.rs discussion chapters
+ normalize usable world.rs the world briefing
+ refs what's there epub/ the two editions
+ fetch download chapters, cover, build, x4
+ encode re-encode publish.rs BookOrbit + XTC
+ embed into the page server.rs ratings, OPDS
+html.rs markup helpers db.rs SQLite
+```
+
+Two modules are worth knowing about before you go looking for their contents.
+`src/html.rs` holds the generic markup helpers — tag scanning, escaping, entity
+decoding, XHTML fixups — that extraction, images, comments and the world briefing
+all need; put anything that works on markup without caring what the markup is
+*about* there. `src/images/` owns every stage of an article's images, which is
+otherwise the kind of concern that smears itself across extraction and EPUB
+building; see its module docs for the order the stages run in.
### Auditing images against real articles
@@ -419,7 +441,9 @@ cargo run --release --example image_audit -- \
It prints per-article `refs / embedded / shown / placeholders`, a tally of loss
reasons, and totals. Pages are cached on first run, so a change can be measured
against byte-identical input; `--dump
` lists the URLs one
-article resolved to. It needs the network and is not part of `cargo test`.
+article resolved to, and `--epub-out DIR` writes a readable EPUB of the audited
+articles so the images can be looked at on a device rather than counted in a
+table. It needs the network and is not part of `cargo test`.
---
diff --git a/examples/image_audit.rs b/examples/image_audit.rs
index aa73217..cc37b8e 100644
--- a/examples/image_audit.rs
+++ b/examples/image_audit.rs
@@ -20,9 +20,14 @@ use std::collections::BTreeMap;
use std::io::Read;
use std::path::{Path, PathBuf};
-use daily_epub::epub::{build, images};
+use daily_epub::epub::build;
use daily_epub::extract::{self, Extractor};
-use daily_epub::types::{Article, EntryId, ExtractMethod, ImageAsset, Pick, SourceKind, SourceRef};
+use daily_epub::html;
+use daily_epub::images;
+use daily_epub::types::{
+ Article, Colophon, Editorial, EntryId, ExtractMethod, ImageAsset, Issue, IssueMeta, Lineup,
+ Pick, SourceKind, SourceRef,
+};
/// One article we are auditing, pulled back out of a published EPUB.
#[derive(Debug, Clone)]
@@ -60,17 +65,23 @@ async fn main() {
let mut cache = PathBuf::from("/tmp/daily-epub-audit-cache");
let mut dump: Option = None;
+ let mut epub_out: Option = None;
let mut epubs: Vec = Vec::new();
let mut args = std::env::args().skip(1);
while let Some(arg) = args.next() {
match arg.as_str() {
"--cache" => cache = PathBuf::from(args.next().expect("--cache needs a path")),
"--dump" => dump = Some(args.next().expect("--dump needs a title substring")),
+ "--epub-out" => {
+ epub_out = Some(PathBuf::from(args.next().expect("--epub-out needs a path")))
+ }
other => epubs.push(PathBuf::from(other)),
}
}
if epubs.is_empty() {
- eprintln!("usage: image_audit [--cache DIR] ...");
+ eprintln!(
+ "usage: image_audit [--cache DIR] [--dump TITLE] [--epub-out DIR] ..."
+ );
std::process::exit(2);
}
std::fs::create_dir_all(&cache).expect("cache dir");
@@ -212,6 +223,10 @@ async fn main() {
for (kind, n) in &kinds {
println!(" {n:>4} {kind}");
}
+ if let Some(dir) = &epub_out {
+ write_audit_epub(dir, &picks, &assets);
+ }
+
if !skipped.is_empty() {
println!("\n== unfetchable ({})", skipped.len());
for (t, e) in &skipped {
@@ -286,7 +301,7 @@ async fn body_for(
let _ = extractor;
let readable = extract::readability(&html, &base).map_err(|e| e.to_string())?;
Ok(extract::sanitize_with_base(
- &extract::normalize_img_tags(&readable),
+ &images::normalize_img_tags(&readable),
&base,
))
}
@@ -320,8 +335,8 @@ async fn raw_fetch(url: &str) -> Result<(Vec, String), String> {
}
fn pick_for(target: &Target, content_html: String) -> Pick {
- let word_count = extract::word_count(&content_html);
- let image_urls = extract::collect_image_urls(&content_html, &target.url);
+ let word_count = html::word_count(&content_html);
+ let image_urls = images::collect_image_urls(&content_html, &target.url);
Pick {
article: Article {
id: target.entry_id,
@@ -336,7 +351,7 @@ fn pick_for(target: &Target, content_html: String) -> Pick {
sources: vec![SourceRef {
entry_id: target.entry_id,
feed_id: 1,
- feed_title: "Feed".into(),
+ feed_title: target.issue.clone(),
category: None,
kind: SourceKind::Feed,
}],
@@ -344,14 +359,14 @@ fn pick_for(target: &Target, content_html: String) -> Pick {
url: target.url.clone(),
author: None,
feed_id: 1,
- feed_title: "Feed".into(),
+ feed_title: host(&target.url),
category: None,
published_at: None,
comments_url: None,
social: vec![],
extract_method: ExtractMethod::Readability,
},
- section: "Audit".into(),
+ section: target.issue.clone(),
position: 0,
is_lead: false,
summary: None,
@@ -360,6 +375,67 @@ fn pick_for(target: &Target, content_html: String) -> Pick {
}
}
+/// Build a readable EPUB out of the audited articles (`--epub-out DIR`).
+///
+/// Not a regenerated issue — there is no editorial, no discussion chapters and
+/// no world briefing here, and the real thing needs the database. It exists so
+/// the images can be looked at on a device rather than counted in a table.
+fn write_audit_epub(out_dir: &Path, picks: &[Pick], assets: &[ImageAsset]) {
+ let sections: Vec = {
+ let mut seen: Vec = Vec::new();
+ for p in picks {
+ if !seen.contains(&p.section) {
+ seen.push(p.section.clone());
+ }
+ }
+ seen
+ };
+ let issue = Issue {
+ meta: IssueMeta {
+ date: "2026-08-16".parse().unwrap(),
+ issue_number: 0,
+ generated_at: jiff::Timestamp::now(),
+ display_date: "Image audit rebuild".into(),
+ article_count: picks.len() as i64,
+ section_count: sections.len() as i64,
+ total_words: picks.iter().map(|p| p.article.word_count).sum(),
+ reading_minutes: picks.iter().map(|p| p.article.reading_minutes()).sum(),
+ },
+ lineup: Lineup {
+ date: "2026-08-16".parse().unwrap(),
+ picks: picks.to_vec(),
+ section_order: sections,
+ },
+ editorial: Editorial {
+ front_page_html: "Rebuilt from published issues by \
+ examples/image_audit.rs to check image handling. \
+ Articles are re-extracted live; editorial, discussions and the \
+ world briefing are absent by design.
"
+ .into(),
+ section_intros: Default::default(),
+ summaries: Default::default(),
+ },
+ world_briefing: None,
+ colophon: Colophon::default(),
+ };
+ let cfg = daily_epub::config::Config::default();
+ match daily_epub::epub::build_edition_with_images(
+ &issue,
+ daily_epub::types::Edition::Standard,
+ &cfg,
+ out_dir,
+ assets,
+ ) {
+ Ok(artifact) => println!(
+ "\nwrote {} ({:.1} MB, {} images)",
+ artifact.path.display(),
+ artifact.bytes as f64 / 1_048_576.0,
+ assets.len()
+ ),
+ Err(e) => eprintln!("epub build failed: {e}"),
+ }
+}
+
/// Pull `(entry id, title, url)` out of every article chapter in an EPUB.
fn targets_from_epub(path: &Path) -> Vec {
let issue = path
diff --git a/src/comments.rs b/src/comments.rs
index 8093e9e..d66a663 100644
--- a/src/comments.rs
+++ b/src/comments.rs
@@ -9,7 +9,7 @@ use std::collections::HashSet;
use futures::StreamExt;
use serde_json::Value;
-use crate::epub::images::{text_escape, to_xhtml};
+use crate::html::{text_escape, to_xhtml};
use crate::types::{Comment, CommentThread, Discussion, Pick, SocialSource};
/// Top-level threads kept per source (§3.7).
diff --git a/src/curate/editorial.rs b/src/curate/editorial.rs
index 1109806..35ce406 100644
--- a/src/curate/editorial.rs
+++ b/src/curate/editorial.rs
@@ -14,7 +14,7 @@ use std::fmt::Write as _;
use serde::{Deserialize, Serialize};
use super::llm::{LlmClient, LlmError};
-use super::{escape_html, html_to_text, text_to_paragraphs, truncate_tokens, truncate_words};
+use super::{escape_html, prompt_text, text_to_paragraphs, truncate_tokens, truncate_words};
use crate::types::{ArticleId, Editorial, Lineup, Pick};
/// Article text is truncated to roughly this many tokens per summary call (§3.6).
@@ -116,7 +116,7 @@ pub async fn summarize_article(
temperature: f32,
) -> Result {
llm.meter.check_budget()?;
- let body = truncate_tokens(&html_to_text(body_html), SUMMARY_INPUT_TOKEN_BUDGET);
+ let body = truncate_tokens(&prompt_text(body_html), SUMMARY_INPUT_TOKEN_BUDGET);
let mut prompt = String::with_capacity(body.len() + SUMMARY_INSTRUCTIONS.len() + 256);
prompt.push_str(SUMMARY_INSTRUCTIONS);
let _ = write!(
@@ -309,7 +309,7 @@ fn social_note(pick: &Pick) -> String {
/// The article's own opening words, used when no LLM summary exists (§3.6).
pub fn excerpt_summary(pick: &Pick) -> String {
let text = truncate_words(
- &html_to_text(&pick.article.content_html),
+ &prompt_text(&pick.article.content_html),
FALLBACK_SUMMARY_WORDS,
);
if text.is_empty() {
diff --git a/src/curate/mod.rs b/src/curate/mod.rs
index 43ca2ac..93c4130 100644
--- a/src/curate/mod.rs
+++ b/src/curate/mod.rs
@@ -164,7 +164,11 @@ pub fn approx_tokens(text: &str) -> usize {
/// Strip markup and collapse whitespace, so article bodies can go into prompts
/// as plain text (cheaper and less confusing for the model than raw HTML).
-pub fn html_to_text(html: &str) -> String {
+///
+/// Deliberately not [`crate::html::html_to_text`]: this one collapses runs of
+/// whitespace and never builds a DOM, because it runs over every candidate
+/// body on every run and only has to be good enough to size a prompt.
+pub fn prompt_text(html: &str) -> String {
/// Does `tail` open the named element, i.e. ` bool {
let bytes = tail.as_bytes();
@@ -272,11 +276,11 @@ mod tests {
fn html_becomes_readable_text() {
let html = "Title First & best.
\
Second line
";
- assert_eq!(html_to_text(html), "Title First & best. Second line");
- assert_eq!(html_to_text(""), "");
- assert_eq!(html_to_text("no markup at all"), "no markup at all");
+ assert_eq!(prompt_text(html), "Title First & best. Second line");
+ assert_eq!(prompt_text(""), "");
+ assert_eq!(prompt_text("no markup at all"), "no markup at all");
// Unicode survives byte-wise walking.
- assert_eq!(html_to_text("café — naïve
"), "café — naïve");
+ assert_eq!(prompt_text("café — naïve
"), "café — naïve");
}
#[test]
diff --git a/src/curate/profile.rs b/src/curate/profile/mod.rs
similarity index 79%
rename from src/curate/profile.rs
rename to src/curate/profile/mod.rs
index 162b6f9..a7491b1 100644
--- a/src/curate/profile.rs
+++ b/src/curate/profile/mod.rs
@@ -99,303 +99,9 @@ fn xml_unescape(s: &str) -> String {
.replace("&", "&")
}
-// ---------------------------------------------------------------------------
-// Theming (§3.6a)
-// ---------------------------------------------------------------------------
+pub mod themes;
-/// Theme name → lowercase keywords, in priority order. The first theme whose
-/// keyword appears in the interest name wins, so the table is ordered from the
-/// most specific bucket to the most general.
-const THEMES: &[(&str, &[&str])] = &[
- (
- "Systems & languages",
- &[
- "rust",
- "zig",
- "lua",
- "assembly",
- "compiler",
- "systems programming",
- "concurrency",
- "async",
- "memory",
- "simd",
- "zero-copy",
- "parser",
- "fuzz",
- "static analysis",
- "functional programming",
- "data structures",
- "algorithm",
- "performance",
- "profil",
- "python",
- "typescript",
- "node.js",
- "wasm",
- "webassembly",
- "reactive programming",
- "lsp",
- ],
- ),
- (
- "Databases & data",
- &[
- "database",
- "sql",
- "postgres",
- "query",
- "vector databases",
- "bloom",
- "compression",
- "data engineering",
- "knowledge graph",
- "message queue",
- "distributed systems",
- "crdt",
- "microservices",
- "system design",
- ],
- ),
- (
- "AI & machine learning",
- &[
- "ai",
- "llm",
- "machine learning",
- "nlp",
- "rag",
- "prompt",
- "agent",
- "claude",
- "eval",
- ],
- ),
- (
- "Web, infra & devtools",
- &[
- "web",
- "css",
- "htmx",
- "svelte",
- "react",
- "pwa",
- "api",
- "developer",
- "devops",
- "docker",
- "git",
- "observability",
- "cloud",
- "cdn",
- "edge",
- "networking",
- "mesh",
- "ipfs",
- "activitypub",
- "atproto",
- "search engines",
- "tauri",
- "design systems",
- "cybersecurity",
- "cryptography",
- "monitoring",
- "spatial computing",
- "webrtc",
- "webgpu",
- "webgl",
- "webcodecs",
- "android",
- "mobile",
- "code generation",
- ],
- ),
- (
- "Self-hosting, RSS & the indie web",
- &[
- "self-host",
- "self host",
- "homelab",
- "rss",
- "feed reader",
- "indie web",
- "personal website",
- "personal wiki",
- "digital garden",
- "static site",
- "static sites",
- "personal archiving",
- "offline-first",
- "privacy",
- "open source",
- "licensing",
- "side projects",
- "digital nomad",
- "remote living",
- "off-grid",
- "quantified self",
- "cloudflare workers",
- ],
- ),
- (
- "E-ink, terminals & hardware",
- &[
- "e-ink",
- "eink",
- "writerdeck",
- "tmux",
- "neovim",
- "terminal",
- "text editor",
- "editor",
- "window manager",
- "keyboard",
- "hardware",
- "electronics",
- "calibre",
- "ghostty",
- "retro computing",
- "low-tech",
- "manufacturing",
- "typography",
- "linux",
- ],
- ),
- (
- "Games & interactive fiction",
- &[
- "game",
- "gaming",
- "minecraft",
- "mud",
- "interactive fiction",
- "inform7",
- "bevy",
- "sim racing",
- "modding",
- ],
- ),
- (
- "Creative coding & digital art",
- &[
- "generative",
- "creative coding",
- "creative automation",
- "glitch",
- "procedural",
- "digital art",
- "gaussian splatting",
- "cellular automata",
- "sonification",
- "code visualization",
- "photo",
- "photography",
- "agent-based",
- ],
- ),
- (
- "Writing, books & PKM",
- &[
- "writing",
- "write",
- "reading",
- "book",
- "essay",
- "poetry",
- "literature",
- "note-taking",
- "journaling",
- "pkm",
- "knowledge management",
- "obsidian",
- "markdown",
- "commonplace",
- "longform",
- "long-form",
- "fiction",
- "worldbuilding",
- "sci-fi",
- "science fiction",
- "speculative",
- "podcast",
- "narrative",
- "choice architecture",
- ],
- ),
- (
- "Science, space & nature",
- &[
- "space",
- "aerospace",
- "aviation",
- "science",
- "neuroscience",
- "bioinformatics",
- "nature",
- "cognitive",
- "social science",
- ],
- ),
- (
- "History, policy & culture",
- &[
- "history",
- "policy",
- "culture",
- "lgbt",
- "queer",
- "startups",
- "apple",
- "boston",
- "criticism",
- "internet history",
- "computing history",
- ],
- ),
- (
- "Outdoors, coffee & everyday life",
- &[
- "hiking",
- "camping",
- "running",
- "trail",
- "coffee",
- "cats",
- "films",
- "music",
- "engineering",
- ],
- ),
-];
-
-/// Fallback bucket for interests no keyword claims.
-const OTHER_THEME: &str = "Other standing interests";
-
-/// Group ~220 interests into readable themes for the prompt (§3.6).
-///
-/// Deterministic: theme order follows [`THEMES`], members are sorted
-/// case-insensitively, and empty themes are omitted.
-pub fn group_into_themes(interests: &[String]) -> Vec<(String, Vec)> {
- let mut buckets: Vec> = vec![Vec::new(); THEMES.len() + 1];
- for interest in interests {
- let lower = interest.to_lowercase();
- let idx = THEMES
- .iter()
- .position(|(_, keywords)| keywords.iter().any(|k| lower.contains(k)))
- .unwrap_or(THEMES.len());
- buckets[idx].push(interest.clone());
- }
- let mut out = Vec::new();
- for (idx, mut members) in buckets.into_iter().enumerate() {
- if members.is_empty() {
- continue;
- }
- members.sort_by_key(|m| (m.to_lowercase(), m.clone()));
- let name = THEMES.get(idx).map(|(n, _)| *n).unwrap_or(OTHER_THEME);
- out.push((name.to_string(), members));
- }
- out
-}
+pub use themes::group_into_themes;
// ---------------------------------------------------------------------------
// Document assembly (§3.6)
@@ -870,27 +576,6 @@ mod tests {
);
}
- #[test]
- fn theming_is_deterministic_and_total() {
- let list = interests();
- let a = group_into_themes(&list);
- let b = group_into_themes(&list);
- assert_eq!(a, b);
- let grouped: usize = a.iter().map(|(_, m)| m.len()).sum();
- assert_eq!(
- grouped,
- list.len(),
- "every interest lands in exactly one theme"
- );
- assert!(a.len() >= 6, "expected several themes, got {}", a.len());
- // Members are sorted within a theme.
- for (_, members) in &a {
- let mut sorted = members.clone();
- sorted.sort_by_key(|m| (m.to_lowercase(), m.clone()));
- assert_eq!(members, &sorted);
- }
- }
-
#[test]
fn profile_document_is_deterministic_and_complete() {
let list = interests();
diff --git a/src/curate/profile/themes.rs b/src/curate/profile/themes.rs
new file mode 100644
index 0000000..f2d5483
--- /dev/null
+++ b/src/curate/profile/themes.rs
@@ -0,0 +1,354 @@
+//! Grouping the reader's ~220 Scour interests into readable themes (§3.6a).
+//!
+//! A lookup table and one function over it. It lives in its own file because the
+//! table is long and almost never changes, while the profile logic around it
+//! does — and a 260-line const in the middle of that logic is a wall to scroll
+//! past, not something to read.
+
+/// Theme name → lowercase keywords, in priority order. The first theme whose
+/// keyword appears in the interest name wins, so the table is ordered from the
+/// most specific bucket to the most general.
+const THEMES: &[(&str, &[&str])] = &[
+ (
+ "Systems & languages",
+ &[
+ "rust",
+ "zig",
+ "lua",
+ "assembly",
+ "compiler",
+ "systems programming",
+ "concurrency",
+ "async",
+ "memory",
+ "simd",
+ "zero-copy",
+ "parser",
+ "fuzz",
+ "static analysis",
+ "functional programming",
+ "data structures",
+ "algorithm",
+ "performance",
+ "profil",
+ "python",
+ "typescript",
+ "node.js",
+ "wasm",
+ "webassembly",
+ "reactive programming",
+ "lsp",
+ ],
+ ),
+ (
+ "Databases & data",
+ &[
+ "database",
+ "sql",
+ "postgres",
+ "query",
+ "vector databases",
+ "bloom",
+ "compression",
+ "data engineering",
+ "knowledge graph",
+ "message queue",
+ "distributed systems",
+ "crdt",
+ "microservices",
+ "system design",
+ ],
+ ),
+ (
+ "AI & machine learning",
+ &[
+ "ai",
+ "llm",
+ "machine learning",
+ "nlp",
+ "rag",
+ "prompt",
+ "agent",
+ "claude",
+ "eval",
+ ],
+ ),
+ (
+ "Web, infra & devtools",
+ &[
+ "web",
+ "css",
+ "htmx",
+ "svelte",
+ "react",
+ "pwa",
+ "api",
+ "developer",
+ "devops",
+ "docker",
+ "git",
+ "observability",
+ "cloud",
+ "cdn",
+ "edge",
+ "networking",
+ "mesh",
+ "ipfs",
+ "activitypub",
+ "atproto",
+ "search engines",
+ "tauri",
+ "design systems",
+ "cybersecurity",
+ "cryptography",
+ "monitoring",
+ "spatial computing",
+ "webrtc",
+ "webgpu",
+ "webgl",
+ "webcodecs",
+ "android",
+ "mobile",
+ "code generation",
+ ],
+ ),
+ (
+ "Self-hosting, RSS & the indie web",
+ &[
+ "self-host",
+ "self host",
+ "homelab",
+ "rss",
+ "feed reader",
+ "indie web",
+ "personal website",
+ "personal wiki",
+ "digital garden",
+ "static site",
+ "static sites",
+ "personal archiving",
+ "offline-first",
+ "privacy",
+ "open source",
+ "licensing",
+ "side projects",
+ "digital nomad",
+ "remote living",
+ "off-grid",
+ "quantified self",
+ "cloudflare workers",
+ ],
+ ),
+ (
+ "E-ink, terminals & hardware",
+ &[
+ "e-ink",
+ "eink",
+ "writerdeck",
+ "tmux",
+ "neovim",
+ "terminal",
+ "text editor",
+ "editor",
+ "window manager",
+ "keyboard",
+ "hardware",
+ "electronics",
+ "calibre",
+ "ghostty",
+ "retro computing",
+ "low-tech",
+ "manufacturing",
+ "typography",
+ "linux",
+ ],
+ ),
+ (
+ "Games & interactive fiction",
+ &[
+ "game",
+ "gaming",
+ "minecraft",
+ "mud",
+ "interactive fiction",
+ "inform7",
+ "bevy",
+ "sim racing",
+ "modding",
+ ],
+ ),
+ (
+ "Creative coding & digital art",
+ &[
+ "generative",
+ "creative coding",
+ "creative automation",
+ "glitch",
+ "procedural",
+ "digital art",
+ "gaussian splatting",
+ "cellular automata",
+ "sonification",
+ "code visualization",
+ "photo",
+ "photography",
+ "agent-based",
+ ],
+ ),
+ (
+ "Writing, books & PKM",
+ &[
+ "writing",
+ "write",
+ "reading",
+ "book",
+ "essay",
+ "poetry",
+ "literature",
+ "note-taking",
+ "journaling",
+ "pkm",
+ "knowledge management",
+ "obsidian",
+ "markdown",
+ "commonplace",
+ "longform",
+ "long-form",
+ "fiction",
+ "worldbuilding",
+ "sci-fi",
+ "science fiction",
+ "speculative",
+ "podcast",
+ "narrative",
+ "choice architecture",
+ ],
+ ),
+ (
+ "Science, space & nature",
+ &[
+ "space",
+ "aerospace",
+ "aviation",
+ "science",
+ "neuroscience",
+ "bioinformatics",
+ "nature",
+ "cognitive",
+ "social science",
+ ],
+ ),
+ (
+ "History, policy & culture",
+ &[
+ "history",
+ "policy",
+ "culture",
+ "lgbt",
+ "queer",
+ "startups",
+ "apple",
+ "boston",
+ "criticism",
+ "internet history",
+ "computing history",
+ ],
+ ),
+ (
+ "Outdoors, coffee & everyday life",
+ &[
+ "hiking",
+ "camping",
+ "running",
+ "trail",
+ "coffee",
+ "cats",
+ "films",
+ "music",
+ "engineering",
+ ],
+ ),
+];
+
+/// Fallback bucket for interests no keyword claims.
+const OTHER_THEME: &str = "Other standing interests";
+
+/// Group ~220 interests into readable themes for the prompt (§3.6).
+///
+/// Deterministic: theme order follows [`THEMES`], members are sorted
+/// case-insensitively, and empty themes are omitted.
+pub fn group_into_themes(interests: &[String]) -> Vec<(String, Vec)> {
+ let mut buckets: Vec> = vec![Vec::new(); THEMES.len() + 1];
+ for interest in interests {
+ let lower = interest.to_lowercase();
+ let idx = THEMES
+ .iter()
+ .position(|(_, keywords)| keywords.iter().any(|k| lower.contains(k)))
+ .unwrap_or(THEMES.len());
+ buckets[idx].push(interest.clone());
+ }
+ let mut out = Vec::new();
+ for (idx, mut members) in buckets.into_iter().enumerate() {
+ if members.is_empty() {
+ continue;
+ }
+ members.sort_by_key(|m| (m.to_lowercase(), m.clone()));
+ let name = THEMES.get(idx).map(|(n, _)| *n).unwrap_or(OTHER_THEME);
+ out.push((name.to_string(), members));
+ }
+ out
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ fn interests() -> Vec {
+ [
+ "Rust",
+ "Zig",
+ "Postgres",
+ "SQLite",
+ "Kubernetes",
+ "E-ink",
+ "Keyboards",
+ "Retrocomputing",
+ "Bicycles",
+ "Trail running",
+ "Coffee",
+ "Bread baking",
+ "Cartography",
+ "Typography",
+ "Ambient music",
+ "Science fiction",
+ "Local politics",
+ "Boston",
+ "Machine learning",
+ "Self-hosting",
+ ]
+ .iter()
+ .map(|s| s.to_string())
+ .collect()
+ }
+
+ #[test]
+ fn theming_is_deterministic_and_total() {
+ let list = interests();
+ let a = group_into_themes(&list);
+ let b = group_into_themes(&list);
+ assert_eq!(a, b);
+ let grouped: usize = a.iter().map(|(_, m)| m.len()).sum();
+ assert_eq!(
+ grouped,
+ list.len(),
+ "every interest lands in exactly one theme"
+ );
+ assert!(a.len() >= 6, "expected several themes, got {}", a.len());
+ // Members are sorted within a theme.
+ for (_, members) in &a {
+ let mut sorted = members.clone();
+ sorted.sort_by_key(|m| (m.to_lowercase(), m.clone()));
+ assert_eq!(members, &sorted);
+ }
+ }
+}
diff --git a/src/curate/score.rs b/src/curate/score.rs
index 9636ce7..49be464 100644
--- a/src/curate/score.rs
+++ b/src/curate/score.rs
@@ -14,7 +14,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use super::llm::{LlmClient, LlmError, strip_code_fence};
-use super::{html_to_text, truncate_words};
+use super::{prompt_text, truncate_words};
use crate::types::{ArticleId, LlmScore, ScoredArticle, SourceKind};
/// Words of article text sent per candidate in stage A (§3.6).
@@ -155,7 +155,7 @@ fn render_candidate(candidate: &ScoredArticle) -> String {
);
let _ = writeln!(block, "social: {}", social_line(candidate));
let _ = writeln!(block, "came via: {}", sources_line(candidate));
- let excerpt = truncate_words(&html_to_text(&a.content_html), EXCERPT_WORDS);
+ let excerpt = truncate_words(&prompt_text(&a.content_html), EXCERPT_WORDS);
let _ = writeln!(
block,
"excerpt: {}",
diff --git a/src/curate/select.rs b/src/curate/select.rs
index 4ab7dda..75d3db3 100644
--- a/src/curate/select.rs
+++ b/src/curate/select.rs
@@ -16,7 +16,7 @@ use jiff::civil::Date;
use serde::{Deserialize, Serialize};
use super::llm::{LlmClient, LlmError, strip_code_fence};
-use super::{html_to_text, truncate_words};
+use super::{prompt_text, truncate_words};
use crate::types::{ArticleId, Lineup, Pick, ScoredArticle, SourceKind, WORLD_BRIEFING_SECTION};
/// How many candidates are offered to stage B (§3.6).
@@ -154,7 +154,7 @@ fn render_candidate(candidate: &ScoredArticle) -> String {
""
}
);
- let blurb = truncate_words(&html_to_text(&a.content_html), BLURB_WORDS);
+ let blurb = truncate_words(&prompt_text(&a.content_html), BLURB_WORDS);
if !blurb.is_empty() {
let _ = writeln!(block, "opening: {blurb}");
}
diff --git a/src/dedupe.rs b/src/dedupe.rs
index 77fd486..8517708 100644
--- a/src/dedupe.rs
+++ b/src/dedupe.rs
@@ -162,7 +162,7 @@ fn is_enclosure_only(raw_content: &str) -> bool {
|| lower.contains(", feed_urls: &FeedUrls) -> Article
let best = members
.iter()
.enumerate()
- .max_by_key(|(_, (entry, _))| (crate::extract::word_count(&entry.raw_content), -(entry.id)))
+ .max_by_key(|(_, (entry, _))| (crate::html::word_count(&entry.raw_content), -(entry.id)))
.map(|(i, _)| i)
.unwrap_or(0);
let (best_entry, canonical) = &members[best];
@@ -385,7 +385,7 @@ fn build_article(members: Vec<(Entry, String)>, feed_urls: &FeedUrls) -> Article
.filter_map(|(e, _)| e.author.clone())
.find(|a| !a.trim().is_empty());
- let word_count = crate::extract::word_count(&best_entry.raw_content);
+ let word_count = crate::html::word_count(&best_entry.raw_content);
Article {
id: 0,
diff --git a/src/epub/build.rs b/src/epub/build.rs
index 2bb1e82..8e4575d 100644
--- a/src/epub/build.rs
+++ b/src/epub/build.rs
@@ -1,22 +1,34 @@
-//! `epub-builder` assembly and askama chapter rendering (spec §3.10).
+//! Chapter ordering and `epub-builder` assembly (spec §3.10).
//!
//! Structure: cover → From the Editor → In This Issue → sections (title page,
-//! article chapters, discussion chapters) → World Briefing → colophon.
+//! article chapters, discussion chapters) → World Briefing → colophon. The
+//! chapters themselves are rendered by [`super::chapters`] and the cover by
+//! [`super::cover`]; this module decides the order and zips the result.
-use askama::Template;
use epub_builder::{
EpubBuilder, EpubContent, EpubVersion, MetadataOpfV3, ReferenceType, ZipLibrary,
};
use jiff::civil::Date;
-use crate::comments;
-use crate::types::{Edition, ImageAsset, Issue, Pick, SocialRef, Vote, WORLD_BRIEFING_SECTION};
-use crate::world;
+use crate::types::{Edition, ImageAsset, Issue};
use super::EpubError;
-use super::images;
+use super::chapters::{
+ render_colophon, render_front_page, render_in_this_issue, render_section_page,
+ render_world_briefing, section_names,
+};
+use super::cover::{CoverAsset, render_cover_page};
use super::x4;
+// Re-exported so callers keep one import path for "everything about building an
+// issue"; the definitions live in the modules that own them.
+pub use super::chapters::{
+ TOKEN_LEN, prepare_body, rating_message, rating_token, rating_url, render_article,
+ render_discussion, social_line,
+};
+pub use super::cover::{cover_badge, cover_size, render_cover};
+pub use super::fixtures;
+
/// EPUB3 `belongs-to-collection` name (§3.10).
pub const COLLECTION_NAME: &str = "The Daily EPUB";
/// `id` the collection refinements point at (§3.10).
@@ -25,21 +37,6 @@ pub const COLLECTION_ID: &str = "daily-epub-collection";
pub const CREATOR: &str = "The Daily EPUB";
/// `dc:language` (§3.10).
pub const LANGUAGE: &str = "en";
-/// Characters of the hex HMAC kept in rating links (§3.9).
-pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN;
-#[derive(Debug, Clone, PartialEq)]
-pub struct CoverAsset {
- pub bytes: Vec,
- pub filename: &'static str,
- pub mime: &'static str,
-}
-
-pub fn cover_href(edition: Edition) -> &'static str {
- match edition {
- Edition::Standard => "cover.png",
- Edition::X4 => "cover.jpg",
- }
-}
/// One rendered chapter ready to be added to the EPUB (§3.10).
#[derive(Debug, Clone, PartialEq)]
@@ -54,742 +51,7 @@ pub struct Chapter {
pub toc_level: u8,
}
-// ---------------------------------------------------------------------------
-// Rating links (§3.9)
-// ---------------------------------------------------------------------------
-
-// One implementation, shared with the verifying side in [`crate::server`]:
-// see [`crate::auth`] for the formula and the pinned test vector (§3.9).
-pub use crate::auth::{rating_message, rating_token, rating_url};
-
-// ---------------------------------------------------------------------------
-// Templates
-// ---------------------------------------------------------------------------
-
-#[derive(Template)]
-#[template(path = "cover.svg", escape = "html")]
-struct CoverSvg {
- width: i64,
- height: i64,
- margin: i64,
- inner_width: i64,
- inner_height: i64,
- border: i64,
- hairline: i64,
- center_x: i64,
- masthead_y: i64,
- masthead_size: i64,
- rule_x1: i64,
- rule_x2: i64,
- rule_y: i64,
- rule2_y: i64,
- weekday: String,
- weekday_y: i64,
- weekday_size: i64,
- long_date: String,
- date_y: i64,
- date_size: i64,
- issue_number: i64,
- issue_y: i64,
- issue_size: i64,
- stats_line: String,
- stats_y: i64,
- stats_size: i64,
- /// Empty for the standard edition — see [`cover_badge`].
- edition_tag: String,
- badge_x: i64,
- badge_y: i64,
- badge_width: i64,
- badge_height: i64,
- badge_radius: i64,
- badge_text_y: i64,
- badge_size: i64,
- footer: String,
- footer_y: i64,
- footer_size: i64,
-}
-
-#[derive(Template)]
-#[template(path = "cover_page.xhtml", escape = "html")]
-struct CoverPage {
- title: String,
- alt: String,
- cover_href: &'static str,
-}
-
-#[derive(Template)]
-#[template(path = "front_page.xhtml", escape = "html")]
-struct FrontPage {
- title: String,
- display_date: String,
- issue_number: i64,
- stats_line: String,
- body_html: String,
-}
-
-struct IndexEntry {
- href: String,
- title: String,
- source: String,
- reading_minutes: i64,
- summary: String,
-}
-
-struct IndexSection {
- name: String,
- entries: Vec,
-}
-
-#[derive(Template)]
-#[template(path = "in_this_issue.xhtml", escape = "html")]
-struct InThisIssue {
- title: String,
- stats_line: String,
- sections: Vec,
-}
-
-#[derive(Template)]
-#[template(path = "section.xhtml", escape = "html")]
-struct SectionPage {
- title: String,
- name: String,
- intro: Option,
-}
-
-struct RatingLinks {
- up_url: String,
- down_url: String,
-}
-
-#[derive(Template)]
-#[template(path = "chapter.xhtml", escape = "html")]
-struct ArticleChapter {
- title: String,
- article_title: String,
- byline: Option,
- meta_line: String,
- social_line: Option,
- summary: Option,
- excerpt_only: bool,
- body_html: String,
- rating: Option,
- read_online_url: String,
- discussion_href: Option,
-}
-
-#[derive(Template)]
-#[template(path = "discussion.xhtml", escape = "html")]
-struct DiscussionChapter {
- title: String,
- heading: String,
- body_html: String,
- article_href: String,
-}
-
-#[derive(Template)]
-#[template(path = "world_briefing.xhtml", escape = "html")]
-struct WorldBriefingChapter {
- title: String,
- display_date: String,
- body_html: String,
-}
-
-#[derive(Template)]
-#[template(path = "colophon.xhtml", escape = "html")]
-struct ColophonChapter {
- title: String,
- issue_number: i64,
- display_date: String,
- generated_at: String,
- model: String,
- entries_fetched: i64,
- feeds_seen: i64,
- candidates: i64,
- article_count: i64,
- section_count: i64,
- total_words: i64,
- reading_line: String,
- cost_usd: String,
- generator_version: String,
-}
-
-// ---------------------------------------------------------------------------
-// Cover (§3.10)
-// ---------------------------------------------------------------------------
-
-/// Cover pixel size per edition: 1200×1600 standard, 480×800 X4 (§3.10).
-pub fn cover_size(edition: Edition) -> (u32, u32) {
- match edition {
- Edition::Standard => (1200, 1600),
- Edition::X4 => x4::X4_SCREEN,
- }
-}
-
-fn reading_line(minutes: i64) -> String {
- let (h, m) = (minutes / 60, minutes % 60);
- if h > 0 {
- format!("~{h}h {m}m read")
- } else {
- format!("~{m}m read")
- }
-}
-
-/// Split "Friday, August 15, 2026" into ("Friday", "August 15, 2026").
-fn split_display_date(display: &str) -> (String, String) {
- match display.split_once(", ") {
- Some((weekday, rest)) => (weekday.to_string(), rest.to_string()),
- None => (String::new(), display.to_string()),
- }
-}
-
-/// Reversed badge printed on the cover so the two editions are told apart at
-/// thumbnail size, where the title is unreadable (§3.10).
-///
-/// Empty for the standard edition: an unmarked cover is the default, and the
-/// presence of the slab is itself the signal.
-pub fn cover_badge(edition: Edition) -> &'static str {
- match edition {
- Edition::Standard => "",
- Edition::X4 => "X4 EDITION",
- }
-}
-
-fn cover_svg(
- issue: &Issue,
- edition: Edition,
- width: u32,
- height: u32,
-) -> Result {
- let w = i64::from(width);
- let h = i64::from(height);
- let margin = w / 15;
- let (weekday, long_date) = split_display_date(&issue.meta.display_date);
- // A solid bar between the stats line and the footer: at 100px wide in a
- // library grid the black slab is the only thing still legible.
- let badge_height = h / 16;
- let badge_width = w * 2 / 5;
- let tpl = CoverSvg {
- width: w,
- height: h,
- margin,
- inner_width: w - 2 * margin,
- inner_height: h - 2 * margin,
- border: (w / 300).max(2),
- hairline: (w / 600).max(1),
- center_x: w / 2,
- masthead_y: h * 30 / 100,
- masthead_size: w / 9,
- rule_x1: margin + w / 12,
- rule_x2: w - margin - w / 12,
- rule_y: h * 34 / 100,
- rule2_y: h * 53 / 100,
- weekday,
- weekday_y: h * 42 / 100,
- weekday_size: w / 28,
- long_date,
- date_y: h * 47 / 100,
- date_size: w / 22,
- issue_number: issue.meta.issue_number,
- issue_y: h * 60 / 100,
- issue_size: w / 18,
- stats_line: issue.meta.stats_line(),
- stats_y: h * 66 / 100,
- stats_size: w / 32,
- edition_tag: cover_badge(edition).to_string(),
- badge_x: (w - badge_width) / 2,
- badge_y: h * 73 / 100,
- badge_width,
- badge_height,
- badge_radius: badge_height / 2,
- badge_text_y: h * 73 / 100 + badge_height * 7 / 10,
- badge_size: badge_height * 11 / 20,
- footer: "Assembled overnight \u{00b7} read offline".to_string(),
- footer_y: h - margin - h / 25,
- footer_size: w / 40,
- };
- Ok(tpl.render()?)
-}
-
-/// Render the standard cover as RGB PNG and the X4 cover as baseline RGB JPEG.
-pub fn render_cover(issue: &Issue, edition: Edition) -> Result {
- let (width, height) = cover_size(edition);
- let svg = cover_svg(issue, edition, width, height)?;
- let pixmap = match rasterize(&svg, width, height) {
- Some(pixmap) => pixmap,
- None => {
- tracing::warn!("no usable system fonts: falling back to a geometric cover");
- draw_fallback_cover(width, height, edition)
- .ok_or_else(|| EpubError::Build("could not allocate the cover".into()))?
- }
- };
- Ok(CoverAsset {
- bytes: encode_cover(pixmap, edition)?,
- filename: cover_href(edition),
- mime: if edition == Edition::X4 {
- "image/jpeg"
- } else {
- "image/png"
- },
- })
-}
-
-fn rasterize(svg: &str, width: u32, height: u32) -> Option {
- let mut options = resvg::usvg::Options::default();
- options.fontdb_mut().load_system_fonts();
- if options.fontdb.is_empty() {
- return None;
- }
- let tree = resvg::usvg::Tree::from_str(svg, &options)
- .map_err(|e| tracing::warn!("cover svg did not parse: {e}"))
- .ok()?;
- let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
- pixmap.fill(tiny_skia::Color::WHITE);
- let size = tree.size();
- let transform = tiny_skia::Transform::from_scale(
- width as f32 / size.width(),
- height as f32 / size.height(),
- );
- resvg::render(&tree, transform, &mut pixmap.as_mut());
- Some(pixmap)
-}
-
-/// Text-free cover used when font resolution fails — the build never fails (§3.10).
-///
-/// The badge cannot carry its lettering here, but the slab itself still keeps
-/// the editions apart in a thumbnail grid.
-fn draw_fallback_cover(width: u32, height: u32, edition: Edition) -> Option {
- let mut pixmap = tiny_skia::Pixmap::new(width, height)?;
- pixmap.fill(tiny_skia::Color::WHITE);
- let mut paint = tiny_skia::Paint::default();
- paint.set_color(tiny_skia::Color::BLACK);
- paint.anti_alias = true;
-
- let w = width as f32;
- let h = height as f32;
- let margin = w / 15.0;
- let rect = |x: f32, y: f32, rw: f32, rh: f32, pixmap: &mut tiny_skia::Pixmap| {
- if let Some(r) = tiny_skia::Rect::from_xywh(x, y, rw, rh) {
- let path = tiny_skia::PathBuilder::from_rect(r);
- pixmap.fill_path(
- &path,
- &paint,
- tiny_skia::FillRule::Winding,
- tiny_skia::Transform::identity(),
- None,
- );
- }
- };
- // Frame.
- let border = (w / 300.0).max(2.0);
- rect(margin, margin, w - 2.0 * margin, border, &mut pixmap);
- rect(
- margin,
- h - margin - border,
- w - 2.0 * margin,
- border,
- &mut pixmap,
- );
- rect(margin, margin, border, h - 2.0 * margin, &mut pixmap);
- rect(
- w - margin - border,
- margin,
- border,
- h - 2.0 * margin,
- &mut pixmap,
- );
- // Masthead slab plus body rules — a newspaper silhouette.
- rect(
- margin * 2.0,
- h * 0.26,
- w - 4.0 * margin,
- h * 0.035,
- &mut pixmap,
- );
- for i in 0..8 {
- let y = h * 0.42 + (i as f32) * h * 0.045;
- let inset = if i % 3 == 2 {
- margin * 4.0
- } else {
- margin * 2.0
- };
- rect(inset, y, w - 2.0 * inset, border, &mut pixmap);
- }
- if !cover_badge(edition).is_empty() {
- let badge_height = h / 16.0;
- let badge_width = w * 2.0 / 5.0;
- rect(
- (w - badge_width) / 2.0,
- h * 0.73,
- badge_width,
- badge_height,
- &mut pixmap,
- );
- }
- Some(pixmap)
-}
-
-fn encode_cover(pixmap: tiny_skia::Pixmap, edition: Edition) -> Result, EpubError> {
- let (w, h) = (pixmap.width(), pixmap.height());
- let rgba = image::RgbaImage::from_raw(w, h, pixmap.take_demultiplied())
- .ok_or_else(|| EpubError::Build("cover pixel buffer had the wrong size".into()))?;
- let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8();
- let mut bytes = Vec::new();
- match edition {
- Edition::Standard => image::DynamicImage::ImageRgb8(rgb).write_to(
- &mut std::io::Cursor::new(&mut bytes),
- image::ImageFormat::Png,
- ),
- Edition::X4 => image::codecs::jpeg::JpegEncoder::new_with_quality(&mut bytes, 92)
- .encode_image(&image::DynamicImage::ImageRgb8(rgb)),
- }
- .map_err(|e| EpubError::Build(format!("cover encoding failed: {e}")))?;
- Ok(bytes)
-}
-
-// ---------------------------------------------------------------------------
-// Chapters (§3.10)
-// ---------------------------------------------------------------------------
-
-/// Prepare article markup for XHTML: rewrite images, sanitize, self-close voids.
-pub fn prepare_body(html: &str, images_: &[ImageAsset]) -> String {
- // Sanitize first: image rewriting emits our own trusted markup (including the
- // `image-placeholder` class, which ammonia would otherwise strip).
- let cleaned = ammonia::clean(html);
- let rewritten = images::rewrite_img_srcs(&cleaned, images_);
- images::to_xhtml(&rewritten)
-}
-
-/// "▲ 342 on HN · 210 comments" (§3.10).
-pub fn social_line(social: &[SocialRef]) -> Option {
- let mut parts: Vec = Vec::new();
- let mut comments = 0i64;
- for entry in social {
- if entry.score > 0 {
- parts.push(format!(
- "\u{25b2} {} on {}",
- entry.score,
- entry.source.display_name()
- ));
- }
- comments += entry.num_comments.max(0);
- }
- if comments > 0 {
- let noun = if comments == 1 { "comment" } else { "comments" };
- parts.push(format!("{comments} {noun}"));
- }
- if parts.is_empty() {
- None
- } else {
- Some(parts.join(" \u{00b7} "))
- }
-}
-
-fn article_href(pick: &Pick) -> String {
- format!("{}.xhtml", pick.article.chapter_id())
-}
-
-fn discussion_href(pick: &Pick) -> String {
- format!("disc-{}.xhtml", pick.article.best_entry_id)
-}
-
-fn section_href(name: &str) -> String {
- let slug: String = name
- .chars()
- .map(|c| {
- if c.is_ascii_alphanumeric() {
- c.to_ascii_lowercase()
- } else {
- '-'
- }
- })
- .collect();
- let slug = slug
- .split('-')
- .filter(|s| !s.is_empty())
- .collect::>()
- .join("-");
- format!("sec-{slug}.xhtml")
-}
-
-fn summary_for<'a>(issue: &'a Issue, pick: &'a Pick) -> Option<&'a str> {
- pick.summary
- .as_deref()
- .or_else(|| {
- issue
- .editorial
- .summaries
- .get(&pick.article.id)
- .map(|s| s.as_str())
- })
- .filter(|s| !s.trim().is_empty())
-}
-
-fn published_display(pick: &Pick) -> Option {
- pick.article
- .published_at
- .map(|ts| ts.to_zoned(jiff::tz::TimeZone::UTC).date().to_string())
-}
-
-/// "From the Editor" front page plus the issue stats line (§3.10).
-pub fn render_front_page(issue: &Issue) -> Result {
- let body = issue.editorial.front_page_html.trim();
- let body_html = if body.is_empty() {
- format!(
- "{} of reading, chosen overnight.
",
- images::text_escape(&issue.meta.stats_line())
- )
- } else {
- images::to_xhtml(&ammonia::clean(body))
- };
- let tpl = FrontPage {
- title: "From the Editor".into(),
- display_date: issue.meta.display_date.clone(),
- issue_number: issue.meta.issue_number,
- stats_line: issue.meta.stats_line(),
- body_html,
- };
- Ok(Chapter {
- id: "front".into(),
- href: "front.xhtml".into(),
- title: "From the Editor".into(),
- xhtml: tpl.render()?,
- toc_level: 1,
- })
-}
-
-/// Section names in issue order, with the reserved world section removed (§3.6).
-pub fn section_names(issue: &Issue) -> Vec {
- let mut names: Vec = issue
- .lineup
- .section_order
- .iter()
- .filter(|s| s.as_str() != WORLD_BRIEFING_SECTION)
- .cloned()
- .collect();
- for pick in &issue.lineup.picks {
- if pick.section != WORLD_BRIEFING_SECTION && !names.contains(&pick.section) {
- names.push(pick.section.clone());
- }
- }
- names.retain(|n| issue.lineup.picks.iter().any(|p| &p.section == n));
- names
-}
-
-/// "In This Issue": per section, each article's title, source, reading time and
-/// summary, linked to its chapter (§3.10).
-pub fn render_in_this_issue(issue: &Issue) -> Result {
- let mut sections = Vec::new();
- for name in section_names(issue) {
- let entries = issue
- .lineup
- .section_picks(&name)
- .into_iter()
- .map(|pick| IndexEntry {
- href: article_href(pick),
- title: pick.article.title.clone(),
- source: pick.article.feed_title.clone(),
- reading_minutes: pick.article.reading_minutes(),
- summary: summary_for(issue, pick).unwrap_or_default().to_string(),
- })
- .collect();
- sections.push(IndexSection { name, entries });
- }
- if issue.world_briefing.is_some() {
- sections.push(IndexSection {
- name: WORLD_BRIEFING_SECTION.to_string(),
- entries: vec![IndexEntry {
- href: "world.xhtml".into(),
- title: "World Briefing".into(),
- source: "Wikipedia Current Events".into(),
- reading_minutes: 3,
- summary: "The day's events, as recorded by the Current Events portal.".into(),
- }],
- });
- }
- let tpl = InThisIssue {
- title: "In This Issue".into(),
- stats_line: issue.meta.stats_line(),
- sections,
- };
- Ok(Chapter {
- id: "in-this-issue".into(),
- href: "in-this-issue.xhtml".into(),
- title: "In This Issue".into(),
- xhtml: tpl.render()?,
- toc_level: 1,
- })
-}
-
-/// A section title page: name + LLM intro (§3.10).
-pub fn render_section_page(name: &str, intro: Option<&str>) -> Result {
- let tpl = SectionPage {
- title: name.to_string(),
- name: name.to_string(),
- intro: intro
- .map(|s| s.trim().to_string())
- .filter(|s| !s.is_empty()),
- };
- Ok(Chapter {
- id: format!("sec-{name}"),
- href: section_href(name),
- title: name.to_string(),
- xhtml: tpl.render()?,
- toc_level: 1,
- })
-}
-
-/// One article chapter: header, cleaned body with embedded images, rating footer (§3.10).
-pub fn render_article(
- issue: &Issue,
- pick: &Pick,
- images_: &[ImageAsset],
- edition: Edition,
- public_url: &str,
- hmac_secret: Option<&str>,
-) -> Result {
- let article = &pick.article;
- let mut meta_parts = vec![article.feed_title.clone()];
- if let Some(date) = published_display(pick) {
- meta_parts.push(date);
- }
- meta_parts.push(format!(
- "{} min read \u{00b7} {} words",
- article.reading_minutes(),
- article.word_count
- ));
-
- // The X4 has no browser, so rating links are pointless there (§7).
- let rating = match (hmac_secret, edition) {
- (Some(secret), Edition::Standard) if !secret.is_empty() => Some(RatingLinks {
- up_url: rating_url(public_url, secret, issue.meta.date, article.id, Vote::Up),
- down_url: rating_url(public_url, secret, issue.meta.date, article.id, Vote::Down),
- }),
- _ => None,
- };
-
- let tpl = ArticleChapter {
- title: article.title.clone(),
- article_title: article.title.clone(),
- byline: article.author.as_ref().map(|a| format!("By {a}")),
- meta_line: meta_parts.join(" \u{00b7} "),
- social_line: social_line(&article.social),
- summary: summary_for(issue, pick).map(str::to_string),
- excerpt_only: article.excerpt_only,
- body_html: prepare_body(&article.content_html, images_),
- rating,
- read_online_url: article.url.clone(),
- discussion_href: pick.discussion.as_ref().map(|_| discussion_href(pick)),
- };
- Ok(Chapter {
- id: article.chapter_id(),
- href: article_href(pick),
- title: article.title.clone(),
- xhtml: tpl.render()?,
- toc_level: 2,
- })
-}
-
-/// The discussion chapter that follows an article when it has comments (§3.7).
-pub fn render_discussion(pick: &Pick) -> Result, EpubError> {
- let Some(discussion) = &pick.discussion else {
- return Ok(None);
- };
- if discussion.threads.is_empty() {
- return Ok(None);
- }
- let title = comments::chapter_title(&pick.article.title, discussion);
- let tpl = DiscussionChapter {
- title: title.clone(),
- heading: title.clone(),
- body_html: comments::render_xhtml(discussion, &pick.article.title),
- article_href: article_href(pick),
- };
- Ok(Some(Chapter {
- id: format!("disc-{}", pick.article.best_entry_id),
- href: discussion_href(pick),
- title,
- xhtml: tpl.render()?,
- toc_level: 3,
- }))
-}
-
-/// The Wikipedia Current Events section chapter (§3.8).
-pub fn render_world_briefing(issue: &Issue) -> Result , EpubError> {
- let Some(briefing) = &issue.world_briefing else {
- return Ok(None);
- };
- // The briefing may cover an earlier day than the issue: the portal page for
- // the issue's own date is still an empty stub at 05:30 (§3.8). Dateline the
- // section with the day it actually reports on, not the masthead date.
- let tpl = WorldBriefingChapter {
- title: WORLD_BRIEFING_SECTION.to_string(),
- display_date: crate::pipeline::display_date(briefing.date),
- body_html: world::render_xhtml(briefing),
- };
- Ok(Some(Chapter {
- id: "world".into(),
- href: "world.xhtml".into(),
- title: WORLD_BRIEFING_SECTION.to_string(),
- xhtml: tpl.render()?,
- toc_level: 1,
- }))
-}
-
-/// Colophon: generation timestamp, models used, token cost, feed counts (§3.10).
-pub fn render_colophon(issue: &Issue) -> Result {
- let colophon = &issue.colophon;
- let tpl = ColophonChapter {
- title: "Colophon".into(),
- issue_number: issue.meta.issue_number,
- display_date: issue.meta.display_date.clone(),
- generated_at: issue.meta.generated_at.to_string(),
- model: if colophon.model.is_empty() {
- "none (heuristic selection)".into()
- } else {
- colophon.model.clone()
- },
- entries_fetched: colophon.entries_fetched,
- feeds_seen: colophon.feeds_seen,
- candidates: colophon.candidates,
- article_count: issue.meta.article_count,
- section_count: issue.meta.section_count,
- total_words: issue.meta.total_words,
- reading_line: reading_line(issue.meta.reading_minutes),
- cost_usd: format!("${:.4}", colophon.cost_usd),
- generator_version: if colophon.generator_version.is_empty() {
- format!("daily-epub {}", env!("CARGO_PKG_VERSION"))
- } else {
- colophon.generator_version.clone()
- },
- };
- Ok(Chapter {
- id: "colophon".into(),
- href: "colophon.xhtml".into(),
- title: "Colophon".into(),
- xhtml: tpl.render()?,
- toc_level: 1,
- })
-}
-
-fn render_cover_page(issue: &Issue, edition: Edition) -> Result {
- let tpl = CoverPage {
- title: issue.meta.title_for(edition),
- alt: format!(
- "The Daily EPUB, {} \u{2014} No. {}",
- issue.meta.display_date, issue.meta.issue_number
- ),
- cover_href: cover_href(edition),
- };
- Ok(Chapter {
- id: "cover".into(),
- href: "cover.xhtml".into(),
- title: "Cover".into(),
- xhtml: tpl.render()?,
- toc_level: 1,
- })
-}
-
-/// Render every chapter for an edition, in issue order (§3.10).
+/// Render every chapter of one edition, in issue order (§3.10).
pub fn render_all(
issue: &Issue,
edition: Edition,
@@ -993,376 +255,10 @@ pub fn assemble(
Ok(out)
}
-/// A synthetic, fully offline [`Issue`] used by the unit tests here and by the
-/// integration tests in `tests/` (which can only see the public API).
-pub mod fixtures {
- use std::collections::BTreeMap;
-
- use jiff::Timestamp;
-
- use crate::types::*;
-
- pub fn timestamp() -> Timestamp {
- "2026-08-15T05:30:00Z".parse().expect("fixed timestamp")
- }
-
- pub fn article(id: ArticleId, entry_id: EntryId, title: &str) -> Article {
- Article {
- id,
- canonical_url: format!("https://example.com/{entry_id}"),
- title: title.to_string(),
- best_entry_id: entry_id,
- content_html: format!(
- "Body of {title} with an image.
More words & things.
"
- ),
- word_count: 1200,
- excerpt_only: false,
- image_count: 1,
- sources: vec![SourceRef {
- entry_id,
- feed_id: 7,
- feed_title: "Example Feed".into(),
- category: Some("Tech".into()),
- kind: SourceKind::Feed,
- }],
- first_seen: timestamp(),
- url: format!("https://example.com/{entry_id}"),
- author: Some("A. Writer".into()),
- feed_id: 7,
- feed_title: "Example Feed".into(),
- category: Some("Tech".into()),
- published_at: Some(timestamp()),
- comments_url: None,
- image_urls: vec![format!("https://img.example/{entry_id}.png")],
- social: vec![SocialRef {
- article_id: id,
- source: SocialSource::Hn,
- item_id: Some("40100000".into()),
- score: 342,
- num_comments: 210,
- item_url: Some("https://news.ycombinator.com/item?id=40100000".into()),
- fetched_at: timestamp(),
- }],
- extract_method: ExtractMethod::Readability,
- }
- }
-
- pub fn discussion(article_id: ArticleId, entry_id: EntryId) -> Discussion {
- Discussion {
- article_id,
- chapter_id: format!("disc-{entry_id}"),
- threads: vec![CommentThread {
- source: SocialSource::Hn,
- item_url: "https://news.ycombinator.com/item?id=40100000".into(),
- total_comments: 210,
- comments: vec![Comment {
- author: "alice".into(),
- points: Some(61),
- text_html: "The write path is the interesting part.
".into(),
- depth: 0,
- children: vec![Comment {
- author: "bob".into(),
- points: Some(24),
- text_html: "Agreed.
".into(),
- depth: 1,
- children: vec![],
- }],
- }],
- }],
- }
- }
-
- /// A synthetic two-article issue, one of them carrying a discussion.
- pub fn issue() -> Issue {
- let lead = Pick {
- article: article(1, 1001, "The Lead Story"),
- section: "Top Stories".into(),
- position: 0,
- is_lead: true,
- summary: Some("What it argues, and why it is worth the time.".into()),
- llm: None,
- discussion: Some(discussion(1, 1001)),
- };
- let second = Pick {
- article: article(2, 1002, "A Niche Delight & Other Tales"),
- section: "Niche Corner".into(),
- position: 0,
- is_lead: false,
- summary: None,
- llm: None,
- discussion: None,
- };
- let mut section_intros = BTreeMap::new();
- section_intros.insert("Top Stories".to_string(), "The day in brief.".to_string());
- let mut summaries = BTreeMap::new();
- summaries.insert(2, "A short abstract for the second piece.".to_string());
-
- Issue {
- meta: IssueMeta {
- date: "2026-08-15".parse().expect("fixed date"),
- issue_number: 42,
- generated_at: timestamp(),
- display_date: "Friday, August 15, 2026".into(),
- article_count: 2,
- section_count: 2,
- total_words: 2400,
- reading_minutes: 11,
- },
- lineup: Lineup {
- date: "2026-08-15".parse().expect("fixed date"),
- picks: vec![lead, second],
- section_order: vec!["Top Stories".into(), "Niche Corner".into()],
- },
- editorial: Editorial {
- front_page_html: "Two stories today, both worth your coffee.
".into(),
- section_intros,
- summaries,
- },
- world_briefing: Some(WorldBriefing {
- date: "2026-08-15".parse().expect("fixed date"),
- source_url: "https://en.wikipedia.org/wiki/Portal:Current_events/2026_August_15"
- .into(),
- overview: Some("A concise view of the day.".into()),
- sections: vec![WorldBriefingSection {
- title: "Top Stories".into(),
- events: vec![WorldEvent {
- id: "s1-e1".into(),
- source_text: "Something happened somewhere.".into(),
- links: vec![],
- children: vec![],
- summary: Some("The event in context.".into()),
- }],
- }],
- }),
- colophon: Colophon {
- model: "deepseek-v4-flash".into(),
- entries_fetched: 431,
- feeds_seen: 92,
- candidates: 120,
- cost_usd: 0.0731,
- generator_version: "daily-epub 0.1.0".into(),
- },
- }
- }
-}
-
#[cfg(test)]
mod tests {
use super::*;
- use fixtures::issue;
- use hmac::{Hmac, KeyInit};
- use sha2::Sha256;
-
- #[test]
- fn rating_token_matches_the_spec_vector() {
- let date: Date = "2026-08-15".parse().unwrap();
- assert_eq!(rating_message(date, 1234, Vote::Up), "2026-08-15/1234/up");
- // hex(hmac_sha256("test-secret", "2026-08-15/1234/up"))[..16]
- let token = rating_token("test-secret", date, 1234, Vote::Up);
- assert_eq!(token.len(), TOKEN_LEN);
- assert!(token.chars().all(|c| c.is_ascii_hexdigit()));
-
- // Independently computed reference value.
- use hmac::Mac;
- let mut mac = Hmac::::new_from_slice(b"test-secret").unwrap();
- mac.update(b"2026-08-15/1234/up");
- let expected: String = hex::encode(mac.finalize().into_bytes())
- .chars()
- .take(16)
- .collect();
- assert_eq!(token, expected);
-
- // Different vote, article and secret all change the token.
- assert_ne!(token, rating_token("test-secret", date, 1234, Vote::Down));
- assert_ne!(token, rating_token("test-secret", date, 1235, Vote::Up));
- assert_ne!(token, rating_token("other-secret", date, 1234, Vote::Up));
- }
-
- /// The EPUB signs the links and `server.rs` verifies them: one formula, or no
- /// rating ever lands. This is the vector `server::tests` pins from its side
- /// (`VECTOR_SECRET` / `VECTOR_TOKEN_UP`) — change one, change both (§3.9).
- #[test]
- fn epub_and_server_share_one_token_vector() {
- let date: Date = "2026-08-15".parse().unwrap();
- assert_eq!(
- rating_token("test-secret", date, 42, Vote::Up),
- "3b314cf7e6d8f50f"
- );
- }
-
- #[test]
- fn rating_url_has_the_spec_shape() {
- let date: Date = "2026-08-15".parse().unwrap();
- let url = rating_url("https://daily.hallada.net/", "s3cret", date, 99, Vote::Down);
- let token = rating_token("s3cret", date, 99, Vote::Down);
- assert_eq!(
- url,
- format!("https://daily.hallada.net/r/2026-08-15/99/down?t={token}")
- );
- }
-
- #[test]
- fn social_line_matches_the_spec_example() {
- let refs = vec![SocialRef {
- article_id: 1,
- source: crate::types::SocialSource::Hn,
- item_id: None,
- score: 342,
- num_comments: 210,
- item_url: None,
- fetched_at: fixtures::timestamp(),
- }];
- assert_eq!(
- social_line(&refs).as_deref(),
- Some("\u{25b2} 342 on HN \u{00b7} 210 comments")
- );
- assert!(social_line(&[]).is_none());
- }
-
- #[test]
- fn hrefs_are_deterministic() {
- let issue = issue();
- let pick = &issue.lineup.picks[0];
- assert_eq!(article_href(pick), "art-1001.xhtml");
- assert_eq!(discussion_href(pick), "disc-1001.xhtml");
- assert_eq!(
- section_href("Tech & Engineering"),
- "sec-tech-engineering.xhtml"
- );
- }
-
- fn assert_xml_ok(xhtml: &str) {
- // A crude well-formedness check: the document parses as XML only if every
- // tag is closed, so compare open/close counts for the elements we emit.
- assert!(xhtml.starts_with(""));
- assert!(xhtml.contains("xmlns=\"http://www.w3.org/1999/xhtml\""));
- assert!(xhtml.trim_end().ends_with("