Skip to main content

max / makenotwork

8.4 KB · 234 lines History Blame Raw
1 //! A project's blog index, described.
2 //!
3 //! `/p/{slug}/blog`: a heading, who wrote it, a link to the project, the feed,
4 //! and the posts. It replaces `templates/pages/project_blog.html` and
5 //! `ProjectBlogTemplate`.
6 //!
7 //! The first consumer of [`quasi_router::Discovery::feed`], which is why it was
8 //! converted: the page wrote its `<link rel="alternate">` by hand in a
9 //! `{% block head %}`, spelling `application/rss+xml` at one of the three sites
10 //! that spell it. The screen says it has a feed and
11 //! [`quasi_router::FeedKind::media_type`] spells it, so the type cannot drift
12 //! from one page to the next.
13 //!
14 //! # Why the route stays an axum handler
15 //!
16 //! [`super::auth_pages`]'s reason, one step milder: the handler resolves a slug
17 //! to a project, that project to its creator, and the creator to their posts,
18 //! then reads an optional unverified session for the header. None of that needs
19 //! a described route, and the mount's [`Viewer`](super::Viewer) offers nothing
20 //! it is missing. What is described is the document.
21
22 use makeover_layout as layout;
23 use quasi_router::screen::Row;
24 use quasi_router::{Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot};
25 use quasi_webview::Webview;
26
27 use crate::types::BlogPostSummary;
28
29 /// The page's own region, and what the skip link points at.
30 pub const PAGE_REGION: &str = "project-blog";
31
32 /// How wide it runs. The template wrote this on the body.
33 const MEASURE: layout::Measure = layout::Measure::Wide;
34
35 /// Where a project's blog feed answers.
36 #[must_use]
37 pub fn feed_path(project_slug: &str) -> String {
38 format!("/p/{project_slug}/blog/feed.xml")
39 }
40
41 /// The whole document.
42 #[must_use]
43 pub fn screen(
44 project_title: &str,
45 project_slug: &str,
46 creator_username: &str,
47 posts: &[BlogPostSummary],
48 ) -> Described {
49 let feed = feed_path(project_slug);
50
51 let listing = if posts.is_empty() {
52 Node::empty("No blog posts yet.")
53 } else {
54 Node::list(posts.iter().map(|post| {
55 Row::new(post.title.clone())
56 .meta(post.published_at.clone())
57 .activate(Action::get(format!("/p/{project_slug}/blog/{}", post.slug)).navigating())
58 }))
59 };
60
61 let page = Slot::new(PAGE_REGION, RegionKind::Pane)
62 .with(Node::page(format!("{project_title} Blog")))
63 .with(Node::Link {
64 text: creator_username.to_owned(),
65 action: Action::get(format!("/u/{creator_username}")).navigating(),
66 })
67 .with(Node::Link {
68 text: "View project".to_owned(),
69 action: Action::get(format!("/p/{project_slug}")).navigating(),
70 })
71 // The visible offer, beside the autodiscovery tag rather than instead
72 // of it: one is what a reader clicks and the other is what a reader's
73 // app finds, and a page that has a feed owes both.
74 .with(Node::act(
75 "RSS Feed",
76 Action::get(feed.clone()).navigating(),
77 ))
78 .with(listing)
79 // The attribution the template's footer carried. Content rather than
80 // decoration: it is where a reader on a creator's blog finds out whose
81 // platform they are on.
82 .with(Node::Link {
83 text: "Powered by Makenot.work".to_owned(),
84 action: Action::get("/").navigating(),
85 });
86
87 Described::single(format!("Blog - {project_title}"))
88 .measured(MEASURE)
89 .documented(
90 Document::default().classed(crate::shell::body_class(MEASURE, &["project-blog-page"])),
91 )
92 .summarised(format!(
93 "Posts from {project_title}, by {creator_username}."
94 ))
95 .about(quasi_router::SocialKind::Article)
96 .syndicating(Feed::new(
97 FeedKind::Rss,
98 format!("{project_title} - Blog RSS"),
99 feed,
100 ))
101 .with(page)
102 }
103
104 /// The document this screen is drawn in.
105 #[must_use]
106 pub fn renderer(user: Option<&crate::auth::SessionUser>, csrf: Option<&str>) -> Webview {
107 let csrf = csrf.unwrap_or_default();
108 Webview::new().with_shell(
109 crate::shell::described()
110 .sending("X-CSRF-Token", csrf)
111 .with_body_last(crate::shell::body_last())
112 .with_body_first(format!(
113 "{}{}",
114 crate::shell::skip_link(PAGE_REGION),
115 crate::shell::site_header(user)
116 ))
117 .with_head(format!(
118 "<meta name=\"csrf-token\" content=\"{}\">",
119 crate::helpers::escape_html(csrf)
120 )),
121 )
122 }
123
124 /// Render it.
125 #[must_use]
126 pub fn document(
127 user: Option<&crate::auth::SessionUser>,
128 csrf: Option<&str>,
129 screen: &Described,
130 ) -> String {
131 use quasi_axum::Serves as _;
132
133 renderer(user, csrf).screen(screen)
134 }
135
136 #[cfg(test)]
137 mod tests {
138 use super::*;
139
140 fn posts() -> Vec<BlogPostSummary> {
141 vec![BlogPostSummary {
142 title: "First light".to_owned(),
143 slug: "first-light".to_owned(),
144 published_at: "2026-08-01".to_owned(),
145 }]
146 }
147
148 fn html(screen: &Described) -> String {
149 document(None, Some("t"), screen)
150 }
151
152 /// The tag the template wrote by hand, said by the screen instead. The
153 /// media type comes off `FeedKind` rather than out of a template, which is
154 /// the whole of what typing it bought.
155 #[test]
156 fn the_feed_is_declared_once_and_spelled_by_the_vocabulary() {
157 let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
158 let feed = screen.discovery.feed.as_ref().expect("declared");
159 assert_eq!(feed.href, "/p/blue-hour/blog/feed.xml");
160 assert_eq!(feed.title, "Blue Hour - Blog RSS");
161
162 let rendered = html(&screen);
163 assert!(
164 rendered.contains(
165 "<link rel=\"alternate\" type=\"application/rss+xml\" \
166 title=\"Blue Hour - Blog RSS\" href=\"/p/blue-hour/blog/feed.xml\">"
167 ),
168 "{rendered}"
169 );
170 // Once. The visible link is an anchor, not a second head tag.
171 assert_eq!(
172 rendered.matches("rel=\"alternate\"").count(),
173 1,
174 "{rendered}"
175 );
176 }
177
178 /// A reader clicks a link and a reader's app finds a tag. A page with a
179 /// feed owes both, and the template offered both.
180 #[test]
181 fn the_feed_is_offered_to_a_reader_as_well_as_to_their_app() {
182 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
183 assert!(rendered.contains(">RSS Feed<"), "{rendered}");
184 }
185
186 /// Every post the template listed is still listed, and still reachable.
187 #[test]
188 fn every_post_keeps_its_row_and_its_address() {
189 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
190 assert!(rendered.contains("First light"), "{rendered}");
191 assert!(
192 rendered.contains("/p/blue-hour/blog/first-light"),
193 "{rendered}"
194 );
195 assert!(rendered.contains("2026-08-01"), "{rendered}");
196 }
197
198 /// A project with nothing published says so, rather than drawing an empty
199 /// list. `ui::empty_state` is what the template called.
200 #[test]
201 fn a_blog_with_no_posts_says_so() {
202 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &[]));
203 assert!(rendered.contains("No blog posts yet."), "{rendered}");
204 }
205
206 /// `2790e5c4`. The template wrote the measure and the page token on the
207 /// body; a described document has no container, so both land there.
208 #[test]
209 fn the_document_carries_the_classes_the_template_carried() {
210 let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
211 assert_eq!(
212 screen.document.body_class.as_deref(),
213 Some("padded-page project-blog-page")
214 );
215 }
216
217 /// The footer attribution the template carried. A reader on a creator's
218 /// blog finds out whose platform they are on from it.
219 #[test]
220 fn the_page_still_says_whose_platform_it_is() {
221 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
222 assert!(rendered.contains("Powered by Makenot.work"), "{rendered}");
223 }
224
225 /// `736f45a5`: none of the four spellings.
226 #[test]
227 fn the_page_spells_no_spinner() {
228 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
229 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
230 assert!(!rendered.contains(spelling), "{spelling} survives");
231 }
232 }
233 }
234