//! Rendering one chapter of XHTML at a time (spec §3.10). //! //! Each function here turns part of an [`Issue`] into a [`Chapter`]: the front //! page, the index, section title pages, articles, discussions, the world //! briefing and the colophon. [`super::build::render_all`] puts them in order; //! [`super::build::assemble`] zips them. use askama::Template; use crate::comments; use crate::html::{text_escape, to_xhtml}; use crate::images; use crate::types::{ Article, BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote, WORLD_BRIEFING_SECTION, }; use crate::world; use super::EpubError; use super::build::Chapter; /// Characters of the hex HMAC kept in rating links (§3.9). pub const TOKEN_LEN: usize = crate::auth::TOKEN_LEN; // 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}; #[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, why: Option, understanding: Understanding, } 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, } struct RatingLinks { loved_url: String, good_url: String, not_for_me_url: String, slop_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, understanding: Understanding, why: 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 = "behind.xhtml", escape = "html")] struct BehindChapter { title: String, summary_line: String, admitted_line: String, learned_line: String, near_misses: Vec, models_line: String, } #[derive(Template)] #[template(path = "colophon.xhtml", escape = "html")] struct ColophonChapter { title: String, issue_number: i64, display_date: String, generated_at: String, bulk_model: String, editor_model: String, summaries_model: String, provider_costs: Vec, 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, } struct ProviderCostLine { provider: String, cost: String, } // --------------------------------------------------------------------------- // 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_); to_xhtml(&rewritten) } /// "~2h 15m read" for the colophon (§3.10). 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") } } /// "▲ 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 facet_label(token: &str) -> Option { let label = match token { "software_engineering" => "Software engineering", "ai_ml" => "AI & ML", "science_space" => "Science & space", "culture_arts" => "Culture & arts", "books_writing" => "Books & writing", "games" => "Games", "hardware" => "Hardware", "internet_web" => "Internet & web", "business_economics" => "Business & economics", "politics_policy" => "Politics & policy", "boston_new_england" => "Boston & New England", "outdoors_lifestyle" => "Outdoors & lifestyle", "history" => "History", "reported_news" => "Reported", "analysis_essay" => "Analysis", "how_to_technical" => "How-to", "first_hand_account" => "First person", "announcement_roundup" => "Announcement", "code_repository" => "Code", "documentation_reference" => "Documentation", "tool_or_product_page" => "Product page", "discussion_thread" => "Discussion", "paper_or_report" => "Paper", "interview_or_transcript" => "Interview", "video_or_podcast" => "Video or podcast", "fiction_or_humor" => "Fiction & humor", "other" => return None, unknown => return Some(unknown.replace('_', " ")), }; Some(label.to_string()) } #[derive(Clone, Debug, Default, Eq, PartialEq)] pub struct Understanding { pub kicker: Option, pub topics: Option, pub interests: Option, } /// Reader-facing assessment details, split so templates can give each part the /// same editorial hierarchy across web and EPUB surfaces. pub fn understanding(pick: &Pick) -> Understanding { let (kicker, topics) = pick.llm.as_ref().map_or((None, None), |deep| { let facets = &deep.facets; let kicker = [facets.topic_group.as_deref(), facets.format.as_deref()] .into_iter() .flatten() .filter_map(facet_label) .filter(|label| !label.is_empty()) .collect::>(); let kicker = (!kicker.is_empty()).then(|| kicker.join(" \u{00b7} ")); let topics = facets.specific_topics.as_ref().and_then(|topics| { let topics = topics .iter() .map(|topic| topic.trim()) .filter(|topic| !topic.is_empty()) .collect::>(); (!topics.is_empty()).then(|| topics.join(" \u{00b7} ")) }); (kicker, topics) }); let interests = (!pick.top_interests.is_empty()).then(|| pick.top_interests.join(" \u{00b7} ")); Understanding { kicker, topics, interests, } } 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()) } /// The Brief (§14.2) under the masthead, 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.

