Skip to main content

max / quasi

A control can say its answer is a file
Author: Max Johnson <me@maxj.phd> · 2026-08-11 02:21 UTC
Signed with PGP, not checked
Commit: 995c9ab5b22daa5ffb9160c509839efe0d008df8
Parent: 53e4ccc
4 files changed, +109 insertions, -8 deletions
M Cargo.lock +8 -8
@@ -4894,14 +4894,6 @@
4894 4894 source = "registry+https://github.com/rust-lang/crates.io-index"
4895 4895 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
4896 4896
4897 - [[patch.unused]]
4898 - name = "synckit-client"
4899 - version = "0.8.0"
4900 -
4901 - [[patch.unused]]
4902 - name = "synckit-config"
4903 - version = "0.2.0"
4904 -
4905 4897 [[patch.unused]]
4906 4898 name = "kberg"
4907 4899 version = "0.1.0"
@@ -4913,3 +4905,11 @@
4913 4905 [[patch.unused]]
4914 4906 name = "tagtree"
4915 4907 version = "0.4.0"
4908 +
4909 + [[patch.unused]]
4910 + name = "synckit-client"
4911 + version = "0.8.0"
4912 +
4913 + [[patch.unused]]
4914 + name = "synckit-config"
4915 + version = "0.2.0"
@@ -148,6 +148,26 @@
148 148 /// belongs: the link is then the view, and a middle-click reaches the same
149 149 /// place the control does.
150 150 pub carried: Params,
151 + /// The name to keep the answer under, when the answer is a file.
152 + ///
153 + /// `Some` means the response is not a view: nothing swaps, and the reader
154 + /// ends up holding a file called this. A webview makes that a browser
155 + /// download; a terminal writes it to disk; either way the screen said what
156 + /// it meant rather than a class name on a button implying it.
157 + ///
158 + /// Counted before adding it. Nine sites in the MNW server: five CSV export
159 + /// buttons across four dashboard templates, a sixth in the item-sales tab's
160 + /// own script, and three anchors carrying a `download` attribute. Six of the
161 + /// nine are writes, which is what makes this a property of the action rather
162 + /// than a kind of destination: a write cannot be a plain link, so the host
163 + /// has to be told, and until now it was told by
164 + /// `data-action="exportCsvButton"` plus two positional arguments.
165 + ///
166 + /// Independent of [`method`](Self::method). A read that saves is an anchor
167 + /// the browser downloads instead of navigating to; a write that saves has to
168 + /// be performed and then handed to the reader. Both are the same sentence
169 + /// here and differ only in the emitting.
170 + pub saves: Option<String>,
151 171 }
152 172
153 173 impl Action {
@@ -158,6 +178,7 @@
158 178 destination: Destination::Route(path.into()),
159 179 params: Params::new(),
160 180 carried: Params::new(),
181 + saves: None,
161 182 }
162 183 }
163 184
@@ -168,6 +189,7 @@
168 189 destination: Destination::Route(path.into()),
169 190 params: Params::new(),
170 191 carried: Params::new(),
192 + saves: None,
171 193 }
172 194 }
173 195
@@ -183,6 +205,7 @@
183 205 destination: Destination::Route(path.into()),
184 206 params: Params::new(),
185 207 carried: Params::new(),
208 + saves: None,
186 209 }
187 210 }
188 211
@@ -193,6 +216,7 @@
193 216 destination: Destination::Route(path.into()),
194 217 params: Params::new(),
195 218 carried: Params::new(),
219 + saves: None,
196 220 }
197 221 }
198 222
@@ -206,9 +230,17 @@
206 230 destination: Destination::External(url.into()),
207 231 params: Params::new(),
208 232 carried: Params::new(),
233 + saves: None,
209 234 }
210 235 }
211 236
237 + /// Keep the answer as a file with this name, rather than showing it.
238 + #[must_use]
239 + pub fn saving(mut self, filename: impl Into<String>) -> Self {
240 + self.saves = Some(filename.into());
241 + self
242 + }
243 +
212 244 /// The route this calls, if it calls one.
213 245 #[must_use]
214 246 pub fn route(&self) -> Option<&str> {
@@ -300,6 +300,23 @@
300 300 out.push('"');
301 301 }
302 302
303 + // The answer is a file the reader keeps, not a view. On a link the browser
304 + // does the whole job from the attribute, so nothing else is needed and the
305 + // control still works with JS off. On a write it cannot: a response has to
306 + // be performed before it can be saved, so this is a named hook the host
307 + // acts on, in the same spirit as `data-act` and for the same reason it is an
308 + // attribute rather than a class. The host handles one attribute instead of
309 + // a per-button behaviour named by a class and two positional arguments.
310 + if let Some(filename) = &action.saves {
311 + if is_link(action) {
312 + out.push_str(" download=\"");
313 + } else {
314 + out.push_str(" data-saves=\"");
315 + }
316 + out.push_str(&escape(filename));
317 + out.push('"');
318 + }
319 +
303 320 let verb = match action.method {
304 321 Method::Get => " hx-get=\"",
305 322 Method::Post => " hx-post=\"",
@@ -759,6 +759,58 @@
759 759 assert!(!plain.contains("figure-change"), "{plain}");
760 760 }
761 761
762 + #[test]
763 + fn a_control_whose_answer_is_a_file_says_so() {
764 + // Nine sites in MNW: five CSV exports across four templates, a sixth in the
765 + // item-sales script, three anchors carrying `download`. Six are writes,
766 + // which is why this is a property of the action and not a kind of link.
767 + //
768 + // A read is the whole job: the browser saves it and the control still works
769 + // with JS off.
770 + let read = fragment(&Node::act(
771 + "Download LICENSE.txt",
772 + Action::get("/api/items/7/license.txt").saving("LICENSE.txt"),
773 + ));
774 + assert!(read.contains("download=\"LICENSE.txt\""), "{read}");
775 + assert!(
776 + read.contains("<a "),
777 + "a read that saves is still a link: {read}"
778 + );
779 +
780 + // A write cannot be a link, so the intent is a named hook the host acts on.
781 + let write = fragment(&Node::act(
782 + "Export CSV",
783 + Action::post("/api/export/contacts").saving("contacts.csv"),
784 + ));
785 + assert!(write.contains("data-saves=\"contacts.csv\""), "{write}");
786 + assert!(
787 + write.contains("hx-post=\"/api/export/contacts\""),
788 + "{write}"
789 + );
790 + assert!(
791 + !write.contains("download="),
792 + "a button is not a link: {write}"
793 + );
794 + }
795 +
796 + #[test]
797 + fn a_control_that_saves_nothing_says_nothing() {
798 + let plain = fragment(&Node::act("Export", Action::post("/api/export/contacts")));
799 + assert!(!plain.contains("data-saves"), "{plain}");
800 + assert!(!plain.contains("download="), "{plain}");
801 + }
802 +
803 + #[test]
804 + fn a_filename_cannot_break_out_of_its_attribute() {
805 + // A filename is chosen by the screen today, but it is a string in an
806 + // attribute and the escaping is not optional for that reason.
807 + let html = fragment(&Node::act(
808 + "Export",
809 + Action::post("/api/export").saving("\" onload=\"x()"),
810 + ));
811 + assert!(!html.contains("\" onload="), "{html}");
812 + }
813 +
762 814 #[test]
763 815 fn a_cell_value_that_is_a_link_is_the_link() {
764 816 // 35 cells across 18 of MNW's templates are a title that goes somewhere.