on master help sign in
Fix attribute bugs found with htmx, and name classes by their styles (#2)
5ea0c40iko
CONTRIBUTING.md+8−2
unifiedsplit
@@ -40,8 +40,11 @@ Linux, macOS, and Windows. Your change should pass all three.
40 Every CSS property goes through one generic path (its name, its value
40 Every CSS property goes through one generic path (its name, its value
41 as written, with `vocab::with_px`'s pixels); only htmlang's layout
41 as written, with `vocab::with_px`'s pixels); only htmlang's layout
42 words and `line-clamp` have code of their own. Generated classes are
42 words and `line-clamp` have code of their own. Generated classes are
43+ `hl-` + 7 base-36 digits of `stable_hash` of the style
44+ (`CLASS_DIGITS`), so a style has one name in every file, and
45+ `htmlang build` checks no name stands for two styles; an element's
43− `hl-` + a short name; an element's defaults (its layout,
46+ defaults (its layout,
44− `ElementKind::css`) go with its class, as `:where(.hl-a)`, never a
47+ `ElementKind::css`) go with its class, as `:where(.hl-2k9xq0m)`, never a
45 rule on a global element selector, so `@markdown`/`@raw` HTML and a
48 rule on a global element selector, so `@markdown`/`@raw` HTML and a
46 page embedding a fragment are untouched. The rules are in cascade
49 page embedding a fragment are untouched. The rules are in cascade
47 layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside`
50 layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside`
@@ -100,6 +103,9 @@ Linux, macOS, and Windows. Your change should pass all three.
100 in `docs.rs`.
103 in `docs.rs`.
101- `editors/vscode/` — VS Code extension.
104- `editors/vscode/` — VS Code extension.
102- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
105- `tests/snapshots.rs` — integration / snapshot tests for the compiler.
106+ These tests, and `tests/regressions.rs`, read the output with its
107+ hashed classes numbered (`hl-a`, `hl-b`, ..., see `tests/common`), so a
108+ snapshot changes when a style does, not when a hash does.
103- `tests/regressions.rs` — one test per fixed bug.
109- `tests/regressions.rs` — one test per fixed bug.
104- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
110- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
105 and every page in `examples/`: any diagnostic fails it.
111 and every page in `examples/`: any diagnostic fails it.
DESIGN.md+39−14
@@ -72,16 +72,27 @@ An attribute can take one of three forms:
72 (`spacing 20`, `color red`).
72 (`spacing 20`, `color red`).
73- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
73- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
74 `aria-label=Close`).
74 `aria-label=Close`).
75−- A bare word is a **flag**: a layout attribute such as `center-x`, or a
75+- A bare word is a **flag**: a layout attribute such as `center-x`, a
76− boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`.
76+ boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`,
77+ or a `data-` or `hx-` attribute that has no value (`data-open`,
78+ `hx-preserve`).
77
79
78A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
80A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
79`\,` keeps a comma in the value:
81`\,` keeps a comma in the value:
80
82
81```
83```
82@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif]
84@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif]
85+@el [hx-get=/news, hx-trigger=load\, every 60s]
83```
86```
84
87
88+Right after a `key=value`, a name htmlang doesn't know is most likely the
89+rest of that value, which a comma ended: `[hx-trigger=load, every 60s]` is
90+an error (`split-value`) that shows the value written with `\,`.
91+
92+An attribute given twice, by a bundle and after it, by a function and its
93+call, or written twice, takes the later value. An HTML attribute is
94+written once, with that value.
95+
85A variable fills an attribute's value (`padding $gap`, `alt=$title`), never
96A variable fills an attribute's value (`padding $gap`, `alt=$title`), never
86its name or a whole attribute: attributes come from a
97its name or a whole attribute: attributes come from a
87[bundle](#definitions), written `[$card]`, and a condition chooses whole
98[bundle](#definitions), written `[$card]`, and a condition chooses whole
@@ -154,7 +165,7 @@ values, `@let`, `@page` and `@meta`.
154| `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` |
165| `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` |
155| `\$` | `$` | a `$` before a name, which would start a variable |
166| `\$` | `$` | a `$` before a name, which would start a variable |
156| `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element |
167| `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element |
157−| `\[`, `\]` | `[`, `]` | brackets that don't open or close an attribute list |
168+| `\[`, `\]` | `[`, `]` | brackets that don't open or close an attribute list; a `'...'` in a value (`alert(']')`) doesn't hide one, so a list that seems to end inside one is reported (`unbalanced-quote`) |
158| `\,` | `,` | a comma in an attribute's value |
169| `\,` | `,` | a comma in an attribute's value |
159| `\"` | `"` | a quote that doesn't start or end quoted text |
170| `\"` | `"` | a quote that doesn't start or end quoted text |
160| `\\` | `\` | a backslash before one of these characters |
171| `\\` | `\` | a backslash before one of these characters |
@@ -723,7 +734,7 @@ own attributes are for it alone.
723
734
724Each style under an element prefix compiles to a rule in CSS's `@scope`,
735Each style under an element prefix compiles to a rule in CSS's `@scope`,
725on the element's class, in the layer `hl-inside` (see [CSS](#css)):
736on the element's class, in the layer `hl-inside` (see [CSS](#css)):
726−`@scope (.hl-a) { :scope td { padding: 8px } }`. `@scope`'s proximity
737+`@scope (.hl-2k9xq0m) { :scope td { padding: 8px } }`. `@scope`'s proximity
727rule is what makes the nearest element win. `@scope` is Baseline 2025,
738rule is what makes the nearest element win. `@scope` is Baseline 2025,
728supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
739supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
729browser ignores these rules, so the elements inside keep the styles they
740browser ignores these rules, so the elements inside keep the styles they
@@ -906,7 +917,10 @@ since it holds only the `@option` lines under it. A value with a space in
906it is quoted, as such a label: `@optgroup "Citrus fruits"`. The first word is
917it is quoted, as such a label: `@optgroup "Citrus fruits"`. The first word is
907always the argument, so on an element whose attribute is often left out
918always the argument, so on an element whose attribute is often left out
908(`@form`, `@video`, `@audio`), text goes on the lines under it: `@form Sign
919(`@form`, `@video`, `@audio`), text goes on the lines under it: `@form Sign
909−in` would make `Sign` the form's action.
920+in` would make `Sign` the form's action. A first word that reads as text,
921+a capitalized word with nothing of a URL in it, is taken with a warning
922+(`text-as-url`): `@link [hx-get=/panel] Open panel` makes `Open` the link's
923+`href`. A relative URL like that one is written `./Open`.
910
924
911The attribute can be written as an attribute instead, and it means the same:
925The attribute can be written as an attribute instead, and it means the same:
912`@link [href=/about]` with its text on the lines under it, or
926`@link [href=/about]` with its text on the lines under it, or
@@ -943,12 +957,13 @@ only the file. It isn't shown, so a style on it is an error.
943
957
944HTML attributes are written `key=value`: `id=main`, `class=note`,
958HTML attributes are written `key=value`: `id=main`, `class=note`,
945`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
959`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
946−`aria-label=Close menu`, `data-id=42`. Any name works, one with a colon
960+`aria-label=Close menu`, `data-id=42`, `hx-get=/items`. Any name works,
947−in it included (`xml:lang=en`, `x-on:click=open`): before an `=` a colon is
961+one with a colon in it included (`xml:lang=en`, `hx-on:click=open()`):
948−part of the name, not a prefix. Boolean attributes are
962+before an `=` a colon is part of the name, not a prefix. Boolean attributes
949−written bare: `required`, `disabled`, `checked`, `hidden`, `open`, `popover`.
963+are written bare: `required`, `disabled`, `checked`, `hidden`, `open`,
964+`popover`, and so are `data-` and `hx-` attributes that have no value
950−An HTML attribute written like a style (`type email`) is an error that
965+(`hx-preserve`). An HTML attribute written like a style (`type email`,
951−shows the `key=value` form. A style
966+`hx-get /items`) is an error that shows the `key=value` form. A style
952and an HTML attribute can share a name, because the `=` tells them apart:
967and an HTML attribute can share a name, because the `=` tells them apart:
953
968
954```
969```
@@ -1548,8 +1563,18 @@ for a single rule, on its line:
1548@el [animation fade-in 0.3s ease, class=note] Fades in
1563@el [animation fade-in 0.3s ease, class=note] Fades in
1549```
1564```
1550
1565
1551−Each element with styles gets a short generated class, `hl-a`, `hl-b`, ...,
1566+Each element with styles gets a generated class, and elements with the
1552−and elements with the same styles share one. The `hl-` prefix is htmlang's:
1567+same styles share one. A class is named by its style: `hl-` and seven
1568+characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one
1569+name in every file. A fragment put into a page (`--partial` output that a
1570+server returns, or any HTML of one file put into another's page) means
1571+the same there, and its rules restyle nothing of the page's: a class of
1572+both is the same style. Two different styles of one file never share a
1573+name (the later one takes more characters), and `htmlang build` checks
1574+the whole site: two files whose different styles hash to one name are an
1575+error (`class-collision`), which one more attribute on either style
1576+fixes. A function's scoped `@style` is named by the function and a hash
1577+of the stylesheet, `hl-fn-card-1x0a9zq`. The `hl-` prefix is htmlang's:
1553a class of your own that starts with it gets a warning, since the generated
1578a class of your own that starts with it gets a warning, since the generated
1554rules would apply to it too. Every style goes into the class under its own
1579rules would apply to it too. Every style goes into the class under its own
1555name, with its value as written (plus pixels, see
1580name, with its value as written (plus pixels, see
@@ -1572,7 +1597,7 @@ the specificity:
1572| Layer | Holds |
1597| Layer | Holds |
1573|---|---|
1598|---|---|
1574| `hl-reset` | The reset |
1599| `hl-reset` | The reset |
1575−| `hl-kind` | The defaults of each element's kind, as `:where(.hl-a)` |
1600+| `hl-kind` | The defaults of each element's kind, as `:where(.hl-2k9xq0m)` |
1576| `hl-inside` | The styles an element gets from an element prefix on an element around it (`@td:padding 8`, see [Styling the elements inside](#styling-the-elements-inside)), and functions' scoped `@style` |
1601| `hl-inside` | The styles an element gets from an element prefix on an element around it (`@td:padding 8`, see [Styling the elements inside](#styling-the-elements-inside)), and functions' scoped `@style` |
1577| `htmlang` | Each element's own styles, and its parent's `children:` styles |
1602| `htmlang` | Each element's own styles, and its parent's `children:` styles |
1578
1603
README.md+9−7
@@ -89,11 +89,13 @@ unless you write some, and nothing is added that you didn't ask for.
89 written (`outline 2 solid red` is `outline: 2px solid red`, nothing
89 written (`outline 2 solid red` is `outline: 2px solid red`, nothing
90 added). Nothing you write is left out of the page without an error.
90 added). Nothing you write is left out of the page without an error.
91- **The generated CSS stays on htmlang's elements.** Its classes are
91- **The generated CSS stays on htmlang's elements.** Its classes are
92− `hl-a`, `hl-b`, ..., in cascade layers (`hl-reset`, `hl-kind`,
92+ named by their styles (`hl-2k9xq0m`), in cascade layers (`hl-reset`,
93− `hl-inside`, `htmlang`), so your own CSS wins. An element's defaults (no
93+ `hl-kind`, `hl-inside`, `htmlang`), so your own CSS wins. An element's
94− heading margins, no list markers, links in the text colour) go with its
94+ defaults (no heading margins, no list markers, links in the text colour)
95− own class, so `@markdown` and `@raw` HTML, and a page that embeds
95+ go with its own class, so `@markdown` and `@raw` HTML, and a page that
96− `--partial` output, keep theirs.
96+ embeds `--partial` output, keep theirs. One style has one name in every
97+ file, so a fragment a server swaps into a page (with htmx, say) means
98+ the same there and restyles nothing of the page's.
97- **HTML stays HTML.** An element that isn't about layout or text has its
99- **HTML stays HTML.** An element that isn't about layout or text has its
98 HTML name (`@nav`, `@ul`, `@form`, `@details`, `@strong`, `@em`, `@br`,
100 HTML name (`@nav`, `@ul`, `@form`, `@details`, `@strong`, `@em`, `@br`,
99 `@caption`), from a fixed list, so a typo is an error rather than a new
101 `@caption`), from a fixed list, so a typo is an error rather than a new
@@ -134,8 +136,8 @@ htmlang build src -o dist # compile a whole site
134
136
135| Command | Purpose |
137| Command | Purpose |
136|---|---|
138|---|---|
137−| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory (into `out/` by default), except libraries: files that hold only `@let`s |
139+| `build <dir> [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory (into `out/` by default), except libraries: files that hold only `@let`s. Two files whose different styles got one class name are an error (`class-collision`) |
138−| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload |
140+| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload. It serves files, so it answers GET and HEAD (other methods get 405), and reloads pages, not fragments |
139| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
141| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
140| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
142| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
141| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
143| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
crates/htmlang-core/src/ast.rs+1−1
@@ -12,7 +12,7 @@ pub struct Document {
12 pub css_vars: Vec<(String, String)>,
12 pub css_vars: Vec<(String, String)>,
13 pub custom_css: Vec<String>,
13 pub custom_css: Vec<String>,
14 /// The scoped `@style` of each function called, nested under its
14 /// The scoped `@style` of each function called, nested under its
15− /// `.hl-fn-NAME` class, for `@layer hl-inside`.
15+ /// `.hl-fn-NAME-HASH` class, for `@layer hl-inside`.
16 pub scoped_css: Vec<String>,
16 pub scoped_css: Vec<String>,
17 pub og_tags: Vec<(String, String)>,
17 pub og_tags: Vec<(String, String)>,
18 pub nodes: Vec<Node>,
18 pub nodes: Vec<Node>,
crates/htmlang-core/src/codegen.rs+173−70
@@ -1,5 +1,5 @@
1use std::collections::HashMap;
1use std::collections::HashMap;
2−use std::hash::{Hash, Hasher};
2+use std::hash::Hash;
3
3
4use crate::ast::*;
4use crate::ast::*;
5use crate::vocab::with_px;
5use crate::vocab::with_px;
@@ -17,33 +17,51 @@ const BREAKPOINTS: &[(&str, &str)] = &[
17 ("2xl", "1536px"),
17 ("2xl", "1536px"),
18];
18];
19
19
20−/// The prefix of every generated class (`hl-a`, `hl-b`, ...), which keeps
20+/// The prefix of every generated class (`hl-2k9xq0m`), which keeps them
21−/// them out of the author's own class names.
21+/// out of the author's own class names.
22pub const CLASS_PREFIX: &str = "hl-";
22pub const CLASS_PREFIX: &str = "hl-";
23
23
24−/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
24+/// How many base-36 digits a class's name has after `hl-`: 7, about 36
25−/// aaa, ... The first character is always a letter; later ones are drawn from
25+/// bits of its style's hash, so a site with 10,000 different styles has
26−/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
26+/// odds of about 6 in 10,000 that two of them share a name, which
27−pub(crate) fn short_class_name(idx: usize) -> String {
27+/// `htmlang build` reports (`class-collision`). Within one file, a style
28− const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
28+/// whose name is taken takes more digits.
29− // Find the name length: 26 names of length 1, 26*36 of length 2, ...
29+pub const CLASS_DIGITS: usize = 7;
30− let mut n = idx;
30+
31− let mut len = 1;
31+/// A stable hash of `text`: FNV-1a, then SplitMix64's finalizer, so every
32− let mut count = 26usize;
32+/// digit of a name depends on all of the text. It is the same on every
33− while n >= count {
33+/// platform and in every build, unlike `std`'s hasher.
34− n -= count;
34+pub fn stable_hash(text: &str) -> u64 {
35− len += 1;
35+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
36− count = count.saturating_mul(36);
36+ for byte in text.bytes() {
37− }
37+ hash ^= u64::from(byte);
38− let mut tail = Vec::with_capacity(len - 1);
38+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
39− for _ in 1..len {
39+ }
40− tail.push(REST[n % 36]);
40+ hash = (hash ^ (hash >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9);
41+ hash = (hash ^ (hash >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb);
42+ hash ^ (hash >> 31)
43+}
44+
45+/// `digits` base-36 digits of `hash`: `2k9xq0m`. With 13, all of it.
46+pub fn hash_digits(hash: u64, digits: usize) -> String {
47+ const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz";
48+ let mut n = hash;
49+ (0..digits)
50+ .map(|_| {
51+ let digit = DIGITS[(n % 36) as usize] as char;
41− n /= 36;
52+ n /= 36;
53+ digit
54+ })
55+ .collect()
42− }
56+}
43− let mut name = String::with_capacity(len);
57+
44− name.push((b'a' + n as u8) as char);
58+/// A class htmlang generated: its name, and the hash of the style it
45− name.extend(tail.iter().rev().map(|&b| b as char));
59+/// stands for. One style has one name in every file, so a fragment's
60+/// classes mean the same in any page it goes into.
61+#[derive(Clone, Debug, PartialEq, Eq)]
62+pub struct Class {
46− name
63+ pub name: String,
64+ pub style: u64,
47}
65}
48
66
49/// When a style applies: the at-rule prefixes it is under (their ranks,
67/// When a style applies: the at-rule prefixes it is under (their ranks,
@@ -254,8 +272,33 @@ fn at_rule(rank: usize, dev: bool) -> String {
254 }
272 }
255}
273}
256
274
275+/// A style as text, for its hash: the defaults of its element's kind, its
276+/// rules (the at-rules' ranks, the selector prefixes and the declarations
277+/// of each) and the rules keyed on it, each part ended by a character CSS
278+/// doesn't have.
279+fn style_text(defaults: &str, rules: &[(Condition, String)], distinct: &str) -> String {
280+ let mut text = format!("{defaults}\u{1}");
281+ for (condition, body) in rules {
282+ for rank in &condition.at {
283+ text.push_str(&format!("{rank}\u{2}"));
284+ }
285+ text.push('\u{3}');
286+ for prefix in &condition.selector {
287+ text.push_str(prefix);
288+ text.push('\u{2}');
289+ }
290+ text.push('\u{3}');
291+ text.push_str(body);
292+ text.push('\u{1}');
293+ }
294+ text.push_str(distinct);
295+ text
296+}
297+
257struct StyleEntry {
298struct StyleEntry {
258 class_name: String,
299 class_name: String,
300+ /// The hash of the style, which names the class
301+ style: u64,
259 /// The defaults of the element's kind (its layout, a heading's
302 /// The defaults of the element's kind (its layout, a heading's
260 /// `margin:0`, ...), less what its own attributes set. They are written
303 /// `margin:0`, ...), less what its own attributes set. They are written
261 /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix
304 /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix
@@ -272,10 +315,11 @@ struct StyleEntry {
272
315
273struct StyleCollector {
316struct StyleCollector {
274 entries: Vec<StyleEntry>,
317 entries: Vec<StyleEntry>,
275− /// Maps a pre-hashed style signature to an index into `entries`.
318+ /// Maps a style's hash to its entries (more than one only when two
276− /// Using u64 as the key keeps lookups allocation-free; on the rare case of
319+ /// styles hash alike, which the full comparison tells apart).
277− /// a hash collision we fall back to a full equality check against the entry.
278 index: HashMap<u64, Vec<usize>>,
320 index: HashMap<u64, Vec<usize>>,
321+ /// The names given, which a style whose name is taken doesn't get
322+ names: std::collections::HashSet<String>,
279 /// Rules for an element's children, keyed on its class and written in
323 /// Rules for an element's children, keyed on its class and written in
280 /// the block of an at-rule condition: `(at-rules, selector, body)`, e.g.
324 /// the block of an at-rule condition: `(at-rules, selector, body)`, e.g.
281 /// `([md], ":where(.a)>.b", "flex:1;")` (see [`Flow`]).
325 /// `([md], ":where(.a)>.b", "flex:1;")` (see [`Flow`]).
@@ -289,6 +333,7 @@ impl StyleCollector {
289 StyleCollector {
333 StyleCollector {
290 entries: Vec::new(),
334 entries: Vec::new(),
291 index: HashMap::new(),
335 index: HashMap::new(),
336+ names: std::collections::HashSet::new(),
292 keyed: Vec::new(),
337 keyed: Vec::new(),
293 keyed_seen: std::collections::HashSet::new(),
338 keyed_seen: std::collections::HashSet::new(),
294 }
339 }
@@ -314,14 +359,9 @@ impl StyleCollector {
314 return None;
359 return None;
315 }
360 }
316 rules.sort_by_cached_key(|(condition, _)| condition.order());
361 rules.sort_by_cached_key(|(condition, _)| condition.order());
317− use std::collections::hash_map::DefaultHasher;
362+ let style = stable_hash(&style_text(&defaults, &rules, &distinct));
318− let mut h = DefaultHasher::new();
363+
319− defaults.hash(&mut h);
320− rules.hash(&mut h);
321− distinct.hash(&mut h);
322− let sig = h.finish();
323−
324− if let Some(indices) = self.index.get(&sig) {
364+ if let Some(indices) = self.index.get(&style) {
325 for &idx in indices {
365 for &idx in indices {
326 let e = &self.entries[idx];
366 let e = &self.entries[idx];
327 if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
367 if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
@@ -329,18 +369,34 @@ impl StyleCollector {
329 }
369 }
330 }
370 }
331 }
371 }
372+ // The name is the hash's first digits, and more of them when
373+ // another style of this file has those; after all 13, the entry's
374+ // number tells the two apart
332 let idx = self.entries.len();
375 let idx = self.entries.len();
376+ let name = (CLASS_DIGITS..=13)
377+ .map(|digits| format!("{}{}", CLASS_PREFIX, hash_digits(style, digits)))
378+ .find(|name| !self.names.contains(name))
333− let name = format!("{}{}", CLASS_PREFIX, short_class_name(idx));
379+ .unwrap_or_else(|| format!("{}{}{}", CLASS_PREFIX, hash_digits(style, 13), idx));
380+ self.names.insert(name.clone());
334 self.entries.push(StyleEntry {
381 self.entries.push(StyleEntry {
335 class_name: name.clone(),
382 class_name: name.clone(),
383+ style,
336 defaults,
384 defaults,
337 rules,
385 rules,
338 distinct,
386 distinct,
339 });
387 });
340− self.index.entry(sig).or_default().push(idx);
388+ self.index.entry(style).or_default().push(idx);
341 Some(name)
389 Some(name)
342 }
390 }
343
391
392+ /// The classes given, in the order they were
393+ fn classes(&self) -> impl Iterator<Item = Class> + '_ {
394+ self.entries.iter().map(|e| Class {
395+ name: e.class_name.clone(),
396+ style: e.style,
397+ })
398+ }
399+
344 /// All generated rules, in three layers (see [`LAYERS`]): the element
400 /// All generated rules, in three layers (see [`LAYERS`]): the element
345 /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the
401 /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the
346 /// elements an element prefix picks out inside an element (`@td:`), and
402 /// elements an element prefix picks out inside an element (`@td:`), and
@@ -628,15 +684,22 @@ pub struct CodegenOptions {
628
684
629/// Generate HTML from a parsed document using the given options.
685/// Generate HTML from a parsed document using the given options.
630pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
686pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
687+ generate_with_classes(doc, opts).0
688+}
689+
690+/// [`generate_with`], and the classes the document's styles were given,
691+/// for a check across files that no name stands for two styles.
692+pub fn generate_with_classes(doc: &Document, opts: &CodegenOptions) -> (String, Vec<Class>) {
693+ let mut classes = Vec::new();
631 let mut html = if opts.partial {
694 let mut html = if opts.partial {
632− generate_partial_inner(doc, opts.dev)
695+ generate_partial_inner(doc, opts.dev, &mut classes)
633 } else {
696 } else {
634− generate_full_inner(doc, opts.dev)
697+ generate_full_inner(doc, opts.dev, &mut classes)
635 };
698 };
636 if opts.minify {
699 if opts.minify {
637 html = minify_html(&html);
700 html = minify_html(&html);
638 }
701 }
639− html
702+ (html, classes)
640}
703}
641
704
642pub fn generate(doc: &Document) -> String {
705pub fn generate(doc: &Document) -> String {
@@ -762,10 +825,10 @@ fn minify_html(html: &str) -> String {
762 result
825 result
763}
826}
764
827
765−fn generate_full_inner(doc: &Document, dev: bool) -> String {
828+fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
766 // Without `@page` the output is a fragment
829 // Without `@page` the output is a fragment
767 let Some(page) = &doc.page else {
830 let Some(page) = &doc.page else {
768− return generate_partial_inner(doc, dev);
831+ return generate_partial_inner(doc, dev, classes);
769 };
832 };
770 let mut styles = StyleCollector::new();
833 let mut styles = StyleCollector::new();
771 let mut ctx = GenContext {
834 let mut ctx = GenContext {
@@ -855,15 +918,9 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
855 }
918 }
856
919
857 // `@page`'s HTML attributes go on `<html>`
920 // `@page`'s HTML attributes go on `<html>`
858− let mut html_attrs = String::new();
921+ let mut root_attrs = String::new();
859− for attr in &page.html_attrs {
922+ for attr in html_attrs(&page.html_attrs) {
860− html_attrs.push(' ');
923+ push_html_attr(&mut root_attrs, attr);
861− html_attrs.push_str(&attr.key);
862− if attr.html {
863− html_attrs.push_str("=\"");
864− html_attrs.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
865− html_attrs.push('"');
866− }
867 }
924 }
868 let mut body_attrs = String::new();
925 let mut body_attrs = String::new();
869 emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
926 emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
@@ -886,9 +943,10 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
886 String::new()
943 String::new()
887 };
944 };
888 let reset_css = reset_css(dev, true, &focus_visible_css);
945 let reset_css = reset_css(dev, true, &focus_visible_css);
946+ classes.extend(styles.classes());
889
947
890 format!(
948 format!(
891− "<!DOCTYPE html>{nl}<html{html_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
949+ "<!DOCTYPE html>{nl}<html{root_attrs}>{nl}<head>{nl}<meta charset=\"utf-8\">{nl}\
892 {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
950 {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
893 <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
951 <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
894 )
952 )
@@ -1028,7 +1086,7 @@ fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String {
1028}
1086}
1029
1087
1030/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1088/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
1031−fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1089+fn generate_partial_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
1032 let mut styles = StyleCollector::new();
1090 let mut styles = StyleCollector::new();
1033 let mut ctx = GenContext {
1091 let mut ctx = GenContext {
1034 dev,
1092 dev,
@@ -1048,6 +1106,7 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1048 if !styles.entries.is_empty() || !doc.scoped_css.is_empty() {
1106 if !styles.entries.is_empty() || !doc.scoped_css.is_empty() {
1049 element_css.insert_str(0, &reset_css(dev, false, ""));
1107 element_css.insert_str(0, &reset_css(dev, false, ""));
1050 }
1108 }
1109+ classes.extend(styles.classes());
1051
1110
1052 if element_css.is_empty() {
1111 if element_css.is_empty() {
1053 body
1112 body
@@ -1198,27 +1257,43 @@ pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
1198}
1257}
1199
1258
1200/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1259/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1201−/// are emitted with the generated class) and bare booleans like `required`.
1260+/// are emitted with the generated class) and flags like `required`.
1202fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1261fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1203− for attr in attrs {
1262+ for attr in html_attrs(attrs) {
1204 let key = attr.key.as_str();
1263 let key = attr.key.as_str();
1205− if attr.html && key != "id" && key != "class" {
1264+ if key != "id" && key != "class" {
1206− out.push(' ');
1265+ push_html_attr(out, attr);
1207− out.push_str(key);
1208− out.push_str("=\"");
1209− // Quoted text has already lost its quotes (see parser.rs)
1210− out.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
1211− out.push('"');
1212− } else if !attr.html
1213− && attr.value.is_none()
1214− && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key)
1215− {
1216− out.push(' ');
1217− out.push_str(key);
1218 }
1266 }
1219 }
1267 }
1220}
1268}
1221
1269
1270+/// The HTML attributes of `attrs`, `key=value` ones and flags, each name
1271+/// once: the later one wins, as a style does. (Of two attributes with one
1272+/// name, the browser keeps the first, so a bundle's attribute or a
1273+/// function's own would win over one written after it.)
1274+fn html_attrs(attrs: &[Attribute]) -> impl Iterator<Item = &Attribute> {
1275+ let is_html =
1276+ |a: &Attribute| a.html || (a.value.is_none() && crate::vocab::is_html_flag(&a.key));
1277+ attrs.iter().enumerate().filter_map(move |(i, attr)| {
1278+ let later = attrs[i + 1..]
1279+ .iter()
1280+ .any(|other| is_html(other) && other.key == attr.key);
1281+ (is_html(attr) && !later).then_some(attr)
1282+ })
1283+}
1284+
1285+/// ` key="value"`, or ` key` for a flag.
1286+fn push_html_attr(out: &mut String, attr: &Attribute) {
1287+ out.push(' ');
1288+ out.push_str(&attr.key);
1289+ if attr.html {
1290+ out.push_str("=\"");
1291+ // Quoted text has already lost its quotes (see parser.rs)
1292+ out.push_str(&html_escape(attr.value.as_deref().unwrap_or("")));
1293+ out.push('"');
1294+ }
1295+}
1296+
1222// True if any direct child is `@in-front` or `@behind`. Such children render
1297// True if any direct child is `@in-front` or `@behind`. Such children render
1223// as absolutely positioned overlays, so the parent automatically becomes a
1298// as absolutely positioned overlays, so the parent automatically becomes a
1224// positioning context (position:relative + isolation:isolate).
1299// positioning context (position:relative + isolation:isolate).
@@ -2374,3 +2449,31 @@ fn vlq_encode(value: i64, out: &mut String) {
2374 }
2449 }
2375 }
2450 }
2376}
2451}
2452+
2453+#[cfg(test)]
2454+mod tests {
2455+ use super::*;
2456+
2457+ #[test]
2458+ fn a_name_is_a_stable_hash_of_the_style() {
2459+ // The same in every build and on every platform
2460+ assert_eq!(stable_hash(""), 0xf52a_15e9_a9b5_e89b);
2461+ assert_eq!(hash_digits(35, 3), "z00");
2462+ assert_eq!(hash_digits(u64::MAX, 13), "fsgs46211e5w3");
2463+ }
2464+
2465+ #[test]
2466+ fn a_name_taken_in_the_file_takes_more_digits() {
2467+ // These two styles' hashes end in the same 7 digits
2468+ let mut styles = StyleCollector::new();
2469+ let first = styles.get_class("width:129555px;".into(), Vec::new(), String::new());
2470+ let second = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2471+ let (first, second) = (first.unwrap(), second.unwrap());
2472+ assert_eq!(first.len(), 3 + CLASS_DIGITS, "{first}");
2473+ assert_eq!(second.len(), 3 + CLASS_DIGITS + 1, "{second}");
2474+ assert!(second.starts_with(&first), "{first} {second}");
2475+ // The same style gets the name it got before
2476+ let again = styles.get_class("width:170577px;".into(), Vec::new(), String::new());
2477+ assert_eq!(again.as_deref(), Some(second.as_str()));
2478+ }
2479+}
crates/htmlang-core/src/codegen_tests.rs+31−7
@@ -1,6 +1,8 @@
1#[cfg(test)]
1#[cfg(test)]
2mod tests {
2mod tests {
3+ use crate::codegen::{
3− use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
4+ CodegenOptions, generate, generate_dev, generate_partial, generate_with_classes,
5+ };
4 use crate::parser::parse;
6 use crate::parser::parse;
5
7
6 fn compile(src: &str) -> String {
8 fn compile(src: &str) -> String {
@@ -54,13 +56,35 @@ mod tests {
54 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
56 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
55 }
57 }
56
58
59+ /// The classes of `src`, compiled as a fragment
60+ fn classes(src: &str) -> Vec<crate::codegen::Class> {
61+ let options = CodegenOptions {
62+ partial: true,
63+ ..Default::default()
64+ };
65+ generate_with_classes(&parse(src).document, &options).1
66+ }
67+
57 #[test]
68 #[test]
58− fn short_class_name_is_stable() {
69+ fn a_style_has_one_name_in_every_file() {
59− assert_eq!(short_class_name(0), "a");
70+ // A fragment's classes mean in a page what they mean in the
60− assert_eq!(short_class_name(25), "z");
71+ // fragment, whatever else either holds
61− // Higher indexes must produce unique, stable names.
72+ let page = classes("@el [padding 10, background red] a\n@el [gap 4] b\n");
62− let names: std::collections::HashSet<_> = (0..50_000).map(short_class_name).collect();
73+ let fragment = classes("@row [margin 2] x\n@el [padding 10, background red] y\n");
63− assert_eq!(names.len(), 50_000, "short_class_name collisions");
74+ assert_eq!(page[0], fragment[1]);
75+ assert_ne!(page[1].style, fragment[0].style);
76+ assert_ne!(page[1].name, fragment[0].name);
77+ for class in page.iter().chain(&fragment) {
78+ let digits = class.name.strip_prefix("hl-").expect("hl-");
79+ assert_eq!(digits.len(), crate::codegen::CLASS_DIGITS, "{}", class.name);
80+ assert!(
81+ digits
82+ .bytes()
83+ .all(|b| b.is_ascii_digit() || b.is_ascii_lowercase()),
84+ "{}",
85+ class.name
86+ );
87+ }
64 }
88 }
65
89
66 #[test]
90 #[test]
crates/htmlang-core/src/diagnostic.rs+16
@@ -77,6 +77,9 @@ impl Diagnostic {
77pub mod code {
77pub mod code {
78 // Syntax: found while reading the file, before anything runs
78 // Syntax: found while reading the file, before anything runs
79 pub const UNCLOSED_BRACKET: &str = "unclosed-bracket";
79 pub const UNCLOSED_BRACKET: &str = "unclosed-bracket";
80+ /// A `]` that ends an attribute list inside what reads as a `'...'`
81+ /// string in a value (`hx-on:click=alert(']')`).
82+ pub const UNBALANCED_QUOTE: &str = "unbalanced-quote";
80 pub const UNEXPECTED_BODY: &str = "unexpected-body";
83 pub const UNEXPECTED_BODY: &str = "unexpected-body";
81 pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
84 pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
82 pub const MISSING_ARGUMENT: &str = "missing-argument";
85 pub const MISSING_ARGUMENT: &str = "missing-argument";
@@ -115,6 +118,12 @@ pub mod code {
115 pub const INVALID_COLOR: &str = "invalid-color";
118 pub const INVALID_COLOR: &str = "invalid-color";
116 pub const INVALID_EXPRESSION: &str = "invalid-expression";
119 pub const INVALID_EXPRESSION: &str = "invalid-expression";
117 pub const INVALID_JSON: &str = "invalid-json";
120 pub const INVALID_JSON: &str = "invalid-json";
121+ /// A name htmlang doesn't know right after a `key=value`: the rest of
122+ /// the value, which a comma ended (`hx-trigger=load, every 2s`).
123+ pub const SPLIT_VALUE: &str = "split-value";
124+ /// A leading argument taken as a URL that reads as text (`@link
125+ /// [hx-get=/x] Open panel`, whose href is `Open`).
126+ pub const TEXT_AS_URL: &str = "text-as-url";
118
127
119 // Definitions and functions
128 // Definitions and functions
120 pub const UNUSED_VARIABLE: &str = "unused-variable";
129 pub const UNUSED_VARIABLE: &str = "unused-variable";
@@ -142,6 +151,9 @@ pub mod code {
142 // Files
151 // Files
143 pub const UNREADABLE_FILE: &str = "unreadable-file";
152 pub const UNREADABLE_FILE: &str = "unreadable-file";
144 pub const CIRCULAR_INCLUDE: &str = "circular-include";
153 pub const CIRCULAR_INCLUDE: &str = "circular-include";
154+ /// Two files of one build whose different styles got one class name:
155+ /// a name is a hash of its style (`htmlang build` checks).
156+ pub const CLASS_COLLISION: &str = "class-collision";
145 pub const UNSET_ENVIRONMENT: &str = "unset-environment";
157 pub const UNSET_ENVIRONMENT: &str = "unset-environment";
146
158
147 // Context: an attribute or element where it has no effect
159 // Context: an attribute or element where it has no effect
@@ -174,6 +186,7 @@ pub mod code {
174 /// Every code, for tools and tests.
186 /// Every code, for tools and tests.
175 pub const ALL: &[&str] = &[
187 pub const ALL: &[&str] = &[
176 UNCLOSED_BRACKET,
188 UNCLOSED_BRACKET,
189+ UNBALANCED_QUOTE,
177 UNEXPECTED_BODY,
190 UNEXPECTED_BODY,
178 UNEXPECTED_ARGUMENT,
191 UNEXPECTED_ARGUMENT,
179 MISSING_ARGUMENT,
192 MISSING_ARGUMENT,
@@ -195,6 +208,8 @@ pub mod code {
195 INVALID_COLOR,
208 INVALID_COLOR,
196 INVALID_EXPRESSION,
209 INVALID_EXPRESSION,
197 INVALID_JSON,
210 INVALID_JSON,
211+ SPLIT_VALUE,
212+ TEXT_AS_URL,
198 UNUSED_VARIABLE,
213 UNUSED_VARIABLE,
199 UNUSED_BUNDLE,
214 UNUSED_BUNDLE,
200 UNUSED_FUNCTION,
215 UNUSED_FUNCTION,
@@ -209,6 +224,7 @@ pub mod code {
209 INVALID_SLOT_NAME,
224 INVALID_SLOT_NAME,
210 UNREADABLE_FILE,
225 UNREADABLE_FILE,
211 CIRCULAR_INCLUDE,
226 CIRCULAR_INCLUDE,
227+ CLASS_COLLISION,
212 UNSET_ENVIRONMENT,
228 UNSET_ENVIRONMENT,
213 NO_EFFECT,
229 NO_EFFECT,
214 BLOCK_IN_PARAGRAPH,
230 BLOCK_IN_PARAGRAPH,
crates/htmlang-core/src/parser.rs+118−42
@@ -44,10 +44,10 @@ struct FnDef {
44 slots: Vec<String>,
44 slots: Vec<String>,
45 /// Whether its body has a `@children` for the content of a call.
45 /// Whether its body has a `@children` for the content of a call.
46 has_children: bool,
46 has_children: bool,
47− /// Its scoped `@style`, nested under its `.hl-fn-NAME` class: written
47+ /// Its scoped `@style`: its class (`hl-fn-NAME-HASH`), and the
48− /// into the page once the function is called (see
48+ /// stylesheet nested under it, written into the page once the function
49− /// `Document::scoped_css`).
49+ /// is called (see `Document::scoped_css`).
50− scoped: Option<String>,
50+ scoped: Option<(String, String)>,
51 /// What was visible where it was defined: its body and its defaults
51 /// What was visible where it was defined: its body and its defaults
52 /// see this, plus its parameters.
52 /// see this, plus its parameters.
53 env: Env,
53 env: Env,
@@ -1963,9 +1963,12 @@ impl Evaluator {
1963 let line_num = node.span.line;
1963 let line_num = node.span.line;
1964 let body = &node.children;
1964 let body = &node.children;
1965 // An @style block at the top of the body is scoped to the
1965 // An @style block at the top of the body is scoped to the
1966− // function: its rules apply inside a `.hl-fn-NAME` wrapper (a
1966+ // function: its rules apply inside a `.hl-fn-NAME-HASH` wrapper (a
1967 // generated class never has a second `-`, so the two can't meet).
1967 // generated class never has a second `-`, so the two can't meet).
1968+ // The hash is the stylesheet's, so two functions of one name with
1969+ // two stylesheets, in two files, don't style each other's elements
1968− // It is written into the page when the function is called.
1970+ // in a page that has both. It is written into the page when the
1971+ // function is called.
1969 let (style, body): (Vec<&syntax::Node>, Vec<&syntax::Node>) =
1972 let (style, body): (Vec<&syntax::Node>, Vec<&syntax::Node>) =
1970 body.iter().partition(|node| node.is_directive("style"));
1973 body.iter().partition(|node| node.is_directive("style"));
1971 let scoped = (!style.is_empty()).then(|| {
1974 let scoped = (!style.is_empty()).then(|| {
@@ -1976,13 +1979,17 @@ impl Evaluator {
1976 verbatim_content(node)
1979 verbatim_content(node)
1977 })
1980 })
1978 .collect();
1981 .collect();
1982+ let digits = crate::codegen::CLASS_DIGITS;
1983+ let hash = crate::codegen::hash_digits(crate::codegen::stable_hash(&css), digits);
1984+ let class = format!("hl-fn-{}-{}", name, hash);
1979 // Nested under the scope class, so any CSS works (multi-line
1985 // Nested under the scope class, so any CSS works (multi-line
1980 // rules, at-rules)
1986 // rules, at-rules)
1981− if css.trim().is_empty() {
1987+ let css = if css.trim().is_empty() {
1982 String::new()
1988 String::new()
1983 } else {
1989 } else {
1984− format!(".hl-fn-{} {{\n{}}}", name, css)
1990+ format!(".{} {{\n{}}}", class, css)
1985− }
1991+ };
1992+ (class, css)
1986 });
1993 });
1987
1994
1988 // A default is filled in only when a call leaves its parameter
1995 // A default is filled in only when a call leaves its parameter
@@ -2287,7 +2294,7 @@ impl Evaluator {
2287
2294
2288 // Attributes that aren't parameters style the function's root
2295 // Attributes that aren't parameters style the function's root
2289 // element, and a scoped @style's class goes on it too.
2296 // element, and a scoped @style's class goes on it too.
2290− let scope_class = fn_def.scoped.is_some().then(|| format!("hl-fn-{}", name));
2297+ let scope_class = fn_def.scoped.as_ref().map(|(class, _)| class.clone());
2291 if !forwarded.is_empty() || scope_class.is_some() {
2298 if !forwarded.is_empty() || scope_class.is_some() {
2292 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
2299 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
2293 Node::Element(e) => Some(e),
2300 Node::Element(e) => Some(e),
@@ -2321,7 +2328,7 @@ impl Evaluator {
2321 }
2328 }
2322 // The scoped `@style` goes into the page once, when the
2329 // The scoped `@style` goes into the page once, when the
2323 // function is first called
2330 // function is first called
2324− if let Some(css) = &fn_def.scoped
2331+ if let Some((_, css)) = &fn_def.scoped
2325 && !css.is_empty()
2332 && !css.is_empty()
2326 && !ctx.scoped_css.contains(css)
2333 && !ctx.scoped_css.contains(css)
2327 {
2334 {
@@ -2618,8 +2625,7 @@ fn read_page(
2618 // one is written
2625 // one is written
2619 let mut seen: HashMap<String, usize> = HashMap::new();
2626 let mut seen: HashMap<String, usize> = HashMap::new();
2620 for attr in read {
2627 for attr in read {
2621− let boolean = attr.value.is_none()
2628+ let boolean = attr.value.is_none() && crate::vocab::is_html_flag(&attr.key);
2622− && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&attr.key.as_str());
2623 // Where it is written, when it is written in the list itself
2629 // Where it is written, when it is written in the list itself
2624 let nth = seen.entry(attr.key.clone()).or_default();
2630 let nth = seen.entry(attr.key.clone()).or_default();
2625 let column = list
2631 let column = list
@@ -3656,6 +3662,9 @@ fn parse_single_element(
3656 }
3662 }
3657 } else {
3663 } else {
3658 let column = Some(token.span.column);
3664 let column = Some(token.span.column);
3665+ if let Some(d) = text_as_url(&kind, &token, ctx) {
3666+ ctx.push_once(d);
3667+ }
3659 argument = Some(
3668 argument = Some(
3660 ctx.fill_value(&token.raw, text.span.line, column, Sink::Text)
3669 ctx.fill_value(&token.raw, text.span.line, column, Sink::Text)
3661 .0,
3670 .0,
@@ -3813,6 +3822,35 @@ fn bad_leading_token(
3813 Some(at_attribute(d, Some(token.span.column), ctx))
3822 Some(at_attribute(d, Some(token.span.column), ctx))
3814}
3823}
3815
3824
3825+/// A first word taken as a URL that reads as text: a word with a capital
3826+/// and nothing a URL or a file name has, as in `@link [hx-get=/panel] Open
3827+/// panel`, whose href would be `Open`. It still is (a relative URL can be
3828+/// such a word), with a warning.
3829+fn text_as_url(kind: &ElementKind, token: &syntax::Arg, ctx: &ParseContext) -> Option<Diagnostic> {
3830+ const URLS: &[&str] = &["href", "src", "srcset", "action", "data"];
3831+ let word = token.raw.as_str();
3832+ let reads_as_text = word.starts_with(char::is_uppercase)
3833+ && !word.contains([
3834+ '/', '.', ':', '#', '?', '=', '&', '%', '~', '$', '{', '\\', '"',
3835+ ]);
3836+ if !reads_as_text || !kind.arg().attributes().iter().any(|a| URLS.contains(a)) {
3837+ return None;
3838+ }
3839+ let name = kind.name();
3840+ let attr = leading_name(kind);
3841+ let text = if takes_no_text(kind) {
3842+ String::new()
3843+ } else {
3844+ format!(": write the {attr} before the text, or put the text on the lines under it")
3845+ };
3846+ let message = format!(
3847+ "@{name} takes '{word}', its first word, as its {attr}{text}. A relative URL that \
3848+ starts with a capital is written `./{word}`"
3849+ );
3850+ let d = Diagnostic::warning(code::TEXT_AS_URL, token.span.line, message).subject(word);
3851+ Some(at_attribute(d, Some(token.span.column), ctx))
3852+}
3853+
3816/// `@link /a > @image logo.png`: a `>` chains only after an element's
3854/// `@link /a > @image logo.png`: a `>` chains only after an element's
3817/// name or its `[attributes]`, so after a leading argument it is text.
3855/// name or its `[attributes]`, so after a leading argument it is text.
3818/// Said when what follows names an element or a function.
3856/// Said when what follows names an element or a function.
@@ -4881,7 +4919,7 @@ fn check_attribute(
4881 attr: &Attribute,
4919 attr: &Attribute,
4882 line: usize,
4920 line: usize,
4883 column: Option<usize>,
4921 column: Option<usize>,
4884− after_another: bool,
4922+ before: Option<&Attribute>,
4885 ctx: &mut ParseContext,
4923 ctx: &mut ParseContext,
4886) -> bool {
4924) -> bool {
4887 use crate::vocab;
4925 use crate::vocab;
@@ -4909,12 +4947,6 @@ fn check_attribute(
4909 &attr.key,
4947 &attr.key,
4910 );
4948 );
4911 }
4949 }
4912− let is_html_name = |name: &str| {
4913− vocab::HTML_ATTRIBUTES.contains(&name)
4914− || name.starts_with("aria-")
4915− || name.starts_with("data-")
4916− };
4917−
4918 let Some(value) = &attr.value else {
4950 let Some(value) = &attr.value else {
4919 // `inline` puts a file into the page as it compiles, which no
4951 // `inline` puts a file into the page as it compiles, which no
4920 // state or media condition can undo
4952 // state or media condition can undo
@@ -4933,7 +4965,7 @@ fn check_attribute(
4933 if vocab::HTMLANG_FLAGS.contains(&base) {
4965 if vocab::HTMLANG_FLAGS.contains(&base) {
4934 return true;
4966 return true;
4935 }
4967 }
4936− if vocab::BOOLEAN_HTML_ATTRS.contains(&base) {
4968+ if vocab::is_html_flag(base) {
4937 if !prefixed {
4969 if !prefixed {
4938 return true;
4970 return true;
4939 }
4971 }
@@ -4958,7 +4990,7 @@ fn check_attribute(
4958 &attr.key,
4990 &attr.key,
4959 );
4991 );
4960 }
4992 }
4961− if is_html_name(base) && !prefixed {
4993+ if vocab::is_html_attribute(base) && !prefixed {
4962 return fail(
4994 return fail(
4963 ctx,
4995 ctx,
4964 code::HTML_ATTRIBUTE_FORM,
4996 code::HTML_ATTRIBUTE_FORM,
@@ -4969,7 +5001,7 @@ fn check_attribute(
4969 base,
5001 base,
4970 );
5002 );
4971 }
5003 }
4972− return unknown_attribute(attr, base, line, column, after_another, true, ctx);
5004+ return unknown_attribute(attr, base, line, column, before, true, ctx);
4973 };
5005 };
4974
5006
4975 if vocab::HTMLANG_FLAGS.contains(&base) {
5007 if vocab::HTMLANG_FLAGS.contains(&base) {
@@ -4989,7 +5021,7 @@ fn check_attribute(
4989 {
5021 {
4990 return check_css_value(attr, base, line, column, ctx);
5022 return check_css_value(attr, base, line, column, ctx);
4991 }
5023 }
4992− if is_html_name(base) && !prefixed {
5024+ if vocab::is_html_attribute(base) && !prefixed {
4993 let diagnostic = Diagnostic::error(
5025 let diagnostic = Diagnostic::error(
4994 code::HTML_ATTRIBUTE_FORM,
5026 code::HTML_ATTRIBUTE_FORM,
4995 line,
5027 line,
@@ -5004,7 +5036,7 @@ fn check_attribute(
5004 return false;
5036 return false;
5005 }
5037 }
5006 let passes = vocab::is_property_name(base);
5038 let passes = vocab::is_property_name(base);
5007− unknown_attribute(attr, base, line, column, after_another, !passes, ctx)
5039+ unknown_attribute(attr, base, line, column, before, !passes, ctx)
5008 && check_css_value(attr, base, line, column, ctx)
5040 && check_css_value(attr, base, line, column, ctx)
5009}
5041}
5010
5042
@@ -5015,8 +5047,8 @@ fn check_attribute(
5015fn could_be_parameter(attr: &Attribute) -> bool {
5047fn could_be_parameter(attr: &Attribute) -> bool {
5016 use crate::vocab;
5048 use crate::vocab;
5017 let key = attr.key.as_str();
5049 let key = attr.key.as_str();
5018− let flag = attr.value.is_none()
5050+ let flag =
5019− && (vocab::HTMLANG_FLAGS.contains(&key) || vocab::BOOLEAN_HTML_ATTRS.contains(&key));
5051+ attr.value.is_none() && (vocab::HTMLANG_FLAGS.contains(&key) || vocab::is_html_flag(key));
5020 !attr.html
5052 !attr.html
5021 && !flag
5053 && !flag
5022 && !vocab::is_style_attribute(key)
5054 && !vocab::is_style_attribute(key)
@@ -5071,7 +5103,7 @@ fn unknown_attribute(
5071 base: &str,
5103 base: &str,
5072 line: usize,
5104 line: usize,
5073 column: Option<usize>,
5105 column: Option<usize>,
5074− after_another: bool,
5106+ before: Option<&Attribute>,
5075 error: bool,
5107 error: bool,
5076 ctx: &mut ParseContext,
5108 ctx: &mut ParseContext,
5077) -> bool {
5109) -> bool {
@@ -5117,6 +5149,36 @@ fn unknown_attribute(
5117 // itself
5149 // itself
5118 let suggestion =
5150 let suggestion =
5119 suggest_closest(base, &crate::vocab::all_attributes()).filter(|&closest| closest != base);
5151 suggest_closest(base, &crate::vocab::all_attributes()).filter(|&closest| closest != base);
5152+ // `hx-trigger=load, every 2s`: right after a `key=value`, a name that
5153+ // isn't close to one htmlang knows is the rest of that value, which
5154+ // the comma ended. It is left out.
5155+ let cut = before.filter(|b| b.html && b.value.is_some());
5156+ let rest = match &attr.value {
5157+ Some(value) => format!("{} {}", attr.key, value),
5158+ None => attr.key.clone(),
5159+ };
5160+ let joined = cut.map(|cut| {
5161+ let value = cut.value.as_deref().unwrap_or("");
5162+ (
5163+ format!("{}={}", cut.key, value),
5164+ format!("{}={}\\, {}", cut.key, value, rest),
5165+ )
5166+ });
5167+ if let (Some((cut, joined)), None) = (&joined, suggestion) {
5168+ let diagnostic = Diagnostic::error(
5169+ code::SPLIT_VALUE,
5170+ line,
5171+ format!(
5172+ "unknown attribute '{}' after `{}`: if '{}' is the rest of its value, the comma \
5173+ ended it. To keep a comma in a value, write `\\,`: `{}`",
5174+ base, cut, rest, joined
5175+ ),
5176+ )
5177+ .subject(base);
5178+ let diagnostic = at_attribute(diagnostic, column, ctx);
5179+ ctx.diagnostics.push(diagnostic);
5180+ return false;
5181+ }
5120 let mut message = match (&suggestion, error) {
5182 let mut message = match (&suggestion, error) {
5121 (Some(closest), true) => {
5183 (Some(closest), true) => {
5122 format!(
5184 format!(
@@ -5138,12 +5200,19 @@ fn unknown_attribute(
5138 // sans-serif`: the comma ended the attribute, and the rest of the
5200 // sans-serif`: the comma ended the attribute, and the rest of the
5139 // value became one
5201 // value became one
5140 let rest_of_a_value = !base.starts_with(|c: char| c.is_ascii_alphabetic() || c == '-')
5202 let rest_of_a_value = !base.starts_with(|c: char| c.is_ascii_alphabetic() || c == '-')
5141− || (suggestion.is_none() && attr.value.is_none() && after_another);
5203+ || (suggestion.is_none() && attr.value.is_none() && before.is_some());
5142− if rest_of_a_value && error {
5204+ if joined.is_some() || (rest_of_a_value && error) {
5143 if !message.ends_with('?') {
5205 if !message.ends_with('?') {
5144 message.push('.');
5206 message.push('.');
5145 }
5207 }
5208+ match &joined {
5209+ Some((cut, joined)) => message.push_str(&format!(
5210+ " If it is the rest of `{}`, the comma ended it: write `{}`",
5211+ cut, joined
5212+ )),
5213+ None => message
5146− message.push_str(" A comma separates attributes: to keep one in a value, write `\\,`");
5214+ .push_str(" A comma separates attributes: to keep one in a value, write `\\,`"),
5215+ }
5147 }
5216 }
5148 let severity = if error {
5217 let severity = if error {
5149 Severity::Error
5218 Severity::Error
@@ -5285,7 +5354,13 @@ fn read_attrs(
5285 && !text_keys.contains(&attr.key);
5354 && !text_keys.contains(&attr.key);
5286 if checked_here
5355 if checked_here
5287 && (misspelled_parameter(attr, text_keys, line, column(0), ctx)
5356 && (misspelled_parameter(attr, text_keys, line, column(0), ctx)
5288− || !check_attribute(attr, line, column(0), !attrs.is_empty(), ctx))
5357+ || !check_attribute(
5358+ attr,
5359+ line,
5360+ column(0),
5361+ attrs.last().map(|(a, _)| a),
5362+ ctx,
5363+ ))
5289 {
5364 {
5290 continue;
5365 continue;
5291 }
5366 }
@@ -5389,11 +5464,11 @@ fn read_attrs(
5389 if validate {
5464 if validate {
5390 let seen = (normal_key(&attr.key), attr.html);
5465 let seen = (normal_key(&attr.key), attr.html);
5391 if out.seen_keys.contains(&seen) {
5466 if out.seen_keys.contains(&seen) {
5392− let message = if attr.html {
5467+ let written = if attr.html { "=" } else { "" };
5393− format!("duplicate attribute '{}='", attr.key)
5468+ let message = format!(
5394− } else {
5395− format!("duplicate attribute '{}': the later one wins", attr.key)
5469+ "duplicate attribute '{}{}': the later one wins",
5470+ attr.key, written
5396− };
5471+ );
5397 let diagnostic = Diagnostic::warning(code::DUPLICATE_ATTRIBUTE, line, message)
5472 let diagnostic = Diagnostic::warning(code::DUPLICATE_ATTRIBUTE, line, message)
5398 .subject(attr.key.as_str());
5473 .subject(attr.key.as_str());
5399 let diagnostic = at_attribute(diagnostic, column(0), ctx);
5474 let diagnostic = at_attribute(diagnostic, column(0), ctx);
@@ -5438,8 +5513,8 @@ fn read_attrs(
5438 // A style or a flag: its name is checked, and what can't be
5513 // A style or a flag: its name is checked, and what can't be
5439 // written into the page is an error and left out
5514 // written into the page is an error and left out
5440 let checked = validate && !attr.html && !(ctx.in_bundle && could_be_parameter(&attr));
5515 let checked = validate && !attr.html && !(ctx.in_bundle && could_be_parameter(&attr));
5441− let after_another = !out.attrs.is_empty();
5516+ let before = out.attrs.last().map(|(a, _)| a);
5442− if checked && !check_attribute(&attr, line, column(0), after_another, ctx) {
5517+ if checked && !check_attribute(&attr, line, column(0), before, ctx) {
5443 continue;
5518 continue;
5444 }
5519 }
5445
5520
@@ -5583,7 +5658,7 @@ fn prefixed_member(
5583 };
5658 };
5584 if validate {
5659 if validate {
5585 let fine = if attr.value.is_none() || could_be_parameter(attr) {
5660 let fine = if attr.value.is_none() || could_be_parameter(attr) {
5586− check_attribute(&prefixed, line, column, false, ctx)
5661+ check_attribute(&prefixed, line, column, None, ctx)
5587 } else {
5662 } else {
5588 check_prefixes(&prefixed, line, column, ctx)
5663 check_prefixes(&prefixed, line, column, ctx)
5589 };
5664 };
@@ -5994,6 +6069,7 @@ fn check_branch(
5994 attrs: branch.attrs().to_vec(),
6069 attrs: branch.attrs().to_vec(),
5995 span: token.span,
6070 span: token.span,
5996 closed: true,
6071 closed: true,
6072+ quoted_close: None,
5997 };
6073 };
5998 let grouped = syntax::Attr {
6074 let grouped = syntax::Attr {
5999 key: prefix.to_string(),
6075 key: prefix.to_string(),
@@ -6478,7 +6554,7 @@ fn dropped_by_the_element(elem: &Element, diagnostics: &mut Vec<Diagnostic>) {
6478 let styles: Vec<&Attribute> = elem
6554 let styles: Vec<&Attribute> = elem
6479 .attrs
6555 .attrs
6480 .iter()
6556 .iter()
6481− .filter(|a| !a.html && !crate::vocab::BOOLEAN_HTML_ATTRS.contains(&a.key.as_str()))
6557+ .filter(|a| !a.html && !crate::vocab::is_html_flag(&a.key))
6482 .collect();
6558 .collect();
6483 if !styles.is_empty() {
6559 if !styles.is_empty() {
6484 error(
6560 error(
@@ -6740,7 +6816,7 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
6740 continue;
6816 continue;
6741 }
6817 }
6742 (key, true, Some(_)) => key,
6818 (key, true, Some(_)) => key,
6743− (key, false, None) if crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key) => key,
6819+ (key, false, None) if crate::vocab::is_html_flag(key) => key,
6744 _ => {
6820 _ => {
6745 left_out.push(attr.key.clone());
6821 left_out.push(attr.key.clone());
6746 continue;
6822 continue;
crates/htmlang-core/src/syntax.rs+31
@@ -136,6 +136,10 @@ pub struct AttrList {
136 pub span: Span,
136 pub span: Span,
137 /// Whether the closing `]` was found.
137 /// Whether the closing `]` was found.
138 pub closed: bool,
138 pub closed: bool,
139+ /// The closing `]`, when it looks like it was meant to be inside a
140+ /// `'...'` string: a `'` before it in the list isn't closed, and the
141+ /// word right after it has one, as in `hx-on:click=alert(']')`.
142+ pub quoted_close: Option<Span>,
139}
143}
140
144
141/// One attribute of a list, as written.
145/// One attribute of a list, as written.
@@ -1506,6 +1510,25 @@ impl Reader<'_> {
1506 let (text, open) = self.text(0, self.text.len());
1510 let (text, open) = self.text(0, self.text.len());
1507 (NodeKind::Text(text), open)
1511 (NodeKind::Text(text), open)
1508 };
1512 };
1513+ if let NodeKind::Element(element) = &kind {
1514+ let closes = element
1515+ .chain
1516+ .iter()
1517+ .filter_map(|head| head.attrs.as_ref()?.quoted_close);
1518+ for close in closes {
1519+ problems.push(
1520+ Diagnostic::warning(
1521+ code::UNBALANCED_QUOTE,
1522+ close.line,
1523+ "this `]` ends the attribute list, but it looks like it is inside a \
1524+ `'...'` in the value: write `\\]` to keep it in the value"
1525+ .to_string(),
1526+ )
1527+ .column(close.column)
1528+ .source(self.text.to_string()),
1529+ );
1530+ }
1531+ }
1509 if open {
1532 if open {
1510 // An unclosed quote keeps the list open to the end of the file
1533 // An unclosed quote keeps the list open to the end of the file
1511 let mut quotes = 0;
1534 let mut quotes = 0;
@@ -1590,6 +1613,8 @@ impl Reader<'_> {
1590 fn attr_list(&self, open: usize, limit: usize) -> (AttrList, usize) {
1613 fn attr_list(&self, open: usize, limit: usize) -> (AttrList, usize) {
1591 let mut depth = 0;
1614 let mut depth = 0;
1592 let mut quoted = false;
1615 let mut quoted = false;
1616+ // The `'`s outside `"..."`, which don't quote anything in htmlang
1617+ let mut apostrophes = 0;
1593 let mut close = None;
1618 let mut close = None;
1594 let mut i = open;
1619 let mut i = open;
1595 while i < limit {
1620 while i < limit {
@@ -1603,6 +1628,7 @@ impl Reader<'_> {
1603 match c {
1628 match c {
1604 '"' => quoted = !quoted,
1629 '"' => quoted = !quoted,
1605 _ if quoted => {}
1630 _ if quoted => {}
1631+ '\'' => apostrophes += 1,
1606 '[' => depth += 1,
1632 '[' => depth += 1,
1607 ']' => {
1633 ']' => {
1608 depth -= 1;
1634 depth -= 1;
@@ -1622,10 +1648,15 @@ impl Reader<'_> {
1622 .filter_map(|(s, e)| self.attr(s, e))
1648 .filter_map(|(s, e)| self.attr(s, e))
1623 .collect();
1649 .collect();
1624 let end = close.map_or(limit, |c| c + 1);
1650 let end = close.map_or(limit, |c| c + 1);
1651+ let quoted_close = close.filter(|&c| {
1652+ let word = self.text[c + 1..limit].split(char::is_whitespace).next();
1653+ apostrophes % 2 == 1 && word.is_some_and(|word| word.contains('\''))
1654+ });
1625 let list = AttrList {
1655 let list = AttrList {
1626 attrs,
1656 attrs,
1627 span: self.span(open, end),
1657 span: self.span(open, end),
1628 closed: close.is_some(),
1658 closed: close.is_some(),
1659+ quoted_close: quoted_close.map(|c| self.span(c, c + 1)),
1629 };
1660 };
1630 (list, end)
1661 (list, end)
1631 }
1662 }
crates/htmlang-core/src/vocab.rs+27−1
@@ -5,7 +5,8 @@
5//! property (`spacing`, `center-x`, `width fill`, ...), handled in codegen;
5//! property (`spacing`, `center-x`, `width fill`, ...), handled in codegen;
6//! - a standard CSS property, copied into the generated CSS unchanged;
6//! - a standard CSS property, copied into the generated CSS unchanged;
7//! - an HTML attribute, written `key=value` (or bare, for booleans such as
7//! - an HTML attribute, written `key=value` (or bare, for booleans such as
8−//! `required`) and emitted on the element.
8+//! `required` and for `data-` and `hx-` attributes) and emitted on the
9+//! element.
9
10
10/// htmlang's own attributes (those that are neither a CSS property nor an
11/// htmlang's own attributes (those that are neither a CSS property nor an
11/// HTML attribute).
12/// HTML attribute).
@@ -158,6 +159,31 @@ pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
158 "readonly", "required", "reversed", "sandbox", "selected",
159 "readonly", "required", "reversed", "sandbox", "selected",
159];
160];
160
161
162+/// The families of HTML attributes whose names are open: ARIA's, the
163+/// author's own `data-` attributes, and htmx's `hx-` attributes.
164+pub const HTML_ATTRIBUTE_FAMILIES: &[&str] = &["aria-", "data-", "hx-"];
165+
166+/// An HTML attribute by its name: a common one, or one of a family whose
167+/// names are open (`aria-label`, `data-id`, `hx-get`).
168+pub fn is_html_attribute(name: &str) -> bool {
169+ HTML_ATTRIBUTES.contains(&name)
170+ || HTML_ATTRIBUTE_FAMILIES
171+ .iter()
172+ .any(|family| in_family(name, family))
173+}
174+
175+/// An HTML attribute written bare, as a flag, and rendered without a value:
176+/// a boolean one (`required`), or a `data-` or `hx-` attribute, whose
177+/// presence is what it says (`data-open`, htmx's `hx-preserve`). ARIA's
178+/// attributes always take a value.
179+pub fn is_html_flag(name: &str) -> bool {
180+ BOOLEAN_HTML_ATTRS.contains(&name) || in_family(name, "data-") || in_family(name, "hx-")
181+}
182+
183+fn in_family(name: &str, family: &str) -> bool {
184+ name.len() > family.len() && name.starts_with(family)
185+}
186+
161/// Common HTML attribute names, used to suggest `key=value` when one is
187/// Common HTML attribute names, used to suggest `key=value` when one is
162/// written like a style (`type email`). Any name works with `=`.
188/// written like a style (`type email`). Any name works with `=`.
163#[rustfmt::skip]
189#[rustfmt::skip]
src/bin/htmlang_lsp/docs.rs+6−4
@@ -399,10 +399,12 @@ fn attribute_hover(name: &str) -> Option<String> {
399 "**{name}** \u{2014} Boolean HTML attribute, written bare: `[{name}]`."
399 "**{name}** \u{2014} Boolean HTML attribute, written bare: `[{name}]`."
400 ));
400 ));
401 }
401 }
402− if vocab::HTML_ATTRIBUTES.contains(&name)
402+ if vocab::is_html_flag(name) {
403− || name.starts_with("aria-")
403+ return Some(format!(
404− || name.starts_with("data-")
404+ "**{name}** \u{2014} HTML attribute, written `{name}=value`, or bare for one with no value: `[{name}]`."
405− {
405+ ));
406+ }
407+ if vocab::is_html_attribute(name) {
406 return Some(format!(
408 return Some(format!(
407 "**{name}** \u{2014} HTML attribute, written `{name}=value`."
409 "**{name}** \u{2014} HTML attribute, written `{name}=value`."
408 ));
410 ));
src/main.rs+73−1
@@ -42,6 +42,9 @@ struct CompileConfig<'a> {
42 minify: bool,
42 minify: bool,
43 strict: bool,
43 strict: bool,
44 partial: bool,
44 partial: bool,
45+ /// The classes of the files built so far: each name, the hash of the
46+ /// style it stands for and the file it is from (see `class-collision`)
47+ classes: Option<&'a Mutex<HashMap<String, (u64, String)>>>,
45}
48}
46
49
47fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
50fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
@@ -134,7 +137,7 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
134 let _ = fs::write(&out_path, &error_html);
137 let _ = fs::write(&out_path, &error_html);
135 }
138 }
136 } else {
139 } else {
137− let html = htmlang::codegen::generate_with(
140+ let (html, classes) = htmlang::codegen::generate_with_classes(
138 &result.document,
141 &result.document,
139 &htmlang::codegen::CodegenOptions {
142 &htmlang::codegen::CodegenOptions {
140 dev: cfg.dev,
143 dev: cfg.dev,
@@ -142,6 +145,11 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
142 minify: cfg.minify,
145 minify: cfg.minify,
143 },
146 },
144 );
147 );
148+ if let Some(seen) = cfg.classes
149+ && class_collisions(input_path, classes, seen, cfg)
150+ {
151+ return (true, result.included_files);
152+ }
145 match fs::write(&out_path, &html) {
153 match fs::write(&out_path, &html) {
146 Ok(()) => eprintln!("wrote {}", out_path.display()),
154 Ok(()) => eprintln!("wrote {}", out_path.display()),
147 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
155 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
@@ -166,6 +174,49 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
166 (has_errors, result.included_files)
174 (has_errors, result.included_files)
167}
175}
168
176
177+/// Whether a class of `file` has the name of a different style in a file
178+/// built before it, which it reports: a class's name is a hash of its
179+/// style, so a page and a fragment built together mean the same by one
180+/// name, and two styles whose hashes start alike would style each other's
181+/// elements where one file's HTML goes into the other's page.
182+fn class_collisions(
183+ file: &str,
184+ classes: Vec<htmlang::codegen::Class>,
185+ seen: &Mutex<HashMap<String, (u64, String)>>,
186+ cfg: &CompileConfig,
187+) -> bool {
188+ let mut seen = seen.lock().unwrap();
189+ let mut collided = false;
190+ for class in classes {
191+ let (style, other) = seen
192+ .entry(class.name.clone())
193+ .or_insert_with(|| (class.style, file.to_string()));
194+ if *style == class.style {
195+ continue;
196+ }
197+ collided = true;
198+ let message = format!(
199+ "{} names two different styles, in {} and in {}: a class's name is a hash of its \
200+ style, and these two hash alike. Change either style a little (another value, or \
201+ one more attribute) to give it another name",
202+ class.name, other, file
203+ );
204+ let code = htmlang::diagnostic::code::CLASS_COLLISION;
205+ match cfg.json_collector.filter(|_| cfg.format_json) {
206+ Some(collector) => collector.lock().unwrap().push(DiagnosticJson {
207+ file: file.to_string(),
208+ line: 0,
209+ column: None,
210+ code,
211+ severity: "error".to_string(),
212+ message,
213+ }),
214+ None => eprintln!("error[{}]: {}: {}", code, file, message),
215+ }
216+ }
217+ collided
218+}
219+
169fn json_escape_string(s: &str) -> String {
220fn json_escape_string(s: &str) -> String {
170 let mut out = String::with_capacity(s.len() + 2);
221 let mut out = String::with_capacity(s.len() + 2);
171 out.push('"');
222 out.push('"');
@@ -648,9 +699,11 @@ fn main() {
648 // Compile files in parallel
699 // Compile files in parallel
649 let build_start = std::time::Instant::now();
700 let build_start = std::time::Instant::now();
650 let any_errors = std::sync::atomic::AtomicBool::new(false);
701 let any_errors = std::sync::atomic::AtomicBool::new(false);
702+ let classes = Mutex::new(HashMap::new());
651 std::thread::scope(|s| {
703 std::thread::scope(|s| {
652 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
704 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
653 let any_errors = &any_errors;
705 let any_errors = &any_errors;
706+ let classes = &classes;
654 s.spawn(move || {
707 s.spawn(move || {
655 let path_str = file.to_string_lossy().to_string();
708 let path_str = file.to_string_lossy().to_string();
656 let (has_errors, _) = compile(
709 let (has_errors, _) = compile(
@@ -659,6 +712,7 @@ fn main() {
659 output_path: effective_out.as_deref(),
712 output_path: effective_out.as_deref(),
660 minify: build_minify,
713 minify: build_minify,
661 strict: build_strict,
714 strict: build_strict,
715+ classes: Some(classes),
662 ..Default::default()
716 ..Default::default()
663 },
717 },
664 );
718 );
@@ -1642,6 +1696,24 @@ mod tests {
1642 let _ = fs::remove_dir_all(&dir);
1696 let _ = fs::remove_dir_all(&dir);
1643 }
1697 }
1644
1698
1699+ #[test]
1700+ fn a_class_name_that_stands_for_two_styles_fails_the_build() {
1701+ use htmlang::codegen::Class;
1702+ let class = |name: &str, style| Class {
1703+ name: name.to_string(),
1704+ style,
1705+ };
1706+ let seen = Mutex::new(HashMap::new());
1707+ let cfg = CompileConfig::default();
1708+ let page = vec![class("hl-2k9xq0m", 1), class("hl-0000000", 2)];
1709+ assert!(!class_collisions("page.hl", page, &seen, &cfg));
1710+ // The same style by the same name in another file is fine
1711+ let fragment = vec![class("hl-2k9xq0m", 1), class("hl-1111111", 3)];
1712+ assert!(!class_collisions("fragment.hl", fragment, &seen, &cfg));
1713+ let other = vec![class("hl-0000000", 4)];
1714+ assert!(class_collisions("other.hl", other, &seen, &cfg));
1715+ }
1716+
1645 #[test]
1717 #[test]
1646 fn json_escape_handles_control_characters() {
1718 fn json_escape_handles_control_characters() {
1647 assert_eq!(
1719 assert_eq!(
src/serve.rs+128−50
@@ -64,11 +64,10 @@ where
64 }
64 }
65
65
66 let request = String::from_utf8_lossy(&buf[..n]);
66 let request = String::from_utf8_lossy(&buf[..n]);
67− let path = request
67+ let (method, path) = request_line(&request);
68− .lines()
68+ let Some(head) = reads(method) else {
69− .next()
69+ return method_not_allowed(&mut stream).await;
70− .and_then(|line| line.split_whitespace().nth(1))
70+ };
71− .unwrap_or("/");
72
71
73 if path == "/_events" {
72 if path == "/_events" {
74 return handle_sse(stream, reload_rx).await;
73 return handle_sse(stream, reload_rx).await;
@@ -91,13 +90,8 @@ where
91 } else {
90 } else {
92 // Generate directory listing
91 // Generate directory listing
93 let listing = generate_directory_listing(&root_dir);
92 let listing = generate_directory_listing(&root_dir);
94− let header = format!(
93+ let html = "text/html; charset=utf-8";
95− "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
94+ return respond(&mut stream, "200 OK", html, listing.as_bytes(), head).await;
96− listing.len(),
97− );
98− stream.write_all(header.as_bytes()).await?;
99− stream.write_all(listing.as_bytes()).await?;
100− return Ok(());
101 }
95 }
102 } else {
96 } else {
103 // The file itself, then `<path>.html`, then `<path>/index.html`.
97 // The file itself, then `<path>.html`, then `<path>/index.html`.
@@ -130,23 +124,67 @@ where
130 return Ok(());
124 return Ok(());
131 }
125 }
132 };
126 };
133− let content_type = content_type_for(&fp);
127+ respond(&mut stream, "200 OK", content_type_for(&fp), &body, head).await?;
134− let header = format!(
135− "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
136− content_type,
137− body.len(),
138− );
139− stream.write_all(header.as_bytes()).await?;
140− stream.write_all(&body).await?;
141 }
128 }
142 None => {
129 None => {
143− send_404(&mut stream, clean_path).await?;
130+ send_404(&mut stream, clean_path, head).await?;
144 }
131 }
145 }
132 }
146
133
147 Ok(())
134 Ok(())
148}
135}
149
136
137+/// The method and the target of a request, from its first line.
138+fn request_line(request: &str) -> (&str, &str) {
139+ let mut words = request.lines().next().unwrap_or("").split_whitespace();
140+ (words.next().unwrap_or("GET"), words.next().unwrap_or("/"))
141+}
142+
143+/// Whether the server reads files for `method`: `Some(true)` for HEAD (the
144+/// headers only), `Some(false)` for GET, and `None` for any other method,
145+/// which a server of files can't answer: a form that posts to a page, or
146+/// htmx's `hx-post`, gets 405 rather than the page.
147+fn reads(method: &str) -> Option<bool> {
148+ match method {
149+ "GET" => Some(false),
150+ "HEAD" => Some(true),
151+ _ => None,
152+ }
153+}
154+
155+async fn method_not_allowed<S>(stream: &mut S) -> std::io::Result<()>
156+where
157+ S: AsyncRead + AsyncWrite + Unpin + Send,
158+{
159+ stream
160+ .write_all(
161+ b"HTTP/1.1 405 Method Not Allowed\r\nAllow: GET, HEAD\r\nContent-Length: 0\r\n\r\n",
162+ )
163+ .await
164+}
165+
166+/// A response with `body`, which a HEAD request doesn't get.
167+async fn respond<S>(
168+ stream: &mut S,
169+ status: &str,
170+ content_type: &str,
171+ body: &[u8],
172+ head: bool,
173+) -> std::io::Result<()>
174+where
175+ S: AsyncRead + AsyncWrite + Unpin + Send,
176+{
177+ let header = format!(
178+ "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
179+ body.len(),
180+ );
181+ stream.write_all(header.as_bytes()).await?;
182+ if !head {
183+ stream.write_all(body).await?;
184+ }
185+ Ok(())
186+}
187+
150/// Generate a directory listing HTML page showing available .html pages.
188/// Generate a directory listing HTML page showing available .html pages.
151fn generate_directory_listing(dir: &Path) -> String {
189fn generate_directory_listing(dir: &Path) -> String {
152 let mut pages = Vec::new();
190 let mut pages = Vec::new();
@@ -214,16 +252,15 @@ where
214 }
252 }
215
253
216 let request = String::from_utf8_lossy(&buf[..n]);
254 let request = String::from_utf8_lossy(&buf[..n]);
217− let path = request
255+ let (method, path) = request_line(&request);
218− .lines()
256+ let Some(head) = reads(method) else {
219− .next()
257+ return method_not_allowed(&mut stream).await;
220− .and_then(|line| line.split_whitespace().nth(1))
258+ };
221− .unwrap_or("/");
222
259
223 if path == "/_events" {
260 if path == "/_events" {
224 handle_sse(stream, reload_rx).await
261 handle_sse(stream, reload_rx).await
225 } else {
262 } else {
226− handle_file(stream, &html_path, root_dir, path).await
263+ handle_file(stream, &html_path, root_dir, path, head).await
227 }
264 }
228}
265}
229
266
@@ -281,6 +318,7 @@ async fn handle_file<S>(
281 html_path: &Path,
318 html_path: &Path,
282 root_dir: Option<&Path>,
319 root_dir: Option<&Path>,
283 request_path: &str,
320 request_path: &str,
321+ head: bool,
284) -> std::io::Result<()>
322) -> std::io::Result<()>
285where
323where
286 S: AsyncRead + AsyncWrite + Unpin + Send,
324 S: AsyncRead + AsyncWrite + Unpin + Send,
@@ -302,7 +340,7 @@ where
302 } else if is_within(dir, &requested) {
340 } else if is_within(dir, &requested) {
303 requested
341 requested
304 } else {
342 } else {
305− send_404(&mut stream, request_path).await?;
343+ send_404(&mut stream, request_path, head).await?;
306 return Ok(());
344 return Ok(());
307 };
345 };
308
346
@@ -315,20 +353,13 @@ where
315 }
353 }
316 }
354 }
317 Err(_) => {
355 Err(_) => {
318− send_404(&mut stream, request_path).await?;
356+ send_404(&mut stream, request_path, head).await?;
319 return Ok(());
357 return Ok(());
320 }
358 }
321 };
359 };
322
360
323 let content_type = content_type_for(&file_path);
361 let content_type = content_type_for(&file_path);
324− let header = format!(
362+ respond(&mut stream, "200 OK", content_type, &body, head).await
325− "HTTP/1.1 200 OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
326− body.len(),
327− );
328− stream.write_all(header.as_bytes()).await?;
329− stream.write_all(&body).await?;
330−
331− Ok(())
332}
363}
333
364
334/// Map a request target (`/a/b.css?v=2`) to a path under `root`. Strips the
365/// Map a request target (`/a/b.css?v=2`) to a path under `root`. Strips the
@@ -373,7 +404,7 @@ fn percent_decode(s: &str) -> Option<String> {
373 String::from_utf8(out).ok()
404 String::from_utf8(out).ok()
374}
405}
375
406
376−async fn send_404<S>(stream: &mut S, path: &str) -> std::io::Result<()>
407+async fn send_404<S>(stream: &mut S, path: &str, head: bool) -> std::io::Result<()>
377where
408where
378 S: AsyncRead + AsyncWrite + Unpin + Send,
409 S: AsyncRead + AsyncWrite + Unpin + Send,
379{
410{
@@ -390,15 +421,13 @@ where
390 <a href=\"/\">Back to index</a></div></body></html>",
421 <a href=\"/\">Back to index</a></div></body></html>",
391 path.replace('<', "&lt;").replace('>', "&gt;")
422 path.replace('<', "&lt;").replace('>', "&gt;")
392 );
423 );
393− let header = format!(
424+ let html = "text/html; charset=utf-8";
394− "HTTP/1.1 404 Not Found\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nCache-Control: no-cache\r\n\r\n",
395− body.len()
396− );
397− stream.write_all(header.as_bytes()).await?;
398− stream.write_all(body.as_bytes()).await?;
425+ respond(stream, "404 Not Found", html, body.as_bytes(), head).await
399− Ok(())
400}
426}
401
427
428+/// The page with the reload script before its `</body>`. A fragment
429+/// (`--partial`) has no `</body>`, and is served as it is: it goes into
430+/// a page that has the script, and a second one would reload it twice.
402fn inject_reload_script(content: &[u8]) -> Vec<u8> {
431fn inject_reload_script(content: &[u8]) -> Vec<u8> {
403 let html = String::from_utf8_lossy(content);
432 let html = String::from_utf8_lossy(content);
404 match html.rfind("</body>") {
433 match html.rfind("</body>") {
@@ -410,11 +439,7 @@ fn inject_reload_script(content: &[u8]) -> Vec<u8> {
410 result.push_str(&html[pos..]);
439 result.push_str(&html[pos..]);
411 result.into_bytes()
440 result.into_bytes()
412 }
441 }
413− None => {
414− let mut result = content.to_vec();
442+ None => content.to_vec(),
415− result.extend_from_slice(RELOAD_SCRIPT.as_bytes());
416− result
417− }
418 }
443 }
419}
444}
420
445
@@ -477,4 +502,57 @@ mod tests {
477 assert_eq!(resolve_request_path(root, "/bad%zz"), None);
502 assert_eq!(resolve_request_path(root, "/bad%zz"), None);
478 assert_eq!(resolve_request_path(root, "é"), Some(root.join("é")));
503 assert_eq!(resolve_request_path(root, "é"), Some(root.join("é")));
479 }
504 }
505+
506+ /// The response of the directory server at `root` to `request`.
507+ async fn respond_to(root: &Path, request: &str) -> String {
508+ let (mut client, server) = tokio::io::duplex(1 << 16);
509+ let (reload, _) = broadcast::channel(1);
510+ let server = tokio::spawn(handle_dir_request(
511+ server,
512+ root.to_path_buf(),
513+ reload.subscribe(),
514+ ));
515+ client.write_all(request.as_bytes()).await.unwrap();
516+ let mut response = Vec::new();
517+ client.read_to_end(&mut response).await.unwrap();
518+ server.await.unwrap().unwrap();
519+ String::from_utf8(response).unwrap()
520+ }
521+
522+ #[tokio::test]
523+ async fn files_are_read_by_get_and_head_only() {
524+ let root = std::env::temp_dir().join(format!("htmlang-serve-{}", std::process::id()));
525+ std::fs::create_dir_all(&root).unwrap();
526+ std::fs::write(
527+ root.join("page.html"),
528+ "<html><body><p>Hi</p></body></html>",
529+ )
530+ .unwrap();
531+ std::fs::write(root.join("frag.html"), "<p class=\"hl-a\">Hi</p>").unwrap();
532+
533+ let page = respond_to(&root, "GET /page HTTP/1.1\r\n\r\n").await;
534+ assert!(page.starts_with("HTTP/1.1 200 OK"), "{page}");
535+ assert!(page.contains(RELOAD_SCRIPT), "{page}");
536+
537+ // A fragment goes into a page that already reloads
538+ let fragment = respond_to(&root, "GET /frag.html HTTP/1.1\r\n\r\n").await;
539+ assert!(
540+ fragment.ends_with("\r\n\r\n<p class=\"hl-a\">Hi</p>"),
541+ "{fragment}"
542+ );
543+
544+ let head = respond_to(&root, "HEAD /page HTTP/1.1\r\n\r\n").await;
545+ assert!(head.starts_with("HTTP/1.1 200 OK"), "{head}");
546+ assert!(head.ends_with("\r\n\r\n"), "{head}");
547+
548+ for method in ["POST", "PUT", "DELETE"] {
549+ let request = format!("{method} /page HTTP/1.1\r\nContent-Length: 3\r\n\r\nq=1");
550+ let response = respond_to(&root, &request).await;
551+ assert!(
552+ response.starts_with("HTTP/1.1 405 Method Not Allowed\r\nAllow: GET, HEAD"),
553+ "{response}"
554+ );
555+ }
556+ let _ = std::fs::remove_dir_all(&root);
557+ }
480}
558}
tests/common/mod.rsnew file+140
@@ -0,0 +1,140 @@
1+//! What the integration tests share.
2+//!
3+//! A generated class is named by a hash of its style (`hl-2k9xq0m`, see
4+//! `htmlang::codegen::CLASS_DIGITS`), and a function's scoped `@style` by
5+//! the function and a hash of the stylesheet (`hl-fn-card-1x0a9zq`). The
6+//! tests read the HTML with the classes numbered instead, `hl-a`, `hl-b`,
7+//! ... in the order they first appear in a `class=`, and `hl-fn-card`: a
8+//! test says which element has which class, and a snapshot changes when
9+//! the styles do, not when a hash does. The names themselves are tested
10+//! in `codegen.rs` and `codegen_tests.rs`.
11+
12+#![allow(dead_code)]
13+
14+/// `htmlang::codegen`, with the HTML's classes numbered.
15+pub mod codegen {
16+ pub use htmlang::codegen::*;
17+
18+ use super::numbered;
19+ use htmlang::ast::Document;
20+
21+ pub fn generate(doc: &Document) -> String {
22+ numbered(&htmlang::codegen::generate(doc))
23+ }
24+
25+ pub fn generate_dev(doc: &Document) -> String {
26+ numbered(&htmlang::codegen::generate_dev(doc))
27+ }
28+
29+ pub fn generate_partial(doc: &Document) -> String {
30+ numbered(&htmlang::codegen::generate_partial(doc))
31+ }
32+
33+ pub fn generate_partial_dev(doc: &Document) -> String {
34+ numbered(&htmlang::codegen::generate_partial_dev(doc))
35+ }
36+
37+ pub fn generate_minified(doc: &Document) -> String {
38+ numbered(&htmlang::codegen::generate_minified(doc))
39+ }
40+
41+ pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String {
42+ numbered(&htmlang::codegen::generate_with(doc, opts))
43+ }
44+}
45+
46+/// `html` with its generated classes numbered in the order they first
47+/// appear in a `class=` (then in the CSS), and the hash left out of a
48+/// function's scoped class.
49+pub fn numbered(html: &str) -> String {
50+ let mut order: Vec<&str> = Vec::new();
51+ for value in html.split("class=\"").skip(1) {
52+ let value = value.split('"').next().unwrap_or("");
53+ for name in value.split_whitespace() {
54+ if let Some(Name::Generated(name)) = name_at(name) {
55+ if !order.contains(&name) {
56+ order.push(name);
57+ }
58+ }
59+ }
60+ }
61+ let mut out = String::with_capacity(html.len());
62+ let mut rest = html;
63+ while let Some(at) = rest.find("hl-") {
64+ out.push_str(&rest[..at]);
65+ rest = &rest[at..];
66+ match name_at(rest) {
67+ Some(Name::Generated(name)) => {
68+ let index = order.iter().position(|n| *n == name).unwrap_or_else(|| {
69+ order.push(name);
70+ order.len() - 1
71+ });
72+ out.push_str("hl-");
73+ out.push_str(&sequential(index));
74+ rest = &rest[name.len()..];
75+ }
76+ Some(Name::Scoped { name, function }) => {
77+ out.push_str("hl-fn-");
78+ out.push_str(function);
79+ rest = &rest[name.len()..];
80+ }
81+ None => {
82+ out.push_str("hl-");
83+ rest = &rest[3..];
84+ }
85+ }
86+ }
87+ out.push_str(rest);
88+ out
89+}
90+
91+enum Name<'a> {
92+ /// `hl-2k9xq0m`
93+ Generated(&'a str),
94+ /// `hl-fn-card-1x0a9zq`, and `card`
95+ Scoped { name: &'a str, function: &'a str },
96+}
97+
98+/// The generated class `text` starts with, if any.
99+fn name_at(text: &str) -> Option<Name<'_>> {
100+ let after = text.strip_prefix("hl-")?;
101+ let word = |s: &str, dash: bool| {
102+ s.find(|c: char| !(c.is_ascii_digit() || c.is_ascii_lowercase() || (dash && c == '-')))
103+ .unwrap_or(s.len())
104+ };
105+ if let Some(scoped) = after.strip_prefix("fn-") {
106+ let end = word(scoped, true);
107+ let (function, hash) = scoped[..end].rsplit_once('-')?;
108+ let digits = htmlang::codegen::CLASS_DIGITS;
109+ return (hash.len() == digits).then(|| Name::Scoped {
110+ name: &text[..6 + end],
111+ function,
112+ });
113+ }
114+ let end = word(after, false);
115+ let generated = end >= htmlang::codegen::CLASS_DIGITS && !after[end..].starts_with('-');
116+ generated.then(|| Name::Generated(&text[..3 + end]))
117+}
118+
119+/// The names classes had before they were hashes: a..z, then aa..a9,
120+/// ba..b9, ..., z9, then aaa, ...
121+fn sequential(index: usize) -> String {
122+ const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
123+ let mut n = index;
124+ let mut len = 1;
125+ let mut count = 26usize;
126+ while n >= count {
127+ n -= count;
128+ len += 1;
129+ count = count.saturating_mul(36);
130+ }
131+ let mut tail = Vec::with_capacity(len - 1);
132+ for _ in 1..len {
133+ tail.push(REST[n % 36]);
134+ n /= 36;
135+ }
136+ let mut name = String::with_capacity(len);
137+ name.push((b'a' + n as u8) as char);
138+ name.extend(tail.iter().rev().map(|&b| b as char));
139+ name
140+}
tests/regressions.rs+162−15
@@ -1,9 +1,11 @@
1//! Regression tests for bugs found in review. Each test names the behavior
1//! Regression tests for bugs found in review. Each test names the behavior
2//! that used to be wrong.
2//! that used to be wrong.
3
3
4+mod common;
5+
4use std::path::PathBuf;
6use std::path::PathBuf;
5
7
6−use htmlang::codegen;
8+use common::codegen;
7use htmlang::parser::{self, Severity};
9use htmlang::parser::{self, Severity};
8
10
9fn compile(input: &str) -> String {
11fn compile(input: &str) -> String {
@@ -478,15 +480,14 @@ fn inline_element_attributes_can_continue_on_the_next_line() {
478 "@paragraph\n Press {@kbd [\n padding 2 6, border-radius 4\n ] Ctrl+K} to search.\n",
480 "@paragraph\n Press {@kbd [\n padding 2 6, border-radius 4\n ] Ctrl+K} to search.\n",
479 );
481 );
480 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
482 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
481− let html = htmlang::codegen::generate(&result.document);
483+ let html = codegen::generate(&result.document);
482 assert!(html.contains(">Ctrl+K</kbd> to search."), "{}", html);
484 assert!(html.contains(">Ctrl+K</kbd> to search."), "{}", html);
483}
485}
484
486
485#[test]
487#[test]
486fn lines_under_text_are_its_siblings() {
488fn lines_under_text_are_its_siblings() {
487− let html = htmlang::codegen::generate(
489+ let html =
488− &htmlang::parser::parse("@el\n Some text\n more text\n").document,
490+ codegen::generate(&htmlang::parser::parse("@el\n Some text\n more text\n").document);
489− );
490 assert!(
491 assert!(
491 html.contains("<span>Some text</span><span>more text</span>"),
492 html.contains("<span>Some text</span><span>more text</span>"),
492 "{}",
493 "{}",
@@ -546,16 +547,28 @@ fn css_values_pass_through() {
546#[test]
547#[test]
547fn page_attributes_are_checked_like_any_element_s() {
548fn page_attributes_are_checked_like_any_element_s() {
548 // `canonical` belongs in a `@head` link: on @page it is an unknown CSS
549 // `canonical` belongs in a `@head` link: on @page it is an unknown CSS
549− // property (for <body>), reported rather than dropped
550+ // property (for <body>), reported rather than dropped. Right after an
551+ // HTML attribute, it may be the rest of that one's value too.
552+ for (source, code) in [
553+ (
554+ "@page [canonical https://x.dev, lang=en] T\n",
555+ "unknown-attribute",
556+ ),
557+ (
558+ "@page [lang=en, canonical https://x.dev] T\n",
559+ "split-value",
560+ ),
561+ ] {
550− let result = parser::parse("@page [lang=en, canonical https://x.dev] T\n");
562+ let result = parser::parse(source);
551− assert!(
563+ assert!(
552− result
564+ result
553− .diagnostics
565+ .diagnostics
554− .iter()
566+ .iter()
555− .any(|d| d.code == "unknown-attribute" && d.message.contains("'canonical'")),
567+ .any(|d| d.code == code && d.message.contains("'canonical'")),
556− "{:?}",
568+ "{:?}",
557− result.diagnostics
569+ result.diagnostics
558− );
570+ );
571+ }
559}
572}
560
573
561// --- One grammar: the syntax tree and the checks built on it ---
574// --- One grammar: the syntax tree and the checks built on it ---
@@ -2322,3 +2335,137 @@ fn a_leading_argument_that_can_t_be_one_is_reported() {
2322 .collect();
2335 .collect();
2323 assert_eq!(lines, [1, 2], "{:?}", d);
2336 assert_eq!(lines, [1, 2], "{:?}", d);
2324}
2337}
2338+
2339+// --- Found while looking at htmx's attributes ---
2340+
2341+#[test]
2342+fn an_attribute_after_a_bundle_or_a_function_s_own_wins() {
2343+ // The browser keeps the first of two attributes with one name, so
2344+ // writing both made the bundle's win over the one written after it
2345+ let out = compile("@let live [hx-get=/x, hx-trigger=load]\n@el [$live, hx-trigger=click] A\n");
2346+ assert!(
2347+ out.contains(r#"hx-get="/x" hx-trigger="click">"#),
2348+ "{}",
2349+ out
2350+ );
2351+ assert_eq!(out.matches("hx-trigger=").count(), 1, "{}", out);
2352+ let out = compile(
2353+ "@let @action [url]\n @button [type=button, hx-post=$url, hx-target=#result] Save\n@action [url /save, hx-target=#other]\n",
2354+ );
2355+ assert!(out.contains(r##"hx-target="#other""##), "{}", out);
2356+ assert_eq!(out.matches("hx-target=").count(), 1, "{}", out);
2357+ let out = compile("@page [lang=en, lang=fr] T\n@text a\n");
2358+ assert!(out.contains(r#"<html lang="fr">"#), "{}", out);
2359+ let d = diagnostics_of("@el [hx-get=/a, hx-get=/b] B\n");
2360+ assert!(
2361+ d.iter()
2362+ .any(|d| d.code == "duplicate-attribute" && d.message.contains("later one wins")),
2363+ "{:?}",
2364+ d
2365+ );
2366+}
2367+
2368+#[test]
2369+fn a_comma_that_ends_an_html_attribute_s_value_is_reported() {
2370+ // The rest of `hx-trigger=load, every 2s` was a CSS property `every`,
2371+ // with a warning that didn't say why
2372+ let d = diagnostics_of("@el [hx-get=/poll, hx-trigger=load, every 2s] A\n");
2373+ let split = d
2374+ .iter()
2375+ .find(|d| d.code == "split-value")
2376+ .expect("split-value");
2377+ assert_eq!(split.severity, Severity::Error);
2378+ assert!(
2379+ split.message.contains(r"`hx-trigger=load\, every 2s`"),
2380+ "{}",
2381+ split.message
2382+ );
2383+ // A bare word, as in `hx-confirm=Sure, really?`
2384+ assert!(coded_in(
2385+ &diagnostics_of("@el [hx-confirm=Sure, really?] A\n"),
2386+ "split-value"
2387+ ));
2388+ // Close to a known name, it is that name misspelled, with a note
2389+ let d = diagnostics_of("@el [id=x, colr red] A\n");
2390+ let typo = d
2391+ .iter()
2392+ .find(|d| d.code == "unknown-attribute")
2393+ .expect("unknown-attribute");
2394+ assert!(typo.message.contains("'color'") && typo.message.contains(r"`id=x\, colr red`"));
2395+ // An escaped comma, or a known attribute after the comma, is fine
2396+ let out = compile("@el [hx-trigger=load\\, every 2s, color red] A\n");
2397+ assert!(out.contains(r#"hx-trigger="load, every 2s""#), "{}", out);
2398+}
2399+
2400+#[test]
2401+fn hx_attributes_are_html_attributes() {
2402+ // `hx-get /items` was a CSS property, with a warning
2403+ let d = diagnostics_of("@button [hx-get /items] B\n");
2404+ let form = d
2405+ .iter()
2406+ .find(|d| d.code == "html-attribute-form")
2407+ .expect("form");
2408+ assert_eq!(form.severity, Severity::Error);
2409+ assert!(form.message.contains("`hx-get=/items`"), "{}", form.message);
2410+ // htmx's flags are written bare, like `data-` ones; ARIA's take a value
2411+ let out = compile("@el [hx-preserve, data-open, id=player] A\n");
2412+ assert!(
2413+ out.contains(r#"id="player" hx-preserve data-open>"#),
2414+ "{}",
2415+ out
2416+ );
2417+ assert!(coded_in(
2418+ &diagnostics_of("@el [aria-hidden] A\n"),
2419+ "html-attribute-form"
2420+ ));
2421+ assert!(coded_in(
2422+ &diagnostics_of("@el [md:hx-preserve] A\n"),
2423+ "invalid-prefix"
2424+ ));
2425+}
2426+
2427+#[test]
2428+fn link_text_taken_as_the_url_is_reported() {
2429+ // `Open` became the href, and `panel` the text, without a word
2430+ let d = diagnostics_of("@link [hx-get=/panel, hx-target=#main] Open panel\n");
2431+ let d = d
2432+ .iter()
2433+ .find(|d| d.code == "text-as-url")
2434+ .expect("text-as-url");
2435+ assert_eq!(d.severity, Severity::Warning);
2436+ assert!(
2437+ d.message.contains("'Open'") && d.message.contains("href"),
2438+ "{}",
2439+ d.message
2440+ );
2441+ assert!(coded_in(&diagnostics_of("@form Sign in\n"), "text-as-url"));
2442+ for fine in [
2443+ "@link /about About\n",
2444+ "@link about About us\n",
2445+ "@link ./Home Home\n",
2446+ "@link https://example.com Out\n",
2447+ "@link $url More\n",
2448+ "@image [alt=Logo] Logo.svg\n",
2449+ "@optgroup Citrus\n @option Lemon\n",
2450+ ] {
2451+ assert!(!coded_in(&diagnostics_of(fine), "text-as-url"), "{}", fine);
2452+ }
2453+}
2454+
2455+#[test]
2456+fn a_bracket_inside_single_quotes_is_reported() {
2457+ // The `]` in `alert(']')` ended the list, and the rest became text
2458+ let d = diagnostics_of("@button [type=button, hx-on:click=alert(']')] Go\n");
2459+ let d = d
2460+ .iter()
2461+ .find(|d| d.code == "unbalanced-quote")
2462+ .expect("unbalanced-quote");
2463+ assert_eq!(d.column, Some(41));
2464+ // Escaped, it is in the value; an apostrophe in text is fine
2465+ let out = compile("@button [type=button, hx-on:click=alert('\\]')] Go\n");
2466+ assert!(out.contains(r#"hx-on:click="alert(']')">Go"#), "{}", out);
2467+ assert!(!coded_in(
2468+ &diagnostics_of("@el [title=Don't] It's fine\n"),
2469+ "unbalanced-quote"
2470+ ));
2471+}
tests/snapshots.rs+55−52
@@ -1,3 +1,6 @@
1+mod common;
2+
3+use common::codegen;
1use std::fs;
4use std::fs;
2use std::path::Path;
5use std::path::Path;
3
6
@@ -11,7 +14,7 @@ fn compile(input: &str) -> String {
11 "unexpected parse errors: {:?}",
14 "unexpected parse errors: {:?}",
12 result.diagnostics
15 result.diagnostics
13 );
16 );
14− htmlang::codegen::generate(&result.document)
17+ codegen::generate(&result.document)
15}
18}
16
19
17fn compile_with_base(input: &str, base: &Path) -> String {
20fn compile_with_base(input: &str, base: &Path) -> String {
@@ -24,7 +27,7 @@ fn compile_with_base(input: &str, base: &Path) -> String {
24 "unexpected parse errors: {:?}",
27 "unexpected parse errors: {:?}",
25 result.diagnostics
28 result.diagnostics
26 );
29 );
27− htmlang::codegen::generate(&result.document)
30+ codegen::generate(&result.document)
28}
31}
29
32
30fn parse_diagnostics(input: &str) -> Vec<htmlang::parser::Diagnostic> {
33fn parse_diagnostics(input: &str) -> Vec<htmlang::parser::Diagnostic> {
@@ -256,7 +259,7 @@ fn error_unknown_attribute() {
256 diags
259 diags
257 );
260 );
258 assert!(
261 assert!(
259− htmlang::codegen::generate(&result.document).contains("bakground:red"),
262+ codegen::generate(&result.document).contains("bakground:red"),
260 "an unknown property is passed through"
263 "an unknown property is passed through"
261 );
264 );
262}
265}
@@ -313,7 +316,7 @@ fn values_pass_through_unchecked() {
313 color rebeccapurple, width fit-content, font-weight 450, display blok]\n x";
316 color rebeccapurple, width fit-content, font-weight 450, display blok]\n x";
314 let result = htmlang::parser::parse(src);
317 let result = htmlang::parser::parse(src);
315 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
318 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
316− let html = htmlang::codegen::generate(&result.document);
319+ let html = codegen::generate(&result.document);
317 for css in [
320 for css in [
318 "padding:abc",
321 "padding:abc",
319 "opacity:50%",
322 "opacity:50%",
@@ -2748,7 +2751,7 @@ fn test_variable_filters() {
2748 .iter()
2751 .iter()
2749 .all(|d| d.severity != htmlang::parser::Severity::Error)
2752 .all(|d| d.severity != htmlang::parser::Severity::Error)
2750 );
2753 );
2751− let html = htmlang::codegen::generate(&result.document);
2754+ let html = codegen::generate(&result.document);
2752 assert!(
2755 assert!(
2753 html.contains("HELLO"),
2756 html.contains("HELLO"),
2754 "uppercase filter should work, got: {}",
2757 "uppercase filter should work, got: {}",
@@ -2756,7 +2759,7 @@ fn test_variable_filters() {
2756 );
2759 );
2757
2760
2758 let result = htmlang::parser::parse("@let name HELLO\n@text ${lowercase($name)}");
2761 let result = htmlang::parser::parse("@let name HELLO\n@text ${lowercase($name)}");
2759− let html = htmlang::codegen::generate(&result.document);
2762+ let html = codegen::generate(&result.document);
2760 assert!(
2763 assert!(
2761 html.contains("hello"),
2764 html.contains("hello"),
2762 "lowercase filter should work, got: {}",
2765 "lowercase filter should work, got: {}",
@@ -2764,7 +2767,7 @@ fn test_variable_filters() {
2764 );
2767 );
2765
2768
2766 let result = htmlang::parser::parse("@let name hello\n@text ${capitalize($name)}");
2769 let result = htmlang::parser::parse("@let name hello\n@text ${capitalize($name)}");
2767− let html = htmlang::codegen::generate(&result.document);
2770+ let html = codegen::generate(&result.document);
2768 assert!(
2771 assert!(
2769 html.contains("Hello"),
2772 html.contains("Hello"),
2770 "capitalize filter should work, got: {}",
2773 "capitalize filter should work, got: {}",
@@ -2772,7 +2775,7 @@ fn test_variable_filters() {
2772 );
2775 );
2773
2776
2774 let result = htmlang::parser::parse("@let name hello\n@text ${length($name)}");
2777 let result = htmlang::parser::parse("@let name hello\n@text ${length($name)}");
2775− let html = htmlang::codegen::generate(&result.document);
2778+ let html = codegen::generate(&result.document);
2776 assert!(
2779 assert!(
2777 html.contains("5"),
2780 html.contains("5"),
2778 "length filter should work, got: {}",
2781 "length filter should work, got: {}",
@@ -2780,7 +2783,7 @@ fn test_variable_filters() {
2780 );
2783 );
2781
2784
2782 let result = htmlang::parser::parse("@let name hello\n@text ${reverse($name)}");
2785 let result = htmlang::parser::parse("@let name hello\n@text ${reverse($name)}");
2783− let html = htmlang::codegen::generate(&result.document);
2786+ let html = codegen::generate(&result.document);
2784 assert!(
2787 assert!(
2785 html.contains("olleh"),
2788 html.contains("olleh"),
2786 "reverse filter should work, got: {}",
2789 "reverse filter should work, got: {}",
@@ -2788,7 +2791,7 @@ fn test_variable_filters() {
2788 );
2791 );
2789
2792
2790 let result = htmlang::parser::parse("@let name hello world\n@text ${truncate($name, 5)}");
2793 let result = htmlang::parser::parse("@let name hello world\n@text ${truncate($name, 5)}");
2791− let html = htmlang::codegen::generate(&result.document);
2794+ let html = codegen::generate(&result.document);
2792 assert!(
2795 assert!(
2793 html.contains("hello..."),
2796 html.contains("hello..."),
2794 "truncate filter should work, got: {}",
2797 "truncate filter should work, got: {}",
@@ -2799,7 +2802,7 @@ fn test_variable_filters() {
2799#[test]
2802#[test]
2800fn test_css_shorthands_output() {
2803fn test_css_shorthands_output() {
2801 let result = htmlang::parser::parse("@text [$truncate] Hello");
2804 let result = htmlang::parser::parse("@text [$truncate] Hello");
2802− let html = htmlang::codegen::generate(&result.document);
2805+ let html = codegen::generate(&result.document);
2803 assert!(
2806 assert!(
2804 html.contains("text-overflow:ellipsis"),
2807 html.contains("text-overflow:ellipsis"),
2805 "truncate should add ellipsis, got: {}",
2808 "truncate should add ellipsis, got: {}",
@@ -2811,7 +2814,7 @@ fn test_css_shorthands_output() {
2811 );
2814 );
2812
2815
2813 let result = htmlang::parser::parse("@paragraph [line-clamp 3] Text");
2816 let result = htmlang::parser::parse("@paragraph [line-clamp 3] Text");
2814− let html = htmlang::codegen::generate(&result.document);
2817+ let html = codegen::generate(&result.document);
2815 assert!(
2818 assert!(
2816 html.contains("-webkit-line-clamp:3"),
2819 html.contains("-webkit-line-clamp:3"),
2817 "line-clamp should work, got: {}",
2820 "line-clamp should work, got: {}",
@@ -2819,7 +2822,7 @@ fn test_css_shorthands_output() {
2819 );
2822 );
2820
2823
2821 let result = htmlang::parser::parse("@el [filter blur(4px)] Content");
2824 let result = htmlang::parser::parse("@el [filter blur(4px)] Content");
2822− let html = htmlang::codegen::generate(&result.document);
2825+ let html = codegen::generate(&result.document);
2823 assert!(
2826 assert!(
2824 html.contains("filter:blur(4px)"),
2827 html.contains("filter:blur(4px)"),
2825 "blur should work, got: {}",
2828 "blur should work, got: {}",
@@ -2827,7 +2830,7 @@ fn test_css_shorthands_output() {
2827 );
2830 );
2828
2831
2829 let result = htmlang::parser::parse("@el [backdrop-filter blur(10px)] Content");
2832 let result = htmlang::parser::parse("@el [backdrop-filter blur(10px)] Content");
2830− let html = htmlang::codegen::generate(&result.document);
2833+ let html = codegen::generate(&result.document);
2831 assert!(
2834 assert!(
2832 html.contains("backdrop-filter:blur(10px)"),
2835 html.contains("backdrop-filter:blur(10px)"),
2833 "backdrop-blur should work, got: {}",
2836 "backdrop-blur should work, got: {}",
@@ -2865,7 +2868,7 @@ fn test_theme_directive() {
2865 "theme should not cause errors: {:?}",
2868 "theme should not cause errors: {:?}",
2866 errors
2869 errors
2867 );
2870 );
2868− let html = htmlang::codegen::generate(&result.document);
2871+ let html = codegen::generate(&result.document);
2869 assert!(
2872 assert!(
2870 html.contains("--primary:#3b82f6"),
2873 html.contains("--primary:#3b82f6"),
2871 "theme should emit CSS vars, got: {}",
2874 "theme should emit CSS vars, got: {}",
@@ -2991,7 +2994,7 @@ fn a_token_is_named_like_a_custom_property_on_an_element() {
2991#[test]
2994#[test]
2992fn test_autofocus_attribute() {
2995fn test_autofocus_attribute() {
2993 let result = htmlang::parser::parse("@input [type=text, autofocus]");
2996 let result = htmlang::parser::parse("@input [type=text, autofocus]");
2994− let html = htmlang::codegen::generate(&result.document);
2997+ let html = codegen::generate(&result.document);
2995 assert!(
2998 assert!(
2996 html.contains("autofocus"),
2999 html.contains("autofocus"),
2997 "autofocus should be in output, got: {}",
3000 "autofocus should be in output, got: {}",
@@ -3681,8 +3684,8 @@ fn switch_with_attrs() {
3681fn minified_output_is_smaller() {
3684fn minified_output_is_smaller() {
3682 let input = "@page Test\n@el [padding 20]\n @text [font-weight bold] Hello World\n @paragraph\n Some text here\n";
3685 let input = "@page Test\n@el [padding 20]\n @text [font-weight bold] Hello World\n @paragraph\n Some text here\n";
3683 let result = htmlang::parser::parse(input);
3686 let result = htmlang::parser::parse(input);
3684− let normal = htmlang::codegen::generate(&result.document);
3687+ let normal = codegen::generate(&result.document);
3685− let minified = htmlang::codegen::generate_minified(&result.document);
3688+ let minified = codegen::generate_minified(&result.document);
3686 assert!(
3689 assert!(
3687 minified.len() <= normal.len(),
3690 minified.len() <= normal.len(),
3688 "minified ({}) should be <= normal ({})",
3691 "minified ({}) should be <= normal ({})",
@@ -3696,8 +3699,8 @@ fn minified_output_is_smaller() {
3696fn minified_strips_comments() {
3699fn minified_strips_comments() {
3697 let input = "@page Test\n@el\n @text Hello\n";
3700 let input = "@page Test\n@el\n @text Hello\n";
3698 let result = htmlang::parser::parse(input);
3701 let result = htmlang::parser::parse(input);
3699− let dev = htmlang::codegen::generate_dev(&result.document);
3702+ let dev = codegen::generate_dev(&result.document);
3700− let minified = htmlang::codegen::generate_minified(&result.document);
3703+ let minified = codegen::generate_minified(&result.document);
3701 // Dev mode has comments, minified should not
3704 // Dev mode has comments, minified should not
3702 assert!(dev.contains("<!--"));
3705 assert!(dev.contains("<!--"));
3703 assert!(!minified.contains("<!--"));
3706 assert!(!minified.contains("<!--"));
@@ -4201,7 +4204,7 @@ fn snapshot_responsive_images() {
4201fn source_map_generation() {
4204fn source_map_generation() {
4202 let input = "@page Test\n@text [font-weight bold] Hello";
4205 let input = "@page Test\n@text [font-weight bold] Hello";
4203 let result = htmlang::parser::parse(input);
4206 let result = htmlang::parser::parse(input);
4204− let map = htmlang::codegen::generate_source_map(&result.document, "test.hl");
4207+ let map = codegen::generate_source_map(&result.document, "test.hl");
4205 assert!(
4208 assert!(
4206 map.contains("\"version\":3"),
4209 map.contains("\"version\":3"),
4207 "source map should have version 3"
4210 "source map should have version 3"
@@ -4293,7 +4296,7 @@ fn svg_directive_inline() {
4293 "should parse without errors: {:?}",
4296 "should parse without errors: {:?}",
4294 result.diagnostics
4297 result.diagnostics
4295 );
4298 );
4296− let html = htmlang::codegen::generate(&result.document);
4299+ let html = codegen::generate(&result.document);
4297 assert!(
4300 assert!(
4298 html.contains("<svg"),
4301 html.contains("<svg"),
4299 "should inline SVG content, got: {}",
4302 "should inline SVG content, got: {}",
@@ -4324,7 +4327,7 @@ fn svg_directive_with_attrs() {
4324 svg_path.display()
4327 svg_path.display()
4325 );
4328 );
4326 let result = htmlang::parser::parse(&input);
4329 let result = htmlang::parser::parse(&input);
4327− let html = htmlang::codegen::generate(&result.document);
4330+ let html = codegen::generate(&result.document);
4328 assert!(
4331 assert!(
4329 html.contains("style=\"width:24px;fill:red\""),
4332 html.contains("style=\"width:24px;fill:red\""),
4330 "should override width, got: {}",
4333 "should override width, got: {}",
@@ -4363,7 +4366,7 @@ fn css_property_directive() {
4363 "should parse without errors: {:?}",
4366 "should parse without errors: {:?}",
4364 result.diagnostics
4367 result.diagnostics
4365 );
4368 );
4366− let html = htmlang::codegen::generate(&result.document);
4369+ let html = codegen::generate(&result.document);
4367 assert!(
4370 assert!(
4368 html.contains("@property --my-color"),
4371 html.contains("@property --my-color"),
4369 "@property rule should be emitted, got: {}",
4372 "@property rule should be emitted, got: {}",
@@ -4389,7 +4392,7 @@ fn css_property_directive() {
4389#[test]
4392#[test]
4390fn partial_output() {
4393fn partial_output() {
4391 let result = htmlang::parser::parse("@page Test\n@el [padding 20]\n Hello");
4394 let result = htmlang::parser::parse("@page Test\n@el [padding 20]\n Hello");
4392− let html = htmlang::codegen::generate_partial(&result.document);
4395+ let html = codegen::generate_partial(&result.document);
4393 assert!(
4396 assert!(
4394 !html.contains("<!DOCTYPE"),
4397 !html.contains("<!DOCTYPE"),
4395 "partial should not have doctype, got: {}",
4398 "partial should not have doctype, got: {}",
@@ -4425,7 +4428,7 @@ fn partial_output() {
4425#[test]
4428#[test]
4426fn partial_output_dev() {
4429fn partial_output_dev() {
4427 let result = htmlang::parser::parse("@page Test\n@el [padding 20]\n Hello");
4430 let result = htmlang::parser::parse("@page Test\n@el [padding 20]\n Hello");
4428− let html = htmlang::codegen::generate_partial_dev(&result.document);
4431+ let html = codegen::generate_partial_dev(&result.document);
4429 assert!(
4432 assert!(
4430 !html.contains("<!DOCTYPE"),
4433 !html.contains("<!DOCTYPE"),
4431 "partial dev should not have doctype"
4434 "partial dev should not have doctype"
@@ -4466,7 +4469,7 @@ fn auto_image_dimensions_respects_explicit() {
4466 png_path.display()
4469 png_path.display()
4467 );
4470 );
4468 let result = htmlang::parser::parse(&input);
4471 let result = htmlang::parser::parse(&input);
4469− let html = htmlang::codegen::generate(&result.document);
4472+ let html = codegen::generate(&result.document);
4470 // When width/height are set as CSS attrs, auto-dimensions should not add HTML width/height
4473 // When width/height are set as CSS attrs, auto-dimensions should not add HTML width/height
4471 assert!(
4474 assert!(
4472 html.contains("width:100px"),
4475 html.contains("width:100px"),
@@ -4503,7 +4506,7 @@ fn perf_large_document() {
4503 let input = std::fs::read_to_string(dir.join("stress_large.hl")).unwrap();
4506 let input = std::fs::read_to_string(dir.join("stress_large.hl")).unwrap();
4504 let start = std::time::Instant::now();
4507 let start = std::time::Instant::now();
4505 let result = htmlang::parser::parse(&input);
4508 let result = htmlang::parser::parse(&input);
4506− let _ = htmlang::codegen::generate(&result.document);
4509+ let _ = codegen::generate(&result.document);
4507 let elapsed = start.elapsed();
4510 let elapsed = start.elapsed();
4508 assert!(
4511 assert!(
4509 elapsed.as_millis() < 5000,
4512 elapsed.as_millis() < 5000,
@@ -4576,7 +4579,7 @@ fn markdown_file_renders_content() {
4576
4579
4577 let input = "@markdown article.md\n";
4580 let input = "@markdown article.md\n";
4578 let result = htmlang::parser::parse_with_base(input, Some(&dir));
4581 let result = htmlang::parser::parse_with_base(input, Some(&dir));
4579− let html = htmlang::codegen::generate(&result.document);
4582+ let html = codegen::generate(&result.document);
4580 let _ = std::fs::remove_dir_all(&dir);
4583 let _ = std::fs::remove_dir_all(&dir);
4581
4584
4582 assert!(
4585 assert!(
@@ -4609,7 +4612,7 @@ fn markdown_file_with_variable_path() {
4609
4612
4610 let input = "@let file post.md\n@markdown $file\n";
4613 let input = "@let file post.md\n@markdown $file\n";
4611 let result = htmlang::parser::parse_with_base(input, Some(&dir));
4614 let result = htmlang::parser::parse_with_base(input, Some(&dir));
4612− let html = htmlang::codegen::generate(&result.document);
4615+ let html = codegen::generate(&result.document);
4613 let _ = std::fs::remove_dir_all(&dir);
4616 let _ = std::fs::remove_dir_all(&dir);
4614
4617
4615 assert!(
4618 assert!(
@@ -4746,7 +4749,7 @@ fn only_a_function_takes_a_body() {
4746 assert_eq!(found[0].0, 3);
4749 assert_eq!(found[0].0, 3);
4747 assert!(found[0].1.contains("`@let @card"), "{}", found[0].1);
4750 assert!(found[0].1.contains("`@let @card"), "{}", found[0].1);
4748 let result = htmlang::parser::parse(&input);
4751 let result = htmlang::parser::parse(&input);
4749− let html = htmlang::codegen::generate(&result.document);
4752+ let html = codegen::generate(&result.document);
4750 assert!(!html.contains("Body"), "{}: {}", head, html);
4753 assert!(!html.contains("Body"), "{}: {}", head, html);
4751 }
4754 }
4752}
4755}
@@ -4780,7 +4783,7 @@ fn a_let_needs_a_bare_name_and_a_value() {
4780 }
4783 }
4781 // The body of a `@let` without a value isn't rendered either
4784 // The body of a `@let` without a value isn't rendered either
4782 let result = htmlang::parser::parse("@let card\n @el Body\n");
4785 let result = htmlang::parser::parse("@let card\n @el Body\n");
4783− assert!(!htmlang::codegen::generate(&result.document).contains("Body"));
4786+ assert!(!codegen::generate(&result.document).contains("Body"));
4784
4787
4785 for name in ["1x", "a!b", "true"] {
4788 for name in ["1x", "a!b", "true"] {
4786 let input = format!("@let {} = 1\n", name);
4789 let input = format!("@let {} = 1\n", name);
@@ -5054,7 +5057,7 @@ fn a_parameter_passed_with_equals_is_an_error() {
5054 // Not also reported as missing, and not forwarded as an HTML attribute
5057 // Not also reported as missing, and not forwarded as an HTML attribute
5055 assert!(coded(&diags, "missing-parameter").is_empty(), "{:?}", diags);
5058 assert!(coded(&diags, "missing-parameter").is_empty(), "{:?}", diags);
5056 let result = htmlang::parser::parse("@let @card [title]\n @el $title\n@card [title=Hi]\n");
5059 let result = htmlang::parser::parse("@let @card [title]\n @el $title\n@card [title=Hi]\n");
5057− let out = htmlang::codegen::generate(&result.document);
5060+ let out = codegen::generate(&result.document);
5058 assert!(out.contains("Hi"), "{}", out);
5061 assert!(out.contains("Hi"), "{}", out);
5059 assert!(!out.contains("title=\"Hi\""), "{}", out);
5062 assert!(!out.contains("title=\"Hi\""), "{}", out);
5060}
5063}
@@ -5311,7 +5314,7 @@ fn a_function_in_its_own_callers_content_is_not_recursion() {
5311 let src = "@let @box\n @el [padding 4]\n @children\n@box\n @box\n Inner\n {@box x}\n@box {@box y}\n";
5314 let src = "@let @box\n @el [padding 4]\n @children\n@box\n @box\n Inner\n {@box x}\n@box {@box y}\n";
5312 let result = htmlang::parser::parse(src);
5315 let result = htmlang::parser::parse(src);
5313 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5316 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5314− let out = htmlang::codegen::generate(&result.document);
5317+ let out = codegen::generate(&result.document);
5315 // The two calls inside text are inline-flex spans
5318 // The two calls inside text are inline-flex spans
5316 assert_eq!(out.matches("<div").count(), 3, "{}", out);
5319 assert_eq!(out.matches("<div").count(), 3, "{}", out);
5317 assert_eq!(out.matches("<span class=").count(), 2, "{}", out);
5320 assert_eq!(out.matches("<span class=").count(), 2, "{}", out);
@@ -5705,7 +5708,7 @@ fn an_included_file_s_slots_are_where_its_include_is() {
5705 Some(&dir),
5708 Some(&dir),
5706 );
5709 );
5707 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5710 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5708− let html = htmlang::codegen::generate(&result.document);
5711+ let html = codegen::generate(&result.document);
5709 for text in ["Hello", "Foot", "From fillers", "Plain"] {
5712 for text in ["Hello", "Foot", "From fillers", "Plain"] {
5710 assert!(html.contains(text), "{}: {}", text, html);
5713 assert!(html.contains(text), "{}: {}", text, html);
5711 }
5714 }
@@ -5866,7 +5869,7 @@ fn css_if_in_a_value_is_css() {
5866 "@el [width if(media(width > 40em): 50%; else: 100%), color if(style(--dark: 1): white; else: black)]\n x\n",
5869 "@el [width if(media(width > 40em): 50%; else: 100%), color if(style(--dark: 1): white; else: black)]\n x\n",
5867 );
5870 );
5868 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5871 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5869− let html = htmlang::codegen::generate(&result.document);
5872+ let html = codegen::generate(&result.document);
5870 assert!(
5873 assert!(
5871 html.contains("width:if(media(width > 40em): 50%; else: 100%);"),
5874 html.contains("width:if(media(width > 40em): 50%; else: 100%);"),
5872 "{}",
5875 "{}",
@@ -5978,7 +5981,7 @@ fn the_after_snippet_of_names_not_values() {
5978 let unknown = coded(&result.diagnostics, "unknown-attribute");
5981 let unknown = coded(&result.diagnostics, "unknown-attribute");
5979 assert_eq!(unknown.len(), 1, "{:?}", result.diagnostics);
5982 assert_eq!(unknown.len(), 1, "{:?}", result.diagnostics);
5980 assert_eq!(unknown[0].severity, htmlang::parser::Severity::Warning);
5983 assert_eq!(unknown[0].severity, htmlang::parser::Severity::Warning);
5981− assert!(htmlang::codegen::generate(&result.document).contains("text-grow:per-line"));
5984+ assert!(codegen::generate(&result.document).contains("text-grow:per-line"));
5982
5985
5983 let d = parse_diagnostics("@let name Ada\n@text Hello $nmae\n");
5986 let d = parse_diagnostics("@let name Ada\n@text Hello $nmae\n");
5984 let undefined = coded(&d, "undefined-variable");
5987 let undefined = coded(&d, "undefined-variable");
@@ -6011,7 +6014,7 @@ fn unknown_css_properties_pass_through_with_a_warning() {
6011 assert_eq!(unknown[0].subject.as_deref(), Some("colr"));
6014 assert_eq!(unknown[0].subject.as_deref(), Some("colr"));
6012 assert_eq!(unknown[0].suggestion.as_deref(), Some("color"));
6015 assert_eq!(unknown[0].suggestion.as_deref(), Some("color"));
6013 assert_eq!(unknown[0].column, Some(5));
6016 assert_eq!(unknown[0].column, Some(5));
6014− let html = htmlang::codegen::generate(&result.document);
6017+ let html = codegen::generate(&result.document);
6015 for css in [
6018 for css in [
6016 "colr:red",
6019 "colr:red",
6017 "--gap:12px",
6020 "--gap:12px",
@@ -6045,7 +6048,7 @@ fn what_cannot_be_css_is_an_error_and_left_out() {
6045 src,
6048 src,
6046 result.diagnostics
6049 result.diagnostics
6047 );
6050 );
6048− let html = htmlang::codegen::generate(&result.document);
6051+ let html = codegen::generate(&result.document);
6049 assert!(
6052 assert!(
6050 !html.contains("center-z") && !html.contains("email"),
6053 !html.contains("center-z") && !html.contains("email"),
6051 "{}",
6054 "{}",
@@ -6103,7 +6106,7 @@ fn selector_prefixes_are_css_pseudo_classes_and_elements() {
6103 @el [not([title=\") x\"]):color blue] x\n";
6106 @el [not([title=\") x\"]):color blue] x\n";
6104 let result = htmlang::parser::parse(src);
6107 let result = htmlang::parser::parse(src);
6105 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
6108 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
6106− let html = htmlang::codegen::generate(&result.document);
6109+ let html = codegen::generate(&result.document);
6107 for css in [
6110 for css in [
6108 ":first-child{font-weight:700;}",
6111 ":first-child{font-weight:700;}",
6109 ":nth-child(odd){background:var(--subtle);}",
6112 ":nth-child(odd){background:var(--subtle);}",
@@ -6170,7 +6173,7 @@ fn values_that_would_break_out_of_the_css_rule_are_errors() {
6170 result.diagnostics
6173 result.diagnostics
6171 );
6174 );
6172 // The value is left out of the page
6175 // The value is left out of the page
6173− let html = htmlang::codegen::generate(&result.document);
6176+ let html = codegen::generate(&result.document);
6174 assert!(
6177 assert!(
6175 !html.contains("background blue") && !html.contains("{3}"),
6178 !html.contains("background blue") && !html.contains("{3}"),
6176 "{}",
6179 "{}",
@@ -6220,7 +6223,7 @@ fn a_style_whose_value_comes_out_empty_is_left_out() {
6220 "@data $p {\"title\": \"Hi\"}\n@let on false\n@el [padding $p.gap, margin ${if($on, 4)}, color red]\n $p.title\n",
6223 "@data $p {\"title\": \"Hi\"}\n@let on false\n@el [padding $p.gap, margin ${if($on, 4)}, color red]\n $p.title\n",
6221 );
6224 );
6222 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
6225 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
6223− let html = htmlang::codegen::generate(&result.document);
6226+ let html = codegen::generate(&result.document);
6224 assert!(
6227 assert!(
6225 !html.contains("padding:") && !html.contains("margin:"),
6228 !html.contains("padding:") && !html.contains("margin:"),
6226 "{}",
6229 "{}",
@@ -6330,7 +6333,7 @@ fn a_warning_in_a_loop_or_a_function_is_reported_once() {
6330fn hidden_is_a_boolean_attribute() {
6333fn hidden_is_a_boolean_attribute() {
6331 let result = htmlang::parser::parse("@el [hidden] x\n");
6334 let result = htmlang::parser::parse("@el [hidden] x\n");
6332 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
6335 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
6333− assert!(htmlang::codegen::generate(&result.document).contains("<div class=\"hl-a\" hidden>"));
6336+ assert!(codegen::generate(&result.document).contains("<div class=\"hl-a\" hidden>"));
6334}
6337}
6335
6338
6336#[test]
6339#[test]
@@ -6527,7 +6530,7 @@ fn spacing_goes_only_on_a_row_column_or_grid() {
6527 );
6530 );
6528 assert!(errors[0].message.contains(name), "{}: {:?}", src, errors);
6531 assert!(errors[0].message.contains(name), "{}: {:?}", src, errors);
6529 // Left out of the page
6532 // Left out of the page
6530− let out = htmlang::codegen::generate(&result.document);
6533+ let out = codegen::generate(&result.document);
6531 assert!(!out.contains("gap:"), "{}: {}", src, out);
6534 assert!(!out.contains("gap:"), "{}: {}", src, out);
6532 assert!(!out.contains("flex-wrap"), "{}: {}", src, out);
6535 assert!(!out.contains("flex-wrap"), "{}: {}", src, out);
6533 assert!(!out.contains("grid-template"), "{}: {}", src, out);
6536 assert!(!out.contains("grid-template"), "{}: {}", src, out);
@@ -6575,7 +6578,7 @@ fn fill_and_center_compile_against_the_parent_s_layout() {
6575 "{:?}",
6578 "{:?}",
6576 result.diagnostics
6579 result.diagnostics
6577 );
6580 );
6578− assert!(!htmlang::codegen::generate(&result.document).contains("flex:1"));
6581+ assert!(!codegen::generate(&result.document).contains("flex:1"));
6579}
6582}
6580
6583
6581#[test]
6584#[test]
@@ -7385,7 +7388,7 @@ fn an_element_inside_text_is_not_a_child_of_the_row_around_it() {
7385/// Output with the errors allowed, for tests of what an error leaves.
7388/// Output with the errors allowed, for tests of what an error leaves.
7386fn compile_anyway(input: &str) -> (String, Vec<htmlang::parser::Diagnostic>) {
7389fn compile_anyway(input: &str) -> (String, Vec<htmlang::parser::Diagnostic>) {
7387 let result = htmlang::parser::parse(input);
7390 let result = htmlang::parser::parse(input);
7388− let html = htmlang::codegen::generate_partial(&result.document);
7391+ let html = codegen::generate_partial(&result.document);
7389 (html, result.diagnostics)
7392 (html, result.diagnostics)
7390}
7393}
7391
7394
@@ -7740,7 +7743,7 @@ fn readable_output_adds_no_whitespace_inside_pre_and_textarea() {
7740 "@el\n @pre > @code\n a\n b\n @textarea [aria-label=x]\n c\n",
7743 "@el\n @pre > @code\n a\n b\n @textarea [aria-label=x]\n c\n",
7741 )
7744 )
7742 .document;
7745 .document;
7743− let out = htmlang::codegen::generate_dev(&doc);
7746+ let out = codegen::generate_dev(&doc);
7744 assert!(
7747 assert!(
7745 out.contains("><code class=\"hl-c\">a\n b</code></pre>\n"),
7748 out.contains("><code class=\"hl-c\">a\n b</code></pre>\n"),
7746 "{}",
7749 "{}",
@@ -8198,7 +8201,7 @@ fn a_custom_property_is_a_style_on_any_element_under_any_prefix() {
8198 "@el [--a 1, hover:--b 2, md:--c 3, dark:--d 4, children:--e 5, nth-child(2n):--f 6, has(.x):--g 7, print:--h 8, cq-md:--i 9]\n x\n@image [alt=x, --w 20px] a.png\n@paragraph {@em [--j 1] y}\n",
8201 "@el [--a 1, hover:--b 2, md:--c 3, dark:--d 4, children:--e 5, nth-child(2n):--f 6, has(.x):--g 7, print:--h 8, cq-md:--i 9]\n x\n@image [alt=x, --w 20px] a.png\n@paragraph {@em [--j 1] y}\n",
8199 );
8202 );
8200 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8203 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8201− let html = htmlang::codegen::generate(&result.document);
8204+ let html = codegen::generate(&result.document);
8202 for declaration in [
8205 for declaration in [
8203 "--a:1;",
8206 "--a:1;",
8204 "--b:2;",
8207 "--b:2;",
@@ -8228,7 +8231,7 @@ fn a_vendor_prefixed_property_is_a_style_under_any_prefix() {
8228 "@el [-webkit-text-stroke 1px red, hover:-webkit-text-stroke 2px red, dark:-webkit-tap-highlight-color transparent, -moz-osx-font-smoothing grayscale, -ms-overflow-style none]\n x\n",
8231 "@el [-webkit-text-stroke 1px red, hover:-webkit-text-stroke 2px red, dark:-webkit-tap-highlight-color transparent, -moz-osx-font-smoothing grayscale, -ms-overflow-style none]\n x\n",
8229 );
8232 );
8230 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8233 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8231− let html = htmlang::codegen::generate(&result.document);
8234+ let html = codegen::generate(&result.document);
8232 for declaration in [
8235 for declaration in [
8233 "-webkit-text-stroke:1px red;",
8236 "-webkit-text-stroke:1px red;",
8234 ":hover{-webkit-text-stroke:2px red;}",
8237 ":hover{-webkit-text-stroke:2px red;}",
@@ -8264,7 +8267,7 @@ fn a_custom_property_on_a_line_of_a_list_is_a_declaration() {
8264 "@el [\n padding 4,\n --surface white,\n -- a comment\n --\n color red\n]\n x\n",
8267 "@el [\n padding 4,\n --surface white,\n -- a comment\n --\n color red\n]\n x\n",
8265 );
8268 );
8266 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8269 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8267− let html = htmlang::codegen::generate(&result.document);
8270+ let html = codegen::generate(&result.document);
8268 assert!(
8271 assert!(
8269 html.contains("padding:4px;--surface:white;color:red;"),
8272 html.contains("padding:4px;--surface:white;color:red;"),
8270 "{}",
8273 "{}",
@@ -8296,7 +8299,7 @@ fn a_style_an_inline_svg_can_t_take_is_an_error() {
8296 "@image [inline, width 24, color red, class=icon, aria-label=Logo, role=img] i.svg\n@image [inline, --w 2px, hover:color blue, alt=x] i.svg\n",
8299 "@image [inline, width 24, color red, class=icon, aria-label=Logo, role=img] i.svg\n@image [inline, --w 2px, hover:color blue, alt=x] i.svg\n",
8297 Some(&dir),
8300 Some(&dir),
8298 );
8301 );
8299− let html = htmlang::codegen::generate(&result.document);
8302+ let html = codegen::generate(&result.document);
8300 assert!(
8303 assert!(
8301 html.contains(
8304 html.contains(
8302 "<svg viewBox=\"0 0 1 1\" class=\"icon\" aria-label=\"Logo\" role=\"img\" style=\"width:24px;fill:red\">"
8305 "<svg viewBox=\"0 0 1 1\" class=\"icon\" aria-label=\"Logo\" role=\"img\" style=\"width:24px;fill:red\">"
@@ -8331,7 +8334,7 @@ fn a_style_an_inline_svg_can_t_take_is_an_error() {
8331 "{}",
8334 "{}",
8332 errors[0].message
8335 errors[0].message
8333 );
8336 );
8334− let html = htmlang::codegen::generate(&result.document);
8337+ let html = codegen::generate(&result.document);
8335 assert!(!html.contains("width=\"fill\""), "{}", html);
8338 assert!(!html.contains("width=\"fill\""), "{}", html);
8336 assert!(html.contains("style=\"height:2em\""), "{}", html);
8339 assert!(html.contains("style=\"height:2em\""), "{}", html);
8337 let _ = fs::remove_dir_all(&dir);
8340 let _ = fs::remove_dir_all(&dir);