Compare commits
5
Commits
c707f432f2
...
f2a44507d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f2a44507d4 | ||
|
|
87f3a5dbf1 | ||
|
|
f71a4c2799 | ||
|
|
8a8d5d1f16 | ||
|
|
c8df55e91a |
No files matched your search
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
-- Persist page-extracted publication names independently of the best Miniflux entry.
|
||||||
|
|
||||||
|
ALTER TABLE articles ADD COLUMN publication TEXT;
|
||||||
@@ -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)]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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 · 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();
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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![
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 });
|
||||||
})();
|
})();
|
||||||
@@ -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 %}
|
||||||
@@ -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,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 @@
|
|||||||
{% 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 %}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 %}
|
||||||
Reference in new issue
Block a user