", text_escape(&issue.meta.stats_line()) ) } else { to_xhtml(&ammonia::clean(body)) }; let tpl = FrontPage { title: "The Brief".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: "The Brief".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 } fn source_line(article: &Article) -> String { match article.publication_label() { Some(publication) => format!("{} · {publication}", article.feed_title), None => article.feed_title.clone(), } } /// The index's meta lead: the author, when known, ahead of [`source_line`]. fn index_source_line(article: &Article) -> String { match article.author_label() { Some(author) => format!("{author} · {}", source_line(article)), None => source_line(article), } } /// "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: index_source_line(&pick.article), reading_minutes: pick.article.reading_minutes(), summary: summary_for(issue, pick).unwrap_or_default().to_string(), why: pick.why.clone(), understanding: understanding(pick), }) .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(), why: None, understanding: Understanding::default(), }], }); } 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: the name only (§14.2 removed the LLM intros). pub fn render_section_page(name: &str) -> Result { let tpl = SectionPage { title: name.to_string(), name: name.to_string(), }; 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![source_line(article)]; 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 { loved_url: rating_url(public_url, secret, issue.meta.date, article.id, Vote::Loved), good_url: rating_url(public_url, secret, issue.meta.date, article.id, Vote::Good), not_for_me_url: rating_url( public_url, secret, issue.meta.date, article.id, Vote::NotForMe, ), slop_url: rating_url(public_url, secret, issue.meta.date, article.id, Vote::Slop), }), _ => 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), understanding: understanding(pick), why: pick.why.clone(), 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, })) } /// "1,465" — thousands separators for the counts in Behind the paper. fn thousands(n: i64) -> String { let digits = n.abs().to_string(); let mut out = String::with_capacity(digits.len() + digits.len() / 3); for (i, c) in digits.chars().enumerate() { if i > 0 && (digits.len() - i).is_multiple_of(3) { out.push(','); } out.push(c); } if n < 0 { format!("-{out}") } else { out } } /// `Considered 412 articles from 1,465 feeds · 398 eligible · …` (§15.1). pub fn behind_summary_line(b: &BehindThePaper) -> String { format!( "Considered {} articles from {} feeds \u{00b7} {} eligible \u{00b7} {} triaged \u{00b7} {} read closely \u{00b7} {} shortlisted \u{00b7} {} selected.", thousands(b.considered), thousands(b.feeds_seen), thousands(b.eligible), thousands(b.triaged), thousands(b.read_closely), thousands(b.shortlisted), thousands(b.selected), ) } /// `Admitted via: triage 60 · interests 20 · your ratings 12 · exploration 5 · blend 23.` pub fn behind_admitted_line(b: &BehindThePaper) -> String { let count = |name: &str| b.admitted_by.get(name).copied().unwrap_or(0); let mut parts = vec![ format!("triage {}", count("triage")), format!("interests {}", count("interest")), format!("your ratings {}", count("knn")), format!("exploration {}", count("exploration")), format!("blend {}", count("blend")), ]; if count("auto_include") > 0 { parts.push(format!("always-include {}", count("auto_include"))); } format!("Admitted via: {}.", parts.join(" \u{00b7} ")) } /// `Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off.` pub fn behind_learned_line(b: &BehindThePaper) -> String { let percent = |gate: f64| (gate.clamp(0.0, 1.0) * 100.0).round() as i64; let neighbour = if b.knn_gate > 0.0 { format!("neighbour signal at {}%", percent(b.knn_gate)) } else { "neighbour signal off".to_string() }; let feed = if b.feed_gate > 0.0 { format!("feed affinity at {}%", percent(b.feed_gate)) } else { "feed affinity off".to_string() }; format!( "Learned signals: {} rated articles with embeddings ({neighbour}); {feed}.", thousands(b.rated_with_embeddings) ) } /// ` — <feed> · quality 8.0 · fit 6.5 · shortlisted, not selected`. pub fn behind_near_miss_line(miss: &NearMiss) -> String { let mut parts = vec![format!("{} \u{2014} {}", miss.title, miss.feed_title)]; if let Some(quality) = miss.quality { parts.push(format!("quality {quality:.1}")); } if let Some(fit) = miss.fit { parts.push(format!("fit {fit:.1}")); } parts.push(match &miss.reason { Some(reason) => format!("{}, {}", miss.stage, reason.replace('_', " ")), None => miss.stage.clone(), }); parts.join(" \u{00b7} ") } /// `Models: triage and assessment … · editor and summaries … · embeddings …. /// Cost $0.81. Generation 23 min.` pub fn behind_models_line(b: &BehindThePaper) -> String { let editorial = if b.models.summaries == b.models.editor { format!("editor and summaries {}", b.models.editor) } else { format!( "editor {} \u{00b7} summaries {}", b.models.editor, b.models.summaries ) }; let generation = if b.generation_secs < 60 { format!("{} s", b.generation_secs.max(0)) } else { format!("{} min", (b.generation_secs as f64 / 60.0).round() as i64) }; format!( "Models: triage and assessment {} \u{00b7} {editorial} \u{00b7} embeddings {}. Cost ${:.2}. Generation {generation}.", b.models.bulk, b.embedding_model, b.cost_usd ) } /// "Behind the paper": the run's counts, admission mix, learned-signal state, /// near misses and models (§15.1). Same text in both editions; no links. pub fn render_behind_the_paper(issue: &Issue) -> Result<Chapter, EpubError> { let behind = &issue.behind; let tpl = BehindChapter { title: "Behind the paper".into(), summary_line: behind_summary_line(behind), admitted_line: behind_admitted_line(behind), learned_line: behind_learned_line(behind), near_misses: behind .near_misses .iter() .map(behind_near_miss_line) .collect(), models_line: behind_models_line(behind), }; Ok(Chapter { id: "behind".into(), href: "behind.xhtml".into(), title: "Behind the paper".into(), xhtml: tpl.render()?, toc_level: 1, }) } /// Colophon: generation timestamp, models used, token cost, feed counts (§3.10). pub fn render_colophon(issue: &Issue) -> Result<Chapter, EpubError> { let colophon = &issue.colophon; let provider_costs = colophon .provider_costs .iter() .map(|(provider, cost)| ProviderCostLine { provider: provider.clone(), cost: format!("${cost:.4}"), }) .collect(); 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(), bulk_model: colophon.models.bulk.clone(), editor_model: colophon.models.editor.clone(), summaries_model: colophon.models.summaries.clone(), provider_costs, 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, }) } #[cfg(test)] mod tests { use super::*; use crate::epub::fixtures::{self, assert_xml_ok, issue}; use crate::types::{SocialRef, Vote}; use hmac::{Hmac, KeyInit}; use jiff::civil::Date; 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::Loved), "2026-08-15/1234/loved" ); // hex(hmac_sha256("test-secret", "2026-08-15/1234/loved"))[..16] let token = rating_token("test-secret", date, 1234, Vote::Loved); 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::<Sha256>::new_from_slice(b"test-secret").unwrap(); mac.update(b"2026-08-15/1234/loved"); 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::NotForMe) ); assert_ne!(token, rating_token("test-secret", date, 1235, Vote::Loved)); assert_ne!(token, rating_token("other-secret", date, 1234, Vote::Loved)); } /// 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(); let token = rating_token("test-secret", date, 42, Vote::Loved); assert_eq!(token, "cece96767d6c5f8a"); assert!(crate::server::verify_token( "test-secret", date, 42, Vote::Loved, &token )); } #[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::NotForMe, ); let token = rating_token("s3cret", date, 99, Vote::NotForMe); 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 understanding_includes_kicker_topics_and_interests() { let issue = issue(); assert_eq!( understanding(&issue.lineup.picks[0]), Understanding { kicker: Some("Software engineering · Analysis".into()), topics: Some("copy-on-write · ZFS".into()), interests: Some("Filesystems · Rust".into()), } ); } #[test] fn understanding_is_empty_without_a_deep_read_or_interests() { let issue = issue(); assert_eq!( understanding(&issue.lineup.picks[1]), Understanding::default() ); } #[test] fn understanding_can_contain_only_interests() { let issue = issue(); let mut pick = issue.lineup.picks[1].clone(); pick.top_interests = vec!["Rust".into()]; assert_eq!( understanding(&pick), Understanding { interests: Some("Rust".into()), ..Understanding::default() } ); } #[test] fn understanding_skips_other_topic_group() { let issue = issue(); let mut pick = issue.lineup.picks[0].clone(); pick.llm.as_mut().unwrap().facets = crate::types::Facets { topic_group: Some("other".into()), ..crate::types::Facets::default() }; pick.top_interests.clear(); assert_eq!(understanding(&pick), Understanding::default()); } #[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" ); } #[test] fn front_page_renders_stats_and_editorial() { let issue = issue(); let chapter = render_front_page(&issue).unwrap(); assert_eq!(chapter.href, "front.xhtml"); assert!(chapter.xhtml.contains("The Brief")); assert!(chapter.xhtml.contains("2 articles")); assert!(chapter.xhtml.contains("both worth your coffee")); assert!(chapter.xhtml.contains("No. 42")); assert_xml_ok(&chapter.xhtml); } #[test] fn in_this_issue_links_every_pick() { let issue = issue(); let chapter = render_in_this_issue(&issue).unwrap(); assert!(chapter.xhtml.contains("href=\"art-1001.xhtml\"")); assert!(chapter.xhtml.contains("href=\"art-1002.xhtml\"")); assert!(chapter.xhtml.contains("Example Feed")); assert!(chapter.xhtml.contains("6 min read")); assert!(chapter.xhtml.contains("What it argues")); assert!(chapter.xhtml.contains("A short abstract")); // The lead's rubric and matches are on the index; the second pick has neither. assert_eq!(chapter.xhtml.matches("class=\"rubric\"").count(), 1); assert!(chapter.xhtml.contains("Software engineering · Analysis")); assert!(chapter.xhtml.contains("Matches: Filesystems · Rust")); // Titles are escaped (askama emits numeric references), never injected raw. assert!(chapter.xhtml.contains("A Niche Delight & Other Tales")); assert_xml_ok(&chapter.xhtml); } #[test] fn article_sources_include_distinct_publications_without_repeating_the_feed() { let mut issue = issue(); issue.lineup.picks[0].article.publication = Some("Example Journal".into()); issue.lineup.picks[1].article.publication = Some("Example Feed".into()); issue.lineup.picks[1].article.author = Some("example feed".into()); let index = render_in_this_issue(&issue).unwrap(); assert!( index .xhtml .contains("A. Writer · Example Feed · Example Journal · 6 min read") ); assert!(!index.xhtml.contains("Example Feed · Example Feed")); assert!(!index.xhtml.contains("example feed · Example Feed")); let article = render_article( &issue, &issue.lineup.picks[0], &[], Edition::Standard, "https://daily.hallada.net", None, ) .unwrap(); assert!(article.xhtml.contains("Example Feed · Example Journal")); let matching = render_article( &issue, &issue.lineup.picks[1], &[], Edition::Standard, "https://daily.hallada.net", None, ) .unwrap(); assert!(matching.xhtml.contains("Example Feed")); assert!(!matching.xhtml.contains("Example Feed · Example Feed")); } #[test] fn article_chapter_has_header_body_and_footer() { let issue = issue(); let pick = &issue.lineup.picks[0]; let chapter = render_article( &issue, pick, &[], Edition::Standard, "https://daily.hallada.net", Some("s3cret"), ) .unwrap(); assert_eq!(chapter.id, "art-1001"); assert_eq!(chapter.toc_level, 2); assert!(chapter.xhtml.contains("By A. Writer")); assert!(chapter.xhtml.contains("Example Feed")); assert!(chapter.xhtml.contains("6 min read")); assert!(chapter.xhtml.contains("\u{25b2} 342 on HN")); assert!(chapter.xhtml.contains("/r/2026-08-15/1/loved?t=")); assert!(chapter.xhtml.contains("/r/2026-08-15/1/good?t=")); assert!(chapter.xhtml.contains("/r/2026-08-15/1/down?t=")); assert!(chapter.xhtml.contains("Read online")); assert!(chapter.xhtml.contains("href=\"disc-1001.xhtml\"")); assert!(chapter.xhtml.contains("class=\"rubric\"")); assert!(chapter.xhtml.contains("Software engineering · Analysis")); assert!(chapter.xhtml.contains("copy-on-write · ZFS")); assert!(chapter.xhtml.contains("Matches: Filesystems · Rust")); let rubric_position = chapter.xhtml.find("class=\"rubric\"").unwrap(); let summary_position = chapter.xhtml.find("class=\"summary\"").unwrap(); let social_position = chapter.xhtml.find("class=\"social\"").unwrap(); assert!(rubric_position < summary_position); assert!(summary_position < social_position); let footer = chapter .xhtml .split_once("<div class=\"article-footer\">") .unwrap() .1; assert!(!footer.contains("class=\"rubric\"")); assert!(!footer.contains("Matches:")); let second = render_article( &issue, &issue.lineup.picks[1], &[], Edition::Standard, "https://daily.hallada.net", Some("s3cret"), ) .unwrap(); assert!(!second.xhtml.contains("class=\"rubric\"")); // The un-downloaded image degrades to a placeholder. assert!( chapter .xhtml .contains("[image: A chart of the daily figures]") ); assert_xml_ok(&chapter.xhtml); } #[test] fn x4_articles_omit_rating_links() { let issue = issue(); let chapter = render_article( &issue, &issue.lineup.picks[0], &[], Edition::X4, "https://daily.hallada.net", Some("s3cret"), ) .unwrap(); assert!(!chapter.xhtml.contains("/r/2026-08-15/")); assert!(chapter.xhtml.contains("Read online")); } #[test] fn discussion_chapter_nests_under_its_article() { let issue = issue(); let chapter = render_discussion(&issue.lineup.picks[0]).unwrap().unwrap(); assert_eq!(chapter.id, "disc-1001"); assert_eq!(chapter.toc_level, 3); assert!( chapter .title .starts_with("\u{1f4ac} Discussion: The Lead Story") ); assert!(chapter.xhtml.contains("alice")); assert!(chapter.xhtml.contains("blockquote class=\"comment\"")); assert!(chapter.xhtml.contains("href=\"art-1001.xhtml\"")); assert_xml_ok(&chapter.xhtml); assert!(render_discussion(&issue.lineup.picks[1]).unwrap().is_none()); } #[test] fn behind_the_paper_lines_follow_the_plan_shape() { let issue = issue(); let behind = &issue.behind; assert_eq!( behind_summary_line(behind), "Considered 412 articles from 1,465 feeds \u{00b7} 398 eligible \u{00b7} 398 triaged \u{00b7} 120 read closely \u{00b7} 60 shortlisted \u{00b7} 2 selected." ); assert_eq!( behind_admitted_line(behind), "Admitted via: triage 60 \u{00b7} interests 20 \u{00b7} your ratings 12 \u{00b7} exploration 5 \u{00b7} blend 23." ); assert_eq!( behind_learned_line(behind), "Learned signals: 14 rated articles with embeddings (neighbour signal at 35%); feed affinity off." ); assert_eq!( behind_near_miss_line(&behind.near_misses[0]), "The One That Got Away \u{2014} Example Feed \u{00b7} quality 8.0 \u{00b7} fit 6.5 \u{00b7} shortlisted, not selected" ); assert_eq!( behind_near_miss_line(&behind.near_misses[1]), "Never Read Closely \u{2014} Other Feed \u{00b7} triaged, not admitted" ); assert_eq!( behind_models_line(behind), "Models: triage and assessment deepseek-v4-flash \u{00b7} editor and summaries claude-opus-5 \u{00b7} embeddings voyage-4-lite. Cost $0.81. Generation 23 min." ); let chapter = render_behind_the_paper(&issue).unwrap(); assert_eq!(chapter.id, "behind"); assert_eq!(chapter.href, "behind.xhtml"); assert!(chapter.xhtml.contains("Behind the paper")); assert!(chapter.xhtml.contains("1,465 feeds")); assert!(chapter.xhtml.contains("The One That Got Away")); assert!(!chapter.xhtml.contains("<a "), "no links in the chapter"); assert_xml_ok(&chapter.xhtml); // Auto-includes are named only when there were any; a short run is in // seconds; a differing summaries model is listed separately. let mut short = behind.clone(); short.admitted_by.insert("auto_include".into(), 2); short.generation_secs = 48; short.feed_gate = 0.6; short.models.summaries = "deepseek-v4-flash".into(); short.near_misses.clear(); assert!(behind_admitted_line(&short).ends_with("blend 23 \u{00b7} always-include 2.")); assert!(behind_learned_line(&short).ends_with("feed affinity at 60%.")); assert!( behind_models_line(&short) .contains("editor claude-opus-5 \u{00b7} summaries deepseek-v4-flash") ); assert!(behind_models_line(&short).ends_with("Generation 48 s.")); let mut issue = issue; issue.behind = short; let chapter = render_behind_the_paper(&issue).unwrap(); assert!(chapter.xhtml.contains("None recorded")); assert_xml_ok(&chapter.xhtml); assert_eq!(thousands(0), "0"); assert_eq!(thousands(999), "999"); assert_eq!(thousands(1_000), "1,000"); assert_eq!(thousands(1_234_567), "1,234,567"); } #[test] fn world_and_colophon_chapters_render() { let issue = issue(); let world = render_world_briefing(&issue).unwrap().unwrap(); assert!(world.xhtml.contains("Something happened somewhere")); assert!(world.xhtml.contains("CC BY-SA")); assert_xml_ok(&world.xhtml); let colophon = render_colophon(&issue).unwrap(); assert!(colophon.xhtml.contains("deepseek-v4-flash")); assert!(colophon.xhtml.contains("431")); assert!(colophon.xhtml.contains("$0.0731")); assert_xml_ok(&colophon.xhtml); } }