Help us shape the future of Go! Take the 2026 Go Developer Survey

cssengine

package
v0.0.0-...-db6e04a Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 27, 2026 License: MIT Imports: 9 Imported by: 0

README

cssengine

internal/cssengine owns htmlterm's CSS parsing, selector matching, and cascade resolution.

It is internal deliberately: the boundary keeps CSS/selector machinery out of the renderer and DOM packages without committing htmlterm to a public CSS engine API. Only internal/render imports it.

What it does

  • Parses stylesheets and inline styles (css.go) — ParseStylesheet turns CSS source into an ordered list of Rules (selector group + declarations + specificity + source order); ParseDeclarations parses a bare declaration block, the form used for <style> attributes.
  • Parses and matches selectors (selector.go) — ParseSelectorGroup/SelectorGroup.Match support the selector subset documented in CSS.md: universal (*), element, class, multiple classes, ID, attribute operators, descendant, child (>), adjacent sibling (+), general sibling (~), :root, :first-child, :last-child, :nth-child(odd|even), :not(...), and the state-driven pseudo-classes (:focus, :hover, :modal, :checked, :disabled, :required) the document package's live event/focus model needs.
  • Resolves the cascade (cascade.go) — ExtractStyleRules pulls rules out of <style> elements in a parsed document; Cascade.Resolve computes an element's final property map by applying inheritance, specificity, and source order across all applicable rules; Cascade.Direct returns only the declarations that apply to a node directly (no inheritance), and Cascade.PseudoElement resolves ::before/::after declarations.

Key types

type Rule struct { /* selector group, declarations, specificity, source order */ }

func ParseStylesheet(src string) ([]Rule, error)
func ParseDeclarations(src string) map[string]string

type SelectorGroup struct { /* ... */ }
func ParseSelectorGroup(sel string) SelectorGroup
func (g SelectorGroup) Match(n *html.Node, focusAttr string) bool

type Cascade struct { /* ordered rules */ }
func (c Cascade) Resolve(n *html.Node) map[string]string
func (c Cascade) Direct(n *html.Node) map[string]string
func (c Cascade) PseudoElement(n *html.Node, which string) map[string]string

SelectorGroup.Match's Markers parameter carries the reserved marker attributes the layers above use to track state this package can't see for itself: the currently focused node (:focus), an open <select>'s arrow-key-highlighted option (:hover), and a <dialog> opened modally (:modal). Matching them needs no knowledge of the DOM/event layer above, only the attribute name to look for. They are bundled in a struct rather than passed as separate parameters because every matching function forwards all of them down the whole recursive walk, and a growing list of same-typed positional strings is the kind that gets silently transposed at a call site.

Testing

cssengine_internal_test.go covers parsing, selector matching, and cascade resolution from inside the package. Higher-level behavior (how cascade output turns into terminal styling) is covered by internal/render's and the root package's tests instead.

See also

  • CSS.md — the full supported CSS surface as seen by users of htmlterm.
  • internal/render — the only consumer of this package; turns resolved cascade output into layout and ANSI styling.

Documentation

Overview

Package cssengine owns htmlterm's CSS parsing, selector matching, and cascade resolution.

It is internal deliberately: the boundary keeps CSS/selector machinery out of the renderer and DOM packages without committing htmlterm to a public CSS engine API.

Index

Constants

This section is empty.

Variables

View Source
var BorderStyleNames = map[string]bool{
	"solid": true, "rounded": true, "heavy": true, "double": true,
	"markdown": true, "standard": true, "hidden": true, "none": true,
}

BorderStyleNames is the closed vocabulary border-style, and by extension the border and border-<edge> shorthands, accept: htmlterm's own named presets, not real CSS's solid/dashed/dotted/double/groove/ridge/inset/ outset/none/hidden keyword set (see CSS.md's border-style entry for which names overlap and which don't).

