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 |
|
||||
|---|---|---|
|
||||
| `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
@@ -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(
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user