//! 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_declare::declare;
use quasi_router::{Document, Feed, FeedKind, Screen as Described};
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")
}
declare! {
/// The whole document.
#[must_use]
pub shape screen(
project_title: &str,
project_slug: &str,
creator_username: &str,
posts: &[BlogPostSummary],
) -> Screen;
let feed = feed_path(project_slug);
screen single "Blog - {project_title}" {
measured MEASURE;
documented Document::default()
.classed(crate::shell::body_class(MEASURE, &["project-blog-page"]));
summarised "Posts from {project_title}, by {creator_username}.";
about quasi_router::SocialKind::Article;
syndicating Feed::new(FeedKind::Rss, "{project_title} - Blog RSS", feed);
region PAGE_REGION as Pane {
page "{project_title} Blog";
link creator_username to get "/u/{creator_username}" navigating;
link "View project" to get "/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.
act "RSS Feed" to get feed.clone() navigating;
given posts.is_empty() {
true -> empty "No blog posts yet.";
otherwise -> list {
for post in posts.iter() {
row post.title.clone() {
meta post.published_at.clone();
activate to get "/p/{project_slug}/blog/{post.slug}" navigating;
}
}
}
}
// 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.
link "Powered by Makenot.work" to get "/" navigating;
}
}
}
/// 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}");
}
/// The two links the page carries besides the feed and the attribution.
/// Every address the screen offers is what the conversion had to keep, and
/// a link whose text survives with the wrong route reads as fine.
#[test]
fn the_creator_and_the_project_are_both_still_reachable() {
let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts()));
assert!(rendered.contains("href=\"/u/maxj\""), "{rendered}");
assert!(rendered.contains("href=\"/p/blue-hour\""), "{rendered}");
assert!(rendered.contains(">maxj<"), "{rendered}");
assert!(rendered.contains(">View project<"), "{rendered}");
}
/// What the page is, for anything reading the social tags. Said by the
/// screen rather than by a template's `og:type`.
#[test]
fn a_blog_index_says_it_is_an_article() {
let screen = screen("Blue Hour", "blue-hour", "maxj", &posts());
assert_eq!(screen.discovery.kind, quasi_router::SocialKind::Article);
}
/// `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");
}
}
}