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]
|
||||
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
|
||||
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(),
|
||||
url: target.url.clone(),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 1,
|
||||
feed_title: host(&target.url),
|
||||
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)]
|
||||
pub struct MinifluxConfig {
|
||||
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`.
|
||||
pub api_key: Option<String>,
|
||||
/// Page size for `GET /v1/entries` (Miniflux caps this at 250).
|
||||
@@ -132,12 +135,29 @@ impl Default for MinifluxConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
base_url: "http://127.0.0.1:8082".into(),
|
||||
public_url: None,
|
||||
api_key: None,
|
||||
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).
|
||||
///
|
||||
/// `bulk` runs triage, deep assessment and every fallback; `editor` runs the
|
||||
@@ -1473,6 +1493,46 @@ mod tests {
|
||||
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]
|
||||
// `Jail::expect_with` dictates the closure's `figment::Error` return type.
|
||||
#[allow(clippy::result_large_err)]
|
||||
|
||||
@@ -1089,6 +1089,7 @@ mod tests {
|
||||
first_seen: "2026-08-15T00:00:00Z".parse().unwrap(),
|
||||
url: format!("https://example.com/{id}"),
|
||||
author: Some("Secret Author".into()),
|
||||
publication: None,
|
||||
feed_id: 9,
|
||||
feed_title: "Secret Feed".into(),
|
||||
category: None,
|
||||
|
||||
@@ -154,6 +154,7 @@ pub(crate) mod tests {
|
||||
first_seen: ts(),
|
||||
url: format!("https://example.com/{id}"),
|
||||
author: Some("A. Writer".into()),
|
||||
publication: None,
|
||||
feed_id: 7,
|
||||
feed_title: "Some Blog".into(),
|
||||
category: Some("Tech".into()),
|
||||
|
||||
@@ -763,6 +763,7 @@ mod tests {
|
||||
first_seen: "2026-08-15T00:00:00Z".parse().unwrap(),
|
||||
url: format!("https://example.com/{id}"),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: feeds.first().copied().unwrap_or(0),
|
||||
feed_title: String::new(),
|
||||
category: None,
|
||||
|
||||
@@ -292,16 +292,18 @@ impl Db {
|
||||
/// Upsert a deduped cluster by canonical URL; returns its `articles.id`.
|
||||
///
|
||||
/// 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> {
|
||||
let sources = serde_json::to_string(&article.sources).unwrap_or_else(|_| "[]".into());
|
||||
let row = sqlx::query(
|
||||
"INSERT INTO articles (canonical_url, title, author, best_entry_id, content_html,
|
||||
word_count, excerpt_only, image_count, sources_json, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
"INSERT INTO articles (canonical_url, title, author, publication, best_entry_id,
|
||||
content_html, word_count, excerpt_only, image_count,
|
||||
sources_json, first_seen)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(canonical_url) DO UPDATE SET
|
||||
title = excluded.title,
|
||||
author = excluded.author,
|
||||
publication = excluded.publication,
|
||||
best_entry_id = excluded.best_entry_id,
|
||||
content_html = excluded.content_html,
|
||||
word_count = excluded.word_count,
|
||||
@@ -313,6 +315,7 @@ impl Db {
|
||||
.bind(&article.canonical_url)
|
||||
.bind(&article.title)
|
||||
.bind(&article.author)
|
||||
.bind(&article.publication)
|
||||
.bind((article.best_entry_id != 0).then_some(article.best_entry_id))
|
||||
.bind(&article.content_html)
|
||||
.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.image_count AS image_count, a.sources_json AS sources_json,
|
||||
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.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
|
||||
@@ -967,6 +971,7 @@ fn article_from_row(row: &sqlx::sqlite::SqliteRow) -> Result<Article> {
|
||||
.get::<Option<String>, _>("entry_url")
|
||||
.unwrap_or_else(|| row.get("canonical_url")),
|
||||
author: row.get("author"),
|
||||
publication: row.get("publication"),
|
||||
feed_id: row.get::<Option<i64>, _>("feed_id").unwrap_or(0),
|
||||
feed_title: row
|
||||
.get::<Option<String>, _>("feed_title")
|
||||
@@ -1193,6 +1198,7 @@ mod tests {
|
||||
first_seen: ts("2026-08-15T05:30:00Z"),
|
||||
url: "https://example.com/1".into(),
|
||||
author: Some("Page Writer".into()),
|
||||
publication: Some("Example Gazette".into()),
|
||||
feed_id: 7,
|
||||
feed_title: "Hacker News".into(),
|
||||
category: None,
|
||||
@@ -1224,6 +1230,7 @@ mod tests {
|
||||
assert_eq!(loaded.social[0].score, 342);
|
||||
assert_eq!(loaded.feed_title, "Hacker News");
|
||||
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.
|
||||
let batch = db.get_articles(&[id, 9_999]).await.unwrap();
|
||||
assert_eq!(batch.len(), 1);
|
||||
@@ -1261,6 +1268,7 @@ mod tests {
|
||||
first_seen: ts("2026-09-06T00:00:00Z"),
|
||||
url: "https://example.com/imported".into(),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 0,
|
||||
feed_title: "Imported".into(),
|
||||
category: None,
|
||||
@@ -1479,6 +1487,7 @@ mod tests {
|
||||
first_seen: ts("2026-08-15T05:30:00Z"),
|
||||
url: "https://example.com/1".into(),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 7,
|
||||
feed_title: "Hacker News".into(),
|
||||
category: None,
|
||||
@@ -1596,6 +1605,7 @@ mod tests {
|
||||
first_seen: ts("2026-08-15T05:30:00Z"),
|
||||
url: "https://example.com/1".into(),
|
||||
author: Some("Page Writer".into()),
|
||||
publication: None,
|
||||
feed_id: 7,
|
||||
feed_title: "Hacker News".into(),
|
||||
category: None,
|
||||
|
||||
@@ -409,6 +409,7 @@ fn build_article(members: Vec<(Entry, String)>, feed_urls: &FeedUrls) -> Article
|
||||
first_seen,
|
||||
url: best_entry.url.clone(),
|
||||
author,
|
||||
publication: None,
|
||||
feed_id: best_entry.feed_id,
|
||||
feed_title: best_entry
|
||||
.feed_title
|
||||
|
||||
@@ -1049,6 +1049,7 @@ mod tests {
|
||||
first_seen: ts("2026-09-07T00:00:00Z"),
|
||||
url: url.into(),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 1,
|
||||
feed_title: "Feed".into(),
|
||||
category: None,
|
||||
|
||||
+48
-3
@@ -11,7 +11,7 @@ use crate::comments;
|
||||
use crate::html::{text_escape, to_xhtml};
|
||||
use crate::images;
|
||||
use crate::types::{
|
||||
BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote,
|
||||
Article, BehindThePaper, Edition, ImageAsset, Issue, NearMiss, Pick, SocialRef, Vote,
|
||||
WORLD_BRIEFING_SECTION,
|
||||
};
|
||||
use crate::world;
|
||||
@@ -363,6 +363,13 @@ pub fn section_names(issue: &Issue) -> Vec<String> {
|
||||
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
|
||||
/// summary, linked to its chapter (§3.10).
|
||||
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 {
|
||||
href: article_href(pick),
|
||||
title: pick.article.title.clone(),
|
||||
source: pick.article.feed_title.clone(),
|
||||
source: source_line(&pick.article),
|
||||
reading_minutes: pick.article.reading_minutes(),
|
||||
summary: summary_for(issue, pick).unwrap_or_default().to_string(),
|
||||
why: pick.why.clone(),
|
||||
@@ -437,7 +444,7 @@ pub fn render_article(
|
||||
hmac_secret: Option<&str>,
|
||||
) -> Result<Chapter, EpubError> {
|
||||
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) {
|
||||
meta_parts.push(date);
|
||||
}
|
||||
@@ -879,6 +886,44 @@ mod tests {
|
||||
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]
|
||||
fn article_chapter_has_header_body_and_footer() {
|
||||
let issue = issue();
|
||||
|
||||
@@ -35,6 +35,7 @@ pub fn article(id: ArticleId, entry_id: EntryId, title: &str) -> Article {
|
||||
first_seen: timestamp(),
|
||||
url: format!("https://example.com/{entry_id}"),
|
||||
author: Some("A. Writer".into()),
|
||||
publication: None,
|
||||
feed_id: 7,
|
||||
feed_title: "Example Feed".into(),
|
||||
category: Some("Tech".into()),
|
||||
|
||||
+44
-22
@@ -198,6 +198,7 @@ impl Extractor {
|
||||
Extracted {
|
||||
content_html,
|
||||
author: None,
|
||||
publication: None,
|
||||
word_count: words,
|
||||
excerpt_only,
|
||||
image_urls,
|
||||
@@ -280,11 +281,12 @@ impl Extractor {
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
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 {
|
||||
title,
|
||||
html,
|
||||
author,
|
||||
site_name,
|
||||
final_url,
|
||||
})
|
||||
}
|
||||
@@ -297,6 +299,7 @@ impl Extractor {
|
||||
Extracted {
|
||||
content_html: clean,
|
||||
author: page.author.clone(),
|
||||
publication: page.site_name.clone(),
|
||||
word_count: words,
|
||||
excerpt_only: looks_paywalled(requested_url, words, &self.paywall_domains),
|
||||
image_urls,
|
||||
@@ -314,6 +317,8 @@ pub struct Page {
|
||||
pub html: String,
|
||||
/// Readability's normalized byline for the page.
|
||||
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.
|
||||
pub final_url: String,
|
||||
}
|
||||
@@ -326,10 +331,13 @@ pub struct Page {
|
||||
/// their image with them) and its lazy-image heuristic overwrites a perfectly
|
||||
/// good `src` with whatever other attribute happens to contain `.jpg`.
|
||||
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 config = dom_smoothie::Config {
|
||||
max_elements_to_parse: 60_000,
|
||||
@@ -345,17 +353,18 @@ fn readable_page(html: &str, url: &str) -> Result<(String, String, Option<String
|
||||
Ok((
|
||||
parsed.title.trim().to_string(),
|
||||
content,
|
||||
normalize_author(parsed.byline),
|
||||
normalize_meta_text(parsed.byline),
|
||||
normalize_meta_text(parsed.site_name),
|
||||
))
|
||||
}
|
||||
|
||||
fn normalize_author(author: Option<String>) -> Option<String> {
|
||||
let author = author?;
|
||||
if author.chars().any(|c| matches!(c, '\n' | '\r')) {
|
||||
fn normalize_meta_text(text: Option<String>) -> Option<String> {
|
||||
let text = text?;
|
||||
if text.chars().any(|c| matches!(c, '\n' | '\r')) {
|
||||
return None;
|
||||
}
|
||||
let author = author.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
(!author.is_empty() && author.chars().count() <= 100).then_some(author)
|
||||
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
(!text.is_empty() && text.chars().count() <= 100).then_some(text)
|
||||
}
|
||||
|
||||
/// Copy an [`Extracted`] onto its [`Article`].
|
||||
@@ -371,6 +380,9 @@ pub fn apply(article: &mut Article, extracted: Extracted) {
|
||||
{
|
||||
article.author = Some(author);
|
||||
}
|
||||
if let Some(publication) = extracted.publication {
|
||||
article.publication = Some(publication);
|
||||
}
|
||||
}
|
||||
|
||||
fn merge_paywall_domains(configured: Vec<String>) -> Vec<String> {
|
||||
@@ -518,6 +530,7 @@ mod tests {
|
||||
first_seen: ts(),
|
||||
url: url.into(),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 1,
|
||||
feed_title: "Feed".into(),
|
||||
category: None,
|
||||
@@ -745,10 +758,11 @@ mod tests {
|
||||
<article><h1>A Post</h1><p>{paragraph}</p><p>{paragraph}</p></article>\
|
||||
<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");
|
||||
assert_eq!(title, "A Post");
|
||||
assert_eq!(author, None);
|
||||
assert_eq!(site_name, None);
|
||||
assert!(content.contains("Readability keeps the body copy"));
|
||||
let clean = sanitize_with_base(&content, "https://blog.dev/p");
|
||||
assert!(word_count(&clean) > 200);
|
||||
@@ -756,53 +770,60 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readable_page_plumbs_meta_author_through_extracted() {
|
||||
fn readable_page_plumbs_meta_author_and_og_site_name_through_extracted() {
|
||||
let html = format!(
|
||||
"<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>",
|
||||
"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");
|
||||
let page = Page {
|
||||
title,
|
||||
html: content,
|
||||
author,
|
||||
site_name,
|
||||
final_url: "https://blog.dev/p".into(),
|
||||
};
|
||||
let extracted = Extractor::offline(vec![]).finish_readable(&page.final_url, &page);
|
||||
assert_eq!(extracted.author.as_deref(), Some("Jane Dev"));
|
||||
assert_eq!(extracted.publication.as_deref(), Some("Dev Journal"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn readable_page_plumbs_json_ld_author_through_extracted() {
|
||||
fn readable_page_plumbs_json_ld_author_and_publisher_through_extracted() {
|
||||
let html = format!(
|
||||
r#"<html><head><title>A Post</title>
|
||||
<script type="application/ld+json">{{
|
||||
"@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>
|
||||
<body><article><h1>A Post</h1><p>{}</p></article></body></html>"#,
|
||||
"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");
|
||||
let page = Page {
|
||||
title,
|
||||
html: content,
|
||||
author,
|
||||
site_name,
|
||||
final_url: "https://blog.dev/p".into(),
|
||||
};
|
||||
let extracted = Extractor::offline(vec![]).finish_readable(&page.final_url, &page);
|
||||
assert_eq!(extracted.author.as_deref(), Some("Alex Writer"));
|
||||
assert_eq!(extracted.publication.as_deref(), Some("Example Gazette"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_uses_page_author_with_feed_precedence() {
|
||||
fn apply_uses_page_metadata() {
|
||||
let extracted = |author: &str| Extracted {
|
||||
content_html: "<p>body</p>".into(),
|
||||
author: Some(author.into()),
|
||||
publication: Some("Example Gazette".into()),
|
||||
word_count: 1,
|
||||
excerpt_only: false,
|
||||
image_urls: vec![],
|
||||
@@ -814,6 +835,7 @@ mod tests {
|
||||
aggregator.author = Some("Submitter".into());
|
||||
apply(&mut aggregator, extracted("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", "");
|
||||
direct.author = Some("Feed Writer".into());
|
||||
@@ -826,13 +848,13 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn implausible_page_authors_are_dropped() {
|
||||
fn implausible_page_metadata_is_dropped() {
|
||||
assert_eq!(
|
||||
normalize_author(Some(" Jane Dev ".into())).as_deref(),
|
||||
normalize_meta_text(Some(" Jane Dev ".into())).as_deref(),
|
||||
Some("Jane Dev")
|
||||
);
|
||||
assert_eq!(normalize_author(Some("Jane\nDev".into())), None);
|
||||
assert_eq!(normalize_author(Some("x".repeat(101))), None);
|
||||
assert_eq!(normalize_author(Some(" ".into())), None);
|
||||
assert_eq!(normalize_meta_text(Some("Jane\nDev".into())), None);
|
||||
assert_eq!(normalize_meta_text(Some("x".repeat(101))), None);
|
||||
assert_eq!(normalize_meta_text(Some(" ".into())), None);
|
||||
}
|
||||
}
|
||||
@@ -167,6 +167,7 @@ fn imported_article(
|
||||
first_seen,
|
||||
url: page.final_url,
|
||||
author: extracted.author,
|
||||
publication: extracted.publication,
|
||||
feed_id: 0,
|
||||
feed_title: "Imported".into(),
|
||||
category: None,
|
||||
@@ -379,6 +380,7 @@ mod tests {
|
||||
first_seen: now,
|
||||
url: url.into(),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 7,
|
||||
feed_title: "Feed".into(),
|
||||
category: None,
|
||||
@@ -484,6 +486,7 @@ mod tests {
|
||||
title: "A historical essay".into(),
|
||||
html: "<article><p>Useful old writing.</p><script>bad()</script><img src=\"/chart.png\"></article>".into(),
|
||||
author: Some("Essay Writer".into()),
|
||||
site_name: Some("Example Review".into()),
|
||||
final_url: "https://example.com/essays/old".into(),
|
||||
};
|
||||
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.feed_title, "Imported");
|
||||
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.content_html.contains("script"));
|
||||
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.feed_title, "Imported");
|
||||
assert_eq!(loaded.author.as_deref(), Some("Essay Writer"));
|
||||
assert_eq!(loaded.publication.as_deref(), Some("Example Review"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -1530,6 +1530,7 @@ mod tests {
|
||||
first_seen: now(),
|
||||
url,
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 100 + entry_id,
|
||||
feed_title: format!("Feed {entry_id}"),
|
||||
category: None,
|
||||
|
||||
@@ -972,6 +972,7 @@ mod tests {
|
||||
first_seen: ts("2026-08-15T05:30:00Z"),
|
||||
url: "https://example.com/1".into(),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 7,
|
||||
feed_title: "Hacker News".into(),
|
||||
category: None,
|
||||
|
||||
@@ -328,6 +328,7 @@ mod tests {
|
||||
first_seen: ts("2026-08-15T05:00:00Z"),
|
||||
url: url.into(),
|
||||
author: None,
|
||||
publication: None,
|
||||
feed_id: 1,
|
||||
feed_title: "Feed".into(),
|
||||
category: None,
|
||||
|
||||
@@ -92,6 +92,8 @@ pub struct Extracted {
|
||||
pub content_html: String,
|
||||
/// Author discovered in the fetched page, if any.
|
||||
pub author: Option<String>,
|
||||
/// Site name discovered in the fetched page, if any.
|
||||
pub publication: Option<String>,
|
||||
pub word_count: i64,
|
||||
/// True when we only have an excerpt/paywall stub — penalized in pre-filter.
|
||||
pub excerpt_only: bool,
|
||||
@@ -124,6 +126,8 @@ pub struct Article {
|
||||
// --- joined / derived fields ---
|
||||
pub url: String,
|
||||
pub author: Option<String>,
|
||||
/// Page-extracted site name persisted on `articles`.
|
||||
pub publication: Option<String>,
|
||||
pub feed_id: FeedId,
|
||||
pub feed_title: String,
|
||||
pub category: Option<String>,
|
||||
@@ -150,6 +154,19 @@ impl Article {
|
||||
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).
|
||||
pub fn chapter_id(&self) -> String {
|
||||
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
|
||||
}
|
||||
|
||||
/// 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)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -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]
|
||||
fn composite_social_score_matches_spec_formula() {
|
||||
let refs = vec![
|
||||
|
||||
@@ -88,7 +88,7 @@ struct FeedRow {
|
||||
/// timestamps sit in the cell's tooltip.
|
||||
seen: 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>,
|
||||
}
|
||||
|
||||
@@ -157,10 +157,9 @@ async fn index(
|
||||
None => load_evidence(db, &config, &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
|
||||
.iter()
|
||||
.map(|candidate| row(candidate, &evidence, &titles, &config, &base_url))
|
||||
.map(|candidate| row(candidate, &evidence, &titles, &config))
|
||||
.collect();
|
||||
|
||||
// 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>,
|
||||
titles: &HashMap<ArticleId, String>,
|
||||
config: &Config,
|
||||
base_url: &str,
|
||||
) -> FeedRow {
|
||||
let mut scored = evidence_of(candidate, evidence);
|
||||
let score = discovery::score(&scored);
|
||||
@@ -433,7 +431,7 @@ fn row(
|
||||
decided_at: fmt_stored_time(candidate.decided_at.as_deref(), config),
|
||||
miniflux_href: candidate
|
||||
.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}");
|
||||
let added =
|
||||
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]
|
||||
|
||||
@@ -218,6 +218,7 @@ pub const RESTART_REQUIRED: &[&str] = &[
|
||||
/// Optional keys that `Config::default()` leaves unset (and therefore do not
|
||||
/// appear when the defaults are serialized), with the kind they take.
|
||||
const OPTIONAL_KEYS: &[(&str, FieldKind)] = &[
|
||||
("miniflux.public_url", FieldKind::Text),
|
||||
("miniflux.api_key", FieldKind::Secret),
|
||||
("providers.*.api_key", FieldKind::Secret),
|
||||
("providers.*.effort", FieldKind::Text),
|
||||
@@ -296,6 +297,7 @@ pub const SETTINGS_HELP: &[(&str, &str)] = &[
|
||||
("profile_path", "Hand-maintained reader profile, loaded every run."),
|
||||
("interests_opml", "Scour interests OPML merged with the profile interests."),
|
||||
("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.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)."),
|
||||
|
||||
+78
-6
@@ -12,12 +12,14 @@ use jiff::civil::Date;
|
||||
use serde::Deserialize;
|
||||
use sqlx::Row;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::db::Db;
|
||||
use crate::epub::chapters;
|
||||
use crate::pipeline::display_date;
|
||||
use crate::server::AppState;
|
||||
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::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)]
|
||||
struct FullEntry {
|
||||
title: String,
|
||||
href: String,
|
||||
dashboard_href: String,
|
||||
source: String,
|
||||
source: Source,
|
||||
reading_minutes: i64,
|
||||
is_lead: bool,
|
||||
summary: String,
|
||||
@@ -926,6 +947,7 @@ struct ArticleTemplate {
|
||||
title: String,
|
||||
source_url: String,
|
||||
byline: Option<String>,
|
||||
source: Source,
|
||||
meta_line: String,
|
||||
why: Option<String>,
|
||||
social_line: Option<String>,
|
||||
@@ -979,6 +1001,7 @@ pub async fn render_full(
|
||||
) -> Result<Response, WebError> {
|
||||
let date = view.issue.meta.date;
|
||||
let is_admin = viewer.role == crate::web::users::Role::Admin;
|
||||
let config = state.config();
|
||||
let current = if is_admin {
|
||||
rate::current_for_issue(state, date).await?
|
||||
} else {
|
||||
@@ -998,7 +1021,7 @@ pub async fn render_full(
|
||||
title: pick.article.title.clone(),
|
||||
href: article_href(date, 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(),
|
||||
is_lead: pick.is_lead,
|
||||
summary: summary_for(&view.issue, pick)
|
||||
@@ -1090,6 +1113,8 @@ pub async fn article(
|
||||
href: article_href(date, next.article.id),
|
||||
});
|
||||
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 mut page = Page::new(article.title.clone(), Some(viewer.clone()), active_nav);
|
||||
page.flash = take_flash(&session).await?;
|
||||
@@ -1099,9 +1124,9 @@ pub async fn article(
|
||||
title: article.title.clone(),
|
||||
source_url: article.canonical_url.clone(),
|
||||
byline: article.author.as_ref().map(|author| format!("By {author}")),
|
||||
source: Source::new(article, &config, is_admin),
|
||||
meta_line: format!(
|
||||
"{} · {} words · ~{} min read",
|
||||
article.feed_title,
|
||||
"{} words · ~{} min read",
|
||||
thousands(article.word_count),
|
||||
article.reading_minutes()
|
||||
),
|
||||
@@ -1117,7 +1142,7 @@ pub async fn article(
|
||||
.map(|discussion| crate::comments::render_xhtml(discussion, &article.title)),
|
||||
read_online_url: article.url.clone(),
|
||||
dashboard_href: format!("/dashboard/articles/{}", article.id),
|
||||
rating: (viewer.role == crate::web::users::Role::Admin).then(|| {
|
||||
rating: is_admin.then(|| {
|
||||
RatingWidget::for_issue(
|
||||
article.id,
|
||||
date,
|
||||
@@ -2047,6 +2072,53 @@ mod tests {
|
||||
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]
|
||||
async fn fallback_full_issue_recovers_colophon_world_and_behind() {
|
||||
let (_dir, db, source) = seeded_issue(false).await;
|
||||
|
||||
+6
-10
@@ -49,7 +49,7 @@ pub struct PublicEntry {
|
||||
pub url: String,
|
||||
pub author: Option<String>,
|
||||
pub source: String,
|
||||
pub domain: String,
|
||||
pub publication: Option<String>,
|
||||
pub reading_minutes: i64,
|
||||
pub word_count: i64,
|
||||
pub summary: Option<String>,
|
||||
@@ -116,7 +116,7 @@ impl From<&Issue> for PublicIssue {
|
||||
url: article.canonical_url.clone(),
|
||||
author: article.author.clone(),
|
||||
source: article.feed_title.clone(),
|
||||
domain: domain(&article.canonical_url),
|
||||
publication: article.publication_label(),
|
||||
reading_minutes: article.reading_minutes(),
|
||||
word_count: article.word_count,
|
||||
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)]
|
||||
#[template(path = "issue_public.html")]
|
||||
struct IssuePublicTemplate {
|
||||
@@ -434,6 +426,10 @@ mod tests {
|
||||
fn public_issue_shows_summaries_and_why_but_no_bodies() {
|
||||
let source = crate::epub::fixtures::issue();
|
||||
let public = PublicIssue::from(&source);
|
||||
assert_eq!(
|
||||
public.sections[0].entries[0].publication.as_deref(),
|
||||
Some("example.com")
|
||||
);
|
||||
let html = IssuePublicTemplate {
|
||||
page: Page::new("Test issue", None, "latest"),
|
||||
issue: public,
|
||||
|
||||
+18
-6
@@ -132,9 +132,19 @@ if (tocPanel) {
|
||||
const tocStatus = document.querySelector("[data-toc-status]");
|
||||
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
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.
|
||||
const revealCurrent = () => {
|
||||
const revealCurrent = (behavior = "smooth") => {
|
||||
const current = tocPanel.querySelector(".toc-link[aria-current]");
|
||||
if (!current || tocPanel.scrollHeight <= tocPanel.clientHeight) return;
|
||||
const stickyHeader = tocPanel.querySelector(":scope > div");
|
||||
@@ -147,7 +157,7 @@ if (tocPanel) {
|
||||
const visibleBottom = tocPanel.scrollTop + tocPanel.clientHeight - revealMargin;
|
||||
if (top >= visibleTop && top + currentRect.height <= visibleBottom) return;
|
||||
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 = () => {
|
||||
if (tocPanel.dataset.open !== "true" || largeScreen.matches || !tocBar) return;
|
||||
@@ -168,7 +178,7 @@ if (tocPanel) {
|
||||
}
|
||||
};
|
||||
setOpen(false);
|
||||
revealCurrent();
|
||||
revealCurrent("auto");
|
||||
if (tocToggle) {
|
||||
tocToggle.addEventListener("click", () => setOpen(tocPanel.dataset.open !== "true"));
|
||||
document.addEventListener("click", (event) => {
|
||||
@@ -196,7 +206,7 @@ if (tocPanel) {
|
||||
});
|
||||
};
|
||||
if (currentLink) markPassed(currentLink);
|
||||
const setCurrent = (link) => {
|
||||
const setCurrent = (link, behavior = "smooth") => {
|
||||
if (!link || (link === currentLink && link.getAttribute("aria-current") === "location")) return;
|
||||
tocLinks.forEach((candidate) => candidate.removeAttribute("aria-current"));
|
||||
markPassed(link);
|
||||
@@ -216,7 +226,7 @@ if (tocPanel) {
|
||||
delete bar.dataset.live;
|
||||
bar.value = position;
|
||||
});
|
||||
revealCurrent();
|
||||
revealCurrent(behavior);
|
||||
};
|
||||
|
||||
const tocEntries = Array.from(document.querySelectorAll("[data-toc-entry]")).map((entry) => ({
|
||||
@@ -225,6 +235,7 @@ if (tocPanel) {
|
||||
})).filter(({ link }) => link);
|
||||
if (tocEntries.length) {
|
||||
let queued = false;
|
||||
let firstPaint = true;
|
||||
const paintCurrent = () => {
|
||||
queued = false;
|
||||
const threshold = (tocBar ? tocBar.offsetHeight : 0) + window.innerHeight / 3;
|
||||
@@ -232,7 +243,8 @@ if (tocPanel) {
|
||||
tocEntries.forEach(({ entry, link }) => {
|
||||
if (entry.getBoundingClientRect().top <= threshold) next = link;
|
||||
});
|
||||
setCurrent(next);
|
||||
setCurrent(next, firstPaint ? "auto" : "smooth");
|
||||
firstPaint = false;
|
||||
};
|
||||
const scheduleCurrent = () => {
|
||||
if (queued) return;
|
||||
|
||||
+31
-2
@@ -31,9 +31,38 @@
|
||||
} 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) => {
|
||||
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 });
|
||||
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>
|
||||
<progress class="toc-progress absolute inset-x-0 -bottom-px" data-toc-progress value="{{ toc.position }}" max="{{ toc.total }}" aria-hidden="true"></progress>
|
||||
</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">
|
||||
<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>
|
||||
|
||||
@@ -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" %}
|
||||
<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>
|
||||
{% 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>
|
||||
|
||||
@@ -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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
{% 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>
|
||||
|
||||
@@ -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>
|
||||
<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 %}
|
||||
{% 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>
|
||||
{% endif %}</article>{% endblock %}
|
||||
Reference in new issue
Block a user