//! A project's blog index, described.
//!
//! `/p/{slug}/blog`: a heading, who wrote it, a link to the project, the feed,
//! and the posts. It replaces `templates/pages/project_blog.html` and
//! `ProjectBlogTemplate`.
//!
//! The first consumer of [`quasi_router::Discovery::feed`], which is why it was
//! converted: the page wrote its `` by hand in a
//! `{% block head %}`, spelling `application/rss+xml` at one of the three sites
//! that spell it. The screen says it has a feed and
//! [`quasi_router::FeedKind::media_type`] spells it, so the type cannot drift
//! from one page to the next.
//!
//! # Why the route stays an axum handler
//!
//! [`super::auth_pages`]'s reason, one step milder: the handler resolves a slug
//! to a project, that project to its creator, and the creator to their posts,
//! then reads an optional unverified session for the header. None of that needs
//! a described route, and the mount's [`Viewer`](super::Viewer) offers nothing
//! it is missing. What is described is the document.
use makeover_layout as layout;
use quasi_router::screen::Row;
use quasi_router::{Action, Document, Feed, FeedKind, Node, RegionKind, Screen as Described, Slot};
use quasi_webview::Webview;
use crate::types::BlogPostSummary;
/// The page's own region, and what the skip link points at.
pub const PAGE_REGION: &str = "project-blog";
/// How wide it runs. The template wrote this on the body.
const MEASURE: layout::Measure = layout::Measure::Wide;
/// Where a project's blog feed answers.
#[must_use]
pub fn feed_path(project_slug: &str) -> String {
format!("/p/{project_slug}/blog/feed.xml")
}
/// The whole document.
#[must_use]
pub fn screen(
project_title: &str,
project_slug: &str,
creator_username: &str,
posts: &[BlogPostSummary],
) -> Described {
let feed = feed_path(project_slug);
let listing = if posts.is_empty() {
Node::empty("No blog posts yet.")
} else {
Node::list(posts.iter().map(|post| {
Row::new(post.title.clone())
.meta(post.published_at.clone())
.activate(Action::get(format!("/p/{project_slug}/blog/{}", post.slug)).navigating())
}))
};
let page = Slot::new(PAGE_REGION, RegionKind::Pane)
.with(Node::page(format!("{project_title} Blog")))
.with(Node::Link {
text: creator_username.to_owned(),
action: Action::get(format!("/u/{creator_username}")).navigating(),
})
.with(Node::Link {
text: "View project".to_owned(),
action: Action::get(format!("/p/{project_slug}")).navigating(),
})
// The visible offer, beside the autodiscovery tag rather than instead
// of it: one is what a reader clicks and the other is what a reader's
// app finds, and a page that has a feed owes both.
.with(Node::act(
"RSS Feed",
Action::get(feed.clone()).navigating(),
))
.with(listing)
// The attribution the template's footer carried. Content rather than
// decoration: it is where a reader on a creator's blog finds out whose
// platform they are on.
.with(Node::Link {
text: "Powered by Makenot.work".to_owned(),
action: Action::get("/").navigating(),
});
Described::single(format!("Blog - {project_title}"))
.measured(MEASURE)
.documented(
Document::default().classed(crate::shell::body_class(MEASURE, &["project-blog-page"])),
)
.summarised(format!(
"Posts from {project_title}, by {creator_username}."
))
.about(quasi_router::SocialKind::Article)
.syndicating(Feed::new(
FeedKind::Rss,
format!("{project_title} - Blog RSS"),
feed,
))
.with(page)
}
/// The document this screen is drawn in.
#[must_use]
pub fn renderer(user: Option<&crate::auth::SessionUser>, csrf: Option<&str>) -> Webview {
let csrf = csrf.unwrap_or_default();
Webview::new().with_shell(
crate::shell::described()
.sending("X-CSRF-Token", csrf)
.with_body_last(crate::shell::body_last())
.with_body_first(format!(
"{}{}",
crate::shell::skip_link(PAGE_REGION),
crate::shell::site_header(user)
))
.with_head(format!(
"",
crate::helpers::escape_html(csrf)
)),
)
}
/// Render it.
#[must_use]
pub fn document(
user: Option<&crate::auth::SessionUser>,
csrf: Option<&str>,
screen: &Described,
) -> String {
use quasi_axum::Serves as _;
renderer(user, csrf).screen(screen)
}
#[cfg(test)]
mod tests {
use super::*;
fn posts() -> Vec {
vec![BlogPostSummary {
title: "First light".to_owned(),
slug: "first-light".to_owned(),
published_at: "2026-08-01".to_owned(),
}]
}
fn html(screen: &Described) -> String {
document(None, Some("t"), screen)
}
/// The tag the template wrote by hand, said by the screen instead. The
/// media type comes off `FeedKind` rather than out of a template, which is
/// the whole of what typing it bought.
#[test]
fn the_feed_is_declared_once_and_spelled_by_the_vocabulary() {
let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
let feed = screen.discovery.feed.as_ref().expect("declared");
assert_eq!(feed.href, "/p/blue-hour/blog/feed.xml");
assert_eq!(feed.title, "Blue Hour - Blog RSS");
let rendered = html(&screen);
assert!(
rendered.contains(
""
),
"{rendered}"
);
// Once. The visible link is an anchor, not a second head tag.
assert_eq!(
rendered.matches("rel=\"alternate\"").count(),
1,
"{rendered}"
);
}
/// A reader clicks a link and a reader's app finds a tag. A page with a
/// feed owes both, and the template offered both.
#[test]
fn the_feed_is_offered_to_a_reader_as_well_as_to_their_app() {
let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
assert!(rendered.contains(">RSS Feed<"), "{rendered}");
}
/// Every post the template listed is still listed, and still reachable.
#[test]
fn every_post_keeps_its_row_and_its_address() {
let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
assert!(rendered.contains("First light"), "{rendered}");
assert!(
rendered.contains("/p/blue-hour/blog/first-light"),
"{rendered}"
);
assert!(rendered.contains("2026-08-01"), "{rendered}");
}
/// A project with nothing published says so, rather than drawing an empty
/// list. `ui::empty_state` is what the template called.
#[test]
fn a_blog_with_no_posts_says_so() {
let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &[]));
assert!(rendered.contains("No blog posts yet."), "{rendered}");
}
/// `2790e5c4`. The template wrote the measure and the page token on the
/// body; a described document has no container, so both land there.
#[test]
fn the_document_carries_the_classes_the_template_carried() {
let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
assert_eq!(
screen.document.body_class.as_deref(),
Some("padded-page project-blog-page")
);
}
/// The footer attribution the template carried. A reader on a creator's
/// blog finds out whose platform they are on from it.
#[test]
fn the_page_still_says_whose_platform_it_is() {
let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
assert!(rendered.contains("Powered by Makenot.work"), "{rendered}");
}
/// `736f45a5`: none of the four spellings.
#[test]
fn the_page_spells_no_spinner() {
let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
assert!(!rendered.contains(spelling), "{spelling} survives");
}
}
}