Compare commits

..
5 Commits
Author SHA1 Message Date
thalladaandClaude Fable 5.1 f2a44507d4 Show the publication after the feed name, and link feeds to Miniflux for admins
Readers see "Feed · Publication" on the issue, article, public and RSS
pages and in the EPUB index and chapter headers; the publication is the
page's site name, else its domain, and is dropped when it would repeat
the feed's title. Admins get the feed name linked to its Miniflux page.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
2026-09-09 06:01:29 +00:00
thallada 87f3a5dbf1 Merge branch 'miniflux-feed-link' into publication 2026-09-09 05:48:46 +00:00
thalladaandClaude Fable 5.1 f71a4c2799 Keep the publication an article's page declares
Readability already reads og:site_name and JSON-LD publisher.name; carry
that through Page and Extracted and store it in articles.publication, the
same way the page byline is kept. `publication_label` is what the readers
will see after the feed name: the site name, else the domain, and nothing
when it would only repeat the feed's own title.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
2026-09-09 05:48:46 +00:00
thalladaandClaude Fable 5.1 8a8d5d1f16 Add a browser-facing Miniflux URL and link feeds to their entries page
The pipeline talks to Miniflux on loopback, so links meant for a person
need their own base. `[miniflux].public_url` defaults to base_url, and
`feed_url` builds the web UI's `/feed/{id}/entries` page, which the feeds
dashboard now uses instead of the non-existent `/feeds/{id}` route.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
2026-09-09 05:45:14 +00:00
thalladaandClaude Fable 5.1 c8df55e91a Keep the contents sidebar where it was across page loads
Every navigation is a full document load, so the sidebar came back at
scrollTop 0 and revealCurrent() smooth-scrolled the current chapter into
view: a jump on every click even though the link was already in view.

app.js saves the panel's scrollTop to sessionStorage (large screens only,
keyed by the issue) on link clicks and pagehide; theme.js, which already
runs before first paint, restores it as soon as the parser has closed the
nav. The load-time reveals (initial and the first scroll-tracking paint on
the full issue page) are now instant; opening the mobile panel and
tracking while scrolling stay smooth.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VXYGBPoHZSDSfE5WcJ9bvj
2026-09-09 05:27:02 +00:00
30 changed files with 414 additions and 65 deletions

No files matched your search

