diff --git a/README.md b/README.md index 1921798..a687f3e 100644 --- a/README.md +++ b/README.md @@ -290,7 +290,7 @@ articles do not yet have embeddings to compare. | Route | Access | Purpose | |---|---|---| -| `GET /`, `/issues`, `/issues/{date}`, `/feed.xml` | Public | Latest issue, archive, stripped issue index, and equivalent Atom feed. Signed-in issue views expand to the complete issue. | +| `GET /`, `/issues`, `/issues/{date}`, `/feed.xml`, `/feed.json` | Public | Latest issue, archive, stripped issue index, and the equivalent Atom and JSON Feed 1.1 feeds. Signed-in issue views expand to the complete issue. | | `GET /issues/{date}/articles/{id}`, `/world`, `/behind` | User or admin | Private article, World Briefing, and Behind the paper chapters. | | `GET /issues/{date}/read` | User or admin | Open the Standard edition in BookOrbit's web reader when the integration is enabled. | | `GET /robots.txt`, `/static/{file}` | Public | Crawler policy and embedded CSS, JavaScript, and favicon. | @@ -721,7 +721,7 @@ toggle and nothing else. The whole matrix: | route | `Cache-Control` | |---|---| -| `/`, `/issues`, `/issues/{date}`, `/feed.xml`, `/issues.json` (anonymous) | `public, max-age=300` | +| `/`, `/issues`, `/issues/{date}`, `/feed.xml`, `/feed.json`, `/issues.json` (anonymous) | `public, max-age=300` | | the same pages with a `daily_session=` cookie | `private, no-store` | | `/robots.txt` | `public, max-age=86400` | | `/static/*?v=` | `public, max-age=31536000, immutable` | diff --git a/src/web/issue.rs b/src/web/issue.rs index 4df2265..8982505 100644 --- a/src/web/issue.rs +++ b/src/web/issue.rs @@ -1888,19 +1888,64 @@ mod tests { ) .unwrap(); let document = roxmltree::Document::parse(&feed).unwrap(); - assert_eq!( - document - .descendants() - .filter(|node| node.tag_name().name() == "entry") - .count(), - 1 - ); + let atom_entries = document + .descendants() + .filter(|node| node.tag_name().name() == "entry") + .count(); + assert_eq!(atom_entries, 1); assert!(feed.contains("What it argues, and why it is worth the time.")); assert!(!feed.contains("Something happened")); assert!(!feed.contains("Two stories today")); assert!(!feed.contains("Body of")); assert!(!feed.contains("write path")); + let json = app + .clone() + .oneshot( + Request::builder() + .uri("/feed.json") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + json.headers().get(header::CONTENT_TYPE).unwrap(), + "application/feed+json; charset=utf-8" + ); + assert_eq!( + json.headers().get(header::CACHE_CONTROL).unwrap(), + "public, max-age=300" + ); + let json: serde_json::Value = + serde_json::from_slice(&to_bytes(json.into_body(), 1024 * 1024).await.unwrap()) + .unwrap(); + assert_eq!(json["version"], "https://jsonfeed.org/version/1.1"); + let items = json["items"].as_array().unwrap(); + assert_eq!(items.len(), atom_entries); + let item = &items[0]; + assert!(item["id"].as_str().unwrap().starts_with("tag:"), "{item}"); + assert!( + item["url"] + .as_str() + .unwrap() + .ends_with(&format!("/issues/{}", source.meta.date)), + "{item}" + ); + assert!( + item["title"] + .as_str() + .unwrap() + .starts_with("The Daily EPUB — "), + "{item}" + ); + let content_html = item["content_html"].as_str().unwrap(); + assert!(content_html.contains("

