From 742c5ee4b015f87907949f5d5265fee638e4d6bd Mon Sep 17 00:00:00 2001 From: Brent Rowland Date: Mon, 30 Mar 2026 07:21:15 -0700 Subject: [PATCH 1/3] Add tiered LTML rule cascade --- ltml/EXTENDING.md | 5 +- ltml/SYNTAX.md | 10 ++- ltml/ltml.go | 8 ++ ltml/rule.go | 73 ++++++++++++++++++- ltml/rule_integration_test.go | 133 ++++++++++++++++++++++++++++++++++ ltml/rule_test.go | 111 +++++++++++++++++++++++++--- ltml/scope.go | 50 +++++++++---- ltml/selectors.go | 107 ++++++++++++++++++++++++++- 8 files changed, 461 insertions(+), 36 deletions(-) diff --git a/ltml/EXTENDING.md b/ltml/EXTENDING.md index 28fb8e0..14d8fe4 100644 --- a/ltml/EXTENDING.md +++ b/ltml/EXTENDING.md @@ -441,9 +441,10 @@ Attributes are applied to an element in this order (each overrides the previous) 1. **Alias defaults** — `Attrs` map from the matching `*Alias`, if the tag name resolved through an alias. 2. **Rule attributes** — for each `Rule` whose selector matches the element's - path, attributes are applied in document order (outer scope before inner). + path, attributes are applied in cascade order: lower `tier`, then lower + selector specificity, then earlier declarations. 3. **Direct XML attributes** — the actual attributes written on the tag in the document. -This mirrors CSS specificity: inline styles win over rules, which win over +This mirrors a CSS-like cascade: inline styles win over rules, which win over defaults. diff --git a/ltml/SYNTAX.md b/ltml/SYNTAX.md index d47348d..300c0c6 100644 --- a/ltml/SYNTAX.md +++ b/ltml/SYNTAX.md @@ -528,7 +528,7 @@ Now `` is equivalent to `

