---
title: "CSS parsing and stylesheet snapshots"
url: https://recursiveai.org/onehtml/docs/css-syntax/
description: "Normative behaviour of RecursiveAI.OneHtml.Css, written so another language can reproduce it. The algorithms are those of CSS Syntax Module Level 3…"
version: "1.0.0"
source: https://github.com/recursive-ai-oss/onehtml-csharp/blob/cecf589eb9acfc9e1fbc529486dfef519a32c19c/docs/css-syntax.md
---

# CSS parsing and stylesheet snapshots

Normative behaviour of `RecursiveAI.OneHtml.Css`, written so another language can reproduce it. The algorithms are
those of CSS Syntax Module Level 3 (Editor's Draft of 20 October 2026, drafts.csswg.org/css-syntax-3/); this file
records only what that text leaves open or what this library adds.

## Input and offsets

- Input is decoded text (UTF-16 code units). Every range is a half-open `[start, end)` pair of offsets into that text.
- Preprocessing (§3.3) is applied while reading, not by rewriting the text, so offsets stay offsets into the original:
  CR, FF and CRLF are each one newline; U+0000 and lone surrogates act as U+FFFD (an ident code point, so they
  continue identifiers and are copied into values as U+FFFD).
- Byte input goes through the §3.2 determination (`CssEncoding.DetermineFallbackEncoding`) with a caller-supplied
  label lookup and decoder; the library has no encoding tables of its own in this layer.

## Tokens

- Tokens are exactly those of §4. Comments are not tokens: they are recorded as ranges in a separate list.
- Every character of the input belongs to exactly one token or one comment, so concatenating tokens and comments in
  offset order gives back the input. `CssSourceWriter.Write` does this and fails loudly if it ever does not.
- Token values are decoded from the source on request (escapes, U+FFFD replacement). A backslash at the very end of the
  input stands for U+FFFD, except inside a string, where it is dropped (§4.3.5, §4.3.7).
- Unicode-range tokens exist only in the value of a `unicode-range` declaration, which is tokenized a second time
  from its source text (§5.5.6, §5.5.11). Those extra tokens sit after the ordinary ones and are not part of the
  text-covering sequence.
- `CssToken.NumberValue` is a double parsed from the number's text; magnitudes beyond the double range are infinite.

## Brackets

Function and `(`, `[`, `{` openers are paired with their closers in one pass with an explicit stack: a closer closes
the innermost open block only if it is that block's ending token, otherwise it is an ordinary token (§5.5.9, §5.5.10).
An opener left open at end of input has no closer and runs to the end. The same pass applies the nesting limit (see Limits).
All parsing then skips a nested block by jumping to its matching closer, so nothing recurses on input depth.

## Nodes

A parse yields a tree of nodes in source order. Kinds:

- **Qualified rule**: prelude (token range) and a `{}` block. Style rules are qualified rules.
- **At-rule**: name, prelude, and either a terminating `;` or a `{}` block.
- **Declaration**: name, colon, value (leading and trailing whitespace and `!important` removed), important flag,
  optional terminating `;`. A name starting `--` (after unescaping) is a custom property: its value is kept verbatim
  as source text and its name is case-sensitive.
- **Invalid**: source the specification says to drop ("return nothing"). Kept so every construct has a range.

Block contents are parsed with §5.5.5 into children in source order. Declarations and rules stay interleaved; a
consumer that needs CSS Nesting's grouping of declarations into nested-declaration rules derives it from the order
(declarations after a nested rule start a new group).

- In a list of declarations (`style` attribute text) at-rules are kept as at-rule nodes; the specification discards
  them, so a consumer that wants exactly the specification's result reads only the declarations.
- `@charset` is an ordinary at-rule node; it is not a rule (§8.3) and the CSSOM must skip it.
- A rule whose prelude begins `--name:` is dropped as invalid (§5.5.3) rather than parsed as a rule; nested, it
  swallows the rest of the would-be declaration.
- Declarations are tried first for every construct in a block. The attempt fails fast (before scanning to a
  semicolon) when a `{}` block appears in a non-custom value anywhere but as the whole value, which keeps parsing
  linear on long runs of nested rules.
- Validity beyond syntax (is this a known property, a valid selector, does a custom property value contain an
  unmatched `]`) is not decided here.

### Ranges

- A node's range runs from its first token to its closing `}` or terminating `;`; for a declaration with no `;`
  to its last value or `!important` token. A block that is never closed ends at the end of the text.
- Prelude range: from the first to the last non-whitespace prelude token (comments inside kept). Value range likewise.
- Content range of a block: the text strictly between the braces (to the end of the text if unclosed).
- Declaration parts have their own ranges: name, colon, value, `!important` (from `!` to the end of `important`),
  semicolon. Whitespace and comments between parts belong to no part.

## Limits

`CssParseOptions` defaults: input 16,777,216 code units, 8,388,608 tokens, nesting depth 256 (blocks and functions
together). Input length is checked before tokenizing and token count as tokens are added (the arrays grow only up to the
limit). Exceeding either throws `CssLimitExceededException` with the limit that was hit. Malformed CSS never throws.
Cancellation is checked every 4096 tokens and every 4096 parser steps.

**Nesting depth degrades; it does not throw.** CSS Syntax 3 closes every block still open at the end of the input
(§5.5.9 "consume a simple block" and §5.5.10 "consume a function": end of input is a parse error and the block is returned),
so input made of thousands of unclosed brackets is ordinary, if odd, CSS, and so is binary data that was served as a
stylesheet. Throwing for it would fail the whole sheet, and every other sheet's rules with it, over one bad region. So when
a block or function would nest deeper than `MaxNestingDepth`, bracket pairing stops at that opener: it and everything after
it stay unpaired, which the parser already reads as blocks that run to the end of their range, and `CssTree.NestingLimitReached`
is set. The block that crosses the limit keeps its tokens but is not parsed into rules or declarations (its contents were
never bracket-matched, so they may hold closers that match nothing). The tree stays lossless (`ToSourceText` equals the
input) and its depth stays at most `MaxNestingDepth` plus one; nothing recurses on input depth. `CssLimit.NestingDepth`
is kept in the enum for compatibility and is no longer thrown. Tests: `CssLimitsAndFuzzTests` (hand-written deep and
random bracket input, lossless round trip). On the corpus the one stylesheet that hit the limit was a gzip body stored
undecoded, 56 kB of binary data.

## Serialization

- `CssSourceWriter` / `Stylesheet.ToSourceText()`: the source text, rebuilt from tokens and comments.
- `CssSerializer`: canonical text from the structure alone, following CSSOM (identifier and string escaping, `name:
  value;` declarations, `{ }` for empty blocks, grouping rules on indented lines) and §9 (a `/**/` between token pairs
  that would otherwise merge; a unit that would read as an exponent is escaped; a `\` delim is followed by a newline).
  Comments are dropped, whitespace runs become one space, names of non-custom properties are lowercased, unclosed
  constructs are closed. It re-parses to the same structure.

## Known deviations of the css-parsing-tests suite

The suite follows the 2021 candidate draft. 22 of its cases are expected to differ from the current text and are
listed with the deciding sentence in `tests/OneHtml.Tests/Css/CssParsingTests.cs`: 1 case with U+0080..U+00B6 as
ident characters (§4.2 lists the non-ASCII ident ranges), 9 with unicode-range tokens outside a `unicode-range`
descriptor (§4.3.1), 2 with `~=`-style match tokens (not in §4), 8 for declaration value trimming and the
semicolon stop token (§5.5.6), 2 with CDO/CDC kept in a rule list (§5.5.1). The suite lists parse errors for
unterminated strings and urls inside values; the test harness derives them from `CssToken.IsTerminated`.