This is the canonical copy. internal/render owns what each name actually draws (internal/render/table.go's borderStylePresets), since glyphs are a rendering concern and cssengine sits below render in the package layering, unable to import it to share that mapping directly. But render already imports cssengine, so borderStylePresets keys off this map instead of carrying a second hand-written name list: rather than "kept in sync by hand" with a chance to silently drift, TestNamedTableStyleMatchesBorderStyleVocabulary (internal/render) asserts the two sets are identical on every test run, so adding a name in only one place fails a test immediately instead of quietly misclassifying a shorthand value or leaving a preset undrawable.

Functions

func ConsumeQuotedToken

func ConsumeQuotedToken(s string, i int) int

ConsumeQuotedToken exports consumeCSSQuotedToken for internal/render's convertViewportLengths (cascade.go), which needs to skip a quoted CSS string span entirely — `content: "3vw wide"` must not have its literal "3vw" mistaken for a length and rewritten — rather than scan into it looking for a vw/vh length the way it does everywhere else in a value. s[i] must be '"' or '\”; the caller checks that before calling.

func Eval

func Eval(e Expr, basis float64, basisOK bool) (float64, bool)

Eval resolves e to a single number of cells, given basis (the containing block's size on the relevant axis, or whatever else the calling property's own resolver uses as its basis) and basisOK (whether that basis is itself definite). The result is not floored, clamped to zero, or rounded; every caller in internal/render applies its own property's sign and zero policy on top of the raw number, the same way it already does for a plain percentage today.

A percentage leaf fails to resolve when basisOK is false, and that failure propagates up through every enclosing node, so the whole expression fails rather than falling back to whichever branch didn't need a basis. This matches CSS2.1 §10.5's own worked example for a plain calc() sum — `calc(100% - 100% + 1em)` computes to `calc(0% + 1em)`, not `calc(1em)`, and because it still contains a percentage relative to an indefinite containing-block size, the whole declaration resolves as auto, not 1em. calc() doesn't pre-simplify a percentage away before deciding whether a value depends on the basis, and neither does this. min()/max()/clamp() get no separate rule: CSS Values 4 §10 defines them as sugar over the same resolution algorithm calc() uses, so `max-height: min(50%, 20)` on an auto-height container is unconstrained, not 20 — the absolute-length branch does not rescue the value.

Division by zero also fails here rather than at parse time, since the divisor may be an expression (e.g. `calc(4 / (2 - 2))`) whose value isn't known until basis is supplied.

func InheritedDecls

func InheritedDecls(parent map[string]string) map[string]string

InheritedDecls returns the subset of an already-resolved declaration map that a child would inherit from it: the inheritable properties plus every custom property. It is the same filter Resolve's own fill-in pass applies, exposed for a caller that has no element to resolve.

The caller with no element is an *anonymous box*: a box CSS generates around content that has no element of its own, such as the anonymous flex item wrapping a run of loose text inside a flex container. Such a box has no declarations of its own and cannot be selected, so its entire computed style is what it inherits from its parent, which is exactly this map. Resolving a synthetic element through the cascade instead would let a `div > *` rule style a box the author has no way to name.

func IsAuthorTextProp

func IsAuthorTextProp(prop string) bool

IsAuthorTextProp reports whether prop's value is author-supplied text (or a custom identifier) that must survive untouched, not CSS length/ keyword syntax to go looking inside for anything — the same caseSensitiveValueProps set foldKeywordValue uses to skip lowercasing. internal/render's convertViewportLengths (cascade.go) uses this to skip scanning these values for a vw/vh length entirely: content's own mini- language can legitimately contain a numeral immediately followed by "vw" or "vh" with no length meaning at all, e.g. the "2vw" in `content: counter(x, 2vw)`, and scanning into it would silently rewrite that author text.

func IsFieldsetDisabled

func IsFieldsetDisabled(n *html.Node) bool

IsFieldsetDisabled reports whether n is disabled by an ancestor <fieldset disabled>, per HTML's fieldset-disabling algorithm: every disabled <fieldset> ancestor disables n, except one whose first <legend> child n is nested inside, which exempts only that fieldset's own disabling, not any further-out disabled fieldset that also isn't exempted. Exported because both this package's :disabled matching and document.go's DispatchClick/isFormFocusable/isEditable need the exact same predicate; document.go already imports cssengine directly (for ParseSelectorGroup, ParseDeclarations, and Cascade), so it calls this instead of keeping its own independent copy that could drift out of sync.

n must itself be one of the elements HTML's algorithm actually reaches: only a "listed" form-associated element (button, fieldset, input, select, or textarea; real HTML's own list also has output and object, neither implemented here) can be fieldset-disabled at all. Every other element type returns false unconditionally, even when genuinely nested inside a disabled fieldset: a plain <div> or <a> in <fieldset disabled><div>...</div></fieldset> is not disabled, and must keep receiving clicks and :disabled must not match it, matching real HTML, which never disables non-form content this way.

func IsMathFunctionToken

func IsMathFunctionToken(s string) bool