`. Apply attributes to elements based on tag name, id, and class selectors. ```xml - + p { font.size: 14; } p.intro { font.weight: Bold; style.text-align: justify; } div#footer { margin-top: 20; } @@ -550,9 +550,15 @@ CSS-style `/* ... */` comments are ignored inside ``. Rules inside `` are also parsed, allowing rules to be commented out with nested comment delimiters. +`` also accepts an optional `tier` attribute. Higher tiers override lower +tiers before specificity and source order are considered. Default tiers are: + +- document-scope ``: `0` +- page-scope ``: `1` + Attribute priority (lowest to highest): 1. Default attributes from an alias (``) -2. Attributes from matching rules +2. Attributes from matching rules, ordered by tier, then specificity, then declaration order 3. Direct XML attributes on the element --- diff --git a/ltml/ltml.go b/ltml/ltml.go index efd82d7..7171aad 100644 --- a/ltml/ltml.go +++ b/ltml/ltml.go @@ -16,6 +16,7 @@ type Doc struct { stack []any scopes []HasScope rootScope Scope // per-document root scope; parent = &defaultScope + parseErr error } func (doc *Doc) Parse(b []byte) error { @@ -59,6 +60,9 @@ func (doc *Doc) ParseReader(r io.Reader) error { traceComment(t) doc.comment(t) } + if doc.parseErr != nil { + return doc.parseErr + } } return nil } @@ -105,7 +109,10 @@ func (doc *Doc) startElement(elem xml.StartElement) { } } if d, ok := e.(*StdDocument); ok { + d.Scope.defaultRuleTier = 0 doc.ltmls = append(doc.ltmls, d) + } else if page, ok := e.(*StdPage); ok { + page.Scope.defaultRuleTier = 1 } doc.push(e) @@ -176,6 +183,7 @@ func (doc *Doc) startElement(elem xml.StartElement) { } if rules, ok := e.(*Rules); ok { if err := doc.scope().AddRules(rules); err != nil { + doc.parseErr = err debugf("Adding rules: %s\n", err) } } diff --git a/ltml/rule.go b/ltml/rule.go index 2fcc9e1..ef86888 100644 --- a/ltml/rule.go +++ b/ltml/rule.go @@ -4,10 +4,28 @@ package ltml import ( + "fmt" "regexp" + "strconv" "strings" ) +type Specificity struct { + IDs int + Classes int + Tags int +} + +func (s Specificity) Compare(other Specificity) int { + if s.IDs != other.IDs { + return cmpInt(s.IDs, other.IDs) + } + if s.Classes != other.Classes { + return cmpInt(s.Classes, other.Classes) + } + return cmpInt(s.Tags, other.Tags) +} + // Rule associates a CSS-like selector with a set of attributes. When an element's // path matches the selector, the attributes are applied to that element before any // directly-specified attributes (so direct attributes take precedence). @@ -15,16 +33,22 @@ type Rule struct { Selector string SelectorRegexp *regexp.Regexp Attrs map[string]string + Tier int + Specificity Specificity + Order int } // NewRule creates a Rule for the given selector string and attribute map. The // selector is compiled into a regexp once so matching during document layout is // fast. See selectors.go for supported selector syntax. -func NewRule(selector string, attrs map[string]string) *Rule { +func NewRule(selector string, attrs map[string]string, tier, order int) *Rule { return &Rule{ Selector: selector, SelectorRegexp: regexpForSelector(selector), Attrs: attrs, + Tier: tier, + Specificity: specificityForSelector(selector), + Order: order, } } @@ -32,7 +56,11 @@ func NewRule(selector string, attrs map[string]string) *Rule { // more Rule values parsed from CSS-like text and is registered with the enclosing // Scope so that elements can be matched against them during parsing. type Rules struct { - rules []*Rule + rules []*Rule + tier int + tierExplicit bool + nextRuleOrder int + parseErr error } // AddComment satisfies the XML comment handler interface. Rule declarations may @@ -53,13 +81,50 @@ var reRule = regexp.MustCompile(`\s*([^\{]+?)\s*\{([^\}]+)\}`) // Multiple declarations may appear in a single call. Whitespace around selectors // and values is trimmed automatically. CSS-style block comments are ignored. func (r *Rules) AddText(text string) { + if r.parseErr != nil { + return + } text = stripCSSComments(text) matches := reRule.FindAllStringSubmatch(text, -1) for _, m := range matches { - r.rules = append(r.rules, NewRule(m[1], attrsMapFromString(m[2]))) + for _, selector := range splitRuleSelectors(m[1]) { + r.rules = append(r.rules, NewRule(selector, attrsMapFromString(m[2]), r.tier, r.nextRuleOrder)) + r.nextRuleOrder++ + } } } +func (r *Rules) SetAttrs(attrs map[string]string) { + tierText, ok := attrs["tier"] + if !ok || tierText == "" { + return + } + tier, err := strconv.Atoi(strings.TrimSpace(tierText)) + if err != nil { + r.parseErr = fmt.Errorf("invalid rules tier %q", tierText) + return + } + if tier < 0 { + r.parseErr = fmt.Errorf("invalid rules tier %q: tier must be >= 0", tierText) + return + } + r.tier = tier + r.tierExplicit = true +} + +func (r *Rules) ensureTier(defaultTier int) error { + if r.parseErr != nil { + return r.parseErr + } + if !r.tierExplicit { + r.tier = defaultTier + for _, rule := range r.rules { + rule.Tier = defaultTier + } + } + return nil +} + var reAttrs = regexp.MustCompile(`\s*([^:]+)\s*:\s*([^;]+)\s*;?`) // attrsMapFromString parses a semicolon-separated list of "key: value" pairs @@ -81,3 +146,5 @@ func stripCSSComments(s string) string { func init() { registerTag(DefaultSpace, "rules", func() any { return &Rules{} }) } + +var _ HasAttrs = (*Rules)(nil) diff --git a/ltml/rule_integration_test.go b/ltml/rule_integration_test.go index e2766dd..21aa64e 100644 --- a/ltml/rule_integration_test.go +++ b/ltml/rule_integration_test.go @@ -127,6 +127,61 @@ func TestRules_integration_direct_attrs_override_rule(t *testing.T) { } } +func TestRules_integration_page_default_tier_beats_document_default_tier(t *testing.T) { + doc := parseDoc(t, ` + + p { font.size: 14; } + + p { font.size: 20; } +