+2
View File
@@ -30,6 +30,8 @@ interests_opml = "data/scour-interests.opml"
[miniflux] [miniflux]
base_url = "http://127.0.0.1:8082" base_url = "http://127.0.0.1:8082"
# Browser-facing Miniflux web UI URL; defaults to base_url.
# public_url = "https://miniflux.example.com"
# api_key via DAILY_EPUB_MINIFLUX__API_KEY env # api_key via DAILY_EPUB_MINIFLUX__API_KEY env
page_limit = 250 page_limit = 250
+1
View File
@@ -358,6 +358,7 @@ fn pick_for(target: &Target, content_html: String) -> Pick {
first_seen: "2026-08-15T05:30:00Z".parse().unwrap(), first_seen: "2026-08-15T05:30:00Z".parse().unwrap(),
url: target.url.clone(), url: target.url.clone(),
author: None, author: None,
publication: None,
feed_id: 1, feed_id: 1,
feed_title: host(&target.url), feed_title: host(&target.url),
category: None, category: None,
+3
View File
@@ -0,0 +1,3 @@
-- Persist page-extracted publication names independently of the best Miniflux entry.
ALTER TABLE articles ADD COLUMN publication TEXT;
+60
View File
@@ -122,6 +122,9 @@ impl Default for Config {
#[serde(deny_unknown_fields, default)] #[serde(deny_unknown_fields, default)]
pub struct MinifluxConfig { pub struct MinifluxConfig {
pub base_url: String, pub base_url: String,
/// Where a browser reaches the Miniflux web UI, for links in the dashboard;
/// defaults to `base_url`.
pub public_url: Option<String>,
/// `X-Auth-Token`; supply via `DAILY_EPUB_MINIFLUX__API_KEY`. /// `X-Auth-Token`; supply via `DAILY_EPUB_MINIFLUX__API_KEY`.
pub api_key: Option<String>, pub api_key: Option<String>,
/// Page size for `GET /v1/entries` (Miniflux caps this at 250). /// Page size for `GET /v1/entries` (Miniflux caps this at 250).
@@ -132,12 +135,29 @@ impl Default for MinifluxConfig {
fn default() -> Self { fn default() -> Self {
Self { Self {
base_url: "http://127.0.0.1:8082".into(), base_url: "http://127.0.0.1:8082".into(),
public_url: None,
api_key: None, api_key: None,
page_limit: 250, page_limit: 250,
} }
} }
} }
impl MinifluxConfig {
/// Browser-facing base URL without trailing slashes.
pub fn public_url(&self) -> &str {
self.public_url
.as_deref()
.filter(|url| !url.trim().is_empty())
.unwrap_or(&self.base_url)
.trim_end_matches('/')
}
/// Browser-facing URL for one feed's entries.
pub fn feed_url(&self, feed_id: i64) -> String {
format!("{}/feed/{feed_id}/entries", self.public_url())
}
}
/// `[llm]` — the role assignments and the role-level knobs (§4). /// `[llm]` — the role assignments and the role-level knobs (§4).
/// ///
/// `bulk` runs triage, deep assessment and every fallback; `editor` runs the /// `bulk` runs triage, deep assessment and every fallback; `editor` runs the
@@ -1473,6 +1493,46 @@ mod tests {
c.validate().unwrap(); c.validate().unwrap();
} }
#[test]
fn miniflux_public_url_defaults_to_base_url() {
let miniflux = MinifluxConfig {
base_url: "http://127.0.0.1:8082/".into(),
..MinifluxConfig::default()
};
assert_eq!(miniflux.public_url(), "http://127.0.0.1:8082");
let blank = MinifluxConfig {
base_url: "https://api.example.com/".into(),
public_url: Some(" ".into()),
..MinifluxConfig::default()
};
assert_eq!(blank.public_url(), "https://api.example.com");
}
#[test]
fn explicit_miniflux_public_url_wins_and_trims_trailing_slashes() {
let miniflux = MinifluxConfig {
public_url: Some("https://miniflux.example.com///".into()),
..MinifluxConfig::default()
};
assert_eq!(miniflux.public_url(), "https://miniflux.example.com");
}
#[test]
fn miniflux_feed_url_points_to_the_web_ui_entries_route() {
let miniflux = MinifluxConfig {
public_url: Some("https://miniflux.example.com/".into()),
..MinifluxConfig::default()
};
assert_eq!(
miniflux.feed_url(77),
"https://miniflux.example.com/feed/77/entries"
);
}
#[test] #[test]
// `Jail::expect_with` dictates the closure's `figment::Error` return type. // `Jail::expect_with` dictates the closure's `figment::Error` return type.
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
+1
View File
@@ -1089,6 +1089,7 @@ mod tests {
first_seen: "2026-08-15T00:00:00Z".parse().unwrap(), first_seen: "2026-08-15T00:00:00Z".parse().unwrap(),
url: format!("https://example.com/{id}"), url: format!("https://example.com/{id}"),
author: Some("Secret Author".into()), author: Some("Secret Author".into()),
publication: None,
feed_id: 9, feed_id: 9,
feed_title: "Secret Feed".into(), feed_title: "Secret Feed".into(),
category: None, category: None,
+1
View File
@@ -154,6 +154,7 @@ pub(crate) mod tests {
first_seen: ts(), first_seen: ts(),
url: format!("https://example.com/{id}"), url: format!("https://example.com/{id}"),
author: Some("A. Writer".into()), author: Some("A. Writer".into()),
publication: None,
feed_id: 7, feed_id: 7,
feed_title: "Some Blog".into(), feed_title: "Some Blog".into(),
category: Some("Tech".into()), category: Some("Tech".into()),
+1
View File
@@ -763,6 +763,7 @@ mod tests {
first_seen: "2026-08-15T00:00:00Z".parse().unwrap(), first_seen: "2026-08-15T00:00:00Z".parse().unwrap(),
url: format!("https://example.com/{id}"), url: format!("https://example.com/{id}"),
author: None, author: None,
publication: None,
feed_id: feeds.first().copied().unwrap_or(0), feed_id: feeds.first().copied().unwrap_or(0),
feed_title: String::new(), feed_title: String::new(),
category: None, category: None,
+15 -5
View File
@@ -292,16 +292,18 @@ impl Db {
/// Upsert a deduped cluster by canonical URL; returns its `articles.id`. /// Upsert a deduped cluster by canonical URL; returns its `articles.id`.
/// ///
/// Most denormalized fields on [`Article`] come from the joined `entries` /// Most denormalized fields on [`Article`] come from the joined `entries`
/// row when loading; the extracted author is stored on `articles`. /// row when loading; extracted page metadata is stored on `articles`.
pub async fn upsert_article(&self, article: &Article) -> Result<ArticleId> { pub async fn upsert_article(&self, article: &Article) -> Result<ArticleId> {
let sources = serde_json::to_string(&article.sources).unwrap_or_else(|_| "[]".into()); let sources = serde_json::to_string(&article.sources).unwrap_or_else(|_| "[]".into());
let row = sqlx::query( let row = sqlx::query(
"INSERT INTO articles (canonical_url, title, author, best_entry_id, content_html, "INSERT INTO articles (canonical_url, title, author, publication, best_entry_id,
word_count, excerpt_only, image_count, sources_json, first_seen) content_html, word_count, excerpt_only, image_count,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) sources_json, first_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(canonical_url) DO UPDATE SET ON CONFLICT(canonical_url) DO UPDATE SET
title = excluded.title, title = excluded.title,
author = excluded.author, author = excluded.author,
publication = excluded.publication,
best_entry_id = excluded.best_entry_id, best_entry_id = excluded.best_entry_id,
content_html = excluded.content_html, content_html = excluded.content_html,
word_count = excluded.word_count, word_count = excluded.word_count,
@@ -313,6 +315,7 @@ impl Db {
.bind(&article.canonical_url) .bind(&article.canonical_url)
.bind(&article.title) .bind(&article.title)
.bind(&article.author) .bind(&article.author)
.bind(&article.publication)
.bind((article.best_entry_id != 0).then_some(article.best_entry_id)) .bind((article.best_entry_id != 0).then_some(article.best_entry_id))
.bind(&article.content_html) .bind(&article.content_html)
.bind(article.word_count) .bind(article.word_count)
@@ -889,7 +892,8 @@ macro_rules! article_select {
a.word_count AS word_count, a.excerpt_only AS excerpt_only, a.word_count AS word_count, a.excerpt_only AS excerpt_only,
a.image_count AS image_count, a.sources_json AS sources_json, a.image_count AS image_count, a.sources_json AS sources_json,
a.first_seen AS first_seen, a.first_seen AS first_seen,
e.url AS entry_url, COALESCE(a.author, e.author) AS author, e.feed_id AS feed_id, e.url AS entry_url, COALESCE(a.author, e.author) AS author,
a.publication AS publication, e.feed_id AS feed_id,
e.feed_title AS feed_title, e.category AS category, e.feed_title AS feed_title, e.category AS category,
e.published_at AS published_at, e.comments_url AS comments_url e.published_at AS published_at, e.comments_url AS comments_url
FROM articles a LEFT JOIN entries e ON e.id = a.best_entry_id FROM articles a LEFT JOIN entries e ON e.id = a.best_entry_id
@@ -967,6 +971,7 @@ fn article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Article> {
.get::<Option<String>, _>("entry_url") .get::<Option<String>, _>("entry_url")
.unwrap_or_else(|| row.get("canonical_url")), .unwrap_or_else(|| row.get("canonical_url")),
author: row.get("author"), author: row.get("author"),
publication: row.get("publication"),
feed_id: row.get::<Option<i64>, _>("feed_id").unwrap_or(0), feed_id: row.get::<Option<i64>, _>("feed_id").unwrap_or(0),
feed_title: row feed_title: row
.get::<Option<String>, _>("feed_title") .get::<Option<String>, _>("feed_title")
@@ -1193,6 +1198,7 @@ mod tests {
first_seen: ts("2026-08-15T05:30:00Z"), first_seen: ts("2026-08-15T05:30:00Z"),
url: "https://example.com/1".into(), url: "https://example.com/1".into(),
author: Some("Page Writer".into()), author: Some("Page Writer".into()),
publication: Some("Example Gazette".into()),
feed_id: 7, feed_id: 7,
feed_title: "Hacker News".into(), feed_title: "Hacker News".into(),
category: None, category: None,
@@ -1224,6 +1230,7 @@ mod tests {
assert_eq!(loaded.social[0].score, 342); assert_eq!(loaded.social[0].score, 342);
assert_eq!(loaded.feed_title, "Hacker News"); assert_eq!(loaded.feed_title, "Hacker News");
assert_eq!(loaded.author.as_deref(), Some("Page Writer")); assert_eq!(loaded.author.as_deref(), Some("Page Writer"));
assert_eq!(loaded.publication.as_deref(), Some("Example Gazette"));
// The batched lookup agrees with the single one and skips unknown ids. // The batched lookup agrees with the single one and skips unknown ids.
let batch = db.get_articles(&[id, 9_999]).await.unwrap(); let batch = db.get_articles(&[id, 9_999]).await.unwrap();
assert_eq!(batch.len(), 1); assert_eq!(batch.len(), 1);
@@ -1261,6 +1268,7 @@ mod tests {
first_seen: ts("2026-09-06T00:00:00Z"), first_seen: ts("2026-09-06T00:00:00Z"),
url: "https://example.com/imported".into(), url: "https://example.com/imported".into(),
author: None, author: None,
publication: None,
feed_id: 0, feed_id: 0,
feed_title: "Imported".into(), feed_title: "Imported".into(),
category: None, category: None,
@@ -1479,6 +1487,7 @@ mod tests {
first_seen: ts("2026-08-15T05:30:00Z"), first_seen: ts("2026-08-15T05:30:00Z"),
url: "https://example.com/1".into(), url: "https://example.com/1".into(),
author: None, author: None,
publication: None,
feed_id: 7, feed_id: 7,
feed_title: "Hacker News".into(), feed_title: "Hacker News".into(),
category: None, category: None,
@@ -1596,6 +1605,7 @@ mod tests {
first_seen: ts("2026-08-15T05:30:00Z"), first_seen: ts("2026-08-15T05:30:00Z"),
url: "https://example.com/1".into(), url: "https://example.com/1".into(),
author: Some("Page Writer".into()), author: Some("Page Writer".into()),
publication: None,
feed_id: 7, feed_id: 7,
feed_title: "Hacker News".into(), feed_title: "Hacker News".into(),
category: None, category: None,
+1
View File
@@ -409,6 +409,7 @@ fn build_article(members: Vec<(Entry, String)>, feed_urls: &FeedUrls) -> Article
first_seen, first_seen,
url: best_entry.url.clone(), url: best_entry.url.clone(),
author, author,
publication: None,
feed_id: best_entry.feed_id, feed_id: best_entry.feed_id,
feed_title: best_entry feed_title: best_entry
.feed_title .feed_title
+1
View File
@@ -1049,6 +1049,7 @@ mod tests {
first_seen: ts("2026-09-07T00:00:00Z"), first_seen: ts("2026-09-07T00:00:00Z"),
url: url.into(), url: url.into(),
author: None, author: None,
publication: None,
feed_id: 1, feed_id: 1,
feed_title: "Feed".into(), feed_title: "Feed".into(),
category: None, category: None,
+48 -3
View File
@@ -11,7 +11,7 @@ use crate::comments;
use crate::html::{text_escape, to_xhtml}; use crate::html::{text_escape, to_xhtml};
use crate::images; use crate::images;
use crate::types::{ use crate::types::{
BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote, Article, BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote,
WORLD_BRIEFING_SECTION, WORLD_BRIEFING_SECTION,
}; };
use crate::world; use crate::world;
@@ -363,6 +363,13 @@ pub fn section_names(issue: &Issue) -> Vec<String> {
names names
} }
fn source_line(article: &Article) -> String {
match article.publication_label() {
Some(publication) => format!("{} · {publication}", article.feed_title),
None => article.feed_title.clone(),
}
}
/// "In This Issue": per section, each article's title, source, reading time and /// "In This Issue": per section, each article's title, source, reading time and
/// summary, linked to its chapter (§3.10). /// summary, linked to its chapter (§3.10).
pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> { pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
@@ -375,7 +382,7 @@ pub fn render_in_this_issue(issue: &Issue) -> Result<Chapter, EpubError> {
.map(|pick| IndexEntry { .map(|pick| IndexEntry {
href: article_href(pick), href: article_href(pick),
title: pick.article.title.clone(), title: pick.article.title.clone(),
source: pick.article.feed_title.clone(), source: source_line(&pick.article),
reading_minutes: pick.article.reading_minutes(), reading_minutes: pick.article.reading_minutes(),
summary: summary_for(issue, pick).unwrap_or_default().to_string(), summary: summary_for(issue, pick).unwrap_or_default().to_string(),
why: pick.why.clone(), why: pick.why.clone(),
@@ -437,7 +444,7 @@ pub fn render_article(
hmac_secret: Option<&str>, hmac_secret: Option<&str>,
) -> Result<Chapter, EpubError> { ) -> Result<Chapter, EpubError> {
let article = &pick.article; let article = &pick.article;
let mut meta_parts = vec![article.feed_title.clone()]; let mut meta_parts = vec![source_line(article)];
if let Some(date) = published_display(pick) { if let Some(date) = published_display(pick) {
meta_parts.push(date); meta_parts.push(date);
} }
@@ -879,6 +886,44 @@ mod tests {
assert_xml_ok(&chapter.xhtml); 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());
let index = render_in_this_issue(&issue).unwrap();
assert!(
index
.xhtml
.contains("Example Feed · Example Journal &#183; 6 min read")
);
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] #[test]
fn article_chapter_has_header_body_and_footer() { fn article_chapter_has_header_body_and_footer() {
let issue = issue(); let issue = issue();
+1
View File
@@ -35,6 +35,7 @@ pub fn article(id: ArticleId, entry_id: EntryId, title: &str) -> Article {
first_seen: timestamp(), first_seen: timestamp(),
url: format!("https://example.com/{entry_id}"), url: format!("https://example.com/{entry_id}"),
author: Some("A. Writer".into()), author: Some("A. Writer".into()),
publication: None,
feed_id: 7, feed_id: 7,
feed_title: "Example Feed".into(), feed_title: "Example Feed".into(),
category: Some("Tech".into()), category: Some("Tech".into()),
+44 -22
View File
@@ -198,6 +198,7 @@ impl Extractor {
Extracted { Extracted {
content_html, content_html,
author: None, author: None,
publication: None,
word_count: words, word_count: words,
excerpt_only, excerpt_only,
image_urls, image_urls,
@@ -280,11 +281,12 @@ impl Extractor {
body.extend_from_slice(&chunk); body.extend_from_slice(&chunk);
} }
let html = String::from_utf8_lossy(&body).into_owned(); let html = String::from_utf8_lossy(&body).into_owned();
let (title, html, author) = readable_page(&html, &final_url)?; let (title, html, author, site_name) = readable_page(&html, &final_url)?;
Ok(Page { Ok(Page {
title, title,
html, html,
author, author,
site_name,
final_url, final_url,
}) })
} }
@@ -297,6 +299,7 @@ impl Extractor {
Extracted { Extracted {
content_html: clean, content_html: clean,
author: page.author.clone(), author: page.author.clone(),
publication: page.site_name.clone(),
word_count: words, word_count: words,
excerpt_only: looks_paywalled(requested_url, words, &self.paywall_domains), excerpt_only: looks_paywalled(requested_url, words, &self.paywall_domains),
image_urls, image_urls,
@@ -314,6 +317,8 @@ pub struct Page {
pub html: String, pub html: String,
/// Readability's normalized byline for the page. /// Readability's normalized byline for the page.
pub author: Option<String>, pub author: Option<String>,
/// Readability's normalized site name for the page.
pub site_name: Option<String>,
/// Where the fetch ended up, after any redirects — the base for relative URLs. /// Where the fetch ended up, after any redirects — the base for relative URLs.
pub final_url: String, pub final_url: String,
} }
@@ -326,10 +331,13 @@ pub struct Page {
/// their image with them) and its lazy-image heuristic overwrites a perfectly /// their image with them) and its lazy-image heuristic overwrites a perfectly
/// good `src` with whatever other attribute happens to contain `.jpg`. /// good `src` with whatever other attribute happens to contain `.jpg`.
pub fn readability(html: &str, url: &str) -> Result<String, ExtractError> { pub fn readability(html: &str, url: &str) -> Result<String, ExtractError> {
readable_page(html, url).map(|(_, content, _)| content) readable_page(html, url).map(|(_, content, _, _)| content)
} }
fn readable_page(html: &str, url: &str) -> Result<(String, String, Option<String>), ExtractError> { fn readable_page(
html: &str,
url: &str,
) -> Result<(String, String, Option<String>, Option<String>), ExtractError> {
let html = prepare_for_readability(html); let html = prepare_for_readability(html);
let config = dom_smoothie::Config { let config = dom_smoothie::Config {
max_elements_to_parse: 60_000, max_elements_to_parse: 60_000,
@@ -345,17 +353,18 @@ fn readable_page(html: &str, url: &str) -> Result<(String, String, Option<String
Ok(( Ok((
parsed.title.trim().to_string(), parsed.title.trim().to_string(),
content, content,
normalize_author(parsed.byline), normalize_meta_text(parsed.byline),
normalize_meta_text(parsed.site_name),
)) ))
} }
fn normalize_author(author: Option<String>) -> Option<String> { fn normalize_meta_text(text: Option<String>) -> Option<String> {
let author = author?; let text = text?;
if author.chars().any(|c| matches!(c, '\n' | '\r')) { if text.chars().any(|c| matches!(c, '\n' | '\r')) {
return None; return None;
} }
let author = author.split_whitespace().collect::<Vec<_>>().join(" "); let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
(!author.is_empty() && author.chars().count() <= 100).then_some(author) (!text.is_empty() && text.chars().count() <= 100).then_some(text)
} }
/// Copy an [`Extracted`] onto its [`Article`]. /// Copy an [`Extracted`] onto its [`Article`].
@@ -371,6 +380,9 @@ pub fn apply(article: &mut Article, extracted: Extracted) {
{ {
article.author = Some(author); article.author = Some(author);
} }
if let Some(publication) = extracted.publication {
article.publication = Some(publication);
}
} }
fn merge_paywall_domains(configured: Vec<String>) -> Vec<String> { fn merge_paywall_domains(configured: Vec<String>) -> Vec<String> {
@@ -518,6 +530,7 @@ mod tests {
first_seen: ts(), first_seen: ts(),
url: url.into(), url: url.into(),
author: None, author: None,
publication: None,
feed_id: 1, feed_id: 1,
feed_title: "Feed".into(), feed_title: "Feed".into(),
category: None, category: None,
@@ -745,10 +758,11 @@ mod tests {
<article><h1>A Post</h1><p>{paragraph}</p><p>{paragraph}</p></article>\ <article><h1>A Post</h1><p>{paragraph}</p><p>{paragraph}</p></article>\
<footer>© 2026</footer></body></html>" <footer>© 2026</footer></body></html>"
); );
let (title, content, author) = let (title, content, author, site_name) =
readable_page(&html, "https://blog.dev/p").expect("main content"); readable_page(&html, "https://blog.dev/p").expect("main content");
assert_eq!(title, "A Post"); assert_eq!(title, "A Post");
assert_eq!(author, None); assert_eq!(author, None);
assert_eq!(site_name, None);
assert!(content.contains("Readability keeps the body copy")); assert!(content.contains("Readability keeps the body copy"));
let clean = sanitize_with_base(&content, "https://blog.dev/p"); let clean = sanitize_with_base(&content, "https://blog.dev/p");
assert!(word_count(&clean) > 200); assert!(word_count(&clean) > 200);
@@ -756,53 +770,60 @@ mod tests {
} }
#[test] #[test]
fn readable_page_plumbs_meta_author_through_extracted() { fn readable_page_plumbs_meta_author_and_og_site_name_through_extracted() {
let html = format!( let html = format!(
"<html><head><title>A Post</title>\ "<html><head><title>A Post</title>\
<meta name=\"author\" content=\" Jane Dev \"></head>\ <meta name=\"author\" content=\" Jane Dev \">\
<meta property=\"og:site_name\" content=\" Dev Journal \"></head>\
<body><article><h1>A Post</h1><p>{}</p></article></body></html>", <body><article><h1>A Post</h1><p>{}</p></article></body></html>",
"Substantial body copy for readability. ".repeat(40) "Substantial body copy for readability. ".repeat(40)
); );
let (title, content, author) = let (title, content, author, site_name) =
readable_page(&html, "https://blog.dev/p").expect("main content"); readable_page(&html, "https://blog.dev/p").expect("main content");
let page = Page { let page = Page {
title, title,
html: content, html: content,
author, author,
site_name,
final_url: "https://blog.dev/p".into(), final_url: "https://blog.dev/p".into(),
}; };
let extracted = Extractor::offline(vec![]).finish_readable(&page.final_url, &page); let extracted = Extractor::offline(vec![]).finish_readable(&page.final_url, &page);
assert_eq!(extracted.author.as_deref(), Some("Jane Dev")); assert_eq!(extracted.author.as_deref(), Some("Jane Dev"));
assert_eq!(extracted.publication.as_deref(), Some("Dev Journal"));
} }
#[test] #[test]
fn readable_page_plumbs_json_ld_author_through_extracted() { fn readable_page_plumbs_json_ld_author_and_publisher_through_extracted() {
let html = format!( let html = format!(
r#"<html><head><title>A Post</title> r#"<html><head><title>A Post</title>
<script type="application/ld+json">{{ <script type="application/ld+json">{{
"@context":"https://schema.org", "@type":"Article", "@context":"https://schema.org", "@type":"Article",
"headline":"A Post", "author":{{"@type":"Person","name":"Alex Writer"}} "headline":"A Post", "author":{{"@type":"Person","name":"Alex Writer"}},
"publisher":{{"@type":"Organization","name":"Example Gazette"}}
}}</script></head> }}</script></head>
<body><article><h1>A Post</h1><p>{}</p></article></body></html>"#, <body><article><h1>A Post</h1><p>{}</p></article></body></html>"#,
"Substantial body copy for readability. ".repeat(40) "Substantial body copy for readability. ".repeat(40)
); );
let (title, content, author) = let (title, content, author, site_name) =
readable_page(&html, "https://blog.dev/p").expect("main content"); readable_page(&html, "https://blog.dev/p").expect("main content");
let page = Page { let page = Page {
title, title,
html: content, html: content,
author, author,
site_name,
final_url: "https://blog.dev/p".into(), final_url: "https://blog.dev/p".into(),
}; };
let extracted = Extractor::offline(vec![]).finish_readable(&page.final_url, &page); let extracted = Extractor::offline(vec![]).finish_readable(&page.final_url, &page);
assert_eq!(extracted.author.as_deref(), Some("Alex Writer")); assert_eq!(extracted.author.as_deref(), Some("Alex Writer"));
assert_eq!(extracted.publication.as_deref(), Some("Example Gazette"));
} }
#[test] #[test]
fn apply_uses_page_author_with_feed_precedence() { fn apply_uses_page_metadata() {
let extracted = |author: &str| Extracted { let extracted = |author: &str| Extracted {
content_html: "<p>body</p>".into(), content_html: "<p>body</p>".into(),
author: Some(author.into()), author: Some(author.into()),
publication: Some("Example Gazette".into()),
word_count: 1, word_count: 1,
excerpt_only: false, excerpt_only: false,
image_urls: vec![], image_urls: vec![],
@@ -814,6 +835,7 @@ mod tests {
aggregator.author = Some("Submitter".into()); aggregator.author = Some("Submitter".into());
apply(&mut aggregator, extracted("Page Writer")); apply(&mut aggregator, extracted("Page Writer"));
assert_eq!(aggregator.author.as_deref(), Some("Page Writer")); assert_eq!(aggregator.author.as_deref(), Some("Page Writer"));
assert_eq!(aggregator.publication.as_deref(), Some("Example Gazette"));
let mut direct = article("https://blog.dev/direct", ""); let mut direct = article("https://blog.dev/direct", "");
direct.author = Some("Feed Writer".into()); direct.author = Some("Feed Writer".into());
@@ -826,13 +848,13 @@ mod tests {
} }
#[test] #[test]
fn implausible_page_authors_are_dropped() { fn implausible_page_metadata_is_dropped() {
assert_eq!( assert_eq!(
normalize_author(Some(" Jane Dev ".into())).as_deref(), normalize_meta_text(Some(" Jane Dev ".into())).as_deref(),
Some("Jane Dev") Some("Jane Dev")
); );
assert_eq!(normalize_author(Some("Jane\nDev".into())), None); assert_eq!(normalize_meta_text(Some("Jane\nDev".into())), None);
assert_eq!(normalize_author(Some("x".repeat(101))), None); assert_eq!(normalize_meta_text(Some("x".repeat(101))), None);
assert_eq!(normalize_author(Some(" ".into())), None); assert_eq!(normalize_meta_text(Some(" ".into())), None);
} }
} }
+5
View File
@@ -167,6 +167,7 @@ fn imported_article(
first_seen, first_seen,
url: page.final_url, url: page.final_url,
author: extracted.author, author: extracted.author,
publication: extracted.publication,
feed_id: 0, feed_id: 0,
feed_title: "Imported".into(), feed_title: "Imported".into(),
category: None, category: None,
@@ -379,6 +380,7 @@ mod tests {
first_seen: now, first_seen: now,
url: url.into(), url: url.into(),
author: None, author: None,
publication: None,
feed_id: 7, feed_id: 7,
feed_title: "Feed".into(), feed_title: "Feed".into(),
category: None, category: None,
@@ -484,6 +486,7 @@ mod tests {
title: "A historical essay".into(), title: "A historical essay".into(),
html: "<article><p>Useful old writing.</p><script>bad()</script><img src=\"/chart.png\"></article>".into(), html: "<article><p>Useful old writing.</p><script>bad()</script><img src=\"/chart.png\"></article>".into(),
author: Some("Essay Writer".into()), author: Some("Essay Writer".into()),
site_name: Some("Example Review".into()),
final_url: "https://example.com/essays/old".into(), final_url: "https://example.com/essays/old".into(),
}; };
let extracted = extractor.finish_readable("https://example.com/old", &page); let extracted = extractor.finish_readable("https://example.com/old", &page);
@@ -497,6 +500,7 @@ mod tests {
assert_eq!(article.best_entry_id, 0); assert_eq!(article.best_entry_id, 0);
assert_eq!(article.feed_title, "Imported"); assert_eq!(article.feed_title, "Imported");
assert_eq!(article.author.as_deref(), Some("Essay Writer")); assert_eq!(article.author.as_deref(), Some("Essay Writer"));
assert_eq!(article.publication.as_deref(), Some("Example Review"));
assert!(article.sources.is_empty()); assert!(article.sources.is_empty());
assert!(!article.content_html.contains("script")); assert!(!article.content_html.contains("script"));
assert_eq!(article.image_urls, ["https://example.com/chart.png"]); assert_eq!(article.image_urls, ["https://example.com/chart.png"]);
@@ -506,6 +510,7 @@ mod tests {
assert_eq!(loaded.best_entry_id, 0); assert_eq!(loaded.best_entry_id, 0);
assert_eq!(loaded.feed_title, "Imported"); assert_eq!(loaded.feed_title, "Imported");
assert_eq!(loaded.author.as_deref(), Some("Essay Writer")); assert_eq!(loaded.author.as_deref(), Some("Essay Writer"));
assert_eq!(loaded.publication.as_deref(), Some("Example Review"));
} }
#[tokio::test] #[tokio::test]
+1
View File
@@ -1530,6 +1530,7 @@ mod tests {
first_seen: now(), first_seen: now(),
url, url,
author: None, author: None,
publication: None,
feed_id: 100 + entry_id, feed_id: 100 + entry_id,
feed_title: format!("Feed {entry_id}"), feed_title: format!("Feed {entry_id}"),
category: None, category: None,
+1
View File
@@ -972,6 +972,7 @@ mod tests {
first_seen: ts("2026-08-15T05:30:00Z"), first_seen: ts("2026-08-15T05:30:00Z"),
url: "https://example.com/1".into(), url: "https://example.com/1".into(),
author: None, author: None,
publication: None,
feed_id: 7, feed_id: 7,
feed_title: "Hacker News".into(), feed_title: "Hacker News".into(),
category: None, category: None,
+1
View File
@@ -328,6 +328,7 @@ mod tests {
first_seen: ts("2026-08-15T05:00:00Z"), first_seen: ts("2026-08-15T05:00:00Z"),
url: url.into(), url: url.into(),
author: None, author: None,
publication: None,
feed_id: 1, feed_id: 1,
feed_title: "Feed".into(), feed_title: "Feed".into(),
category: None, category: None,
+82
View File
@@ -92,6 +92,8 @@ pub struct Extracted {
pub content_html: String, pub content_html: String,
/// Author discovered in the fetched page, if any. /// Author discovered in the fetched page, if any.
pub author: Option<String>, pub author: Option<String>,
/// Site name discovered in the fetched page, if any.
pub publication: Option<String>,
pub word_count: i64, pub word_count: i64,
/// True when we only have an excerpt/paywall stub — penalized in pre-filter. /// True when we only have an excerpt/paywall stub — penalized in pre-filter.
pub excerpt_only: bool, pub excerpt_only: bool,
@@ -124,6 +126,8 @@ pub struct Article {
// --- joined / derived fields --- // --- joined / derived fields ---
pub url: String, pub url: String,
pub author: Option<String>, pub author: Option<String>,
/// Page-extracted site name persisted on `articles`.
pub publication: Option<String>,
pub feed_id: FeedId, pub feed_id: FeedId,
pub feed_title: String, pub feed_title: String,
pub category: Option<String>, pub category: Option<String>,
@@ -150,6 +154,19 @@ impl Article {
self.sources.iter().any(|s| s.kind == kind) self.sources.iter().any(|s| s.kind == kind)
} }
/// What to show after the feed name: the page's site name, else the domain;
/// nothing when it would just repeat the feed name.
pub fn publication_label(&self) -> Option<String> {
let publication = self
.publication
.clone()
.or_else(|| domain(&self.canonical_url))?;
(!publication
.trim()
.eq_ignore_ascii_case(self.feed_title.trim()))
.then_some(publication)
}
/// Stable EPUB chapter id used by TOC and rating links (implementation notes §12). /// Stable EPUB chapter id used by TOC and rating links (implementation notes §12).
pub fn chapter_id(&self) -> String { pub fn chapter_id(&self) -> String {
format!("art-{}", self.best_entry_id) format!("art-{}", self.best_entry_id)
@@ -161,6 +178,14 @@ pub fn reading_minutes(word_count: i64) -> i64 {
(word_count.max(0) as f64 / 220.0).ceil().max(1.0) as i64 (word_count.max(0) as f64 / 220.0).ceil().max(1.0) as i64
} }
/// URL host without a leading `www.`.
pub fn domain(url: &str) -> Option<String> {
url::Url::parse(url)
.ok()?
.host_str()
.map(|host| host.strip_prefix("www.").unwrap_or(host).to_string())
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Social proof (§3.4) // Social proof (§3.4)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -847,6 +872,63 @@ mod tests {
} }
} }
fn publication_article() -> Article {
Article {
id: 1,
canonical_url: "https://www.example.com/post".into(),
title: "A post".into(),
best_entry_id: 1,
content_html: String::new(),
word_count: 1,
excerpt_only: false,
image_count: 0,
sources: Vec::new(),
first_seen: ts(),
url: "https://www.example.com/post".into(),
author: None,
publication: None,
feed_id: 1,
feed_title: "A Feed".into(),
category: None,
published_at: None,
comments_url: None,
image_urls: Vec::new(),
social: Vec::new(),
extract_method: ExtractMethod::Readability,
}
}
#[test]
fn publication_label_prefers_the_stored_publication() {
let mut article = publication_article();
article.publication = Some("Example Journal".into());
assert_eq!(
article.publication_label().as_deref(),
Some("Example Journal")
);
}
#[test]
fn publication_label_falls_back_to_the_domain_without_www() {
let article = publication_article();
assert_eq!(article.publication_label().as_deref(), Some("example.com"));
}
#[test]
fn publication_label_omits_a_publication_matching_the_feed() {
let mut article = publication_article();
article.feed_title = " example JOURNAL ".into();
article.publication = Some(" Example Journal ".into());
assert_eq!(article.publication_label(), None);
}
#[test]
fn publication_label_omits_a_domain_matching_the_feed() {
let mut article = publication_article();
article.feed_title = " EXAMPLE.com ".into();
assert_eq!(article.publication_label(), None);
}
#[test] #[test]
fn composite_social_score_matches_spec_formula() { fn composite_social_score_matches_spec_formula() {
let refs = vec![ let refs = vec![
+4 -6
View File
@@ -88,7 +88,7 @@ struct FeedRow {
/// timestamps sit in the cell's tooltip. /// timestamps sit in the cell's tooltip.
seen: String, seen: String,
decided_at: String, decided_at: String,
/// `{miniflux.base_url}/feeds/{id}` for a candidate we subscribed to. /// The Miniflux web UI page for a candidate we subscribed to.
miniflux_href: Option<String>, miniflux_href: Option<String>,
} }
@@ -157,10 +157,9 @@ async fn index(
None => load_evidence(db, &config, &candidates).await?, None => load_evidence(db, &config, &candidates).await?,
}; };
let titles = article_titles(db, &candidates).await?; let titles = article_titles(db, &candidates).await?;
let base_url = config.miniflux.base_url.trim_end_matches('/').to_string();
let rows: Vec<FeedRow> = candidates let rows: Vec<FeedRow> = candidates
.iter() .iter()
.map(|candidate| row(candidate, &evidence, &titles, &config, &base_url)) .map(|candidate| row(candidate, &evidence, &titles, &config))
.collect(); .collect();
// The picker is only ever used by the candidate view, so only it pays for // The picker is only ever used by the candidate view, so only it pays for
@@ -381,7 +380,6 @@ fn row(
evidence: &HashMap<ArticleId, ArticleEvidence>, evidence: &HashMap<ArticleId, ArticleEvidence>,
titles: &HashMap<ArticleId, String>, titles: &HashMap<ArticleId, String>,
config: &Config, config: &Config,
base_url: &str,
) -> FeedRow { ) -> FeedRow {
let mut scored = evidence_of(candidate, evidence); let mut scored = evidence_of(candidate, evidence);
let score = discovery::score(&scored); let score = discovery::score(&scored);
@@ -433,7 +431,7 @@ fn row(
decided_at: fmt_stored_time(candidate.decided_at.as_deref(), config), decided_at: fmt_stored_time(candidate.decided_at.as_deref(), config),
miniflux_href: candidate miniflux_href: candidate
.miniflux_feed_id .miniflux_feed_id
.map(|feed_id| format!("{base_url}/feeds/{feed_id}")), .map(|feed_id| config.miniflux.feed_url(feed_id)),
} }
} }
@@ -610,7 +608,7 @@ mod tests {
assert!(body.contains("Added Strong Blog."), "{body}"); assert!(body.contains("Added Strong Blog."), "{body}");
let added = let added =
response_text(get(&app, "/dashboard/feeds?status=added", Some(&admin)).await).await; response_text(get(&app, "/dashboard/feeds?status=added", Some(&admin)).await).await;
assert!(added.contains("/feeds/77"), "{added}"); assert!(added.contains("/feed/77/entries"), "{added}");
} }
#[tokio::test] #[tokio::test]
+2
View File
@@ -218,6 +218,7 @@ pub const RESTART_REQUIRED: &[&str] = &[
/// Optional keys that `Config::default()` leaves unset (and therefore do not /// Optional keys that `Config::default()` leaves unset (and therefore do not
/// appear when the defaults are serialized), with the kind they take. /// appear when the defaults are serialized), with the kind they take.
const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[ const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[
("miniflux.public_url", FieldKind::Text),
("miniflux.api_key", FieldKind::Secret), ("miniflux.api_key", FieldKind::Secret),
("providers.*.api_key", FieldKind::Secret), ("providers.*.api_key", FieldKind::Secret),
("providers.*.effort", FieldKind::Text), ("providers.*.effort", FieldKind::Text),
@@ -296,6 +297,7 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
("profile_path", "Hand-maintained reader profile, loaded every run."), ("profile_path", "Hand-maintained reader profile, loaded every run."),
("interests_opml", "Scour interests OPML merged with the profile interests."), ("interests_opml", "Scour interests OPML merged with the profile interests."),
("miniflux.base_url", "Miniflux root (no /v1)."), ("miniflux.base_url", "Miniflux root (no /v1)."),
("miniflux.public_url", "Browser-facing Miniflux web UI URL for dashboard links. Defaults to miniflux.base_url."),
("miniflux.api_key", "X-Auth-Token for Miniflux. Required; environment only."), ("miniflux.api_key", "X-Auth-Token for Miniflux. Required; environment only."),
("miniflux.page_limit", "Entries per page for GET /v1/entries; Miniflux caps this at 250."), ("miniflux.page_limit", "Entries per page for GET /v1/entries; Miniflux caps this at 250."),
("llm.bulk", "The [providers.*] name that runs triage, deep assessment and every fallback. Empty means no bulk provider (those stages are skipped)."), ("llm.bulk", "The [providers.*] name that runs triage, deep assessment and every fallback. Empty means no bulk provider (those stages are skipped)."),
+78 -6
View File
@@ -12,12 +12,14 @@ use jiff::civil::Date;
use serde::Deserialize; use serde::Deserialize;
use sqlx::Row; use sqlx::Row;
use crate::config::Config;
use crate::db::Db; use crate::db::Db;
use crate::epub::chapters; use crate::epub::chapters;
use crate::pipeline::display_date; use crate::pipeline::display_date;
use crate::server::AppState; use crate::server::AppState;
use crate::types::{ use crate::types::{
ArticleId, BehindThePaper, Colophon, Edition, Editorial, Issue, IssueMeta, Lineup, Models, Pick, Article, ArticleId, BehindThePaper, Colophon, Edition, Editorial, Issue, IssueMeta, Lineup,
Models, Pick,
}; };
use crate::web::rate::{self, RatingWidget}; use crate::web::rate::{self, RatingWidget};
use crate::web::session::{AuthSession, Viewer}; use crate::web::session::{AuthSession, Viewer};
@@ -850,12 +852,31 @@ fn issue_toc(view: &IssueView, current: TocPosition) -> Toc {
} }
} }
/// The "Feed · Publication" lead of an article's meta line.
#[derive(Debug)]
struct Source {
feed_title: String,
feed_href: Option<String>,
publication: Option<String>,
}
impl Source {
fn new(article: &Article, config: &Config, is_admin: bool) -> Self {
Self {
feed_title: article.feed_title.clone(),
feed_href: (is_admin && article.feed_id > 0)
.then(|| config.miniflux.feed_url(article.feed_id)),
publication: article.publication_label(),
}
}
}
#[derive(Debug)] #[derive(Debug)]
struct FullEntry { struct FullEntry {
title: String, title: String,
href: String, href: String,
dashboard_href: String, dashboard_href: String,
source: String, source: Source,
reading_minutes: i64, reading_minutes: i64,
is_lead: bool, is_lead: bool,
summary: String, summary: String,
@@ -926,6 +947,7 @@ struct ArticleTemplate {
title: String, title: String,
source_url: String, source_url: String,
byline: Option<String>, byline: Option<String>,
source: Source,
meta_line: String, meta_line: String,
why: Option<String>, why: Option<String>,
social_line: Option<String>, social_line: Option<String>,
@@ -979,6 +1001,7 @@ pub async fn render_full(
) -> Result<Response, WebError> { ) -> Result<Response, WebError> {
let date = view.issue.meta.date; let date = view.issue.meta.date;
let is_admin = viewer.role == crate::web::users::Role::Admin; let is_admin = viewer.role == crate::web::users::Role::Admin;
let config = state.config();
let current = if is_admin { let current = if is_admin {
rate::current_for_issue(state, date).await? rate::current_for_issue(state, date).await?
} else { } else {
@@ -998,7 +1021,7 @@ pub async fn render_full(
title: pick.article.title.clone(), title: pick.article.title.clone(),
href: article_href(date, pick.article.id), href: article_href(date, pick.article.id),
dashboard_href: format!("/dashboard/articles/{}", pick.article.id), dashboard_href: format!("/dashboard/articles/{}", pick.article.id),
source: pick.article.feed_title.clone(), source: Source::new(&pick.article, &config, is_admin),
reading_minutes: pick.article.reading_minutes(), reading_minutes: pick.article.reading_minutes(),
is_lead: pick.is_lead, is_lead: pick.is_lead,
summary: summary_for(&view.issue, pick) summary: summary_for(&view.issue, pick)
@@ -1090,6 +1113,8 @@ pub async fn article(
href: article_href(date, next.article.id), href: article_href(date, next.article.id),
}); });
let article = &pick.article; let article = &pick.article;
let is_admin = viewer.role == crate::web::users::Role::Admin;
let config = state.config();
let active_nav = if view.is_latest { "latest" } else { "archive" }; let active_nav = if view.is_latest { "latest" } else { "archive" };
let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav); let mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav);
page.flash = take_flash(&session).await?; page.flash = take_flash(&session).await?;
@@ -1099,9 +1124,9 @@ pub async fn article(
title: article.title.clone(), title: article.title.clone(),
source_url: article.canonical_url.clone(), source_url: article.canonical_url.clone(),
byline: article.author.as_ref().map(|author| format!("By {author}")), byline: article.author.as_ref().map(|author| format!("By {author}")),
source: Source::new(article, &config, is_admin),
meta_line: format!( meta_line: format!(
"{} · {} words · ~{} min read", "{} words · ~{} min read",
article.feed_title,
thousands(article.word_count), thousands(article.word_count),
article.reading_minutes() article.reading_minutes()
), ),
@@ -1117,7 +1142,7 @@ pub async fn article(
.map(|discussion| crate::comments::render_xhtml(discussion, &article.title)), .map(|discussion| crate::comments::render_xhtml(discussion, &article.title)),
read_online_url: article.url.clone(), read_online_url: article.url.clone(),
dashboard_href: format!("/dashboard/articles/{}", article.id), dashboard_href: format!("/dashboard/articles/{}", article.id),
rating: (viewer.role == crate::web::users::Role::Admin).then(|| { rating: is_admin.then(|| {
RatingWidget::for_issue( RatingWidget::for_issue(
article.id, article.id,
date, date,
@@ -2047,6 +2072,53 @@ mod tests {
assert!(response_text(missing).await.contains("Not found")); assert!(response_text(missing).await.contains("Not found"));
} }
#[tokio::test]
async fn article_feed_links_to_miniflux_for_admins_only() {
let (_dir, db, source) = seeded_issue(true).await;
crate::web::users::add(&db, "admin", "correct horse battery", true)
.await
.unwrap();
crate::web::users::add(&db, "reader", "correct horse battery", false)
.await
.unwrap();
let mut config = crate::config::Config::default();
config.miniflux.public_url = Some("https://miniflux.example/".into());
let app = crate::server::router(crate::server::AppState::new(db, config, None));
let admin_cookie = login_cookie(&app, "admin", "correct horse battery").await;
let reader_cookie = login_cookie(&app, "reader", "correct horse battery").await;
let article = &source.lineup.picks[0].article;
let article_uri = format!("/issues/{}/articles/{}", source.meta.date, article.id);
let feed_href = format!("https://miniflux.example/feed/{}/entries", article.feed_id);
let admin = app
.clone()
.oneshot(
Request::builder()
.uri(&article_uri)
.header(header::COOKIE, admin_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let admin = response_text(admin).await;
assert!(admin.contains(&format!("href=\"{feed_href}\"")));
let reader = app
.oneshot(
Request::builder()
.uri(article_uri)
.header(header::COOKIE, reader_cookie)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let reader = response_text(reader).await;
assert!(reader.contains(&article.feed_title));
assert!(!reader.contains(&feed_href));
}
#[tokio::test] #[tokio::test]
async fn fallback_full_issue_recovers_colophon_world_and_behind() { async fn fallback_full_issue_recovers_colophon_world_and_behind() {
let (_dir, db, source) = seeded_issue(false).await; let (_dir, db, source) = seeded_issue(false).await;
+6 -10
View File
@@ -49,7 +49,7 @@ pub struct PublicEntry {
pub url: String, pub url: String,
pub author: Option<String>, pub author: Option<String>,
pub source: String, pub source: String,
pub domain: String, pub publication: Option<String>,
pub reading_minutes: i64, pub reading_minutes: i64,
pub word_count: i64, pub word_count: i64,
pub summary: Option<String>, pub summary: Option<String>,
@@ -116,7 +116,7 @@ impl From<&Issue> for PublicIssue {
url: article.canonical_url.clone(), url: article.canonical_url.clone(),
author: article.author.clone(), author: article.author.clone(),
source: article.feed_title.clone(), source: article.feed_title.clone(),
domain: domain(&article.canonical_url), publication: article.publication_label(),
reading_minutes: article.reading_minutes(), reading_minutes: article.reading_minutes(),
word_count: article.word_count, word_count: article.word_count,
summary: pick summary: pick
@@ -169,14 +169,6 @@ impl PublicIssue {
} }
} }
fn domain(raw: &str) -> String {
url::Url::parse(raw)
.ok()
.and_then(|url| url.host_str().map(str::to_string))
.map(|host| host.strip_prefix("www.").unwrap_or(&host).to_string())
.unwrap_or_default()
}
#[derive(Template)] #[derive(Template)]
#[template(path = "issue_public.html")] #[template(path = "issue_public.html")]
struct IssuePublicTemplate { struct IssuePublicTemplate {
@@ -434,6 +426,10 @@ mod tests {
fn public_issue_shows_summaries_and_why_but_no_bodies() { fn public_issue_shows_summaries_and_why_but_no_bodies() {
let source = crate::epub::fixtures::issue(); let source = crate::epub::fixtures::issue();
let public = PublicIssue::from(&source); let public = PublicIssue::from(&source);
assert_eq!(
public.sections[0].entries[0].publication.as_deref(),
Some("example.com")
);
let html = IssuePublicTemplate { let html = IssuePublicTemplate {
page: Page::new("Test issue", None, "latest"), page: Page::new("Test issue", None, "latest"),
issue: public, issue: public,
+18 -6
View File
@@ -132,9 +132,19 @@ if (tocPanel) {
const tocStatus = document.querySelector("[data-toc-status]"); const tocStatus = document.querySelector("[data-toc-status]");
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)"); const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
const largeScreen = window.matchMedia("(min-width: 64rem)"); const largeScreen = window.matchMedia("(min-width: 64rem)");
const saveScroll = () => {
if (!largeScreen.matches || !tocPanel.dataset.tocIssue) return;
try {
sessionStorage.setItem("toc-scroll", `${tocPanel.dataset.tocIssue}\n${tocPanel.scrollTop}`);
} catch (_) {}
};
window.addEventListener("pagehide", saveScroll);
tocPanel.addEventListener("click", (event) => {
if (event.target.closest?.("a")) saveScroll();
});
// Long issues overflow the sidebar; scroll just enough to show where we are. // Long issues overflow the sidebar; scroll just enough to show where we are.
const revealCurrent = () => { const revealCurrent = (behavior = "smooth") => {
const current = tocPanel.querySelector(".toc-link[aria-current]"); const current = tocPanel.querySelector(".toc-link[aria-current]");
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return; if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
const stickyHeader = tocPanel.querySelector(":scope > div"); const stickyHeader = tocPanel.querySelector(":scope > div");
@@ -147,7 +157,7 @@ if (tocPanel) {
const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - revealMargin; const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - revealMargin;
if (top >= visibleTop && top + currentRect.height <= visibleBottom) return; if (top >= visibleTop && top + currentRect.height <= visibleBottom) return;
const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2)); const target = Math.max(0, top - Math.max(topMargin, (tocPanel.clientHeight - currentRect.height) / 2));
tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : "smooth" }); tocPanel.scrollTo({ top: target, behavior: reducedMotion.matches ? "auto" : behavior });
}; };
const sizeOpenPanel = () => { const sizeOpenPanel = () => {
if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return; if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return;
@@ -168,7 +178,7 @@ if (tocPanel) {
} }
}; };
setOpen(false); setOpen(false);
revealCurrent(); revealCurrent("auto");
if (tocToggle) { if (tocToggle) {
tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true")); tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true"));
document.addEventListener("click", (event) => { document.addEventListener("click", (event) => {
@@ -196,7 +206,7 @@ if (tocPanel) {
}); });
}; };
if (currentLink) markPassed(currentLink); if (currentLink) markPassed(currentLink);
const setCurrent = (link) => { const setCurrent = (link, behavior = "smooth") => {
if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return; if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return;
tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current")); tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current"));
markPassed(link); markPassed(link);
@@ -216,7 +226,7 @@ if (tocPanel) {
delete bar.dataset.live; delete bar.dataset.live;
bar.value = position; bar.value = position;
}); });
revealCurrent(); revealCurrent(behavior);
}; };
const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({ const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({
@@ -225,6 +235,7 @@ if (tocPanel) {
})).filter(({ link }) => link); })).filter(({ link }) => link);
if (tocEntries.length) { if (tocEntries.length) {
let queued = false; let queued = false;
let firstPaint = true;
const paintCurrent = () => { const paintCurrent = () => {
queued = false; queued = false;
const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3; const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3;
@@ -232,7 +243,8 @@ if (tocPanel) {
tocEntries.forEach(({ entry, link }) => { tocEntries.forEach(({ entry, link }) => {
if (entry.getBoundingClientRect().top <= threshold) next = link; if (entry.getBoundingClientRect().top <= threshold) next = link;
}); });
setCurrent(next); setCurrent(next, firstPaint ? "auto" : "smooth");
firstPaint = false;
}; };
const scheduleCurrent = () => { const scheduleCurrent = () => {
if (queued) return; if (queued) return;
+31 -2
View File
@@ -31,9 +31,38 @@
} catch (_) {} } catch (_) {}
}); });
}; };
let tocPanel;
let tocRestored = false;
const findToc = (root) => {
if (tocPanel || root.nodeType !== Node.ELEMENT_NODE) return;
tocPanel = root.matches("[data-toc-panel]") ? root : root.querySelector("[data-toc-panel]");
};
const restoreToc = () => {
if (!tocPanel || tocRestored) return;
tocRestored = true;
if (!window.matchMedia("(min-width: 64rem)").matches) return;
try {
const saved = sessionStorage.getItem("toc-scroll");
const separator = saved ? saved.lastIndexOf("\n") : -1;
if (separator < 0 || saved.slice(0, separator) !== tocPanel.dataset.tocIssue) return;
const scrollTop = Number(saved.slice(separator + 1));
if (Number.isFinite(scrollTop)) tocPanel.scrollTop = scrollTop;
} catch (_) {}
};
const detailsObserver = new MutationObserver((records) => { const detailsObserver = new MutationObserver((records) => {
records.forEach((record) => record.addedNodes.forEach(restoreDetails)); const addedNodes = [];
records.forEach((record) => record.addedNodes.forEach((node) => {
addedNodes.push(node);
restoreDetails(node);
findToc(node);
}));
// A following node means the parser has closed the nav and its list is complete.
if (tocPanel && addedNodes.some((node) => !tocPanel.contains(node)
&& (tocPanel.compareDocumentPosition(node) & Node.DOCUMENT_POSITION_FOLLOWING))) restoreToc();
}); });
detailsObserver.observe(document.documentElement, { childList: true, subtree: true }); detailsObserver.observe(document.documentElement, { childList: true, subtree: true });
document.addEventListener("DOMContentLoaded", () => detailsObserver.disconnect(), { once: true }); document.addEventListener("DOMContentLoaded", () => {
restoreToc();
detailsObserver.disconnect();
}, { once: true });
})(); })();
+1
View File
@@ -0,0 +1 @@
{% match source.feed_href %}{% when Some with (href) %}<a class="text-muted hover:text-accent" href="{{ href }}" target="_blank" rel="noopener">{{ source.feed_title }}</a>{% when None %}{{ source.feed_title }}{% endmatch %}{% match source.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %}
+1 -1
View File
@@ -8,7 +8,7 @@
<span class="shrink-0 font-sans text-xs tabular-nums text-muted" data-toc-count{% if toc.position == 0 %} hidden{% endif %}>{{ toc.position }} / {{ toc.total }}</span> <span class="shrink-0 font-sans text-xs tabular-nums text-muted" data-toc-count{% if toc.position == 0 %} hidden{% endif %}>{{ toc.position }} / {{ toc.total }}</span>
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress> <progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
</div> </div>
<nav id="toc" data-toc-panel class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 lg:pt-0 lg:shadow-none" aria-label="Contents"> <nav id="toc" data-toc-panel data-toc-issue="{{ toc.issue_href }}" class="absolute inset-x-0 top-full z-30 overflow-y-auto overscroll-contain border-b border-rule bg-paper pb-7 pl-4 pr-6 pt-2 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)] sm:pl-6 sm:pr-8 lg:sticky lg:inset-x-auto lg:top-6 lg:z-auto lg:mt-12 lg:max-h-[calc(100dvh-3rem)] lg:border-0 lg:pb-10 lg:pl-0 lg:pr-3 lg:pt-0 lg:shadow-none" aria-label="Contents">
<div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3"> <div class="hidden lg:sticky lg:top-0 lg:z-10 lg:block lg:bg-paper lg:pb-3">
<a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a> <a class="block font-sans text-[0.68rem] font-semibold uppercase tracking-[0.12em] text-muted no-underline hover:text-ink" href="{{ toc.issue_href }}">No. {{ toc.issue_number }} <span class="text-rule" aria-hidden="true">·</span> {{ toc.short_date }}</a>
<p class="mt-2 font-sans text-[0.72rem] text-ink-2" data-toc-status>{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p> <p class="mt-2 font-sans text-[0.72rem] text-ink-2" data-toc-status>{% if toc.position > 0 %}Chapter {{ toc.position }} of {{ toc.total }}{% else %}Front page{% endif %}</p>
+1 -1
View File
@@ -1,6 +1,6 @@
{% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-x-8 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %} {% extends "layout.html" %}{% block ears %}<span class="sm:hidden">{{ toc.short_date }}</span><span class="hidden sm:inline">{{ toc.display_date }} · No. {{ toc.issue_number }}</span>{% endblock %}{% block content %}<div class="mx-auto max-w-7xl lg:grid lg:grid-cols-[15rem_minmax(0,1fr)] lg:gap-x-8 lg:px-6 xl:grid-cols-[18rem_minmax(0,1fr)] xl:gap-x-14">{% include "_toc.html" %}
<article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0" data-toc-scroll> <article class="reader-page mx-auto mt-10 w-full max-w-[68ch] px-4 sm:px-6 lg:mt-12 lg:px-0" data-toc-scroll>
<header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{{ meta_line }}</p>{% match why %}{% when Some with (why) %}<p class="mt-6 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match understanding %}{% when Some with (line) %}<p class="mt-4 font-sans text-sm text-muted">{{ line }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header> <header><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">Article</p><h1 class="mt-3 text-4xl font-semibold leading-[1.1] tracking-[-0.01em] sm:text-5xl"><a class="text-ink no-underline hover:text-accent" href="{{ source_url }}">{{ title }}</a></h1>{% match byline %}{% when Some with (byline) %}<p class="mt-5 font-sans text-sm font-medium text-ink-2">{{ byline }}</p>{% when None %}{% endmatch %}<p class="mt-1 font-sans text-sm text-muted">{% include "_source.html" %} · {{ meta_line }}</p>{% match why %}{% when Some with (why) %}<p class="mt-6 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match understanding %}{% when Some with (line) %}<p class="mt-4 font-sans text-sm text-muted">{{ line }}</p>{% when None %}{% endmatch %}{% match social_line %}{% when Some with (social) %}<p class="mt-4 font-sans text-sm text-muted">{{ social }}</p>{% when None %}{% endmatch %}{% match summary %}{% when Some with (summary) %}<p class="mt-7 text-xl italic leading-relaxed text-ink-2">{{ summary }}</p>{% when None %}{% endmatch %}{% if excerpt_only %}<p class="notice mt-6">Excerpt only — continue reading at the original site.</p>{% endif %}</header>
<div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div> <div class="prose-body mt-10 border-t border-rule pt-8">{{ body_html|safe }}</div>
{% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %} {% match discussion_html %}{% when Some with (discussion) %}<section class="discussion prose-body mt-12 border-t border-rule pt-6 [&_.comment-meta]:font-sans [&_.comment-meta]:text-sm [&_.comment-meta]:text-muted [&_blockquote.reply]:ml-6"><h2 class="mb-6 border-0 pt-0 text-2xl">Discussion</h2>{{ discussion|safe }}</section>{% when None %}{% endmatch %}
<footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer> <footer class="mt-12 border-t border-rule pt-6">{% match rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}<p class="mt-6 flex flex-wrap gap-2"><a class="btn" href="{{ read_online_url }}">Read online ↗</a>{% if page.is_admin() %}<a class="btn" href="{{ dashboard_href }}">Dashboard</a>{% endif %}</p><nav class="mt-10 grid grid-cols-1 gap-3 font-sans sm:grid-cols-2" aria-label="Adjacent articles">{% match previous %}{% when Some with (previous) %}<a class="group min-h-24 border border-rule p-4 text-ink no-underline hover:border-accent" rel="prev" href="{{ previous.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Previous</span><span class="mt-2 block leading-snug group-hover:text-accent">← {{ previous.title }}</span></a>{% when None %}{% endmatch %}{% match next %}{% when Some with (next) %}<a class="group min-h-24 border border-rule p-4 text-right text-ink no-underline hover:border-accent sm:col-start-2" rel="next" href="{{ next.href }}"><span class="block text-[0.68rem] uppercase tracking-[0.12em] text-muted">Next</span><span class="mt-2 block leading-snug group-hover:text-accent">{{ next.title }} →</span></a>{% when None %}{% endmatch %}</nav><p class="mt-8 font-sans text-sm"><a href="{{ issue_href }}">← Back to this issue</a></p></footer>
+1 -1
View File
@@ -1 +1 @@
{% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.source }} ({{ entry.domain }}){% match entry.summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %} {% for section in issue.sections %}<h2>{{ section.name }}</h2><ul>{% for entry in section.entries %}<li><a href="{{ entry.url }}">{{ entry.title }}</a> — {{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %}{% match entry.summary %}{% when Some with (summary) %}<p class="summary">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="why"><em>Why it's here: {{ why }}</em></p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %} · {% for link in entry.comment_links %}<a href="{{ link.url }}">{{ link.label }}</a>{% endfor %}{% endif %}</li>{% endfor %}</ul>{% endfor %}
+1 -1
View File
@@ -5,7 +5,7 @@
<section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section> <section aria-labelledby="brief-heading" data-toc-entry="/issues/{{ date }}"><h1 id="brief-heading" class="reader-section-heading">The Brief</h1><div class="editorial prose-body mt-5 [&>p:first-child]:first-letter:float-left [&>p:first-child]:first-letter:mr-2 [&>p:first-child]:first-letter:mt-1 [&>p:first-child]:first-letter:font-serif [&>p:first-child]:first-letter:text-[4.6rem] [&>p:first-child]:first-letter:font-semibold [&>p:first-child]:first-letter:leading-[0.72]">{{ front_page_html|safe }}</div></section>
{% if (page.is_admin() && read_href.is_some()) || downloads.is_some() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% if page.is_admin() %}{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% endif %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}<a class="btn" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a>{% else %}<div class="relative inline-flex"><a class="btn rounded-r-none" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a><details class="-ml-px"><summary class="btn list-none rounded-l-none px-2 [&::-webkit-details-marker]:hidden" aria-label="Choose download format"><span aria-hidden="true">▾</span></summary><div class="absolute left-0 top-full z-20 mt-1 min-w-52 border border-rule bg-paper p-1 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)]"><a class="btn group w-full justify-between border-0" href="{{ downloads.primary.href }}"><span>{% if downloads.primary.label == "EPUB" %}Standard {% endif %}{{ downloads.primary.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ downloads.primary.size }}</span></a>{% for download in downloads.others %}<a class="btn group w-full justify-between border-0" href="{{ download.href }}"><span>{{ download.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ download.size }}</span></a>{% endfor %}</div></details></div>{% endif %}{% when None %}{% endmatch %}</div>{% endif %} {% if (page.is_admin() && read_href.is_some()) || downloads.is_some() %}<div class="my-8 flex flex-wrap gap-2 border-y border-rule py-4">{% if page.is_admin() %}{% match read_href %}{% when Some with (href) %}<a class="btn" href="{{ href }}" target="_blank" rel="noopener">Read in BookOrbit</a>{% when None %}{% endmatch %}{% endif %}{% match downloads %}{% when Some with (downloads) %}{% if downloads.others.is_empty() %}<a class="btn" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a>{% else %}<div class="relative inline-flex"><a class="btn rounded-r-none" href="{{ downloads.primary.href }}">Download {{ downloads.primary.label }}</a><details class="-ml-px"><summary class="btn list-none rounded-l-none px-2 [&::-webkit-details-marker]:hidden" aria-label="Choose download format"><span aria-hidden="true">▾</span></summary><div class="absolute left-0 top-full z-20 mt-1 min-w-52 border border-rule bg-paper p-1 shadow-[0_18px_32px_-26px_rgb(0_0_0_/_0.6)]"><a class="btn group w-full justify-between border-0" href="{{ downloads.primary.href }}"><span>{% if downloads.primary.label == "EPUB" %}Standard {% endif %}{{ downloads.primary.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ downloads.primary.size }}</span></a>{% for download in downloads.others %}<a class="btn group w-full justify-between border-0" href="{{ download.href }}"><span>{{ download.label }}</span><span class="ml-4 text-muted group-hover:text-paper">{{ download.size }}</span></a>{% endfor %}</div></details></div>{% endif %}{% when None %}{% endmatch %}</div>{% endif %}
<section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2> <section class="mt-12" aria-labelledby="index-heading"><h2 id="index-heading" class="text-center text-3xl font-semibold leading-[1.1] tracking-[-0.01em]">In This Issue</h2>
{% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{{ entry.source }} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}<p class="mt-3 font-sans text-sm text-muted">{{ line }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %} {% for section in sections %}<section class="mt-14"><h3 class="reader-section-heading">{{ section.name }}</h3><ul class="m-0 list-none p-0">{% for entry in section.entries %}<li class="border-b border-rule py-6" data-toc-entry="{{ entry.href }}"><h4 class="font-serif font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.href }}">{{ entry.title }}</a></h4><p class="mt-2 font-sans text-sm text-muted">{% let source = entry.source %}{% include "_source.html" %} · {{ entry.reading_minutes }} min read{% if page.is_admin() %} · <a class="text-muted hover:text-accent" href="{{ entry.dashboard_href }}">dashboard</a>{% endif %}</p>{% if !entry.summary.is_empty() %}<p class="index-summary mt-4">{{ entry.summary }}</p>{% endif %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}<p class="mt-3 font-sans text-sm text-muted">{{ line }}</p>{% when None %}{% endmatch %}{% match entry.rating %}{% when Some with (widget) %}{% include "_rating_widget.html" %}{% when None %}{% endmatch %}</li>{% endfor %}</ul></section>{% endfor %}
</section> </section>
{% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %} {% if has_world || has_behind %}<nav class="my-12 flex flex-wrap items-center justify-center gap-x-3 gap-y-1 border-y border-rule py-4 text-center font-sans text-sm uppercase tracking-[0.08em]" aria-label="Issue chapters">{% if has_world %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/world">World Briefing</a>{% endif %}{% if has_world && has_behind %}<span class="text-muted" aria-hidden="true">·</span>{% endif %}{% if has_behind %}<a class="text-ink no-underline hover:text-accent" href="/issues/{{ date }}/behind">Behind the paper</a>{% endif %}</nav>{% endif %}
<footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer> <footer id="colophon" class="mt-14 scroll-mt-6 font-sans text-sm leading-relaxed" data-toc-entry="/issues/{{ date }}#colophon"><h2 class="reader-section-heading">Colophon</h2><p class="my-4 text-ink-2"><em>The Daily EPUB</em> is assembled every morning from a personal feed reader.</p><dl class="kv border-y border-rule py-4 text-xs sm:text-sm"><dt>Generated</dt><dd>{{ colophon.generated_at }}</dd><dt>Bulk model</dt><dd>{{ colophon.bulk_model }}</dd><dt>Editor model</dt><dd>{{ colophon.editor_model }}</dd><dt>Summaries model</dt><dd>{{ colophon.summaries_model }}</dd><dt>Entries considered</dt><dd>{% match colophon.entries_fetched %}{% when Some with (entries) %}{{ entries }}{% match colophon.feeds_seen %}{% when Some with (feeds) %} from {{ feeds }} feeds{% when None %}{% endmatch %}{% when None %}n/a{% endmatch %}</dd><dt>Candidates scored</dt><dd>{% match colophon.candidates %}{% when Some with (candidates) %}{{ candidates }}{% when None %}n/a{% endmatch %}</dd><dt>Articles selected</dt><dd>{{ colophon.article_count }} across {{ colophon.section_count }} sections</dd><dt>Words</dt><dd>{{ colophon.total_words }} · ~{{ colophon.reading_minutes }} min read</dd>{% for cost in colophon.provider_costs %}<dt>{{ cost.provider }} cost</dt><dd>{{ cost.cost }}</dd>{% endfor %}<dt>Total token cost</dt><dd>{% match colophon.cost_usd %}{% when Some with (cost) %}{{ cost }}{% when None %}n/a{% endmatch %}</dd><dt>Generator</dt><dd>{{ colophon.generator_version }}</dd></dl></footer>
+1 -1
View File
@@ -4,6 +4,6 @@
<header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header> <header class="mb-10 text-center"><p class="font-sans text-[0.72rem] uppercase tracking-[0.12em] text-muted">{{ issue.display_date }} · No. {{ issue.issue_number }}</p><p class="mt-2 font-sans text-sm text-muted">{{ issue.stats_line }}</p><p class="mx-auto mt-5 max-w-[58ch] text-lg italic leading-relaxed text-ink-2">A personal morning paper, assembled daily; the selection is the reader's, the words are the authors'.</p></header>
<p class="notice mb-8"><a href="/login">Sign in</a> to read every article's full text and download the editions, or <a href="/request-access">request access</a>.</p> <p class="notice mb-8"><a href="/login">Sign in</a> to read every article's full text and download the editions, or <a href="/request-access">request access</a>.</p>
{% if !downloads.is_empty() %}<div class="mb-8 flex flex-wrap justify-center gap-2">{% for download in downloads %}<a class="btn" href="{{ download.href }}">{{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %} {% if !downloads.is_empty() %}<div class="mb-8 flex flex-wrap justify-center gap-2">{% for download in downloads %}<a class="btn" href="{{ download.href }}">{{ download.label }} <span class="ml-1 text-muted">{{ download.size }}</span></a>{% endfor %}</div>{% endif %}
{% for section in issue.sections %}<section class="mt-14"><h2 class="reader-section-heading">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% if !entry.domain.is_empty() %} ({{ entry.domain }}){% endif %} · {{ entry.reading_minutes }} min</p>{% match entry.summary %}{% when Some with (summary) %}<p class="index-summary mt-4">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}<p class="mt-3 font-sans text-sm text-muted">{{ line }}</p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %} {% for section in issue.sections %}<section class="mt-14"><h2 class="reader-section-heading">{{ section.name }}</h2><div>{% for entry in section.entries %}<article class="border-b border-rule py-6{% if entry.is_lead %} pt-5{% endif %}"><h3 class="font-semibold leading-[1.1] tracking-[-0.01em] {% if entry.is_lead %}text-3xl{% else %}text-2xl{% endif %}"><a class="text-ink no-underline hover:text-accent" href="{{ entry.url }}">{{ entry.title }}</a></h3><p class="mt-2 font-sans text-sm leading-relaxed text-muted">{% match entry.author %}{% when Some with (author) %}{{ author }} · {% when None %}{% endmatch %}{{ entry.source }}{% match entry.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %} · {{ entry.reading_minutes }} min</p>{% match entry.summary %}{% when Some with (summary) %}<p class="index-summary mt-4">{{ summary }}</p>{% when None %}{% endmatch %}{% match entry.why %}{% when Some with (why) %}<p class="mt-4 border-l-2 border-accent pl-3 italic text-ink-2">Why it's here: {{ why }}</p>{% when None %}{% endmatch %}{% match entry.understanding %}{% when Some with (line) %}<p class="mt-3 font-sans text-sm text-muted">{{ line }}</p>{% when None %}{% endmatch %}{% if !entry.comment_links.is_empty() %}<p class="mt-3 flex flex-wrap gap-x-4 gap-y-1 font-sans text-sm text-muted">{% for link in entry.comment_links %}<a class="text-muted hover:text-accent" rel="noopener" target="_blank" href="{{ link.url }}">{{ link.label }}{% if !link.meta.is_empty() %}: {{ link.meta }}{% endif %}</a>{% endfor %}</p>{% endif %}</article>{% endfor %}</div></section>{% endfor %}
<p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p> <p class="mt-10 text-center font-sans text-sm"><a href="/issues">Browse the archive →</a></p>
{% endif %}</article>{% endblock %} {% endif %}</article>{% endblock %}