IsMathFunctionToken reports whether s (once trimmed) is shaped like a calc(), min(), max(), or clamp() call: an accepted name immediately followed by "(", with the trimmed string ending in ")". It does not validate the arguments; that's ParseMathExpr's job. Two callers rely on this being a cheap shape check rather than a real parse: isCSSFlexBasisToken (css.go), classifying a flex shorthand token without fully parsing it, and internal/render's resolveLength, which uses it as a fast-path guard so a plain length with no math function in it never pays for a parse attempt.

func ParseDeclarations

func ParseDeclarations(src string) map[string]string

ParseDeclarations parses a CSS declaration list, the value of a style="" attribute, into a property→value map, discarding any !important flags. Its only caller outside this package, internal/render/strip.go's hidden-inline check, only needs bare values. cascade.go's inline-style handling, which needs importance to merge correctly, calls parseDeclarationsWithImportance directly instead.

func ParseNumber

func ParseNumber(s string) (float64, bool)

ParseNumber parses a CSS <number> token: an optional sign, digits with an optional fractional part, where either side may be omitted but not both, and an optional e-notation exponent. Surrounding whitespace is ignored.

This is deliberately stricter than strconv.ParseFloat, which is a Go literal parser and also accepts "NaN", "Inf", "Infinity", hex-float forms like "0x1p-2", and digit-separating underscores. None of those are CSS numbers, and a NaN reaching layout is worse than a dropped declaration: it compares false against every bound, so it slips past validity checks and then poisons any total it's summed into.

func ScanNumberToken

func ScanNumberToken(s string, i int) int

ScanNumberToken returns the end index of the longest prefix of s[i:] that looks like a CSS <number> token: an optional sign, digits, an optional fractional part, and an optional exponent. It doesn't validate the result — "-" alone "matches" as a bare sign with no digits — a caller that needs the real validation hands the substring to ParseNumber, which does that and rejects anything that isn't a well-formed number.

Exported for internal/render's convertViewportLengths (cascade.go), which needs the same "where does a number token end" boundary to find a vw/vh length glued next to a paren or operator inside a math function, e.g. the "2vw" in calc(2vw + 4). See that function's own doc comment.

Types

type Cascade

type Cascade struct {
	Rules []Rule
	// UARules is the subset of Rules that came from the user-agent
	// stylesheet specifically, rather than from Options.CSS, an
	// Options.Stylesheets entry, a document <style> element, or an inline
	// style= attribute. It exists only to give the "revert" cascade-wide
	// keyword something to revert to: real CSS defines revert as rolling a
	// property back to the value the cascade origin *before* the one that
	// set it would have produced, and this engine has only two origins, UA
	// and author, with no separate user-origin stylesheet of its own. A nil
	// or empty UARules makes every revert behave as unset, the same
	// fallback real CSS uses when the UA stylesheet has no matching rule
	// either.
	UARules      []Rule
	IgnoreInline bool
	FocusAttr    string
	// HoverAttr is a synthetic marker attribute, matched the same way
	// FocusAttr drives :focus: a node carrying HoverAttr matches :hover.
	// Real terminal output has no pointer to hover with, so this is
	// repurposed by the render engine to mean "the arrow-key-highlighted
	// <option> in an open <select> popup" (see render.Engine's
	// selectHighlightAttr) rather than true mouse hover.
	HoverAttr string
	// ModalAttr is a synthetic marker attribute, matched the same way
	// FocusAttr drives :focus: a node carrying ModalAttr matches :modal.
	// The render engine sets it on a <dialog> opened as a modal (see
	// render.Engine's dialogModalAttr), which is what lets the UA
	// stylesheet's own `dialog:modal` rule give such a dialog the
	// position/z-index/centering that puts it in the top layer.
	ModalAttr string

	// Cache, if non-nil, memoizes Direct's per-node result across every
	// Resolve and Direct call sharing this map. Resolve's ancestor walk calls
	// Direct(anc) once for every descendant of anc, so without a cache
	// shared across an entire render pass, resolving N nodes at average
	// depth D against R rules costs O(N*D*R), with every descendant re-running
	// the full R-rule match against the same ancestor from scratch, instead
	// of the O(N*R) achievable by matching each node against the ruleset
	// once and reusing that result for all of its descendants. Callers that
	// only resolve a handful of nodes, and the package's own tests, can leave
	// this nil. Direct and Resolve behave identically either way, just without
	// the reuse. A zero-value Cascade{Rules: ...} is safe, since Direct and
	// Resolve both nil-check before touching it.
	Cache map[*html.Node]map[string]string
}

