Add feed.json alternate to feed.xml
Mainly so my glance web dashboard can read the feed.
This commit is contained in:
@@ -290,7 +290,7 @@ articles do not yet have embeddings to compare.
|
|||||||
|
|
||||||
| Route | Access | Purpose |
|
| 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}/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 /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. |
|
| `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` |
|
| 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` |
|
| the same pages with a `daily_session=` cookie | `private, no-store` |
|
||||||
| `/robots.txt` | `public, max-age=86400` |
|
| `/robots.txt` | `public, max-age=86400` |
|
||||||
| `/static/*?v=<hash>` | `public, max-age=31536000, immutable` |
|
| `/static/*?v=<hash>` | `public, max-age=31536000, immutable` |
|
||||||
|
|||||||
+52
-7
@@ -1888,19 +1888,64 @@ mod tests {
|
|||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
let document = roxmltree::Document::parse(&feed).unwrap();
|
let document = roxmltree::Document::parse(&feed).unwrap();
|
||||||
assert_eq!(
|
let atom_entries = document
|
||||||
document
|
.descendants()
|
||||||
.descendants()
|
.filter(|node| node.tag_name().name() == "entry")
|
||||||
.filter(|node| node.tag_name().name() == "entry")
|
.count();
|
||||||
.count(),
|
assert_eq!(atom_entries, 1);
|
||||||
1
|
|
||||||
);
|
|
||||||
assert!(feed.contains("What it argues, and why it is worth the time."));
|
assert!(feed.contains("What it argues, and why it is worth the time."));
|
||||||
assert!(!feed.contains("Something happened"));
|
assert!(!feed.contains("Something happened"));
|
||||||
assert!(!feed.contains("Two stories today"));
|
assert!(!feed.contains("Two stories today"));
|
||||||
assert!(!feed.contains("Body of"));
|
assert!(!feed.contains("Body of"));
|
||||||
assert!(!feed.contains("write path"));
|
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
|
let robots = app
|
||||||
.clone()
|
.clone()
|
||||||
.oneshot(
|
.oneshot(
|
||||||
|
|||||||
@@ -708,6 +708,7 @@ pub fn router(config: &crate::config::Config) -> axum::Router<crate::server::App
|
|||||||
.route("/issues", get(public::archive))
|
.route("/issues", get(public::archive))
|
||||||
.route("/issues/{date}", get(public::show_issue))
|
.route("/issues/{date}", get(public::show_issue))
|
||||||
.route("/feed.xml", get(public::feed))
|
.route("/feed.xml", get(public::feed))
|
||||||
|
.route("/feed.json", get(public::feed_json))
|
||||||
.route("/robots.txt", get(public::robots))
|
.route("/robots.txt", get(public::robots))
|
||||||
.route("/static/{file}", get(static_asset))
|
.route("/static/{file}", get(static_asset))
|
||||||
.merge(access_routes)
|
.merge(access_routes)
|
||||||
|
|||||||
+102
-15
@@ -4,6 +4,7 @@ use axum::http::{HeaderMap, StatusCode, header};
|
|||||||
use axum::response::{IntoResponse, Response};
|
use axum::response::{IntoResponse, Response};
|
||||||
use axum_login::tower_sessions::Session;
|
use axum_login::tower_sessions::Session;
|
||||||
use jiff::civil::Date;
|
use jiff::civil::Date;
|
||||||
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::server::AppState;
|
use crate::server::AppState;
|
||||||
use crate::types::{Issue, SocialSource};
|
use crate::types::{Issue, SocialSource};
|
||||||
@@ -289,10 +290,25 @@ pub async fn archive(
|
|||||||
Ok(public_cache(response, &headers))
|
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 config = state.config();
|
||||||
let rows = state.db.issue_dates(Some(30)).await?;
|
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;
|
let mut updated = jiff::Timestamp::UNIX_EPOCH;
|
||||||
for row in rows {
|
for row in rows {
|
||||||
let Some(view) = issue::load(&state.db, &config, row.date).await? else {
|
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();
|
let content = FeedEntryTemplate { issue: &issue }.render();
|
||||||
crate::web::timing::record_render(started.elapsed());
|
crate::web::timing::record_render(started.elapsed());
|
||||||
let content = content.map_err(|error| WebError::Internal(error.into()))?;
|
let content = content.map_err(|error| WebError::Internal(error.into()))?;
|
||||||
let href = format!(
|
entries.push(FeedEntry {
|
||||||
"{}/issues/{}",
|
id: format!(
|
||||||
config.server.public_url.trim_end_matches('/'),
|
"tag:{},{}:issue/{}",
|
||||||
issue.date
|
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!(
|
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>",
|
"<entry><id>{}</id><title>{}</title><updated>{}</updated><link rel=\"alternate\" href=\"{}\"/><content type=\"html\">{}</content></entry>",
|
||||||
feed_host(&config.server.public_url),
|
entry.id,
|
||||||
issue.date.year(),
|
entry.title,
|
||||||
issue.date,
|
entry.generated_at,
|
||||||
issue.date,
|
xml_escape(&entry.href),
|
||||||
issue.generated_at,
|
xml_escape(&entry.content),
|
||||||
xml_escape(&href),
|
|
||||||
xml_escape(&content),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
let home = config.server.public_url.trim_end_matches('/');
|
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())
|
.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 {
|
pub async fn robots() -> Response {
|
||||||
(
|
(
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
|
|||||||
Reference in New Issue
Block a user