hello

+ + `) + + p := firstParagraph(t, doc) + if p.font == nil { + t.Fatal("font was not set on paragraph") + } + if p.font.size != 20 { + t.Errorf("expected page default tier to win with font.size=20, got %v", p.font.size) + } +} + +func TestRules_integration_document_override_tier_beats_page_default_tier(t *testing.T) { + doc := parseDoc(t, ` + + p { font.size: 22; } + + p { font.size: 18; } +

hello

+
+
`) + + p := firstParagraph(t, doc) + if p.font == nil { + t.Fatal("font was not set on paragraph") + } + if p.font.size != 22 { + t.Errorf("expected document override tier to win with font.size=22, got %v", p.font.size) + } +} + +func TestRules_integration_higher_document_tier_beats_lower_document_tier(t *testing.T) { + doc := parseDoc(t, ` + + p { font.size: 18; } + p { font.size: 24; } +

hello

+
`) + + p := firstParagraph(t, doc) + if p.font == nil { + t.Fatal("font was not set on paragraph") + } + if p.font.size != 24 { + t.Errorf("expected higher document tier to win with font.size=24, got %v", p.font.size) + } +} + // ---------------------------------------------------------------------------- // Rules inside XML comments are also parsed and applied // ---------------------------------------------------------------------------- @@ -167,6 +222,58 @@ func TestRules_integration_later_rule_wins(t *testing.T) { } } +func TestRules_integration_more_specific_rule_wins_within_tier(t *testing.T) { + doc := parseDoc(t, ` + + p { font.size: 10; } p.intro { font.size: 16; } +

hello

+
`) + + p := firstParagraph(t, doc) + if p.font == nil { + t.Fatal("font was not set on paragraph") + } + if p.font.size != 16 { + t.Errorf("expected more specific p.intro rule to win with font.size=16, got %v", p.font.size) + } +} + +func TestRules_integration_id_rule_wins_over_class_rule_within_tier(t *testing.T) { + doc := parseDoc(t, ` + + p.intro { font.size: 16; } p#hero { font.size: 19; } +

hello

+
`) + + p := firstParagraph(t, doc) + if p.font == nil { + t.Fatal("font was not set on paragraph") + } + if p.font.size != 19 { + t.Errorf("expected id selector to win with font.size=19, got %v", p.font.size) + } +} + +func TestRules_integration_descendant_rule_beats_tag_rule_on_tag_count_tiebreak(t *testing.T) { + doc := parseDoc(t, ` + + p { font.size: 10; } div p { font.size: 15; } + +

nested paragraph

+
+
`) + + page := firstPage(t, doc) + div := page.children[0].(*StdContainer) + p := div.children[0].(*StdParagraph) + if p.font == nil { + t.Fatal("font was not set on paragraph") + } + if p.font.size != 15 { + t.Errorf("expected descendant selector to win with font.size=15, got %v", p.font.size) + } +} + // ---------------------------------------------------------------------------- // Descendant selector: rule only applies when element is inside the right parent // ---------------------------------------------------------------------------- @@ -432,3 +539,29 @@ func TestRules_integration_rule_with_multiple_attrs(t *testing.T) { t.Errorf("expected font.weight=Bold, got %q", p.font.weight) } } + +func TestRules_integration_grouped_selectors_apply_independently(t *testing.T) { + doc := parseDoc(t, ` + + p, span { font.size: 13; } +

hello

