on master help sign in
Fix attribute bugs found with htmx, and name classes by their styles (#2)

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>

commit5ea0c4057e6f170b375b3933e27ce7d246e601ad authoriko <mail@iko.soy> dateSun 27 Sep 2026, 18:28 (2 weeks ago) parent693bde9
16 files changed+1017−266
unifiedsplit
CONTRIBUTING.md+8−2 DESIGN.md+39−14 README.md+9−7 crates/htmlang-core/src/ast.rs+1−1 crates/htmlang-core/src/codegen.rs+173−70 crates/htmlang-core/src/codegen_tests.rs+31−7 crates/htmlang-core/src/diagnostic.rs+16 crates/htmlang-core/src/parser.rs+118−42 crates/htmlang-core/src/syntax.rs+31 crates/htmlang-core/src/vocab.rs+27−1 src/bin/htmlang_lsp/docs.rs+6−4 src/main.rs+73−1 src/serve.rs+128−50 tests/common/mod.rs+140 tests/regressions.rs+162−15 tests/snapshots.rs+55−52
CONTRIBUTING.md+8−2
@@ -40,8 +40,11 @@ Linux, macOS, and Windows. Your change should pass all three.
4040 Every CSS property goes through one generic path (its name, its value
4141 as written, with `vocab::with_px`'s pixels); only htmlang's layout
4242 words and `line-clamp` have code of their own. Generated classes are
43− `hl-` + a short name; an element's defaults (its layout,
44− `ElementKind::css`) go with its class, as `:where(.hl-a)`, never a
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
46+ defaults (its layout,
47+ `ElementKind::css`) go with its class, as `:where(.hl-2k9xq0m)`, never a
4548 rule on a global element selector, so `@markdown`/`@raw` HTML and a
4649 page embedding a fragment are untouched. The rules are in cascade
4750 layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside`
@@ -100,6 +103,9 @@ Linux, macOS, and Windows. Your change should pass all three.
100103 in `docs.rs`.
101104- `editors/vscode/` — VS Code extension.
102105- `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.
103109- `tests/regressions.rs` — one test per fixed bug.
104110- `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`,
105111 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:
7272 (`spacing 20`, `color red`).
7373- `key=value` is an **HTML attribute** (`id=main`, `type=email`,
7474 `aria-label=Close`).
75−- A bare word is a **flag**: a layout attribute such as `center-x`, or a
76− boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`.
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`,
77+ or a `data-` or `hx-` attribute that has no value (`data-open`,
78+ `hx-preserve`).
7779
7880A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else,
7981`\,` keeps a comma in the value:
8082
8183```
8284@el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif]
85+@el [hx-get=/news, hx-trigger=load\, every 60s]
8386```
8487
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+
8596A variable fills an attribute's value (`padding $gap`, `alt=$title`), never
8697its name or a whole attribute: attributes come from a
8798[bundle](#definitions), written `[$card]`, and a condition chooses whole
@@ -154,7 +165,7 @@ values, `@let`, `@page` and `@meta`.
154165| `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` |
155166| `\$` | `$` | a `$` before a name, which would start a variable |
156167| `\{`, `\}` | `{`, `}` | 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`) |
158169| `\,` | `,` | a comma in an attribute's value |
159170| `\"` | `"` | a quote that doesn't start or end quoted text |
160171| `\\` | `\` | a backslash before one of these characters |
@@ -723,7 +734,7 @@ own attributes are for it alone.
723734
724735Each style under an element prefix compiles to a rule in CSS's `@scope`,
725736on 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
727738rule is what makes the nearest element win. `@scope` is Baseline 2025,
728739supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older
729740browser 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
906917it is quoted, as such a label: `@optgroup "Citrus fruits"`. The first word is
907918always the argument, so on an element whose attribute is often left out
908919(`@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`.
910924
911925The attribute can be written as an attribute instead, and it means the same:
912926`@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.
943957
944958HTML attributes are written `key=value`: `id=main`, `class=note`,
945959`href=/about`, `type=email`, `alt=Logo`, `target=_blank`,
946−`aria-label=Close menu`, `data-id=42`. Any name works, one with a colon
947−in it included (`xml:lang=en`, `x-on:click=open`): before an `=` a colon is
948−part of the name, not a prefix. Boolean attributes are
949−written bare: `required`, `disabled`, `checked`, `hidden`, `open`, `popover`.
950−An HTML attribute written like a style (`type email`) is an error that
951−shows the `key=value` form. A style
960+`aria-label=Close menu`, `data-id=42`, `hx-get=/items`. Any name works,
961+one with a colon in it included (`xml:lang=en`, `hx-on:click=open()`):
962+before an `=` a colon is part of the name, not a prefix. Boolean attributes
963+are written bare: `required`, `disabled`, `checked`, `hidden`, `open`,
964+`popover`, and so are `data-` and `hx-` attributes that have no value
965+(`hx-preserve`). An HTML attribute written like a style (`type email`,
966+`hx-get /items`) is an error that shows the `key=value` form. A style
952967and an HTML attribute can share a name, because the `=` tells them apart:
953968
954969```
@@ -1548,8 +1563,18 @@ for a single rule, on its line:
15481563@el [animation fade-in 0.3s ease, class=note] Fades in
15491564```
15501565
1551−Each element with styles gets a short generated class, `hl-a`, `hl-b`, ...,
1552−and elements with the same styles share one. The `hl-` prefix is htmlang's:
1566+Each element with styles gets a generated class, and elements with the
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:
15531578a class of your own that starts with it gets a warning, since the generated
15541579rules would apply to it too. Every style goes into the class under its own
15551580name, with its value as written (plus pixels, see
@@ -1572,7 +1597,7 @@ the specificity:
15721597| Layer | Holds |
15731598|---|---|
15741599| `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)` |
15761601| `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` |
15771602| `htmlang` | Each element's own styles, and its parent's `children:` styles |
15781603
README.md+9−7
@@ -89,11 +89,13 @@ unless you write some, and nothing is added that you didn't ask for.
8989 written (`outline 2 solid red` is `outline: 2px solid red`, nothing
9090 added). Nothing you write is left out of the page without an error.
9191- **The generated CSS stays on htmlang's elements.** Its classes are
92− `hl-a`, `hl-b`, ..., in cascade layers (`hl-reset`, `hl-kind`,
93− `hl-inside`, `htmlang`), so your own CSS wins. An element's defaults (no
94− heading margins, no list markers, links in the text colour) go with its
95− own class, so `@markdown` and `@raw` HTML, and a page that embeds
96− `--partial` output, keep theirs.
92+ named by their styles (`hl-2k9xq0m`), in cascade layers (`hl-reset`,
93+ `hl-kind`, `hl-inside`, `htmlang`), so your own CSS wins. An element's
94+ defaults (no heading margins, no list markers, links in the text colour)
95+ go with its own class, so `@markdown` and `@raw` HTML, and a page that
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.
9799- **HTML stays HTML.** An element that isn't about layout or text has its
98100 HTML name (`@nav`, `@ul`, `@form`, `@details`, `@strong`, `@em`, `@br`,
99101 `@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
134136
135137| Command | Purpose |
136138|---|---|
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 |
138−| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload |
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`) |
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 |
139141| `watch [dir\|file] [-o out]` | Recompile on change, without a server |
140142| `check <file\|dir> [--format json]` | Report diagnostics without writing output |
141143| `lint <file\|dir> [--format json]` | Stricter checks (accessibility, nesting) |
crates/htmlang-core/src/ast.rs+1−1
@@ -12,7 +12,7 @@ pub struct Document {
1212 pub css_vars: Vec<(String, String)>,
1313 pub custom_css: Vec<String>,
1414 /// 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`.
1616 pub scoped_css: Vec<String>,
1717 pub og_tags: Vec<(String, String)>,
1818 pub nodes: Vec<Node>,
crates/htmlang-core/src/codegen.rs+173−70
@@ -1,5 +1,5 @@
11use std::collections::HashMap;
2−use std::hash::{Hash, Hasher};
2+use std::hash::Hash;
33
44use crate::ast::*;
55use crate::vocab::with_px;
@@ -17,33 +17,51 @@ const BREAKPOINTS: &[(&str, &str)] = &[
1717 ("2xl", "1536px"),
1818];
1919
20−/// The prefix of every generated class (`hl-a`, `hl-b`, ...), which keeps
21−/// them out of the author's own class names.
20+/// The prefix of every generated class (`hl-2k9xq0m`), which keeps them
21+/// out of the author's own class names.
2222pub const CLASS_PREFIX: &str = "hl-";
2323
24−/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then
25−/// aaa, ... The first character is always a letter; later ones are drawn from
26−/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide.
27−pub(crate) fn short_class_name(idx: usize) -> String {
28− const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789";
29− // Find the name length: 26 names of length 1, 26*36 of length 2, ...
30− let mut n = idx;
31− let mut len = 1;
32− let mut count = 26usize;
33− while n >= count {
34− n -= count;
35− len += 1;
36− count = count.saturating_mul(36);
37− }
38− let mut tail = Vec::with_capacity(len - 1);
39− for _ in 1..len {
40− tail.push(REST[n % 36]);
41− n /= 36;
42− }
43− let mut name = String::with_capacity(len);
44− name.push((b'a' + n as u8) as char);
45− name.extend(tail.iter().rev().map(|&b| b as char));
46− name
24+/// How many base-36 digits a class's name has after `hl-`: 7, about 36
25+/// bits of its style's hash, so a site with 10,000 different styles has
26+/// odds of about 6 in 10,000 that two of them share a name, which
27+/// `htmlang build` reports (`class-collision`). Within one file, a style
28+/// whose name is taken takes more digits.
29+pub const CLASS_DIGITS: usize = 7;
30+
31+/// A stable hash of `text`: FNV-1a, then SplitMix64's finalizer, so every
32+/// digit of a name depends on all of the text. It is the same on every
33+/// platform and in every build, unlike `std`'s hasher.
34+pub fn stable_hash(text: &str) -> u64 {
35+ let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
36+ for byte in text.bytes() {
37+ hash ^= u64::from(byte);
38+ hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
39+ }
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;
52+ n /= 36;
53+ digit
54+ })
55+ .collect()
56+}
57+
58+/// A class htmlang generated: its name, and the hash of the style it
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 {
63+ pub name: String,
64+ pub style: u64,
4765}
4866
4967/// 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 {
254272 }
255273}
256274
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+
257298struct StyleEntry {
258299 class_name: String,
300+ /// The hash of the style, which names the class
301+ style: u64,
259302 /// The defaults of the element's kind (its layout, a heading's
260303 /// `margin:0`, ...), less what its own attributes set. They are written
261304 /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix
@@ -272,10 +315,11 @@ struct StyleEntry {
272315
273316struct StyleCollector {
274317 entries: Vec<StyleEntry>,
275− /// Maps a pre-hashed style signature to an index into `entries`.
276− /// Using u64 as the key keeps lookups allocation-free; on the rare case of
277− /// a hash collision we fall back to a full equality check against the entry.
318+ /// Maps a style's hash to its entries (more than one only when two
319+ /// styles hash alike, which the full comparison tells apart).
278320 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>,
279323 /// Rules for an element's children, keyed on its class and written in
280324 /// the block of an at-rule condition: `(at-rules, selector, body)`, e.g.
281325 /// `([md], ":where(.a)>.b", "flex:1;")` (see [`Flow`]).
@@ -289,6 +333,7 @@ impl StyleCollector {
289333 StyleCollector {
290334 entries: Vec::new(),
291335 index: HashMap::new(),
336+ names: std::collections::HashSet::new(),
292337 keyed: Vec::new(),
293338 keyed_seen: std::collections::HashSet::new(),
294339 }
@@ -314,14 +359,9 @@ impl StyleCollector {
314359 return None;
315360 }
316361 rules.sort_by_cached_key(|(condition, _)| condition.order());
317− use std::collections::hash_map::DefaultHasher;
318− let mut h = DefaultHasher::new();
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) {
362+ let style = stable_hash(&style_text(&defaults, &rules, &distinct));
363+
364+ if let Some(indices) = self.index.get(&style) {
325365 for &idx in indices {
326366 let e = &self.entries[idx];
327367 if e.defaults == defaults && e.rules == rules && e.distinct == distinct {
@@ -329,18 +369,34 @@ impl StyleCollector {
329369 }
330370 }
331371 }
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
332375 let idx = self.entries.len();
333− let name = format!("{}{}", CLASS_PREFIX, short_class_name(idx));
376+ let name = (CLASS_DIGITS..=13)
377+ .map(|digits| format!("{}{}", CLASS_PREFIX, hash_digits(style, digits)))
378+ .find(|name| !self.names.contains(name))
379+ .unwrap_or_else(|| format!("{}{}{}", CLASS_PREFIX, hash_digits(style, 13), idx));
380+ self.names.insert(name.clone());
334381 self.entries.push(StyleEntry {
335382 class_name: name.clone(),
383+ style,
336384 defaults,
337385 rules,
338386 distinct,
339387 });
340− self.index.entry(sig).or_default().push(idx);
388+ self.index.entry(style).or_default().push(idx);
341389 Some(name)
342390 }
343391
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+
344400 /// All generated rules, in three layers (see [`LAYERS`]): the element
345401 /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the
346402 /// elements an element prefix picks out inside an element (`@td:`), and
@@ -628,15 +684,22 @@ pub struct CodegenOptions {
628684
629685/// Generate HTML from a parsed document using the given options.
630686pub 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();
631694 let mut html = if opts.partial {
632− generate_partial_inner(doc, opts.dev)
695+ generate_partial_inner(doc, opts.dev, &mut classes)
633696 } else {
634− generate_full_inner(doc, opts.dev)
697+ generate_full_inner(doc, opts.dev, &mut classes)
635698 };
636699 if opts.minify {
637700 html = minify_html(&html);
638701 }
639− html
702+ (html, classes)
640703}
641704
642705pub fn generate(doc: &Document) -> String {
@@ -762,10 +825,10 @@ fn minify_html(html: &str) -> String {
762825 result
763826}
764827
765−fn generate_full_inner(doc: &Document, dev: bool) -> String {
828+fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec<Class>) -> String {
766829 // Without `@page` the output is a fragment
767830 let Some(page) = &doc.page else {
768− return generate_partial_inner(doc, dev);
831+ return generate_partial_inner(doc, dev, classes);
769832 };
770833 let mut styles = StyleCollector::new();
771834 let mut ctx = GenContext {
@@ -855,15 +918,9 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
855918 }
856919
857920 // `@page`'s HTML attributes go on `<html>`
858− let mut html_attrs = String::new();
859− for attr in &page.html_attrs {
860− html_attrs.push(' ');
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− }
921+ let mut root_attrs = String::new();
922+ for attr in html_attrs(&page.html_attrs) {
923+ push_html_attr(&mut root_attrs, attr);
867924 }
868925 let mut body_attrs = String::new();
869926 emit_class_attr(&mut body_attrs, body_class.as_deref(), None);
@@ -886,9 +943,10 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
886943 String::new()
887944 };
888945 let reset_css = reset_css(dev, true, &focus_visible_css);
946+ classes.extend(styles.classes());
889947
890948 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}\
892950 {meta_html}<style>{nl}{reset_css}{element_css}</style>{nl}</head>{nl}\
893951 <body{body_attrs}>{nl}{body}</body>{nl}</html>{nl}",
894952 )
@@ -1028,7 +1086,7 @@ fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String {
10281086}
10291087
10301088/// 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 {
10321090 let mut styles = StyleCollector::new();
10331091 let mut ctx = GenContext {
10341092 dev,
@@ -1048,6 +1106,7 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
10481106 if !styles.entries.is_empty() || !doc.scoped_css.is_empty() {
10491107 element_css.insert_str(0, &reset_css(dev, false, ""));
10501108 }
1109+ classes.extend(styles.classes());
10511110
10521111 if element_css.is_empty() {
10531112 body
@@ -1198,27 +1257,43 @@ pub(crate) fn data_uri(path: &str, data: &[u8]) -> String {
11981257}
11991258
12001259/// 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`.
12021261fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1203− for attr in attrs {
1262+ for attr in html_attrs(attrs) {
12041263 let key = attr.key.as_str();
1205− if attr.html && key != "id" && key != "class" {
1206− out.push(' ');
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);
1264+ if key != "id" && key != "class" {
1265+ push_html_attr(out, attr);
12181266 }
12191267 }
12201268}
12211269
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+
12221297// True if any direct child is `@in-front` or `@behind`. Such children render
12231298// as absolutely positioned overlays, so the parent automatically becomes a
12241299// positioning context (position:relative + isolation:isolate).
@@ -2374,3 +2449,31 @@ fn vlq_encode(value: i64, out: &mut String) {
23742449 }
23752450 }
23762451}
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 @@
11#[cfg(test)]
22mod tests {
3− use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
3+ use crate::codegen::{
4+ CodegenOptions, generate, generate_dev, generate_partial, generate_with_classes,
5+ };
46 use crate::parser::parse;
57
68 fn compile(src: &str) -> String {
@@ -54,13 +56,35 @@ mod tests {
5456 assert_eq!(generate_dev(&r1.document), generate_dev(&r2.document));
5557 }
5658
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+
5768 #[test]
58− fn short_class_name_is_stable() {
59− assert_eq!(short_class_name(0), "a");
60− assert_eq!(short_class_name(25), "z");
61− // Higher indexes must produce unique, stable names.
62− let names: std::collections::HashSet<_> = (0..50_000).map(short_class_name).collect();
63− assert_eq!(names.len(), 50_000, "short_class_name collisions");
69+ fn a_style_has_one_name_in_every_file() {
70+ // A fragment's classes mean in a page what they mean in the
71+ // fragment, whatever else either holds
72+ let page = classes("@el [padding 10, background red] a\n@el [gap 4] b\n");
73+ let fragment = classes("@row [margin 2] x\n@el [padding 10, background red] y\n");
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+ }
6488 }
6589
6690 #[test]
crates/htmlang-core/src/diagnostic.rs+16
@@ -77,6 +77,9 @@ impl Diagnostic {
7777pub mod code {
7878 // Syntax: found while reading the file, before anything runs
7979 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";
8083 pub const UNEXPECTED_BODY: &str = "unexpected-body";
8184 pub const UNEXPECTED_ARGUMENT: &str = "unexpected-argument";
8285 pub const MISSING_ARGUMENT: &str = "missing-argument";
@@ -115,6 +118,12 @@ pub mod code {
115118 pub const INVALID_COLOR: &str = "invalid-color";
116119 pub const INVALID_EXPRESSION: &str = "invalid-expression";
117120 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";
118127
119128 // Definitions and functions
120129 pub const UNUSED_VARIABLE: &str = "unused-variable";
@@ -142,6 +151,9 @@ pub mod code {
142151 // Files
143152 pub const UNREADABLE_FILE: &str = "unreadable-file";
144153 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";
145157 pub const UNSET_ENVIRONMENT: &str = "unset-environment";
146158
147159 // Context: an attribute or element where it has no effect
@@ -174,6 +186,7 @@ pub mod code {
174186 /// Every code, for tools and tests.
175187 pub const ALL: &[&str] = &[
176188 UNCLOSED_BRACKET,
189+ UNBALANCED_QUOTE,
177190 UNEXPECTED_BODY,
178191 UNEXPECTED_ARGUMENT,
179192 MISSING_ARGUMENT,
@@ -195,6 +208,8 @@ pub mod code {
195208 INVALID_COLOR,
196209 INVALID_EXPRESSION,
197210 INVALID_JSON,
211+ SPLIT_VALUE,
212+ TEXT_AS_URL,
198213 UNUSED_VARIABLE,
199214 UNUSED_BUNDLE,
200215 UNUSED_FUNCTION,
@@ -209,6 +224,7 @@ pub mod code {
209224 INVALID_SLOT_NAME,
210225 UNREADABLE_FILE,
211226 CIRCULAR_INCLUDE,
227+ CLASS_COLLISION,
212228 UNSET_ENVIRONMENT,
213229 NO_EFFECT,
214230 BLOCK_IN_PARAGRAPH,
crates/htmlang-core/src/parser.rs+118−42
@@ -44,10 +44,10 @@ struct FnDef {
4444 slots: Vec<String>,
4545 /// Whether its body has a `@children` for the content of a call.
4646 has_children: bool,
47− /// Its scoped `@style`, nested under its `.hl-fn-NAME` class: written
48− /// into the page once the function is called (see
49− /// `Document::scoped_css`).
50− scoped: Option<String>,
47+ /// Its scoped `@style`: its class (`hl-fn-NAME-HASH`), and the
48+ /// stylesheet nested under it, written into the page once the function
49+ /// is called (see `Document::scoped_css`).
50+ scoped: Option<(String, String)>,
5151 /// What was visible where it was defined: its body and its defaults
5252 /// see this, plus its parameters.
5353 env: Env,
@@ -1963,9 +1963,12 @@ impl Evaluator {
19631963 let line_num = node.span.line;
19641964 let body = &node.children;
19651965 // 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
19671967 // generated class never has a second `-`, so the two can't meet).
1968− // It is written into the page when the function is called.
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
1970+ // in a page that has both. It is written into the page when the
1971+ // function is called.
19691972 let (style, body): (Vec<&syntax::Node>, Vec<&syntax::Node>) =
19701973 body.iter().partition(|node| node.is_directive("style"));
19711974 let scoped = (!style.is_empty()).then(|| {
@@ -1976,13 +1979,17 @@ impl Evaluator {
19761979 verbatim_content(node)
19771980 })
19781981 .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);
19791985 // Nested under the scope class, so any CSS works (multi-line
19801986 // rules, at-rules)
1981− if css.trim().is_empty() {
1987+ let css = if css.trim().is_empty() {
19821988 String::new()
19831989 } else {
1984− format!(".hl-fn-{} {{\n{}}}", name, css)
1985− }
1990+ format!(".{} {{\n{}}}", class, css)
1991+ };
1992+ (class, css)
19861993 });
19871994
19881995 // A default is filled in only when a call leaves its parameter
@@ -2287,7 +2294,7 @@ impl Evaluator {
22872294
22882295 // Attributes that aren't parameters style the function's root
22892296 // 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());
22912298 if !forwarded.is_empty() || scope_class.is_some() {
22922299 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
22932300 Node::Element(e) => Some(e),
@@ -2321,7 +2328,7 @@ impl Evaluator {
23212328 }
23222329 // The scoped `@style` goes into the page once, when the
23232330 // function is first called
2324− if let Some(css) = &fn_def.scoped
2331+ if let Some((_, css)) = &fn_def.scoped
23252332 && !css.is_empty()
23262333 && !ctx.scoped_css.contains(css)
23272334 {
@@ -2618,8 +2625,7 @@ fn read_page(
26182625 // one is written
26192626 let mut seen: HashMap<String, usize> = HashMap::new();
26202627 for attr in read {
2621− let boolean = attr.value.is_none()
2622− && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&attr.key.as_str());
2628+ let boolean = attr.value.is_none() && crate::vocab::is_html_flag(&attr.key);
26232629 // Where it is written, when it is written in the list itself
26242630 let nth = seen.entry(attr.key.clone()).or_default();
26252631 let column = list
@@ -3656,6 +3662,9 @@ fn parse_single_element(
36563662 }
36573663 } else {
36583664 let column = Some(token.span.column);
3665+ if let Some(d) = text_as_url(&kind, &token, ctx) {
3666+ ctx.push_once(d);
3667+ }
36593668 argument = Some(
36603669 ctx.fill_value(&token.raw, text.span.line, column, Sink::Text)
36613670 .0,
@@ -3813,6 +3822,35 @@ fn bad_leading_token(
38133822 Some(at_attribute(d, Some(token.span.column), ctx))
38143823}
38153824
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+
38163854/// `@link /a > @image logo.png`: a `>` chains only after an element's
38173855/// name or its `[attributes]`, so after a leading argument it is text.
38183856/// Said when what follows names an element or a function.
@@ -4881,7 +4919,7 @@ fn check_attribute(
48814919 attr: &Attribute,
48824920 line: usize,
48834921 column: Option<usize>,
4884− after_another: bool,
4922+ before: Option<&Attribute>,
48854923 ctx: &mut ParseContext,
48864924) -> bool {
48874925 use crate::vocab;
@@ -4909,12 +4947,6 @@ fn check_attribute(
49094947 &attr.key,
49104948 );
49114949 }
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−
49184950 let Some(value) = &attr.value else {
49194951 // `inline` puts a file into the page as it compiles, which no
49204952 // state or media condition can undo
@@ -4933,7 +4965,7 @@ fn check_attribute(
49334965 if vocab::HTMLANG_FLAGS.contains(&base) {
49344966 return true;
49354967 }
4936− if vocab::BOOLEAN_HTML_ATTRS.contains(&base) {
4968+ if vocab::is_html_flag(base) {
49374969 if !prefixed {
49384970 return true;
49394971 }
@@ -4958,7 +4990,7 @@ fn check_attribute(
49584990 &attr.key,
49594991 );
49604992 }
4961− if is_html_name(base) && !prefixed {
4993+ if vocab::is_html_attribute(base) && !prefixed {
49624994 return fail(
49634995 ctx,
49644996 code::HTML_ATTRIBUTE_FORM,
@@ -4969,7 +5001,7 @@ fn check_attribute(
49695001 base,
49705002 );
49715003 }
4972− return unknown_attribute(attr, base, line, column, after_another, true, ctx);
5004+ return unknown_attribute(attr, base, line, column, before, true, ctx);
49735005 };
49745006
49755007 if vocab::HTMLANG_FLAGS.contains(&base) {
@@ -4989,7 +5021,7 @@ fn check_attribute(
49895021 {
49905022 return check_css_value(attr, base, line, column, ctx);
49915023 }
4992− if is_html_name(base) && !prefixed {
5024+ if vocab::is_html_attribute(base) && !prefixed {
49935025 let diagnostic = Diagnostic::error(
49945026 code::HTML_ATTRIBUTE_FORM,
49955027 line,
@@ -5004,7 +5036,7 @@ fn check_attribute(
50045036 return false;
50055037 }
50065038 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)
50085040 && check_css_value(attr, base, line, column, ctx)
50095041}
50105042
@@ -5015,8 +5047,8 @@ fn check_attribute(
50155047fn could_be_parameter(attr: &Attribute) -> bool {
50165048 use crate::vocab;
50175049 let key = attr.key.as_str();
5018− let flag = attr.value.is_none()
5019− && (vocab::HTMLANG_FLAGS.contains(&key) || vocab::BOOLEAN_HTML_ATTRS.contains(&key));
5050+ let flag =
5051+ attr.value.is_none() && (vocab::HTMLANG_FLAGS.contains(&key) || vocab::is_html_flag(key));
50205052 !attr.html
50215053 && !flag
50225054 && !vocab::is_style_attribute(key)
@@ -5071,7 +5103,7 @@ fn unknown_attribute(
50715103 base: &str,
50725104 line: usize,
50735105 column: Option<usize>,
5074− after_another: bool,
5106+ before: Option<&Attribute>,
50755107 error: bool,
50765108 ctx: &mut ParseContext,
50775109) -> bool {
@@ -5117,6 +5149,36 @@ fn unknown_attribute(
51175149 // itself
51185150 let suggestion =
51195151 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+ }
51205182 let mut message = match (&suggestion, error) {
51215183 (Some(closest), true) => {
51225184 format!(
@@ -5138,12 +5200,19 @@ fn unknown_attribute(
51385200 // sans-serif`: the comma ended the attribute, and the rest of the
51395201 // value became one
51405202 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);
5142− if rest_of_a_value && error {
5203+ || (suggestion.is_none() && attr.value.is_none() && before.is_some());
5204+ if joined.is_some() || (rest_of_a_value && error) {
51435205 if !message.ends_with('?') {
51445206 message.push('.');
51455207 }
5146− message.push_str(" A comma separates attributes: to keep one in a value, write `\\,`");
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
5214+ .push_str(" A comma separates attributes: to keep one in a value, write `\\,`"),
5215+ }
51475216 }
51485217 let severity = if error {
51495218 Severity::Error
@@ -5285,7 +5354,13 @@ fn read_attrs(
52855354 && !text_keys.contains(&attr.key);
52865355 if checked_here
52875356 && (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+ ))
52895364 {
52905365 continue;
52915366 }
@@ -5389,11 +5464,11 @@ fn read_attrs(
53895464 if validate {
53905465 let seen = (normal_key(&attr.key), attr.html);
53915466 if out.seen_keys.contains(&seen) {
5392− let message = if attr.html {
5393− format!("duplicate attribute '{}='", attr.key)
5394− } else {
5395− format!("duplicate attribute '{}': the later one wins", attr.key)
5396− };
5467+ let written = if attr.html { "=" } else { "" };
5468+ let message = format!(
5469+ "duplicate attribute '{}{}': the later one wins",
5470+ attr.key, written
5471+ );
53975472 let diagnostic = Diagnostic::warning(code::DUPLICATE_ATTRIBUTE, line, message)
53985473 .subject(attr.key.as_str());
53995474 let diagnostic = at_attribute(diagnostic, column(0), ctx);
@@ -5438,8 +5513,8 @@ fn read_attrs(
54385513 // A style or a flag: its name is checked, and what can't be
54395514 // written into the page is an error and left out
54405515 let checked = validate && !attr.html && !(ctx.in_bundle && could_be_parameter(&attr));
5441− let after_another = !out.attrs.is_empty();
5442− if checked && !check_attribute(&attr, line, column(0), after_another, ctx) {
5516+ let before = out.attrs.last().map(|(a, _)| a);
5517+ if checked && !check_attribute(&attr, line, column(0), before, ctx) {
54435518 continue;
54445519 }
54455520
@@ -5583,7 +5658,7 @@ fn prefixed_member(
55835658 };
55845659 if validate {
55855660 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)
55875662 } else {
55885663 check_prefixes(&prefixed, line, column, ctx)
55895664 };
@@ -5994,6 +6069,7 @@ fn check_branch(
59946069 attrs: branch.attrs().to_vec(),
59956070 span: token.span,
59966071 closed: true,
6072+ quoted_close: None,
59976073 };
59986074 let grouped = syntax::Attr {
59996075 key: prefix.to_string(),
@@ -6478,7 +6554,7 @@ fn dropped_by_the_element(elem: &Element, diagnostics: &mut Vec<Diagnostic>) {
64786554 let styles: Vec<&Attribute> = elem
64796555 .attrs
64806556 .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))
64826558 .collect();
64836559 if !styles.is_empty() {
64846560 error(
@@ -6740,7 +6816,7 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
67406816 continue;
67416817 }
67426818 (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,
67446820 _ => {
67456821 left_out.push(attr.key.clone());
67466822 continue;
crates/htmlang-core/src/syntax.rs+31
@@ -136,6 +136,10 @@ pub struct AttrList {
136136 pub span: Span,
137137 /// Whether the closing `]` was found.
138138 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>,
139143}
140144
141145/// One attribute of a list, as written.
@@ -1506,6 +1510,25 @@ impl Reader<'_> {
15061510 let (text, open) = self.text(0, self.text.len());
15071511 (NodeKind::Text(text), open)
15081512 };
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+ }
15091532 if open {
15101533 // An unclosed quote keeps the list open to the end of the file
15111534 let mut quotes = 0;
@@ -1590,6 +1613,8 @@ impl Reader<'_> {
15901613 fn attr_list(&self, open: usize, limit: usize) -> (AttrList, usize) {
15911614 let mut depth = 0;
15921615 let mut quoted = false;
1616+ // The `'`s outside `"..."`, which don't quote anything in htmlang
1617+ let mut apostrophes = 0;
15931618 let mut close = None;
15941619 let mut i = open;
15951620 while i < limit {
@@ -1603,6 +1628,7 @@ impl Reader<'_> {
16031628 match c {
16041629 '"' => quoted = !quoted,
16051630 _ if quoted => {}
1631+ '\'' => apostrophes += 1,
16061632 '[' => depth += 1,
16071633 ']' => {
16081634 depth -= 1;
@@ -1622,10 +1648,15 @@ impl Reader<'_> {
16221648 .filter_map(|(s, e)| self.attr(s, e))
16231649 .collect();
16241650 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+ });
16251655 let list = AttrList {
16261656 attrs,
16271657 span: self.span(open, end),
16281658 closed: close.is_some(),
1659+ quoted_close: quoted_close.map(|c| self.span(c, c + 1)),
16291660 };
16301661 (list, end)
16311662 }
crates/htmlang-core/src/vocab.rs+27−1
@@ -5,7 +5,8 @@
55//! property (`spacing`, `center-x`, `width fill`, ...), handled in codegen;
66//! - a standard CSS property, copied into the generated CSS unchanged;
77//! - 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.
910
1011/// htmlang's own attributes (those that are neither a CSS property nor an
1112/// HTML attribute).
@@ -158,6 +159,31 @@ pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
158159 "readonly", "required", "reversed", "sandbox", "selected",
159160];
160161
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+
161187/// Common HTML attribute names, used to suggest `key=value` when one is
162188/// written like a style (`type email`). Any name works with `=`.
163189#[rustfmt::skip]
src/bin/htmlang_lsp/docs.rs+6−4
@@ -399,10 +399,12 @@ fn attribute_hover(name: &str) -> Option<String> {
399399 "**{name}** \u{2014} Boolean HTML attribute, written bare: `[{name}]`."
400400 ));
401401 }
402− if vocab::HTML_ATTRIBUTES.contains(&name)
403− || name.starts_with("aria-")
404− || name.starts_with("data-")
405− {
402+ if vocab::is_html_flag(name) {
403+ return Some(format!(
404+ "**{name}** \u{2014} HTML attribute, written `{name}=value`, or bare for one with no value: `[{name}]`."
405+ ));
406+ }
407+ if vocab::is_html_attribute(name) {
406408 return Some(format!(
407409 "**{name}** \u{2014} HTML attribute, written `{name}=value`."
408410 ));
src/main.rs+73−1
@@ -42,6 +42,9 @@ struct CompileConfig<'a> {
4242 minify: bool,
4343 strict: bool,
4444 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)>>>,
4548}
4649
4750fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
@@ -134,7 +137,7 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
134137 let _ = fs::write(&out_path, &error_html);
135138 }
136139 } else {
137− let html = htmlang::codegen::generate_with(
140+ let (html, classes) = htmlang::codegen::generate_with_classes(
138141 &result.document,
139142 &htmlang::codegen::CodegenOptions {
140143 dev: cfg.dev,
@@ -142,6 +145,11 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
142145 minify: cfg.minify,
143146 },
144147 );
148+ if let Some(seen) = cfg.classes
149+ && class_collisions(input_path, classes, seen, cfg)
150+ {
151+ return (true, result.included_files);
152+ }
145153 match fs::write(&out_path, &html) {
146154 Ok(()) => eprintln!("wrote {}", out_path.display()),
147155 Err(e) => eprintln!("error: {}: {}", out_path.display(), e),
@@ -166,6 +174,49 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
166174 (has_errors, result.included_files)
167175}
168176
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+
169220fn json_escape_string(s: &str) -> String {
170221 let mut out = String::with_capacity(s.len() + 2);
171222 out.push('"');
@@ -648,9 +699,11 @@ fn main() {
648699 // Compile files in parallel
649700 let build_start = std::time::Instant::now();
650701 let any_errors = std::sync::atomic::AtomicBool::new(false);
702+ let classes = Mutex::new(HashMap::new());
651703 std::thread::scope(|s| {
652704 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
653705 let any_errors = &any_errors;
706+ let classes = &classes;
654707 s.spawn(move || {
655708 let path_str = file.to_string_lossy().to_string();
656709 let (has_errors, _) = compile(
@@ -659,6 +712,7 @@ fn main() {
659712 output_path: effective_out.as_deref(),
660713 minify: build_minify,
661714 strict: build_strict,
715+ classes: Some(classes),
662716 ..Default::default()
663717 },
664718 );
@@ -1642,6 +1696,24 @@ mod tests {
16421696 let _ = fs::remove_dir_all(&dir);
16431697 }
16441698
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+
16451717 #[test]
16461718 fn json_escape_handles_control_characters() {
16471719 assert_eq!(
src/serve.rs+128−50
@@ -64,11 +64,10 @@ where
6464 }
6565
6666 let request = String::from_utf8_lossy(&buf[..n]);
67− let path = request
68− .lines()
69− .next()
70− .and_then(|line| line.split_whitespace().nth(1))
71− .unwrap_or("/");
67+ let (method, path) = request_line(&request);
68+ let Some(head) = reads(method) else {
69+ return method_not_allowed(&mut stream).await;
70+ };
7271
7372 if path == "/_events" {
7473 return handle_sse(stream, reload_rx).await;
@@ -91,13 +90,8 @@ where
9190 } else {
9291 // Generate directory listing
9392 let listing = generate_directory_listing(&root_dir);
94− let header = format!(
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",
96− listing.len(),
97− );
98− stream.write_all(header.as_bytes()).await?;
99− stream.write_all(listing.as_bytes()).await?;
100− return Ok(());
93+ let html = "text/html; charset=utf-8";
94+ return respond(&mut stream, "200 OK", html, listing.as_bytes(), head).await;
10195 }
10296 } else {
10397 // The file itself, then `<path>.html`, then `<path>/index.html`.
@@ -130,23 +124,67 @@ where
130124 return Ok(());
131125 }
132126 };
133− let content_type = content_type_for(&fp);
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?;
127+ respond(&mut stream, "200 OK", content_type_for(&fp), &body, head).await?;
141128 }
142129 None => {
143− send_404(&mut stream, clean_path).await?;
130+ send_404(&mut stream, clean_path, head).await?;
144131 }
145132 }
146133
147134 Ok(())
148135}
149136
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+
150188/// Generate a directory listing HTML page showing available .html pages.
151189fn generate_directory_listing(dir: &Path) -> String {
152190 let mut pages = Vec::new();
@@ -214,16 +252,15 @@ where
214252 }
215253
216254 let request = String::from_utf8_lossy(&buf[..n]);
217− let path = request
218− .lines()
219− .next()
220− .and_then(|line| line.split_whitespace().nth(1))
221− .unwrap_or("/");
255+ let (method, path) = request_line(&request);
256+ let Some(head) = reads(method) else {
257+ return method_not_allowed(&mut stream).await;
258+ };
222259
223260 if path == "/_events" {
224261 handle_sse(stream, reload_rx).await
225262 } else {
226− handle_file(stream, &html_path, root_dir, path).await
263+ handle_file(stream, &html_path, root_dir, path, head).await
227264 }
228265}
229266
@@ -281,6 +318,7 @@ async fn handle_file<S>(
281318 html_path: &Path,
282319 root_dir: Option<&Path>,
283320 request_path: &str,
321+ head: bool,
284322) -> std::io::Result<()>
285323where
286324 S: AsyncRead + AsyncWrite + Unpin + Send,
@@ -302,7 +340,7 @@ where
302340 } else if is_within(dir, &requested) {
303341 requested
304342 } else {
305− send_404(&mut stream, request_path).await?;
343+ send_404(&mut stream, request_path, head).await?;
306344 return Ok(());
307345 };
308346
@@ -315,20 +353,13 @@ where
315353 }
316354 }
317355 Err(_) => {
318− send_404(&mut stream, request_path).await?;
356+ send_404(&mut stream, request_path, head).await?;
319357 return Ok(());
320358 }
321359 };
322360
323361 let content_type = content_type_for(&file_path);
324− let header = format!(
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(())
362+ respond(&mut stream, "200 OK", content_type, &body, head).await
332363}
333364
334365/// 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> {
373404 String::from_utf8(out).ok()
374405}
375406
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<()>
377408where
378409 S: AsyncRead + AsyncWrite + Unpin + Send,
379410{
@@ -390,15 +421,13 @@ where
390421 <a href=\"/\">Back to index</a></div></body></html>",
391422 path.replace('<', "&lt;").replace('>', "&gt;")
392423 );
393− let header = format!(
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?;
399− Ok(())
424+ let html = "text/html; charset=utf-8";
425+ respond(stream, "404 Not Found", html, body.as_bytes(), head).await
400426}
401427
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.
402431fn inject_reload_script(content: &[u8]) -> Vec<u8> {
403432 let html = String::from_utf8_lossy(content);
404433 match html.rfind("</body>") {
@@ -410,11 +439,7 @@ fn inject_reload_script(content: &[u8]) -> Vec<u8> {
410439 result.push_str(&html[pos..]);
411440 result.into_bytes()
412441 }
413− None => {
414− let mut result = content.to_vec();
415− result.extend_from_slice(RELOAD_SCRIPT.as_bytes());
416− result
417− }
442+ None => content.to_vec(),
418443 }
419444}
420445
@@ -477,4 +502,57 @@ mod tests {
477502 assert_eq!(resolve_request_path(root, "/bad%zz"), None);
478503 assert_eq!(resolve_request_path(root, "é"), Some(root.join("é")));
479504 }
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+ }
480558}
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 @@
11//! Regression tests for bugs found in review. Each test names the behavior
22//! that used to be wrong.
33
4+mod common;
5+
46use std::path::PathBuf;
57
6−use htmlang::codegen;
8+use common::codegen;
79use htmlang::parser::{self, Severity};
810
911fn compile(input: &str) -> String {
@@ -478,15 +480,14 @@ fn inline_element_attributes_can_continue_on_the_next_line() {
478480 "@paragraph\n Press {@kbd [\n padding 2 6, border-radius 4\n ] Ctrl+K} to search.\n",
479481 );
480482 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
481− let html = htmlang::codegen::generate(&result.document);
483+ let html = codegen::generate(&result.document);
482484 assert!(html.contains(">Ctrl+K</kbd> to search."), "{}", html);
483485}
484486
485487#[test]
486488fn lines_under_text_are_its_siblings() {
487− let html = htmlang::codegen::generate(
488− &htmlang::parser::parse("@el\n Some text\n more text\n").document,
489− );
489+ let html =
490+ codegen::generate(&htmlang::parser::parse("@el\n Some text\n more text\n").document);
490491 assert!(
491492 html.contains("<span>Some text</span><span>more text</span>"),
492493 "{}",
@@ -546,16 +547,28 @@ fn css_values_pass_through() {
546547#[test]
547548fn page_attributes_are_checked_like_any_element_s() {
548549 // `canonical` belongs in a `@head` link: on @page it is an unknown CSS
549− // property (for <body>), reported rather than dropped
550− let result = parser::parse("@page [lang=en, canonical https://x.dev] T\n");
551− assert!(
552− result
553− .diagnostics
554− .iter()
555− .any(|d| d.code == "unknown-attribute" && d.message.contains("'canonical'")),
556− "{:?}",
557− result.diagnostics
558− );
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+ ] {
562+ let result = parser::parse(source);
563+ assert!(
564+ result
565+ .diagnostics
566+ .iter()
567+ .any(|d| d.code == code && d.message.contains("'canonical'")),
568+ "{:?}",
569+ result.diagnostics
570+ );
571+ }
559572}
560573
561574// --- 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() {
23222335 .collect();
23232336 assert_eq!(lines, [1, 2], "{:?}", d);
23242337}
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;
14use std::fs;
25use std::path::Path;
36
@@ -11,7 +14,7 @@ fn compile(input: &str) -> String {
1114 "unexpected parse errors: {:?}",
1215 result.diagnostics
1316 );
14− htmlang::codegen::generate(&result.document)
17+ codegen::generate(&result.document)
1518}
1619
1720fn compile_with_base(input: &str, base: &Path) -> String {
@@ -24,7 +27,7 @@ fn compile_with_base(input: &str, base: &Path) -> String {
2427 "unexpected parse errors: {:?}",
2528 result.diagnostics
2629 );
27− htmlang::codegen::generate(&result.document)
30+ codegen::generate(&result.document)
2831}
2932
3033fn parse_diagnostics(input: &str) -> Vec<htmlang::parser::Diagnostic> {
@@ -256,7 +259,7 @@ fn error_unknown_attribute() {
256259 diags
257260 );
258261 assert!(
259− htmlang::codegen::generate(&result.document).contains("bakground:red"),
262+ codegen::generate(&result.document).contains("bakground:red"),
260263 "an unknown property is passed through"
261264 );
262265}
@@ -313,7 +316,7 @@ fn values_pass_through_unchecked() {
313316 color rebeccapurple, width fit-content, font-weight 450, display blok]\n x";
314317 let result = htmlang::parser::parse(src);
315318 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
316− let html = htmlang::codegen::generate(&result.document);
319+ let html = codegen::generate(&result.document);
317320 for css in [
318321 "padding:abc",
319322 "opacity:50%",
@@ -2748,7 +2751,7 @@ fn test_variable_filters() {
27482751 .iter()
27492752 .all(|d| d.severity != htmlang::parser::Severity::Error)
27502753 );
2751− let html = htmlang::codegen::generate(&result.document);
2754+ let html = codegen::generate(&result.document);
27522755 assert!(
27532756 html.contains("HELLO"),
27542757 "uppercase filter should work, got: {}",
@@ -2756,7 +2759,7 @@ fn test_variable_filters() {
27562759 );
27572760
27582761 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);
27602763 assert!(
27612764 html.contains("hello"),
27622765 "lowercase filter should work, got: {}",
@@ -2764,7 +2767,7 @@ fn test_variable_filters() {
27642767 );
27652768
27662769 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);
27682771 assert!(
27692772 html.contains("Hello"),
27702773 "capitalize filter should work, got: {}",
@@ -2772,7 +2775,7 @@ fn test_variable_filters() {
27722775 );
27732776
27742777 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);
27762779 assert!(
27772780 html.contains("5"),
27782781 "length filter should work, got: {}",
@@ -2780,7 +2783,7 @@ fn test_variable_filters() {
27802783 );
27812784
27822785 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);
27842787 assert!(
27852788 html.contains("olleh"),
27862789 "reverse filter should work, got: {}",
@@ -2788,7 +2791,7 @@ fn test_variable_filters() {
27882791 );
27892792
27902793 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);
27922795 assert!(
27932796 html.contains("hello..."),
27942797 "truncate filter should work, got: {}",
@@ -2799,7 +2802,7 @@ fn test_variable_filters() {
27992802#[test]
28002803fn test_css_shorthands_output() {
28012804 let result = htmlang::parser::parse("@text [$truncate] Hello");
2802− let html = htmlang::codegen::generate(&result.document);
2805+ let html = codegen::generate(&result.document);
28032806 assert!(
28042807 html.contains("text-overflow:ellipsis"),
28052808 "truncate should add ellipsis, got: {}",
@@ -2811,7 +2814,7 @@ fn test_css_shorthands_output() {
28112814 );
28122815
28132816 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);
28152818 assert!(
28162819 html.contains("-webkit-line-clamp:3"),
28172820 "line-clamp should work, got: {}",
@@ -2819,7 +2822,7 @@ fn test_css_shorthands_output() {
28192822 );
28202823
28212824 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);
28232826 assert!(
28242827 html.contains("filter:blur(4px)"),
28252828 "blur should work, got: {}",
@@ -2827,7 +2830,7 @@ fn test_css_shorthands_output() {
28272830 );
28282831
28292832 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);
28312834 assert!(
28322835 html.contains("backdrop-filter:blur(10px)"),
28332836 "backdrop-blur should work, got: {}",
@@ -2865,7 +2868,7 @@ fn test_theme_directive() {
28652868 "theme should not cause errors: {:?}",
28662869 errors
28672870 );
2868− let html = htmlang::codegen::generate(&result.document);
2871+ let html = codegen::generate(&result.document);
28692872 assert!(
28702873 html.contains("--primary:#3b82f6"),
28712874 "theme should emit CSS vars, got: {}",
@@ -2991,7 +2994,7 @@ fn a_token_is_named_like_a_custom_property_on_an_element() {
29912994#[test]
29922995fn test_autofocus_attribute() {
29932996 let result = htmlang::parser::parse("@input [type=text, autofocus]");
2994− let html = htmlang::codegen::generate(&result.document);
2997+ let html = codegen::generate(&result.document);
29952998 assert!(
29962999 html.contains("autofocus"),
29973000 "autofocus should be in output, got: {}",
@@ -3681,8 +3684,8 @@ fn switch_with_attrs() {
36813684fn minified_output_is_smaller() {
36823685 let input = "@page Test\n@el [padding 20]\n @text [font-weight bold] Hello World\n @paragraph\n Some text here\n";
36833686 let result = htmlang::parser::parse(input);
3684− let normal = htmlang::codegen::generate(&result.document);
3685− let minified = htmlang::codegen::generate_minified(&result.document);
3687+ let normal = codegen::generate(&result.document);
3688+ let minified = codegen::generate_minified(&result.document);
36863689 assert!(
36873690 minified.len() <= normal.len(),
36883691 "minified ({}) should be <= normal ({})",
@@ -3696,8 +3699,8 @@ fn minified_output_is_smaller() {
36963699fn minified_strips_comments() {
36973700 let input = "@page Test\n@el\n @text Hello\n";
36983701 let result = htmlang::parser::parse(input);
3699− let dev = htmlang::codegen::generate_dev(&result.document);
3700− let minified = htmlang::codegen::generate_minified(&result.document);
3702+ let dev = codegen::generate_dev(&result.document);
3703+ let minified = codegen::generate_minified(&result.document);
37013704 // Dev mode has comments, minified should not
37023705 assert!(dev.contains("<!--"));
37033706 assert!(!minified.contains("<!--"));
@@ -4201,7 +4204,7 @@ fn snapshot_responsive_images() {
42014204fn source_map_generation() {
42024205 let input = "@page Test\n@text [font-weight bold] Hello";
42034206 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");
42054208 assert!(
42064209 map.contains("\"version\":3"),
42074210 "source map should have version 3"
@@ -4293,7 +4296,7 @@ fn svg_directive_inline() {
42934296 "should parse without errors: {:?}",
42944297 result.diagnostics
42954298 );
4296− let html = htmlang::codegen::generate(&result.document);
4299+ let html = codegen::generate(&result.document);
42974300 assert!(
42984301 html.contains("<svg"),
42994302 "should inline SVG content, got: {}",
@@ -4324,7 +4327,7 @@ fn svg_directive_with_attrs() {
43244327 svg_path.display()
43254328 );
43264329 let result = htmlang::parser::parse(&input);
4327− let html = htmlang::codegen::generate(&result.document);
4330+ let html = codegen::generate(&result.document);
43284331 assert!(
43294332 html.contains("style=\"width:24px;fill:red\""),
43304333 "should override width, got: {}",
@@ -4363,7 +4366,7 @@ fn css_property_directive() {
43634366 "should parse without errors: {:?}",
43644367 result.diagnostics
43654368 );
4366− let html = htmlang::codegen::generate(&result.document);
4369+ let html = codegen::generate(&result.document);
43674370 assert!(
43684371 html.contains("@property --my-color"),
43694372 "@property rule should be emitted, got: {}",
@@ -4389,7 +4392,7 @@ fn css_property_directive() {
43894392#[test]
43904393fn partial_output() {
43914394 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);
43934396 assert!(
43944397 !html.contains("<!DOCTYPE"),
43954398 "partial should not have doctype, got: {}",
@@ -4425,7 +4428,7 @@ fn partial_output() {
44254428#[test]
44264429fn partial_output_dev() {
44274430 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);
44294432 assert!(
44304433 !html.contains("<!DOCTYPE"),
44314434 "partial dev should not have doctype"
@@ -4466,7 +4469,7 @@ fn auto_image_dimensions_respects_explicit() {
44664469 png_path.display()
44674470 );
44684471 let result = htmlang::parser::parse(&input);
4469− let html = htmlang::codegen::generate(&result.document);
4472+ let html = codegen::generate(&result.document);
44704473 // When width/height are set as CSS attrs, auto-dimensions should not add HTML width/height
44714474 assert!(
44724475 html.contains("width:100px"),
@@ -4503,7 +4506,7 @@ fn perf_large_document() {
45034506 let input = std::fs::read_to_string(dir.join("stress_large.hl")).unwrap();
45044507 let start = std::time::Instant::now();
45054508 let result = htmlang::parser::parse(&input);
4506− let _ = htmlang::codegen::generate(&result.document);
4509+ let _ = codegen::generate(&result.document);
45074510 let elapsed = start.elapsed();
45084511 assert!(
45094512 elapsed.as_millis() < 5000,
@@ -4576,7 +4579,7 @@ fn markdown_file_renders_content() {
45764579
45774580 let input = "@markdown article.md\n";
45784581 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);
45804583 let _ = std::fs::remove_dir_all(&dir);
45814584
45824585 assert!(
@@ -4609,7 +4612,7 @@ fn markdown_file_with_variable_path() {
46094612
46104613 let input = "@let file post.md\n@markdown $file\n";
46114614 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);
46134616 let _ = std::fs::remove_dir_all(&dir);
46144617
46154618 assert!(
@@ -4746,7 +4749,7 @@ fn only_a_function_takes_a_body() {
47464749 assert_eq!(found[0].0, 3);
47474750 assert!(found[0].1.contains("`@let @card"), "{}", found[0].1);
47484751 let result = htmlang::parser::parse(&input);
4749− let html = htmlang::codegen::generate(&result.document);
4752+ let html = codegen::generate(&result.document);
47504753 assert!(!html.contains("Body"), "{}: {}", head, html);
47514754 }
47524755}
@@ -4780,7 +4783,7 @@ fn a_let_needs_a_bare_name_and_a_value() {
47804783 }
47814784 // The body of a `@let` without a value isn't rendered either
47824785 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"));
47844787
47854788 for name in ["1x", "a!b", "true"] {
47864789 let input = format!("@let {} = 1\n", name);
@@ -5054,7 +5057,7 @@ fn a_parameter_passed_with_equals_is_an_error() {
50545057 // Not also reported as missing, and not forwarded as an HTML attribute
50555058 assert!(coded(&diags, "missing-parameter").is_empty(), "{:?}", diags);
50565059 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);
50585061 assert!(out.contains("Hi"), "{}", out);
50595062 assert!(!out.contains("title=\"Hi\""), "{}", out);
50605063}
@@ -5311,7 +5314,7 @@ fn a_function_in_its_own_callers_content_is_not_recursion() {
53115314 let src = "@let @box\n @el [padding 4]\n @children\n@box\n @box\n Inner\n {@box x}\n@box {@box y}\n";
53125315 let result = htmlang::parser::parse(src);
53135316 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5314− let out = htmlang::codegen::generate(&result.document);
5317+ let out = codegen::generate(&result.document);
53155318 // The two calls inside text are inline-flex spans
53165319 assert_eq!(out.matches("<div").count(), 3, "{}", out);
53175320 assert_eq!(out.matches("<span class=").count(), 2, "{}", out);
@@ -5705,7 +5708,7 @@ fn an_included_file_s_slots_are_where_its_include_is() {
57055708 Some(&dir),
57065709 );
57075710 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5708− let html = htmlang::codegen::generate(&result.document);
5711+ let html = codegen::generate(&result.document);
57095712 for text in ["Hello", "Foot", "From fillers", "Plain"] {
57105713 assert!(html.contains(text), "{}: {}", text, html);
57115714 }
@@ -5866,7 +5869,7 @@ fn css_if_in_a_value_is_css() {
58665869 "@el [width if(media(width > 40em): 50%; else: 100%), color if(style(--dark: 1): white; else: black)]\n x\n",
58675870 );
58685871 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
5869− let html = htmlang::codegen::generate(&result.document);
5872+ let html = codegen::generate(&result.document);
58705873 assert!(
58715874 html.contains("width:if(media(width > 40em): 50%; else: 100%);"),
58725875 "{}",
@@ -5978,7 +5981,7 @@ fn the_after_snippet_of_names_not_values() {
59785981 let unknown = coded(&result.diagnostics, "unknown-attribute");
59795982 assert_eq!(unknown.len(), 1, "{:?}", result.diagnostics);
59805983 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"));
59825985
59835986 let d = parse_diagnostics("@let name Ada\n@text Hello $nmae\n");
59845987 let undefined = coded(&d, "undefined-variable");
@@ -6011,7 +6014,7 @@ fn unknown_css_properties_pass_through_with_a_warning() {
60116014 assert_eq!(unknown[0].subject.as_deref(), Some("colr"));
60126015 assert_eq!(unknown[0].suggestion.as_deref(), Some("color"));
60136016 assert_eq!(unknown[0].column, Some(5));
6014− let html = htmlang::codegen::generate(&result.document);
6017+ let html = codegen::generate(&result.document);
60156018 for css in [
60166019 "colr:red",
60176020 "--gap:12px",
@@ -6045,7 +6048,7 @@ fn what_cannot_be_css_is_an_error_and_left_out() {
60456048 src,
60466049 result.diagnostics
60476050 );
6048− let html = htmlang::codegen::generate(&result.document);
6051+ let html = codegen::generate(&result.document);
60496052 assert!(
60506053 !html.contains("center-z") && !html.contains("email"),
60516054 "{}",
@@ -6103,7 +6106,7 @@ fn selector_prefixes_are_css_pseudo_classes_and_elements() {
61036106 @el [not([title=\") x\"]):color blue] x\n";
61046107 let result = htmlang::parser::parse(src);
61056108 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
6106− let html = htmlang::codegen::generate(&result.document);
6109+ let html = codegen::generate(&result.document);
61076110 for css in [
61086111 ":first-child{font-weight:700;}",
61096112 ":nth-child(odd){background:var(--subtle);}",
@@ -6170,7 +6173,7 @@ fn values_that_would_break_out_of_the_css_rule_are_errors() {
61706173 result.diagnostics
61716174 );
61726175 // The value is left out of the page
6173− let html = htmlang::codegen::generate(&result.document);
6176+ let html = codegen::generate(&result.document);
61746177 assert!(
61756178 !html.contains("background blue") && !html.contains("{3}"),
61766179 "{}",
@@ -6220,7 +6223,7 @@ fn a_style_whose_value_comes_out_empty_is_left_out() {
62206223 "@data $p {\"title\": \"Hi\"}\n@let on false\n@el [padding $p.gap, margin ${if($on, 4)}, color red]\n $p.title\n",
62216224 );
62226225 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
6223− let html = htmlang::codegen::generate(&result.document);
6226+ let html = codegen::generate(&result.document);
62246227 assert!(
62256228 !html.contains("padding:") && !html.contains("margin:"),
62266229 "{}",
@@ -6330,7 +6333,7 @@ fn a_warning_in_a_loop_or_a_function_is_reported_once() {
63306333fn hidden_is_a_boolean_attribute() {
63316334 let result = htmlang::parser::parse("@el [hidden] x\n");
63326335 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>"));
63346337}
63356338
63366339#[test]
@@ -6527,7 +6530,7 @@ fn spacing_goes_only_on_a_row_column_or_grid() {
65276530 );
65286531 assert!(errors[0].message.contains(name), "{}: {:?}", src, errors);
65296532 // Left out of the page
6530− let out = htmlang::codegen::generate(&result.document);
6533+ let out = codegen::generate(&result.document);
65316534 assert!(!out.contains("gap:"), "{}: {}", src, out);
65326535 assert!(!out.contains("flex-wrap"), "{}: {}", src, out);
65336536 assert!(!out.contains("grid-template"), "{}: {}", src, out);
@@ -6575,7 +6578,7 @@ fn fill_and_center_compile_against_the_parent_s_layout() {
65756578 "{:?}",
65766579 result.diagnostics
65776580 );
6578− assert!(!htmlang::codegen::generate(&result.document).contains("flex:1"));
6581+ assert!(!codegen::generate(&result.document).contains("flex:1"));
65796582}
65806583
65816584#[test]
@@ -7385,7 +7388,7 @@ fn an_element_inside_text_is_not_a_child_of_the_row_around_it() {
73857388/// Output with the errors allowed, for tests of what an error leaves.
73867389fn compile_anyway(input: &str) -> (String, Vec<htmlang::parser::Diagnostic>) {
73877390 let result = htmlang::parser::parse(input);
7388− let html = htmlang::codegen::generate_partial(&result.document);
7391+ let html = codegen::generate_partial(&result.document);
73897392 (html, result.diagnostics)
73907393}
73917394
@@ -7740,7 +7743,7 @@ fn readable_output_adds_no_whitespace_inside_pre_and_textarea() {
77407743 "@el\n @pre > @code\n a\n b\n @textarea [aria-label=x]\n c\n",
77417744 )
77427745 .document;
7743− let out = htmlang::codegen::generate_dev(&doc);
7746+ let out = codegen::generate_dev(&doc);
77447747 assert!(
77457748 out.contains("><code class=\"hl-c\">a\n b</code></pre>\n"),
77467749 "{}",
@@ -8198,7 +8201,7 @@ fn a_custom_property_is_a_style_on_any_element_under_any_prefix() {
81988201 "@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",
81998202 );
82008203 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8201− let html = htmlang::codegen::generate(&result.document);
8204+ let html = codegen::generate(&result.document);
82028205 for declaration in [
82038206 "--a:1;",
82048207 "--b:2;",
@@ -8228,7 +8231,7 @@ fn a_vendor_prefixed_property_is_a_style_under_any_prefix() {
82288231 "@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",
82298232 );
82308233 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8231− let html = htmlang::codegen::generate(&result.document);
8234+ let html = codegen::generate(&result.document);
82328235 for declaration in [
82338236 "-webkit-text-stroke:1px red;",
82348237 ":hover{-webkit-text-stroke:2px red;}",
@@ -8264,7 +8267,7 @@ fn a_custom_property_on_a_line_of_a_list_is_a_declaration() {
82648267 "@el [\n padding 4,\n --surface white,\n -- a comment\n --\n color red\n]\n x\n",
82658268 );
82668269 assert!(result.diagnostics.is_empty(), "{:?}", result.diagnostics);
8267− let html = htmlang::codegen::generate(&result.document);
8270+ let html = codegen::generate(&result.document);
82688271 assert!(
82698272 html.contains("padding:4px;--surface:white;color:red;"),
82708273 "{}",
@@ -8296,7 +8299,7 @@ fn a_style_an_inline_svg_can_t_take_is_an_error() {
82968299 "@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",
82978300 Some(&dir),
82988301 );
8299− let html = htmlang::codegen::generate(&result.document);
8302+ let html = codegen::generate(&result.document);
83008303 assert!(
83018304 html.contains(
83028305 "<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() {
83318334 "{}",
83328335 errors[0].message
83338336 );
8334− let html = htmlang::codegen::generate(&result.document);
8337+ let html = codegen::generate(&result.document);
83358338 assert!(!html.contains("width=\"fill\""), "{}", html);
83368339 assert!(html.contains("style=\"height:2em\""), "{}", html);
83378340 let _ = fs::remove_dir_all(&dir);