Skip to content

Extract minify into standalone function, deprecate format minify option#235

Open
bartveneman wants to merge 7 commits into
mainfrom
claude/minify-function-refactor-jn6e87
Open

Extract minify into standalone function, deprecate format minify option#235
bartveneman wants to merge 7 commits into
mainfrom
claude/minify-function-refactor-jn6e87

Conversation

@bartveneman

Copy link
Copy Markdown
Member

Summary

This PR refactors the minification logic into a standalone, token-stream-based minifier that doesn't build an AST. The minify() function is now a separate implementation rather than a wrapper around format(), and the minify option on format() and related functions is deprecated in favor of using minify() directly.

Key Changes

  • New standalone minifier (src/lib/minify.ts): Implements CSS minification by walking the token stream directly, dropping comments and unnecessary whitespace without building a syntax tree. This is more efficient than the previous approach of parsing and then formatting with minification flags.

  • Refactored format() function:

    • The minify option is now deprecated (marked with @deprecated JSDoc)
    • When minify: true is passed, it delegates to the standalone minify() function
    • Removed all minification-specific logic from the pretty-printer (removed OPTIONAL_SPACE, LAST_SEMICOLON, and conditional branches)
    • Simplified the formatter to always pretty-print
  • Updated exports:

    • minify() is now exported from src/lib/minify.ts
    • Added subpath export ./minify in package.json to allow tree-shaking (users can import just the minifier without the formatter)
    • Re-exported minify from main index for backwards compatibility
  • Updated documentation (README.md):

    • Clarified that minify() is a standalone function with different behavior than format()
    • Documented the subpath import for bundle size optimization
    • Noted that the minify option on format() is deprecated
  • Test updates: Updated test descriptions to note deprecated options, and changed tests to use minify() directly instead of format(..., { minify: true })

  • Build configuration: Updated tsdown.config.ts to build the minify module separately and configured vitest.config.ts with path aliases for the new subpath export

Implementation Details

The new minifier uses a two-pass approach:

  1. First pass: Tokenize and filter out whitespace/comments while tracking space_before flags
  2. Second pass: Walk the filtered token stream, reconstructing minimal valid CSS with context-aware spacing rules (e.g., mandatory spaces in calc expressions, optional spaces in nth-child, combinator handling in selectors)

This approach is more efficient than building a full AST and allows for precise control over which whitespace is semantically meaningful.

https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1

claude added 2 commits July 22, 2026 14:46
minify() is now its own module that walks the CSS token stream
directly (no parser/AST at all) and gets its own bundle entry
(@projectwallace/format-css/minify), so importing it no longer pulls
in format()'s pretty-printer.

This is non-breaking: FormatOptions.minify and the matching { minify }
option on format_value, format_declaration, format_selector,
format_selector_list and format_atrule_prelude keep working exactly as
before, just marked @deprecated - format(css, { minify: true })
delegates straight to the new minify() under the hood. Removing the
deprecated option entirely is left for a later major version.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1
format() no longer branches on minify (it delegates to minify() up
front instead), so these were always just aliases for SPACE and
SEMICOLON. Inline them, and drop the now-always-true is_last ternary
they fed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1
@github-actions

github-actions Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

⚠️ Package Size Increase

📦 Package 📏 Base Size 📏 Source Size 📈 Size Change
@projectwallace/format-css 8.7 kB 10.3 kB +1.6 kB

tsc's "Check types" job runs on a fresh checkout, before any build
step, so package.json's ./minify export (which points at
dist/minify.d.ts) can't resolve yet - only worked locally because
dist/ happened to already exist from a prior build. Add a paths
mapping to the source file, mirroring the existing one for the bare
package specifier.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1
Comment thread src/lib/index.ts Outdated
}

let printed = print_selector(selector, OPTIONAL_SPACE)
let printed = print_selector(selector, SPACE)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should not pass space param here. Print selector should have this as i ternal knowledge.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 60768e1 - print_selector() already defaults optional_space to SPACE, so the explicit argument was redundant.


Generated by Claude Code

