max / makenotwork
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
- Claude-Session
- https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
8 files changed,
+766 insertions,
-382 deletions
| @@ -666,13 +666,37 @@ | |||
| 666 | 666 | vec![ | |
| 667 | 667 | ( | |
| 668 | 668 | team::PATH, | |
| 669 | - | public_document_mount(app, team::PATH, team::screen, team::renderer), | |
| 669 | + | served_document_mount( | |
| 670 | + | app, | |
| 671 | + | team::PATH, | |
| 672 | + | |_| team::page_screen(), | |
| 673 | + | team::renderer, | |
| 674 | + | |_| residuals::settled(&residuals::TEAM), | |
| 675 | + | ), | |
| 670 | 676 | ), | |
| 671 | 677 | ( | |
| 672 | 678 | use_cases::PATH, | |
| 673 | - | public_document_mount(app, use_cases::PATH, use_cases::screen, use_cases::renderer), | |
| 679 | + | served_document_mount( | |
| 680 | + | app, | |
| 681 | + | use_cases::PATH, | |
| 682 | + | |viewer| use_cases::page_screen(&use_cases::prices(viewer)), | |
| 683 | + | use_cases::renderer, | |
| 684 | + | |viewer| { | |
| 685 | + | use_cases::page_region_serve(&residuals::USE_CASES, &use_cases::prices(viewer)) | |
| 686 | + | .into() | |
| 687 | + | }, | |
| 688 | + | ), | |
| 689 | + | ), | |
| 690 | + | ( | |
| 691 | + | policy::PATH, | |
| 692 | + | served_document_mount( | |
| 693 | + | app, | |
| 694 | + | policy::PATH, | |
| 695 | + | |_| policy::page_screen(), | |
| 696 | + | policy::renderer, | |
| 697 | + | |_| residuals::settled(&residuals::POLICY), | |
| 698 | + | ), | |
| 674 | 699 | ), | |
| 675 | - | (policy::PATH, policy::mount(app)), | |
| 676 | 700 | ( | |
| 677 | 701 | fan_plus::PATH, | |
| 678 | 702 | public_document_mount(app, fan_plus::PATH, fan_plus::screen, fan_plus::renderer), | |
| @@ -769,6 +793,77 @@ | |||
| 769 | 793 | .into_router() | |
| 770 | 794 | } | |
| 771 | 795 | ||
| 796 | + | /// A public document whose regions are already written, mounted. | |
| 797 | + | /// | |
| 798 | + | /// The seam's mount, shared by every screen on it. It is not | |
| 799 | + | /// [`public_document_mount`] and cannot be: that one builds a | |
| 800 | + | /// `quasi_axum::Adapter`, whose whole job is to call a handler that answers a | |
| 801 | + | /// `Screen` and render it, and there is no `Screen` here to answer with. The | |
| 802 | + | /// markup was derived on a build machine, so answering is writing the document | |
| 803 | + | /// around it (quasicoherent `793d99dd`). | |
| 804 | + | /// | |
| 805 | + | /// `body` is where the two kinds of screen differ, and it is the only place | |
| 806 | + | /// they do. A screen that reads nothing has a residual of one literal, so its | |
| 807 | + | /// `body` hands back `residual.settled()` and copies nothing. A screen that | |
| 808 | + | /// reads something has holes, so its `body` calls the generated filler, which | |
| 809 | + | /// walks the residual and writes the request's values into the gaps. Neither | |
| 810 | + | /// builds a `Node`, which is the property the seam exists for; a `Cow` is what | |
| 811 | + | /// lets the first path stay a borrow while the second returns a `String`. | |
| 812 | + | /// | |
| 813 | + | /// `screen` takes the viewer because a document is not derivable: the shell | |
| 814 | + | /// carries whoever is looking, and a screen with parameters needs them to state | |
| 815 | + | /// its own title and measure. | |
| 816 | + | /// | |
| 817 | + | /// Pairing a body with the wrong screen serves one page's markup inside | |
| 818 | + | /// another's document, which nothing here can catch; what catches it is | |
| 819 | + | /// `residuals::tests`, which produces each screen's markup both ways and | |
| 820 | + | /// compares them. | |
| 821 | + | fn served_document_mount( | |
| 822 | + | app: &AppState, | |
| 823 | + | path: &'static str, | |
| 824 | + | screen: fn(&Viewer) -> quasi_router::Screen, | |
| 825 | + | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 826 | + | body: fn(&Viewer) -> std::borrow::Cow<'static, str>, | |
| 827 | + | ) -> axum::Router { | |
| 828 | + | let app = app.clone(); | |
| 829 | + | axum::Router::new().route( | |
| 830 | + | path, | |
| 831 | + | axum::routing::get(move |parts: axum::http::request::Parts| { | |
| 832 | + | let app = app.clone(); | |
| 833 | + | async move { | |
| 834 | + | use axum::response::IntoResponse as _; | |
| 835 | + | ||
| 836 | + | // `Audience::Anyone` never refuses a reader for being signed | |
| 837 | + | // out, so the error arm here is the session layer or the CSRF | |
| 838 | + | // store failing, which is this server being broken rather than | |
| 839 | + | // this page being unreachable. Same answer the adapter gives | |
| 840 | + | // every other screen for the same failure. | |
| 841 | + | let Ok(viewer) = viewer_factory(app.clone(), Audience::Anyone)(&parts).await else { | |
| 842 | + | return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); | |
| 843 | + | }; | |
| 844 | + | ||
| 845 | + | let markup = body(&viewer); | |
| 846 | + | axum::response::Html(served(&viewer, screen, renderer, &markup)).into_response() | |
| 847 | + | } | |
| 848 | + | }), | |
| 849 | + | ) | |
| 850 | + | } | |
| 851 | + | ||
| 852 | + | /// The document written around markup this renderer already produced. | |
| 853 | + | /// | |
| 854 | + | /// Split out only so [`served_document_mount`] reads as one expression, and | |
| 855 | + | /// because `Webview::served` carries the one caveat worth keeping next to a | |
| 856 | + | /// call: the markup has to have come from this renderer, which is what | |
| 857 | + | /// `residuals::tests` asserts and nothing at this level can. | |
| 858 | + | fn served( | |
| 859 | + | viewer: &Viewer, | |
| 860 | + | screen: fn(&Viewer) -> quasi_router::Screen, | |
| 861 | + | renderer: fn(&Viewer) -> quasi_webview::Webview, | |
| 862 | + | markup: &str, | |
| 863 | + | ) -> String { | |
| 864 | + | renderer(viewer).served(&screen(viewer), markup) | |
| 865 | + | } | |
| 866 | + | ||
| 772 | 867 | /// Every described screen a reader with no session can reach, mounted. | |
| 773 | 868 | /// | |
| 774 | 869 | /// Separate from [`mounts`] because of the factory, not because of the address: | |
| @@ -1044,14 +1139,14 @@ | |||
| 1044 | 1139 | // Two mount styles, and both are public documents. Most screens go | |
| 1045 | 1140 | // through the adapter, which calls a handler that answers a `Screen` | |
| 1046 | 1141 | // and renders it. A screen on the residual seam has no `Screen` to | |
| 1047 | - | // answer with, so it brings its own mount; `policy` is the first | |
| 1142 | + | // answer with, so it takes [`served_document_mount`] instead | |
| 1048 | 1143 | // (quasicoherent `793d99dd`). Counting only the first style would let a | |
| 1049 | 1144 | // screen leave `PUBLIC_DOCUMENT_PATHS` unnoticed by moving onto the | |
| 1050 | 1145 | // seam, which is the drift this test exists to catch. | |
| 1051 | 1146 | let adapted = mounted.matches("public_document_mount(").count(); | |
| 1052 | - | let own = mounted.matches("::mount(app)").count(); | |
| 1147 | + | let served = mounted.matches("served_document_mount(").count(); | |
| 1053 | 1148 | assert_eq!( | |
| 1054 | - | adapted + own, | |
| 1149 | + | adapted + served, | |
| 1055 | 1150 | PUBLIC_DOCUMENT_PATHS.len(), | |
| 1056 | 1151 | "public_document_mounts and PUBLIC_DOCUMENT_PATHS describe the same screens" | |
| 1057 | 1152 | ); |
| @@ -82,53 +82,6 @@ | |||
| 82 | 82 | Ok(page_screen().into()) | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | - | /// The address, served from the residual rather than from a tree. | |
| 86 | - | /// | |
| 87 | - | /// The first screen on the seam (quasicoherent `793d99dd`). Its regions were | |
| 88 | - | /// derived on a build machine and are a `&'static str` in this binary, so | |
| 89 | - | /// answering is writing the document around them: no `Node` is constructed and | |
| 90 | - | /// nothing is rendered. | |
| 91 | - | /// | |
| 92 | - | /// **Its own mount rather than [`super::public_document_mount`]**, because the | |
| 93 | - | /// adapter's whole job is to call a handler that answers a `Screen` and render | |
| 94 | - | /// it. There is no `Screen` here to answer with, and inventing an `Outcome` | |
| 95 | - | /// that carries markup would put an opaque string in the vocabulary for every | |
| 96 | - | /// screen to reach for. The two paths coexist by construction, which is what | |
| 97 | - | /// the transition said they would: every other public document is still mounted | |
| 98 | - | /// the old way on the line above this one. | |
| 99 | - | pub fn mount(app: &crate::AppState) -> axum::Router { | |
| 100 | - | let app = app.clone(); | |
| 101 | - | axum::Router::new().route( | |
| 102 | - | PATH, | |
| 103 | - | axum::routing::get(move |parts: axum::http::request::Parts| { | |
| 104 | - | let app = app.clone(); | |
| 105 | - | async move { document(&app, &parts).await } | |
| 106 | - | }), | |
| 107 | - | ) | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | /// The document, around markup that is already written. | |
| 111 | - | async fn document( | |
| 112 | - | app: &crate::AppState, | |
| 113 | - | parts: &axum::http::request::Parts, | |
| 114 | - | ) -> axum::response::Response { | |
| 115 | - | use axum::response::IntoResponse as _; | |
| 116 | - | ||
| 117 | - | // `Audience::Anyone` never refuses a reader for being signed out, so the | |
| 118 | - | // error arm here is the session layer or the CSRF store failing, which is | |
| 119 | - | // this server being broken rather than this page being unreachable. Same | |
| 120 | - | // answer the adapter gives every other screen for the same failure. | |
| 121 | - | let Ok(viewer) = super::viewer_factory(app.clone(), super::Audience::Anyone)(parts).await | |
| 122 | - | else { | |
| 123 | - | return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response(); | |
| 124 | - | }; | |
| 125 | - | let body = super::residuals::POLICY | |
| 126 | - | .settled() | |
| 127 | - | .expect("the policy page reads nothing, so its residual is one literal"); | |
| 128 | - | ||
| 129 | - | axum::response::Html(renderer(&viewer).served(&page_screen(), body)).into_response() | |
| 130 | - | } | |
| 131 | - | ||
| 132 | 85 | declare! { | |
| 133 | 86 | /// The whole document: the title, the measure, the body. | |
| 134 | 87 | pub(crate) shape page_screen() -> Screen; |
| @@ -59,9 +59,38 @@ | |||
| 59 | 59 | /// know what is on the seam and what is still building a tree, and the | |
| 60 | 60 | /// alternative was a macro nobody can grep. | |
| 61 | 61 | fn roster() -> Vec<Staged> { | |
| 62 | - | vec![("POLICY", |plan| { | |
| 63 | - | Node::Region(super::policy::page_region_staged(plan)) | |
| 64 | - | })] | |
| 62 | + | vec![ | |
| 63 | + | ("POLICY", |plan| { | |
| 64 | + | Node::Region(super::policy::page_region_staged(plan)) | |
| 65 | + | }), | |
| 66 | + | ("TEAM", |plan| { | |
| 67 | + | Node::Region(super::team::page_region_staged(plan)) | |
| 68 | + | }), | |
| 69 | + | ("USE_CASES", |plan| { | |
| 70 | + | Node::Region(super::use_cases::page_region_staged(plan)) | |
| 71 | + | }), | |
| 72 | + | ] | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | /// A settled screen's markup, borrowed rather than built. | |
| 76 | + | /// | |
| 77 | + | /// The `body` half of [`super::served_document_mount`] for a screen that reads | |
| 78 | + | /// nothing. A `Cow::Borrowed` of the compiled literal: no allocation, no walk, | |
| 79 | + | /// no `Node`, and the same `&'static str` for every reader on every request. | |
| 80 | + | /// | |
| 81 | + | /// # Panics | |
| 82 | + | /// | |
| 83 | + | /// When the residual has a hole, a branch or a loop in it, which means the | |
| 84 | + | /// screen started reading the request and wants the filler instead. It cannot | |
| 85 | + | /// happen behind a mount that compiles, because | |
| 86 | + | /// `tests::a_settled_screen_is_one_literal` fails first. | |
| 87 | + | #[must_use] | |
| 88 | + | pub fn settled(residual: &'static Residual) -> std::borrow::Cow<'static, str> { | |
| 89 | + | std::borrow::Cow::Borrowed( | |
| 90 | + | residual | |
| 91 | + | .settled() | |
| 92 | + | .expect("a settled screen's residual is one literal"), | |
| 93 | + | ) | |
| 65 | 94 | } | |
| 66 | 95 | ||
| 67 | 96 | /// The residual of one staged screen, derived by rendering it now. | |
| @@ -112,24 +141,72 @@ | |||
| 112 | 141 | ); | |
| 113 | 142 | } | |
| 114 | 143 | ||
| 115 | - | /// `/policy` compiles to a single `&'static str`. | |
| 144 | + | /// One screen on the seam, and the two ways to produce its markup. | |
| 145 | + | /// | |
| 146 | + | /// A table for [`roster`]'s reason: the checks below hold of every settled | |
| 147 | + | /// screen and not of `/policy` in particular, so a screen joins them by | |
| 148 | + | /// adding a row rather than by somebody copying two tests and renaming the | |
| 149 | + | /// halves they remembered to. | |
| 150 | + | struct Settled { | |
| 151 | + | /// What the address is, for a failure to name. | |
| 152 | + | path: &'static str, | |
| 153 | + | /// The compiled residual. | |
| 154 | + | residual: &'static Residual, | |
| 155 | + | /// The whole document, which is what the region has to appear inside. | |
| 156 | + | screen: fn() -> quasi_router::Screen, | |
| 157 | + | /// The region on its own, rendered the ordinary way. | |
| 158 | + | region: fn() -> Node, | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// Every screen whose residual settles to one literal. | |
| 162 | + | /// | |
| 163 | + | /// Named for what it returns rather than `settled`, which is the helper in | |
| 164 | + | /// the module above and would be shadowed by this under `use super::*`. | |
| 165 | + | /// | |
| 166 | + | /// Not every entry in [`roster`] belongs here: a screen that reads a request | |
| 167 | + | /// has holes, and `settled` answers `None` for it. Those are checked by | |
| 168 | + | /// filling instead. | |
| 169 | + | fn one_literal_screens() -> Vec<Settled> { | |
| 170 | + | vec![ | |
| 171 | + | Settled { | |
| 172 | + | path: crate::quasi::policy::PATH, | |
| 173 | + | residual: &POLICY, | |
| 174 | + | screen: crate::quasi::policy::page_screen, | |
| 175 | + | region: || Node::Region(crate::quasi::policy::page_region()), | |
| 176 | + | }, | |
| 177 | + | Settled { | |
| 178 | + | path: crate::quasi::team::PATH, | |
| 179 | + | residual: &TEAM, | |
| 180 | + | screen: crate::quasi::team::page_screen, | |
| 181 | + | region: || Node::Region(crate::quasi::team::page_region()), | |
| 182 | + | }, | |
| 183 | + | ] | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | /// A settled screen's residual is one `Op::Lit` and nothing else. | |
| 116 | 187 | /// | |
| 117 | 188 | /// Phase 2's sharp test (`60047dc0`), which moved here when it turned out | |
| 118 | - | /// to be a pipeline property rather than a vocabulary one. The screen's | |
| 119 | - | /// declaration holds no operator and now reaches no value a request brings: | |
| 120 | - | /// its copy is in `content/policy.toml` and read at macro time, and its six | |
| 121 | - | /// prose includes are `#[constant]`, so each is evaluated once while the | |
| 122 | - | /// residual is derived rather than once per request. | |
| 189 | + | /// to be a pipeline property rather than a vocabulary one. These screens | |
| 190 | + | /// hold no operator and reach no value a request brings: their copy is in | |
| 191 | + | /// `content/` and read at macro time, and every shape they include is | |
| 192 | + | /// `#[constant]`, so each is evaluated once while the residual is derived | |
| 193 | + | /// rather than once per request. | |
| 123 | 194 | /// | |
| 124 | - | /// One `Op::Lit` and nothing else is what that adds up to. A hole, a branch | |
| 125 | - | /// or a loop appearing here means something about the page started | |
| 126 | - | /// depending on the request, which is a real change and worth failing on. | |
| 195 | + | /// A hole, a branch or a loop appearing here means something about a page | |
| 196 | + | /// started depending on the request, which is a real change and worth | |
| 197 | + | /// failing on. | |
| 127 | 198 | #[test] | |
| 128 | - | fn the_policy_page_is_one_literal() { | |
| 129 | - | let ops = POLICY.ops(); | |
| 199 | + | fn a_settled_screen_is_one_literal() { | |
| 200 | + | for screen in one_literal_screens() { | |
| 201 | + | let ops = screen.residual.ops(); | |
| 130 | 202 | ||
| 131 | - | assert_eq!(ops.len(), 1, "{ops:?}"); | |
| 132 | - | assert!(matches!(ops[0], quasi_router::stage::Op::Lit(_)), "{ops:?}"); | |
| 203 | + | assert_eq!(ops.len(), 1, "{}: {ops:?}", screen.path); | |
| 204 | + | assert!( | |
| 205 | + | matches!(ops[0], quasi_router::stage::Op::Lit(_)), | |
| 206 | + | "{}: {ops:?}", | |
| 207 | + | screen.path, | |
| 208 | + | ); | |
| 209 | + | } | |
| 133 | 210 | } | |
| 134 | 211 | ||
| 135 | 212 | /// The residual is the document's own bytes, not a lookalike. | |
| @@ -141,13 +218,18 @@ | |||
| 141 | 218 | /// than the page has always had. So the fragment has to appear in the | |
| 142 | 219 | /// document verbatim, and this is what says it does. | |
| 143 | 220 | #[test] | |
| 144 | - | fn what_the_residual_holds_appears_in_the_document_verbatim() { | |
| 221 | + | fn what_a_residual_holds_appears_in_its_document_verbatim() { | |
| 145 | 222 | use quasi_axum::Serves as _; | |
| 146 | 223 | ||
| 147 | - | let document = Webview::new().screen(&crate::quasi::policy::page_screen()); | |
| 148 | - | let body = POLICY.settled().expect("nothing in the page varies"); | |
| 224 | + | for screen in one_literal_screens() { | |
| 225 | + | let document = Webview::new().screen(&(screen.screen)()); | |
| 226 | + | let body = screen | |
| 227 | + | .residual | |
| 228 | + | .settled() | |
| 229 | + | .expect("a settled screen's residual is one literal"); | |
| 149 | 230 | ||
| 150 | - | assert!(document.contains(body), "{document}"); | |
| 231 | + | assert!(document.contains(body), "{}: {document}", screen.path); | |
| 232 | + | } | |
| 151 | 233 | } | |
| 152 | 234 | ||
| 153 | 235 | /// Filling the residual gives back what the renderer gives. | |
| @@ -157,14 +239,105 @@ | |||
| 157 | 239 | /// check is that the two paths agree byte for byte on a screen that has | |
| 158 | 240 | /// nothing varying in it. | |
| 159 | 241 | #[test] | |
| 160 | - | fn the_residual_serves_what_the_renderer_serves() { | |
| 242 | + | fn a_residual_serves_what_the_renderer_serves() { | |
| 161 | 243 | use quasi_axum::Serves as _; | |
| 162 | 244 | ||
| 163 | - | let rendered = Webview::new().fragment(&Node::Region(crate::quasi::policy::page_region())); | |
| 245 | + | for screen in one_literal_screens() { | |
| 246 | + | let rendered = Webview::new().fragment(&(screen.region)()); | |
| 247 | + | ||
| 248 | + | assert_eq!( | |
| 249 | + | screen | |
| 250 | + | .residual | |
| 251 | + | .settled() | |
| 252 | + | .expect("a settled screen's residual is one literal"), | |
| 253 | + | rendered, | |
| 254 | + | "{}", | |
| 255 | + | screen.path, | |
| 256 | + | ); | |
| 257 | + | } | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | /// A holed screen's residual fills to what the renderer builds. | |
| 261 | + | /// | |
| 262 | + | /// `/use-cases` is the first screen on the seam that does not fold to a | |
| 263 | + | /// literal, and this is the check the settled ones cannot have: it is the | |
| 264 | + | /// filler that is being tested, not a borrow. Nine holes, one per card's | |
| 265 | + | /// tier line, and the equality is byte for byte against the tree the screen | |
| 266 | + | /// has always built. | |
| 267 | + | /// | |
| 268 | + | /// Two price sets rather than one, and that is the point. Filling with the | |
| 269 | + | /// defaults would pass against a residual that had baked one request's | |
| 270 | + | /// prices into its literals, which is the exact failure the seam could | |
| 271 | + | /// have. Prices nothing else in the tree uses cannot be baked. | |
| 272 | + | #[test] | |
| 273 | + | fn the_use_cases_residual_fills_to_what_the_renderer_builds() { | |
| 274 | + | use quasi_axum::Serves as _; | |
| 275 | + | ||
| 276 | + | for prices in [crate::tier_prices::TierPrices::default(), odd_prices()] { | |
| 277 | + | let filled = crate::quasi::use_cases::page_region_serve(&USE_CASES, &prices); | |
| 278 | + | let rendered = Webview::new() | |
| 279 | + | .fragment(&Node::Region(crate::quasi::use_cases::page_region(&prices))); | |
| 280 | + | ||
| 281 | + | assert_eq!(filled, rendered); | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | /// The nine holes are holes, and the rest of the page is not. | |
| 286 | + | /// | |
| 287 | + | /// What says the split landed where the module header claims. A tenth hole | |
| 288 | + | /// means something else on the page started reading the request; a loop or | |
| 289 | + | /// a branch means the copy stopped being read at macro time, which is the | |
| 290 | + | /// regression `98fbee62` exists to prevent and which no rendering test | |
| 291 | + | /// would notice. | |
| 292 | + | #[test] | |
| 293 | + | fn the_use_cases_residual_is_nine_holes_and_literals() { | |
| 294 | + | use quasi_router::stage::Op; | |
| 295 | + | ||
| 296 | + | let ops = USE_CASES.ops(); | |
| 297 | + | let holes = ops | |
| 298 | + | .iter() | |
| 299 | + | .filter(|op| matches!(op, Op::Hole { .. })) | |
| 300 | + | .count(); | |
| 301 | + | ||
| 302 | + | assert_eq!(holes, 9, "one per card's tier line: {ops:?}"); | |
| 303 | + | assert!( | |
| 304 | + | ops.iter() | |
| 305 | + | .all(|op| matches!(op, Op::Lit(_) | Op::Hole { .. })), | |
| 306 | + | "a branch or a loop reached the residual: {ops:?}", | |
| 307 | + | ); | |
| 308 | + | } | |
| 309 | + | ||
| 310 | + | /// Prices no default and no assumptions file would produce. | |
| 311 | + | fn odd_prices() -> crate::tier_prices::TierPrices { | |
| 312 | + | crate::tier_prices::TierPrices { | |
| 313 | + | basic_std: 4321, | |
| 314 | + | small_files_std: 5678, | |
| 315 | + | big_files_std: 8765, | |
| 316 | + | ..Default::default() | |
| 317 | + | } | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | /// Every screen the roster names is checked above. | |
| 321 | + | /// | |
| 322 | + | /// The gap this closes is the one a table opens: a screen added to | |
| 323 | + | /// [`roster`] and not checked anywhere is generated, compiled, served and | |
| 324 | + | /// never compared against the renderer, and nothing else here would say so. | |
| 325 | + | /// | |
| 326 | + | /// `HOLED` is the count of screens checked by a filler of their own rather | |
| 327 | + | /// than by [`one_literal_screens`], and it is written out rather than | |
| 328 | + | /// derived so that adding a screen to the roster and forgetting its check | |
| 329 | + | /// fails here. A screen that has holes cannot join the settled table, so | |
| 330 | + | /// the two counts have to be kept by hand or not at all. | |
| 331 | + | #[test] | |
| 332 | + | fn every_screen_on_the_seam_is_checked() { | |
| 333 | + | /// Screens with their own filling test: `/use-cases`. | |
| 334 | + | const HOLED: usize = 1; | |
| 164 | 335 | ||
| 165 | 336 | assert_eq!( | |
| 166 | - | POLICY.settled().expect("nothing in the page varies"), | |
| 167 | - | rendered, | |
| 337 | + | roster().len(), | |
| 338 | + | one_literal_screens().len() + HOLED, | |
| 339 | + | "a screen on the seam is not checked: give it a row in \ | |
| 340 | + | `one_literal_screens`, or a filling test and a bump to HOLED", | |
| 168 | 341 | ); | |
| 169 | 342 | } | |
| 170 | 343 | } |
| @@ -67,38 +67,54 @@ | |||
| 67 | 67 | ||
| 68 | 68 | declare! { | |
| 69 | 69 | /// The whole document: the title, the measure, the body. | |
| 70 | - | shape page_screen() -> Screen; | |
| 70 | + | pub(crate) shape page_screen() -> Screen; | |
| 71 | 71 | ||
| 72 | 72 | screen single "Team - Makenotwork" { | |
| 73 | 73 | measured MEASURE; | |
| 74 | 74 | documented Document::default().classed(crate::shell::body_class(MEASURE, &["team-page"])); | |
| 75 | 75 | summarised "Small by design: who builds Makenotwork, and what they did before."; | |
| 76 | 76 | ||
| 77 | - | region PAGE_REGION as Pane { | |
| 78 | - | page "Team"; | |
| 79 | - | text "Small by design. The base platform was designed by the founder, who intends \ | |
| 80 | - | to stay as the technical lead through the project's growth."; | |
| 81 | - | section "Founder"; | |
| 82 | - | list [founder()]; | |
| 83 | - | include bio(); | |
| 84 | - | } | |
| 77 | + | include page_region(); | |
| 85 | 78 | } | |
| 86 | 79 | } | |
| 87 | 80 | ||
| 88 | 81 | declare! { | |
| 89 | - | /// The one card on the page. | |
| 82 | + | /// The page's one region, split out so it can be staged. | |
| 90 | 83 | /// | |
| 91 | - | /// A row rather than a grid of one: `use-case-grid-2col` held a single article, | |
| 92 | - | /// so the grid was describing an intention rather than a layout. When there is a | |
| 93 | - | /// second person the list grows a second row and the design system decides how | |
| 94 | - | /// two rows sit, which is the arrangement question answered once for every | |
| 95 | - | /// screen instead of in this page's own CSS. | |
| 96 | - | shape founder() -> Row; | |
| 84 | + | /// The split is where the request stops mattering, which on this page is | |
| 85 | + | /// immediately: nothing below this line reads anything. See | |
| 86 | + | /// `policy::page_region` for why the document cannot come with it. | |
| 87 | + | #[staged] | |
| 88 | + | pub(crate) shape page_region() -> Slot; | |
| 97 | 89 | ||
| 98 | - | row "Max" { | |
| 99 | - | secondary "CEO & Tech Lead"; | |
| 100 | - | meta "Designed and built the platform."; | |
| 101 | - | act "@max" to get "/u/max" navigating; | |
| 90 | + | region PAGE_REGION as Pane { | |
| 91 | + | page "Team"; | |
| 92 | + | text "Small by design. The base platform was designed by the founder, who intends \ | |
| 93 | + | to stay as the technical lead through the project's growth."; | |
| 94 | + | section "Founder"; | |
| 95 | + | // A row rather than a grid of one: `use-case-grid-2col` held a single | |
| 96 | + | // article, so the grid was describing an intention rather than a | |
| 97 | + | // layout. When there is a second person the list grows a second row and | |
| 98 | + | // the design system decides how two rows sit, which is the arrangement | |
| 99 | + | // question answered once for every screen instead of in this page's own | |
| 100 | + | // CSS. | |
| 101 | + | // | |
| 102 | + | // Written here rather than in a `founder()` shape of its own, and that | |
| 103 | + | // is a staging constraint rather than a preference. `list [founder()]` | |
| 104 | + | // is a bracket list, which is the one place the grammar calls a shape | |
| 105 | + | // without an `include`, and the `#[constant]` fold does not reach it: a | |
| 106 | + | // bracket also holds plain constructors (`stats [Figure::new(..)]`), so | |
| 107 | + | // folding every all-literal call in one would demand a | |
| 108 | + | // `Figure::new_constant` that cannot exist. One row is not worth a | |
| 109 | + | // production, so it moved. | |
| 110 | + | list { | |
| 111 | + | row "Max" { | |
| 112 | + | secondary "CEO & Tech Lead"; | |
| 113 | + | meta "Designed and built the platform."; | |
| 114 | + | act "@max" to get "/u/max" navigating; | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | include bio(); | |
| 102 | 118 | } | |
| 103 | 119 | } | |
| 104 | 120 | ||
| @@ -106,25 +122,25 @@ | |||
| 106 | 122 | /// The bio, closed until asked for. | |
| 107 | 123 | /// | |
| 108 | 124 | /// `showing_at_most_one None` is closed; `Some(0)` would be `<details open>`. | |
| 125 | + | /// | |
| 126 | + | /// `#[constant]`: it reads nothing, so a staged `include` of it folds to | |
| 127 | + | /// the disclosure's markup instead of opening a scope per request. The | |
| 128 | + | /// words are in `content/team.toml` and read at macro time, so the loop is | |
| 129 | + | /// unrolled before any AST exists and nothing here survives to a request. | |
| 130 | + | #[constant] | |
| 109 | 131 | shape bio() -> Node; | |
| 110 | 132 | ||
| 111 | 133 | region BIO as Group { | |
| 112 | 134 | showing_at_most_one None; | |
| 113 | 135 | region BIO_BODY as Pane { | |
| 114 | 136 | label "Bio"; | |
| 115 | - | text BIO_TEXT; | |
| 137 | + | for part in copy "content/team.toml" as bio { | |
| 138 | + | text part.body; | |
| 139 | + | } | |
| 116 | 140 | } | |
| 117 | 141 | } | |
| 118 | 142 | } | |
| 119 | 143 | ||
| 120 | - | /// Carried over verbatim from the template. Prose the founder wrote, so it is | |
| 121 | - | /// content rather than copy and nothing here rewrites it. | |
| 122 | - | const BIO_TEXT: &str = "I started in the creator space as a young kid making backgrounds and \ | |
| 123 | - | montages for CoD MW2 players. I picked up programming around the same time, helping Minecraft \ | |
| 124 | - | server owners with whatever they needed. I spent the years after that focused on my \ | |
| 125 | - | education: a BA in mathematics at the University of Chicago, then a PhD in mathematics at UC \ | |
| 126 | - | San Diego. Afterward I decided to start my own business, and here we are."; | |
| 127 | - | ||
| 128 | 144 | /// The document this screen is drawn in. | |
| 129 | 145 | /// | |
| 130 | 146 | /// The head, the tail and the token meta come off | |
| @@ -186,6 +202,27 @@ | |||
| 186 | 202 | assert!(html.contains(r#"href="/u/max""#), "{html}"); | |
| 187 | 203 | } | |
| 188 | 204 | ||
| 205 | + | /// The bio the content file holds is the bio on the page. | |
| 206 | + | /// | |
| 207 | + | /// Reads `content/team.toml` rather than holding a second copy of the | |
| 208 | + | /// prose, for `policy`'s reason: the test asks the content file what the | |
| 209 | + | /// page should say, so an edit to the words is an edit and not a failure, | |
| 210 | + | /// and words that stop being rendered are. | |
| 211 | + | #[test] | |
| 212 | + | fn the_bio_is_the_prose_the_content_file_holds() { | |
| 213 | + | let copy: toml::Table = include_str!("../../content/team.toml") | |
| 214 | + | .parse() | |
| 215 | + | .expect("the team copy is TOML"); | |
| 216 | + | let bio = copy["bio"].as_array().expect("a list of one bio"); | |
| 217 | + | assert_eq!(bio.len(), 1, "the page shows one founder"); | |
| 218 | + | ||
| 219 | + | let body = bio[0]["body"].as_str().expect("a body"); | |
| 220 | + | assert!( | |
| 221 | + | html().contains(&crate::helpers::escape_html(body)), | |
| 222 | + | "{body}" | |
| 223 | + | ); | |
| 224 | + | } | |
| 225 | + | ||
| 189 | 226 | /// `736f45a5`: this screen's markup carries none of the four spellings. | |
| 190 | 227 | #[test] | |
| 191 | 228 | fn the_page_spells_no_spinner() { |
| @@ -39,6 +39,25 @@ | |||
| 39 | 39 | //! reads it off `viewer.app` rather than taking a state of its own: a public | |
| 40 | 40 | //! document already has a per-request viewer, and a second copy of the prices | |
| 41 | 41 | //! is a second thing to keep in step with the calculator. | |
| 42 | + | //! | |
| 43 | + | //! # The words are in `content/use-cases.toml`, and the prices are the holes | |
| 44 | + | //! | |
| 45 | + | //! The first screen on the build-time seam that does not fold to a literal, and | |
| 46 | + | //! it is the one that shows what the seam is for. Nine profiles, four promises | |
| 47 | + | //! and three onward links were `const` arrays here; they are copy now, read at | |
| 48 | + | //! macro time, so all three loops are unrolled before an AST exists. What is | |
| 49 | + | //! left varying is nine tier lines, each built from the live `TierPrices`. | |
| 50 | + | //! | |
| 51 | + | //! So the residual is the page's markup with nine holes in it. Serving it | |
| 52 | + | //! writes nine strings into gaps. The comparison is against what this page used | |
| 53 | + | //! to do, which was build some two hundred nodes and render them, on every | |
| 54 | + | //! request, to produce markup that differed in nine short strings. | |
| 55 | + | //! | |
| 56 | + | //! A profile's `features` is a list field, which is what the copy production | |
| 57 | + | //! grew to admit this page (`for feature in profile.features`). A list is still | |
| 58 | + | //! words; the seal that file's header describes is against types, and the | |
| 59 | + | //! alternative here was nine numbered fields with the count written into the | |
| 60 | + | //! declaration. | |
| 42 | 61 | ||
| 43 | 62 | use makeover_layout as layout; | |
| 44 | 63 | use quasi_declare::declare; | |
| @@ -61,245 +80,84 @@ | |||
| 61 | 80 | ||
| 62 | 81 | const MEASURE: layout::Measure = layout::Measure::Wide; | |
| 63 | 82 | ||
| 64 | - | /// One creator profile. | |
| 83 | + | /// The four tier lines a card can end with, by the name the copy gives. | |
| 65 | 84 | /// | |
| 66 | - | /// `anchor` is the template's `id=`, kept because the marketing site links to | |
| 67 | - | /// these cards directly (`/use-cases#podcasters`) and a conversion that drops | |
| 68 | - | /// the id breaks somebody else's link rather than this page. | |
| 69 | - | struct Profile { | |
| 70 | - | anchor: &'static str, | |
| 71 | - | title: &'static str, | |
| 72 | - | who: &'static str, | |
| 73 | - | description: &'static str, | |
| 74 | - | features: &'static [&'static str], | |
| 75 | - | /// Which tier line the card ends with. See [`Priced`]. | |
| 76 | - | priced: Priced, | |
| 77 | - | } | |
| 78 | - | ||
| 79 | - | /// How a card states its price. | |
| 85 | + | /// The one thing on this page a request decides. Everything else a card says is | |
| 86 | + | /// words and lives in `content/use-cases.toml`; the price does not, because a | |
| 87 | + | /// formatted price in a content file is a fourth place a price can go stale. | |
| 88 | + | /// So the file names the *shape* of the line and this builds it from the live | |
| 89 | + | /// `TierPrices`. | |
| 80 | 90 | /// | |
| 81 | - | /// The template wrote three shapes of tier line by hand, and one of them | |
| 82 | - | /// (Educators) names two tiers. Naming the shape rather than the string is what | |
| 83 | - | /// keeps every price on this page coming from `TierPrices` -- a formatted string | |
| 84 | - | /// in the table here would be a fourth place a price can go stale. | |
| 85 | - | #[derive(Clone, Copy)] | |
| 86 | - | enum Priced { | |
| 87 | - | Basic, | |
| 88 | - | SmallFiles, | |
| 89 | - | BigFiles, | |
| 90 | - | /// "Basic $X/mo or Small Files $Y/mo", which is Educators and only | |
| 91 | - | /// Educators: the work fits in either tier depending on what is uploaded. | |
| 92 | - | BasicOrSmallFiles, | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | impl Priced { | |
| 96 | - | /// The line the card ends with, built from the live prices. | |
| 97 | - | fn line(self, prices: &TierPrices) -> String { | |
| 98 | - | match self { | |
| 99 | - | Self::Basic => format!( | |
| 100 | - | "Basic ${}/mo · {}, {}/file", | |
| 101 | - | prices.basic_std, prices.basic_total, prices.basic_per_file | |
| 102 | - | ), | |
| 103 | - | Self::SmallFiles => format!( | |
| 104 | - | "Small Files ${}/mo · {}, {}/file", | |
| 105 | - | prices.small_files_std, prices.small_files_total, prices.small_files_per_file | |
| 106 | - | ), | |
| 107 | - | Self::BigFiles => format!( | |
| 108 | - | "Big Files ${}/mo · {}, {}/file", | |
| 109 | - | prices.big_files_std, prices.big_files_total, prices.big_files_per_file | |
| 110 | - | ), | |
| 111 | - | Self::BasicOrSmallFiles => format!( | |
| 112 | - | "Basic ${}/mo or Small Files ${}/mo", | |
| 113 | - | prices.basic_std, prices.small_files_std | |
| 114 | - | ), | |
| 115 | - | } | |
| 91 | + | /// A `&str` rather than the enum this replaced, and that is the cost of the | |
| 92 | + | /// copy moving out of Rust: a proc macro cannot evaluate a path, so a variant | |
| 93 | + | /// written in the file would survive into the AST as one. What buys the | |
| 94 | + | /// exhaustiveness back is [`tests::every_priced_name_in_the_copy_is_one_of_the_four`], | |
| 95 | + | /// which holds the file to the four names this answers. | |
| 96 | + | /// | |
| 97 | + | /// # Panics | |
| 98 | + | /// | |
| 99 | + | /// On a name this does not know, which is a content file naming a line that | |
| 100 | + | /// does not exist. The test above is what makes that a test failure rather | |
| 101 | + | /// than a page that renders empty. | |
| 102 | + | fn tier_line(priced: &str, prices: &TierPrices) -> String { | |
| 103 | + | match priced { | |
| 104 | + | "basic" => format!( | |
| 105 | + | "Basic ${}/mo \u{b7} {}, {}/file", | |
| 106 | + | prices.basic_std, prices.basic_total, prices.basic_per_file | |
| 107 | + | ), | |
| 108 | + | "small-files" => format!( | |
| 109 | + | "Small Files ${}/mo \u{b7} {}, {}/file", | |
| 110 | + | prices.small_files_std, prices.small_files_total, prices.small_files_per_file | |
| 111 | + | ), | |
| 112 | + | "big-files" => format!( | |
| 113 | + | "Big Files ${}/mo \u{b7} {}, {}/file", | |
| 114 | + | prices.big_files_std, prices.big_files_total, prices.big_files_per_file | |
| 115 | + | ), | |
| 116 | + | // "Basic $X/mo or Small Files $Y/mo", which is Educators and only | |
| 117 | + | // Educators: the work fits in either tier depending on what is | |
| 118 | + | // uploaded. | |
| 119 | + | "basic-or-small-files" => format!( | |
| 120 | + | "Basic ${}/mo or Small Files ${}/mo", | |
| 121 | + | prices.basic_std, prices.small_files_std | |
| 122 | + | ), | |
| 123 | + | other => panic!("content/use-cases.toml names a tier line that does not exist: {other}"), | |
| 116 | 124 | } | |
| 117 | 125 | } | |
| 118 | 126 | ||
| 119 | - | /// The nine, in the order the template listed them. | |
| 120 | - | const PROFILE_LIST: &[Profile] = &[ | |
| 121 | - | Profile { | |
| 122 | - | anchor: "musicians", | |
| 123 | - | title: "Musicians", | |
| 124 | - | who: "Bands, solo artists, producers", | |
| 125 | - | description: "Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art.", | |
| 126 | - | features: &[ | |
| 127 | - | "MP3, FLAC, WAV, OGG, AAC, AIFF upload", | |
| 128 | - | "In-browser audio player with chapters", | |
| 129 | - | "Cover art and rich metadata", | |
| 130 | - | "RSS feed per project", | |
| 131 | - | "Pay-what-you-want pricing", | |
| 132 | - | ], | |
| 133 | - | priced: Priced::SmallFiles, | |
| 134 | - | }, | |
| 135 | - | Profile { | |
| 136 | - | anchor: "podcasters", | |
| 137 | - | title: "Podcasters", | |
| 138 | - | who: "Independent shows, networks", | |
| 139 | - | description: "Host and distribute your podcast with full RSS support for every major directory.", | |
| 140 | - | features: &[ | |
| 141 | - | "RSS for Apple, Spotify, Pocket Casts", | |
| 142 | - | "Chapter markers", | |
| 143 | - | "Subscriber-only feeds", | |
| 144 | - | "Broadcast emails to followers", | |
| 145 | - | ], | |
| 146 | - | priced: Priced::SmallFiles, | |
| 147 | - | }, | |
| 148 | - | Profile { | |
| 149 | - | anchor: "writers", | |
| 150 | - | title: "Writers", | |
| 151 | - | who: "Bloggers, newsletter writers, authors", | |
| 152 | - | description: "Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts.", | |
| 153 | - | features: &[ | |
| 154 | - | "Markdown editor with formatting", | |
| 155 | - | "Per-project blog", | |
| 156 | - | "RSS feed", | |
| 157 | - | "Scheduled publishing", | |
| 158 | - | "Broadcast emails", | |
| 159 | - | ], | |
| 160 | - | priced: Priced::Basic, | |
| 161 | - | }, | |
| 162 | - | Profile { | |
| 163 | - | anchor: "developers", | |
| 164 | - | title: "Software Developers", | |
| 165 | - | who: "Indie devs, tool makers, plugin authors", | |
| 166 | - | description: "Distribute software with versioned releases, license keys, and a built-in git browser.", | |
| 167 | - | features: &[ | |
| 168 | - | "Versioned releases with changelogs", | |
| 169 | - | "License keys with activation tracking", | |
| 170 | - | "Git source browser", | |
| 171 | - | "Promo codes and discounts", | |
| 172 | - | ], | |
| 173 | - | priced: Priced::SmallFiles, | |
| 174 | - | }, | |
| 175 | - | Profile { | |
| 176 | - | anchor: "sample-packs", | |
| 177 | - | title: "Sample Packs", | |
| 178 | - | who: "Sound designers, beat makers, foley artists", | |
| 179 | - | description: "Sell sample packs and sound effects with audio previews and versioned downloads.", | |
| 180 | - | features: &[ | |
| 181 | - | "Audio previews in-browser", | |
| 182 | - | "Versioned downloads", | |
| 183 | - | "Hierarchical tags for organization", | |
| 184 | - | "Pay-what-you-want pricing", | |
| 185 | - | "Cover art", | |
| 186 | - | ], | |
| 187 | - | priced: Priced::SmallFiles, | |
| 188 | - | }, | |
| 189 | - | Profile { | |
| 190 | - | anchor: "digital-art", | |
| 191 | - | title: "Digital Art & Photography", | |
| 192 | - | who: "Illustrators, photographers, font designers", | |
| 193 | - | description: "Sell digital art, photography, and fonts with versioned releases and subscription tiers.", | |
| 194 | - | features: &[ | |
| 195 | - | "Any file type supported", | |
| 196 | - | "Versioned releases", | |
| 197 | - | "Subscription tiers", | |
| 198 | - | "Cover images", | |
| 199 | - | ], | |
| 200 | - | priced: Priced::SmallFiles, | |
| 201 | - | }, | |
| 202 | - | Profile { | |
| 203 | - | anchor: "educators", | |
| 204 | - | title: "Educators", | |
| 205 | - | who: "Course creators, tutors, workshop leaders", | |
| 206 | - | description: "Publish courses and teaching materials with text content, downloads, and subscriptions.", | |
| 207 | - | features: &[ | |
| 208 | - | "Text content with Markdown", | |
| 209 | - | "Downloadable materials", | |
| 210 | - | "Subscription tiers", | |
| 211 | - | "Scheduled publishing", | |
| 212 | - | ], | |
| 213 | - | priced: Priced::BasicOrSmallFiles, | |
| 214 | - | }, | |
| 215 | - | Profile { | |
| 216 | - | anchor: "game-devs", | |
| 217 | - | title: "Game Developers", | |
| 218 | - | who: "Indie games, mods, TTRPG content", | |
| 219 | - | description: "Distribute games up to 20GB per file with versioned downloads, license keys, and promo codes. Need more than 20GB? Request a size increase from your dashboard.", | |
| 220 | - | features: &[ | |
| 221 | - | "Up to 20GB per file (increase on request)", | |
| 222 | - | "Versioned downloads with changelogs", | |
| 223 | - | "License keys with activation limits", | |
| 224 | - | "Promo codes", | |
| 225 | - | ], | |
| 226 | - | priced: Priced::BigFiles, | |
| 227 | - | }, | |
| 228 | - | Profile { | |
| 229 | - | anchor: "comic-creators", | |
| 230 | - | title: "Comic Creators", | |
| 231 | - | who: "Webcomic artists, graphic novelists", | |
| 232 | - | description: "Sell digital comics and graphic novels with project organization and scheduled publishing.", | |
| 233 | - | features: &[ | |
| 234 | - | "PDF, CBZ, and any file type", | |
| 235 | - | "Project organization", | |
| 236 | - | "Subscriptions", | |
| 237 | - | "Scheduled publishing", | |
| 238 | - | ], | |
| 239 | - | priced: Priced::SmallFiles, | |
| 240 | - | }, | |
| 241 | - | ]; | |
| 127 | + | /// Every name [`tier_line`] answers to, which is what the copy is held to. | |
| 128 | + | #[cfg(test)] | |
| 129 | + | const TIER_LINES: &[&str] = &["basic", "small-files", "big-files", "basic-or-small-files"]; | |
| 242 | 130 | ||
| 243 | - | /// One thing every tier includes. | |
| 244 | - | struct Included { | |
| 245 | - | name: &'static str, | |
| 246 | - | sentence: &'static str, | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | /// What every tier includes, name and sentence, in the template's order. | |
| 250 | - | const UNIVERSAL_LIST: &[Included] = &[ | |
| 251 | - | Included { | |
| 252 | - | name: "0% platform fee", | |
| 253 | - | sentence: "Only ~3% payment processing. No percentage cuts, no revenue sharing.", | |
| 254 | - | }, | |
| 255 | - | Included { | |
| 256 | - | name: "Full data export", | |
| 257 | - | sentence: "Download everything anytime. Projects, items, sales, contacts: all yours.", | |
| 258 | - | }, | |
| 259 | - | Included { | |
| 260 | - | name: "No lock-in", | |
| 261 | - | sentence: "Month-to-month. Cancel anytime. Take your audience with you.", | |
| 262 | - | }, | |
| 263 | - | Included { | |
| 264 | - | name: "Source available", | |
| 265 | - | sentence: "Read the codebase. Verify every claim about privacy and data handling.", | |
| 266 | - | }, | |
| 267 | - | ]; | |
| 268 | - | ||
| 269 | - | /// One way onward off this page. | |
| 270 | - | struct Onward { | |
| 271 | - | label: &'static str, | |
| 272 | - | route: &'static str, | |
| 273 | - | } | |
| 274 | - | ||
| 275 | - | /// Where the page sends a reader who is convinced, and where it sends one who | |
| 276 | - | /// is not yet. | |
| 277 | - | const ONWARD: &[Onward] = &[ | |
| 278 | - | Onward { | |
| 279 | - | label: "Pricing Calculator", | |
| 280 | - | route: "/pricing", | |
| 281 | - | }, | |
| 282 | - | Onward { | |
| 283 | - | label: "Browse as guest", | |
| 284 | - | route: "/discover", | |
| 285 | - | }, | |
| 286 | - | Onward { | |
| 287 | - | label: "Creator Guide", | |
| 288 | - | route: "/docs/getting-started", | |
| 289 | - | }, | |
| 290 | - | ]; | |
| 291 | - | ||
| 292 | - | /// The page. Reads no database, only the prices already in memory. | |
| 293 | - | pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 131 | + | /// The prices this page ends every card with. | |
| 132 | + | /// | |
| 133 | + | /// Read off the viewer rather than from a state of this screen's own, which is | |
| 134 | + | /// the module header's point: a public document already has a per-request | |
| 135 | + | /// viewer, and a second copy of the prices is a second thing to keep in step | |
| 136 | + | /// with the calculator. | |
| 137 | + | /// | |
| 138 | + | /// Its own function because both halves of the mount want it: the document, to | |
| 139 | + | /// state the screen, and the body, to fill the nine holes. | |
| 140 | + | #[must_use] | |
| 141 | + | pub fn prices(viewer: &super::Viewer) -> TierPrices { | |
| 294 | 142 | use axum::extract::FromRef as _; | |
| 295 | 143 | ||
| 296 | - | let billing = crate::Billing::from_ref(&viewer.app); | |
| 297 | - | Ok(page_screen(&billing.tier_prices).into()) | |
| 144 | + | crate::Billing::from_ref(&viewer.app).tier_prices | |
| 145 | + | } | |
| 146 | + | ||
| 147 | + | /// The page. Reads no database, only the prices already in memory. | |
| 148 | + | /// | |
| 149 | + | /// Kept beside the residual mount rather than replaced by it, and it is what | |
| 150 | + | /// the residual is checked against: `quasi::residuals` asserts that filling the | |
| 151 | + | /// compiled one gives back exactly what building and rendering this gives. A | |
| 152 | + | /// screen with no second way to produce its markup has nothing to check the | |
| 153 | + | /// first one with. | |
| 154 | + | pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> { | |
| 155 | + | Ok(page_screen(&prices(viewer)).into()) | |
| 298 | 156 | } | |
| 299 | 157 | ||
| 300 | 158 | declare! { | |
| 301 | 159 | /// The whole document: the title, the measure, the body. | |
| 302 | - | shape page_screen(prices: &TierPrices) -> Screen; | |
| 160 | + | pub(crate) shape page_screen(prices: &TierPrices) -> Screen; | |
| 303 | 161 | ||
| 304 | 162 | screen single "Use Cases - Makenotwork" { | |
| 305 | 163 | measured MEASURE; | |
| @@ -307,53 +165,77 @@ | |||
| 307 | 165 | summarised "A flat monthly fee and no platform cut, for musicians, podcasters, writers, \ | |
| 308 | 166 | developers and six more kinds of creator."; | |
| 309 | 167 | ||
| 310 | - | region PAGE_REGION as Pane { | |
| 311 | - | page "Use Cases"; | |
| 312 | - | text "A flat monthly fee. 0% platform cut. Who it's built for:"; | |
| 313 | - | ||
| 314 | - | section "Available now"; | |
| 315 | - | region PROFILES as Group { | |
| 316 | - | for profile in PROFILE_LIST { | |
| 317 | - | include card(profile, prices); | |
| 318 | - | } | |
| 319 | - | } | |
| 320 | - | ||
| 321 | - | section "Everyone gets"; | |
| 322 | - | region UNIVERSAL as Group { | |
| 323 | - | list { | |
| 324 | - | for gets in UNIVERSAL_LIST { | |
| 325 | - | row gets.name { | |
| 326 | - | secondary gets.sentence; | |
| 327 | - | } | |
| 328 | - | } | |
| 329 | - | } | |
| 330 | - | } | |
| 331 | - | ||
| 332 | - | act "Join the Alpha" to get "/join" navigating; | |
| 333 | - | for onward in ONWARD { | |
| 334 | - | act onward.label to get onward.route navigating; | |
| 335 | - | } | |
| 336 | - | } | |
| 168 | + | include page_region(prices); | |
| 337 | 169 | } | |
| 338 | 170 | } | |
| 339 | 171 | ||
| 340 | 172 | declare! { | |
| 341 | - | /// One profile card: who it is for, what they get, what it costs. | |
| 173 | + | /// The page's one region, split out so it can be staged. | |
| 342 | 174 | /// | |
| 343 | - | /// The region is identified by the template's anchor so `/use-cases#podcasters` | |
| 344 | - | /// still lands on the right card. | |
| 345 | - | shape card(profile: &Profile, prices: &TierPrices) -> Slot; | |
| 175 | + | /// The split is where the request stops mattering, and on this page that is | |
| 176 | + | /// not the whole region: nine cards each end in a price built from | |
| 177 | + | /// `TierPrices`, which the server holds in memory and can change under a | |
| 178 | + | /// running process. So the residual here is not one literal the way | |
| 179 | + | /// `/policy` and `/team` are. It is the page's markup with nine holes in | |
| 180 | + | /// it, and serving it is writing nine strings into gaps rather than | |
| 181 | + | /// building and rendering a tree of some two hundred nodes. | |
| 182 | + | /// | |
| 183 | + | /// **The card is written here rather than in a `card()` shape**, and that | |
| 184 | + | /// is the copy production deciding it. A card's words are an entry in | |
| 185 | + | /// `content/use-cases.toml`, and a shape takes arguments one at a time: a | |
| 186 | + | /// `card(anchor, title, who, description, ...)` would have to spell each | |
| 187 | + | /// field at the call site and could not pass `features` at all, because a | |
| 188 | + | /// list field is iterated and not written. Unrolled here the loop is gone | |
| 189 | + | /// either way, so the shape bought a name and cost the copy file. | |
| 190 | + | #[staged] | |
| 191 | + | pub(crate) shape page_region(prices: &TierPrices) -> Slot; | |
| 346 | 192 | ||
| 347 | - | region profile.anchor as Group { | |
| 348 | - | label profile.title; | |
| 349 | - | text profile.who; | |
| 350 | - | text profile.description; | |
| 351 | - | list { | |
| 352 | - | for &feature in profile.features { | |
| 353 | - | row feature; | |
| 193 | + | region PAGE_REGION as Pane { | |
| 194 | + | page "Use Cases"; | |
| 195 | + | text "A flat monthly fee. 0% platform cut. Who it's built for:"; | |
| 196 | + | ||
| 197 | + | section "Available now"; | |
| 198 | + | region PROFILES as Group { | |
| 199 | + | for profile in copy "content/use-cases.toml" as profiles { | |
| 200 | + | // The region is identified by the template's anchor so | |
| 201 | + | // `/use-cases#podcasters` still lands on the right card. | |
| 202 | + | region profile.anchor as Group { | |
| 203 | + | // `section` and not `label`. A region's label is rendered | |
| 204 | + | // only by a container that discloses or steps through its | |
| 205 | + | // members, and `PROFILES` is a plain group, so a `label` | |
| 206 | + | // here reaches no markup at all. The template drew | |
| 207 | + | // `<div class="use-case-title">Musicians</div>`, and every | |
| 208 | + | // one of the nine has been missing from the page since | |
| 209 | + | // `84f0886b` described it; the card's own test asserted the | |
| 210 | + | // anchor, the who and the features, and never the title. | |
| 211 | + | section profile.title; | |
| 212 | + | text profile.who; | |
| 213 | + | text profile.description; | |
| 214 | + | list { | |
| 215 | + | for feature in profile.features { | |
| 216 | + | row feature; | |
| 217 | + | } | |
| 218 | + | } | |
| 219 | + | text super::use_cases::tier_line(profile.priced, prices); | |
| 220 | + | } | |
| 354 | 221 | } | |
| 355 | 222 | } | |
| 356 | - | text profile.priced.line(prices); | |
| 223 | + | ||
| 224 | + | section "Everyone gets"; | |
| 225 | + | region UNIVERSAL as Group { | |
| 226 | + | list { | |
| 227 | + | for gets in copy "content/use-cases.toml" as universal { | |
| 228 | + | row gets.name { | |
| 229 | + | secondary gets.sentence; | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | act "Join the Alpha" to get "/join" navigating; | |
| 236 | + | for onward in copy "content/use-cases.toml" as onward { | |
| 237 | + | act onward.label to get onward.route navigating; | |
| 238 | + | } | |
| 357 | 239 | } | |
| 358 | 240 | } | |
| 359 | 241 | ||
| @@ -401,23 +283,78 @@ | |||
| 401 | 283 | ); | |
| 402 | 284 | } | |
| 403 | 285 | ||
| 286 | + | /// The copy the page is built from, read the way the macro reads it. | |
| 287 | + | /// | |
| 288 | + | /// The test asks the content file what the page should say rather than | |
| 289 | + | /// holding a second copy of it, which is `policy`'s rule: a card somebody | |
| 290 | + | /// deletes from the file is a deletion and not a failure, and a card that | |
| 291 | + | /// stops being rendered is a failure. | |
| 292 | + | fn copy() -> toml::Table { | |
| 293 | + | include_str!("../../content/use-cases.toml") | |
| 294 | + | .parse() | |
| 295 | + | .expect("the use-cases copy is TOML") | |
| 296 | + | } | |
| 297 | + | ||
| 404 | 298 | /// Every card the template drew is still drawn, and still reachable by the | |
| 405 | 299 | /// anchor the marketing links use. | |
| 406 | 300 | #[test] | |
| 407 | 301 | fn every_profile_keeps_its_card_and_its_anchor() { | |
| 408 | 302 | let html = html(); | |
| 409 | 303 | ||
| 410 | - | assert_eq!(PROFILE_LIST.len(), 9, "the template drew nine"); | |
| 411 | - | for profile in PROFILE_LIST { | |
| 412 | - | assert!(html.contains(profile.anchor), "{} missing", profile.anchor); | |
| 413 | - | assert!(html.contains(profile.who), "{} missing", profile.who); | |
| 414 | - | for feature in profile.features { | |
| 415 | - | assert!( | |
| 416 | - | html.contains(feature), | |
| 417 | - | "{feature} missing from {}", | |
| 418 | - | profile.title | |
| 419 | - | ); | |
| 304 | + | let copy = copy(); | |
| 305 | + | let profiles = copy["profiles"].as_array().expect("a list of profiles"); | |
| 306 | + | assert_eq!(profiles.len(), 9, "the template drew nine"); | |
| 307 | + | for profile in profiles { | |
| 308 | + | let anchor = profile["anchor"].as_str().expect("an anchor"); | |
| 309 | + | assert!(html.contains(anchor), "{anchor} missing"); | |
| 310 | + | for field in ["who", "title", "description"] { | |
| 311 | + | let text = profile[field].as_str().expect("words"); | |
| 312 | + | let escaped = crate::helpers::escape_html(text); | |
| 313 | + | assert!(html.contains(&escaped), "{anchor}: {field} missing"); | |
| 420 | 314 | } | |
| 315 | + | for feature in profile["features"].as_array().expect("a list") { | |
| 316 | + | let feature = feature.as_str().expect("words"); | |
| 317 | + | let escaped = crate::helpers::escape_html(feature); | |
| 318 | + | assert!(html.contains(&escaped), "{feature} missing from {anchor}"); | |
| 319 | + | } | |
| 320 | + | } |
Lines truncated
| @@ -10,3 +10,53 @@ | |||
| 10 | 10 | "<div id=\"policy\" class=\"region pane\"><h1 class=\"heading\">Content Policy</h1><p class=\"text\">Makenotwork exists so creators can sell their work on fair terms. This policy describes what belongs here, what doesn't, and how we handle problems.</p><h2 class=\"heading\">What's Welcome</h2><div class=\"rich\" data-disable-scripting><p>Creative work across all supported types:</p>\n<ul>\n<li>Software, plugins, presets, and templates</li>\n<li>Audio: music, podcasts, samples, sound design</li>\n<li>Writing: articles, guides, courses, fiction</li>\n<li>Visual work: images, photography, design assets</li>\n<li>Video: tutorials, performances, documentaries</li>\n</ul>\n<p>If you made it and it's legal to distribute, it belongs here.</p>\n</div><h2 class=\"heading\">What's Not Allowed</h2><div class=\"rich\" data-disable-scripting><ul>\n<li>Content that violates applicable law</li>\n<li>Harassment, threats, or doxxing</li>\n<li>Spam, deceptive listings, or bait-and-switch pricing</li>\n<li>Malware, exploits, or tools designed to cause harm</li>\n<li>Impersonation of other creators or organizations</li>\n<li>Content you don't have the rights to distribute</li>\n</ul>\n</div><h2 class=\"heading\">How We Handle Issues</h2><div class=\"rich\" data-disable-scripting><p>During private alpha, every creator has a direct relationship with the admin. If something comes up, we talk about it. No automated takedowns, no faceless tickets.</p>\n<p>Post-alpha, we'll introduce a formal process with written notice of any policy violation, an opportunity to appeal, and continued access to data export throughout.</p>\n</div><h2 class=\"heading\">Your Rights</h2><div class=\"rich\" data-disable-scripting><ul>\n<li>Full data export is always available: your content, metadata, and transaction history</li>\n<li>If we ever moderate content or suspend an account, you'll get a clear explanation of what policy was violated</li>\n<li>You'll have the opportunity to appeal</li>\n<li>You can export your data even while suspended (excluding content we can't legally host)</li>\n</ul>\n<p>These commitments are part of our <a href=\"/docs/guarantees\" rel=\"noopener noreferrer\">creator guarantees</a>.</p>\n</div><h2 class=\"heading\">Software Downloads</h2><div class=\"rich\" data-disable-scripting><p>Makenotwork hosts downloadable software uploaded by creators. While we take steps to make sure downloads are safe:</p>\n<ul>\n<li>Creators are responsible for the safety and integrity of their uploads</li>\n<li>Users should verify downloads with antivirus software before running them</li>\n<li>We do not guarantee that any download is free of malware or other harmful content</li>\n<li>Report suspicious downloads to <strong>reports@makenot.work</strong></li>\n</ul>\n</div><h2 class=\"heading\">Other Policies</h2><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Terms of Service</span><span class=\"row-secondary\">What you agree to by using Makenotwork</span><span class=\"row-actions\"><a class=\"button\" data-act href=\"/docs/terms-of-service\">Read</a></span></li><li class=\"row\" data-row><span class=\"row-primary\">Privacy Policy</span><span class=\"row-secondary\">What we collect, why, and how to exercise your rights</span><span class=\"row-actions\"><a class=\"button\" data-act href=\"/docs/privacy-policy\">Read</a></span></li><li class=\"row\" data-row><span class=\"row-primary\">Payments & Refunds</span><span class=\"row-secondary\">Merchant-of-record model, refunds, chargebacks</span><span class=\"row-actions\"><a class=\"button\" data-act href=\"/docs/payments\">Read</a></span></li><li class=\"row\" data-row><span class=\"row-primary\">Acceptable Use</span><span class=\"row-secondary\">Specific behaviour that gets accounts suspended</span><span class=\"row-actions\"><a class=\"button\" data-act href=\"/docs/acceptable-use\">Read</a></span></li><li class=\"row\" data-row><span class=\"row-primary\">Copyright & DMCA</span><span class=\"row-secondary\">How takedowns and counter-notifications work</span><span class=\"row-actions\"><a class=\"button\" data-act href=\"/docs/copyright\">Read</a></span></li><li class=\"row\" data-row><span class=\"row-primary\">Appeals</span><span class=\"row-secondary\">How to challenge a moderation decision</span><span class=\"row-actions\"><a class=\"button\" data-act href=\"/docs/appeals\">Read</a></span></li><li class=\"row\" data-row><span class=\"row-primary\">Mailing List Data Processing</span><span class=\"row-secondary\">Who answers a subscriber's request about a project mailing list</span><span class=\"row-actions\"><a class=\"button\" data-act href=\"/docs/mailing-list-data-processing\">Read</a></span></li></ul><h2 class=\"heading\">Questions</h2><div class=\"rich\" data-disable-scripting><p>If something's unclear or you want to check before posting, reach out at <strong>policy@makenot.work</strong>.</p>\n</div><div class=\"anchored\" id=\"policy-anchored\" data-menu=\"anchored\" hidden></div></div>", | |
| 11 | 11 | )), | |
| 12 | 12 | ]); | |
| 13 | + | ||
| 14 | + | pub static TEAM: ::quasi_router::stage::Residual = ::quasi_router::stage::Residual::compiled(&[ | |
| 15 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 16 | + | "<div id=\"team\" class=\"region pane\"><h1 class=\"heading\">Team</h1><p class=\"text\">Small by design. The base platform was designed by the founder, who intends to stay as the technical lead through the project's growth.</p><h2 class=\"heading\">Founder</h2><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Max</span><span class=\"row-secondary\">CEO & Tech Lead</span><span class=\"row-meta\">Designed and built the platform.</span><span class=\"row-actions\"><a class=\"button\" data-act href=\"/u/max\">@max</a></span></li></ul><section id=\"founder-bio\" class=\"region group\" data-showing=\"at-most-one\"><button type=\"button\" class=\"button\" data-shows=\"0\" aria-expanded=\"false\" _=\"on click toggle .current on the first <[data-frame='founder-bio']/> then if the first <[data-frame='founder-bio']/> matches '.current' set @aria-expanded of me to 'true' else set @aria-expanded of me to 'false' end\">Bio</button><div class=\"showing-frame\" data-frame=\"founder-bio\" data-shown=\"0\"><div id=\"founder-bio-body\" class=\"region pane\"><p class=\"text\">I started in the creator space as a young kid making backgrounds and montages for CoD MW2 players. I picked up programming around the same time, helping Minecraft server owners with whatever they needed. I spent the years after that focused on my education: a BA in mathematics at the University of Chicago, then a PhD in mathematics at UC San Diego. Afterward I decided to start my own business, and here we are.</p><div class=\"anchored\" id=\"founder-bio-body-anchored\" data-menu=\"anchored\" hidden></div></div></div><div class=\"anchored\" id=\"founder-bio-anchored\" data-menu=\"anchored\" hidden></div></section><div class=\"anchored\" id=\"team-anchored\" data-menu=\"anchored\" hidden></div></div>", | |
| 17 | + | )), | |
| 18 | + | ]); | |
| 19 | + | ||
| 20 | + | pub static USE_CASES: ::quasi_router::stage::Residual = ::quasi_router::stage::Residual::compiled( | |
| 21 | + | &[ | |
| 22 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 23 | + | "<div id=\"use-cases\" class=\"region pane\"><h1 class=\"heading\">Use Cases</h1><p class=\"text\">A flat monthly fee. 0% platform cut. Who it's built for:</p><h2 class=\"heading\">Available now</h2><section id=\"use-case-profiles\" class=\"region group\"><section id=\"musicians\" class=\"region group\"><h2 class=\"heading\">Musicians</h2><p class=\"text\">Bands, solo artists, producers</p><p class=\"text\">Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">MP3, FLAC, WAV, OGG, AAC, AIFF upload</span></li><li class=\"row\" data-row><span class=\"row-primary\">In-browser audio player with chapters</span></li><li class=\"row\" data-row><span class=\"row-primary\">Cover art and rich metadata</span></li><li class=\"row\" data-row><span class=\"row-primary\">RSS feed per project</span></li><li class=\"row\" data-row><span class=\"row-primary\">Pay-what-you-want pricing</span></li></ul><p class=\"text\">", | |
| 24 | + | )), | |
| 25 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 0 }, | |
| 26 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 27 | + | "</p><div class=\"anchored\" id=\"musicians-anchored\" data-menu=\"anchored\" hidden></div></section><section id=\"podcasters\" class=\"region group\"><h2 class=\"heading\">Podcasters</h2><p class=\"text\">Independent shows, networks</p><p class=\"text\">Host and distribute your podcast with full RSS support for every major directory.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">RSS for Apple, Spotify, Pocket Casts</span></li><li class=\"row\" data-row><span class=\"row-primary\">Chapter markers</span></li><li class=\"row\" data-row><span class=\"row-primary\">Subscriber-only feeds</span></li><li class=\"row\" data-row><span class=\"row-primary\">Broadcast emails to followers</span></li></ul><p class=\"text\">", | |
| 28 | + | )), | |
| 29 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 1 }, | |
| 30 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 31 | + | "</p><div class=\"anchored\" id=\"podcasters-anchored\" data-menu=\"anchored\" hidden></div></section><section id=\"writers\" class=\"region group\"><h2 class=\"heading\">Writers</h2><p class=\"text\">Bloggers, newsletter writers, authors</p><p class=\"text\">Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Markdown editor with formatting</span></li><li class=\"row\" data-row><span class=\"row-primary\">Per-project blog</span></li><li class=\"row\" data-row><span class=\"row-primary\">RSS feed</span></li><li class=\"row\" data-row><span class=\"row-primary\">Scheduled publishing</span></li><li class=\"row\" data-row><span class=\"row-primary\">Broadcast emails</span></li></ul><p class=\"text\">", | |
| 32 | + | )), | |
| 33 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 2 }, | |
| 34 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 35 | + | "</p><div class=\"anchored\" id=\"writers-anchored\" data-menu=\"anchored\" hidden></div></section><section id=\"developers\" class=\"region group\"><h2 class=\"heading\">Software Developers</h2><p class=\"text\">Indie devs, tool makers, plugin authors</p><p class=\"text\">Distribute software with versioned releases, license keys, and a built-in git browser.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Versioned releases with changelogs</span></li><li class=\"row\" data-row><span class=\"row-primary\">License keys with activation tracking</span></li><li class=\"row\" data-row><span class=\"row-primary\">Git source browser</span></li><li class=\"row\" data-row><span class=\"row-primary\">Promo codes and discounts</span></li></ul><p class=\"text\">", | |
| 36 | + | )), | |
| 37 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 3 }, | |
| 38 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 39 | + | "</p><div class=\"anchored\" id=\"developers-anchored\" data-menu=\"anchored\" hidden></div></section><section id=\"sample-packs\" class=\"region group\"><h2 class=\"heading\">Sample Packs</h2><p class=\"text\">Sound designers, beat makers, foley artists</p><p class=\"text\">Sell sample packs and sound effects with audio previews and versioned downloads.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Audio previews in-browser</span></li><li class=\"row\" data-row><span class=\"row-primary\">Versioned downloads</span></li><li class=\"row\" data-row><span class=\"row-primary\">Hierarchical tags for organization</span></li><li class=\"row\" data-row><span class=\"row-primary\">Pay-what-you-want pricing</span></li><li class=\"row\" data-row><span class=\"row-primary\">Cover art</span></li></ul><p class=\"text\">", | |
| 40 | + | )), | |
| 41 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 4 }, | |
| 42 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 43 | + | "</p><div class=\"anchored\" id=\"sample-packs-anchored\" data-menu=\"anchored\" hidden></div></section><section id=\"digital-art\" class=\"region group\"><h2 class=\"heading\">Digital Art & Photography</h2><p class=\"text\">Illustrators, photographers, font designers</p><p class=\"text\">Sell digital art, photography, and fonts with versioned releases and subscription tiers.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Any file type supported</span></li><li class=\"row\" data-row><span class=\"row-primary\">Versioned releases</span></li><li class=\"row\" data-row><span class=\"row-primary\">Subscription tiers</span></li><li class=\"row\" data-row><span class=\"row-primary\">Cover images</span></li></ul><p class=\"text\">", | |
| 44 | + | )), | |
| 45 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 5 }, | |
| 46 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 47 | + | "</p><div class=\"anchored\" id=\"digital-art-anchored\" data-menu=\"anchored\" hidden></div></section><section id=\"educators\" class=\"region group\"><h2 class=\"heading\">Educators</h2><p class=\"text\">Course creators, tutors, workshop leaders</p><p class=\"text\">Publish courses and teaching materials with text content, downloads, and subscriptions.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Text content with Markdown</span></li><li class=\"row\" data-row><span class=\"row-primary\">Downloadable materials</span></li><li class=\"row\" data-row><span class=\"row-primary\">Subscription tiers</span></li><li class=\"row\" data-row><span class=\"row-primary\">Scheduled publishing</span></li></ul><p class=\"text\">", | |
| 48 | + | )), | |
| 49 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 6 }, | |
| 50 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 51 | + | "</p><div class=\"anchored\" id=\"educators-anchored\" data-menu=\"anchored\" hidden></div></section><section id=\"game-devs\" class=\"region group\"><h2 class=\"heading\">Game Developers</h2><p class=\"text\">Indie games, mods, TTRPG content</p><p class=\"text\">Distribute games up to 20GB per file with versioned downloads, license keys, and promo codes. Need more than 20GB? Request a size increase from your dashboard.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Up to 20GB per file (increase on request)</span></li><li class=\"row\" data-row><span class=\"row-primary\">Versioned downloads with changelogs</span></li><li class=\"row\" data-row><span class=\"row-primary\">License keys with activation limits</span></li><li class=\"row\" data-row><span class=\"row-primary\">Promo codes</span></li></ul><p class=\"text\">", | |
| 52 | + | )), | |
| 53 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 7 }, | |
| 54 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 55 | + | "</p><div class=\"anchored\" id=\"game-devs-anchored\" data-menu=\"anchored\" hidden></div></section><section id=\"comic-creators\" class=\"region group\"><h2 class=\"heading\">Comic Creators</h2><p class=\"text\">Webcomic artists, graphic novelists</p><p class=\"text\">Sell digital comics and graphic novels with project organization and scheduled publishing.</p><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">PDF, CBZ, and any file type</span></li><li class=\"row\" data-row><span class=\"row-primary\">Project organization</span></li><li class=\"row\" data-row><span class=\"row-primary\">Subscriptions</span></li><li class=\"row\" data-row><span class=\"row-primary\">Scheduled publishing</span></li></ul><p class=\"text\">", | |
| 56 | + | )), | |
| 57 | + | ::quasi_router::stage::Op::Hole { scope: 0, id: 8 }, | |
| 58 | + | ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed( | |
| 59 | + | "</p><div class=\"anchored\" id=\"comic-creators-anchored\" data-menu=\"anchored\" hidden></div></section><div class=\"anchored\" id=\"use-case-profiles-anchored\" data-menu=\"anchored\" hidden></div></section><h2 class=\"heading\">Everyone gets</h2><section id=\"everyone-gets\" class=\"region group\"><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">0% platform fee</span><span class=\"row-secondary\">Only ~3% payment processing. No percentage cuts, no revenue sharing.</span></li><li class=\"row\" data-row><span class=\"row-primary\">Full data export</span><span class=\"row-secondary\">Download everything anytime. Projects, items, sales, contacts: all yours.</span></li><li class=\"row\" data-row><span class=\"row-primary\">No lock-in</span><span class=\"row-secondary\">Month-to-month. Cancel anytime. Take your audience with you.</span></li><li class=\"row\" data-row><span class=\"row-primary\">Source available</span><span class=\"row-secondary\">Read the codebase. Verify every claim about privacy and data handling.</span></li></ul><div class=\"anchored\" id=\"everyone-gets-anchored\" data-menu=\"anchored\" hidden></div></section><a class=\"button\" data-act href=\"/join\">Join the Alpha</a><a class=\"button\" data-act href=\"/pricing\">Pricing Calculator</a><a class=\"button\" data-act href=\"/discover\">Browse as guest</a><a class=\"button\" data-act href=\"/docs/getting-started\">Creator Guide</a><div class=\"anchored\" id=\"use-cases-anchored\" data-menu=\"anchored\" hidden></div></div>", | |
| 60 | + | )), | |
| 61 | + | ], | |
| 62 | + | ); |
| @@ -1,0 +1,14 @@ | |||
| 1 | + | # The copy at `/team`, which is words and nothing else. | |
| 2 | + | # | |
| 3 | + | # Read at build time by the `declare!` in `src/quasi/team.rs`. Editing this file | |
| 4 | + | # is editing the page: no Rust to read, no code to touch, and a rebuild picks it | |
| 5 | + | # up. See `content/policy.toml` for the split this follows. | |
| 6 | + | # | |
| 7 | + | # The bio is prose the founder wrote, so nothing here rewrites it. It lives in | |
| 8 | + | # TOML rather than in a Rust `const` for the reason `policy.toml` records: a | |
| 9 | + | # `const` is a path and not a literal, so an `include` of one opens a staged | |
| 10 | + | # scope and the sentence gets built and rendered on every request. Copy the | |
| 11 | + | # macro can read is copy the macro can fold. | |
| 12 | + | ||
| 13 | + | [[bio]] | |
| 14 | + | body = "I started in the creator space as a young kid making backgrounds and montages for CoD MW2 players. I picked up programming around the same time, helping Minecraft server owners with whatever they needed. I spent the years after that focused on my education: a BA in mathematics at the University of Chicago, then a PhD in mathematics at UC San Diego. Afterward I decided to start my own business, and here we are." |
| @@ -1,0 +1,162 @@ | |||
| 1 | + | # The copy at `/use-cases`, which is words and nothing else. | |
| 2 | + | # | |
| 3 | + | # Read at build time by the `declare!` in `src/quasi/use_cases.rs`. Editing this | |
| 4 | + | # file is editing the page. See `content/policy.toml` for the split this follows. | |
| 5 | + | # | |
| 6 | + | # `priced` names which tier line a card ends with, and that is the one thing on | |
| 7 | + | # this page a request decides: the numbers come from `TierPrices` in memory, so | |
| 8 | + | # the card's last line is a hole and everything above it is a literal. The word | |
| 9 | + | # here is a shape name and not a price, which is what keeps a stale number from | |
| 10 | + | # ever being written down. | |
| 11 | + | ||
| 12 | + | [[profiles]] | |
| 13 | + | anchor = "musicians" | |
| 14 | + | title = "Musicians" | |
| 15 | + | who = "Bands, solo artists, producers" | |
| 16 | + | description = "Sell albums, singles, and EPs with in-browser streaming, chapter markers, and cover art." | |
| 17 | + | features = [ | |
| 18 | + | "MP3, FLAC, WAV, OGG, AAC, AIFF upload", | |
| 19 | + | "In-browser audio player with chapters", | |
| 20 | + | "Cover art and rich metadata", | |
| 21 | + | "RSS feed per project", | |
| 22 | + | "Pay-what-you-want pricing", | |
| 23 | + | ] | |
| 24 | + | priced = "small-files" | |
| 25 | + | ||
| 26 | + | [[profiles]] | |
| 27 | + | anchor = "podcasters" | |
| 28 | + | title = "Podcasters" | |
| 29 | + | who = "Independent shows, networks" | |
| 30 | + | description = "Host and distribute your podcast with full RSS support for every major directory." | |
| 31 | + | features = [ | |
| 32 | + | "RSS for Apple, Spotify, Pocket Casts", | |
| 33 | + | "Chapter markers", | |
| 34 | + | "Subscriber-only feeds", | |
| 35 | + | "Broadcast emails to followers", | |
| 36 | + | ] | |
| 37 | + | priced = "small-files" | |
| 38 | + | ||
| 39 | + | [[profiles]] | |
| 40 | + | anchor = "writers" | |
| 41 | + | title = "Writers" | |
| 42 | + | who = "Bloggers, newsletter writers, authors" | |
| 43 | + | description = "Publish long-form writing with Markdown, per-project blogs, and subscriber broadcasts." | |
| 44 | + | features = [ | |
| 45 | + | "Markdown editor with formatting", | |
| 46 | + | "Per-project blog", | |
| 47 | + | "RSS feed", | |
| 48 | + | "Scheduled publishing", | |
| 49 | + | "Broadcast emails", | |
| 50 | + | ] | |
| 51 | + | priced = "basic" | |
| 52 | + | ||
| 53 | + | [[profiles]] | |
| 54 | + | anchor = "developers" | |
| 55 | + | title = "Software Developers" | |
| 56 | + | who = "Indie devs, tool makers, plugin authors" | |
| 57 | + | description = "Distribute software with versioned releases, license keys, and a built-in git browser." | |
| 58 | + | features = [ | |
| 59 | + | "Versioned releases with changelogs", | |
| 60 | + | "License keys with activation tracking", | |
| 61 | + | "Git source browser", | |
| 62 | + | "Promo codes and discounts", | |
| 63 | + | ] | |
| 64 | + | priced = "small-files" | |
| 65 | + | ||
| 66 | + | [[profiles]] | |
| 67 | + | anchor = "sample-packs" | |
| 68 | + | title = "Sample Packs" | |
| 69 | + | who = "Sound designers, beat makers, foley artists" | |
| 70 | + | description = "Sell sample packs and sound effects with audio previews and versioned downloads." | |
| 71 | + | features = [ | |
| 72 | + | "Audio previews in-browser", | |
| 73 | + | "Versioned downloads", | |
| 74 | + | "Hierarchical tags for organization", | |
| 75 | + | "Pay-what-you-want pricing", | |
| 76 | + | "Cover art", | |
| 77 | + | ] | |
| 78 | + | priced = "small-files" | |
| 79 | + | ||
| 80 | + | [[profiles]] | |
| 81 | + | anchor = "digital-art" | |
| 82 | + | title = "Digital Art & Photography" | |
| 83 | + | who = "Illustrators, photographers, font designers" | |
| 84 | + | description = "Sell digital art, photography, and fonts with versioned releases and subscription tiers." | |
| 85 | + | features = [ | |
| 86 | + | "Any file type supported", | |
| 87 | + | "Versioned releases", | |
| 88 | + | "Subscription tiers", | |
| 89 | + | "Cover images", | |
| 90 | + | ] | |
| 91 | + | priced = "small-files" | |
| 92 | + | ||
| 93 | + | [[profiles]] | |
| 94 | + | anchor = "educators" | |
| 95 | + | title = "Educators" | |
| 96 | + | who = "Course creators, tutors, workshop leaders" | |
| 97 | + | description = "Publish courses and teaching materials with text content, downloads, and subscriptions." | |
| 98 | + | features = [ | |
| 99 | + | "Text content with Markdown", | |
| 100 | + | "Downloadable materials", | |
| 101 | + | "Subscription tiers", | |
| 102 | + | "Scheduled publishing", | |
| 103 | + | ] | |
| 104 | + | priced = "basic-or-small-files" | |
| 105 | + | ||
| 106 | + | [[profiles]] | |
| 107 | + | anchor = "game-devs" | |
| 108 | + | title = "Game Developers" | |
| 109 | + | who = "Indie games, mods, TTRPG content" | |
| 110 | + | description = "Distribute games up to 20GB per file with versioned downloads, license keys, and promo codes. Need more than 20GB? Request a size increase from your dashboard." | |
| 111 | + | features = [ | |
| 112 | + | "Up to 20GB per file (increase on request)", | |
| 113 | + | "Versioned downloads with changelogs", | |
| 114 | + | "License keys with activation limits", | |
| 115 | + | "Promo codes", | |
| 116 | + | ] | |
| 117 | + | priced = "big-files" | |
| 118 | + | ||
| 119 | + | [[profiles]] | |
| 120 | + | anchor = "comic-creators" | |
| 121 | + | title = "Comic Creators" | |
| 122 | + | who = "Webcomic artists, graphic novelists" | |
| 123 | + | description = "Sell digital comics and graphic novels with project organization and scheduled publishing." | |
| 124 | + | features = [ | |
| 125 | + | "PDF, CBZ, and any file type", | |
| 126 | + | "Project organization", | |
| 127 | + | "Subscriptions", | |
| 128 | + | "Scheduled publishing", | |
| 129 | + | ] | |
| 130 | + | priced = "small-files" | |
| 131 | + | ||
| 132 | + | # What every tier includes, in the order the page shows them. | |
| 133 | + | ||
| 134 | + | [[universal]] | |
| 135 | + | name = "0% platform fee" | |
| 136 | + | sentence = "Only ~3% payment processing. No percentage cuts, no revenue sharing." | |
| 137 | + | ||
| 138 | + | [[universal]] | |
| 139 | + | name = "Full data export" | |
| 140 | + | sentence = "Download everything anytime. Projects, items, sales, contacts: all yours." | |
| 141 | + | ||
| 142 | + | [[universal]] | |
| 143 | + | name = "No lock-in" | |
| 144 | + | sentence = "Month-to-month. Cancel anytime. Take your audience with you." | |
| 145 | + | ||
| 146 | + | [[universal]] | |
| 147 | + | name = "Source available" | |
| 148 | + | sentence = "Read the codebase. Verify every claim about privacy and data handling." | |
| 149 | + | ||
| 150 | + | # Where the page sends a reader who is convinced, and one who is not yet. | |
| 151 | + | ||
| 152 | + | [[onward]] | |
| 153 | + | label = "Pricing Calculator" | |
| 154 | + | route = "/pricing" | |
| 155 | + | ||
| 156 | + | [[onward]] | |
| 157 | + | label = "Browse as guest" | |
| 158 | + | route = "/discover" | |
| 159 | + | ||
| 160 | + | [[onward]] | |
| 161 | + | label = "Creator Guide" | |
| 162 | + | route = "/docs/getting-started" |