Cascade resolves declarations from a parsed rule set against an HTML tree.

func (Cascade) Direct

func (c Cascade) Direct(n *html.Node) map[string]string

Direct returns CSS declarations for n based only on rules that directly match n, with no ancestor inheritance. Used by Resolve.

func (Cascade) PseudoElement

func (c Cascade) PseudoElement(n *html.Node, which string, env map[string]string) map[string]string

PseudoElement returns the merged CSS declarations from all rules whose selector targets the pseudo-element named by which on element n. Recognized names are "before", "after", "marker", "scrollbar", "scrollbar-track", "scrollbar-thumb", "scrollbar-cap-start", and "scrollbar-cap-end". It handles both the CSS2 :before and :after syntax and the CSS3 ::before and ::after syntax for all of them, not just before and after.

env is the custom-property environment to resolve any var() found in these declarations against, typically customPropSubset over the caller's own already-resolved declarations for n. PseudoElement deliberately does not call c.Resolve(n) itself to build this. Every real caller already resolves n's own declarations for its own purposes right next to its PseudoElement call (see internal/render/cascade.go's pseudoElemDecls and its call sites), and Resolve is not memoized the way Direct is, so an internal Resolve(n) here would silently double that cost for every element with a pseudo-element. env's values are assumed already fully resolved, with no leftover var() text, which holds for anything produced by Resolve, so this is a single substitution pass with no fixed-point or cycle logic of its own.

func (Cascade) Resolve

func (c Cascade) Resolve(n *html.Node) map[string]string

Resolve returns the winning CSS declarations for node n, merging all matching rules by ascending specificity, resolving any inherit, unset, initial, or revert keyword among n's own direct declarations, then filling missing inheritable properties from the nearest ancestor element's own resolved value.

type Expr

type Expr struct {
	// contains filtered or unexported fields
}

Expr is a parsed calc()/min()/max()/clamp() expression tree. The zero value is a leaf equal to the constant 0; every real value comes from ParseMathExpr, never constructed directly by a caller outside this file.

func ParseMathExpr

func ParseMathExpr(s string) (Expr, bool)

ParseMathExpr parses s as a top-level calc(), min(), max(), or clamp() call. ok is false for anything else, including a plain "50%" — that's deliberately outside this function's job; callers keep using their own existing fast path for a plain length and only reach for this on the math-function path IsMathFunctionToken flags.

type Markers

type Markers struct {
	Focus string
	Hover string
	Modal string
}

Markers carries the synthetic marker attributes that stand in for state a terminal renderer has no other way to observe. Each names an attribute a caller sets on a node to make it match the corresponding pseudo-class; an empty name disables that pseudo-class entirely, which is what a one-shot render with no live Document gets.

They are bundled rather than passed as separate parameters because every selector-matching function has to forward all of them down the whole recursive walk, and a growing list of same-typed positional strings is exactly the kind that gets silently transposed at a call site.

See Cascade's own FocusAttr, HoverAttr, and ModalAttr fields for what each one is repurposed to mean here.

type Rule

type Rule struct {
	// contains filtered or unexported fields
}

Rule is one parsed CSS ruleset for one selector. Comma-separated selector groups are expanded into one Rule per selector so cascade matching can work against an already-parsed selector without reparsing on every node.

func ExtractStyleRules

func ExtractStyleRules(doc *html.Node) []Rule

ExtractStyleRules walks doc and parses CSS text from every active <style> element.

func ParseStylesheet

func ParseStylesheet(src string) ([]Rule, error)

ParseStylesheet parses a CSS stylesheet into a list of rules using the raw lexer. The high-level css.Parser discards selector tokens before BeginRulesetGrammar, so we drive the lexer directly and build a tiny state machine.

type SelectorGroup

type SelectorGroup struct {
	// contains filtered or unexported fields
}

SelectorGroup is a parsed comma-separated selector group.

func ParseSelectorGroup

func ParseSelectorGroup(sel string) SelectorGroup

ParseSelectorGroup parses a comma-separated selector group. Splitting uses splitSelectorList rather than a naive strings.Split so a comma nested inside a functional pseudo-class argument, as in "a:is(.x, .y), b", isn't mistaken for a top-level group separator.

func (SelectorGroup) Match

func (g SelectorGroup) Match(n *html.Node, m Markers) bool

Match reports whether n matches any selector in the group. See Markers for how :focus, :hover, and :modal are resolved.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL