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