Comment thread src/lib/minify.ts Outdated
Comment on lines +64 to +67
* Adapted from the pretty-printer's at-rule prelude formatter, hardcoded to
* always minify (at-rule preludes are matched as raw text, not tokenized
* into a real value tree, so this text-based approach is reused as-is).
*/

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a shortcoming of the css parser? It sounds really weird.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not a parser limitation - @projectwallace/css-parser can fully parse at-rule preludes into a tree via parse_atrule_preludes: true. format() just passes false and formats preludes as raw text via regex instead (see the comment above format_atrule_prelude - the combinatorial grammar across @media/@supports/@container/etc. wasn't judged worth a full tree for this formatter). My comment wording made it sound like a parser shortcoming; reworded in 60768e1.


Generated by Claude Code

claude added 3 commits July 23, 2026 10:25
- print_rule_selectors: drop the redundant explicit SPACE argument to
  print_selector() - it already defaults to SPACE, and format() never
  varies it, so the caller doesn't need to know that detail.
- Correct a misleading comment in minify_atrule_prelude(): the css
  parser can parse at-rule preludes into a real tree
  (parse_atrule_preludes: true) - format()'s own pretty-printer just
  chooses not to use that and formats preludes as raw text via regex
  instead. It's format-css's design choice, not a parser limitation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1
Replace the recursive-descent token walker (statement/rule/declaration
disambiguation, separate selector/value printing modes, nth-child
micro-parsing, attribute-selector handling, a context stack) with one
flat loop over the token stream. It tracks only the previous token's
last character and applies one rule everywhere: a fixed set of
characters (`, : ; ! > < = ~ /` plus grouping punctuation) always drop
their surrounding space since they're unambiguous in any CSS context;
everything else keeps exactly one space if the source had one. Two
small lookbehind checks handle the remaining correctness-relevant
cases (an empty custom-property value's "space toggle", and a
redundant `;` right before a block closes).

`+`, `-` and `*` are deliberately left out of the always-strip set:
they're also used where whitespace is significant (calc's mandatory
`+`/`-`, the universal selector `*` before a descendant combinator),
and disambiguating those would require knowing what's being printed -
exactly the state this rewrite removes. This means a few spots
(`calc(1px * 2)`, `:nth-child(-n + 3)`, `a + b`) keep an optional space
a fussier minifier would strip; updated the three tests that pinned
that stripping to reflect the new, deliberately-not-maximal behavior.

85 lines total, no recursion, no allocated token array (streams the
generator directly). dist/minify.js drops from 12.5 kB to 2.5 kB
(1.26 kB gzip), and dist/index.js (which re-exports it) from 27.8 kB
to 17.7 kB.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1
,  :  ;  (  )  [  ]  {  } each have their own dedicated token type, so
checking type (a number) is both cheaper and clearer than slicing the
source into a 1-char string and hashing it through a Set<string>. Only
the remaining delimiter characters (! / < = > ~ + - *) share a single
DELIM token type and still need a char-level check, but that's now a
numeric charCodeAt comparison instead of a string.

No behavior change - same 263 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1
Comment thread src/lib/minify.ts Outdated
Comment on lines +117 to +120
if (type === TOKEN_RIGHT_BRACE && prev_type === TOKEN_SEMICOLON) {
out = out.slice(0, -1)
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is causing garbage collection a lot to clean up temp strings. Look for better implementation, or else just skip it.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 74c045c - the ; is now never written eagerly. It's held as a pending_semicolon flag and only flushed once the following token is known, dropped entirely if that's }. No more write-then-slice(0,-1) re-copy of the whole accumulated output per rule. Verified on a 1.2MB/20k-rule synthetic stylesheet (~156ms, no quadratic blowup) plus the full test suite.


Generated by Claude Code

out.slice(0, -1) re-copies the whole accumulated output every time a
rule ends with `;` before `}` - on a large stylesheet that's an O(n)
copy per rule, so effectively O(n^2) overall and a lot of short-lived
garbage. Defer writing `;` at all instead: hold it as a pending flag,
write it only once we see what follows, and just never write it if
that's a closing brace. A `;` at the very end of the input (nothing
following it to trigger the flush) needed one more line to flush after
the loop.

Verified on a 1.2MB/20k-rule synthetic stylesheet (~156ms, no
quadratic blowup) and against the full test suite.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants