Oregami
Repositories/oxedyne/fe2o3

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

10.7 KiB, 75 runs

created by r1870400018:14354, 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 JSON, for a page that would rather render them itself.
2//!
3//! The convenience, not the point. A post's canonical form is a [page](super::page): a URL, HTML in
4//! the first response, and the tags a card is built from. This exists so an app that is already a
5//! running page can show its posts inline without a navigation, and it hands over prose that was
6//! rendered on the way out, so there is one renderer rather than one per client.
7//!
8//! Served from the same prefix as everything else here, because the prefix is the module's and a post
9//! is a post however it is asked for. A slug cannot collide with this: `index.json` is not a name a
10//! slug may wear, punctuation not being allowed in one.
11//!
12//! [Written with AI entirely](https://need2know.ai/entirely-ai/code)\
13//! Anthropic Claude
14
15use crate::srv::cache;
16use crate::srv::publish::{
17 Author,
18 Post,
19 PublishConfig,
20 date_text,
21 declare,
22 read_mins,
23};
24
25use oxedyne_fe2o3_core::prelude::*;
26use oxedyne_fe2o3_jdat::prelude::*;
27use oxedyne_fe2o3_jdat::string::enc::EncoderConfig;
28use oxedyne_fe2o3_net::http::{
29 fields::{
30 HeaderFieldValue,
31 HeaderName,
32 },
33 msg::HttpMessage,
34};
35
36
37/// Serves the posts as JSON: rendered, newest first.
38///
39/// Beside the posts go the two things a page cannot work out from them: the authors they name,
40/// resolved to a face, and the categories the site offers. A post carries its author as a login
41/// username, which is a hash and shows a reader nothing, and it carries only the categories it wears
42/// rather than the ones it could have worn -- so a client drawing its own filter would otherwise
43/// offer a taxonomy narrower than the site's and a row of hashes for faces.
44pub fn serve(
45 cfg: &PublishConfig,
46 posts: &[Post],
47 authors: &[Author],
48 id: &str,
49)
50 -> Outcome<HttpMessage>
51{
52 // A post's author, by the handle the page shows, resolved through the authors this request already
53 // read. The stored username is the SHA-256 of a passphrase and never leaves the server.
54 let handle_of = |username: &str| -> String {
55 authors.iter().find(|a| a.username == username)
56 .map(|a| a.handle.clone())
57 .unwrap_or_default()
58 };
59 let list = posts.iter()
60 .map(|p| {
61 let mut fields = vec![
62 (dat!("slug"), dat!(p.slug.clone())),
63 (dat!("title"), dat!(p.title.clone())),
64 // The author, by the public handle the faces below carry, so a page can group posts
65 // under one. Empty where none is named, or where the author could not be resolved,
66 // which draws as no author rather than as a missing one.
67 (dat!("author"), dat!(handle_of(&p.author))),
68 (dat!("url"), dat!(cfg.path_of(&p.slug))),
69 (dat!("excerpt"), dat!(p.excerpt.clone())),
70 (dat!("html"), dat!(p.html.clone())),
71 // Reading time in whole minutes, so a filter can offer a min/max slider without recounting
72 // words in the client. The figure the post's own badge shows, from the one definition.
73 (dat!("read_mins"), dat!(read_mins(p.words) as u64)),
74 // The tags and categories, always present as arrays so a page reading this need not ask
75 // whether the key is there -- an empty post carries the empty list, the same thing said
76 // once. Each is a plain string the store already normalised.
77 (dat!("tags"), Dat::List(p.tags.iter().map(|t| dat!(t.clone())).collect())),
78 (dat!("categories"), Dat::List(p.categories.iter().map(|c| dat!(c.clone())).collect())),
79 ];
80 // What the author declared about writing it, resolved to the words, the artwork and the
81 // link a page would draw -- so the one rule for building a mark stays here rather than
82 // being written a second time in every client. Absent where the author declared nothing.
83 if let Some(level) = p.ai_level {
84 let d = declare::Declaration::new(level, declare::Medium::Doc);
85 fields.push((dat!("declare"), create_dat_ordmap(vec![
86 (dat!("level"), dat!(d.level.slug().to_string())),
87 (dat!("words"), dat!(d.level.words().to_string())),
88 (dat!("mark"), dat!(fmt!("{}/{}", cfg.declare.marks, d.mark_file()))),
89 (dat!("href"), dat!(fmt!("{}{}", cfg.declare.url, d.path()))),
90 ])));
91 }
92 // A post without a date carries no date key, rather than a key saying nothing. The reader
93 // asks whether the post has one; it should not also have to ask what a date of nothing means.
94 if let Some(d) = &p.date {
95 fields.push((dat!("date"), dat!(d.clone())));
96 // The same instant, said the way a person says it, so a page showing this does not
97 // have to know that the stored form is ISO.
98 fields.push((dat!("date_text"), dat!(date_text(d))));
99 }
100 create_dat_ordmap(fields)
101 })
102 .collect::<Vec<_>>();
103
104 // The authors, each with the name and avatar a reader sees, keyed by the username a post stores.
105 // A client matches a post to a face on that username, as the server's own filter does.
106 let faces = authors.iter()
107 .map(|a| create_dat_ordmap(vec![
108 (dat!("handle"), dat!(a.handle.clone())),
109 (dat!("name"), dat!(a.name.clone())),
110 (dat!("avatar"), dat!(a.avatar.clone())),
111 // What the author writes about, which a page drawing its own reader shows above the
112 // posts the way the index page does.
113 (dat!("bio"), dat!(a.bio.clone())),
114 // The letter drawn where there is no avatar, worked out once here rather than in every
115 // client that would have to know the same fallback rule.
116 (dat!("initial"), dat!(a.initial())),
117 ]))
118 .collect::<Vec<_>>();
119
120 let mut top = vec![
121 (dat!("posts"), Dat::List(list)),
122 (dat!("authors"), Dat::List(faces)),
123 // The site's whole category vocabulary, in the order the config gives, so a client's checkboxes
124 // stand in that order and offer what the composer offers.
125 (dat!("categories"), Dat::List(cfg.categories.iter().map(|c| dat!(c.clone())).collect())),
126 ];
127 // What the site declares about itself, so a page drawing its own footer needs no second fetch to
128 // know what to put in it. Absent where the site declares nothing about itself.
129 if let Some(d) = cfg.declare.site {
130 top.push((dat!("declare"), create_dat_ordmap(vec![
131 (dat!("level"), dat!(d.level.slug().to_string())),
132 (dat!("words"), dat!(d.level.words().to_string())),
133 (dat!("mark"), dat!(fmt!("{}/{}", cfg.declare.marks, d.mark_file()))),
134 (dat!("href"), dat!(fmt!("{}{}", cfg.declare.url, d.path()))),
135 ])));
136 }
137 let body_dat = create_dat_ordmap(top);
138 let json_cfg = EncoderConfig::<(), ()>::json(None);
139 let body_json = res!(body_dat.encode_string_with_config(&json_cfg));
140
141 info!("{}: publish: json, {} posts", id, posts.len());
142
143 let mut resp = HttpMessage::ok_respond_with_text(body_json);
144 resp = resp.with_field(
145 HeaderName::ContentType,
146 HeaderFieldValue::Generic(fmt!("application/json")),
147 );
148 // An app drawing its own stream from this must see a publication at once, and will not
149 // think to force a refresh.
150 Ok(cache::generated(resp))
151}
152
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 use crate::srv::publish::Source;
159
160 fn cfg() -> PublishConfig {
161 PublishConfig {
162 path: fmt!("/asides"),
163 dir: fmt!("/nonexistent"),
164 source: Source::Dir,
165 title: fmt!("Asides"),
166 site_name: fmt!("Elearnity"),
167 base_url: fmt!("https://example.com"),
168 css: vec![],
169 creds: Default::default(),
170 comments: true,
171 comment_rate_secs: 0,
172 comment_rate_hourly: 0,
173 subscribe_rate_secs: 0,
174 subscribe_rate_hourly: 0,
175 newsletter_from: String::new(),
176 categories: vec![fmt!("Personal"), fmt!("Big Ideas")],
177 default_author: String::new(),
178 logo: String::new(),
179 home: String::new(),
180 // A site in a declaration scheme, so a post's declaration has somewhere to point.
181 declare: declare::DeclareConfig {
182 url: fmt!("https://example.org"),
183 marks: fmt!("/assets/marks"),
184 site: Some(declare::Declaration::new(
185 declare::Level::With, declare::Medium::Code)),
186 items: Vec::new(),
187 },
188 }
189 }
190
191 fn post() -> Post {
192 Post {
193 slug: fmt!("on-rent"),
194 title: fmt!("On rent"),
195 author: fmt!("9f3ac1"),
196 categories: vec![fmt!("Big Ideas")],
197 date: Some(fmt!("2026-07-17")),
198 words: 420,
199 excerpt: fmt!("An opening sentence."),
200 html: fmt!("<p>An opening sentence.</p>\n"),
201 also_on: Vec::new(),
202 tags: vec![fmt!("rent")],
203 ai_level: None,
204 }
205 }
206
207 /// The feed a page draws itself from carries the two things it cannot work out from the posts: the
208 /// faces behind the usernames, and the whole category vocabulary the site offers.
209 #[test]
210 fn test_the_json_carries_faces_and_a_taxonomy_00() -> Outcome<()> {
211 let authors = vec![Author {
212 username: fmt!("9f3ac1"),
213 handle: fmt!("qv7m2ab9dz"),
214 name: fmt!("Jason"),
215 avatar: String::new(),
216 bio: fmt!("Notes on rent."),
217 }];
218 let resp = res!(serve(&cfg(), &[post()], &authors, "test"));
219 let body = String::from_utf8_lossy(&resp.body).to_string();
220 // The public handle, and nowhere the login username, which is the SHA-256 of a passphrase.
221 assert!(body.contains(r#""handle": "qv7m2ab9dz""#), "no author handle: {}", body);
222 assert!(!body.contains("9f3ac1"), "the login username reached the feed: {}", body);
223 assert!(body.contains(r#""author": "qv7m2ab9dz""#), "a post is not keyed to its author: {}", body);
224 assert!(body.contains(r#""name": "Jason""#), "no author name: {}", body);
225 // The letter a client draws where an author has no picture, settled here.
226 assert!(body.contains(r#""initial": "J""#), "no drawn initial: {}", body);
227 // What the author says they write about, which a client shows above the posts.
228 assert!(body.contains(r#""bio": "Notes on rent.""#), "no description: {}", body);
229 // Every category the site offers, not only the one the post wears.
230 assert!(body.contains(r#""Personal""#), "an unworn category is still offered: {}", body);
231 assert!(body.contains(r#""Big Ideas""#), "no category with a space: {}", body);
232 assert!(body.contains(r#""read_mins": 3"#), "no reading time: {}", body);
233 Ok(())
234 }
235
236 /// The feed says it may not be served from a store unasked. Without that an app redraws its
237 /// stream from a copy taken before the post was published, and only a forced refresh gets past it.
238 #[test]
239 fn test_the_json_is_never_served_from_a_store_unasked_02() -> Outcome<()> {
240 let resp = res!(serve(&cfg(), &[post()], &[], "test"));
241 let held = res!(resp.header.fields.get_one(&HeaderName::CacheControl).ok_or_else(||
242 err!("The feed carried no cache directive, so a store is free to guess one."; Missing)));
243 assert_eq!(fmt!("{}", held), "no-cache");
244 Ok(())
245 }
246
247 /// A site with no authors and no posts still answers the three keys, each an empty list, so a page
248 /// reading it never has to ask whether a key is there.
249 #[test]
250 fn test_an_empty_site_still_answers_in_shape_01() -> Outcome<()> {
251 let resp = res!(serve(&cfg(), &[], &[], "test"));
252 let body = String::from_utf8_lossy(&resp.body).to_string();
253 assert!(body.contains(r#""posts": []"#), "no empty post list: {}", body);
254 assert!(body.contains(r#""authors": []"#), "no empty author list: {}", body);
255 Ok(())
256 }
257}