|
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 |
+ |
}
|