---
title: 'Preprocessors and Postprocessors'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/preprocessors-and-postprocessors'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:29+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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 $(0, 1, 1)$. In the fourth line, on the
other hand, the `&--missing` notation **merges** the selector into a single class, and the
triple stays $(0, 1, 0)$. 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

```css
/* 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.
