max / makenotwork
3 files changed,
+257 insertions,
-194 deletions
| @@ -35,9 +35,8 @@ | |||
| 35 | 35 | //! is the ability to collapse a section that starts expanded, and no reader | |
| 36 | 36 | //! loses anything they can currently see. | |
| 37 | 37 | ||
| 38 | - | use makeover_layout as layout; | |
| 39 | - | use quasi_router::screen::{Cell, Cells, Column, Table}; | |
| 40 | - | use quasi_router::{Action, Node, RegionKind, Request, Response, RouteError, Slot}; | |
| 38 | + | use quasi_declare::declare; | |
| 39 | + | use quasi_router::{Request, Response, RouteError}; | |
| 41 | 40 | use quasi_webview::Webview; | |
| 42 | 41 | ||
| 43 | 42 | use super::Viewer; | |
| @@ -89,84 +88,98 @@ | |||
| 89 | 88 | Ok(Response::fragment(REGION, pane(&buyers))) | |
| 90 | 89 | } | |
| 91 | 90 | ||
| 92 | - | /// Everything inside the section. | |
| 93 | - | fn pane(buyers: &[BuyerView]) -> Node { | |
| 94 | - | let slot = Slot::new(REGION, RegionKind::Pane) | |
| 95 | - | .with(Node::section(format!("Shared Contacts ({})", buyers.len()))) | |
| 96 | - | .with(Node::text( | |
| 97 | - | "Buyers who opted to share their email at checkout. \ | |
| 98 | - | They can revoke sharing from their library.", | |
| 99 | - | )); | |
| 91 | + | declare! { | |
| 92 | + | /// Everything inside the section. | |
| 93 | + | shape pane(buyers: &[BuyerView]) -> Node; | |
| 100 | 94 | ||
| 101 | - | if buyers.is_empty() { | |
| 102 | - | return Node::Region(slot.with(Node::empty( | |
| 103 | - | "No shared contacts yet. When buyers opt to share their email at checkout, \ | |
| 104 | - | they will appear here.", | |
| 105 | - | ))); | |
| 95 | + | region REGION as Pane { | |
| 96 | + | section "Shared Contacts ({buyers.len()})"; | |
| 97 | + | text "Buyers who opted to share their email at checkout. \ | |
| 98 | + | They can revoke sharing from their library."; | |
| 99 | + | ||
| 100 | + | empty "No shared contacts yet. When buyers opt to share their email at checkout, \ | |
| 101 | + | they will appear here." when buyers.is_empty(); | |
| 102 | + | ||
| 103 | + | // The template hides the button and the table together: an export of an | |
| 104 | + | // empty set is a file nobody wants. | |
| 105 | + | include export() unless buyers.is_empty(); | |
| 106 | + | include table(buyers) unless buyers.is_empty(); | |
| 106 | 107 | } | |
| 107 | - | ||
| 108 | - | Node::Region(slot.with(export()).with(table(buyers))) | |
| 109 | 108 | } | |
| 110 | 109 | ||
| 111 | - | /// The Export CSV control. | |
| 112 | - | /// | |
| 113 | - | /// `Action::saving` is the whole of what used to be | |
| 114 | - | /// `data-action="exportCsvButton" data-arg="/api/export/contacts" | |
| 115 | - | /// data-arg2="contacts.csv"`: a class naming a behaviour, plus the two things | |
| 116 | - | /// the behaviour needed, positionally. Said here it is one sentence, the host | |
| 117 | - | /// performs it from one attribute, and a terminal renderer can write the file to | |
| 118 | - | /// disk without being told which button this is. | |
| 119 | - | /// | |
| 120 | - | /// `awaiting` because the server assembles the file before any of it comes | |
| 121 | - | /// back, which is the report case `Action::awaiting`'s own docs name. Nothing | |
| 122 | - | /// countable to say about it: the row count is known here but the bytes are | |
| 123 | - | /// not, and `layout::Awaiting` takes a measurement rather than a stand-in for | |
| 124 | - | /// one. | |
| 125 | - | fn export() -> Node { | |
| 110 | + | declare! { | |
| 111 | + | /// The Export CSV control. | |
| 112 | + | /// | |
| 113 | + | /// `Action::saving` is the whole of what used to be | |
| 114 | + | /// `data-action="exportCsvButton" data-arg="/api/export/contacts" | |
| 115 | + | /// data-arg2="contacts.csv"`: a class naming a behaviour, plus the two | |
| 116 | + | /// things the behaviour needed, positionally. Said here it is one sentence, | |
| 117 | + | /// the host performs it from one attribute, and a terminal renderer can | |
| 118 | + | /// write the file to disk without being told which button this is. | |
| 119 | + | /// | |
| 120 | + | /// `awaiting` because the server assembles the file before any of it comes | |
| 121 | + | /// back, which is the report case `Action::awaiting`'s own docs name. | |
| 122 | + | /// Nothing countable to say about it: the row count is known here but the | |
| 123 | + | /// bytes are not, and `layout::Awaiting` takes a measurement rather than a | |
| 124 | + | /// stand-in for one. | |
| 125 | + | #[must_use] | |
| 126 | + | shape export() -> Node; | |
| 127 | + | ||
| 126 | 128 | // Through `export_act` rather than spelled again here. This screen had the | |
| 127 | 129 | // only described copy when it was written; there are five call sites now | |
| 128 | 130 | // (`27d5e5b8`, the glue-module ruling), and one of them is the Askama | |
| 129 | 131 | // fallback for this very tab. Two spellings of one control is what the | |
| 130 | 132 | // conversion is for removing. | |
| 131 | - | super::export_act::act("/api/export/contacts", "contacts.csv") | |
| 133 | + | include super::export_act::act("/api/export/contacts", "contacts.csv"); | |
| 132 | 134 | } | |
| 133 | 135 | ||
| 134 | - | /// The buyers who shared an email. | |
| 135 | - | fn table(buyers: &[BuyerView]) -> Node { | |
| 136 | - | // Cells by position rather than by name, which is the safe half of the | |
| 137 | - | // choice `Table` offers: the columns and the rows are the one expression | |
| 138 | - | // below, and every buyer contributes the same five cells, so there is no | |
| 139 | - | // seam for a heading and a cell to drift across. Nothing is paged either, | |
| 140 | - | // so no `more`: this is a whole set the handler already counted. | |
| 141 | - | Table::new(vec![ | |
| 142 | - | Column::new("Username") | |
| 143 | - | .width(layout::Width::Content) | |
| 144 | - | .priority(layout::Priority::Essential), | |
| 145 | - | Column::new("Email") | |
| 146 | - | .width(layout::Width::Fill) | |
| 147 | - | .priority(layout::Priority::Essential), | |
| 148 | - | Column::new("Purchases").width(layout::Width::Content), | |
| 149 | - | Column::new("Total Spent").width(layout::Width::Content), | |
| 150 | - | Column::new("Last Purchase") | |
| 151 | - | .width(layout::Width::Content) | |
| 152 | - | .priority(layout::Priority::Optional), | |
| 153 | - | ]) | |
| 154 | - | .rows(buyers.iter().map(|buyer| { | |
| 155 | - | Cells::new([ | |
| 156 | - | Cell::new(buyer.username.clone()) | |
| 157 | - | .activate(Action::get(format!("/u/{}", buyer.username)).navigating()), | |
| 158 | - | // Plain text, unlike `library_contacts`, and the templates | |
| 159 | - | // differ the same way: a creator's own list does not link | |
| 160 | - | // the address it is showing. Kept rather than harmonised, | |
| 161 | - | // because which of the two is right is a design question | |
| 162 | - | // and this batch is a conversion. | |
| 163 | - | Cell::new(buyer.email.clone()), | |
| 164 | - | Cell::new(buyer.purchases.clone()), | |
| 165 | - | Cell::new(buyer.spent.clone()), | |
| 166 | - | Cell::new(buyer.last_purchase.clone()), | |
| 167 | - | ]) | |
| 168 | - | })) | |
| 169 | - | .into() | |
| 136 | + | declare! { | |
| 137 | + | /// The buyers who shared an email. | |
| 138 | + | /// | |
| 139 | + | /// Cells by position rather than by name, which is the safe half of the | |
| 140 | + | /// choice `Table` offers: the columns and the rows are the one block below, | |
| 141 | + | /// and every buyer contributes the same five cells, so there is no seam for | |
| 142 | + | /// a heading and a cell to drift across. Nothing is paged either, so no | |
| 143 | + | /// `more`: this is a whole set the handler already counted. | |
| 144 | + | shape table(buyers: &[BuyerView]) -> Node; | |
| 145 | + | ||
| 146 | + | table { | |
| 147 | + | column "Username" { | |
| 148 | + | width Content; | |
| 149 | + | priority Essential; | |
| 150 | + | } | |
| 151 | + | column "Email" { | |
| 152 | + | width Fill; | |
| 153 | + | priority Essential; | |
| 154 | + | } | |
| 155 | + | column "Purchases" { | |
| 156 | + | width Content; | |
| 157 | + | } | |
| 158 | + | column "Total Spent" { | |
| 159 | + | width Content; | |
| 160 | + | } | |
| 161 | + | column "Last Purchase" { | |
| 162 | + | width Content; | |
| 163 | + | priority Optional; | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | for buyer in buyers { | |
| 167 | + | cells { | |
| 168 | + | cell buyer.username.clone() { | |
| 169 | + | activate to get "/u/{buyer.username}" navigating; | |
| 170 | + | } | |
| 171 | + | // Plain text, unlike `library_contacts`, and the templates | |
| 172 | + | // differ the same way: a creator's own list does not link the | |
| 173 | + | // address it is showing. Kept rather than harmonised, because | |
| 174 | + | // which of the two is right is a design question and this batch | |
| 175 | + | // is a conversion. | |
| 176 | + | cell buyer.email.clone(); | |
| 177 | + | cell buyer.purchases.clone(); | |
| 178 | + | cell buyer.spent.clone(); | |
| 179 | + | cell buyer.last_purchase.clone(); | |
| 180 | + | } | |
| 181 | + | } | |
| 182 | + | } | |
| 170 | 183 | } | |
| 171 | 184 | ||
| 172 | 185 | /// The renderer this screen is drawn with. | |
| @@ -176,8 +189,10 @@ | |||
| 176 | 189 | ||
| 177 | 190 | #[cfg(test)] | |
| 178 | 191 | mod tests { | |
| 179 | - | use super::*; | |
| 180 | 192 | use quasi_axum::Serves; | |
| 193 | + | use quasi_router::Node; | |
| 194 | + | ||
| 195 | + | use super::*; | |
| 181 | 196 | ||
| 182 | 197 | fn buyer(username: &str) -> BuyerView { | |
| 183 | 198 | BuyerView { | |
| @@ -252,6 +267,24 @@ | |||
| 252 | 267 | assert!(!html.contains("mailto:"), "{html}"); | |
| 253 | 268 | } | |
| 254 | 269 | ||
| 270 | + | /// The five headings, which moved from a hand-written `Table::new` list into | |
| 271 | + | /// the declaration. A column dropped on the way is silent: the cells still | |
| 272 | + | /// render and land under the wrong name. | |
| 273 | + | #[test] | |
| 274 | + | fn every_column_the_table_had_is_still_named() { | |
| 275 | + | let html = render(&table(&[buyer("ada")])); | |
| 276 | + | ||
| 277 | + | for heading in [ | |
| 278 | + | "Username", | |
| 279 | + | "Email", | |
| 280 | + | "Purchases", | |
| 281 | + | "Total Spent", | |
| 282 | + | "Last Purchase", | |
| 283 | + | ] { | |
| 284 | + | assert!(html.contains(heading), "{heading} is gone from {html}"); | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 255 | 288 | #[test] | |
| 256 | 289 | fn a_username_cannot_smuggle_markup() { | |
| 257 | 290 | let html = render(&table(&[buyer("<script>x()</script>")])); |
| @@ -28,10 +28,9 @@ | |||
| 28 | 28 | //! markup and drops the reason leaves the next reader to rediscover it. | |
| 29 | 29 | ||
| 30 | 30 | use makeover_layout as layout; | |
| 31 | - | use quasi_router::screen::{Cell, Cells, Column, Rest, Table}; | |
| 32 | - | use quasi_router::{ | |
| 33 | - | Action, Document, Node, RegionKind, Request, Response, RouteError, Screen as Described, Slot, | |
| 34 | - | }; | |
| 31 | + | use quasi_declare::declare; | |
| 32 | + | use quasi_router::screen::Rest; | |
| 33 | + | use quasi_router::{Action, Document, Request, Response, RouteError}; | |
| 35 | 34 | use quasi_webview::Webview; | |
| 36 | 35 | ||
| 37 | 36 | use crate::{constants, db}; | |
| @@ -109,79 +108,89 @@ | |||
| 109 | 108 | }) | |
| 110 | 109 | } | |
| 111 | 110 | ||
| 112 | - | /// The whole document: the title, the measure, the body. | |
| 113 | - | fn page_screen(loaded: &Loaded) -> Described { | |
| 114 | - | let mut page = Slot::new(PAGE_REGION, RegionKind::Pane) | |
| 115 | - | .with(Node::page("Repositories")) | |
| 116 | - | // Notes are the one thing this browser does that no other forge does, | |
| 117 | - | // and nothing on a repository page says so to somebody who has never | |
| 118 | - | // seen one. The landing page is where that sentence reaches everybody. | |
| 119 | - | .with(super::own_prose( | |
| 120 | - | "Every repository here renders [git notes](/docs/git-notes): annotation attached \ | |
| 121 | - | to a commit without rewriting it, stored in the repository and carried by a clone.", | |
| 122 | - | )); | |
| 111 | + | declare! { | |
| 112 | + | /// The whole document: the title, the measure, the body. | |
| 113 | + | shape page_screen(loaded: &Loaded) -> Screen; | |
| 123 | 114 | ||
| 124 | - | // The only route to an annotation whose target repository is gone: nothing | |
| 125 | - | // else links to it once there is no commit page to link from. | |
| 126 | - | if loaded.signed_in { | |
| 127 | - | page = page.with(super::own_prose( | |
| 128 | - | "[Your annotations](/git/my-annotations), private to you, across every repository \ | |
| 129 | - | you have read here.", | |
| 130 | - | )); | |
| 131 | - | } | |
| 115 | + | screen single "Repositories - Git - Makenotwork" { | |
| 116 | + | measured MEASURE; | |
| 117 | + | documented Document::default().classed(crate::shell::body_class(MEASURE, &[])); | |
| 118 | + | summarised "Public repositories on Makenotwork, with git notes rendered on every commit."; | |
| 132 | 119 | ||
| 133 | - | page = if loaded.repos.is_empty() { | |
| 134 | - | page.with(Node::empty("No public repositories yet.")) | |
| 135 | - | } else { | |
| 136 | - | page.with(listing(loaded)) | |
| 137 | - | }; | |
| 120 | + | region PAGE_REGION as Pane { | |
| 121 | + | page "Repositories"; | |
| 138 | 122 | ||
| 139 | - | Described::single("Repositories - Git - Makenotwork") | |
| 140 | - | .measured(MEASURE) | |
| 141 | - | .documented(Document::default().classed(crate::shell::body_class(MEASURE, &[]))) | |
| 142 | - | .summarised("Public repositories on Makenotwork, with git notes rendered on every commit.") | |
| 143 | - | .with(page) | |
| 144 | - | } | |
| 123 | + | // Notes are the one thing this browser does that no other forge | |
| 124 | + | // does, and nothing on a repository page says so to somebody who | |
| 125 | + | // has never seen one. The landing page is where that sentence | |
| 126 | + | // reaches everybody. | |
| 127 | + | include super::own_prose( | |
| 128 | + | "Every repository here renders [git notes](/docs/git-notes): annotation attached \ | |
| 129 | + | to a commit without rewriting it, stored in the repository and carried by a clone." | |
| 130 | + | ); | |
| 145 | 131 | ||
| 146 | - | /// The repositories, as a table, with whatever pages remain. | |
| 147 | - | fn listing(loaded: &Loaded) -> Node { | |
| 148 | - | // Two columns, two cells, all four written here, so the row stays | |
| 149 | - | // positional: naming would buy nothing a reader cannot already check by | |
| 150 | - | // looking up four lines. | |
| 151 | - | let table = Table::new(vec![ | |
| 152 | - | Column::new("Repository") | |
| 153 | - | .width(layout::Width::Content) | |
| 154 | - | .priority(layout::Priority::Essential), | |
| 155 | - | Column::new("Description").width(layout::Width::Fill), | |
| 156 | - | ]) | |
| 157 | - | .rows(loaded.repos.iter().map(|repo| { | |
| 158 | - | Cells::new([ | |
| 159 | - | Cell::new(format!("{}/{}", repo.owner, repo.name)), | |
| 160 | - | Cell::new(repo.description.clone()), | |
| 161 | - | ]) | |
| 162 | - | .activate(Action::get(format!("/git/{}/{}", repo.owner, repo.name)).navigating()) | |
| 163 | - | })); | |
| 132 | + | // The only route to an annotation whose target repository is gone: | |
| 133 | + | // nothing else links to it once there is no commit page to link | |
| 134 | + | // from. | |
| 135 | + | include super::own_prose( | |
| 136 | + | "[Your annotations](/git/my-annotations), private to you, across every repository \ | |
| 137 | + | you have read here." | |
| 138 | + | ) when loaded.signed_in; | |
| 164 | 139 | ||
| 165 | - | // One page of one is the whole listing, and [`rest`] answers `None` for it. | |
| 166 | - | match rest(loaded) { | |
| 167 | - | Some(rest) => table.more(rest).into(), | |
| 168 | - | None => table.into(), | |
| 140 | + | given loaded.repos.is_empty() { | |
| 141 | + | true -> empty "No public repositories yet."; | |
| 142 | + | otherwise -> include listing(loaded); | |
| 143 | + | } | |
| 144 | + | } | |
| 169 | 145 | } | |
| 170 | 146 | } | |
| 171 | 147 | ||
| 172 | - | /// What the reader has not been shown, when there is any. | |
| 148 | + | declare! { | |
| 149 | + | /// The repositories, as a table, with whatever pages remain. | |
| 150 | + | /// | |
| 151 | + | /// Two columns, two cells, all four written here, so the row stays | |
| 152 | + | /// positional: naming would buy nothing a reader cannot already check by | |
| 153 | + | /// looking up four lines. | |
| 154 | + | shape listing(loaded: &Loaded) -> Node; | |
| 155 | + | ||
| 156 | + | table { | |
| 157 | + | column "Repository" { | |
| 158 | + | width Content; | |
| 159 | + | priority Essential; | |
| 160 | + | } | |
| 161 | + | column "Description" { | |
| 162 | + | width Fill; | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | for repo in loaded.repos.iter() { | |
| 166 | + | cells { | |
| 167 | + | cell "{repo.owner}/{repo.name}"; | |
| 168 | + | cell repo.description.clone(); | |
| 169 | + | activate to get "/git/{repo.owner}/{repo.name}" navigating; | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // One page of one is the whole listing, and the pager is what says so. | |
| 174 | + | // The condition is `rest`'s own, said here rather than answered with an | |
| 175 | + | // `Option`: a table either shows what it has not shown or does not, and | |
| 176 | + | // that is what a guard is for. | |
| 177 | + | more rest(loaded) when loaded.page over 1 or loaded.has_more; | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | /// What the reader has not been shown. | |
| 173 | 182 | /// | |
| 174 | 183 | /// Prev/next only. The template drew `Newer` and `Older` and no numbers, and | |
| 175 | 184 | /// `Rest` draws numbers only for a screen that calls `jumping`, so this is a | |
| 176 | 185 | /// parity conversion rather than a reduction. | |
| 177 | - | fn rest(loaded: &Loaded) -> Option<Rest> { | |
| 186 | + | /// | |
| 187 | + | /// Whether there is anything to show is [`listing`]'s guard rather than an | |
| 188 | + | /// `Option` here: built for a page that offers neither direction this is a | |
| 189 | + | /// `Rest` with no controls, and nothing asks for it. | |
| 190 | + | fn rest(loaded: &Loaded) -> Rest { | |
| 178 | 191 | let per = constants::GIT_REPOS_PER_PAGE; | |
| 179 | 192 | let from = (loaded.page - 1) * per; | |
| 180 | 193 | ||
| 181 | - | if loaded.page == 1 && !loaded.has_more { | |
| 182 | - | return None; | |
| 183 | - | } | |
| 184 | - | ||
| 185 | 194 | let mut rest = Rest::page(from, per); | |
| 186 | 195 | if loaded.page > 1 { | |
| 187 | 196 | rest = rest.back(Action::get(format!("{PATH}?page={}", loaded.page - 1)).navigating()); | |
| @@ -190,7 +199,7 @@ | |||
| 190 | 199 | rest = rest.forward(Action::get(format!("{PATH}?page={}", loaded.page + 1)).navigating()); | |
| 191 | 200 | } | |
| 192 | 201 | ||
| 193 | - | Some(rest) | |
| 202 | + | rest | |
| 194 | 203 | } | |
| 195 | 204 | ||
| 196 | 205 | /// The document this screen is drawn in. | |
| @@ -265,11 +274,23 @@ | |||
| 265 | 274 | assert!(!html(&loaded(1, 1, false, false)).contains("/git/my-annotations")); | |
| 266 | 275 | } | |
| 267 | 276 | ||
| 277 | + | /// Both headings, which moved from a hand-written `Table::new` list into the | |
| 278 | + | /// declaration. A column dropped on the way is silent. | |
| 279 | + | #[test] | |
| 280 | + | fn every_column_the_listing_had_is_still_named() { | |
| 281 | + | let html = html(&loaded(2, 1, false, false)); | |
| 282 | + | ||
| 283 | + | assert!(html.contains("Repository"), "{html}"); | |
| 284 | + | assert!(html.contains("Description"), "{html}"); | |
| 285 | + | } | |
| 286 | + | ||
| 268 | 287 | /// A single page of results offers no paging at all, rather than two | |
| 269 | 288 | /// disabled controls. | |
| 270 | 289 | #[test] | |
| 271 | 290 | fn one_page_of_repositories_has_no_rest() { | |
| 272 | - | assert!(rest(&loaded(3, 1, false, false)).is_none()); | |
| 291 | + | let html = html(&loaded(3, 1, false, false)); | |
| 292 | + | ||
| 293 | + | assert!(!html.contains("page="), "{html}"); | |
| 273 | 294 | } | |
| 274 | 295 | ||
| 275 | 296 | /// Older on the first page, both on a middle page, Newer on the last. |
| @@ -20,8 +20,8 @@ | |||
| 20 | 20 | //! it is missing. What is described is the document. | |
| 21 | 21 | ||
| 22 | 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}; | |
| 23 | + | use quasi_declare::declare; | |
| 24 | + | use quasi_router::{Document, Feed, FeedKind, Screen as Described}; | |
| 25 | 25 | use quasi_webview::Webview; | |
| 26 | 26 | ||
| 27 | 27 | use crate::types::BlogPostSummary; | |
| @@ -38,67 +38,54 @@ | |||
| 38 | 38 | format!("/p/{project_slug}/blog/feed.xml") | |
| 39 | 39 | } | |
| 40 | 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 { | |
| 41 | + | declare! { | |
| 42 | + | /// The whole document. | |
| 43 | + | #[must_use] | |
| 44 | + | pub shape screen( | |
| 45 | + | project_title: &str, | |
| 46 | + | project_slug: &str, | |
| 47 | + | creator_username: &str, | |
| 48 | + | posts: &[BlogPostSummary], | |
| 49 | + | ) -> Screen; | |
| 50 | + | ||
| 49 | 51 | let feed = feed_path(project_slug); | |
| 50 | 52 | ||
| 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 | - | }; | |
| 53 | + | screen single "Blog - {project_title}" { | |
| 54 | + | measured MEASURE; | |
| 55 | + | documented Document::default() | |
| 56 | + | .classed(crate::shell::body_class(MEASURE, &["project-blog-page"])); | |
| 57 | + | summarised "Posts from {project_title}, by {creator_username}."; | |
| 58 | + | about quasi_router::SocialKind::Article; | |
| 59 | + | syndicating Feed::new(FeedKind::Rss, "{project_title} - Blog RSS", feed); | |
| 60 | 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 | - | }); | |
| 61 | + | region PAGE_REGION as Pane { | |
| 62 | + | page "{project_title} Blog"; | |
| 63 | + | link creator_username to get "/u/{creator_username}" navigating; | |
| 64 | + | link "View project" to get "/p/{project_slug}" navigating; | |
| 86 | 65 | ||
| 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) | |
| 66 | + | // The visible offer, beside the autodiscovery tag rather than | |
| 67 | + | // instead of it: one is what a reader clicks and the other is what | |
| 68 | + | // a reader's app finds, and a page that has a feed owes both. | |
| 69 | + | act "RSS Feed" to get feed.clone() navigating; | |
| 70 | + | ||
| 71 | + | given posts.is_empty() { | |
| 72 | + | true -> empty "No blog posts yet."; | |
| 73 | + | otherwise -> list { | |
| 74 | + | for post in posts.iter() { | |
| 75 | + | row post.title.clone() { | |
| 76 | + | meta post.published_at.clone(); | |
| 77 | + | activate to get "/p/{project_slug}/blog/{post.slug}" navigating; | |
| 78 | + | } | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // The attribution the template's footer carried. Content rather | |
| 84 | + | // than decoration: it is where a reader on a creator's blog finds | |
| 85 | + | // out whose platform they are on. | |
| 86 | + | link "Powered by Makenot.work" to get "/" navigating; | |
| 87 | + | } | |
| 88 | + | } | |
| 102 | 89 | } | |
| 103 | 90 | ||
| 104 | 91 | /// The document this screen is drawn in. | |
| @@ -203,6 +190,28 @@ | |||
| 203 | 190 | assert!(rendered.contains("No blog posts yet."), "{rendered}"); | |
| 204 | 191 | } | |
| 205 | 192 | ||
| 193 | + | /// The two links the page carries besides the feed and the attribution. | |
| 194 | + | /// Every address the screen offers is what the conversion had to keep, and | |
| 195 | + | /// a link whose text survives with the wrong route reads as fine. | |
| 196 | + | #[test] | |
| 197 | + | fn the_creator_and_the_project_are_both_still_reachable() { | |
| 198 | + | let rendered = html(&screen("Blue Hour", "blue-hour", "maxj", &posts())); | |
| 199 | + | ||
| 200 | + | assert!(rendered.contains("href=\"/u/maxj\""), "{rendered}"); | |
| 201 | + | assert!(rendered.contains("href=\"/p/blue-hour\""), "{rendered}"); | |
| 202 | + | assert!(rendered.contains(">maxj<"), "{rendered}"); | |
| 203 | + | assert!(rendered.contains(">View project<"), "{rendered}"); | |
| 204 | + | } | |
| 205 | + | ||
| 206 | + | /// What the page is, for anything reading the social tags. Said by the | |
| 207 | + | /// screen rather than by a template's `og:type`. | |
| 208 | + | #[test] | |
| 209 | + | fn a_blog_index_says_it_is_an_article() { | |
| 210 | + | let screen = screen("Blue Hour", "blue-hour", "maxj", &posts()); | |
| 211 | + | ||
| 212 | + | assert_eq!(screen.discovery.kind, quasi_router::SocialKind::Article); | |
| 213 | + | } | |
| 214 | + | ||
| 206 | 215 | /// `2790e5c4`. The template wrote the measure and the page token on the | |
| 207 | 216 | /// body; a described document has no container, so both land there. | |
| 208 | 217 | #[test] |