Zero-dependency CSS filter. PostCSS-identical output, 6–13× faster.
Remove rules and at-rules, or rewrite selectors, with PostCSS-style visitors. caligula edits the source text instead of building an AST, so its output is byte-identical to the same visitors run as a PostCSS plugin (with postcss-discard-empty), at a fraction of the cost.
bun i caligulaimport { filterCss } from "caligula";
const { css, map, removed, skipped } = filterCss(source, {
rule: ({ selector }) => used.has(selector) || false, // false = remove
atRule: (atRule) =>
atRule.name !== "keyframes" || usedKeyframes.has(atRule.params) || false,
map: { source: "app.css" },
});| Option | Description |
|---|---|
rule({ selector, parsed }) |
Return false to remove the rule, a string to replace its selector, or anything else to keep it. parsed is the selector list parsed by parseSelectorList (below), computed the first time it's read. |
atRule({ name, params, walkDecls }) |
Return false to remove the at-rule. |
readDecls |
At-rule names (case-insensitive) whose declarations atRule reads through walkDecls(cb(prop, value)), e.g. ["font-face"]. |
discardEmpty |
Drop containers left with no children, like postcss-discard-empty. Default true. |
map |
true or { source, includeContent } returns a v3 source map. |
selector, params and declaration values read exactly as PostCSS's rule.selector, atRule.params and decl.value would: comments dropped per PostCSS's raw() rule, !important stripped. Visitors run in PostCSS's order, and a rule whose selector was rewritten is visited again, as PostCSS's Rule visitor would be.
To list selectors without changing anything, return nothing from rule and check skipped.
Selector utilities for deciding what to keep, without a CSS parser of your own.
import {
parseSelector,
parseSelectorList,
requiredClasses,
splitSelectorList,
} from "caligula/selector";
splitSelectorList(".a, .b:is(.c, .d)"); // [".a", ".b:is(.c, .d)"]
const parsed = parseSelector(".x .y > .z:not(.w)");
parsed.compounds; // [{ classes: ["x"], negated: [] }, { classes: ["y"], negated: [] }, { classes: ["z"], negated: ["w"] }]
parsed.combinators; // [" ", ">"]
parsed.subject; // the last compound, { classes: ["z"], negated: ["w"] }
requiredClasses(".bx--a .x > .bx--b", { prefix: "bx--" }); // ["bx--a", "bx--b"]| Function | Returns |
|---|---|
splitSelectorList(list) |
The selectors in a list, trimmed: split on commas outside parentheses, attribute selectors, strings and comments. |
parseSelector(selector) |
{ text, compounds, combinators, subject } for one selector. Throws on a list. |
parseSelectorList(list) |
parseSelector for each selector in a list, in one pass. |
requiredClasses(selector, { prefix? }) |
Every class the selector requires, across its compounds, deduplicated; prefix keeps only those that start with it. |
A compound's classes are what an element must have to match: its own .a, plus the classes every branch of an :is()/:where() (and :matches(), :-webkit-any(), :-moz-any()) requires. Classes in :has() are on other elements and aren't listed; those in :not() are listed in negated. Names are unescaped (.sm\:p-4 is sm:p-4). A leading combinator (> .a in nested CSS) gets an empty compound before it. Malformed selectors parse as far as they make sense; nothing but parseSelector on a list throws.
- PostCSS parity. For every input it doesn't skip, the output equals
postcss([visitors, discardEmpty()]).process(css).css, and visitors see the same strings in the same order. It's tested on Bootstrap, Carbon, Tailwind v4-style output and modern syntax (nesting,@layer,@container,@scope,@property), and by a differential fuzzer that also compares visitor calls and declaration values. - 6–13× faster than PostCSS. No value parsing and no re-printing: kept text is copied from the source.
- Source maps. Optional v3 maps for the output.
- No-op is the identity. With no visitor changes, the output is the input, byte for byte.
- Fail-safe. A syntax error, nesting deeper than 1,000 blocks, or anything else it can't reproduce exactly returns the input unchanged with
skipped: true, before any visitor runs. - Small and portable. No dependencies, no Node APIs, ~7 kB gzipped.
- Deliberate differences from PostCSS:
<is never escaped.- A reversed BOM (U+FFFE) is left as-is; PostCSS rewrites it to U+FEFF.
postcss-discard-empty's extra deletions (empty declarations, rules with an empty selector in the source, paramless@foo;) aren't reproduced. A rule a visitor rewrites to""is dropped, as PostCSS does.
Apple M2, medians of warm calls. The filter job drops every rule whose selector names none of a few kept classes.
| Stylesheet | Job | caligula | PostCSS | Lightning CSS1 |
|---|---|---|---|---|
Carbon white.css, 562 kB min |
filter | 2.9 ms | 18.5 ms (6.3×) | 28.7 ms (9.8×) |
| filter + source map | 3.4 ms | 20.6 ms (6.1×) | – | |
| round trip | 2.4 ms | 16.0 ms (6.8×) | 10.0 ms (4.2×) | |
bootstrap.css, 280 kB formatted |
filter | 1.4 ms | 17.7 ms (13×) | 13.0 ms (9.6×) |
| filter + source map | 1.8 ms | 11.6 ms (6.5×) | – | |
| round trip | 1.2 ms | 11.8 ms (9.9×) | 4.5 ms (3.8×) | |
bootstrap.min.css, 232 kB |
filter | 1.0 ms | 9.5 ms (9.3×) | 13.0 ms (13×) |
| filter + source map | 1.4 ms | 8.2 ms (6.1×) | – | |
| round trip | 0.96 ms | 7.8 ms (8.2×) | 4.6 ms (4.7×) | |
| Tailwind v4-shaped utilities, 145 kB | filter | 0.77 ms | 5.8 ms (7.6×) | 13.9 ms (18×) |
| round trip | 0.82 ms | 6.3 ms (7.7×) | 4.3 ms (5.2×) |
The first call in a fresh process runs about 2–3× slower while the JIT warms up (Carbon: 9.5 ms). Peak memory is about 3–7× the input size.
- Filter only. It can remove rules and at-rules and rewrite selectors. It can't edit or remove individual declarations or insert nodes.
- No input source map chaining. It maps to the CSS it was given, not through a previous map.
- Declaration values are only readable under
readDeclsat-rules. That keeps the fail-safe check ahead of the visitors.
Footnotes
-
Lightning CSS does a different job: it re-prints the stylesheet (so it doesn't preserve the source) and can do much more (minify, lower syntax, bundle). Its filter runs a JS visitor; its round trip is native only. ↩
