Skip to main content

max / makenotwork

Describe the project blog index, so a screen says it has a feed `/p/{slug}/blog` and the changelog that aliases it are one described screen now, replacing `templates/pages/project_blog.html` and `ProjectBlogTemplate`. The first consumer of `Discovery::feed`. The page wrote its `<link rel="alternate">` by hand in a `{% block head %}`, spelling `application/rss+xml` at one of the three sites in this server that spell it; it says it has a feed and `FeedKind::media_type` spells it, so the type cannot drift page to page. The visible RSS link stays beside the 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 with a feed owes both. The route stays an axum handler for `auth_pages`'s reason one step milder: it resolves a slug to a project, that to its creator, and that to their posts, and the mount's `Viewer` offers nothing it is missing. What is described is the document. Twelve `.project-blog-page` rules went with the markup they styled. The heading, the list and the rows are makeover's, which is what the design system is for; what is left is the one width that is this page's. The footer attribution survives as a link rather than as a `<footer>`: it is where a reader on a creator's blog finds out whose platform they are on, so dropping it with the markup would have been a real loss.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01MptwXZ8k65v19rFmdGAyki
Author: Max Johnson <me@maxj.phd> · 2026-09-02 13:37 UTC
Signed with PGP, not checked
Commit: 3bdde500708f78aec8683713360658fc90401ad3
Parent: f4debfe
7 files changed, +268 insertions, -120 deletions
@@ -3804,41 +3804,13 @@
3804 3804 What is left is the measure, on <main>. */
3805 3805 .collection-page main { max-width: 700px; }
3806 3806
3807 - /* Project blog index page (templates/pages/project_blog.html). */
3808 - .project-blog-page .blog-header { margin-bottom: var(--gap-page); }
3809 - .project-blog-page .blog-title { font-size: var(--text-display); margin-bottom: var(--gap-peer); }
3810 -
3811 - .project-blog-page .blog-meta {
3812 - font-size: var(--text-note);
3813 - opacity: 0.7;
3814 - margin-bottom: var(--gap-pane);
3815 - }
3816 -
3817 - .project-blog-page .blog-meta a { color: var(--content); }
3818 -
3819 - .project-blog-page .blog-actions {
3820 - display: flex;
3821 - gap: var(--gap-section);
3822 - }
3823 -
3824 - .project-blog-page .posts-list { max-width: 800px; }
3825 -
3826 - .project-blog-page .post-entry {
3827 - padding: var(--gap-pane) 0;
3828 - border-bottom: 1px solid var(--border);
3829 - }
3830 -
3831 - .project-blog-page .post-entry:first-child { padding-top: 0; }
3832 - .project-blog-page .post-title { font-size: var(--text-head); margin-bottom: var(--gap-peer); }
3833 - .project-blog-page .post-title a:hover { text-decoration: underline; }
3834 -
3835 - .project-blog-page .post-date {
3836 - font-size: var(--text-note);
3837 - opacity: 0.6;
3838 - font-family: var(--font-mono);
3839 - }
3840 -
3841 - .project-blog-page .empty-state { padding: var(--gap-page) 0; opacity: 0.6; }
3807 + /* Project blog index page. Twelve rules stood here for markup the template
3808 + wrote by hand -- `.blog-header`, `.post-entry`, `.post-title` and the rest.
3809 + The page is a described screen (`crate::quasi::project_blog`) and its
3810 + heading, list and rows are makeover's, which is what the design system is
3811 + for. What is left is the one thing that is this page's rather than the
3812 + system's. */
3813 + .project-blog-page main { max-width: 800px; }
3842 3814
3843 3815 /* Canonical "page-level h1", left-aligned, smaller than the global centered
3844 3816 default. Used by dashboards, admin, health, import, delete-account, export,
@@ -10869,15 +10841,6 @@
10869 10841 /* Faded "Following" follow-button state. */
10870 10842 .follow-btn.is-selected { opacity: 0.7; }
10871 10843
10872 - /* Project blog footer. */
10873 - .project-blog-footer {
10874 - margin-top: var(--gap-page);
10875 - padding-top: var(--gap-page);
10876 - border-top: 1px solid var(--border);
10877 - opacity: 0.6;
10878 - }
10879 - .project-blog-footer a { color: var(--content); }
10880 -
10881 10844 /* Compact alert / paragraph margin variants used 2+ times. */
10882 10845
10883 10846 /* Paywall trust line. */
@@ -68,6 +68,7 @@
68 68 pub mod policy;
69 69 pub mod pricing;
70 70 pub mod project_analytics;
71 + pub mod project_blog;
71 72 pub mod project_content;
72 73 pub mod project_members;
73 74 pub mod project_overview;
@@ -164,7 +164,6 @@
164 164 BuyPageTemplate,
165 165 StripeConnectDisclaimerTemplate,
166 166 // Blog pages
167 - ProjectBlogTemplate,
168 167 BlogPostTemplate,
169 168 // Documentation pages
170 169 DocTemplate,
@@ -17,7 +17,7 @@
17 17 db::{self, Slug},
18 18 error::{AppError, Result},
19 19 helpers::{fetch_discussion_info, get_csrf_token, get_initials},
20 - templates::{BlogPostTemplate, ProjectBlogTemplate},
20 + templates::BlogPostTemplate,
21 21 types::{BlogPostSummary, Project, json_escape},
22 22 };
23 23
@@ -56,14 +56,17 @@
56 56
57 57 let posts: Vec<BlogPostSummary> = db_posts.iter().map(BlogPostSummary::from).collect();
58 58
59 - Ok(ProjectBlogTemplate {
60 - csrf_token,
61 - session_user: maybe_user,
62 - project,
63 - creator_username: db_user.username.to_string(),
64 - project_slug: db_project.slug.to_string(),
65 - posts,
66 - })
59 + let screen = crate::quasi::project_blog::screen(
60 + &project.title,
61 + db_project.slug.as_ref(),
62 + db_user.username.as_ref(),
63 + &posts,
64 + );
65 + Ok(axum::response::Html(crate::quasi::project_blog::document(
66 + maybe_user.as_ref(),
67 + csrf_token.as_deref(),
68 + &screen,
69 + )))
67 70 }
68 71
69 72 /// Public blog post reader page.
@@ -169,14 +172,19 @@
169 172 let project = Project::from_db(&db_project, 0);
170 173 let posts: Vec<BlogPostSummary> = db_posts.iter().map(BlogPostSummary::from).collect();
171 174
172 - Ok(ProjectBlogTemplate {
173 - csrf_token,
174 - session_user: maybe_user,
175 - project,
176 - creator_username: db_user.username.to_string(),
177 - project_slug: db_project.slug.to_string(),
178 - posts,
179 - })
175 + // The changelog is a project blog under a reserved slug, so it is the same
176 + // screen. One description rather than two that drift.
177 + let screen = crate::quasi::project_blog::screen(
178 + &project.title,
179 + db_project.slug.as_ref(),
180 + db_user.username.as_ref(),
181 + &posts,
182 + );
183 + Ok(axum::response::Html(crate::quasi::project_blog::document(
184 + maybe_user.as_ref(),
185 + csrf_token.as_deref(),
186 + &screen,
187 + )))
180 188 }
181 189
182 190 /// Platform changelog post (alias for a "changelog" project blog post).
@@ -15,9 +15,8 @@
15 15
16 16 use crate::auth::SessionUser;
17 17 use crate::types::{
18 - BlogPostSummary, Chapter, Collection, CustomLink, DiscoverItem, DiscoverProject, Item,
19 - ItemContent, ItemSection, Project, SidebarView, SubscriptionTier, TagBreadcrumb, TagTreeNode,
20 - User, Version,
18 + Chapter, Collection, CustomLink, DiscoverItem, DiscoverProject, Item, ItemContent, ItemSection,
19 + Project, SidebarView, SubscriptionTier, TagBreadcrumb, TagTreeNode, User, Version,
21 20 };
22 21
23 22 use super::CsrfTokenOption;
@@ -728,18 +727,6 @@
728 727
729 728 // Blog Pages
730 729
731 - /// Public blog index for a project.
732 - #[derive(Template)]
733 - #[template(path = "pages/project_blog.html")]
734 - pub struct ProjectBlogTemplate {
735 - pub csrf_token: CsrfTokenOption,
736 - pub session_user: Option<SessionUser>,
737 - pub project: Project,
738 - pub creator_username: String,
739 - pub project_slug: String,
740 - pub posts: Vec<BlogPostSummary>,
741 - }
742 -
743 730 /// Public blog post reader.
744 731 #[derive(Template)]
745 732 #[template(path = "pages/blog_post.html")]
@@ -1,0 +1,233 @@
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 + }
@@ -1,43 +1,0 @@
1 - {% extends "base.html" %}
2 - {%- import "partials/_ui.html" as ui -%}
3 -
4 - {% block title %}Blog - {{ project.title }}{% endblock %}
5 - {% block body_attrs %} class="{{ crate::shell::measure(crate::shell::Measure::Wide) }} project-blog-page"{% endblock %}
6 -
7 - {% block head %}
8 - <link rel="alternate" type="application/rss+xml" title="{{ project.title }} - Blog RSS" href="/p/{{ project_slug }}/blog/feed.xml">
9 - {% endblock %}
10 -
11 - {% block content %}
12 - {% include "partials/site_header.html" %}
13 -
14 - <div class="container">
15 - <header class="blog-header">
16 - <h1 class="blog-title">{{ project.title }} Blog<span class="dot">.</span></h1>
17 - <div class="blog-meta">
18 - by <a href="/u/{{ creator_username }}">{{ creator_username }}</a> &middot;
19 - <a href="/p/{{ project_slug }}">View project</a>
20 - </div>
21 - <div class="blog-actions">
22 - <a href="/p/{{ project_slug }}/blog/feed.xml" class="btn-secondary btn-link">RSS Feed</a>
23 - </div>
24 - </header>
25 -
26 - <section class="posts-list">
27 - {% if posts.is_empty() %}
28 - {% call ui::empty_state("", "No blog posts yet.") %}{% endcall %}
29 - {% else %}
30 - {% for post in posts %}
31 - <div class="post-entry">
32 - <h2 class="post-title"><a class="unstyled-link" href="/p/{{ project_slug }}/blog/{{ post.slug }}">{{ post.title }}</a></h2>
33 - <div class="post-date">{{ post.published_at }}</div>
34 - </div>
35 - {% endfor %}
36 - {% endif %}
37 - </section>
38 -
39 - <footer class="project-blog-footer">
40 - <p>Powered by <a href="/">Makenot<span class="dot">.</span>work</a></p>
41 - </footer>
42 - </div>
43 - {% endblock %}