Skip to content

Commit 8a85e73

Browse files
committed
docs: add Selectors section to CSS_SUPPORT.md
Documents the ~33 selector forms Folio's stylesheet parser recognizes: 4 combinators, 5 simple selectors, 7 attribute operators, 13 pseudo-classes, 4 pseudo-elements (::before, ::after, ::marker, ::placeholder). Calls out each category's known cliffs from one place: - Interaction-state pseudo-classes (:hover/:focus/:active/:visited/ :target/:checked/:disabled) are not supported — PDFs are static. - :not() takes a single simple selector, not a selector list. - Single-colon pseudo-element forms (:before, :after) are NOT recognized — only the double-colon form routes to pseudoElement in css.go's selector parser. - Case-sensitivity flags ([attr="x" i]) are not parsed. TestSelectorsDocCoverage maintains a static expected list and asserts each name appears as a code-fenced reference in the rendered doc. Behavioral coverage for matching is in the existing selector tests. Closes #162.
1 parent 3f5301e commit 8a85e73

3 files changed

Lines changed: 172 additions & 2 deletions

File tree

docs/CSS_SUPPORT.md

Lines changed: 73 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Folio CSS support
22

3-
> Auto-generated from `html/css_props.go`, `html/css.go`, and the function parsers in `html/`. Do not edit by hand.
3+
> Auto-generated from `html/css_props.go`, `html/css.go`, `html/css_selectors.go`, and the function parsers in `html/`. Do not edit by hand.
44
> Run `go generate ./html/...` to regenerate after changing the registry.
55
66
Folio's HTML-to-PDF converter recognizes the CSS properties listed below.
@@ -287,6 +287,78 @@ also takes effect on flex containers as the gap between items.
287287
| `counter-reset` || `<identifier> [<integer>]+` ||
288288
| `string-set` || `<identifier> <content-list>` | Used by @page margin boxes for running headers/footers. |
289289

290+
## Selectors
291+
292+
CSS selectors recognized by Folio's stylesheet parser. Selectors not
293+
listed here are silently dropped at parse time — the rule's declarations
294+
never apply to any element.
295+
296+
### Combinators
297+
298+
| Combinator | Example | Meaning |
299+
|---|---|---|
300+
| descendant (space) | `article p` | `p` anywhere inside `article`. |
301+
| `>` | `ul > li` | Direct child only. |
302+
| `+` | `h2 + p` | Immediately-following sibling. |
303+
| `~` | `h2 ~ p` | Any later sibling. |
304+
305+
### Simple selectors
306+
307+
| Selector | Example | Notes |
308+
|---|---|---|
309+
| Type | `p`, `h1` | Element name match. |
310+
| Class | `.note` | Matches elements whose `class` attribute contains the name. Multiple classes can be chained: `.note.warning`. |
311+
| ID | `#title` | Matches the element with the given `id`. |
312+
| Universal | `*` | Matches every element. |
313+
| Attribute | `[lang]`, `[lang="en"]` | See attribute operators below. |
314+
315+
Selectors compose: `article.featured > p.lead` matches a `p` with class `lead` that is a direct child of an `article` with class `featured`.
316+
317+
### Attribute operators
318+
319+
| Operator | Example | Matches when... |
320+
|---|---|---|
321+
| presence | `[hidden]` | Attribute is present (any value, including empty). |
322+
| `=` | `[type="submit"]` | Attribute value equals the operand exactly. |
323+
| `^=` | `[href^="https://"]` | Value starts with the operand. |
324+
| `$=` | `[src$=".pdf"]` | Value ends with the operand. |
325+
| `*=` | `[class*="btn"]` | Value contains the operand as a substring. |
326+
| `~=` | `[rel~="author"]` | Value, treated as a whitespace-separated list, contains the operand as a whole word. |
327+
| `|=` | `[lang|="en"]` | Value equals the operand or starts with `operand-`. |
328+
329+
Case-sensitivity flags (`[lang="EN" i]`) are not parsed.
330+
331+
### Pseudo-classes
332+
333+
| Pseudo-class | Notes |
334+
|---|---|
335+
| `:root` | The document root (`<html>`). |
336+
| `:empty` | Element with no element children and no non-empty text nodes. |
337+
| `:first-child` | First child of its parent. |
338+
| `:last-child` | Last child of its parent. |
339+
| `:nth-child(<expr>)` | Position match. `<expr>` accepts `odd`, `even`, an integer, or `An+B` form (e.g. `2n+1`, `3n`, `-n+3`). |
340+
| `:nth-last-child(<expr>)` | Same as `:nth-child` but counted from the end. |
341+
| `:first-of-type` | First element of its tag type among siblings. |
342+
| `:last-of-type` | Last element of its tag type among siblings. |
343+
| `:nth-of-type(<expr>)` | Position match restricted to the element's tag type. |
344+
| `:nth-last-of-type(<expr>)` | Same, counted from the end. |
345+
| `:not(<simple>)` | Negation. Argument is a single simple selector — selector lists inside `:not()` are not parsed. |
346+
| `:is(<list>)` | Matches if any selector in the comma-separated list matches. Specificity follows the highest-specificity argument. |
347+
| `:where(<list>)` | Same matching as `:is()` but contributes zero specificity. |
348+
349+
Interaction-state pseudo-classes (`:hover`, `:focus`, `:active`, `:visited`, `:target`, `:checked`, `:disabled`) are not supported — PDFs are static.
350+
351+
### Pseudo-elements
352+
353+
| Pseudo-element | Notes |
354+
|---|---|
355+
| `::before` | Inserts generated content before the element. Driven by the `content` declaration. |
356+
| `::after` | Inserts generated content after the element. |
357+
| `::marker` | Styles the list marker on `<li>` elements (`color`, `font-size`, etc.). |
358+
| `::placeholder` | Styles the placeholder text on form fields. |
359+
360+
The double-colon form is required — single-colon legacy forms (`:before`, `:after`) are not recognized. `::first-letter`, `::first-line`, `::selection`, `::backdrop` are not supported.
361+
290362
## At-rules
291363

