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 | |
| 26 | use crate::srv::cache; |
| 27 | use crate::srv::publish::{ |
| 28 | DATE_LEN, |
| 29 | Post, |
| 30 | PublishConfig, |
| 31 | STAMP_LEN, |
| 32 | }; |
| 33 | |
| 34 | #[cfg(test)] |
| 35 | use crate::srv::publish::valid_date; |
| 36 | |
| 37 | use oxedyne_fe2o3_core::prelude::*; |
| 38 | use oxedyne_fe2o3_net::http::{ |
| 39 | fields::{ |
| 40 | HeaderFieldValue, |
| 41 | HeaderName, |
| 42 | }, |
| 43 | msg::HttpMessage, |
| 44 | }; |
| 45 | use 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. |
| 54 | const EPOCH: &str = "1970-01-01T00:00:00Z"; |
| 55 | |
| 56 | |
| 57 | pub 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. |
| 148 | fn 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)] |
| 157 | mod 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 | } |