+
`) + + p := firstParagraph(t, doc) + if p.font == nil { + t.Fatal("font was not set on paragraph") + } + if p.font.size != 13 { + t.Errorf("expected grouped selector rule to apply with font.size=13, got %v", p.font.size) + } +} + +func TestRules_integration_invalid_tier_returns_parse_error(t *testing.T) { + if _, err := Parse([]byte(` + + p { font.size: 14; } +

hello

+
`)); err == nil { + t.Fatal("expected invalid tier to return parse error") + } +} diff --git a/ltml/rule_test.go b/ltml/rule_test.go index acc2874..a3b7b9e 100644 --- a/ltml/rule_test.go +++ b/ltml/rule_test.go @@ -52,7 +52,7 @@ func TestAttrsMapFromString_trailing_semicolon_optional(t *testing.T) { // ---------------------------------------------------------------------------- func TestNewRule_stores_selector(t *testing.T) { - rule := NewRule("p", map[string]string{"font.size": "12"}) + rule := NewRule("p", map[string]string{"font.size": "12"}, 0, 0) if rule.Selector != "p" { t.Errorf("expected selector %q, got %q", "p", rule.Selector) } @@ -60,7 +60,7 @@ func TestNewRule_stores_selector(t *testing.T) { func TestNewRule_stores_attrs(t *testing.T) { attrs := map[string]string{"font.size": "12", "font.weight": "Bold"} - rule := NewRule("p", attrs) + rule := NewRule("p", attrs, 0, 0) if rule.Attrs["font.size"] != "12" { t.Errorf("expected font.size=12, got %q", rule.Attrs["font.size"]) } @@ -70,19 +70,33 @@ func TestNewRule_stores_attrs(t *testing.T) { } func TestNewRule_compiles_selector_regexp(t *testing.T) { - rule := NewRule("p", nil) + rule := NewRule("p", nil, 0, 0) if rule.SelectorRegexp == nil { t.Error("expected non-nil SelectorRegexp") } } func TestNewRule_regexp_matches_selector(t *testing.T) { - rule := NewRule("p", nil) + rule := NewRule("p", nil, 0, 0) if !rule.SelectorRegexp.MatchString("p") { t.Error("rule regexp should match its own selector tag") } } +func TestNewRule_stores_specificity_tier_and_order(t *testing.T) { + rule := NewRule("div.notice > p#hero.summary", nil, 4, 7) + if rule.Tier != 4 { + t.Fatalf("expected tier 4, got %d", rule.Tier) + } + if rule.Order != 7 { + t.Fatalf("expected order 7, got %d", rule.Order) + } + want := Specificity{IDs: 1, Classes: 2, Tags: 2} + if rule.Specificity != want { + t.Fatalf("expected specificity %+v, got %+v", want, rule.Specificity) + } +} + // ---------------------------------------------------------------------------- // Rules.AddText // ---------------------------------------------------------------------------- @@ -154,6 +168,50 @@ func TestRules_AddText_ignores_css_comments_inside_rule_body(t *testing.T) { } } +func TestRules_AddText_splits_grouped_selectors(t *testing.T) { + var r Rules + r.AddText("p, span { font.size: 12; }") + if len(r.rules) != 2 { + t.Fatalf("expected 2 rules from grouped selector, got %d", len(r.rules)) + } + if r.rules[0].Selector != "p" || r.rules[1].Selector != "span" { + t.Fatalf("expected selectors p and span, got %q and %q", r.rules[0].Selector, r.rules[1].Selector) + } +} + +func TestRules_SetAttrs_parses_tier(t *testing.T) { + var r Rules + r.SetAttrs(map[string]string{"tier": "4"}) + if err := r.ensureTier(0); err != nil { + t.Fatalf("ensureTier: %v", err) + } + if r.tier != 4 { + t.Fatalf("expected tier 4, got %d", r.tier) + } +} + +func TestRules_SetAttrs_rejects_negative_tier(t *testing.T) { + var r Rules + r.SetAttrs(map[string]string{"tier": "-1"}) + if err := r.ensureTier(0); err == nil { + t.Fatal("expected invalid tier error") + } +} + +func TestRules_EnsureTier_defaults_when_unspecified(t *testing.T) { + var r Rules + r.AddText("p { font.size: 12; }") + if err := r.ensureTier(3); err != nil { + t.Fatalf("ensureTier: %v", err) + } + if r.tier != 3 { + t.Fatalf("expected default tier 3, got %d", r.tier) + } + if r.rules[0].Tier != 3 { + t.Fatalf("expected rule tier 3, got %d", r.rules[0].Tier) + } +} + // ---------------------------------------------------------------------------- // Rules.AddComment // ---------------------------------------------------------------------------- @@ -223,9 +281,8 @@ func TestScope_EachRuleFor_multiple_matching_rules(t *testing.T) { } } -// Parent scope rules are yielded before child scope rules, mirroring the CSS -// cascade so that inner-scope declarations can override outer ones. -func TestScope_EachRuleFor_parent_rules_come_first(t *testing.T) { +// Parent scope rules remain visible to child scopes. +func TestScope_EachRuleFor_parent_rules_are_visible(t *testing.T) { var parentRules Rules parentRules.AddText("p { font.size: 10; }") @@ -247,11 +304,43 @@ func TestScope_EachRuleFor_parent_rules_come_first(t *testing.T) { if len(sizes) != 2 { t.Fatalf("expected 2 matches (parent + child), got %d", len(sizes)) } - if sizes[0] != "10" { - t.Errorf("expected parent rule first (font.size=10), got %q", sizes[0]) + if sizes[0] != "10" || sizes[1] != "14" { + t.Fatalf("expected matching rule values 10 then 14, got %v", sizes) + } +} + +func TestScope_EachRuleFor_sorts_by_tier_then_specificity_then_order(t *testing.T) { + var parentRules Rules + parentRules.SetAttrs(map[string]string{"tier": "4"}) + parentRules.AddText("p { font.size: 10; }") + + var childRules Rules + childRules.SetAttrs(map[string]string{"tier": "4"}) + childRules.AddText("p.notice { font.size: 14; } p.notice { font.size: 18; }") + + var parent Scope + parent.defaultRuleTier = 0 + if err := parent.AddRules(&parentRules); err != nil { + t.Fatalf("AddRules parent: %v", err) + } + + var child Scope + child.defaultRuleTier = 1 + child.SetParentScope(&parent) + if err := child.AddRules(&childRules); err != nil { + t.Fatalf("AddRules child: %v", err) + } + + var sizes []string + child.EachRuleFor("p.notice", func(rule *Rule) { + sizes = append(sizes, rule.Attrs["font.size"]) + }) + + if len(sizes) != 3 { + t.Fatalf("expected 3 matches, got %d", len(sizes)) } - if sizes[1] != "14" { - t.Errorf("expected child rule second (font.size=14), got %q", sizes[1]) + if sizes[0] != "10" || sizes[1] != "14" || sizes[2] != "18" { + t.Fatalf("expected cascade order [10 14 18], got %v", sizes) } } diff --git a/ltml/scope.go b/ltml/scope.go index 6a26906..9cef778 100644 --- a/ltml/scope.go +++ b/ltml/scope.go @@ -6,15 +6,17 @@ package ltml import ( "bytes" "fmt" + "sort" ) type Scope struct { - parent HasScope - aliases map[string]*Alias - styles map[string]Styler - layouts map[string]*LayoutStyle - pageStyles map[string]*PageStyle - rules []*Rules + parent HasScope + aliases map[string]*Alias + styles map[string]Styler + layouts map[string]*LayoutStyle + pageStyles map[string]*PageStyle + rules []*Rules + defaultRuleTier int } func (scope *Scope) AddAlias(alias *Alias) error { @@ -53,6 +55,9 @@ func (scope *Scope) AddPageStyle(style *PageStyle) error { // AddRules registers a parsed Rules collection with the scope. Multiple Rules // sets may be added; they are consulted in the order they were added. func (scope *Scope) AddRules(rules *Rules) error { + if err := rules.ensureTier(scope.defaultRuleTier); err != nil { + return err + } scope.rules = append(scope.rules, rules) return nil } @@ -76,22 +81,39 @@ func (scope *Scope) AliasFor(name string) (alias *Alias, ok bool) { return } -// EachRuleFor calls f for every Rule whose selector matches path. Rules from -// ancestor scopes are yielded first (lowest specificity), followed by rules in -// the current scope in declaration order. This mirrors the CSS cascade: inner -// scope rules override outer scope rules, and later declarations win over -// earlier ones at the same scope level. +// EachRuleFor calls f for every Rule whose selector matches path in cascade +// order: lower tiers first, then lower specificity, then earlier declarations. +// Direct XML attributes are still applied after rules and therefore win last. func (scope *Scope) EachRuleFor(path string, f func(rule *Rule)) { - if scope.parent != nil { - scope.parent.EachRuleFor(path, f) + matched := scope.matchingRules(path) + sort.SliceStable(matched, func(i, j int) bool { + left, right := matched[i], matched[j] + if left.Tier != right.Tier { + return left.Tier < right.Tier + } + if cmp := left.Specificity.Compare(right.Specificity); cmp != 0 { + return cmp < 0 + } + return left.Order < right.Order + }) + for _, rule := range matched { + f(rule) + } +} + +func (scope *Scope) matchingRules(path string) []*Rule { + var matched []*Rule + if parent, ok := scope.parent.(interface{ matchingRules(string) []*Rule }); ok { + matched = append(matched, parent.matchingRules(path)...) } for _, rz := range scope.rules { for _, rule := range rz.rules { if rule.SelectorRegexp.MatchString(path) { - f(rule) + matched = append(matched, rule) } } } + return matched } func (scope *Scope) LayoutFor(id string) (style *LayoutStyle, ok bool) { diff --git a/ltml/selectors.go b/ltml/selectors.go index 03b372c..c2db9a0 100644 --- a/ltml/selectors.go +++ b/ltml/selectors.go @@ -6,6 +6,8 @@ import ( "strings" ) +// regexpForSelector compiles an LTML selector into a regexp that can be matched +// against an element path such as "div/p" or "div/p#hero.notice". func regexpForSelector(selector string) *regexp.Regexp { return regexp.MustCompile(regexpStringForSelector(selector)) } @@ -24,15 +26,14 @@ const ( resSpecClass = `(\.\w+)*\.%s(\.\w+)*` ) +// regexpStringForSelector normalizes a selector string and converts it into the +// regexp source used for matching LTML element paths. func regexpStringForSelector(selector string) string { selector = strings.TrimSpace(selector) selector = reExtraSpaces.ReplaceAllLiteralString(selector, " ") selector = reSpacesAroundAngleBracket.ReplaceAllLiteralString(selector, ">") - selectors := strings.Split(selector, ",") - for i, s := range selectors { - selectors[i] = strings.TrimSpace(s) - } + selectors := splitRuleSelectors(selector) var result []string for _, sel := range selectors { groups := strings.Split(sel, " ") @@ -51,6 +52,22 @@ func regexpStringForSelector(selector string) string { return "" } +// splitRuleSelectors splits a comma-separated selector list into individual, +// trimmed selectors and discards empty entries. +func splitRuleSelectors(selector string) []string { + rawSelectors := strings.Split(selector, ",") + selectors := make([]string, 0, len(rawSelectors)) + for _, s := range rawSelectors { + s = strings.TrimSpace(s) + if s != "" { + selectors = append(selectors, s) + } + } + return selectors +} + +// regexpStringForSelectorGroup converts one direct-child chain, such as +// "div>p.notice", into the corresponding regexp fragment. func regexpStringForSelectorGroup(group string) string { items := strings.Split(group, ">") var reItems []string @@ -60,6 +77,8 @@ func regexpStringForSelectorGroup(group string) string { return strings.Join(reItems, resGT) } +// regexpStringForSelectorItem converts one selector item, such as "p.notice" +// or "#hero", into the regexp fragment that matches a single path segment. func regexpStringForSelectorItem(item string) string { t, k := split2(item, ".") if t == "" { @@ -76,3 +95,83 @@ func regexpStringForSelectorItem(item string) string { } return t + k } + +// specificityForSelector returns the highest specificity among the selectors in +// a comma-separated selector list. +func specificityForSelector(selector string) Specificity { + var spec Specificity + for _, sel := range splitRuleSelectors(selector) { + current := specificityForSingleSelector(sel) + if current.Compare(spec) > 0 { + spec = current + } + } + return spec +} + +// specificityForSingleSelector computes CSS-like specificity for one selector. +// IDs outrank classes, which outrank tag names; combinators contribute no +// weight. +func specificityForSingleSelector(selector string) Specificity { + selector = strings.TrimSpace(selector) + selector = reExtraSpaces.ReplaceAllLiteralString(selector, " ") + selector = reSpacesAroundAngleBracket.ReplaceAllLiteralString(selector, ">") + + var spec Specificity + for _, group := range strings.Split(selector, " ") { + for _, item := range strings.Split(group, ">") { + item = strings.TrimSpace(item) + if item == "" { + continue + } + spec = addSpecificity(spec, specificityForSelectorItem(item)) + } + } + return spec +} + +// specificityForSelectorItem computes specificity for a single selector item +// such as "p.notice", ".notice", or "p#hero". +func specificityForSelectorItem(item string) Specificity { + var spec Specificity + parts := strings.Split(item, ".") + head := parts[0] + if len(parts) > 1 { + spec.Classes += len(parts) - 1 + } + if head == "" { + return spec + } + if strings.HasPrefix(head, "#") { + spec.IDs++ + return spec + } + if strings.Contains(head, "#") { + spec.Tags++ + spec.IDs++ + return spec + } + spec.Tags++ + return spec +} + +// addSpecificity adds two specificity tuples component-wise. +func addSpecificity(left, right Specificity) Specificity { + return Specificity{ + IDs: left.IDs + right.IDs, + Classes: left.Classes + right.Classes, + Tags: left.Tags + right.Tags, + } +} + +// cmpInt compares two integers and returns -1, 0, or 1. +func cmpInt(left, right int) int { + switch { + case left < right: + return -1 + case left > right: + return 1 + default: + return 0 + } +} From 99d878015ec78733f04bbdb234777b149c72b906 Mon Sep 17 00:00:00 2001 From: Brent Rowland Date: Mon, 30 Mar 2026 07:32:23 -0700 Subject: [PATCH 2/3] Add LTML style tag alias for rules --- ltml/EXTENDING.md | 5 +++-- ltml/SYNTAX.md | 30 +++++++++++++++++------------- ltml/rule.go | 8 +++++--- ltml/rule_integration_test.go | 34 +++++++++++++++++++++++++++++++++- 4 files changed, 58 insertions(+), 19 deletions(-) diff --git a/ltml/EXTENDING.md b/ltml/EXTENDING.md index 14d8fe4..27e2163 100644 --- a/ltml/EXTENDING.md +++ b/ltml/EXTENDING.md @@ -441,8 +441,9 @@ Attributes are applied to an element in this order (each overrides the previous) 1. **Alias defaults** — `Attrs` map from the matching `*Alias`, if the tag name resolved through an alias. 2. **Rule attributes** — for each `Rule` whose selector matches the element's - path, attributes are applied in cascade order: lower `tier`, then lower - selector specificity, then earlier declarations. + path, attributes from ` ``` -Rules use CSS-style selector syntax: +Style blocks use CSS-style selector syntax: | Pattern | Matches | |-----------------|---------| @@ -546,15 +547,18 @@ Rules use CSS-style selector syntax: | `div > p` | `

