Add feed.json alternate to feed.xml

Mainly so my glance web dashboard can read the feed.
This commit is contained in:
2026-09-19 18:22:16 +00:00
parent f1b1e7fd98
commit 525ddfd88d
4 changed files with 157 additions and 24 deletions
+2 -2
View File
@@ -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=<hash>` | `public, max-age=31536000, immutable` |
+52 -7
View File
@@ -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("<h2>"), "{content_html}");
assert!(
!item["date_published"].as_str().unwrap().is_empty(),
"{item}"
);
let robots = app
.clone()
.oneshot(
+1
View File
@@ -708,6 +708,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
.route("/issues", get(public::archive))
.route("/issues/{date}", get(public::show_issue))
.route("/feed.xml", get(public::feed))
.route("/feed.json", get(public::feed_json))
.route("/robots.txt", get(public::robots))
.route("/static/{file}", get(static_asset))
.merge(access_routes)
+102 -15
View File
@@ -4,6 +4,7 @@ use axum::http::{HeaderMap, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum_login::tower_sessions::Session;
use jiff::civil::Date;
use serde::Serialize;
use crate::server::AppState;
use crate::types::{Issue, SocialSource};
@@ -289,10 +290,25 @@ pub async fn archive(
Ok(public_cache(response, &headers))
}
pub async fn feed(State(state): State<AppState>) -> Result<Response, WebError> {
/// 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 `<updated>`).
async fn feed_entries(state: &AppState) -> Result<(Vec<FeedEntry>, 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<AppState>) -> Result<Response, WebError> {
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<AppState>) -> Result<Response, WebError> {
let config = state.config();
let (issues, updated) = feed_entries(&state).await?;
let mut entries = String::new();
for entry in &issues {
entries.push_str(&format!(
"<entry><id>tag:{},{}:issue/{}</id><title>The Daily EPUB — {}</title><updated>{}</updated><link rel=\"alternate\" href=\"{}\"/><content type=\"html\">{}</content></entry>",
feed_host(&config.server.public_url),
issue.date.year(),
issue.date,
issue.date,
issue.generated_at,
xml_escape(&href),
xml_escape(&content),
"<entry><id>{}</id><title>{}</title><updated>{}</updated><link rel=\"alternate\" href=\"{}\"/><content type=\"html\">{}</content></entry>",
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<AppState>) -> Result<Response, WebError> {
.into_response())
}
/// JSON Feed 1.1 <https://jsonfeed.org/version/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<JsonFeedItem<'a>>,
}
#[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<AppState>) -> Result<Response, WebError> {
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,