Lesson 20 / 23
Preprocessors and Postprocessors
Style tools that run at compile time; flattening nested notation, the cost of body copying, the distinction between a compile-time value and a runtime value, and postprocessor transforms.
Contents
Every name in the previous lesson was resolved in the browser, while the page was running. A separate class of tools does part of the same work earlier: before the file ever reaches the browser, during compilation.
This lesson takes up what those tools do, which work belongs to compile time and which to runtime, and the cost of the compile step. Not a specific tool, but two classes of tools are described.
Two Tool Classes
A preprocessor takes a non-CSS source language as input and produces CSS. The source language adds writing conveniences on top of CSS: nested rule notation, compile-time variables, reused declaration bodies, file splitting and importing, loops and conditions.
A postprocessor takes CSS and produces CSS. Its job is transformation: turning one notation into another, dropping unnecessary characters, merging rules, removing unused rules.
The boundary is not strict; both are program transformers that run at compile time. What makes the distinction is whether the input is CSS. Both can be used one after the other in a build chain.
Flattening Nested Notation
The most-used convenience is nested notation. In the source, rules are written inside one another, and at the end of compilation they are expanded into flat rules.
// preprocessing.mjs — flattening nested notation and counting compile-time repetition // A tree of nested rules is flattened into flat rules at compile time. const SOURCE = { selector: ".measurement-card", declarations: ["display: grid", "gap: var(--spacing-0)"], nested: [ { selector: "&__heading", declarations: ["color: var(--text-muted)"], nested: [] }, { selector: "&__value", declarations: ["font-size: 2rem"], nested: [ { selector: "&--missing", declarations: ["color: var(--warning)"], nested: [] }, ] }, { selector: "&:hover", declarations: ["transform: translateY(-8px)"], nested: [] }, { selector: ".measurement-cards &", declarations: ["margin: 0"], nested: [] }, { selector: "p", declarations: ["margin-block: 0"], nested: [] }, // no &: descendant combinator { selector: "@media (min-width: 48rem)", declarations: [], nested: [ { selector: "&", declarations: ["gap: var(--spacing-1)"], nested: [] }, ] }, ], }; function flatten(node, parentSelector = null, condition = null, output = []) { const isConditional = node.selector.startsWith("@"); const newCondition = isConditional ? node.selector : condition; let selector = parentSelector; if (!isConditional) { if (parentSelector === null) selector = node.selector; else if (node.selector.includes("&")) selector = node.selector.replaceAll("&", parentSelector); else selector = `${parentSelector} ${node.selector}`; } if (node.declarations.length > 0) output.push({ condition: newCondition, selector, declarations: node.declarations }); for (const child of node.nested) flatten(child, selector, newCondition, output); return output; } // specificity triple: a=id, b=class/attribute/pseudo-class, c=type const specificity = (s) => [ (s.match(/#[\w-]+/g) || []).length, (s.match(/\.[\w-]+/g) || []).length + (s.match(/\[[^\]]+\]/g) || []).length + (s.match(/:(?!:)[\w-]+/g) || []).length, (s.replace(/:(?!:)[\w-]+/g, " ").match(/(^|[\s>+~])([a-zA-Z][\w-]*)/g) || []).length, ]; console.log("--- flattening the nested source ---"); for (const rule of flatten(SOURCE)) { const [a, b, c] = specificity(rule.selector); const prefix = rule.condition ? `${rule.condition} { ` : ""; const suffix = rule.condition ? " }" : ""; console.log(` ${(prefix + rule.selector + " { " + rule.declarations.join("; ") + " }" + suffix).padEnd(78)} (${a}, ${b}, ${c})`); } // --- compile-time repetition: the body is copied on every call --- console.log("\n--- two ways to share the same declaration set ---"); const BODY = ["border: 1px solid var(--line)", "border-radius: 4px", "padding: var(--spacing-1)"]; const bodyBytes = BODY.join("; ").length + 2; const COMPONENTS = [".measurement-card", ".aside__box", ".masthead", ".location-section__panel", ".measurement-filter"]; let duplicated = 0; for (const comp of COMPONENTS) duplicated += comp.length + 3 + bodyBytes; const sharedSelector = COMPONENTS.join(", "); const shared = sharedSelector.length + 3 + bodyBytes; console.log(` body: ${BODY.length} declarations, ${bodyBytes} bytes`); console.log(` notation copying the body to ${COMPONENTS.length} components : ${duplicated} bytes`); console.log(` single rule with a shared selector list : ${shared} bytes`); console.log(` difference: ${duplicated - shared} bytes (%${(((duplicated - shared) / duplicated) * 100).toFixed(1)} less)`); // --- the distinction between a compile-time value and a runtime value --- console.log("\n--- same name, two moments of resolution ---"); const COMPILE = { "$spacing": "1.5rem" }; // substituted at compile time const generated = "padding: $spacing;".replace(/\$[\w-]+/g, (m) => COMPILE[m]); console.log(" compile-time variable -> generated CSS:", generated); console.log(" runtime property -> generated CSS: padding: var(--spacing-1);"); console.log(" in the first notation, the name 'spacing' does not exist in the generated file; the value is fixed.");
--- flattening the nested source ---
.measurement-card { display: grid; gap: var(--spacing-0) } (0, 1, 0)
.measurement-card__heading { color: var(--text-muted) } (0, 1, 0)
.measurement-card__value { font-size: 2rem } (0, 1, 0)
.measurement-card__value--missing { color: var(--warning) } (0, 1, 0)
.measurement-card:hover { transform: translateY(-8px) } (0, 2, 0)
.measurement-cards .measurement-card { margin: 0 } (0, 2, 0)
.measurement-card p { margin-block: 0 } (0, 1, 1)
@media (min-width: 48rem) { .measurement-card { gap: var(--spacing-1) } } (0, 1, 0)
--- two ways to share the same declaration set ---
body: 3 declarations, 78 bytes
notation copying the body to 5 components : 485 bytes
single rule with a shared selector list : 169 bytes
difference: 316 bytes (%65.2 less)
--- same name, two moments of resolution ---
compile-time variable -> generated CSS: padding: 1.5rem;
runtime property -> generated CSS: padding: var(--spacing-1);
in the first notation, the name 'spacing' does not exist in the generated file; the value is fixed.
Flattening rests on a single rule: if the nested rule carries &, it is substituted for the
parent selector; if not, a descendant combinator is inserted between it and the parent
selector.
This distinction shows up in the output’s seventh line. Since the p selector carries no &,
it becomes .measurement-card p; its triple comes to . In the fourth line, on the
other hand, the &--missing notation merges the selector into a single class, and the
triple stays . The previous lesson’s naming methodology works together with nested
notation only in this second form.
The fifth line shows that the & mark can also be written at the end: the
.measurement-cards & notation places the component inside a context and grows the triple.
One warning is needed: nested notation is also defined in CSS itself. In that case, the browser does the flattening, not a compiler, and the rule stays the same. So nested notation alone does not require a compile step.
The Cost of Repetition
Preprocessors’ second common convenience is naming a declaration body and calling it in more than one place. The call is copied at compile time; in the generated file, the same declarations sit separately under every selector.
The output’s second block counts this for five components: the copying notation comes to 485 bytes, a single rule written with a shared selector list comes to 169 bytes. The difference grows in direct proportion to how many times the body is called.
This does not mean sharing a body is forbidden; it gives the criterion for the decision. If the body is longer than a few declarations and is called in many places, a shared selector list or a shared class produces fewer bytes. For short bodies called in few places, copying improves readability.
Compile-Time Value vs. Runtime Value
The third block compares the two kinds of variables over the same result. A compile-time
variable leaves no trace in the generated file: padding: 1.5rem is what is written, the
name spacing does not exist. A runtime property, on the other hand, stays in the file and is
resolved in the browser.
The distinction decides which one to pick:
- If the value needs to change with context — with a media query, a user preference, an attribute value, a script — a runtime property is needed. A compile-time value can see none of these.
- If the value is only for organizing the source — a file path, a breakpoint number, a loop bound — a compile-time variable is enough and shrinks the generated file.
Mixed use is also common: a source scale is computed at compile time, and the computed values are written into runtime properties.
What Postprocessors Do
Postprocessor transforms can be grouped into four categories.
Syntax expansion. A property’s notation that is not yet resolved everywhere is generated together with an equivalent older notation. The source stays in one notation, the generated file carries both.
Minification. Whitespace, comments, and unnecessary semicolons are dropped; colors and numbers are converted to their shortest equivalent notation. Meaning does not change, byte count drops.
Merging. Media queries carrying the same condition are gathered into a single block; selectors sharing the same declaration set are merged into a single rule.
Unused-rule pruning. Selectors that match nothing in the document are removed. This transform is risky: if a class name is produced at runtime — assembled from an attribute value or added by a script — the pruner cannot see that name and deletes the needed rule. This is why names appearing as full literal text in the source becomes a writing rule.
The Cost of the Compile Step
The compile step brings three costs. The first is setup: source files do not run directly, every change has to go through a build command.
The second is debugging distance. The browser shows the generated file, not the file that was written. A source map closes this distance: a side file is generated that keeps each position in the generated file’s counterpart in the source, and developer tools show the rule by its source line.
The third is dependency. If the source language is not CSS, files cannot be read without that language’s tool chain. This cost shrinks as the source language’s conveniences move into the platform itself.
The Station Page’s Source
/* components/measurement-card.css — step 3: the same component in nested notation */ .measurement-card { display: grid; gap: var(--spacing-0); &__heading { color: var(--text-muted); } &__value { font-size: 2rem; font-variant-numeric: tabular-nums; } &__value--missing { color: var(--warning); } &:hover, &:focus-within { transform: translateY(var(--card-lift-distance, -8px)); } @media (prefers-reduced-motion: reduce) { &:hover, &:focus-within { transform: none; } } }
All of the component’s rules are gathered into a single block, and all of them produce single
class selectors merged with &. The media query also sits inside the component; the
reduced-motion rule is no longer in a separate file, it sits next to the component it belongs
to.
This notation’s limit is nesting depth. Nesting more than two levels deep makes the generated
selectors hard to read, and an inner rule carrying no & silently grows the triple.
Summary
- A preprocessor produces CSS from a non-CSS source language; a postprocessor takes CSS and produces CSS. Both run at compile time and their boundary is not strict.
- Nested notation flattens by a single rule: if
&is present it is substituted for the parent selector, if not a descendant combinator is inserted and the specificity triple grows. - Every call to a named declaration body is copied into the generated file; at many calls, a shared selector list produces fewer bytes.
- A compile-time value leaves no trace in the generated file and cannot see context; any value that needs to change with context must be a runtime property.
- Unused-rule pruning can delete needed rules if class names do not appear as full literal text in the source.
Next Step
The compile step did not solve one problem: names are still global. If the same class name is written in two separate component files, the rules get mixed up, and the previous lesson’s naming methodology prevents this only by convention — if no one checks, a collision happens. The next lesson takes up approaches that genuinely scope names.
To keep your progress and take notes, Log in
My notes
Log in to take notes.