292364
CSS at-rules recognized by Folio's stylesheet parser. Anything not listed here

html/css_props_doc.go

Lines changed: 63 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ func RenderCSSPropertiesMarkdown() string {
2323
var b strings.Builder
2424

2525
b.WriteString("# Folio CSS support\n\n")
26-
b.WriteString("> Auto-generated from `html/css_props.go`, `html/css.go`, and the function parsers in `html/`. Do not edit by hand.\n")
26+
b.WriteString("> Auto-generated from `html/css_props.go`, `html/css.go`, `html/css_selectors.go`, and the function parsers in `html/`. Do not edit by hand.\n")
2727
b.WriteString("> Run `go generate ./html/...` to regenerate after changing the registry.\n\n")
2828
b.WriteString("Folio's HTML-to-PDF converter recognizes the CSS properties listed below.\n")
2929
b.WriteString("Properties not in this document are silently ignored at render time.\n\n")
@@ -159,6 +159,68 @@ func RenderCSSPropertiesMarkdown() string {
159159
b.WriteString("\n")
160160
}
161161

162+
// Selectors — hand-curated section. ~33 entries already centralized
163+
// in css_selectors.go; a registry-driven version was considered, but
164+
// for a doc-only payoff a hand-written section + drift-guard test
165+
// is the lower-friction option. TestSelectorsDocCoverage maintains
166+
// the static expected list.
167+
b.WriteString("## Selectors\n\n")
168+
b.WriteString("CSS selectors recognized by Folio's stylesheet parser. Selectors not\n")
169+
b.WriteString("listed here are silently dropped at parse time — the rule's declarations\n")
170+
b.WriteString("never apply to any element.\n\n")
171+
b.WriteString("### Combinators\n\n")
172+
b.WriteString("| Combinator | Example | Meaning |\n")
173+
b.WriteString("|---|---|---|\n")
174+
b.WriteString("| descendant (space) | `article p` | `p` anywhere inside `article`. |\n")
175+
b.WriteString("| `>` | `ul > li` | Direct child only. |\n")
176+
b.WriteString("| `+` | `h2 + p` | Immediately-following sibling. |\n")
177+
b.WriteString("| `~` | `h2 ~ p` | Any later sibling. |\n\n")
178+
b.WriteString("### Simple selectors\n\n")
179+
b.WriteString("| Selector | Example | Notes |\n")
180+
b.WriteString("|---|---|---|\n")
181+
b.WriteString("| Type | `p`, `h1` | Element name match. |\n")
182+
b.WriteString("| Class | `.note` | Matches elements whose `class` attribute contains the name. Multiple classes can be chained: `.note.warning`. |\n")
183+
b.WriteString("| ID | `#title` | Matches the element with the given `id`. |\n")
184+
b.WriteString("| Universal | `*` | Matches every element. |\n")
185+
b.WriteString("| Attribute | `[lang]`, `[lang=\"en\"]` | See attribute operators below. |\n\n")
186+
b.WriteString("Selectors compose: `article.featured > p.lead` matches a `p` with class `lead` that is a direct child of an `article` with class `featured`.\n\n")
187+
b.WriteString("### Attribute operators\n\n")
188+
b.WriteString("| Operator | Example | Matches when... |\n")
189+
b.WriteString("|---|---|---|\n")
190+
b.WriteString("| presence | `[hidden]` | Attribute is present (any value, including empty). |\n")
191+
b.WriteString("| `=` | `[type=\"submit\"]` | Attribute value equals the operand exactly. |\n")
192+
b.WriteString("| `^=` | `[href^=\"https://\"]` | Value starts with the operand. |\n")
193+
b.WriteString("| `$=` | `[src$=\".pdf\"]` | Value ends with the operand. |\n")
194+
b.WriteString("| `*=` | `[class*=\"btn\"]` | Value contains the operand as a substring. |\n")
195+
b.WriteString("| `~=` | `[rel~=\"author\"]` | Value, treated as a whitespace-separated list, contains the operand as a whole word. |\n")
196+
b.WriteString("| `|=` | `[lang|=\"en\"]` | Value equals the operand or starts with `operand-`. |\n\n")
197+
b.WriteString("Case-sensitivity flags (`[lang=\"EN\" i]`) are not parsed.\n\n")
198+
b.WriteString("### Pseudo-classes\n\n")
199+
b.WriteString("| Pseudo-class | Notes |\n")
200+
b.WriteString("|---|---|\n")
201+
b.WriteString("| `:root` | The document root (`<html>`). |\n")
202+
b.WriteString("| `:empty` | Element with no element children and no non-empty text nodes. |\n")
203+
b.WriteString("| `:first-child` | First child of its parent. |\n")
204+
b.WriteString("| `:last-child` | Last child of its parent. |\n")
205+
b.WriteString("| `:nth-child(<expr>)` | Position match. `<expr>` accepts `odd`, `even`, an integer, or `An+B` form (e.g. `2n+1`, `3n`, `-n+3`). |\n")
206+
b.WriteString("| `:nth-last-child(<expr>)` | Same as `:nth-child` but counted from the end. |\n")
207+
b.WriteString("| `:first-of-type` | First element of its tag type among siblings. |\n")
208+
b.WriteString("| `:last-of-type` | Last element of its tag type among siblings. |\n")
209+
b.WriteString("| `:nth-of-type(<expr>)` | Position match restricted to the element's tag type. |\n")
210+
b.WriteString("| `:nth-last-of-type(<expr>)` | Same, counted from the end. |\n")
211+
b.WriteString("| `:not(<simple>)` | Negation. Argument is a single simple selector — selector lists inside `:not()` are not parsed. |\n")
212+
b.WriteString("| `:is(<list>)` | Matches if any selector in the comma-separated list matches. Specificity follows the highest-specificity argument. |\n")
213+
b.WriteString("| `:where(<list>)` | Same matching as `:is()` but contributes zero specificity. |\n\n")
214+
b.WriteString("Interaction-state pseudo-classes (`:hover`, `:focus`, `:active`, `:visited`, `:target`, `:checked`, `:disabled`) are not supported — PDFs are static.\n\n")
215+
b.WriteString("### Pseudo-elements\n\n")
216+
b.WriteString("| Pseudo-element | Notes |\n")
217+
b.WriteString("|---|---|\n")
218+
b.WriteString("| `::before` | Inserts generated content before the element. Driven by the `content` declaration. |\n")
219+
b.WriteString("| `::after` | Inserts generated content after the element. |\n")
220+
b.WriteString("| `::marker` | Styles the list marker on `<li>` elements (`color`, `font-size`, etc.). |\n")
221+
b.WriteString("| `::placeholder` | Styles the placeholder text on form fields. |\n\n")
222+
b.WriteString("The double-colon form is required — single-colon legacy forms (`:before`, `:after`) are not recognized. `::first-letter`, `::first-line`, `::selection`, `::backdrop` are not supported.\n\n")
223+
162224
// At-rules — hand-curated section. The set of @-rules Folio
163225
// recognizes is small and changes rarely; a parallel registry would
164226
// be more bookkeeping than the doc payoff justifies. The

html/css_props_test.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1176,3 +1176,39 @@ func TestFunctionsDocCoverage(t *testing.T) {
11761176
t.Errorf("parseTransform produced %d ops for translate+rotate; want 2", len(ops))
11771177
}
11781178
}
1179+
1180+
// TestSelectorsDocCoverage is the drift guard for the Selectors
1181+
// section of CSS_SUPPORT.md. The static lists below mirror what the
1182+
// doc claims; the test asserts each name appears as a code-fenced
1183+
// reference in the rendered doc.
1184+
//
1185+
// Behavioral coverage for selector matching is in TestSelectorMatches
1186+
// (and friends) — this test focuses on the doc/parser surface area:
1187+
// if a contributor adds a new pseudo-class to pseudoMatches without
1188+
// also updating CSS_SUPPORT.md, this fails.
1189+
func TestSelectorsDocCoverage(t *testing.T) {
1190+
combinators := []string{">", "+", "~"}
1191+
attrOps := []string{"^=", "$=", "*=", "~=", "|="}
1192+
pseudoClasses := []string{
1193+
":root", ":empty",
1194+
":first-child", ":last-child",
1195+
":nth-child(", ":nth-last-child(",
1196+
":first-of-type", ":last-of-type",
1197+
":nth-of-type(", ":nth-last-of-type(",
1198+
":not(", ":is(", ":where(",
1199+
}
1200+
pseudoElements := []string{"::before", "::after", "::marker", "::placeholder"}
1201+
1202+
doc := RenderCSSPropertiesMarkdown()
1203+
check := func(group string, names []string) {
1204+
for _, name := range names {
1205+
if !strings.Contains(doc, "`"+name) {
1206+
t.Errorf("%s %q is in the documented-selectors list but not present in CSS_SUPPORT.md — add it to the Selectors section in html/css_props_doc.go", group, name)
1207+
}
1208+
}
1209+
}
1210+
check("combinator", combinators)
1211+
check("attribute operator", attrOps)
1212+
check("pseudo-class", pseudoClasses)
1213+
check("pseudo-element", pseudoElements)
1214+
}

0 commit comments

Comments
 (0)