Oregami
Repositories/oxedyne/fe2o3

oxedyne/fe2o3/fe2o3_steel/src/srv/publish/feed.rs

8.6 KiB, 55 runs

created by r1870400018:14352, which is this file's identity for as long as the history lasts, whatever it is later renamed to

download · who wrote it · its history

1//! The posts as a feed, for a reader that subscribes rather than visits.
2//!
3//! # Atom, not RSS
4//!
5//! Because of the dates. RSS's `pubDate` is an RFC 822 date -- `Thu, 17 Jul 2026 00:00:00 GMT` --
6//! and that leading day name is a calendar calculation: to emit it, this would have to work out which
7//! day of the week a date fell on. Atom's `updated` is ISO 8601, `2026-07-17T00:00:00Z`, which a post
8//! named `2026-07-17-on-rent.md` is already most of the way to.
9//!
10//! So RSS would mean owning a calendar here, or taking a dependency for one field. Atom means neither,
11//! and every reader worth having reads it.
12//!
13//! # A date without a time
14//!
15//! A post dated only to the day is published at midnight UTC on the day it names. A day is not an
16//! instant and the feed must claim one, so this is a fiction -- but a stable one: it does not drift,
17//! it does not depend on where the server is, and re-serving a feed never reorders it.
18//!
19//! A post dated to the minute is published at that minute, and needs no fiction. Both are read as
20//! UTC, because a post carries no zone and inventing one from where the server happens to be would
21//! make the same post's feed entry move when the server did.
22//!
23//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
24//! Anthropic Claude
25
26use crate::srv::cache;
27use crate::srv::publish::{
28 DATE_LEN,
29 Post,
30 PublishConfig,
31 STAMP_LEN,
32};
33
34#[cfg(test)]
35use crate::srv::publish::valid_date;
36
37use oxedyne_fe2o3_core::prelude::*;
38use oxedyne_fe2o3_net::http::{
39 fields::{
40 HeaderFieldValue,
41 HeaderName,
42 },
43 msg::HttpMessage,
44};
45use oxedyne_fe2o3_text::doc::html::{
46 escape_attr,
47 escape_text,
48};
49
50
51// The instant an undated post, or an empty feed, claims. A feed must say when it was last updated,
52// and one with nothing in it has never been. The epoch says that plainly and never looks like a real
53// time that happens to be wrong.
54const EPOCH: &str = "1970-01-01T00:00:00Z";
55
56
57pub fn serve(cfg: &PublishConfig, posts: &[Post], id: &str) -> Outcome<HttpMessage> {
58 let self_url = cfg.url_of(&cfg.feed_path());
59 let index_url = cfg.url_of(&cfg.path);
60
61 let mut s = String::new();
62 s.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
63 s.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");
64
65 s.push_str(" <title>");
66 escape_text(&mut s, &cfg.title);
67 s.push_str("</title>\n");
68
69 s.push_str(" <id>");
70 escape_text(&mut s, &index_url);
71 s.push_str("</id>\n");
72
73 s.push_str(" <link rel=\"alternate\" type=\"text/html\" href=\"");
74 escape_attr(&mut s, &index_url);
75 s.push_str("\"/>\n");
76 s.push_str(" <link rel=\"self\" type=\"application/atom+xml\" href=\"");
77 escape_attr(&mut s, &self_url);
78 s.push_str("\"/>\n");
79
80 // The feed is as new as its newest post, which is the first, the list being newest first.
81 let newest = posts.first()
82 .and_then(|p| p.date.as_ref())
83 .map(|d| instant(d))
84 .unwrap_or_else(|| EPOCH.to_string());
85 s.push_str(" <updated>");
86 s.push_str(&newest);
87 s.push_str("</updated>\n");
88
89 if !cfg.site_name.is_empty() {
90 s.push_str(" <author><name>");
91 escape_text(&mut s, &cfg.site_name);
92 s.push_str("</name></author>\n");
93 }
94
95 for p in posts {
96 let url = cfg.url_of(&cfg.path_of(&p.slug));
97 s.push_str(" <entry>\n <title>");
98 escape_text(&mut s, &p.title);
99 s.push_str("</title>\n <id>");
100 escape_text(&mut s, &url);
101 s.push_str("</id>\n <link rel=\"alternate\" type=\"text/html\" href=\"");
102 escape_attr(&mut s, &url);
103 s.push_str("\"/>\n <updated>");
104 s.push_str(&p.date.as_ref().map(|d| instant(d)).unwrap_or_else(|| EPOCH.to_string()));
105 s.push_str("</updated>\n");
106 // One category per tag, so a reader's feed reader can file the entry by the same tags the site
107 // shows. The term is escaped for an attribute, since a tag reaches the feed as the store kept it.
108 for t in &p.tags {
109 s.push_str(" <category term=\"");
110 escape_attr(&mut s, t);
111 s.push_str("\"/>\n");
112 }
113 if !p.excerpt.is_empty() {
114 s.push_str(" <summary>");
115 escape_text(&mut s, &p.excerpt);
116 s.push_str("</summary>\n");
117 }
118 // The whole post travels with the entry, so a reader who subscribed can read without coming
119 // back. Escaped rather than wrapped in CDATA: CDATA cannot carry `]]>` and prose can.
120 s.push_str(" <content type=\"html\">");
121 escape_text(&mut s, &p.html);
122 s.push_str("</content>\n </entry>\n");
123 }
124
125 s.push_str("</feed>\n");
126
127 info!("{}: publish: feed, {} entries", id, posts.len());
128
129 let mut resp = HttpMessage::ok_respond_with_text(s);
130 resp = resp.with_field(
131 HeaderName::ContentType,
132 HeaderFieldValue::Generic(fmt!("application/atom+xml; charset=utf-8")),
133 );
134 // A feed reader polls this on a schedule; a store answering from a copy would defeat the poll.
135 Ok(cache::generated(resp))
136}
137
138/// A post's date, as the instant the feed claims for it.
139///
140/// A date naming a day becomes midnight UTC on it; a date naming a minute becomes that minute. Both
141/// are already ISO, so both are a suffix away from RFC 3339 and neither needs a calendar.
142///
143/// Anything else is passed through as the epoch rather than emitted malformed: a feed that will not
144/// parse is worse than one that admits it does not know. **That fallback is silent**, which is why
145/// the length is tested against the shapes [`super::valid_date`] admits rather than a bare `10` --
146/// a date the store accepts and the feed quietly dates to 1970 is the kind of wrong nobody sees
147/// until a reader's feed reader has already sorted it to the bottom for a year.
148fn instant(date: &str) -> String {
149 match date.len() {
150 DATE_LEN => fmt!("{}T00:00:00Z", date),
151 STAMP_LEN => fmt!("{}:00Z", date),
152 _ => EPOCH.to_string(),
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 /// A date becomes the instant its day began, in UTC, whoever is asking.
161 #[test]
162 fn test_a_date_becomes_midnight_utc_00() -> Outcome<()> {
163 assert_eq!(instant("2026-07-17"), "2026-07-17T00:00:00Z");
164 Ok(())
165 }
166
167 /// A date naming a minute is that minute, and needs no fiction about when the day began.
168 #[test]
169 fn test_a_minute_is_the_minute_02() -> Outcome<()> {
170 assert_eq!(instant("2026-07-17T14:30"), "2026-07-17T14:30:00Z");
171 assert_eq!(instant("2026-07-17T00:00"), "2026-07-17T00:00:00Z");
172 Ok(())
173 }
174
175 /// Every shape the store accepts is a shape the feed dates properly.
176 ///
177 /// The pairing that matters: [`valid_date`] decides what may be stored and this decides what a
178 /// reader's feed reader is told, and the two agreeing is not automatic. A date the store took
179 /// and the feed dated to 1970 would sort to the bottom of every reader in the world and say
180 /// nothing about it here.
181 #[test]
182 fn test_the_feed_dates_everything_the_store_takes_03() -> Outcome<()> {
183 for d in ["2026-07-17", "2026-07-17T14:30"] {
184 assert!(valid_date(d), "the store would refuse {}", d);
185 assert_ne!(instant(d), EPOCH, "the store takes {} and the feed dates it to 1970", d);
186 assert!(instant(d).ends_with('Z'), "{} did not become an instant", d);
187 }
188 Ok(())
189 }
190
191 /// Anything that is not a date says the epoch rather than producing a feed that will not parse.
192 #[test]
193 fn test_a_non_date_says_the_epoch_01() -> Outcome<()> {
194 assert_eq!(instant("whenever"), EPOCH);
195 assert_eq!(instant(""), EPOCH);
196 // Ten characters of the wrong kind still reach the suffix, and the calendar is nobody's
197 // business here -- the shape is all this claims to know.
198 assert_eq!(instant("2026-02-31"), "2026-02-31T00:00:00Z");
199 Ok(())
200 }
201
202 /// The feed may not be served from a store unasked. A feed reader polls this on a schedule, and a
203 /// store answering the poll from a copy is the poll not happening.
204 #[test]
205 fn test_the_feed_is_never_served_from_a_store_unasked_04() -> Outcome<()> {
206 let cfg = PublishConfig {
207 path: fmt!("/asides"),
208 dir: fmt!("/nonexistent"),
209 source: crate::srv::publish::Source::Dir,
210 title: fmt!("Asides"),
211 site_name: fmt!("Elearnity"),
212 base_url: fmt!("https://example.com"),
213 css: vec![],
214 creds: Default::default(),
215 comments: true,
216 comment_rate_secs: 0,
217 comment_rate_hourly: 0,
218 subscribe_rate_secs: 0,
219 subscribe_rate_hourly: 0,
220 newsletter_from: String::new(),
221 categories: vec![],
222 default_author: String::new(),
223 logo: String::new(),
224 home: String::new(),
225 declare: Default::default(),
226 };
227 let post = Post {
228 slug: fmt!("on-rent"),
229 title: fmt!("On rent"),
230 author: fmt!("jason"),
231 categories: vec![],
232 date: Some(fmt!("2026-07-17")),
233 words: 420,
234 excerpt: fmt!("An opening sentence."),
235 html: fmt!("<p>An opening sentence.</p>\n"),
236 also_on: Vec::new(),
237 tags: vec![fmt!("rent")],
238 ai_level: None,
239 };
240 cache::assert_not_held(&res!(serve(&cfg, &[post], "test")), "the feed");
241 cache::assert_not_held(&res!(serve(&cfg, &[], "test")), "an empty feed");
242 Ok(())
243 }
244}