Skip to main content

max / makenotwork

9.0 KB · 243 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_declare::declare;
24 use quasi_router::{Document, Feed, FeedKind, Screen as Described};
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 declare! {
42 /// The whole document.
43 #[must_use]
44 pub shape screen(
45 project_title: &str,
46 project_slug: &str,
47 creator_username: &str,
48 posts: &[BlogPostSummary],
49 ) -> Screen;
50
51 let feed = feed_path(project_slug);
52
53 screen single "Blog - {project_title}" {
54 measured MEASURE;
55 documented Document::default()
56 .classed(crate::shell::body_class(MEASURE, &["project-blog-page"]));
57 summarised "Posts from {project_title}, by {creator_username}.";
58 about quasi_router::SocialKind::Article;
59 syndicating Feed::new(FeedKind::Rss, "{project_title} - Blog RSS", feed);
60
61 region PAGE_REGION as Pane {
62 page "{project_title} Blog";
63 link creator_username to get "/u/{creator_username}" navigating;
64 link "View project" to get "/p/{project_slug}" navigating;
65
66 // The visible offer, beside the autodiscovery tag rather than
67 // instead of it: one is what a reader clicks and the other is what
68 // a reader's app finds, and a page that has a feed owes both.
69 act "RSS Feed" to get feed.clone() navigating;
70
71 given posts.is_empty() {
72 true -> empty "No blog posts yet.";
73 otherwise -> list {
74 for post in posts.iter() {
75 row post.title.clone() {
76 meta post.published_at.clone();
77 activate to get "/p/{project_slug}/blog/{post.slug}" navigating;
78 }
79 }
80 }
81 }
82
83 // The attribution the template's footer carried. Content rather
84 // than decoration: it is where a reader on a creator's blog finds
85 // out whose platform they are on.
86 link "Powered by Makenot.work" to get "/" navigating;
87 }
88 }
89 }
90
91 /// The document this screen is drawn in.
92 #[must_use]
93 pub fn renderer(user: Option<&crate::auth::SessionUser>, csrf: Option<&str>) -> Webview {
94 let csrf = csrf.unwrap_or_default();
95 Webview::new().with_shell(
96 crate::shell::described()
97 .sending("X-CSRF-Token", csrf)
98 .with_body_last(crate::shell::body_last())
99 .with_body_first(format!(
100 "{}{}",
101 crate::shell::skip_link(PAGE_REGION),
102 crate::shell::site_header(user)
103 ))
104 .with_head(format!(
105 "<meta name=\"csrf-token\" content=\"{}\">",
106 crate::helpers::escape_html(csrf)
107 )),
108 )
109 }
110
111 /// Render it.
112 #[must_use]
113 pub fn document(
114 user: Option<&crate::auth::SessionUser>,
115 csrf: Option<&str>,
116 screen: &Described,
117 ) -> String {
118 use quasi_axum::Serves as _;
119
120 renderer(user, csrf).screen(screen)
121 }
122
123 #[cfg(test)]
124 mod tests {
125 use super::*;
126
127 fn posts() -> Vec<BlogPostSummary> {
128 vec![BlogPostSummary {
129 title: "First light".to_owned(),
130 slug: "first-light".to_owned(),
131 published_at: "2026-08-01".to_owned(),
132 }]
133 }
134
135 fn html(screen: &Described) -> String {
136 document(None, Some("t"), screen)
137 }
138
139 /// The tag the template wrote by hand, said by the screen instead. The
140 /// media type comes off `FeedKind` rather than out of a template, which is
141 /// the whole of what typing it bought.
142 #[test]
143 fn the_feed_is_declared_once_and_spelled_by_the_vocabulary() {
144 let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
145 let feed = screen.discovery.feed.as_ref().expect("declared");
146 assert_eq!(feed.href, "/p/blue-hour/blog/feed.xml");
147 assert_eq!(feed.title, "Blue Hour - Blog RSS");
148
149 let rendered = html(&screen);
150 assert!(
151 rendered.contains(
152 "<link rel=\"alternate\" type=\"application/rss+xml\" \
153 title=\"Blue Hour - Blog RSS\" href=\"/p/blue-hour/blog/feed.xml\">"
154 ),
155 "{rendered}"
156 );
157 // Once. The visible link is an anchor, not a second head tag.
158 assert_eq!(
159 rendered.matches("rel=\"alternate\"").count(),
160 1,
161 "{rendered}"
162 );
163 }
164
165 /// A reader clicks a link and a reader's app finds a tag. A page with a
166 /// feed owes both, and the template offered both.
167 #[test]
168 fn the_feed_is_offered_to_a_reader_as_well_as_to_their_app() {
169 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
170 assert!(rendered.contains(">RSS Feed<"), "{rendered}");
171 }
172
173 /// Every post the template listed is still listed, and still reachable.
174 #[test]
175 fn every_post_keeps_its_row_and_its_address() {
176 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
177 assert!(rendered.contains("First light"), "{rendered}");
178 assert!(
179 rendered.contains("/p/blue-hour/blog/first-light"),
180 "{rendered}"
181 );
182 assert!(rendered.contains("2026-08-01"), "{rendered}");
183 }
184
185 /// A project with nothing published says so, rather than drawing an empty
186 /// list. `ui::empty_state` is what the template called.
187 #[test]
188 fn a_blog_with_no_posts_says_so() {
189 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &[]));
190 assert!(rendered.contains("No blog posts yet."), "{rendered}");
191 }
192
193 /// The two links the page carries besides the feed and the attribution.
194 /// Every address the screen offers is what the conversion had to keep, and
195 /// a link whose text survives with the wrong route reads as fine.
196 #[test]
197 fn the_creator_and_the_project_are_both_still_reachable() {
198 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
199
200 assert!(rendered.contains("href=\"/u/maxj\""), "{rendered}");
201 assert!(rendered.contains("href=\"/p/blue-hour\""), "{rendered}");
202 assert!(rendered.contains(">maxj<"), "{rendered}");
203 assert!(rendered.contains(">View project<"), "{rendered}");
204 }
205
206 /// What the page is, for anything reading the social tags. Said by the
207 /// screen rather than by a template's `og:type`.
208 #[test]
209 fn a_blog_index_says_it_is_an_article() {
210 let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
211
212 assert_eq!(screen.discovery.kind, quasi_router::SocialKind::Article);
213 }
214
215 /// `2790e5c4`. The template wrote the measure and the page token on the
216 /// body; a described document has no container, so both land there.
217 #[test]
218 fn the_document_carries_the_classes_the_template_carried() {
219 let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
220 assert_eq!(
221 screen.document.body_class.as_deref(),
222 Some("padded-page project-blog-page")
223 );
224 }
225
226 /// The footer attribution the template carried. A reader on a creator's
227 /// blog finds out whose platform they are on from it.
228 #[test]
229 fn the_page_still_says_whose_platform_it_is() {
230 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
231 assert!(rendered.contains("Powered by Makenot.work"), "{rendered}");
232 }
233
234 /// `736f45a5`: none of the four spellings.
235 #[test]
236 fn the_page_spells_no_spinner() {
237 let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
238 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
239 assert!(!rendered.contains(spelling), "{spelling} survives");
240 }
241 }
242 }
243