Beautify Is Not Format: Three Implementations, Same 23 Inputs
"Beautify" and "format" get used as synonyms and are not the same operation. A beautifier re-inserts whitespace into text. A formatter parses and re-prints from a tree. The difference is invisible on well-behaved CSS and total on everything else, so I ran the same 23 inputs through three implementations and one 95 KB real file.
Two of the three are here for contrast, including the tool on this page. I would rather publish the cases where this site loses.
Setup, reproducible
cd /home/walshie/code/sites/dev-utilities/scripts/fuzz
node -e 'const {loadApp}=require("./load");
const api=loadApp("csstidy",["beautifyCss","minifyCss"]).api;
console.log(JSON.stringify(api.runHandler(api.beautifyCss,"a { color: red; }")))'
# → "a {\n color:red;\n \n}"
runHandler drives the real Format button handler from apps/csstidy/public/index.html, not a reimplementation. The comparators are [email protected] (jb.css(src)) and [email protected] (parser: "css"), installed in a scratch dir; both are the current latest per https://registry.npmjs.org/<pkg> pulled 2026-10-11.
The shipped beautifier is five regexes:
text.replace(/\s*([\{\};:])\s*/g, "$1")
.replace(/;/g, ";\n ")
.replace(/\{/g, " {\n ")
.replace(/\}/g, "\n}\n\n")
.replace(/ \}/g, "}");
No input in the battery is "unusual". A content string containing } is the single most normal thing in an icon font's stylesheet.
Where they differ, measured
Output is JSON.stringify-quoted so \n and trailing spaces are visible. ✓ = semantically identical to input, ✗ = output differs in a way that changes rendering or validity.
| Input | csstidy beautifyCss | js-beautify 2.0.3 | Prettier 3.9.10 |
|---|---|---|---|
div::before{content:"}"} | ✗ "div::before {\n content:\"\n}\n\n\"\n}" | ✓ "div::before {\n content: \"}\"\n}" | ✓ "div::before {\n content: \"}\";\n}\n" |
div::before{content:"a;b"} | ✗ "div::before {\n content:\"a;\n b\"\n}" | ✓ content: "a;b" | ✓ content: "a;b"; |
a{background:url(data:image/png;base64,AAA=)} | ✗ split at ;: "a {\n background:url(data:image/png;\n base64,AAA=)\n}" | ✓ "a {\n background: url(data:image/png;base64,AAA=)\n}" | ✓ same, semicolon added |
@media (min-width: 600px) { .a { color: red; } .b { … } } | ✗ nested rules at parent indent, stray " \n" before each } | ✓ .a and .b indented 4, @media closes cleanly | ✓ .a/.b at 4 spaces |
a{}b{color:red} | "a {\n \n}\n\nb {…}" | "a {}\n\nb {\n color: red\n}" | "a {\n}\nb {…}" |
a{color:red;} | ✗ "a {\n color:red;\n \n}" — leaves a line of two spaces | ✓ "a {\n color: red;\n}" | ✓ |
a{color:red;;} | "a {\n color:red;\n ;\n \n}" | "a {\n color: red;\n ;\n}" | ✓ "a {\n color: red;\n}\n" |
a , b , c { color : red } | "a , b , c {\n color:red\n}" — selector spacing untouched | one selector per line | one selector per line |
a{margin: 10px 20px} | margin:10px 20px (triple space survives) | margin: 10px 20px | margin: 10px 20px; |
a{color:var(--x, red)} | color:var(--x, red) | color: var(--x, red) | color: var(--x, red); |
@media screen and (min-width:768px){.a{color:red}} | ✗ same flat-indent problem, }\n\n\n} | ✓ correct nesting | ✓ correct nesting |
/*! keep me */\na{color:red} | ✓ comment survives | ✓ comment survives | ✓ comment survives |
a{content:"x/*y*/z"} | ✓ survives (unlike this tool's minifier — see gotchas) | ✓ | ✓ |
a{content:"12:30"} | ✓ | ✓ | ✓ |
a{color:#ff0000;background:#ffffff} | ✓ colours untouched | ✓ untouched | ✓ untouched |
a{margin:0px;padding:0em;border-width:0} | ✓ units untouched | ✓ untouched | ✓ untouched |
a{*zoom:1;color:red} | ✓ | ✓ | ✓ |
a{_height:20px} | ✓ | ✓ | ✓ |
a{-webkit-box-shadow:…;box-shadow:…} | ✓ | ✓ | ✓ |
a{width:calc(100% - 20px);margin:calc(1px + 2px)} | ✓ spacing kept | ✓ kept | ✓ kept |
a{background:url( my image.png )} | ✓ kept verbatim | url(my image.png ) — one inner space eaten | url(my image.png); |
a { color: red; } (well-behaved) | "a {\n color:red\n}" | "a {\n color: red\n}" | "a {\n color: red;\n}\n" |
a{color:red} (well-behaved) | "a {\n color:red\n}" | same shape | same shape |
Six inputs where this page's output is wrong; js-beautify and Prettier: zero. All six are the same root cause — the beautifier has no tokeniser, so it cannot tell a structural ; or } from one inside a string. That is the whole difference between beautify and format.
The 95 KB file, where the story is less clean than the table
[email protected] (from https://unpkg.com/[email protected]/animate.css), 95,374 bytes in. One run each, same Node process:
| Tool | Output bytes | vs input | f(f(x)) === f(x) |
|---|---|---|---|
csstidy beautifyCss | 91,450 | −4.1% | true |
| js-beautify 2.0.3 | 104,751 | +9.8% | true |
| Prettier 3.9.10 | 95,423 | +0.05% | true |
Three things this measures that the table above cannot.
The shipped beautifier is the only one that makes the file smaller, and that is not a virtue — it is because it strips a semicolon before } and collapses value whitespace, i.e. it is doing half a minify while pretending to be a pretty-printer. A "readable" output that is smaller than the input should make you suspicious of what it deleted.
All three are idempotent. I checked because the naive expectation is that a regex-based tool would drift on re-application (format(format(x)) adding blank lines forever). It does not: beautify(beautify(media)) returned a byte-identical string. So the "I pasted it back in and formatted again" loop is safe. Strings are not.
Prettier is essentially byte-neutral (+0.05%) because animate.css is already Prettier-formatted upstream. js-beautify's +9.8% is its 4-space default plus blank lines between rules. The shipped tool's −4.1% is the anomaly, and I would not call that output "formatted".
What the shipped beautifier cannot do, by construction
None of these are bugs to file; they are absent features implied by "beautify", and the absence is why a formatter exists:
- Indentation by depth. Indent comes from the string
" "inserted after{, never from a depth counter. A rule inside@mediasits at its parent's indent — visible in every@mediarow above. Nested@mediainside@mediais unrepresentable. - Semicolon normalisation. It cannot add the missing semicolon Prettier adds to
color: redbefore}, nor remove the stray;from;;. It can only relocate the ones already there. - Any decision. Sorting, grouping, blank-line policy, line-width wrapping, quote style, trailing commas — all formatter concerns, all requiring a tree.
a , b , ckeeps its spaces because nothing decided anything about it.
Choosing between them, without opinions
- Reading a snippet you will not ship: the shipped beautifier, because it is one page load and one clipboard paste, and
contentstrings are rare in tutorials. Check them if the snippet has icons. - Anything with icons, counters, or
data:URIs: a formatter. Two of the six failures above arecontent:strings and one is an inlinedata:URI — those three cover most real stylesheets. - A repository: Prettier or Biome in CI with the config committed, so the question never comes up. Formatting debates are a symptom of an unenforced config, not of bad tooling.
- js-beautify specifically: it handled every case Prettier did, and it is the smaller download if you are shipping a formatter into a browser bundle. Measured, not estimated: js-beautify's browser file is 54,387 bytes (
https://unpkg.com/[email protected]/js/lib/beautify-css.js) against Prettier's browser build at 81,957 bytes (https://unpkg.com/[email protected]/standalone.mjs) — about two-thirds, not the order-of-magnitude gap the install tarballs suggest (196 KB against 2.8 MB). Ship the file you measured.
The one-line version
A beautifier asks "where do braces go". A formatter asks "what is this file". Only the second question can be answered without corrupting a content: "}", and I measured all three rather than asserting it.