"), "{content_html}"); + assert!( + !item["date_published"].as_str().unwrap().is_empty(), + "{item}" + ); + let robots = app .clone() .oneshot( diff --git a/src/web/mod.rs b/src/web/mod.rs index 71cefea..ec5fb46 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -708,6 +708,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router) -> Result { +/// One issue as both public feeds carry it. +/// +/// Atom and JSON Feed differ only in how these five fields are spelled out, so +/// the expensive part — loading the issue and rendering `feed_entry.html` — is +/// done once, by [`feed_entries`], and shared. +struct FeedEntry { + id: String, + href: String, + title: String, + generated_at: jiff::Timestamp, + content: String, +} + +/// The last 30 issues, newest first, plus the newest `generated_at` among them +/// (the feed-level ``). +async fn feed_entries(state: &AppState) -> Result<(Vec, jiff::Timestamp), WebError> { let config = state.config(); let rows = state.db.issue_dates(Some(30)).await?; - let mut entries = String::new(); + let mut entries = Vec::new(); let mut updated = jiff::Timestamp::UNIX_EPOCH; for row in rows { let Some(view) = issue::load(&state.db, &config, row.date).await? else { @@ -306,20 +322,38 @@ pub async fn feed(State(state): State) -> Result { let content = FeedEntryTemplate { issue: &issue }.render(); crate::web::timing::record_render(started.elapsed()); let content = content.map_err(|error| WebError::Internal(error.into()))?; - let href = format!( - "{}/issues/{}", - config.server.public_url.trim_end_matches('/'), - issue.date - ); + entries.push(FeedEntry { + id: format!( + "tag:{},{}:issue/{}", + feed_host(&config.server.public_url), + issue.date.year(), + issue.date + ), + href: format!( + "{}/issues/{}", + config.server.public_url.trim_end_matches('/'), + issue.date + ), + title: format!("The Daily EPUB — {}", issue.date), + generated_at: issue.generated_at, + content, + }); + } + Ok((entries, updated)) +} + +pub async fn feed(State(state): State) -> Result { + let config = state.config(); + let (issues, updated) = feed_entries(&state).await?; + let mut entries = String::new(); + for entry in &issues { entries.push_str(&format!( - "tag:{},{}:issue/{}The Daily EPUB — {}{}{}", - feed_host(&config.server.public_url), - issue.date.year(), - issue.date, - issue.date, - issue.generated_at, - xml_escape(&href), - xml_escape(&content), + "{}{}{}{}", + entry.id, + entry.title, + entry.generated_at, + xml_escape(&entry.href), + xml_escape(&entry.content), )); } let home = config.server.public_url.trim_end_matches('/'); @@ -341,6 +375,59 @@ pub async fn feed(State(state): State) -> Result { .into_response()) } +/// JSON Feed 1.1 , the same issues `/feed.xml` +/// carries. +#[derive(Serialize)] +struct JsonFeed<'a> { + version: &'static str, + title: &'static str, + home_page_url: &'a str, + feed_url: String, + items: Vec>, +} + +#[derive(Serialize)] +struct JsonFeedItem<'a> { + id: &'a str, + url: &'a str, + title: &'a str, + /// The rendered `feed_entry.html`, raw: JSON escaping is serde's job. + content_html: &'a str, + date_published: String, +} + +pub async fn feed_json(State(state): State) -> Result { + let config = state.config(); + let (entries, _updated) = feed_entries(&state).await?; + let home = config.server.public_url.trim_end_matches('/'); + let body = serde_json::to_string(&JsonFeed { + version: "https://jsonfeed.org/version/1.1", + title: "The Daily EPUB", + home_page_url: home, + feed_url: format!("{home}/feed.json"), + items: entries + .iter() + .map(|entry| JsonFeedItem { + id: &entry.id, + url: &entry.href, + title: &entry.title, + content_html: &entry.content, + date_published: entry.generated_at.to_string(), + }) + .collect(), + }) + .map_err(|error| WebError::Internal(error.into()))?; + Ok(( + StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/feed+json; charset=utf-8"), + (header::CACHE_CONTROL, PUBLIC_CACHE), + ], + body, + ) + .into_response()) +} + pub async fn robots() -> Response { ( StatusCode::OK,