max / quasi
5 files changed,
+400 insertions,
-37 deletions
| @@ -32,6 +32,18 @@ | |||
| 32 | 32 | "alloc-no-stdlib", | |
| 33 | 33 | ] | |
| 34 | 34 | ||
| 35 | + | [[package]] | |
| 36 | + | name = "ammonia" | |
| 37 | + | version = "4.1.4" | |
| 38 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 39 | + | checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d" | |
| 40 | + | dependencies = [ | |
| 41 | + | "cssparser 0.37.0", | |
| 42 | + | "html5ever 0.39.0", | |
| 43 | + | "maplit", | |
| 44 | + | "url", | |
| 45 | + | ] | |
| 46 | + | ||
| 35 | 47 | [[package]] | |
| 36 | 48 | name = "android_system_properties" | |
| 37 | 49 | version = "0.1.6" | |
| @@ -480,6 +492,17 @@ | |||
| 480 | 492 | "smallvec", | |
| 481 | 493 | ] | |
| 482 | 494 | ||
| 495 | + | [[package]] | |
| 496 | + | name = "cssparser" | |
| 497 | + | version = "0.37.0" | |
| 498 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 499 | + | checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" | |
| 500 | + | dependencies = [ | |
| 501 | + | "dtoa-short", | |
| 502 | + | "itoa", | |
| 503 | + | "smallvec", | |
| 504 | + | ] | |
| 505 | + | ||
| 483 | 506 | [[package]] | |
| 484 | 507 | name = "cssparser-macros" | |
| 485 | 508 | version = "0.6.1" | |
| @@ -659,6 +682,15 @@ | |||
| 659 | 682 | "syn 2.0.119", | |
| 660 | 683 | ] | |
| 661 | 684 | ||
| 685 | + | [[package]] | |
| 686 | + | name = "docengine" | |
| 687 | + | version = "0.4.0" | |
| 688 | + | dependencies = [ | |
| 689 | + | "ammonia", | |
| 690 | + | "pulldown-cmark", | |
| 691 | + | "serde", | |
| 692 | + | ] | |
| 693 | + | ||
| 662 | 694 | [[package]] | |
| 663 | 695 | name = "dom_query" | |
| 664 | 696 | version = "0.27.0" | |
| @@ -666,9 +698,9 @@ | |||
| 666 | 698 | checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" | |
| 667 | 699 | dependencies = [ | |
| 668 | 700 | "bit-set", | |
| 669 | - | "cssparser", | |
| 701 | + | "cssparser 0.36.0", | |
| 670 | 702 | "foldhash", | |
| 671 | - | "html5ever", | |
| 703 | + | "html5ever 0.38.0", | |
| 672 | 704 | "precomputed-hash", | |
| 673 | 705 | "selectors", | |
| 674 | 706 | "tendril", | |
| @@ -1031,6 +1063,15 @@ | |||
| 1031 | 1063 | "version_check", | |
| 1032 | 1064 | ] | |
| 1033 | 1065 | ||
| 1066 | + | [[package]] | |
| 1067 | + | name = "getopts" | |
| 1068 | + | version = "0.2.24" | |
| 1069 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1070 | + | checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" | |
| 1071 | + | dependencies = [ | |
| 1072 | + | "unicode-width", | |
| 1073 | + | ] | |
| 1074 | + | ||
| 1034 | 1075 | [[package]] | |
| 1035 | 1076 | name = "getrandom" | |
| 1036 | 1077 | version = "0.2.17" | |
| @@ -1250,7 +1291,17 @@ | |||
| 1250 | 1291 | checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" | |
| 1251 | 1292 | dependencies = [ | |
| 1252 | 1293 | "log", | |
| 1253 | - | "markup5ever", | |
| 1294 | + | "markup5ever 0.38.0", | |
| 1295 | + | ] | |
| 1296 | + | ||
| 1297 | + | [[package]] | |
| 1298 | + | name = "html5ever" | |
| 1299 | + | version = "0.39.0" | |
| 1300 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1301 | + | checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" | |
| 1302 | + | dependencies = [ | |
| 1303 | + | "log", | |
| 1304 | + | "markup5ever 0.39.0", | |
| 1254 | 1305 | ] | |
| 1255 | 1306 | ||
| 1256 | 1307 | [[package]] | |
| @@ -1751,6 +1802,12 @@ | |||
| 1751 | 1802 | "makeover-touch", | |
| 1752 | 1803 | ] | |
| 1753 | 1804 | ||
| 1805 | + | [[package]] | |
| 1806 | + | name = "maplit" | |
| 1807 | + | version = "1.0.2" | |
| 1808 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1809 | + | checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" | |
| 1810 | + | ||
| 1754 | 1811 | [[package]] | |
| 1755 | 1812 | name = "markup5ever" | |
| 1756 | 1813 | version = "0.38.0" | |
| @@ -1762,6 +1819,17 @@ | |||
| 1762 | 1819 | "web_atoms", | |
| 1763 | 1820 | ] | |
| 1764 | 1821 | ||
| 1822 | + | [[package]] | |
| 1823 | + | name = "markup5ever" | |
| 1824 | + | version = "0.39.0" | |
| 1825 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1826 | + | checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" | |
| 1827 | + | dependencies = [ | |
| 1828 | + | "log", | |
| 1829 | + | "tendril", | |
| 1830 | + | "web_atoms", | |
| 1831 | + | ] | |
| 1832 | + | ||
| 1765 | 1833 | [[package]] | |
| 1766 | 1834 | name = "matchit" | |
| 1767 | 1835 | version = "0.8.4" | |
| @@ -2337,6 +2405,25 @@ | |||
| 2337 | 2405 | "unicode-ident", | |
| 2338 | 2406 | ] | |
| 2339 | 2407 | ||
| 2408 | + | [[package]] | |
| 2409 | + | name = "pulldown-cmark" | |
| 2410 | + | version = "0.13.4" | |
| 2411 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2412 | + | checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" | |
| 2413 | + | dependencies = [ | |
| 2414 | + | "bitflags 2.13.1", | |
| 2415 | + | "getopts", | |
| 2416 | + | "memchr", | |
| 2417 | + | "pulldown-cmark-escape", | |
| 2418 | + | "unicase", | |
| 2419 | + | ] | |
| 2420 | + | ||
| 2421 | + | [[package]] | |
| 2422 | + | name = "pulldown-cmark-escape" | |
| 2423 | + | version = "0.11.0" | |
| 2424 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2425 | + | checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" | |
| 2426 | + | ||
| 2340 | 2427 | [[package]] | |
| 2341 | 2428 | name = "quasi" | |
| 2342 | 2429 | version = "0.0.0" | |
| @@ -2386,6 +2473,7 @@ | |||
| 2386 | 2473 | name = "quasi-webview" | |
| 2387 | 2474 | version = "0.1.0" | |
| 2388 | 2475 | dependencies = [ | |
| 2476 | + | "docengine", | |
| 2389 | 2477 | "makeover-layout", | |
| 2390 | 2478 | "makeover-webview", | |
| 2391 | 2479 | "quasi-http", | |
| @@ -2631,7 +2719,7 @@ | |||
| 2631 | 2719 | checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" | |
| 2632 | 2720 | dependencies = [ | |
| 2633 | 2721 | "bitflags 2.13.1", | |
| 2634 | - | "cssparser", | |
| 2722 | + | "cssparser 0.36.0", | |
| 2635 | 2723 | "derive_more", | |
| 2636 | 2724 | "log", | |
| 2637 | 2725 | "new_debug_unreachable", | |
| @@ -3697,6 +3785,12 @@ | |||
| 3697 | 3785 | "unic-common", | |
| 3698 | 3786 | ] | |
| 3699 | 3787 | ||
| 3788 | + | [[package]] | |
| 3789 | + | name = "unicase" | |
| 3790 | + | version = "2.9.0" | |
| 3791 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3792 | + | checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" | |
| 3793 | + | ||
| 3700 | 3794 | [[package]] | |
| 3701 | 3795 | name = "unicode-ident" | |
| 3702 | 3796 | version = "1.0.24" | |
| @@ -3709,6 +3803,12 @@ | |||
| 3709 | 3803 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3710 | 3804 | checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" | |
| 3711 | 3805 | ||
| 3806 | + | [[package]] | |
| 3807 | + | name = "unicode-width" | |
| 3808 | + | version = "0.2.2" | |
| 3809 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3810 | + | checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" | |
| 3811 | + | ||
| 3712 | 3812 | [[package]] | |
| 3713 | 3813 | name = "url" | |
| 3714 | 3814 | version = "2.5.8" | |
| @@ -4530,6 +4630,14 @@ | |||
| 4530 | 4630 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4531 | 4631 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 4532 | 4632 | ||
| 4633 | + | [[patch.unused]] | |
| 4634 | + | name = "synckit-client" | |
| 4635 | + | version = "0.8.0" | |
| 4636 | + | ||
| 4637 | + | [[patch.unused]] | |
| 4638 | + | name = "synckit-config" | |
| 4639 | + | version = "0.2.0" | |
| 4640 | + | ||
| 4533 | 4641 | [[patch.unused]] | |
| 4534 | 4642 | name = "kberg" | |
| 4535 | 4643 | version = "0.1.0" | |
| @@ -4541,15 +4649,3 @@ | |||
| 4541 | 4649 | [[patch.unused]] | |
| 4542 | 4650 | name = "tagtree" | |
| 4543 | 4651 | version = "0.4.0" | |
| 4544 | - | ||
| 4545 | - | [[patch.unused]] | |
| 4546 | - | name = "docengine" | |
| 4547 | - | version = "0.4.0" | |
| 4548 | - | ||
| 4549 | - | [[patch.unused]] | |
| 4550 | - | name = "synckit-client" | |
| 4551 | - | version = "0.8.0" | |
| 4552 | - | ||
| 4553 | - | [[patch.unused]] | |
| 4554 | - | name = "synckit-config" | |
| 4555 | - | version = "0.2.0" |
| @@ -17,3 +17,15 @@ | |||
| 17 | 17 | quasi-http = { path = "../quasi-http", version = "0.1.0" } | |
| 18 | 18 | makeover-layout = "0.10.0" | |
| 19 | 19 | makeover-webview = "0.19.0" | |
| 20 | + | # `Node::Rich` carries markdown source and this is what turns it into markup. | |
| 21 | + | # Optional and default-on: a renderer that cannot render the member makes the | |
| 22 | + | # member do nothing, and a consumer with no rich text should not pay for a | |
| 23 | + | # markdown parser. Sanitising comes with it, which is why the node can carry | |
| 24 | + | # what a user typed. | |
| 25 | + | docengine = { git = "https://makenot.work/git/max/docengine.git", version = "0.4.0", optional = true } | |
| 26 | + | ||
| 27 | + | [features] | |
| 28 | + | default = ["rich"] | |
| 29 | + | # Render `Node::Rich`. Without it the node emits its source as text, which is | |
| 30 | + | # what every renderer did before the member existed. | |
| 31 | + | rich = ["dep:docengine"] |
| @@ -315,7 +315,9 @@ | |||
| 315 | 315 | /// the buffer never went anywhere, so nothing is lost and there is nothing to | |
| 316 | 316 | /// re-offer. This is the layer where the loss happens, so this is the layer that | |
| 317 | 317 | /// repairs it. | |
| 318 | - | #[derive(Debug, Clone, PartialEq, Eq, Hash)] | |
| 318 | + | /// No `Hash`, for the reason [`Tag`] has none: it can hold an [`Action`], which | |
| 319 | + | /// holds [`Params`], which is a `Vec`. | |
| 320 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 319 | 321 | pub struct Field { | |
| 320 | 322 | /// What kind of value it takes. | |
| 321 | 323 | pub kind: layout::FieldKind, | |
| @@ -348,6 +350,20 @@ | |||
| 348 | 350 | /// to set it and every renderer refuses to emit it, so the guarantee does | |
| 349 | 351 | /// not rest on either alone. | |
| 350 | 352 | pub value: Option<String>, | |
| 353 | + | /// What changing this calls, for a control that writes on its own rather | |
| 354 | + | /// than waiting for a submit. | |
| 355 | + | /// | |
| 356 | + | /// `14612ed8`. A field inside a [`Node::Form`] submits with the form and | |
| 357 | + | /// needs nothing here. A settings toggle is the other kind: there is no | |
| 358 | + | /// submit, and changing the control *is* the write. goingson had 13 of these | |
| 359 | + | /// and reached them through `dispatch.js`, 109 lines of its own event | |
| 360 | + | /// plumbing, because nothing in the description could say it. No version of | |
| 361 | + | /// spinning up an app quickly has each app hand-rolling a dispatcher. | |
| 362 | + | /// | |
| 363 | + | /// The route receives the value under this field's [`name`](Self::name), | |
| 364 | + | /// which is the same name a submit would have sent it under. Nothing else | |
| 365 | + | /// changes about the field. | |
| 366 | + | pub changes: Option<Action>, | |
| 351 | 367 | } | |
| 352 | 368 | ||
| 353 | 369 | impl Field { | |
| @@ -364,9 +380,17 @@ | |||
| 364 | 380 | required: false, | |
| 365 | 381 | extended: false, | |
| 366 | 382 | value: None, | |
| 383 | + | changes: None, | |
| 367 | 384 | } | |
| 368 | 385 | } | |
| 369 | 386 | ||
| 387 | + | /// Changing this writes, without waiting for a submit. | |
| 388 | + | #[must_use] | |
| 389 | + | pub fn changes(mut self, action: Action) -> Self { | |
| 390 | + | self.changes = Some(action); | |
| 391 | + | self | |
| 392 | + | } | |
| 393 | + | ||
| 370 | 394 | /// A select offering the given options. | |
| 371 | 395 | pub fn select(name: impl Into<String>, label: impl Into<String>, options: Vec<Choice>) -> Self { | |
| 372 | 396 | Self { | |
| @@ -746,6 +770,23 @@ | |||
| 746 | 770 | /// [`current`](Self::current). goingson's contacts and tasks screens both | |
| 747 | 771 | /// drive bulk actions from it. | |
| 748 | 772 | pub selected: Option<bool>, | |
| 773 | + | /// What ticking this row calls, if ticking it is the write. | |
| 774 | + | /// | |
| 775 | + | /// `14612ed8`, part of it. [`selected`](Self::selected) says whether the row | |
| 776 | + | /// is ticked and whether it can be, and that was the whole story for a bulk | |
| 777 | + | /// checkbox, whose tick is client state feeding a later action. A checklist | |
| 778 | + | /// is the other case: the tick *is* the write, and it is the only affordance | |
| 779 | + | /// the screen offers for it. Described without this, the port drew the tick | |
| 780 | + | /// inert and put the toggle on a button beside it, which is a user clicking | |
| 781 | + | /// a button next to a checkbox that ignores clicks. | |
| 782 | + | /// | |
| 783 | + | /// Two fields rather than a `Selection` struct, matching how | |
| 784 | + | /// [`activate`](Self::activate) sits beside [`current`](Self::current): | |
| 785 | + | /// state and behaviour are separate facts about the row. They do have to | |
| 786 | + | /// agree — a `toggle` with no [`selected`](Self::selected) is a route on a | |
| 787 | + | /// control nothing draws — and [`Row::toggling`] is the constructor that | |
| 788 | + | /// makes them agree. | |
| 789 | + | pub toggle: Option<Action>, | |
| 749 | 790 | } | |
| 750 | 791 | ||
| 751 | 792 | impl Row { | |
| @@ -757,6 +798,19 @@ | |||
| 757 | 798 | } | |
| 758 | 799 | } | |
| 759 | 800 | ||
| 801 | + | /// A tick that is the write, in the state it is currently in. | |
| 802 | + | /// | |
| 803 | + | /// Sets [`selected`](Self::selected) and [`toggle`](Self::toggle) together, | |
| 804 | + | /// because a route on a tick nothing draws is the one way the two fields can | |
| 805 | + | /// disagree. A checklist item is what this is for; a bulk checkbox sets | |
| 806 | + | /// `selected` alone and keeps its meaning as client state. | |
| 807 | + | #[must_use] | |
| 808 | + | pub fn toggling(mut self, ticked: bool, action: Action) -> Self { | |
| 809 | + | self.selected = Some(ticked); | |
| 810 | + | self.toggle = Some(action); | |
| 811 | + | self | |
| 812 | + | } | |
| 813 | + | ||
| 760 | 814 | /// Supporting text under the primary. | |
| 761 | 815 | #[must_use] | |
| 762 | 816 | pub fn secondary(mut self, text: impl Into<String>) -> Self { | |
| @@ -871,6 +925,28 @@ | |||
| 871 | 925 | /// What it is saying. [`layout::Tone::Neutral`] is ordinary content. | |
| 872 | 926 | tone: layout::Tone, | |
| 873 | 927 | }, | |
| 928 | + | /// Prose the author wrote in markdown. | |
| 929 | + | /// | |
| 930 | + | /// `25822137`, decided 2026-08-09. What is carried is the **source**, never | |
| 931 | + | /// markup, which is the property that lets this exist at all. Every renderer | |
| 932 | + | /// has an honest answer because each renders the source its own way: a | |
| 933 | + | /// webview through a markdown-to-HTML pass, a terminal through | |
| 934 | + | /// markdown-to-ANSI, egui through its own. A `Node::Html` would have handed | |
| 935 | + | /// every one of them a string it could not honour, and would have broken | |
| 936 | + | /// [`Node::Text`]'s escaping guarantee for every consumer rather than the | |
| 937 | + | /// one that asked. That refusal stands; this is not it. | |
| 938 | + | /// | |
| 939 | + | /// Sanitising is the renderer's, at the point markup is produced, for the | |
| 940 | + | /// reason escaping already is: this holds text a user typed, and a | |
| 941 | + | /// description that sanitised would be deciding what a host can draw. | |
| 942 | + | /// | |
| 943 | + | /// Not available inside a [`Row`]: a row part holds no node, by the | |
| 944 | + | /// 2026-08-08 ruling, and that is why the projects card keeps its raw | |
| 945 | + | /// markdown in `secondary` rather than gaining this. | |
| 946 | + | Rich { | |
| 947 | + | /// The markdown, as written. | |
| 948 | + | source: String, | |
| 949 | + | }, | |
| 874 | 950 | /// A control that calls a route. | |
| 875 | 951 | Act(Act), | |
| 876 | 952 | /// A small labelled thing sitting inside something else. | |
| @@ -884,6 +960,17 @@ | |||
| 884 | 960 | /// The message. | |
| 885 | 961 | text: String, | |
| 886 | 962 | }, | |
| 963 | + | /// One control, standing on its own. | |
| 964 | + | /// | |
| 965 | + | /// `14612ed8`. A [`Form`](Self::Form) is a set of questions asked together | |
| 966 | + | /// and answered at once. A settings screen is not that: goingson's is | |
| 967 | + | /// sections with headings between them, each holding one control that writes | |
| 968 | + | /// as soon as it changes, and wrapping those in a form would describe markup | |
| 969 | + | /// that is not there and a submit that does not exist. | |
| 970 | + | /// | |
| 971 | + | /// Almost always carries a [`Field::changes`], because a control with no | |
| 972 | + | /// form around it and no route on it collects a value nothing reads. | |
| 973 | + | Field(Field), | |
| 887 | 974 | /// Fields, and the route that submits them. | |
| 888 | 975 | Form { | |
| 889 | 976 | /// Where the answers go. Almost always a [`Method::Post`]. | |
| @@ -982,6 +1069,13 @@ | |||
| 982 | 1069 | } | |
| 983 | 1070 | } | |
| 984 | 1071 | ||
| 1072 | + | /// Prose written in markdown. | |
| 1073 | + | pub fn rich(source: impl Into<String>) -> Self { | |
| 1074 | + | Self::Rich { | |
| 1075 | + | source: source.into(), | |
| 1076 | + | } | |
| 1077 | + | } | |
| 1078 | + | ||
| 985 | 1079 | /// A control calling a route. | |
| 986 | 1080 | pub fn act(label: impl Into<String>, action: Action) -> Self { | |
| 987 | 1081 | Self::Act(Act::new(label, action)) |
| @@ -83,6 +83,61 @@ | |||
| 83 | 83 | } | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | + | /// One field, with whatever `1c4a66a4` and `14612ed8` added around it. | |
| 87 | + | /// | |
| 88 | + | /// The field's own markup is makeover-webview's, unchanged. A second field | |
| 89 | + | /// emitter here is the divergence phase A existed to end, and it would be the | |
| 90 | + | /// same anatomy with a different escaping story. | |
| 91 | + | /// | |
| 92 | + | /// A [`Field::changes`] is a wrapper rather than attributes on the control, | |
| 93 | + | /// because the control is emitted by makeover-webview and there is no seam to | |
| 94 | + | /// put them through. That turns out to be the better shape anyway: the `change` | |
| 95 | + | /// event bubbles, so one element around the group catches it whichever of the | |
| 96 | + | /// input, select or textarea forms the field took, and `hx-include` finds the | |
| 97 | + | /// control back without this having to know which it was. | |
| 98 | + | fn field_group_html(field: &Field, morphs: bool, opts: &Emit, out: &mut String) { | |
| 99 | + | let filling = Filling::of(refill(field)); | |
| 100 | + | let writes = field.changes.as_ref(); | |
| 101 | + | ||
| 102 | + | if let Some(action) = writes { | |
| 103 | + | out.push_str("<div"); | |
| 104 | + | class_attr(&["field-writes"], opts, out); | |
| 105 | + | action_attrs(action, Fires::ChangeInside, morphs, out); | |
| 106 | + | out.push('>'); | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | field.with_layout(|borrowed| { | |
| 110 | + | out.push_str(&field_html(&borrowed, &filling, opts)); | |
| 111 | + | }); | |
| 112 | + | ||
| 113 | + | if writes.is_some() { | |
| 114 | + | out.push_str("</div>"); | |
| 115 | + | } | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// Markdown source into markup, for [`Node::Rich`]. | |
| 119 | + | /// | |
| 120 | + | /// The strict preset, which is a deliberate difference from the | |
| 121 | + | /// `render_standard` goingson's own JS calls: standard lets sanitised raw HTML | |
| 122 | + | /// through, and a shared renderer taking text a user typed should be the safer | |
| 123 | + | /// of the two by default. What it costs is angle brackets in a description | |
| 124 | + | /// rendering as text rather than as markup, which is the outcome | |
| 125 | + | /// [`Node::Text`] would have given anyway. | |
| 126 | + | /// | |
| 127 | + | /// Without the `rich` feature the source is escaped and emitted as it was | |
| 128 | + | /// written. That is lossy and visible rather than silent, and it is what the | |
| 129 | + | /// node did before it could be rendered at all. | |
| 130 | + | fn rich_html(source: &str) -> String { | |
| 131 | + | #[cfg(feature = "rich")] | |
| 132 | + | { | |
| 133 | + | docengine::render_strict(source) | |
| 134 | + | } | |
| 135 | + | #[cfg(not(feature = "rich"))] | |
| 136 | + | { | |
| 137 | + | escape(source) | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 86 | 141 | /// JSON-encode a string, for an `hx-vals` payload. | |
| 87 | 142 | /// | |
| 88 | 143 | /// Small enough to own. Pulling in a JSON crate to write object literals of | |
| @@ -123,6 +178,27 @@ | |||
| 123 | 178 | json | |
| 124 | 179 | } | |
| 125 | 180 | ||
| 181 | + | /// What makes a control call its action. | |
| 182 | + | /// | |
| 183 | + | /// Here rather than at the call sites because every `hx-` attribute this crate | |
| 184 | + | /// emits has to come out of one function, which is decision 13's claim that the | |
| 185 | + | /// transport is replaceable and is asserted by a test. | |
| 186 | + | #[derive(Debug, Clone, Copy, PartialEq, Eq)] | |
| 187 | + | pub(crate) enum Fires { | |
| 188 | + | /// The user activating the control. htmx's default for a button or a link. | |
| 189 | + | Click, | |
| 190 | + | /// The control's own value changing. What a checkbox that is itself the | |
| 191 | + | /// write does. | |
| 192 | + | Change, | |
| 193 | + | /// The value of a control *inside* this element changing. | |
| 194 | + | /// | |
| 195 | + | /// A field group, whose control is emitted by makeover-webview and has no | |
| 196 | + | /// seam to hang attributes on. The `change` event bubbles, so the wrapper | |
| 197 | + | /// catches it whichever of input, select or textarea the field turned out to | |
| 198 | + | /// be, and the value is found back rather than assumed. | |
| 199 | + | ChangeInside, | |
| 200 | + | } | |
| 201 | + | ||
| 126 | 202 | /// The transport attributes for one action. | |
| 127 | 203 | /// | |
| 128 | 204 | /// `hx-vals` rather than a hand-built query string, for both methods. htmx | |
| @@ -130,7 +206,7 @@ | |||
| 130 | 206 | /// one code path covers both, and no part of this stack concatenates a `?`. | |
| 131 | 207 | /// That is where escaping bugs live, and the description layer's own reason for | |
| 132 | 208 | /// carrying [`Action::params`] as values rather than as text. | |
| 133 | - | pub(crate) fn action_attrs(action: &Action, morphs: bool, out: &mut String) { | |
| 209 | + | pub(crate) fn action_attrs(action: &Action, fires: Fires, morphs: bool, out: &mut String) { | |
| 134 | 210 | // An external destination is not htmx's business: nothing swaps, no route | |
| 135 | 211 | // is called, and the browser follows a normal link. `rel` rather than | |
| 136 | 212 | // trust: a new tab with `window.opener` left intact hands the other page a | |
| @@ -156,6 +232,17 @@ | |||
| 156 | 232 | out.push('"'); | |
| 157 | 233 | } | |
| 158 | 234 | ||
| 235 | + | // Named even where it matches htmx's own default for the element, so the | |
| 236 | + | // markup says what it does rather than resting on a default holding. | |
| 237 | + | match fires { | |
| 238 | + | Fires::Click => {} | |
| 239 | + | Fires::Change => out.push_str(" hx-trigger=\"change\""), | |
| 240 | + | Fires::ChangeInside => { | |
| 241 | + | out.push_str(" hx-trigger=\"change\""); | |
| 242 | + | out.push_str(" hx-include=\"find input, find select, find textarea\""); | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 159 | 246 | if morphs { | |
| 160 | 247 | // Decision 7's slack: a morph preserves focus, scroll and input state | |
| 161 | 248 | // through a swap, so a whole-Screen answer stops being destructive. | |
| @@ -211,13 +298,13 @@ | |||
| 211 | 298 | } | |
| 212 | 299 | Some(layout::State::Focus) => { | |
| 213 | 300 | out.push_str(" autofocus"); | |
| 214 | - | action_attrs(&act.action, morphs, out); | |
| 301 | + | action_attrs(&act.action, Fires::Click, morphs, out); | |
| 215 | 302 | } | |
| 216 | 303 | // `State` is `#[non_exhaustive]`, so a member added upstream lands | |
| 217 | 304 | // here. Emitting the transport is the right default for anything that | |
| 218 | 305 | // is not a suppression: a state this renderer has not learned yet | |
| 219 | 306 | // should leave the control working, not silently inert. | |
| 220 | - | _ => action_attrs(&act.action, morphs, out), | |
| 307 | + | _ => action_attrs(&act.action, Fires::Click, morphs, out), | |
| 221 | 308 | } | |
| 222 | 309 | ||
| 223 | 310 | out.push('>'); | |
| @@ -255,10 +342,14 @@ | |||
| 255 | 342 | if ticked { | |
| 256 | 343 | out.push_str(" checked"); | |
| 257 | 344 | } | |
| 258 | - | // What ticking it *does* is not describable here, and deliberately: it | |
| 259 | - | // is local state until something submits it, and the description layer | |
| 260 | - | // does not carry local state. The app binds this the way it binds any | |
| 261 | - | // other input, which for goingson is its delegated dispatcher. | |
| 345 | + | // A tick with no route is local state until something submits it, which | |
| 346 | + | // is what a bulk checkbox is; the app binds those itself. A tick with | |
| 347 | + | // one is the write, which is `14612ed8`, and htmx's own default trigger | |
| 348 | + | // for an input is `change` — named anyway, so the markup says what it | |
| 349 | + | // does rather than relying on a default holding. | |
| 350 | + | if let Some(action) = &row.toggle { | |
| 351 | + | action_attrs(action, Fires::Change, morphs, out); | |
| 352 | + | } | |
| 262 | 353 | out.push_str(" aria-label=\"Select\">"); | |
| 263 | 354 | } | |
| 264 | 355 | ||
| @@ -270,7 +361,7 @@ | |||
| 270 | 361 | let (open, close) = control_tag(action); | |
| 271 | 362 | out.push_str(open); | |
| 272 | 363 | class_attr(&["row-activate"], opts, out); | |
| 273 | - | action_attrs(action, morphs, out); | |
| 364 | + | action_attrs(action, Fires::Click, morphs, out); | |
| 274 | 365 | out.push('>'); | |
| 275 | 366 | out.push_str(&escape(&row.primary)); | |
| 276 | 367 | out.push_str(close); | |
| @@ -349,7 +440,7 @@ | |||
| 349 | 440 | out.push_str(" aria-current=\"true\""); | |
| 350 | 441 | } | |
| 351 | 442 | if let Some(action) = &cells.activate { | |
| 352 | - | action_attrs(action, morphs, out); | |
| 443 | + | action_attrs(action, Fires::Click, morphs, out); | |
| 353 | 444 | } | |
| 354 | 445 | out.push('>'); | |
| 355 | 446 | ||
| @@ -400,6 +491,14 @@ | |||
| 400 | 491 | out.push_str("</p>"); | |
| 401 | 492 | } | |
| 402 | 493 | ||
| 494 | + | Node::Rich { source } => { | |
| 495 | + | out.push_str("<div"); | |
| 496 | + | class_attr(&["rich"], opts, out); | |
| 497 | + | out.push('>'); | |
| 498 | + | out.push_str(&rich_html(source)); | |
| 499 | + | out.push_str("</div>"); | |
| 500 | + | } | |
| 501 | + | ||
| 403 | 502 | Node::Act(act) => act_html(act, morphs, opts, out), | |
| 404 | 503 | ||
| 405 | 504 | Node::Token(tag) => tag_html(tag, morphs, opts, out), | |
| @@ -428,6 +527,8 @@ | |||
| 428 | 527 | out.push_str("</div>"); | |
| 429 | 528 | } | |
| 430 | 529 | ||
| 530 | + | Node::Field(field) => field_group_html(field, morphs, opts, out), | |
| 531 | + | ||
| 431 | 532 | Node::Form { | |
| 432 | 533 | action, | |
| 433 | 534 | submit, | |
| @@ -435,17 +536,10 @@ | |||
| 435 | 536 | } => { | |
| 436 | 537 | out.push_str("<form"); | |
| 437 | 538 | class_attr(&["form"], opts, out); | |
| 438 | - | action_attrs(action, morphs, out); | |
| 539 | + | action_attrs(action, Fires::Click, morphs, out); | |
| 439 | 540 | out.push('>'); | |
| 440 | 541 | for field in fields { | |
| 441 | - | // The field's own markup is makeover-webview's, unchanged. A | |
| 442 | - | // second field emitter here is the divergence phase A existed | |
| 443 | - | // to end, and it would be the same anatomy with a different | |
| 444 | - | // escaping story. | |
| 445 | - | let filling = Filling::of(refill(field)); | |
| 446 | - | field.with_layout(|borrowed| { | |
| 447 | - | out.push_str(&field_html(&borrowed, &filling, opts)); | |
| 448 | - | }); | |
| 542 | + | field_group_html(field, morphs, opts, out); | |
| 449 | 543 | } | |
| 450 | 544 | out.push_str("<button type=\"submit\""); | |
| 451 | 545 | class_attr(&["act", "act-submit"], opts, out); | |
| @@ -579,7 +673,7 @@ | |||
| 579 | 673 | out.push_str(" aria-pressed=\"true\""); | |
| 580 | 674 | } | |
| 581 | 675 | if let Some(action) = action { | |
| 582 | - | action_attrs(action, morphs, out); | |
| 676 | + | action_attrs(action, Fires::Click, morphs, out); | |
| 583 | 677 | } | |
| 584 | 678 | } | |
| 585 | 679 | ||
| @@ -641,7 +735,7 @@ | |||
| 641 | 735 | // `Node::SELECTED`, rather than agreed per screen between a | |
| 642 | 736 | // renderer and a handler. | |
| 643 | 737 | let carrying = action.clone().with(Node::SELECTED, option.value.clone()); | |
| 644 | - | action_attrs(&carrying, morphs, out); | |
| 738 | + | action_attrs(&carrying, Fires::Click, morphs, out); | |
| 645 | 739 | } | |
| 646 | 740 | ||
| 647 | 741 | out.push('>'); |
| @@ -455,6 +455,73 @@ | |||
| 455 | 455 | assert!(html.contains("<label")); | |
| 456 | 456 | } | |
| 457 | 457 | ||
| 458 | + | #[test] | |
| 459 | + | fn a_control_that_writes_on_change_says_so_without_a_form_around_it() { | |
| 460 | + | // `14612ed8`. goingson reached 13 of these through `dispatch.js`, which | |
| 461 | + | // exists because the description could not say this. | |
| 462 | + | let html = fragment(&Node::Field( | |
| 463 | + | Field::select( | |
| 464 | + | "theme", | |
| 465 | + | "Theme", | |
| 466 | + | vec![Choice::new("dark", "Dark"), Choice::new("light", "Light")], | |
| 467 | + | ) | |
| 468 | + | .changes(Action::post("/settings/theme")), | |
| 469 | + | )); | |
| 470 | + | ||
| 471 | + | assert!(html.contains("hx-post=\"/settings/theme\"")); | |
| 472 | + | assert!(html.contains("hx-trigger=\"change\"")); | |
| 473 | + | // The value is found back rather than assumed, because the control could | |
| 474 | + | // have been any of three elements. | |
| 475 | + | assert!(html.contains("hx-include=")); | |
| 476 | + | assert!(html.contains("name=\"theme\"")); | |
| 477 | + | // No form and no submit: that is the whole difference from `Node::Form`. | |
| 478 | + | assert!(!html.contains("<form")); | |
| 479 | + | assert!(!html.contains("type=\"submit\"")); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | #[test] | |
| 483 | + | fn a_field_with_no_route_is_the_plain_group_it_always_was() { | |
| 484 | + | let html = fragment(&Node::Field(Field::new( | |
| 485 | + | layout::FieldKind::Text, | |
| 486 | + | "title", | |
| 487 | + | "Title", | |
| 488 | + | ))); | |
| 489 | + | assert!(html.contains("name=\"title\"")); | |
| 490 | + | assert!(!html.contains("hx-")); | |
| 491 | + | } | |
| 492 | + | ||
| 493 | + | #[test] | |
| 494 | + | fn a_tick_that_is_the_write_calls_a_route_and_a_tick_that_is_not_does_not() { | |
| 495 | + | // The two cases `Row::selected` could not tell apart. A bulk checkbox is | |
| 496 | + | // client state feeding a later action; a checklist item is the write. | |
| 497 | + | let checklist = fragment(&Node::list(vec![ | |
| 498 | + | Row::new("Buy milk").toggling(false, Action::post("/subtasks/1/toggle")), | |
| 499 | + | ])); | |
| 500 | + | assert!(checklist.contains("type=\"checkbox\"")); | |
| 501 | + | assert!(checklist.contains("hx-post=\"/subtasks/1/toggle\"")); | |
| 502 | + | assert!(checklist.contains("hx-trigger=\"change\"")); | |
| 503 | + | ||
| 504 | + | let mut bulk = Row::new("Ada Lovelace"); | |
| 505 | + | bulk.selected = Some(false); | |
| 506 | + | let bulk = fragment(&Node::list(vec![bulk])); | |
| 507 | + | assert!(bulk.contains("type=\"checkbox\"")); | |
| 508 | + | assert!(!bulk.contains("hx-")); | |
| 509 | + | } | |
| 510 | + | ||
| 511 | + | #[test] | |
| 512 | + | fn rich_text_is_rendered_from_source_and_still_cannot_smuggle_markup() { | |
| 513 | + | // `25822137`. The member is only defensible because what it carries is | |
| 514 | + | // source: the renderer decides what becomes markup, so a script tag in a | |
| 515 | + | // description is no more dangerous here than in `Node::Text`. | |
| 516 | + | let html = fragment(&Node::rich("A **bold** claim\n\n<script>alert(1)</script>")); | |
| 517 | + | ||
| 518 | + | assert!(html.contains("<strong>bold</strong>")); | |
| 519 | + | assert!(!html.contains("<script")); | |
| 520 | + | // The node names itself, so a stylesheet has something to hang typography | |
| 521 | + | // on without the app wrapping it in a region of its own. | |
| 522 | + | assert!(html.contains("class=\"rich\"")); | |
| 523 | + | } | |
| 524 | + | ||
| 458 | 525 | #[test] | |
| 459 | 526 | fn a_refused_form_comes_back_with_what_was_typed_in_it() { | |
| 460 | 527 | // `1c4a66a4`. The value the user lost is the whole point of the finding, so |