Skip to main content

max / makenotwork

Put the collection page and the export portal on the residual seam Six of seventeen described screens now serve from a generated template. Both of these bring a first: /c/{username}/{slug} is the first residual with a loop in it, and /dashboard/export is the first gated document on the seam. The loop is the point of the collection page. Its items table is one compiled row body walked once per item rather than a row's markup rendered per item, which is checked at nought, one and three items so a residual that had baked one request's row count into its literals fails. Both visibilities too, since the private banner is a branch. The export portal's five direct cards were a `const DIRECT: &[Direct]`, which is a path rather than a literal, so the loop over it opened a scope per request. They move to content/export-portal.toml and are read at macro time, which folds all five into the residual's literals; the tests that iterated the const now read the file the way the macro reads it. `export_act::control` gains `#[constant]` so the card's control is built once while the residual is derived. Two pieces of mount plumbing, both wanted by these two screens: `Carried` now carries the address's captures as well as its query, so a screen on the seam can serve a parameterised address. They are axum's captures, which are percent-decoded where quasi's matcher hands the raw segment over; the difference is stated where the type is, and it is a widening toward the spelling RFC 3986 says the address means. `served_gated_mount` is the seam's mount under `Audience::Reader` with the `signed_in` layer in front, which is `document_mount`'s relationship to `public_document_mount`. The mount-count test now counts both styles for gated documents, as it already did for public ones.
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session
https://claude.ai/code/session_01P8ostB2UmZJGj5WjSHRSot
Author: Max Johnson <me@maxj.phd> · 2026-09-08 01:44 UTC
Commit: dac6ffd971750bec5a5d5276c1236ef94ba6f6e4
Parent: c554d7e
7 files changed, +574 insertions, -175 deletions
@@ -46,7 +46,7 @@
46 46 const MEASURE: layout::Measure = layout::Measure::Wide;
47 47
48 48 /// Everything the screen draws, resolved before it is drawn.
49 - struct Loaded {
49 + pub(crate) struct Loaded {
50 50 title: String,
51 51 /// The collection's own slug, for the address the Copy link control hands
52 52 /// over. Read back off the row rather than off the request, so the copied
@@ -80,7 +80,7 @@
80 80 clippy::struct_field_names,
81 81 reason = "`type` is a keyword; this is the kind"
82 82 )]
83 - struct Item {
83 + pub(crate) struct Item {
84 84 id: String,
85 85 title: String,
86 86 item_type: String,
@@ -106,6 +106,22 @@
106 106 Ok(page_screen(&loaded).into())
107 107 }
108 108
109 + /// The one read this page makes, for the mount that serves it from a residual.
110 + ///
111 + /// One read, stating the document and filling the holes. Read twice, the two
112 + /// could disagree between them and the page would title itself for one
113 + /// collection and list another's items.
114 + pub(crate) fn reading(
115 + viewer: &super::Viewer,
116 + carried: &super::Carried,
117 + ) -> Result<Loaded, RouteError> {
118 + load(
119 + viewer,
120 + carried.capture("username")?,
121 + carried.capture("slug")?,
122 + )
123 + }
124 +
109 125 /// Resolve the owner, the collection and its items, refusing exactly as the
110 126 /// shipped handler did.
111 127 fn load(viewer: &super::Viewer, username: &str, slug: &str) -> Result<Loaded, RouteError> {
@@ -172,44 +188,56 @@
172 188
173 189 declare! {
174 190 /// The whole document: the title, the measure, the body.
175 - shape page_screen(loaded: &Loaded) -> Screen;
191 + pub(crate) shape page_screen(loaded: &Loaded) -> Screen;
176 192
177 193 screen single "{loaded.title} - {loaded.owner_username} - Makenotwork" {
178 194 measured MEASURE;
179 195 documented Document::default().classed(crate::shell::body_class(MEASURE, &["collection-page"]));
180 196
181 - region PAGE_REGION as Pane {
182 - page loaded.title.clone();
197 + include page_region(loaded);
198 + }
199 + }
183 200
184 - // "by <owner>", with the owner's name as a link rather than a
185 - // button: it is a name that goes somewhere, which is exactly what a
186 - // link is for and what an act would draw a bevel around.
187 - link "by {loaded.owner_shown}" to get "/u/{loaded.owner_username}" navigating;
201 + declare! {
202 + /// The page's one region, split out so it can be staged.
203 + ///
204 + /// Holes and branches over one loop: the collection's own words are read
205 + /// per request, the private banner and the empty state are guards, and the
206 + /// table is a loop whose body is compiled once and walked per item.
207 + #[staged]
208 + pub(crate) shape page_region(loaded: &Loaded) -> Slot;
188 209
189 - // The template put a `Private` badge beside the owner's name. A
190 - // `Tag` is not a `Node` -- tokens belong to rows and cells -- and
191 - // rather than invent a row to hold one, this says it as a banner.
192 - // That is the better reading anyway: the only person who sees it is
193 - // the owner, and what they need to know is that nobody else can open
194 - // the link they are looking at, which a small badge next to a name
195 - // says quietly and a banner says once.
196 - banner layout::Tone::Info "This collection is private. Only you can see it."
197 - unless loaded.is_public;
210 + region PAGE_REGION as Pane {
211 + page loaded.title.clone();
198 212
199 - text loaded.description() unless loaded.description().is_empty();
200 - text "{loaded.items.len()} items";
213 + // "by <owner>", with the owner's name as a link rather than a
214 + // button: it is a name that goes somewhere, which is exactly what a
215 + // link is for and what an act would draw a bevel around.
216 + link "by {loaded.owner_shown}" to get "/u/{loaded.owner_username}" navigating;
201 217
202 - empty "This collection is empty." when loaded.items.is_empty();
203 - include items_table(&loaded.items) unless loaded.items.is_empty();
218 + // The template put a `Private` badge beside the owner's name. A
219 + // `Tag` is not a `Node` -- tokens belong to rows and cells -- and
220 + // rather than invent a row to hold one, this says it as a banner.
221 + // That is the better reading anyway: the only person who sees it is
222 + // the owner, and what they need to know is that nobody else can open
223 + // the link they are looking at, which a small badge next to a name
224 + // says quietly and a banner says once.
225 + banner layout::Tone::Info "This collection is private. Only you can see it."
226 + unless loaded.is_public;
204 227
205 - act "View profile" to get "/u/{loaded.owner_username}" navigating;
228 + text loaded.description() unless loaded.description().is_empty();
229 + text "{loaded.items.len()} items";
206 230
207 - // `copying` is a setting on the control and not a modifier of the
208 - // action: it sets the destination to `local` itself, because a copy
209 - // asks no route and the two facts are one sentence.
210 - act "Copy link" to local {
211 - copying "/c/{loaded.owner_username}/{loaded.slug}";
212 - }
231 + empty "This collection is empty." when loaded.items.is_empty();
232 + include items_table(&loaded.items) unless loaded.items.is_empty();
233 +
234 + act "View profile" to get "/u/{loaded.owner_username}" navigating;
235 +
236 + // `copying` is a setting on the control and not a modifier of the
237 + // action: it sets the destination to `local` itself, because a copy
238 + // asks no route and the two facts are one sentence.
239 + act "Copy link" to local {
240 + copying "/c/{loaded.owner_username}/{loaded.slug}";
213 241 }
214 242 }
215 243 }
@@ -228,6 +256,7 @@
228 256 /// above them in one declaration and every item fills all five. Naming buys
229 257 /// nothing a reader cannot already see; it earns its keep where a cell is
230 258 /// conditional or the headings live in another shape.
259 + #[staged]
231 260 shape items_table(items: &[Item]) -> Node;
232 261
233 262 table {
@@ -272,30 +301,39 @@
272 301 )))
273 302 }
274 303
304 + /// A collection as the tests draw it, with `items` items and that visibility.
305 + ///
306 + /// Module-level rather than inside `mod tests` because `quasi::residuals` needs
307 + /// one too: a screen that branches has to be filled under every branch to be
308 + /// checked at all, and `Loaded` is this module's own type. Test-only, so it
309 + /// costs the shipped binary nothing.
310 + #[cfg(test)]
311 + pub(crate) fn sample(items: usize, is_public: bool) -> Loaded {
312 + Loaded {
313 + title: "Field Recordings".into(),
314 + slug: "field-recordings".into(),
315 + owner_shown: "Ada".into(),
316 + owner_username: "ada".into(),
317 + description: Some("Things I taped outdoors.".into()),
318 + is_public,
319 + items: (0..items)
320 + .map(|n| Item {
321 + id: format!("item{n}"),
322 + title: format!("Track {n}"),
323 + item_type: "Audio".into(),
324 + creator: "ada".into(),
325 + project: "Tapes".into(),
326 + price: "$3".into(),
327 + })
328 + .collect(),
329 + }
330 + }
331 +
275 332 #[cfg(test)]
276 333 mod tests {
277 334 use super::*;
278 335
279 - fn loaded(items: usize, is_public: bool) -> Loaded {
280 - Loaded {
281 - title: "Field Recordings".into(),
282 - slug: "field-recordings".into(),
283 - owner_shown: "Ada".into(),
284 - owner_username: "ada".into(),
285 - description: Some("Things I taped outdoors.".into()),
286 - is_public,
287 - items: (0..items)
288 - .map(|n| Item {
289 - id: format!("item{n}"),
290 - title: format!("Track {n}"),
291 - item_type: "Audio".into(),
292 - creator: "ada".into(),
293 - project: "Tapes".into(),
294 - price: "$3".into(),
295 - })
296 - .collect(),
297 - }
298 - }
336 + use super::sample as loaded;
299 337
300 338 fn html(loaded: &Loaded) -> String {
301 339 use quasi_axum::Serves as _;
@@ -72,6 +72,7 @@
72 72 /// Returns the [`Act`] rather than a [`Node`] because the portal's controls sit
73 73 /// in a [`quasi_router::Row`], which holds acts rather than nodes.
74 74 #[must_use]
75 + #[constant]
75 76 pub shape control(label: &str, route: &str, filename: &str) -> Act;
76 77
77 78 act label to post route saving filename awaiting;
@@ -69,60 +69,6 @@
69 69
70 70 const MEASURE: layout::Measure = layout::Measure::Wide;
71 71
72 - /// One export that hands back a file.
73 - ///
74 - /// `filename` is what the reader's disk ends up with, and it is the name the
75 - /// endpoint itself sets in `Content-Disposition` rather than a shorter one
76 - /// invented here: a `data-saves` control renames the download, so the two
77 - /// disagreeing means the same export arrives under two names depending on which
78 - /// control started it.
79 - struct Direct {
80 - title: &'static str,
81 - description: &'static str,
82 - meta: &'static str,
83 - route: &'static str,
84 - filename: &'static str,
85 - }
86 -
87 - /// The five that answer with a file, in the order the template listed them.
88 - const DIRECT: &[Direct] = &[
89 - Direct {
90 - title: "Projects & Items",
91 - description: "All your project and item metadata including titles, descriptions, prices, and tags.",
92 - meta: "JSON format",
93 - route: "/api/export/projects",
94 - filename: "makenot-work-projects.json",
95 - },
96 - Direct {
97 - title: "Sales History",
98 - description: "Record of all sales you've made, including dates, amounts, and item titles.",
99 - meta: "CSV format",
100 - route: "/api/export/sales",
101 - filename: "makenot-work-sales.csv",
102 - },
103 - Direct {
104 - title: "Collaborator Payouts",
105 - description: "Record of all revenue shared with collaborators on your projects, both incoming and outgoing.",
106 - meta: "CSV format",
107 - route: "/api/export/splits",
108 - filename: "makenot-work-splits.csv",
109 - },
110 - Direct {
111 - title: "Purchase History",
112 - description: "Record of all items you've purchased, for your personal records.",
113 - meta: "CSV format",
114 - route: "/api/export/purchases",
115 - filename: "makenot-work-purchases.csv",
116 - },
117 - Direct {
118 - title: "Followers & Members",
119 - description: "List of users who follow you or have memberships to your projects.",
120 - meta: "CSV format",
121 - route: "/api/export/followers",
122 - filename: "makenot-work-followers.csv",
123 - },
124 - ];
125 -
126 72 /// What the screen needs from the database.
127 73 pub struct Page {
128 74 /// Whether the reader has any exportable files at all.
@@ -159,16 +105,20 @@
159 105 })
160 106 }
161 107
162 - pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
163 - let page = viewer
108 + /// The one read this page makes, for the mount that serves it from a residual.
109 + pub(crate) fn reading(viewer: &super::Viewer) -> Result<Page, RouteError> {
110 + viewer
164 111 .block_on(load(&viewer.app.db, viewer.reader()?.id))
165 - .map_err(|_| RouteError::internal("your exports could not be read"))?;
166 - Ok(page_screen(&page).into())
112 + .map_err(|_| RouteError::internal("your exports could not be read"))
113 + }
114 +
115 + pub fn screen(viewer: &super::Viewer, _request: Request) -> Result<Response, RouteError> {
116 + Ok(page_screen(&reading(viewer)?).into())
167 117 }
168 118
169 119 declare! {
170 120 /// The whole document: the title, the measure, the body.
171 - shape page_screen(page: &Page) -> Screen;
121 + pub(crate) shape page_screen(page: &Page) -> Screen;
172 122
173 123 screen single "Export Your Data - Makenotwork" {
174 124 measured MEASURE;
@@ -179,16 +129,27 @@
179 129 documented Document::default().classed(crate::shell::body_class(MEASURE, &["export-page"]));
180 130 summarised "Download your content, projects, and transaction history.";
181 131
182 - region PAGE_REGION as Pane {
183 - link "Back to Dashboard" to get "/dashboard" navigating;
184 - page "Export Your Data";
185 - text "Download your content, projects, and transaction history.";
186 - include cards(page);
187 - section "About Your Data";
188 - text "Your data belongs to you. These exports contain everything we store \
189 - about your account and content. If you're planning to delete your \
190 - account, we recommend downloading your data first.";
191 - }
132 + include page_region(page);
133 + }
134 + }
135 +
136 + declare! {
137 + /// The page's one region, split out so it can be staged.
138 + ///
139 + /// Everything on it but the content card is words, so the residual is one
140 + /// literal around two branches: whether this reader has files at all.
141 + #[staged]
142 + pub(crate) shape page_region(page: &Page) -> Slot;
143 +
144 + region PAGE_REGION as Pane {
145 + link "Back to Dashboard" to get "/dashboard" navigating;
146 + page "Export Your Data";
147 + text "Download your content, projects, and transaction history.";
148 + include cards(page);
149 + section "About Your Data";
150 + text "Your data belongs to you. These exports contain everything we store \
151 + about your account and content. If you're planning to delete your \
152 + account, we recommend downloading your data first.";
192 153 }
193 154 }
194 155
@@ -200,11 +161,12 @@
200 161 /// server's one sentence about posting a route and keeping the answer as a
201 162 /// file. What a caller must not restate is that sentence, so the control
202 163 /// arrives built and the row places it.
164 + #[staged]
203 165 shape cards(page: &Page) -> Slot;
204 166
205 167 region CARDS_REGION as Group {
206 168 list {
207 - for export in DIRECT {
169 + for export in copy "content/export-portal.toml" as direct {
208 170 row export.title {
209 171 secondary export.description;
210 172 meta export.meta;
@@ -223,6 +185,7 @@
223 185
224 186 declare! {
225 187 /// The asynchronous one. See the module header for why it targets a region.
188 + #[staged]
226 189 shape content_row(size: &str) -> Row;
227 190
228 191 row "Content Files" {
@@ -275,22 +238,48 @@
275 238 );
276 239 }
277 240
241 + /// The five direct exports, read the way the macro reads them.
242 + ///
243 + /// `policy`'s rule: the file is asked what the page should say rather than
244 + /// a second copy of it being kept here. It was a `const DIRECT` in this
245 + /// module and these tests iterated it, which checked the page against the
246 + /// same array the page was built from; now they check it against the file.
247 + fn direct() -> Vec<toml::Table> {
248 + let copy: toml::Table = include_str!("../../content/export-portal.toml")
249 + .parse()
250 + .expect("the export copy is TOML");
251 +
252 + copy["direct"]
253 + .as_array()
254 + .expect("a list of direct exports")
255 + .iter()
256 + .map(|export| export.as_table().expect("a table").clone())
257 + .collect()
258 + }
259 +
260 + /// What a card says it is, out of the copy.
261 + fn says(export: &toml::Table, key: &str) -> String {
262 + export[key].as_str().expect("a string").to_owned()
263 + }
264 +
278 265 /// Each direct export says where it reads from and what the file is called,
279 266 /// and nothing says it twice.
280 267 #[test]
281 268 fn every_direct_export_names_its_route_and_its_filename() {
282 269 let html = html(true);
283 270
284 - for export in DIRECT {
271 + assert_eq!(direct().len(), 5, "five exports answer with a file");
272 + for export in direct() {
273 + let route = says(&export, "route");
274 + let filename = says(&export, "filename");
275 +
285 276 assert!(
286 - html.contains(&format!(r#"hx-post="{}""#, export.route)),
287 - "{} missing from {html}",
288 - export.route
277 + html.contains(&format!(r#"hx-post="{route}""#)),
278 + "{route} missing from {html}"
289 279 );
290 280 assert!(
291 - html.contains(&format!(r#"data-saves="{}""#, export.filename)),
292 - "{} missing from {html}",
293 - export.filename
281 + html.contains(&format!(r#"data-saves="{filename}""#)),
282 + "{filename} missing from {html}"
294 283 );
295 284 }
296 285 }
@@ -301,11 +290,11 @@
301 290 fn the_saved_filenames_are_the_ones_the_endpoints_set() {
302 291 let api = include_str!("../routes/api/exports/mod.rs");
303 292
304 - for export in DIRECT {
293 + for export in direct() {
294 + let filename = says(&export, "filename");
305 295 assert!(
306 - api.contains(export.filename),
307 - "{} is not a filename `routes::api::exports` sets",
308 - export.filename
296 + api.contains(&filename),
297 + "{filename} is not a filename `routes::api::exports` sets"
309 298 );
310 299 }
311 300 }
@@ -587,11 +587,18 @@
587 587 ),
588 588 (
589 589 export_portal::PATH,
590 - document_mount(
590 + served_gated_mount(
591 591 app,
592 592 export_portal::PATH,
593 - export_portal::screen,
594 593 export_portal::renderer,
594 + |viewer, _| {
595 + let page = export_portal::reading(viewer)?;
596 + Ok(Served {
597 + screen: export_portal::page_screen(&page),
598 + markup: export_portal::page_region_serve(&residuals::EXPORT_PORTAL, &page)
599 + .into(),
600 + })
601 + },
595 602 ),
596 603 ),
597 604 ]
@@ -723,11 +730,21 @@
723 730 ),
724 731 (
725 732 collections::PATH,
726 - public_document_mount(
733 + served_document_mount(
727 734 app,
728 735 collections::PATH,
729 - collections::screen,
730 736 collections::renderer,
737 + |viewer, carried| {
738 + // The address's captures and the row they resolve to, read
739 + // once. `Carried` carries them because a screen on the seam
740 + // never reaches the adapter and so has no `Request`.
741 + let loaded = collections::reading(viewer, carried)?;
742 + Ok(Served {
743 + screen: collections::page_screen(&loaded),
744 + markup: collections::page_region_serve(&residuals::COLLECTIONS, &loaded)
745 + .into(),
746 + })
747 + },
731 748 ),
732 749 ),
733 750 // The git browse tree carries a per-IP cap on every read, and a
@@ -857,24 +874,56 @@
857 874 renderer: fn(&Viewer) -> quasi_webview::Webview,
858 875 answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>,
859 876 ) -> axum::Router {
860 - let app = app.clone();
861 - axum::Router::new().route(
877 + served(app, path, Audience::Anyone, renderer, answer)
878 + }
879 +
880 + /// A gated document whose regions are already written, mounted.
881 + ///
882 + /// [`served_document_mount`] with the two things [`public_document_mount`]
883 + /// removes put back: the factory is built on [`Audience::Reader`], so a
884 + /// signed-out request is refused rather than answered, and [`signed_in`] runs in
885 + /// front so that refusal is the branded 401 with its way back in rather than a
886 + /// bare 403. The relationship is exactly [`document_mount`]'s to
887 + /// [`public_document_mount`], and it is written twice for the same reason those
888 + /// are: what a mount refuses is what a test can press it for.
889 + fn served_gated_mount(
890 + app: &AppState,
891 + path: &'static str,
892 + renderer: fn(&Viewer) -> quasi_webview::Webview,
893 + answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>,
894 + ) -> axum::Router {
895 + served(app, path, Audience::Reader, renderer, answer)
896 + }
897 +
898 + /// The seam's mount, under either audience.
899 + fn served(
900 + app: &AppState,
901 + path: &'static str,
902 + audience: Audience,
903 + renderer: fn(&Viewer) -> quasi_webview::Webview,
904 + answer: fn(&Viewer, &Carried) -> Result<Served, quasi_router::RouteError>,
905 + ) -> axum::Router {
906 + let gated = matches!(audience, Audience::Reader);
907 + let held = app.clone();
908 + let router = axum::Router::new().route(
862 909 path,
863 - axum::routing::get(move |parts: axum::http::request::Parts| {
864 - let app = app.clone();
910 + axum::routing::get(move |mut parts: axum::http::request::Parts| {
911 + let app = held.clone();
865 912 async move {
866 913 use axum::response::IntoResponse as _;
867 914
868 - // `Audience::Anyone` never refuses a reader for being signed
869 - // out, so the error arm here is the session layer or the CSRF
870 - // store failing, which is this server being broken rather than
871 - // this page being unreachable. Same answer the adapter gives
872 - // every other screen for the same failure.
873 - let Ok(viewer) = viewer_factory(app.clone(), Audience::Anyone)(&parts).await else {
915 + // Under `Audience::Anyone` a reader is never refused for being
916 + // signed out, so the error arm there is the session layer or
917 + // the CSRF store failing, which is this server being broken
918 + // rather than this page being unreachable. Under
919 + // `Audience::Reader` the gate in front has already refused a
920 + // visitor, so it is the same failure. Same answer the adapter
921 + // gives every other screen for it.
922 + let Ok(viewer) = viewer_factory(app.clone(), audience)(&parts).await else {
874 923 return axum::http::StatusCode::INTERNAL_SERVER_ERROR.into_response();
875 924 };
876 925
877 - let carried = Carried::of(&parts);
926 + let carried = Carried::of(&mut parts).await;
878 927 match answer(&viewer, &carried) {
879 928 Ok(Served { screen, markup }) => {
880 929 axum::response::Html(renderer(&viewer).served(&screen, &markup))
@@ -889,7 +938,13 @@
889 938 }
890 939 }
891 940 }),
892 - )
941 + );
942 +
943 + if gated {
944 + router.layer(axum::middleware::from_fn_with_state(app.clone(), signed_in))
945 + } else {
946 + router
947 + }
893 948 }
894 949
895 950 /// What a screen on the seam answers with: its document, and the markup to draw
@@ -913,28 +968,81 @@
913 968 /// there is no `Request` to read. This is that much of one: the query, parsed
914 969 /// the same way, and nothing else. Kept to what a served document actually
915 970 /// needs rather than growing into a second `Request` nobody asked for.
916 - pub struct Carried(std::collections::HashMap<String, String>);
971 + pub struct Carried {
972 + /// The query the reader arrived with.
973 + query: std::collections::HashMap<String, String>,
974 + /// What the address pattern caught, for a screen at a parameterised
975 + /// address.
976 + captures: std::collections::HashMap<String, String>,
977 + }
917 978
918 979 impl Carried {
919 - /// The query the reader arrived with.
920 - fn of(parts: &axum::http::request::Parts) -> Self {
921 - Self(
922 - parts
923 - .uri
924 - .query()
925 - .map(|query| {
926 - url::form_urlencoded::parse(query.as_bytes())
927 - .map(|(key, value)| (key.into_owned(), value.into_owned()))
928 - .collect()
929 - })
930 - .unwrap_or_default(),
931 - )
980 + /// What this request carried, out of the parts the adapter never sees.
981 + ///
982 + /// # The captures are axum's, and they are percent-decoded
983 + ///
984 + /// A screen on the seam answers no `Screen`, so its address is matched by
985 + /// the axum route this mount registers rather than by
986 + /// `quasi_router::Router`. The two agree about which requests match and
987 + /// differ in one thing: axum decodes `%2f`-style escapes in a capture and
988 + /// quasi hands the raw segment over. So `/c/ada/a%2Db` reaches the seam as
989 + /// slug `a-b` and reaches the described twin as `a%2Db`.
990 + ///
991 + /// Stated rather than reconciled. The difference is a widening toward the
992 + /// spelling RFC 3986 says the address means, it lands on the same row, and
993 + /// the alternative is a second path matcher in this crate.
994 + async fn of(parts: &mut axum::http::request::Parts) -> Self {
995 + use axum::extract::FromRequestParts as _;
996 +
997 + let query = parts
998 + .uri
999 + .query()
1000 + .map(|query| {
1001 + url::form_urlencoded::parse(query.as_bytes())
1002 + .map(|(key, value)| (key.into_owned(), value.into_owned()))
1003 + .collect()
1004 + })
1005 + .unwrap_or_default();
1006 +
1007 + // An address with no captures in it has no `UrlParams` extension at
1008 + // all, which is a refusal here and an empty map rather than a failure:
1009 + // `/policy` carries none and asks for none.
1010 + let captures = axum::extract::RawPathParams::from_request_parts(parts, &())
1011 + .await
1012 + .map(|params| {
1013 + params
1014 + .iter()
1015 + .map(|(key, value)| (key.to_owned(), value.to_owned()))
1016 + .collect()
1017 + })
1018 + .unwrap_or_default();
1019 +
1020 + Self { query, captures }
932 1021 }
933 1022
934 1023 /// Whether the address carried `key` set to exactly `value`.
935 1024 #[must_use]
936 1025 pub fn says(&self, key: &str, value: &str) -> bool {
937 - self.0.get(key).is_some_and(|held| held.trim() == value)
1026 + self.query.get(key).is_some_and(|held| held.trim() == value)
1027 + }
1028 +
1029 + /// What the query carried under `key`, if anything.
1030 + #[must_use]
1031 + pub fn asked(&self, key: &str) -> Option<&str> {
1032 + self.query.get(key).map(String::as_str)
1033 + }
1034 +
1035 + /// What the address pattern caught under `name`.
1036 + ///
1037 + /// Fallible with the refusal a missing capture deserves rather than an
1038 + /// `Option`: a capture the pattern declares is always present, so absence
1039 + /// is this mount being registered at an address that does not name it, and
1040 + /// every caller would write the same `ok_or_else`.
1041 + pub fn capture(&self, name: &str) -> Result<&str, quasi_router::RouteError> {
1042 + self.captures
1043 + .get(name)
1044 + .map(String::as_str)
1045 + .ok_or_else(|| quasi_router::RouteError::not_found("no such page"))
938 1046 }
939 1047 }
940 1048
@@ -1193,8 +1301,16 @@
1193 1301 .split_once("\n}")
1194 1302 .expect("document_mounts ends")
1195 1303 .0;
1304 + // Two mount styles, and both are gated documents, for the same
1305 + // reason `every_public_document_is_listed_in_public_document_paths`
1306 + // counts two: a screen on the residual seam has no `Screen` to answer
1307 + // with, so it takes [`served_gated_mount`] rather than the adapter.
1308 + // Counting only the first would let a screen leave `DOCUMENT_PATHS`
1309 + // unnoticed by moving onto the seam.
1310 + let adapted = mounted.matches("document_mount(").count();
1311 + let served = mounted.matches("served_gated_mount(").count();
1196 1312 assert_eq!(
1197 - mounted.matches("document_mount(").count(),
1313 + adapted + served,
1198 1314 DOCUMENT_PATHS.len(),
1199 1315 "document_mounts and DOCUMENT_PATHS describe the same screens"
1200 1316 );
@@ -72,6 +72,12 @@
72 72 ("FAN_PLUS", |plan| {
73 73 Node::Region(super::fan_plus::page_region_staged(plan))
74 74 }),
75 + ("COLLECTIONS", |plan| {
76 + Node::Region(super::collections::page_region_staged(plan))
77 + }),
78 + ("EXPORT_PORTAL", |plan| {
79 + Node::Region(super::export_portal::page_region_staged(plan))
80 + }),
75 81 ]
76 82 }
77 83
@@ -387,6 +393,133 @@
387 393 }
388 394 }
389 395
396 + /// A looping screen's residual fills to what the renderer builds.
397 + ///
398 + /// `/c/{username}/{slug}` is the first residual on the seam that carries a
399 + /// **loop**: its items table is one compiled row body walked once per item,
400 + /// rather than a row's markup rendered per item. Filled at three lengths so
401 + /// a residual that had baked one request's row count into its literals
402 + /// fails: nought exercises the empty state, one the body once, and three
403 + /// the body repeated.
404 + ///
405 + /// Both visibilities as well, because the private banner is a branch and an
406 + /// equality on the public page alone would pass against a residual that had
407 + /// lost it.
408 + #[test]
409 + fn the_collections_residual_fills_to_what_the_renderer_builds() {
410 + use quasi_axum::Serves as _;
411 +
412 + for items in [0, 1, 3] {
413 + for is_public in [true, false] {
414 + let loaded = crate::quasi::collections::sample(items, is_public);
415 + let filled = crate::quasi::collections::page_region_serve(&COLLECTIONS, &loaded);
416 + let rendered = Webview::new().fragment(&Node::Region(
417 + crate::quasi::collections::page_region(&loaded),
418 + ));
419 +
420 + assert_eq!(filled, rendered, "items={items} is_public={is_public}");
421 + }
422 + }
423 + }
424 +
425 + /// The row body is compiled once, not once per item the derivation saw.
426 + ///
427 + /// What says the loop is a loop. A residual derived from a one-row render
428 + /// and stored flat would still fill correctly at one item and lose rows at
429 + /// three, which the equality above catches; this catches the other half,
430 + /// that the table's markup is in the residual at all rather than being
431 + /// rebuilt per request.
432 + #[test]
433 + fn the_collections_residual_holds_its_row_body_once() {
434 + use quasi_router::stage::Op;
435 +
436 + fn loops(ops: &[Op]) -> usize {
437 + ops.iter()
438 + .map(|op| match op {
439 + Op::Loop(body) => 1 + loops(body),
440 + Op::Branch(body) => loops(body),
441 + _ => 0,
442 + })
443 + .sum()
444 + }
445 +
446 + assert_eq!(loops(COLLECTIONS.ops()), 1, "one loop, over the items");
447 + assert!(
448 + format!("{:?}", COLLECTIONS.ops()).contains("Creator"),
449 + "the table's headings are compiled, not built per request",
450 + );
451 + }
452 +
453 + /// The export portal's residual fills to what the renderer builds.
454 + ///
455 + /// The first gated document on the seam, and the one whose copy moved out
456 + /// of a `const` in the same pass: the five direct cards are read at macro
457 + /// time and fold into literals, and what is left varying is whether this
458 + /// reader has files at all. Both answers, and two size lines under the
459 + /// yes, since the size is a hole and filling with one value would pass
460 + /// against a residual that had baked it in.
461 + #[test]
462 + fn the_export_portal_residual_fills_to_what_the_renderer_builds() {
463 + use crate::quasi::export_portal::Page;
464 + use quasi_axum::Serves as _;
465 +
466 + let pages = [
467 + Page {
468 + has_content: false,
469 + content_size: "No files".to_owned(),
470 + },
471 + Page {
472 + has_content: true,
473 + content_size: "1.4 GB + audio/cover files".to_owned(),
474 + },
475 + Page {
476 + has_content: true,
477 + content_size: "17 KB".to_owned(),
478 + },
479 + ];
480 +
481 + for page in &pages {
482 + let filled = crate::quasi::export_portal::page_region_serve(&EXPORT_PORTAL, page);
483 + let rendered = Webview::new().fragment(&Node::Region(
484 + crate::quasi::export_portal::page_region(page),
485 + ));
486 +
487 + assert_eq!(filled, rendered, "has_content={}", page.has_content);
488 + }
489 + }
490 +
491 + /// The five direct exports are compiled, not rebuilt per request.
492 + ///
493 + /// What says the copy move landed. Each card's route is in the residual's
494 + /// literals; a loop appearing here would mean the cards went back to being
495 + /// read through a path the macro cannot evaluate.
496 + #[test]
497 + fn the_export_portal_residual_holds_its_five_cards() {
498 + use quasi_router::stage::Op;
499 +
500 + let compiled = format!("{:?}", EXPORT_PORTAL.ops());
501 + for route in [
502 + "/api/export/projects",
503 + "/api/export/sales",
504 + "/api/export/splits",
505 + "/api/export/purchases",
506 + "/api/export/followers",
507 + ] {
508 + assert!(compiled.contains(route), "{route} is not in the residual");
509 + }
510 +
511 + fn loops(ops: &[Op]) -> usize {
512 + ops.iter()
513 + .map(|op| match op {
514 + Op::Loop(body) => 1 + loops(body),
515 + Op::Branch(body) => loops(body),
516 + _ => 0,
517 + })
518 + .sum()
519 + }
520 + assert_eq!(loops(EXPORT_PORTAL.ops()), 0, "the cards are unrolled");
521 + }
522 +
390 523 /// Every screen the roster names is checked above.
391 524 ///
392 525 /// The gap this closes is the one a table opens: a screen added to
@@ -400,8 +533,9 @@
400 533 /// the two counts have to be kept by hand or not at all.
401 534 #[test]
402 535 fn every_screen_on_the_seam_is_checked() {
403 - /// Screens with their own filling test: `/use-cases`, `/fan-plus`.
404 - const HOLED: usize = 2;
536 + /// Screens with their own filling test: `/use-cases`, `/fan-plus`,
537 + /// `/c/{username}/{slug}`, `/dashboard/export`.
538 + const HOLED: usize = 4;
405 539
406 540 assert_eq!(
407 541 roster().len(),
@@ -48,35 +48,107 @@
48 48
49 49 pub static FAN_PLUS: ::quasi_router::stage::Residual =
50 50 ::quasi_router::stage::Residual::compiled(&[
51 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"fan-plus\" class=\"region pane\"><h1 class=\"heading\">Fan+</h1><")),
51 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"fan-plus\" class=\"region pane\"><h1 class=\"heading\">Fan+</h1>")),
52 52 ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
53 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("div class=\"banner\" data-tone=\"success\" role=\"status\" aria-live=\"polite\">You&#39;re now a Fan+ member. Welcome.</div><")),
53 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"banner\" data-tone=\"success\" role=\"status\" aria-live=\"polite\">You&#39;re now a Fan+ member. Welcome.</div>")),
54 54
55 55 ])),
56 56 ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
57 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("section id=\"fan-plus-membership\" class=\"region group\"><p class=\"text\">Your Fan+ membership is active.</p><p class=\"text\">")),
57 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<section id=\"fan-plus-membership\" class=\"region group\"><p class=\"text\">Your Fan+ membership is active.</p>")),
58 58 ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
59 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("Current period ends: ")),
59 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">Current period ends: ")),
60 60 ::quasi_router::stage::Op::Hole { scope: 1, id: 0 },
61 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</p><p class=\"text\">")),
61 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</p>")),
62 62
63 63 ])),
64 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("You&#39;ll receive a $5 credit code each billing cycle via email.</p><div class=\"anchored\" id=\"fan-plus-membership-anchored\" data-menu=\"anchored\" hidden></div></section><")),
64 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">You&#39;ll receive a $5 credit code each billing cycle via email.</p><div class=\"anchored\" id=\"fan-plus-membership-anchored\" data-menu=\"anchored\" hidden></div></section>")),
65 65
66 66 ])),
67 67 ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
68 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("section id=\"fan-plus-pitch\" class=\"region group\"><p class=\"text\">Support the platform and get something back every month.</p><h2 class=\"heading\">What you get</h2><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">$5 monthly credit</span><span class=\"row-secondary\">A promo code delivered by email each billing cycle, usable toward any purchase on the platform</span></li><li class=\"row\" data-row><span class=\"row-primary\">+ badge</span><span class=\"row-secondary\">Displayed next to your name on your forum posts</span></li><li class=\"row\" data-row><span class=\"row-primary\">Forum signatures</span><span class=\"row-secondary\">A signature block rendered under everything you post</span></li><li class=\"row\" data-row><span class=\"row-primary\">Image embeds</span><span class=\"row-secondary\">Post images in forum threads</span></li></ul><div class=\"figures\"><div class=\"figure\" aria-label=\"per month: $8\"><span class=\"figure-value\" aria-hidden=\"true\">$8</span><span class=\"figure-caption\" aria-hidden=\"true\">per month</span></div></div><p class=\"text\">Makenotwork is built on 0% platform fees. Fan+ is how you directly support the platform&#39;s development and operations, while getting real value back each month.</p><div class=\"anchored\" id=\"fan-plus-pitch-anchored\" data-menu=\"anchored\" hidden></div></section><")),
68 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<section id=\"fan-plus-pitch\" class=\"region group\"><p class=\"text\">Support the platform and get something back every month.</p><h2 class=\"heading\">What you get</h2><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">$5 monthly credit</span><span class=\"row-secondary\">A promo code delivered by email each billing cycle, usable toward any purchase on the platform</span></li><li class=\"row\" data-row><span class=\"row-primary\">+ badge</span><span class=\"row-secondary\">Displayed next to your name on your forum posts</span></li><li class=\"row\" data-row><span class=\"row-primary\">Forum signatures</span><span class=\"row-secondary\">A signature block rendered under everything you post</span></li><li class=\"row\" data-row><span class=\"row-primary\">Image embeds</span><span class=\"row-secondary\">Post images in forum threads</span></li></ul><div class=\"figures\"><div class=\"figure\" aria-label=\"per month: $8\"><span class=\"figure-value\" aria-hidden=\"true\">$8</span><span class=\"figure-caption\" aria-hidden=\"true\">per month</span></div></div><p class=\"text\">Makenotwork is built on 0% platform fees. Fan+ is how you directly support the platform&#39;s development and operations, while getting real value back each month.</p><div class=\"anchored\" id=\"fan-plus-pitch-anchored\" data-menu=\"anchored\" hidden></div></section>")),
69 69
70 70 ])),
71 71 ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
72 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("button type=\"button\" class=\"button\" data-act hx-post=\"/stripe/fan-plus\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Join Fan+</button><")),
72 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<button type=\"button\" class=\"button\" data-act hx-post=\"/stripe/fan-plus\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Join Fan+</button>")),
73 73
74 74 ])),
75 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("div class=\"")),
76 75 ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
77 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("rich\" data-disable-scripting><p><a href=\"/join\" rel=\"noopener noreferrer\">Create an account</a> to join, or <a href=\"/login\" rel=\"noopener noreferrer\">log in</a> if you already have one.</p>\n</div><div class=\"")),
76 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"rich\" data-disable-scripting><p><a href=\"/join\" rel=\"noopener noreferrer\">Create an account</a> to join, or <a href=\"/login\" rel=\"noopener noreferrer\">log in</a> if you already have one.</p>\n</div>")),
78 77
79 78 ])),
80 - ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("anchored\" id=\"fan-plus-anchored\" data-menu=\"anchored\" hidden></div></div>")),
79 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"fan-plus-anchored\" data-menu=\"anchored\" hidden></div></div>")),
80 + ]);
81 +
82 + pub static COLLECTIONS: ::quasi_router::stage::Residual =
83 + ::quasi_router::stage::Residual::compiled(&[
84 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"collection\" class=\"region pane\"><h1 class=\"heading\">")),
85 + ::quasi_router::stage::Op::Hole { scope: 0, id: 0 },
86 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</h1><a class=\"link\" data-act href=\"/u/")),
87 + ::quasi_router::stage::Op::Hole { scope: 0, id: 2 },
88 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">by ")),
89 + ::quasi_router::stage::Op::Hole { scope: 0, id: 1 },
90 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</a>")),
91 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
92 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"banner\" data-tone=\"info\" role=\"status\" aria-live=\"polite\">This collection is private. Only you can see it.</div>")),
93 +
94 + ])),
95 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
96 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">")),
97 + ::quasi_router::stage::Op::Hole { scope: 0, id: 3 },
98 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</p>")),
99 +
100 + ])),
101 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<p class=\"text\">")),
102 + ::quasi_router::stage::Op::Hole { scope: 0, id: 4 },
103 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(" items</p>")),
104 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
105 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"placeholder\" data-state=\"empty\" role=\"status\" aria-live=\"polite\"><p class=\"placeholder-text\">This collection is empty.</p></div>")),
106 +
107 + ])),
108 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
109 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"table\" class=\"table\"><div role=\"row\" class=\"table-head\"><span role=\"columnheader\" class=\"table-heading col-Item cell-fill cell-keeps\">Item</span><span role=\"columnheader\" class=\"table-heading col-Type cell-content cell-drops-next\">Type</span><span role=\"columnheader\" class=\"table-heading col-Creator cell-content cell-drops-next\">Creator</span><span role=\"columnheader\" class=\"table-heading col-Project cell-content cell-drops-next\">Project</span><span role=\"columnheader\" class=\"table-heading col-Price cell-content cell-drops-next\">Price</span></div>")),
110 + ::quasi_router::stage::Op::Loop(::std::borrow::Cow::Borrowed(&[
111 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div role=\"row\" class=\"table-row\" data-row href=\"/i/")),
112 + ::quasi_router::stage::Op::Hole { scope: 1, id: 5 },
113 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\"><div class=\"cell col-Item cell-fill cell-keeps cell-value\">")),
114 + ::quasi_router::stage::Op::Hole { scope: 1, id: 0 },
115 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Type cell-content cell-drops-next cell-value\">")),
116 + ::quasi_router::stage::Op::Hole { scope: 1, id: 1 },
117 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Creator cell-content cell-drops-next cell-value\">")),
118 + ::quasi_router::stage::Op::Hole { scope: 1, id: 2 },
119 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Project cell-content cell-drops-next cell-value\">")),
120 + ::quasi_router::stage::Op::Hole { scope: 1, id: 3 },
121 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div><div class=\"cell col-Price cell-content cell-drops-next cell-value\">")),
122 + ::quasi_router::stage::Op::Hole { scope: 1, id: 4 },
123 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div></div>")),
124 +
125 + ])),
126 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</div>")),
127 +
128 + ])),
129 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<a class=\"button\" data-act href=\"/u/")),
130 + ::quasi_router::stage::Op::Hole { scope: 0, id: 5 },
131 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">View profile</a><button type=\"button\" class=\"button\" data-act data-local data-copies=\"/c/")),
132 + ::quasi_router::stage::Op::Hole { scope: 0, id: 6 },
133 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("/")),
134 + ::quasi_router::stage::Op::Hole { scope: 0, id: 7 },
135 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("\">Copy link</button><div class=\"anchored\" id=\"collection-anchored\" data-menu=\"anchored\" hidden></div></div>")),
136 + ]);
137 +
138 + pub static EXPORT_PORTAL: ::quasi_router::stage::Residual =
139 + ::quasi_router::stage::Residual::compiled(&[
140 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div id=\"export\" class=\"region pane\"><a class=\"link\" data-act href=\"/dashboard\">Back to Dashboard</a><h1 class=\"heading\">Export Your Data</h1><p class=\"text\">Download your content, projects, and transaction history.</p><section id=\"export-cards\" class=\"region group\"><ul class=\"list\"><li class=\"row\" data-row><span class=\"row-primary\">Projects &amp; Items</span><span class=\"row-secondary\">All your project and item metadata including titles, descriptions, prices, and tags.</span><span class=\"row-meta\">JSON format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-projects.json\" hx-post=\"/api/export/projects\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li><li class=\"row\" data-row><span class=\"row-primary\">Sales History</span><span class=\"row-secondary\">Record of all sales you&#39;ve made, including dates, amounts, and item titles.</span><span class=\"row-meta\">CSV format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-sales.csv\" hx-post=\"/api/export/sales\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li><li class=\"row\" data-row><span class=\"row-primary\">Collaborator Payouts</span><span class=\"row-secondary\">Record of all revenue shared with collaborators on your projects, both incoming and outgoing.</span><span class=\"row-meta\">CSV format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-splits.csv\" hx-post=\"/api/export/splits\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li><li class=\"row\" data-row><span class=\"row-primary\">Purchase History</span><span class=\"row-secondary\">Record of all items you&#39;ve purchased, for your personal records.</span><span class=\"row-meta\">CSV format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-purchases.csv\" hx-post=\"/api/export/purchases\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li><li class=\"row\" data-row><span class=\"row-primary\">Followers &amp; Members</span><span class=\"row-secondary\">List of users who follow you or have memberships to your projects.</span><span class=\"row-meta\">CSV format</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act data-saves=\"makenot-work-followers.csv\" hx-post=\"/api/export/followers\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li>")),
141 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
142 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<li class=\"row\" data-row><span class=\"row-primary\">Content Files</span><span class=\"row-secondary\">All your uploaded audio files, cover images, version downloads, and dynamic clips.</span><span class=\"row-meta\">ZIP archive (")),
143 + ::quasi_router::stage::Op::Hole { scope: 32, id: 0 },
144 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed(")</span><span class=\"row-actions\"><button type=\"button\" class=\"button\" data-act hx-target=\"#content-status\" hx-post=\"/api/export/content\" hx-swap=\"outerMorph\" hx-disable=\"this\" data-awaiting=\"indeterminate\">Download</button></span></li>")),
145 +
146 + ])),
147 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("</ul>")),
148 + ::quasi_router::stage::Op::Branch(::std::borrow::Cow::Borrowed(&[
149 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<section id=\"content-status\" class=\"region group\"><div class=\"anchored\" id=\"content-status-anchored\" data-menu=\"anchored\" hidden></div></section>")),
150 +
151 + ])),
152 + ::quasi_router::stage::Op::Lit(::std::borrow::Cow::Borrowed("<div class=\"anchored\" id=\"export-cards-anchored\" data-menu=\"anchored\" hidden></div></section><h2 class=\"heading\">About Your Data</h2><p class=\"text\">Your data belongs to you. These exports contain everything we store about your account and content. If you&#39;re planning to delete your account, we recommend downloading your data first.</p><div class=\"anchored\" id=\"export-anchored\" data-menu=\"anchored\" hidden></div></div>")),
81 153 ]);
82 154
@@ -1,0 +1,49 @@
1 + # The copy at `/dashboard/export`, which is words and routes and nothing else.
2 + #
3 + # Read at build time by the `declare!` in `src/quasi/export_portal.rs`. Editing
4 + # this file is editing the page. See `content/policy.toml` for the split this
5 + # follows.
6 + #
7 + # `filename` is what the reader's disk ends up with, and it is the name the
8 + # endpoint itself sets in `Content-Disposition` rather than a shorter one
9 + # invented here: a `data-saves` control renames the download, so the two
10 + # disagreeing means the same export arrives under two names depending on which
11 + # control started it.
12 + #
13 + # The order is the order the cards render in, which is the order the shipped
14 + # template listed them.
15 +
16 + [[direct]]
17 + title = "Projects & Items"
18 + description = "All your project and item metadata including titles, descriptions, prices, and tags."
19 + meta = "JSON format"
20 + route = "/api/export/projects"
21 + filename = "makenot-work-projects.json"
22 +
23 + [[direct]]
24 + title = "Sales History"
25 + description = "Record of all sales you've made, including dates, amounts, and item titles."
26 + meta = "CSV format"
27 + route = "/api/export/sales"
28 + filename = "makenot-work-sales.csv"
29 +
30 + [[direct]]
31 + title = "Collaborator Payouts"
32 + description = "Record of all revenue shared with collaborators on your projects, both incoming and outgoing."
33 + meta = "CSV format"
34 + route = "/api/export/splits"
35 + filename = "makenot-work-splits.csv"
36 +
37 + [[direct]]
38 + title = "Purchase History"
39 + description = "Record of all items you've purchased, for your personal records."
40 + meta = "CSV format"
41 + route = "/api/export/purchases"
42 + filename = "makenot-work-purchases.csv"
43 +
44 + [[direct]]
45 + title = "Followers & Members"
46 + description = "List of users who follow you or have memberships to your projects."
47 + meta = "CSV format"
48 + route = "/api/export/followers"
49 + filename = "makenot-work-followers.csv"