Skip to main content

max / makenotwork

34.4 KB · 921 lines History Blame Raw
1 //! The public project page, described.
2 //!
3 //! `/p/{slug}`: the cover, who made it, what is in it, and every way to follow,
4 //! read, subscribe to or support it. It replaces `templates/pages/project.html`
5 //! and `ProjectTemplate`, and it is the last of the three pages that wrote
6 //! `<link rel="alternate">` by hand.
7 //!
8 //! # Why the route stays an axum handler
9 //!
10 //! [`super::project_blog`]'s reason, at the page it is largest for. The handler
11 //! resolves a slug, runs the project-level paywall gate, loads items, tiers,
12 //! git repos, sections, gallery frames and follow state, and records a page
13 //! view unless the caller is a crawler. `super::Viewer` carries no session and
14 //! none of that wants a described route. What is described is the document.
15 //!
16 //! # Four behaviours the vocabulary took over
17 //!
18 //! - **The section tabs.** `.section-tab` buttons over `.section-panel` divs,
19 //! switched by `static/page-project.js`. A [`RegionKind::TabGroup`] whose
20 //! children carry a [`Slot::label`] is that, and `quasi-webview` writes the
21 //! strip and the program that moves it. The script is gone.
22 //! - **The gallery.** An Askama macro over the shipped widget. The described
23 //! half of the same widget is [`super::widgets::carousel::region`], so the
24 //! gallery here and the three templates still calling the macro cannot be two
25 //! different carousels. Its frames are ordinary pictures, so a host that has
26 //! never heard of a carousel still draws every one of them.
27 //! - **The tip offer's disclosure**, which is [`super::tip`]'s.
28 //! - **The share link**, which is [`quasi_router::Act::copies`].
29 //!
30 //! # Three that did not survive, and why
31 //!
32 //! **The grid/list toggle.** Two `.view-btn`s over two copies of the same
33 //! items, with the choice kept in `localStorage` by
34 //! `static/page-project-2.js`. The list copy carried strictly less than the
35 //! grid copy -- a title, a type and a price against a cover, tags, a
36 //! description, a date and a sales count -- so what the toggle offered was one
37 //! full view and one lossy one. There is no member for how densely a list is
38 //! drawn and there should not be: that is the renderer's, which is the whole
39 //! premise. One described list, drawn once.
40 //!
41 //! **`.follow-btn.is-selected`**, for [`super::follow`]'s reason.
42 //!
43 //! **The promo code's `<details>`.** One optional input behind a summary, per
44 //! tier. Said as a field with the placeholder the template wrote, because a
45 //! [`Reveal`](quasi_router::Reveal) names its control by name and every tier
46 //! card on the page would have named the same one.
47 //!
48 //! # What is still markup, and who owes it
49 //!
50 //! **The section bodies.** [`Node::rich`] carries markdown and quasi renders
51 //! it, and a project section's markdown has been through
52 //! [`crate::markdown::render_creator_markdown`] first: media paths rewritten
53 //! against the CDN under the creator's own id, off-platform media hosts
54 //! stripped, and `<img>` of a video file turned into a `<video>`. None of that
55 //! is derivable from the source, so handing quasi the source would silently
56 //! drop three transforms a creator's page depends on. The panels are
57 //! [`RegionKind::Handover`]s and this module fills them; the *strip* over them
58 //! is described, which is the half that was a script.
59 //!
60 //! **The report modal.** A dialogue a control opens, and the vocabulary's
61 //! [`RegionKind::Modal`] is a modal that arrives with the screen. The markup is
62 //! still `templates/partials/report_modal.html`, rendered through
63 //! [`crate::templates::ReportModalTemplate`], so `pages/item.html` and this
64 //! page cannot drift: one file, two callers.
65
66 use makeover_layout as layout;
67 use quasi_declare::declare;
68 use quasi_router::{Action, Document, Feed, FeedKind, RegionKind, Tag};
69 use quasi_webview::Webview;
70
71 use crate::templates::CarouselFrame;
72 use crate::types::{Item, Project, ProjectSection, SubscriptionTier};
73
74 /// The page's own region, and what the skip link points at.
75 pub const PAGE_REGION: &str = "project-store";
76
77 /// The tab group the creator's own sections sit in.
78 const SECTIONS_REGION: &str = "project-sections";
79
80 /// The carousel the gallery is.
81 const GALLERY_REGION: &str = "project-gallery";
82
83 /// The place the report dialogue is handed over in.
84 ///
85 /// The id `partials/report_modal.html` writes and `actions-partials.js` looks
86 /// for, so the control that opens it keeps working without either side being
87 /// told about the other.
88 const REPORT_REGION: &str = "report-modal";
89
90 /// How wide it runs. The template wrote this on the body.
91 const MEASURE: layout::Measure = layout::Measure::Wide;
92
93 /// Where a project's feed answers.
94 #[must_use]
95 pub fn feed_path(slug: &str) -> String {
96 format!("/p/{slug}/rss")
97 }
98
99 /// One repository linked to this project.
100 ///
101 /// Named members rather than a tuple, for `policy`'s reason: a description
102 /// names what it draws, and `.1` is not a name.
103 pub struct LinkedRepo {
104 /// What the repository is called.
105 pub name: String,
106 /// Where it is on this site.
107 pub url: String,
108 }
109
110 /// Everything the screen is about.
111 ///
112 /// A struct rather than twenty arguments, for [`super::user::Profile`]'s
113 /// reason.
114 pub struct Store<'a> {
115 /// The project.
116 pub project: &'a Project,
117 /// Its id, which is what the follow route takes.
118 pub project_id: &'a str,
119 /// Who made it.
120 pub creator_username: &'a str,
121 /// Their id, which is what the tip route takes.
122 pub creator_id: &'a str,
123 /// The site's own base address, for the absolute URLs a crawler and a share
124 /// sheet read.
125 pub host_url: &'a str,
126 /// What is published in it.
127 pub items: &'a [Item],
128 /// The creator's own tabbed sections.
129 pub sections: &'a [ProjectSection],
130 /// The gallery, beside the cover rather than instead of it.
131 pub gallery: &'a [CarouselFrame],
132 /// The tiers a reader may subscribe to.
133 pub tiers: &'a [SubscriptionTier],
134 /// Linked repositories.
135 pub git_repos: &'a [LinkedRepo],
136 /// The paired forum, when one is provisioned.
137 pub community_url: Option<&'a str>,
138 /// How many people follow it.
139 pub follower_count: i64,
140 /// Whether the viewer does.
141 pub is_following: bool,
142 /// Whether the viewer already subscribes.
143 pub has_subscription: bool,
144 /// Whether the viewer owns it.
145 pub is_owner: bool,
146 /// Whether there is a session at all.
147 pub signed_in: bool,
148 /// Whether the project has anything on its blog.
149 pub has_blog_posts: bool,
150 /// Whether the creator takes tips.
151 pub tips_enabled: bool,
152 }
153
154 impl Store<'_> {
155 /// The address this page answers at.
156 fn canonical(&self) -> String {
157 format!("{}/p/{}", self.host_url, self.project.slug)
158 }
159
160 /// The picture a link preview shows: the cover, or the site's card.
161 fn image(&self) -> String {
162 self.project
163 .cover_image_url
164 .clone()
165 .filter(|url| !url.trim().is_empty())
166 .unwrap_or_else(|| format!("{}/static/images/og-card.png", self.host_url))
167 }
168
169 /// Whether there is cover art to draw.
170 ///
171 /// A predicate rather than an `Option` the description reaches into, which
172 /// is the call `super::embeds::ItemView::has_cover` records.
173 fn has_cover(&self) -> bool {
174 !self.cover().is_empty()
175 }
176
177 /// The cover's address, or nothing. R9: the picture is built either way.
178 fn cover(&self) -> &str {
179 self.project
180 .cover_image_url
181 .as_deref()
182 .map(str::trim)
183 .unwrap_or_default()
184 }
185
186 /// What a tip on this page is about.
187 ///
188 /// A method rather than the literal the caller used to write inline: a
189 /// `Type { .. }` aggregate is the form's hard limit.
190 fn tip_offer(&self) -> super::tip::Offer<'_> {
191 super::tip::Offer {
192 creator_id: self.creator_id,
193 project_id: Some(self.project_id),
194 signed_in: self.signed_in,
195 }
196 }
197
198 /// Whether this reader is offered the subscribe form.
199 fn offers_subscribe(&self) -> bool {
200 !self.has_subscription && self.signed_in
201 }
202
203 /// Whether they are offered the way to get a session instead.
204 fn must_sign_in(&self) -> bool {
205 !self.has_subscription && !self.signed_in
206 }
207
208 /// The structured data the template opened a `CollectionPage` block with.
209 ///
210 /// Escaped for JSON rather than for HTML, which is what it is: a JSON
211 /// document inside a `<script>`, where `&gt;` would be four characters in a
212 /// string rather than an escape.
213 fn structured_data(&self) -> String {
214 let json = format!(
215 "{{\"@context\":\"https://schema.org\",\"@type\":\"CollectionPage\",\
216 \"name\":\"{}\",\"description\":\"{}\",\"url\":\"{}\",\
217 \"author\":{{\"@type\":\"Person\",\"name\":\"{}\",\"url\":\"{}\"}},\
218 \"numberOfItems\":{},\
219 \"isPartOf\":{{\"@type\":\"WebSite\",\"name\":\"Makenotwork\",\"url\":\"{}\"}}}}",
220 self.project.title_json(),
221 self.project.description_json(),
222 crate::types::json_escape(&self.canonical()),
223 crate::types::json_escape(self.creator_username),
224 crate::types::json_escape(&format!("{}/u/{}", self.host_url, self.creator_username)),
225 self.project.item_count,
226 crate::types::json_escape(self.host_url),
227 );
228 format!("<script type=\"application/ld+json\">{json}</script>")
229 }
230 }
231
232 /// Whether an item has cover art to draw.
233 ///
234 /// A free function rather than a method, because `Item` is `crate::types`' and
235 /// this is a fact this screen wants rather than a fact about the item.
236 fn item_has_cover(item: &Item) -> bool {
237 item.cover().is_some()
238 }
239
240 /// Its address, or nothing. R9: the picture is built either way.
241 fn item_cover(item: &Item) -> &str {
242 item.cover().unwrap_or_default()
243 }
244
245 /// The trailing line: what kind of thing it is, how many it holds, and when it
246 /// came out.
247 fn item_meta(item: &Item) -> String {
248 if item.bundle_item_count > 0 {
249 format!(
250 "{} ({} items) - {}",
251 item.item_type, item.bundle_item_count, item.release_date
252 )
253 } else {
254 format!("{} - {}", item.item_type, item.release_date)
255 }
256 }
257
258 /// Where opening the row goes: the item, or the way to buy it.
259 fn item_destination(item: &Item) -> String {
260 if item.can_access {
261 format!("/i/{}", item.id)
262 } else {
263 format!("/purchase/{}", item.id)
264 }
265 }
266
267 /// The reader already has it.
268 fn offers_library(item: &Item) -> bool {
269 item.can_access
270 }
271
272 /// It costs nothing and they can take it.
273 fn offers_free(item: &Item) -> bool {
274 !item.can_access && item.is_free
275 }
276
277 /// They name their own price.
278 fn offers_pwyw(item: &Item) -> bool {
279 !item.can_access && !item.is_free && item.pwyw_enabled
280 }
281
282 /// They pay the listed price. The fourth of four, and the four are exhaustive
283 /// and disjoint by construction.
284 fn offers_purchase(item: &Item) -> bool {
285 !item.can_access && !item.is_free && !item.pwyw_enabled
286 }
287
288 declare! {
289 /// One item, as a row.
290 ///
291 /// Everything the card carried, in the roles the vocabulary has for them:
292 /// the cover and the title are what the row is called, the type and the
293 /// date are its meta, the description is its secondary line, the tags are
294 /// its tokens, and the way to get it is what it offers.
295 ///
296 /// The one way in, per state. The template drew four and so does this, as
297 /// four guarded controls rather than a dispatch: a dispatch arm is one
298 /// emission and these are one each, but the guards say which four states
299 /// they answer to and a reader can check they are exhaustive.
300 shape item_row(item: &Item) -> Row;
301
302 row "" {
303 beside Primary picture item_cover(item) item.title.clone()
304 when item_has_cover(item) {
305 lazy;
306 }
307 beside Primary text item.title.clone();
308 meta item_meta(item);
309 secondary item.description.clone();
310 relaxed;
311
312 for tag in item.tags.iter() {
313 token Tag::chip(
314 tag.name.clone(),
315 Action::get("/discover").carrying("tag", tag.slug.clone()).navigating()
316 );
317 }
318
319 beside Meta text item.price.clone();
320 beside Meta text "{item.sales_count} sales";
321 activate to get item_destination(item) navigating;
322
323 act "View in library" to get "/l/{item.id}" navigating when offers_library(item);
324 act "Add to Library" to post "/api/library/add/{item.id}" invalidating
325 when offers_free(item);
326 act "Pay What You Want" to get item_destination(item) navigating
327 when offers_pwyw(item);
328 act "Buy Once" to get item_destination(item) navigating when offers_purchase(item);
329 }
330 }
331
332 declare! {
333 /// One membership tier, as a group.
334 shape tier_group(store: &Store<'_>, tier: &SubscriptionTier) -> Node;
335
336 region "tier-{tier.id}" as Group {
337 section tier.name.clone();
338 text tier.price.clone();
339 text tier.description.clone() unless tier.description.is_empty();
340
341 badge "Subscribed" when store.has_subscription;
342
343 // The hidden `_csrf` goes the way the tip form's did: this arrives as
344 // an htmx post and `frontend/src/core/htmx-glue.ts` attaches the token
345 // from the document's meta. `create_subscription_checkout` ends at
346 // Stripe, so it answers `HX-Redirect` to an htmx caller.
347 form post "/stripe/subscribe/{tier.id}" when store.offers_subscribe() {
348 submit "Subscribe";
349 field Text "promo_code" "Promo code (optional)" {
350 placeholder "e.g. TRIAL14";
351 }
352 }
353
354 act "Log in to Subscribe" to get "/login" navigating when store.must_sign_in();
355 }
356 }
357
358 declare! {
359 /// The whole document.
360 #[must_use]
361 pub shape screen(store: &Store<'_>, theme_css: &str) -> Screen;
362
363 let feed = feed_path(&store.project.slug);
364
365 screen single "{store.project.title} - {store.creator_username}" {
366 measured MEASURE;
367 documented Document::default()
368 .classed(crate::shell::body_class(MEASURE, &["project-page"]))
369 // Tier 0, as `super::user` carries the creator's.
370 .styled(theme_css.to_owned());
371 summarised store.project.description.clone();
372 illustrated store.image();
373 canonical_at store.canonical();
374 about quasi_router::SocialKind::Product;
375 syndicating Feed::new(FeedKind::Rss, "{store.project.title} - RSS Feed", feed);
376
377 region PAGE_REGION as Pane {
378 picture store.cover() store.project.title.clone() when store.has_cover();
379
380 page store.project.title.clone();
381 link store.creator_username to get "/u/{store.creator_username}" navigating;
382 text "{store.project.item_count} items";
383 text store.project.description.clone();
384
385 act "Edit Project" to get "/dashboard/project/{store.project.slug}" navigating
386 when store.is_owner;
387
388 include super::follow::control(
389 "project",
390 store.project_id,
391 store.is_following,
392 store.follower_count
393 ) when store.signed_in;
394 // An `-> Option<_>` shape is placed with a loop, because an
395 // `Option` is an iterator of at most one.
396 for counted in super::follow::count_only(store.follower_count).into_iter() {
397 include counted unless store.signed_in;
398 }
399
400 act "RSS Feed" to get feed.clone() navigating;
401 act "Blog" to get "/p/{store.project.slug}/blog" navigating
402 when store.has_blog_posts;
403
404 for repo in store.git_repos.iter() {
405 act "Git ({repo.name})" to get repo.url.clone() navigating;
406 }
407
408 // The forum is a separate deployment on its own host, and a reader
409 // is expected to come back, which is what `external` says.
410 for url in store.community_url.into_iter() {
411 act "Community" to external url;
412 }
413
414 include super::tip::control(&store.tip_offer()) when store.tips_enabled;
415
416 // The shipped widget rather than a fourth hand-rolled carousel:
417 // `super::widgets::carousel` is the one place a template frame
418 // becomes a described one, and the Askama macro the item page still
419 // calls goes through it too.
420 include super::widgets::carousel::region(GALLERY_REGION, store.gallery)
421 unless store.gallery.is_empty();
422
423 region SECTIONS_REGION as TabGroup unless store.sections.is_empty() {
424 showing_one 0;
425 for section in store.sections.iter() {
426 region panel_region(&section.slug) as RegionKind::handover("a project section") {
427 label section.title.clone();
428 }
429 }
430 }
431
432 section "Available Items";
433 given store.items.is_empty() {
434 true -> empty "Nothing published here yet.";
435 otherwise -> list {
436 for item in store.items.iter() {
437 include item_row(item);
438 }
439 }
440 }
441
442 section "Membership" unless store.tiers.is_empty();
443 for tier in store.tiers.iter() {
444 include tier_group(store, tier);
445 }
446
447 link "Powered by Makenot.work" to get "/" navigating;
448 text "Fair distribution for creatives of all kinds";
449 act "Copy link" to local {
450 copying store.canonical();
451 }
452 link "Policy" to get "/policy" navigating;
453
454 // Reporting is a write, so it needs a session. Signed out, the
455 // offer is the way to get one, which is what the template drew.
456 region REPORT_REGION as RegionKind::handover("the report dialogue")
457 when store.signed_in {}
458 link "Report" to get "/login" navigating unless store.signed_in;
459 }
460 }
461 }
462
463 /// The id of the region one section's body is handed over in.
464 ///
465 /// `section-<slug>` because that is what `style.css` matched and what the
466 /// script's `history.replaceState` wrote into the fragment, so an old link into
467 /// a section still lands on it.
468 fn panel_region(slug: &str) -> String {
469 format!("section-{slug}")
470 }
471
472 /// The document this screen is drawn in, with every handover paid.
473 #[must_use]
474 pub fn renderer(
475 viewer: Option<&crate::auth::SessionUser>,
476 csrf: Option<&str>,
477 store: &Store<'_>,
478 ) -> Webview {
479 let csrf = csrf.unwrap_or_default();
480 let mut webview = Webview::new().with_shell(
481 crate::shell::described()
482 .sending("X-CSRF-Token", csrf)
483 .with_body_last(crate::shell::body_last())
484 .with_body_first(format!(
485 "{}{}",
486 crate::shell::skip_link(PAGE_REGION),
487 crate::shell::site_header(viewer)
488 ))
489 .with_head(format!(
490 "<meta name=\"csrf-token\" content=\"{}\">{}",
491 crate::helpers::escape_html(csrf),
492 store.structured_data()
493 )),
494 );
495
496 for section in store.sections {
497 webview = webview.with_fill(panel_region(&section.slug), section.body_html.clone());
498 }
499
500 if store.signed_in {
501 webview = webview.with_fill(REPORT_REGION, report_markup(store.project_id));
502 }
503
504 webview
505 }
506
507 /// The report dialogue, from the file `pages/item.html` still includes.
508 ///
509 /// One copy of the markup, two callers. A failure to render is an empty fill
510 /// rather than a failure to draw the page: the dialogue is one control on a
511 /// storefront, and losing the storefront over it is the wrong trade.
512 fn report_markup(project_id: &str) -> String {
513 crate::templates::ReportModalTemplate {
514 report_target_type: "project",
515 report_target_id: project_id.to_owned(),
516 report_has_labels: true,
517 }
518 .render_string()
519 .unwrap_or_default()
520 }
521
522 /// Render it.
523 #[must_use]
524 pub fn document(
525 viewer: Option<&crate::auth::SessionUser>,
526 csrf: Option<&str>,
527 store: &Store<'_>,
528 theme_css: &str,
529 ) -> String {
530 use quasi_axum::Serves as _;
531
532 let screen = screen(store, theme_css);
533 renderer(viewer, csrf, store).screen(&screen)
534 }
535
536 #[cfg(test)]
537 mod tests {
538 use super::*;
539 use crate::types::TagView;
540
541 fn project() -> Project {
542 Project {
543 id: "p1".to_owned(),
544 slug: "blue-hour".to_owned(),
545 title: "Blue Hour".to_owned(),
546 description: "Field recordings".to_owned(),
547 item_count: 2,
548 project_type: "Music".to_owned(),
549 cover_image_url: None,
550 }
551 }
552
553 fn item(id: &str) -> Item {
554 Item {
555 id: id.to_owned(),
556 title: format!("Track {id}"),
557 price: "$5".to_owned(),
558 price_cents: 500,
559 item_type: "Audio".to_owned(),
560 description: "A recording".to_owned(),
561 thumbnail: "Audio".to_owned(),
562 release_date: "2026-08-01".to_owned(),
563 sales_count: 7,
564 tags: vec![TagView {
565 id: "t1".to_owned(),
566 name: "Ambient".to_owned(),
567 slug: "ambient".to_owned(),
568 is_primary: true,
569 }],
570 content: crate::types::ItemContent::Text {
571 body: None,
572 body_html: None,
573 reading_time: None,
574 reading_time_minutes: None,
575 word_count: None,
576 },
577 cover_image_url: None,
578 is_free: false,
579 can_access: false,
580 enable_license_keys: false,
581 default_max_activations: None,
582 pwyw_enabled: false,
583 pwyw_min_cents: None,
584 publish_at: None,
585 is_public: true,
586 listed: true,
587 bundle_item_count: 0,
588 license_preset: None,
589 custom_license_text: None,
590 ai_tier: crate::db::AiTier::Handmade,
591 ai_disclosure: None,
592 }
593 }
594
595 fn store<'a>(project: &'a Project, items: &'a [Item]) -> Store<'a> {
596 Store {
597 project,
598 project_id: "p1",
599 creator_username: "maxj",
600 creator_id: "u1",
601 host_url: "https://makenot.work",
602 items,
603 sections: &[],
604 gallery: &[],
605 tiers: &[],
606 git_repos: &[],
607 community_url: None,
608 follower_count: 0,
609 is_following: false,
610 has_subscription: false,
611 is_owner: false,
612 signed_in: false,
613 has_blog_posts: false,
614 tips_enabled: false,
615 }
616 }
617
618 fn html(store: &Store<'_>) -> String {
619 document(None, Some("t"), store, ":root{--x:1}")
620 }
621
622 /// The last of the three hand-written tags, said by the screen, spelled by
623 /// the vocabulary, and emitted once.
624 #[test]
625 fn the_feed_is_declared_once_and_spelled_by_the_vocabulary() {
626 let project = project();
627 let screen = screen(&store(&project, &[]), "");
628 let feed = screen.discovery.feed.as_ref().expect("declared");
629 assert_eq!(feed.href, "/p/blue-hour/rss");
630 assert_eq!(feed.title, "Blue Hour - RSS Feed");
631
632 let rendered = html(&store(&project, &[]));
633 assert!(
634 rendered.contains(
635 "<link rel=\"alternate\" type=\"application/rss+xml\" \
636 title=\"Blue Hour - RSS Feed\" href=\"/p/blue-hour/rss\">"
637 ),
638 "{rendered}"
639 );
640 assert_eq!(
641 rendered.matches("rel=\"alternate\"").count(),
642 1,
643 "{rendered}"
644 );
645 assert!(rendered.contains(">RSS Feed<"), "{rendered}");
646 }
647
648 /// The creator's Tier 0 sheet lands after every stylesheet the shell
649 /// carries, which is what the unlayered `<style>` block held.
650 #[test]
651 fn the_project_theme_outranks_the_site_stylesheets() {
652 let project = project();
653 let rendered = document(
654 None,
655 Some("t"),
656 &store(&project, &[]),
657 ":root{--accent:red}",
658 );
659 let theme = rendered.find(":root{--accent:red}").expect("written");
660 let last_sheet = rendered.rfind("/static/style.css").expect("linked");
661 assert!(theme > last_sheet, "the theme landed before style.css");
662 }
663
664 /// The `CollectionPage` block the template opened, built from the same
665 /// facts and escaped for JSON rather than for HTML.
666 #[test]
667 fn the_page_still_describes_itself_to_a_crawler() {
668 let project = project();
669 let rendered = html(&store(&project, &[]));
670 assert!(
671 rendered.contains("\"@type\":\"CollectionPage\""),
672 "{rendered}"
673 );
674 assert!(rendered.contains("\"numberOfItems\":2"), "{rendered}");
675 assert!(
676 rendered.contains("\"url\":\"https://makenot.work/u/maxj\""),
677 "{rendered}"
678 );
679 }
680
681 /// Everything an item card carried is still on its row, and the row still
682 /// goes where the card went.
683 #[test]
684 fn an_item_keeps_everything_its_card_carried() {
685 let project = project();
686 let items = [item("i1")];
687 let rendered = html(&store(&project, &items));
688 for fact in [
689 "Track i1",
690 "Audio",
691 "2026-08-01",
692 "A recording",
693 "$5",
694 "7 sales",
695 "Ambient",
696 "/purchase/i1",
697 ] {
698 assert!(rendered.contains(fact), "{fact} is missing: {rendered}");
699 }
700 assert!(rendered.contains("tag=ambient"), "{rendered}");
701 }
702
703 /// The four ways in, one per state, which is what the template drew.
704 #[test]
705 fn every_item_state_offers_the_one_control_it_should() {
706 let project = project();
707
708 let mut owned = item("i1");
709 owned.can_access = true;
710 let rendered = html(&store(&project, &[owned]));
711 assert!(rendered.contains("/l/i1"), "{rendered}");
712 assert!(rendered.contains("View in library"), "{rendered}");
713
714 let mut free = item("i2");
715 free.is_free = true;
716 let rendered = html(&store(&project, &[free]));
717 assert!(rendered.contains("Add to Library"), "{rendered}");
718 assert!(rendered.contains("/api/library/add/i2"), "{rendered}");
719
720 let mut pwyw = item("i3");
721 pwyw.pwyw_enabled = true;
722 assert!(html(&store(&project, &[pwyw])).contains("Pay What You Want"));
723
724 assert!(html(&store(&project, &[item("i4")])).contains("Buy Once"));
725 }
726
727 /// Claiming a free item changes the row it was pressed in -- the item is
728 /// owned now -- and a row is not a region a control can answer into. The
729 /// screen says the surface is stale instead, which is what
730 /// `Replaces::Everything` is for, and the reader gets a card that offers
731 /// the library rather than a status line stuck in a card that still says
732 /// "Add".
733 #[test]
734 fn claiming_a_free_item_says_the_page_is_stale() {
735 let project = project();
736 let mut free = item("i1");
737 free.is_free = true;
738 let rendered = html(&store(&project, &[free]));
739 assert!(!rendered.contains("save-status"), "{rendered}");
740 assert!(rendered.contains("/api/library/add/i1"), "{rendered}");
741 }
742
743 /// The strip is described and the panels are handed over, which is the
744 /// split: the switching was a script and the bodies are creator markdown
745 /// that has been through this server's own media pass.
746 #[test]
747 fn the_sections_get_a_described_strip_and_their_own_markup() {
748 let project = project();
749 let sections = [
750 ProjectSection {
751 id: "s1".to_owned(),
752 title: "Privacy".to_owned(),
753 slug: "privacy".to_owned(),
754 body: "raw".to_owned(),
755 body_html: "<p>Rendered <video src=\"x\"></video></p>".to_owned(),
756 sort_order: 0,
757 },
758 ProjectSection {
759 id: "s2".to_owned(),
760 title: "Terms".to_owned(),
761 slug: "terms".to_owned(),
762 body: "raw".to_owned(),
763 body_html: "<p>Second</p>".to_owned(),
764 sort_order: 1,
765 },
766 ];
767 let mut with_sections = store(&project, &[]);
768 with_sections.sections = &sections;
769 let rendered = html(&with_sections);
770
771 // Both panels keep the id an old fragment link points at.
772 assert!(rendered.contains("id=\"section-privacy\""), "{rendered}");
773 assert!(rendered.contains("id=\"section-terms\""), "{rendered}");
774 // Both are filled with the markup this server rendered, untouched.
775 assert!(rendered.contains("<video src=\"x\"></video>"), "{rendered}");
776 assert!(rendered.contains("<p>Second</p>"), "{rendered}");
777 // The strip is the vocabulary's, so nothing calls the deleted script.
778 assert!(rendered.contains("role=\"tablist\""), "{rendered}");
779 assert!(!rendered.contains("onSwitchSectionTab"), "{rendered}");
780 assert!(!rendered.contains("page-project.js"), "{rendered}");
781 }
782
783 /// The gallery is a named assembly, and its frames are ordinary pictures,
784 /// so a host that has never heard of a carousel still draws all of them.
785 #[test]
786 fn the_gallery_is_a_carousel_of_plain_pictures() {
787 let project = project();
788 let gallery = [
789 CarouselFrame {
790 image: "/a.png".to_owned(),
791 alt: "First".to_owned(),
792 caption: None,
793 intrinsic: Some((800, 600)),
794 },
795 CarouselFrame {
796 image: "/b.png".to_owned(),
797 alt: "Second".to_owned(),
798 caption: Some("Two".to_owned()),
799 intrinsic: None,
800 },
801 ];
802 let mut with_gallery = store(&project, &[]);
803 with_gallery.gallery = &gallery;
804 let rendered = html(&with_gallery);
805 assert!(rendered.contains("data-widget=\"carousel\""), "{rendered}");
806 assert!(rendered.contains("/a.png"), "{rendered}");
807 assert!(rendered.contains("/b.png"), "{rendered}");
808 // The one frame whose size this server knows reserves its space.
809 assert!(rendered.contains("width=\"800\""), "{rendered}");
810 }
811
812 /// A tier's form carries no hidden token: the header does, and
813 /// `create_subscription_checkout` answers `HX-Redirect` to an htmx caller.
814 #[test]
815 fn a_tier_is_offered_by_state_and_carries_no_hidden_token() {
816 let project = project();
817 let tiers = [SubscriptionTier {
818 id: "t1".to_owned(),
819 name: "Patron".to_owned(),
820 description: "Everything".to_owned(),
821 price: "$5/mo".to_owned(),
822 price_cents: 500,
823 is_active: true,
824 sort_order: 0,
825 has_stripe_price: true,
826 }];
827
828 let mut signed_out = store(&project, &[]);
829 signed_out.tiers = &tiers;
830 let rendered = html(&signed_out);
831 assert!(rendered.contains("Log in to Subscribe"), "{rendered}");
832 assert!(!rendered.contains("/stripe/subscribe/t1"), "{rendered}");
833
834 let mut signed_in = store(&project, &[]);
835 signed_in.tiers = &tiers;
836 signed_in.signed_in = true;
837 let rendered = html(&signed_in);
838 assert!(rendered.contains("/stripe/subscribe/t1"), "{rendered}");
839 assert!(rendered.contains("promo_code"), "{rendered}");
840 assert!(!rendered.contains("_csrf"), "{rendered}");
841
842 let mut subscribed = store(&project, &[]);
843 subscribed.tiers = &tiers;
844 subscribed.signed_in = true;
845 subscribed.has_subscription = true;
846 let rendered = html(&subscribed);
847 assert!(rendered.contains("Subscribed"), "{rendered}");
848 assert!(!rendered.contains("/stripe/subscribe/t1"), "{rendered}");
849 }
850
851 /// The report dialogue is one file with two callers, and it is only handed
852 /// over to a reader who could file one.
853 #[test]
854 fn the_report_dialogue_is_paid_for_only_when_there_is_a_session() {
855 let project = project();
856 let rendered = html(&store(&project, &[]));
857 assert!(!rendered.contains("report-modal"), "{rendered}");
858 assert!(rendered.contains(">Report<"), "{rendered}");
859
860 let mut signed_in = store(&project, &[]);
861 signed_in.signed_in = true;
862 let rendered = html(&signed_in);
863 assert!(rendered.contains("id=\"report-modal\""), "{rendered}");
864 assert!(rendered.contains("/api/reports"), "{rendered}");
865 }
866
867 /// The offers that depend on what the project has: a blog, repositories, a
868 /// forum. Each was a conditional in the template and each still is.
869 #[test]
870 fn the_page_offers_only_what_the_project_actually_has() {
871 let project = project();
872 let bare = html(&store(&project, &[]));
873 assert!(!bare.contains("/p/blue-hour/blog"), "{bare}");
874 assert!(!bare.contains(">Community<"), "{bare}");
875
876 let repos = [LinkedRepo {
877 name: "engine".to_owned(),
878 url: "/git/maxj/engine".to_owned(),
879 }];
880 let mut full = store(&project, &[]);
881 full.has_blog_posts = true;
882 full.git_repos = &repos;
883 full.community_url = Some("https://forum.example.com/p/blue-hour");
884 let rendered = html(&full);
885 assert!(rendered.contains("/p/blue-hour/blog"), "{rendered}");
886 assert!(rendered.contains("Git (engine)"), "{rendered}");
887 // The forum is another deployment and a reader is expected to come
888 // back, which is what a tab means.
889 assert!(rendered.contains("target=\"_blank\""), "{rendered}");
890 }
891
892 /// A project with nothing published says so rather than drawing an empty
893 /// list, which is what `ui::empty_state` was for.
894 #[test]
895 fn a_project_with_no_items_says_so() {
896 let project = project();
897 assert!(html(&store(&project, &[])).contains("Nothing published here yet."));
898 }
899
900 /// `Act::copies` is what `data-copy-link` meant, and the value is absolute.
901 #[test]
902 fn the_share_control_copies_the_whole_address() {
903 let project = project();
904 assert!(
905 html(&store(&project, &[]))
906 .contains("data-copies=\"https://makenot.work/p/blue-hour\""),
907 );
908 }
909
910 /// `736f45a5`: none of the four spellings.
911 #[test]
912 fn the_page_spells_no_spinner() {
913 let project = project();
914 let items = [item("i1")];
915 let rendered = html(&store(&project, &items));
916 for spelling in ["htmx-indicator", "spinner", "loading-text", "loading-state"] {
917 assert!(!rendered.contains(spelling), "{spelling} survives");
918 }
919 }
920 }
921