` elements that are direct children of a `

` | | `p, span` | All `

` and `` elements | -CSS-style `/* ... */` comments are ignored inside ``. Rules inside -`` are also parsed, allowing rules to be commented out +CSS-style `/* ... */` comments are ignored inside ` +

hello

+ `) + + p := firstParagraph(t, doc) + if p.font == nil { + t.Fatal("font was not set on paragraph by style tag") + } + if p.font.size != 14 { + t.Errorf("expected font size 14, got %v", p.font.size) + } +} + // ---------------------------------------------------------------------------- // Font weight set by a class rule // ---------------------------------------------------------------------------- @@ -149,7 +165,7 @@ func TestRules_integration_page_default_tier_beats_document_default_tier(t *test func TestRules_integration_document_override_tier_beats_page_default_tier(t *testing.T) { doc := parseDoc(t, ` - p { font.size: 22; } + p { font.size: 18; }

hello

@@ -165,6 +181,22 @@ func TestRules_integration_document_override_tier_beats_page_default_tier(t *tes } } +func TestRules_integration_legacy_rules_tag_remains_supported(t *testing.T) { + doc := parseDoc(t, ` + + p { font.size: 16; } +

hello

+
`) + + p := firstParagraph(t, doc) + if p.font == nil { + t.Fatal("font was not set on paragraph by legacy rules tag") + } + if p.font.size != 16 { + t.Errorf("expected font size 16, got %v", p.font.size) + } +} + func TestRules_integration_higher_document_tier_beats_lower_document_tier(t *testing.T) { doc := parseDoc(t, ` From 5847b624a78275fd7a0c3c6b6a50a6422a1a5992 Mon Sep 17 00:00:00 2001 From: Brent Rowland Date: Mon, 30 Mar 2026 07:34:34 -0700 Subject: [PATCH 3/3] Update LTML samples to use style blocks --- ltml/samples/test_005_rounded_rect.ltml | 4 ++-- ltml/samples/test_008_vbox_layout.ltml | 4 ++-- ltml/samples/test_028_table_split_headers.ltml | 4 ++-- ltml/samples/test_030_encodings.ltml | 4 ++-- ltml/samples/test_033_arabic_program.ltml | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/ltml/samples/test_005_rounded_rect.ltml b/ltml/samples/test_005_rounded_rect.ltml index ac4c660..45fb671 100644 --- a/ltml/samples/test_005_rounded_rect.ltml +++ b/ltml/samples/test_005_rounded_rect.ltml @@ -1,9 +1,9 @@ - + diff --git a/ltml/samples/test_008_vbox_layout.ltml b/ltml/samples/test_008_vbox_layout.ltml index ea00934..568d4d7 100644 --- a/ltml/samples/test_008_vbox_layout.ltml +++ b/ltml/samples/test_008_vbox_layout.ltml @@ -2,11 +2,11 @@ - +

VBox Layout

Lorem ipsum dolor sit amet, consectetur adipisicing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.

diff --git a/ltml/samples/test_028_table_split_headers.ltml b/ltml/samples/test_028_table_split_headers.ltml index 5745f60..a8acdbc 100644 --- a/ltml/samples/test_028_table_split_headers.ltml +++ b/ltml/samples/test_028_table_split_headers.ltml @@ -2,9 +2,9 @@ - +
Table split with headers

diff --git a/ltml/samples/test_030_encodings.ltml b/ltml/samples/test_030_encodings.ltml index e0aa69c..2fe22c6 100644 --- a/ltml/samples/test_030_encodings.ltml +++ b/ltml/samples/test_030_encodings.ltml @@ -1,7 +1,7 @@ - +

Encodings


diff --git a/ltml/samples/test_033_arabic_program.ltml b/ltml/samples/test_033_arabic_program.ltml index 14d94aa..0d192ed 100644 --- a/ltml/samples/test_033_arabic_program.ltml +++ b/ltml/samples/test_033_arabic_program.ltml @@ -5,9 +5,9 @@ - + برنامج أمسيات الحي الثقافية مكتبة المدينة - ربيع عام ألفين وستة وعشرين