max / quasi
- Co-Authored-By
- Claude Opus 5 (1M context) <noreply@anthropic.com>
3 files changed,
+177 insertions,
-8 deletions
| @@ -4888,6 +4888,14 @@ | |||
| 4888 | 4888 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4889 | 4889 | checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" | |
| 4890 | 4890 | ||
| 4891 | + | [[patch.unused]] | |
| 4892 | + | name = "synckit-client" | |
| 4893 | + | version = "0.8.0" | |
| 4894 | + | ||
| 4895 | + | [[patch.unused]] | |
| 4896 | + | name = "synckit-config" | |
| 4897 | + | version = "0.2.0" | |
| 4898 | + | ||
| 4891 | 4899 | [[patch.unused]] | |
| 4892 | 4900 | name = "kberg" | |
| 4893 | 4901 | version = "0.1.0" | |
| @@ -4899,11 +4907,3 @@ | |||
| 4899 | 4907 | [[patch.unused]] | |
| 4900 | 4908 | name = "tagtree" | |
| 4901 | 4909 | version = "0.4.0" | |
| 4902 | - | ||
| 4903 | - | [[patch.unused]] | |
| 4904 | - | name = "synckit-client" | |
| 4905 | - | version = "0.8.0" | |
| 4906 | - | ||
| 4907 | - | [[patch.unused]] | |
| 4908 | - | name = "synckit-config" | |
| 4909 | - | version = "0.2.0" |
| @@ -38,6 +38,41 @@ | |||
| 38 | 38 | pub morph_src: Option<String>, | |
| 39 | 39 | /// Stylesheets, in link order. | |
| 40 | 40 | pub stylesheets: Vec<String>, | |
| 41 | + | /// The app's own cascade layer names, in priority order, lowest first. | |
| 42 | + | /// | |
| 43 | + | /// The renderer always emits the layer statement, with `makeover` first and | |
| 44 | + | /// these after it, before any stylesheet link: | |
| 45 | + | /// | |
| 46 | + | /// ```css | |
| 47 | + | /// @layer makeover, base, components, responsive; | |
| 48 | + | /// ``` | |
| 49 | + | /// | |
| 50 | + | /// It is emitted even when this is empty, because the point is not the | |
| 51 | + | /// app's names but `makeover`'s position. A layer's place in the cascade is | |
| 52 | + | /// fixed where its name is FIRST seen, so with no statement the generated | |
| 53 | + | /// stylesheets establish `makeover` simply by loading first, and reordering | |
| 54 | + | /// two link tags silently reorders the cascade. | |
| 55 | + | /// | |
| 56 | + | /// This is a field rather than something the host writes into | |
| 57 | + | /// [`head_first`](Self::head_first) because it is exactly the class of | |
| 58 | + | /// thing the module header says belongs here: a host that could get it | |
| 59 | + | /// wrong is a host that can diverge, and getting it wrong is silent. The | |
| 60 | + | /// CSS stays valid, the minifier stays happy, and buttons and badges look | |
| 61 | + | /// subtly wrong. Only the app's own names are the host's to supply, since | |
| 62 | + | /// the renderer cannot know them. | |
| 63 | + | /// | |
| 64 | + | /// Names are filtered to CSS identifier characters. A name is markup inside | |
| 65 | + | /// a `<style>` element, where HTML escaping does not apply, so a stray `<` | |
| 66 | + | /// would be a way out of the element rather than a character. | |
| 67 | + | pub app_layers: Vec<String>, | |
| 68 | + | /// Markup emitted near the top of the head verbatim, before the layer | |
| 69 | + | /// statement and every stylesheet. Not escaped. | |
| 70 | + | /// | |
| 71 | + | /// For the things whose whole value is being early: a font preload, a | |
| 72 | + | /// preconnect. [`head`](Self::head) is appended last and cannot serve them, | |
| 73 | + | /// and a preload discovered after the stylesheets it races is a preload | |
| 74 | + | /// that bought nothing. | |
| 75 | + | pub head_first: Option<String>, | |
| 41 | 76 | /// Markup appended to the head verbatim. Not escaped. | |
| 42 | 77 | /// | |
| 43 | 78 | /// The escape hatch for what no description will ever name: a favicon, a | |
| @@ -55,6 +90,8 @@ | |||
| 55 | 90 | htmx_src: "/static/htmx.min.js".into(), | |
| 56 | 91 | morph_src: Some("/static/idiomorph-ext.min.js".into()), | |
| 57 | 92 | stylesheets: Vec::new(), | |
| 93 | + | app_layers: Vec::new(), | |
| 94 | + | head_first: None, | |
| 58 | 95 | head: None, | |
| 59 | 96 | body_class: None, | |
| 60 | 97 | } | |
| @@ -83,6 +120,32 @@ | |||
| 83 | 120 | self | |
| 84 | 121 | } | |
| 85 | 122 | ||
| 123 | + | /// Name the app's cascade layers, in priority order, lowest first. | |
| 124 | + | /// | |
| 125 | + | /// `makeover` is not named here; the renderer puts it first on its own. | |
| 126 | + | #[must_use] | |
| 127 | + | pub fn layered<I, S>(mut self, names: I) -> Self | |
| 128 | + | where | |
| 129 | + | I: IntoIterator<Item = S>, | |
| 130 | + | S: Into<String>, | |
| 131 | + | { | |
| 132 | + | self.app_layers = names.into_iter().map(Into::into).collect(); | |
| 133 | + | self | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | /// Prepend markup to the head, chaining. Not escaped. | |
| 137 | + | /// | |
| 138 | + | /// Repeated calls append to each other, so the emitted order matches the | |
| 139 | + | /// call order, the way [`with_head`](Self::with_head) already does. | |
| 140 | + | #[must_use] | |
| 141 | + | pub fn with_head_first(mut self, markup: impl Into<String>) -> Self { | |
| 142 | + | self.head_first = Some(match self.head_first.take() { | |
| 143 | + | Some(existing) => format!("{existing}{}", markup.into()), | |
| 144 | + | None => markup.into(), | |
| 145 | + | }); | |
| 146 | + | self | |
| 147 | + | } | |
| 148 | + | ||
| 86 | 149 | /// Append markup to the head, chaining. Not escaped. | |
| 87 | 150 | #[must_use] | |
| 88 | 151 | pub fn with_head(mut self, markup: impl Into<String>) -> Self { | |
| @@ -126,6 +189,27 @@ | |||
| 126 | 189 | // `script-src` with no `unsafe-inline`. | |
| 127 | 190 | out.push_str(quasi_http::htmx::CONFIG_META); | |
| 128 | 191 | ||
| 192 | + | if let Some(first) = &self.head_first { | |
| 193 | + | out.push_str(first); | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Before every stylesheet, or the statement is not a statement. See | |
| 197 | + | // `app_layers`. | |
| 198 | + | out.push_str("<style>@layer "); | |
| 199 | + | out.push_str(makeover_webview::CSS_LAYER); | |
| 200 | + | for name in &self.app_layers { | |
| 201 | + | let name: String = name | |
| 202 | + | .chars() | |
| 203 | + | .filter(|c| c.is_ascii_alphanumeric() || *c == '_' || *c == '-') | |
| 204 | + | .collect(); | |
| 205 | + | if name.is_empty() { | |
| 206 | + | continue; | |
| 207 | + | } | |
| 208 | + | out.push_str(", "); | |
| 209 | + | out.push_str(&name); | |
| 210 | + | } | |
| 211 | + | out.push_str(";</style>"); | |
| 212 | + | ||
| 129 | 213 | for href in &self.stylesheets { | |
| 130 | 214 | out.push_str("<link rel=\"stylesheet\" href=\""); | |
| 131 | 215 | out.push_str(&escape(href)); |
| @@ -842,6 +842,91 @@ | |||
| 842 | 842 | assert!(icon < html.find("</head>").expect("the head closes")); | |
| 843 | 843 | } | |
| 844 | 844 | ||
| 845 | + | #[test] | |
| 846 | + | fn the_layer_statement_precedes_every_stylesheet() { | |
| 847 | + | // The whole point: a layer's position is fixed where its name is first | |
| 848 | + | // seen, so a statement after the links is not a statement. | |
| 849 | + | let shell = Shell::default() | |
| 850 | + | .layered(["base", "components", "responsive"]) | |
| 851 | + | .styled("/geometry.css") | |
| 852 | + | .styled("/style.css"); | |
| 853 | + | let html = Webview::new() | |
| 854 | + | .with_shell(shell) | |
| 855 | + | .screen(&Screen::list_detail("A", false)); | |
| 856 | + | ||
| 857 | + | let stmt = html | |
| 858 | + | .find("@layer makeover, base, components, responsive;") | |
| 859 | + | .expect("the order is stated"); | |
| 860 | + | let first_sheet = html.find("/geometry.css").expect("the sheet is linked"); | |
| 861 | + | assert!(stmt < first_sheet); | |
| 862 | + | } | |
| 863 | + | ||
| 864 | + | #[test] | |
| 865 | + | fn the_layer_statement_is_emitted_even_with_no_app_layers() { | |
| 866 | + | // An app that names no layers of its own still needs makeover pinned to the | |
| 867 | + | // bottom of the cascade, and that is the case where forgetting is easiest. | |
| 868 | + | let html = Webview::new().screen(&Screen::list_detail("A", false)); | |
| 869 | + | assert!(html.contains("@layer makeover;")); | |
| 870 | + | } | |
| 871 | + | ||
| 872 | + | #[test] | |
| 873 | + | fn a_layer_name_cannot_escape_the_style_element() { | |
| 874 | + | // HTML escaping does not apply inside <style>, so a `<` here would be a way | |
| 875 | + | // out of the element rather than a character in a name. | |
| 876 | + | let shell = Shell::default().layered(["base</style><script>alert(1)</script>"]); | |
| 877 | + | let html = Webview::new() | |
| 878 | + | .with_shell(shell) | |
| 879 | + | .screen(&Screen::list_detail("A", false)); | |
| 880 | + | ||
| 881 | + | assert!(!html.contains("<script>alert(1)")); | |
| 882 | + | assert!(html.contains("@layer makeover, basestylescriptalert1script;")); | |
| 883 | + | } | |
| 884 | + | ||
| 885 | + | #[test] | |
| 886 | + | fn head_first_markup_lands_before_the_layer_statement_and_the_sheets() { | |
| 887 | + | // A preload discovered after the stylesheets it races bought nothing. | |
| 888 | + | let shell = Shell::default() | |
| 889 | + | .with_head_first("<link rel=\"preload\" href=\"/f.woff2\" as=\"font\">") | |
| 890 | + | .styled("/style.css"); | |
| 891 | + | let html = Webview::new() | |
| 892 | + | .with_shell(shell) | |
| 893 | + | .screen(&Screen::list_detail("A", false)); | |
| 894 | + | ||
| 895 | + | let preload = html.find("/f.woff2").expect("the font is preloaded"); | |
| 896 | + | let stmt = html.find("@layer makeover").expect("the order is stated"); | |
| 897 | + | let sheet = html.find("/style.css").expect("the sheet is linked"); | |
| 898 | + | assert!(preload < stmt); | |
| 899 | + | assert!(stmt < sheet); | |
| 900 | + | } | |
| 901 | + | ||
| 902 | + | #[test] | |
| 903 | + | fn head_first_and_head_are_different_ends_of_the_same_head() { | |
| 904 | + | let shell = Shell::default() | |
| 905 | + | .with_head_first("<meta name=\"first\">") | |
| 906 | + | .with_head("<meta name=\"last\">"); | |
| 907 | + | let html = Webview::new() | |
| 908 | + | .with_shell(shell) | |
| 909 | + | .screen(&Screen::list_detail("A", false)); | |
| 910 | + | ||
| 911 | + | let first = html.find("name=\"first\"").expect("first is emitted"); | |
| 912 | + | let last = html.find("name=\"last\"").expect("last is emitted"); | |
| 913 | + | let htmx = html.find("htmx.min.js").expect("htmx is linked"); | |
| 914 | + | assert!(first < htmx); | |
| 915 | + | assert!(htmx < last); | |
| 916 | + | } | |
| 917 | + | ||
| 918 | + | #[test] | |
| 919 | + | fn repeated_head_first_calls_keep_their_call_order() { | |
| 920 | + | let shell = Shell::default() | |
| 921 | + | .with_head_first("<meta name=\"a\">") | |
| 922 | + | .with_head_first("<meta name=\"b\">"); | |
| 923 | + | let html = Webview::new() | |
| 924 | + | .with_shell(shell) | |
| 925 | + | .screen(&Screen::list_detail("A", false)); | |
| 926 | + | ||
| 927 | + | assert!(html.find("name=\"a\"") < html.find("name=\"b\"")); | |
| 928 | + | } | |
| 929 | + | ||
| 845 | 930 | #[test] | |
| 846 | 931 | fn a_nested_region_renders_inside_its_parent() { | |
| 847 | 932 | let screen = |