Show the publication after the feed name, and link feeds to Miniflux for admins
Readers see "Feed · Publication" on the issue, article, public and RSS pages and in the EPUB index and chapter headers; the publication is the page's site name, else its domain, and is dropped when it would repeat the feed's title. Admins get the feed name linked to its Miniflux page. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YWmCpUojfHXhSZ2129Z7Nv
This commit is contained in:
+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();
|
||||||
|
|||||||
+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;
|
||||||
|
|||||||
+7
-3
@@ -6,7 +6,7 @@ use axum_login::tower_sessions::Session;
|
|||||||
use jiff::civil::Date;
|
use jiff::civil::Date;
|
||||||
|
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
use crate::types::{Issue, SocialSource, domain};
|
use crate::types::{Issue, SocialSource};
|
||||||
use crate::web::issue::{self, Download};
|
use crate::web::issue::{self, Download};
|
||||||
use crate::web::session::{AuthSession, Viewer};
|
use crate::web::session::{AuthSession, Viewer};
|
||||||
use crate::web::{Html, Page, WebError};
|
use crate::web::{Html, Page, WebError};
|
||||||
@@ -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).unwrap_or_default(),
|
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
|
||||||
@@ -426,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,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
{% match source.feed_href %}{% when Some with (href) %}<a class="text-muted hover:text-accent" href="{{ href }}" target="_blank" rel="noopener">{{ source.feed_title }}</a>{% when None %}{{ source.feed_title }}{% endmatch %}{% match source.publication %}{% when Some with (publication) %} · {{ publication }}{% when None %}{% endmatch %}
|
||||||
@@ -1,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