diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 48d60ff..41879c8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -40,8 +40,11 @@ Linux, macOS, and Windows. Your change should pass all three. Every CSS property goes through one generic path (its name, its value as written, with `vocab::with_px`'s pixels); only htmlang's layout words and `line-clamp` have code of their own. Generated classes are - `hl-` + a short name; an element's defaults (its layout, - `ElementKind::css`) go with its class, as `:where(.hl-a)`, never a + `hl-` + 7 base-36 digits of `stable_hash` of the style + (`CLASS_DIGITS`), so a style has one name in every file, and + `htmlang build` checks no name stands for two styles; an element's + defaults (its layout, + `ElementKind::css`) go with its class, as `:where(.hl-2k9xq0m)`, never a rule on a global element selector, so `@markdown`/`@raw` HTML and a page embedding a fragment are untouched. The rules are in cascade layers (`LAYERS`): `hl-reset`, `hl-kind` (the defaults), `hl-inside` @@ -100,6 +103,9 @@ Linux, macOS, and Windows. Your change should pass all three. in `docs.rs`. - `editors/vscode/` — VS Code extension. - `tests/snapshots.rs` — integration / snapshot tests for the compiler. + These tests, and `tests/regressions.rs`, read the output with its + hashed classes numbered (`hl-a`, `hl-b`, ..., see `tests/common`), so a + snapshot changes when a style does, not when a hash does. - `tests/regressions.rs` — one test per fixed bug. - `tests/docs.rs` — compiles every example in `DESIGN.md` and `README.md`, and every page in `examples/`: any diagnostic fails it. diff --git a/DESIGN.md b/DESIGN.md index 7f5e76c..1842930 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -72,16 +72,27 @@ An attribute can take one of three forms: (`spacing 20`, `color red`). - `key=value` is an **HTML attribute** (`id=main`, `type=email`, `aria-label=Close`). -- A bare word is a **flag**: a layout attribute such as `center-x`, or a - boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`. +- A bare word is a **flag**: a layout attribute such as `center-x`, a + boolean HTML attribute such as `required`, `disabled`, `hidden` or `open`, + or a `data-` or `hx-` attribute that has no value (`data-open`, + `hx-preserve`). A comma inside `(...)`, `[...]` or `"..."` doesn't split attributes. Anywhere else, `\,` keeps a comma in the value: ``` @el [transition opacity 0.3s\, transform 0.3s, font-family "Open Sans"\, sans-serif] +@el [hx-get=/news, hx-trigger=load\, every 60s] ``` +Right after a `key=value`, a name htmlang doesn't know is most likely the +rest of that value, which a comma ended: `[hx-trigger=load, every 60s]` is +an error (`split-value`) that shows the value written with `\,`. + +An attribute given twice, by a bundle and after it, by a function and its +call, or written twice, takes the later value. An HTML attribute is +written once, with that value. + A variable fills an attribute's value (`padding $gap`, `alt=$title`), never its name or a whole attribute: attributes come from a [bundle](#definitions), written `[$card]`, and a condition chooses whole @@ -154,7 +165,7 @@ values, `@let`, `@page` and `@meta`. | `\@`, `\--` | `@`, `--` | a line of text that starts with `@` or `--` | | `\$` | `$` | a `$` before a name, which would start a variable | | `\{`, `\}` | `{`, `}` | braces that don't start or end an inline element | -| `\[`, `\]` | `[`, `]` | brackets that don't open or close an attribute list | +| `\[`, `\]` | `[`, `]` | 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`) | | `\,` | `,` | a comma in an attribute's value | | `\"` | `"` | a quote that doesn't start or end quoted text | | `\\` | `\` | a backslash before one of these characters | @@ -723,7 +734,7 @@ own attributes are for it alone. Each style under an element prefix compiles to a rule in CSS's `@scope`, on the element's class, in the layer `hl-inside` (see [CSS](#css)): -`@scope (.hl-a) { :scope td { padding: 8px } }`. `@scope`'s proximity +`@scope (.hl-2k9xq0m) { :scope td { padding: 8px } }`. `@scope`'s proximity rule is what makes the nearest element win. `@scope` is Baseline 2025, supported by Chrome and Edge 118, Safari 17.4 and Firefox 146; an older browser 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 it is quoted, as such a label: `@optgroup "Citrus fruits"`. The first word is always the argument, so on an element whose attribute is often left out (`@form`, `@video`, `@audio`), text goes on the lines under it: `@form Sign -in` would make `Sign` the form's action. +in` would make `Sign` the form's action. A first word that reads as text, +a capitalized word with nothing of a URL in it, is taken with a warning +(`text-as-url`): `@link [hx-get=/panel] Open panel` makes `Open` the link's +`href`. A relative URL like that one is written `./Open`. The attribute can be written as an attribute instead, and it means the same: `@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. HTML attributes are written `key=value`: `id=main`, `class=note`, `href=/about`, `type=email`, `alt=Logo`, `target=_blank`, -`aria-label=Close menu`, `data-id=42`. Any name works, one with a colon -in it included (`xml:lang=en`, `x-on:click=open`): before an `=` a colon is -part of the name, not a prefix. Boolean attributes are -written bare: `required`, `disabled`, `checked`, `hidden`, `open`, `popover`. -An HTML attribute written like a style (`type email`) is an error that -shows the `key=value` form. A style +`aria-label=Close menu`, `data-id=42`, `hx-get=/items`. Any name works, +one with a colon in it included (`xml:lang=en`, `hx-on:click=open()`): +before an `=` a colon is part of the name, not a prefix. Boolean attributes +are written bare: `required`, `disabled`, `checked`, `hidden`, `open`, +`popover`, and so are `data-` and `hx-` attributes that have no value +(`hx-preserve`). An HTML attribute written like a style (`type email`, +`hx-get /items`) is an error that shows the `key=value` form. A style and an HTML attribute can share a name, because the `=` tells them apart: ``` @@ -1548,8 +1563,18 @@ for a single rule, on its line: @el [animation fade-in 0.3s ease, class=note] Fades in ``` -Each element with styles gets a short generated class, `hl-a`, `hl-b`, ..., -and elements with the same styles share one. The `hl-` prefix is htmlang's: +Each element with styles gets a generated class, and elements with the +same styles share one. A class is named by its style: `hl-` and seven +characters of a hash of what it sets (`hl-2k9xq0m`), so one style has one +name in every file. A fragment put into a page (`--partial` output that a +server returns, or any HTML of one file put into another's page) means +the same there, and its rules restyle nothing of the page's: a class of +both is the same style. Two different styles of one file never share a +name (the later one takes more characters), and `htmlang build` checks +the whole site: two files whose different styles hash to one name are an +error (`class-collision`), which one more attribute on either style +fixes. A function's scoped `@style` is named by the function and a hash +of the stylesheet, `hl-fn-card-1x0a9zq`. The `hl-` prefix is htmlang's: a class of your own that starts with it gets a warning, since the generated rules would apply to it too. Every style goes into the class under its own name, with its value as written (plus pixels, see @@ -1572,7 +1597,7 @@ the specificity: | Layer | Holds | |---|---| | `hl-reset` | The reset | -| `hl-kind` | The defaults of each element's kind, as `:where(.hl-a)` | +| `hl-kind` | The defaults of each element's kind, as `:where(.hl-2k9xq0m)` | | `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` | | `htmlang` | Each element's own styles, and its parent's `children:` styles | diff --git a/README.md b/README.md index 058611a..9ce0a16 100644 --- a/README.md +++ b/README.md @@ -89,11 +89,13 @@ unless you write some, and nothing is added that you didn't ask for. written (`outline 2 solid red` is `outline: 2px solid red`, nothing added). Nothing you write is left out of the page without an error. - **The generated CSS stays on htmlang's elements.** Its classes are - `hl-a`, `hl-b`, ..., in cascade layers (`hl-reset`, `hl-kind`, - `hl-inside`, `htmlang`), so your own CSS wins. An element's defaults (no - heading margins, no list markers, links in the text colour) go with its - own class, so `@markdown` and `@raw` HTML, and a page that embeds - `--partial` output, keep theirs. + named by their styles (`hl-2k9xq0m`), in cascade layers (`hl-reset`, + `hl-kind`, `hl-inside`, `htmlang`), so your own CSS wins. An element's + defaults (no heading margins, no list markers, links in the text colour) + go with its own class, so `@markdown` and `@raw` HTML, and a page that + embeds `--partial` output, keep theirs. One style has one name in every + file, so a fragment a server swaps into a page (with htmx, say) means + the same there and restyles nothing of the page's. - **HTML stays HTML.** An element that isn't about layout or text has its HTML name (`@nav`, `@ul`, `@form`, `@details`, `@strong`, `@em`, `@br`, `@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 | Command | Purpose | |---|---| -| `build [-o out] [--minify] [--strict]` | Compile every `.hl` file under a directory (into `out/` by default), except libraries: files that hold only `@let`s | -| `serve [dir\|file] [-p PORT] [--open]` | Dev server with live reload | +| `build [-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`) | +| `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 | | `watch [dir\|file] [-o out]` | Recompile on change, without a server | | `check [--format json]` | Report diagnostics without writing output | | `lint [--format json]` | Stricter checks (accessibility, nesting) | diff --git a/crates/htmlang-core/src/ast.rs b/crates/htmlang-core/src/ast.rs index 54e103b..e6f245e 100644 --- a/crates/htmlang-core/src/ast.rs +++ b/crates/htmlang-core/src/ast.rs @@ -12,7 +12,7 @@ pub struct Document { pub css_vars: Vec<(String, String)>, pub custom_css: Vec, /// The scoped `@style` of each function called, nested under its - /// `.hl-fn-NAME` class, for `@layer hl-inside`. + /// `.hl-fn-NAME-HASH` class, for `@layer hl-inside`. pub scoped_css: Vec, pub og_tags: Vec<(String, String)>, pub nodes: Vec, diff --git a/crates/htmlang-core/src/codegen.rs b/crates/htmlang-core/src/codegen.rs index 543f420..8ab60b8 100644 --- a/crates/htmlang-core/src/codegen.rs +++ b/crates/htmlang-core/src/codegen.rs @@ -1,5 +1,5 @@ use std::collections::HashMap; -use std::hash::{Hash, Hasher}; +use std::hash::Hash; use crate::ast::*; use crate::vocab::with_px; @@ -17,33 +17,51 @@ const BREAKPOINTS: &[(&str, &str)] = &[ ("2xl", "1536px"), ]; -/// The prefix of every generated class (`hl-a`, `hl-b`, ...), which keeps -/// them out of the author's own class names. +/// The prefix of every generated class (`hl-2k9xq0m`), which keeps them +/// out of the author's own class names. pub const CLASS_PREFIX: &str = "hl-"; -/// Generate short CSS class names: a..z, then aa..a9, ba..b9, ..., z9, then -/// aaa, ... The first character is always a letter; later ones are drawn from -/// [a-z0-9]. The mapping is a bijection, so distinct indices never collide. -pub(crate) fn short_class_name(idx: usize) -> String { - const REST: &[u8] = b"abcdefghijklmnopqrstuvwxyz0123456789"; - // Find the name length: 26 names of length 1, 26*36 of length 2, ... - let mut n = idx; - let mut len = 1; - let mut count = 26usize; - while n >= count { - n -= count; - len += 1; - count = count.saturating_mul(36); - } - let mut tail = Vec::with_capacity(len - 1); - for _ in 1..len { - tail.push(REST[n % 36]); - n /= 36; - } - let mut name = String::with_capacity(len); - name.push((b'a' + n as u8) as char); - name.extend(tail.iter().rev().map(|&b| b as char)); - name +/// How many base-36 digits a class's name has after `hl-`: 7, about 36 +/// bits of its style's hash, so a site with 10,000 different styles has +/// odds of about 6 in 10,000 that two of them share a name, which +/// `htmlang build` reports (`class-collision`). Within one file, a style +/// whose name is taken takes more digits. +pub const CLASS_DIGITS: usize = 7; + +/// A stable hash of `text`: FNV-1a, then SplitMix64's finalizer, so every +/// digit of a name depends on all of the text. It is the same on every +/// platform and in every build, unlike `std`'s hasher. +pub fn stable_hash(text: &str) -> u64 { + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in text.bytes() { + hash ^= u64::from(byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + hash = (hash ^ (hash >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + hash = (hash ^ (hash >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + hash ^ (hash >> 31) +} + +/// `digits` base-36 digits of `hash`: `2k9xq0m`. With 13, all of it. +pub fn hash_digits(hash: u64, digits: usize) -> String { + const DIGITS: &[u8] = b"0123456789abcdefghijklmnopqrstuvwxyz"; + let mut n = hash; + (0..digits) + .map(|_| { + let digit = DIGITS[(n % 36) as usize] as char; + n /= 36; + digit + }) + .collect() +} + +/// A class htmlang generated: its name, and the hash of the style it +/// stands for. One style has one name in every file, so a fragment's +/// classes mean the same in any page it goes into. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Class { + pub name: String, + pub style: u64, } /// 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 { } } +/// A style as text, for its hash: the defaults of its element's kind, its +/// rules (the at-rules' ranks, the selector prefixes and the declarations +/// of each) and the rules keyed on it, each part ended by a character CSS +/// doesn't have. +fn style_text(defaults: &str, rules: &[(Condition, String)], distinct: &str) -> String { + let mut text = format!("{defaults}\u{1}"); + for (condition, body) in rules { + for rank in &condition.at { + text.push_str(&format!("{rank}\u{2}")); + } + text.push('\u{3}'); + for prefix in &condition.selector { + text.push_str(prefix); + text.push('\u{2}'); + } + text.push('\u{3}'); + text.push_str(body); + text.push('\u{1}'); + } + text.push_str(distinct); + text +} + struct StyleEntry { class_name: String, + /// The hash of the style, which names the class + style: u64, /// The defaults of the element's kind (its layout, a heading's /// `margin:0`, ...), less what its own attributes set. They are written /// as `:where(.hl-a)` in the layer `hl-kind`, so an element prefix @@ -272,10 +315,11 @@ struct StyleEntry { struct StyleCollector { entries: Vec, - /// Maps a pre-hashed style signature to an index into `entries`. - /// Using u64 as the key keeps lookups allocation-free; on the rare case of - /// a hash collision we fall back to a full equality check against the entry. + /// Maps a style's hash to its entries (more than one only when two + /// styles hash alike, which the full comparison tells apart). index: HashMap>, + /// The names given, which a style whose name is taken doesn't get + names: std::collections::HashSet, /// Rules for an element's children, keyed on its class and written in /// the block of an at-rule condition: `(at-rules, selector, body)`, e.g. /// `([md], ":where(.a)>.b", "flex:1;")` (see [`Flow`]). @@ -289,6 +333,7 @@ impl StyleCollector { StyleCollector { entries: Vec::new(), index: HashMap::new(), + names: std::collections::HashSet::new(), keyed: Vec::new(), keyed_seen: std::collections::HashSet::new(), } @@ -314,14 +359,9 @@ impl StyleCollector { return None; } rules.sort_by_cached_key(|(condition, _)| condition.order()); - use std::collections::hash_map::DefaultHasher; - let mut h = DefaultHasher::new(); - defaults.hash(&mut h); - rules.hash(&mut h); - distinct.hash(&mut h); - let sig = h.finish(); - - if let Some(indices) = self.index.get(&sig) { + let style = stable_hash(&style_text(&defaults, &rules, &distinct)); + + if let Some(indices) = self.index.get(&style) { for &idx in indices { let e = &self.entries[idx]; if e.defaults == defaults && e.rules == rules && e.distinct == distinct { @@ -329,18 +369,34 @@ impl StyleCollector { } } } + // The name is the hash's first digits, and more of them when + // another style of this file has those; after all 13, the entry's + // number tells the two apart let idx = self.entries.len(); - let name = format!("{}{}", CLASS_PREFIX, short_class_name(idx)); + let name = (CLASS_DIGITS..=13) + .map(|digits| format!("{}{}", CLASS_PREFIX, hash_digits(style, digits))) + .find(|name| !self.names.contains(name)) + .unwrap_or_else(|| format!("{}{}{}", CLASS_PREFIX, hash_digits(style, 13), idx)); + self.names.insert(name.clone()); self.entries.push(StyleEntry { class_name: name.clone(), + style, defaults, rules, distinct, }); - self.index.entry(sig).or_default().push(idx); + self.index.entry(style).or_default().push(idx); Some(name) } + /// The classes given, in the order they were + fn classes(&self) -> impl Iterator + '_ { + self.entries.iter().map(|e| Class { + name: e.class_name.clone(), + style: e.style, + }) + } + /// All generated rules, in three layers (see [`LAYERS`]): the element /// kinds' defaults in `hl-kind` (`:where(.hl-a)`), the rules for the /// elements an element prefix picks out inside an element (`@td:`), and @@ -628,15 +684,22 @@ pub struct CodegenOptions { /// Generate HTML from a parsed document using the given options. pub fn generate_with(doc: &Document, opts: &CodegenOptions) -> String { + generate_with_classes(doc, opts).0 +} + +/// [`generate_with`], and the classes the document's styles were given, +/// for a check across files that no name stands for two styles. +pub fn generate_with_classes(doc: &Document, opts: &CodegenOptions) -> (String, Vec) { + let mut classes = Vec::new(); let mut html = if opts.partial { - generate_partial_inner(doc, opts.dev) + generate_partial_inner(doc, opts.dev, &mut classes) } else { - generate_full_inner(doc, opts.dev) + generate_full_inner(doc, opts.dev, &mut classes) }; if opts.minify { html = minify_html(&html); } - html + (html, classes) } pub fn generate(doc: &Document) -> String { @@ -762,10 +825,10 @@ fn minify_html(html: &str) -> String { result } -fn generate_full_inner(doc: &Document, dev: bool) -> String { +fn generate_full_inner(doc: &Document, dev: bool, classes: &mut Vec) -> String { // Without `@page` the output is a fragment let Some(page) = &doc.page else { - return generate_partial_inner(doc, dev); + return generate_partial_inner(doc, dev, classes); }; let mut styles = StyleCollector::new(); let mut ctx = GenContext { @@ -855,15 +918,9 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String { } // `@page`'s HTML attributes go on `` - let mut html_attrs = String::new(); - for attr in &page.html_attrs { - html_attrs.push(' '); - html_attrs.push_str(&attr.key); - if attr.html { - html_attrs.push_str("=\""); - html_attrs.push_str(&html_escape(attr.value.as_deref().unwrap_or(""))); - html_attrs.push('"'); - } + let mut root_attrs = String::new(); + for attr in html_attrs(&page.html_attrs) { + push_html_attr(&mut root_attrs, attr); } let mut body_attrs = String::new(); emit_class_attr(&mut body_attrs, body_class.as_deref(), None); @@ -886,9 +943,10 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String { String::new() }; let reset_css = reset_css(dev, true, &focus_visible_css); + classes.extend(styles.classes()); format!( - "{nl}{nl}{nl}{nl}\ + "{nl}{nl}{nl}{nl}\ {meta_html}{nl}{nl}\ {nl}{body}{nl}{nl}", ) @@ -1028,7 +1086,7 @@ fn reset_css(dev: bool, page: bool, focus_visible_css: &str) -> String { } /// Generate an HTML fragment: body + optional