From b81ebe2c2fe2c966fb3ca959ce7542e993375253 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 22 Jul 2026 12:26:10 +0000 Subject: [PATCH 1/7] Add standalone minify module, deprecate the old minify option 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 Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1 --- README.md | 19 +- package.json | 10 +- src/cli/cli.ts | 9 +- src/lib/index.ts | 45 +++-- src/lib/minify.ts | 438 +++++++++++++++++++++++++++++++++++++++++ test/api.test.ts | 12 +- test/comments.test.ts | 5 +- test/selectors.test.ts | 4 +- test/tab-size.test.ts | 2 +- test/values.test.ts | 5 +- tsdown.config.ts | 7 +- vitest.config.ts | 1 + 12 files changed, 503 insertions(+), 54 deletions(-) create mode 100644 src/lib/minify.ts diff --git a/README.md b/README.md index 3cf8408..3716db0 100644 --- a/README.md +++ b/README.md @@ -77,12 +77,9 @@ format_selector(node) format_atrule_prelude(node.prelude.text) ``` -All of these accept the same options as `format()`. However, `tab_size` has no effect on them since they do not produce indented output. +These always pretty-print, the same as `format()`. To minify, use `minify()` (see below) instead. -```js -format_declaration(node, { minify: true }) -format_selector(node, { minify: true }) -``` +Each of these also still accepts a `{ minify: true }` option, same as `format()`. **This is deprecated** and will be removed in a future major version - it's kept only for backwards compatibility. ## Formatting rules @@ -101,18 +98,22 @@ format_selector(node, { minify: true }) ### Minify CSS -This package also exposes a minifier function since minifying CSS follows many of the same rules as formatting. +This package also exposes a standalone minifier function. Unlike `format()`, it doesn't build a syntax tree - it walks the CSS token stream directly, dropping comments and unnecessary whitespace, so it's not affected by (and doesn't accept) any of `format()`'s options. ```js -import { format, minify } from '@projectwallace/format-css' +import { minify } from '@projectwallace/format-css' let minified = minify('a {}') +``` -// which is an alias for +If bundle size matters and you only need `minify()`, import it from the `/minify` subpath instead, so `format()`'s pretty-printer isn't pulled in: -let formatted_mini = format('a {}', { minify: true }) +```js +import { minify } from '@projectwallace/format-css/minify' ``` +`format()` also still accepts a `{ minify: true }` option as a deprecated alias for `minify()` (`tab_size` is ignored when combined with it). **This is deprecated** and will be removed in a future major version - prefer calling `minify()` directly. + ### Tab size For cases where you cannot control the tab size with CSS there is an option to override the default tabbed indentation with N spaces. diff --git a/package.json b/package.json index 6517103..110f96f 100644 --- a/package.json +++ b/package.json @@ -30,8 +30,14 @@ "main": "./dist/index.js", "types": "./dist/index.d.ts", "exports": { - "types": "./dist/index.d.ts", - "default": "./dist/index.js" + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./minify": { + "types": "./dist/minify.d.ts", + "default": "./dist/minify.js" + } }, "publishConfig": { "access": "public", diff --git a/src/cli/cli.ts b/src/cli/cli.ts index 522642b..82e1708 100644 --- a/src/cli/cli.ts +++ b/src/cli/cli.ts @@ -10,6 +10,7 @@ import { fileURLToPath } from 'node:url' // to prevent lib/index.js being bundled into cli.js, which would mean that indexjs // ends up in our /dist twice, which is wasteful import { format } from '@projectwallace/format-css' +import { minify } from '@projectwallace/format-css/minify' function help(): string { return ` @@ -94,17 +95,17 @@ export async function run(args: string[], io: CliIO): Promise { return } - const { files, minify, tab_size } = parse_arguments(args) - const options = { minify, tab_size } + const { files, minify: use_minify, tab_size } = parse_arguments(args) + const process_css = use_minify ? minify : (contents: string) => format(contents, { tab_size }) if (files.length > 0) { for (const file of files) { - io.write(format(io.readFile(file), options)) + io.write(process_css(io.readFile(file))) } } else if (io.isTTY) { io.write(help() + '\n') } else { - io.write(format(await io.readStdin(), options)) + io.write(process_css(await io.readStdin())) } } diff --git a/src/lib/index.ts b/src/lib/index.ts index a6b2fc2..f5f0091 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -39,6 +39,7 @@ import { type CSSNode, type Url, } from '@projectwallace/css-parser' +import { minify } from './minify.js' const SPACE = ' ' const EMPTY_STRING = '' @@ -54,7 +55,12 @@ const CLOSE_BRACE = '}' const COMMA = ',' export type FormatOptions = { - /** Whether to minify the CSS or keep it formatted */ + /** + * @deprecated Use `minify()` instead. This option (and the matching + * `{ minify }` option on `format_value`, `format_declaration`, + * `format_selector`, `format_selector_list` and `format_atrule_prelude`) + * will be removed in a future major version. + */ minify?: boolean /** Tell the formatter to use N spaces instead of tabs */ tab_size?: number @@ -403,8 +409,12 @@ export function format_atrule_prelude( */ export function format( css: string, - { minify = false, tab_size = undefined }: FormatOptions = Object.create(null), + { minify: use_minify = false, tab_size = undefined }: FormatOptions = Object.create(null), ): string { + // Deprecated escape hatch: delegate to the standalone minifier instead of + // pretty-printing. `tab_size` has no meaning when minifying, so it's ignored. + if (use_minify) return minify(css) + if (tab_size !== undefined) { let normalized = Number(tab_size) // An invalid tab_size (non-numeric, fractional, NaN, Infinity, < 1) falls @@ -412,26 +422,22 @@ export function format( tab_size = Number.isInteger(normalized) && normalized >= 1 ? normalized : undefined } - const NEWLINE = minify ? EMPTY_STRING : '\n' - const OPTIONAL_SPACE = minify ? EMPTY_STRING : SPACE - const LAST_SEMICOLON = minify ? EMPTY_STRING : SEMICOLON + const NEWLINE = '\n' + const OPTIONAL_SPACE = SPACE + const LAST_SEMICOLON = SEMICOLON // First pass: collect all comments let comments: number[] = [] let ast = parse(css, { parse_atrule_preludes: false, - on_comment: minify - ? undefined - : ({ start, end }) => { - comments.push(start, end) - }, + on_comment: ({ start, end }) => { + comments.push(start, end) + }, }) let depth = 0 function indent(size: number) { - if (minify === true) return EMPTY_STRING - if (tab_size !== undefined) { return SPACE.repeat(tab_size * size) } @@ -447,7 +453,7 @@ export function format( * @returns The formatted comment string, or empty string if no comment found */ function get_comment(after?: number, before?: number, level: number = depth): string { - if (minify || after === undefined || before === undefined) { + if (after === undefined || before === undefined) { return EMPTY_STRING } @@ -520,7 +526,7 @@ export function format( if (is_declaration(child)) { let is_last = !child.has_next || !is_declaration(child.next_sibling) - let declaration = format_declaration(child, { minify }) + let declaration = format_declaration(child) let semi = is_last ? LAST_SEMICOLON : SEMICOLON lines.push(indent(depth) + declaration + semi) } else if (is_rule(child)) { @@ -578,7 +584,7 @@ export function format( function print_atrule(node: Atrule): string { let name = '@' + print_identifier(node.name!) if (node.prelude) { - name += SPACE + format_atrule_prelude(node.prelude.text, { minify }) + name += SPACE + format_atrule_prelude(node.prelude.text) } let block_has_content = @@ -650,11 +656,4 @@ export function format( return print_stylesheet(ast).trimEnd() } -/** - * Minify a string of CSS - * @param {string} css The original CSS - * @returns {string} The minified CSS - */ -export function minify(css: string): string { - return format(css, { minify: true }) -} +export { minify } diff --git a/src/lib/minify.ts b/src/lib/minify.ts new file mode 100644 index 0000000..2850175 --- /dev/null +++ b/src/lib/minify.ts @@ -0,0 +1,438 @@ +import { + tokenize, + TOKEN_IDENT, + TOKEN_FUNCTION, + TOKEN_AT_KEYWORD, + TOKEN_DELIM, + TOKEN_WHITESPACE, + TOKEN_COLON, + TOKEN_SEMICOLON, + TOKEN_COMMA, + TOKEN_LEFT_BRACKET, + TOKEN_RIGHT_BRACKET, + TOKEN_LEFT_PAREN, + TOKEN_RIGHT_PAREN, + TOKEN_LEFT_BRACE, + TOKEN_RIGHT_BRACE, + TOKEN_COMMENT, + TOKEN_CDO, + TOKEN_CDC, + type Token, + type TokenType, +} from '@projectwallace/css-parser' + +const SPACE = ' ' +const EMPTY_STRING = '' + +// Nth-child/nth-of-type and friends carry their own An+B micro-syntax whose +// `+`/`-` spacing is optional (unlike calc's, which is mandatory). +const NTH_FUNCTIONS = new Set(['nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type']) + +const ATRULE_COLON_COMMA_RE = /\s*([:,])/g +const ATRULE_PAREN_TEXT_RE = /\)([a-zA-Z])/g +const ATRULE_KEYWORD_PAREN_RE = /\b(and|or|not|only)\(/gi +const ATRULE_ARROW_COMPARE_RE = /\s*(=>|>=|<=)\s*/g +const ATRULE_COMPARE_RE = /([^<>=\s])([<>])([^<>=\s])/g +const ATRULE_COMPARE_SPACED_RE = /([^<>=\s])\s+([<>])\s+([^<>=\s])/g +const ATRULE_COLON_COMMA_SPACE_RE = /([:,]) /g +const ATRULE_CALC_RE = /calc\(\s*([^()+\-*/]+)\s*([*/+-])\s*([^()+\-*/]+)\s*\)/g +const NTH_SIGN_RE = /\s*([+-])\s*/g +const WHITESPACE_RE = /\s+/g + +type Tok = { type: TokenType; start: number; end: number; space_before: boolean } + +function is_paren_open(t: TokenType): boolean { + return t === TOKEN_LEFT_PAREN || t === TOKEN_FUNCTION +} +function is_paren_close(t: TokenType): boolean { + return t === TOKEN_RIGHT_PAREN +} +function is_bracket_open(t: TokenType): boolean { + return t === TOKEN_LEFT_BRACKET +} +function is_bracket_close(t: TokenType): boolean { + return t === TOKEN_RIGHT_BRACKET +} +function is_brace_open(t: TokenType): boolean { + return t === TOKEN_LEFT_BRACE +} +function is_brace_close(t: TokenType): boolean { + return t === TOKEN_RIGHT_BRACE +} + +/** + * 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). + */ +function minify_atrule_prelude(prelude: string): string { + return prelude + .replaceAll(ATRULE_COLON_COMMA_RE, prelude.toLowerCase().includes('selector(') ? '$1' : '$1 ') // force whitespace after colon or comma, except inside `selector()` + .replaceAll(ATRULE_PAREN_TEXT_RE, ') $1') // force whitespace between closing parenthesis and following text (usually and|or) + .replaceAll(ATRULE_KEYWORD_PAREN_RE, '$1 (') // force whitespace between media/supports keywords and opening parenthesis + .replaceAll(ATRULE_ARROW_COMPARE_RE, '$1') // remove optional spacing around =>, >= and <= + .replaceAll(ATRULE_COMPARE_RE, '$1$2$3') // remove spacing around < or > except when it's part of <=, >=, => + .replaceAll(ATRULE_COMPARE_SPACED_RE, '$1$2$3') // remove spaces around < or > when they already have surrounding whitespace + .replaceAll(WHITESPACE_RE, SPACE) // collapse multiple whitespaces into one + .replaceAll(ATRULE_COLON_COMMA_SPACE_RE, '$1') // remove the optional space after : and , added above + .replaceAll(ATRULE_CALC_RE, (_, left, operator, right) => { + // force required whitespace around + and -, remove optional whitespace around * and / + let space = operator === '+' || operator === '-' ? SPACE : EMPTY_STRING + return `calc(${left.trim()}${space}${operator}${space}${right.trim()})` + }) + .trim() +} + +/** Collapses whitespace in an An+B expression and removes the optional space around its `+`/`-`. */ +function minify_nth(text: string): string { + return text.trim().replaceAll(WHITESPACE_RE, SPACE).replaceAll(NTH_SIGN_RE, '$1') +} + +/** + * Minify a string of CSS: print every node, drop every comment. + * + * Unlike `format()`, this never builds an AST - it walks the raw token + * stream once and reconstructs the minimal valid text for it. Whitespace + * and comment tokens are dropped up front; a `space_before` flag records + * whether a kept token had any whitespace/comment immediately before it in + * the source, which is what lets a couple of spots (the selector descendant + * combinator, an attribute selector's flag) tell "meaningful gap" apart from + * "nothing there at all" without needing a real parse tree. + */ +export function minify(css: string): string { + let tokens: Tok[] = [] + let space_before = false + for (let t of tokenize(css)) { + if ( + t.type === TOKEN_WHITESPACE || + t.type === TOKEN_COMMENT || + t.type === TOKEN_CDO || + t.type === TOKEN_CDC + ) { + space_before = true + continue + } + tokens.push({ type: t.type, start: t.start, end: t.end, space_before }) + space_before = false + } + + let n = tokens.length + let pos = 0 + + function text(t: Tok): string { + return css.slice(t.start, t.end) + } + + /** Indexes `tokens` without the `| undefined` noise - every call site here is already bounds-checked by its loop condition. */ + function at(i: number): Tok { + return tokens[i] as Tok + } + + /** Reconstructs text for a token span, collapsing any original gap to a single space, with comments already gone. */ + function raw_join(start: number, end: number): string { + let out = EMPTY_STRING + for (let i = start; i < end; i++) { + if (i > start && at(i).space_before) out += SPACE + out += text(at(i)) + } + return out + } + + /** Index of the token matching the opener just before `start`, using the given open/close predicates. `start` is already "one level deep". */ + function find_close( + start: number, + is_open: (t: TokenType) => boolean, + is_close: (t: TokenType) => boolean, + ): number { + let depth = 1 + for (let i = start; i < n; i++) { + let ty = at(i).type + if (is_open(ty)) depth++ + else if (is_close(ty)) { + depth-- + if (depth === 0) return i + } + } + return n + } + + /** Index of the first depth-0 `;` or `{` at or after `pos`, within `limit_end` - the boundary between a declaration and a nested rule/at-rule. */ + function find_statement_terminator(limit_end: number): number { + let depth = 0 + for (let i = pos; i < limit_end; i++) { + let ty = at(i).type + if (is_paren_open(ty) || ty === TOKEN_LEFT_BRACKET) depth++ + else if (is_paren_close(ty) || ty === TOKEN_RIGHT_BRACKET) depth-- + else if (depth === 0 && (ty === TOKEN_LEFT_BRACE || ty === TOKEN_SEMICOLON)) return i + } + return limit_end + } + + function is_operator_delim(t: Tok): boolean { + if (t.type !== TOKEN_DELIM) return false + let ch = css.charCodeAt(t.start) + return ch === 43 || ch === 45 || ch === 42 || ch === 47 // + - * / + } + + /** Prints a declaration value or a function's argument list: comma/operator spacing, mandatory single space between plain siblings, everything else passed through verbatim. */ + function print_value(start: number, end: number): string { + let out = EMPTY_STRING + let boundary = false + for (let i = start; i < end; i++) { + let t = at(i) + if (t.type === TOKEN_COMMA) { + out += ',' + boundary = false + continue + } + if (t.type === TOKEN_RIGHT_PAREN) { + out += ')' + boundary = true + continue + } + if (is_operator_delim(t)) { + let ch = css[t.start] + let space = ch === '+' || ch === '-' ? SPACE : EMPTY_STRING + out += space + ch + space + boundary = false + continue + } + if (boundary) out += SPACE + out += text(t) + boundary = !(t.type === TOKEN_LEFT_PAREN || t.type === TOKEN_FUNCTION) + } + return out + } + + /** Detects a trailing `!important` (case preserved) and returns where the real value ends. */ + function extract_important(start: number, end: number): { value_end: number; important: string } { + if (end - start >= 2) { + let last = at(end - 1) + let prev = at(end - 2) + if ( + last.type === TOKEN_IDENT && + text(last).toLowerCase() === 'important' && + prev.type === TOKEN_DELIM && + css.charCodeAt(prev.start) === 33 // ! + ) { + return { value_end: end - 2, important: '!' + text(last) } + } + } + return { value_end: end, important: EMPTY_STRING } + } + + function print_declaration(end: number): string { + let start = pos + pos = end + if (tokens[start]?.type !== TOKEN_IDENT || tokens[start + 1]?.type !== TOKEN_COLON) { + // Not shaped like a declaration (malformed input) - pass it through. + return raw_join(start, end) + } + let property = text(at(start)) + let { value_end, important } = extract_important(start + 2, end) + let value = print_value(start + 2, value_end) + // A custom property's value may be empty/whitespace-only on purpose (the + // "space toggle" trick) - keep one space so it doesn't vanish entirely. + if (value === EMPTY_STRING) value = SPACE + return property + ':' + value + important + } + + /** Prints an attribute selector, e.g. `[href^="https://" i]`. The flag (if any) is the only part with a mandatory space. */ + function print_attribute_selector(open_i: number, close_i: number): string { + let out = '[' + for (let i = open_i + 1; i < close_i; i++) { + let t = at(i) + if (i === close_i - 1 && t.type === TOKEN_IDENT && t.space_before) { + out += SPACE + } + out += text(t) + } + return out + ']' + } + + /** Prints the An+B [of ] argument of :nth-child() and friends. */ + function print_nth_arguments(start: number, end: number): string { + let of_i = -1 + for (let i = start; i < end; i++) { + if (at(i).type === TOKEN_IDENT && text(at(i)).toLowerCase() === 'of') { + of_i = i + break + } + } + let nth_end = of_i === -1 ? end : of_i + let nth_text = minify_nth(raw_join(start, nth_end)) + if (of_i === -1) return nth_text + return nth_text + ' of ' + print_selector_list(of_i + 1, end) + } + + /** Prints a functional pseudo-class/element's `(...)` argument, e.g. `:is(a, b)` or `:nth-child(2n+1 of .foo)`. */ + function print_pseudo_function(fn_tok: Tok, fn_i: number, close_i: number): string { + let name = text(fn_tok) + let lower = name.slice(0, -1).toLowerCase() + let args_start = fn_i + 1 + let args = NTH_FUNCTIONS.has(lower) + ? print_nth_arguments(args_start, close_i) + : print_selector_list(args_start, close_i) + return name + args + ')' + } + + /** Prints one complex selector, e.g. `div > .foo:hover`: compound-selector parts glue tight; a combinator (explicit or a lone descendant space) separates compounds. */ + function print_complex_selector(start: number, end: number): string { + let out = EMPTY_STRING + let i = start + let is_first = true + let after_combinator = false + while (i < end) { + let t = at(i) + let ch = t.type === TOKEN_DELIM ? css[t.start] : EMPTY_STRING + + if (ch === '>' || ch === '~' || ch === '+') { + out += ch + i++ + is_first = false + after_combinator = true + continue + } + + if (!is_first && !after_combinator && t.space_before) { + out += SPACE + } + is_first = false + after_combinator = false + + if (t.type === TOKEN_COLON) { + let double = tokens[i + 1]?.type === TOKEN_COLON + let name_i = double ? i + 2 : i + 1 + let name_tok = tokens[name_i] + out += double ? '::' : ':' + if (name_tok === undefined) { + i = name_i + continue + } + if (name_tok.type === TOKEN_FUNCTION) { + let close = find_close(name_i + 1, is_paren_open, is_paren_close) + out += print_pseudo_function(name_tok, name_i, close) + i = close + 1 + } else { + out += text(name_tok) + i = name_i + 1 + } + continue + } + + if (t.type === TOKEN_LEFT_BRACKET) { + let close = find_close(i + 1, is_bracket_open, is_bracket_close) + out += print_attribute_selector(i, close) + i = close + 1 + continue + } + + out += text(t) + i++ + } + return out + } + + /** Index of the first depth-0 comma in [start, end) - the split points of a selector list. */ + function find_top_level_comma(start: number, end: number): number { + let depth = 0 + for (let i = start; i < end; i++) { + let ty = at(i).type + if (is_paren_open(ty) || ty === TOKEN_LEFT_BRACKET) depth++ + else if (is_paren_close(ty) || ty === TOKEN_RIGHT_BRACKET) depth-- + else if (depth === 0 && ty === TOKEN_COMMA) return i + } + return end + } + + /** Prints a comma-separated list of complex selectors, e.g. `a, b` or the argument list of `:is(a, b)`. */ + function print_selector_list(start: number, end: number): string { + let out = EMPTY_STRING + let i = start + while (i < end) { + let piece_end = find_top_level_comma(i, end) + out += print_complex_selector(i, piece_end) + i = piece_end + if (i < end && at(i).type === TOKEN_COMMA) { + out += ',' + i++ + } + } + return out + } + + function print_atrule(limit_end: number): string { + let out = text(at(pos)) + pos++ + + let depth = 0 + let term = pos + for (; term < limit_end; term++) { + let ty = at(term).type + if (is_paren_open(ty) || ty === TOKEN_LEFT_BRACKET) depth++ + else if (is_paren_close(ty) || ty === TOKEN_RIGHT_BRACKET) depth-- + else if (depth === 0 && (ty === TOKEN_LEFT_BRACE || ty === TOKEN_SEMICOLON)) break + } + + let prelude = raw_join(pos, term) + if (prelude !== EMPTY_STRING) { + out += SPACE + minify_atrule_prelude(prelude) + } + + if (term < limit_end && at(term).type === TOKEN_LEFT_BRACE) { + let close = find_close(term + 1, is_brace_open, is_brace_close) + pos = term + 1 + out += '{' + print_block(close) + '}' + pos = close + 1 + } else { + pos = term < limit_end && at(term).type === TOKEN_SEMICOLON ? term + 1 : term + out += ';' + } + + return out + } + + function print_rule(brace_i: number): string { + let selector = print_selector_list(pos, brace_i) + let close = find_close(brace_i + 1, is_brace_open, is_brace_close) + pos = brace_i + 1 + let body = print_block(close) + pos = close + 1 + return selector + '{' + body + '}' + } + + /** Prints the children of a `{...}` block, or the whole stylesheet (with `close_i` as the token count). */ + function print_block(close_i: number): string { + let out = EMPTY_STRING + let pending_semi = false + + while (pos < close_i) { + if (at(pos).type === TOKEN_SEMICOLON) { + pos++ + continue + } + if (pending_semi) { + out += ';' + pending_semi = false + } + + if (at(pos).type === TOKEN_AT_KEYWORD) { + out += print_atrule(close_i) + continue + } + + let term = find_statement_terminator(close_i) + if (term < close_i && at(term).type === TOKEN_LEFT_BRACE) { + out += print_rule(term) + } else { + out += print_declaration(term) + pos = term < close_i && at(term).type === TOKEN_SEMICOLON ? term + 1 : term + pending_semi = true + } + } + + return out + } + + return print_block(n) +} diff --git a/test/api.test.ts b/test/api.test.ts index 6f4e2fd..e38fcce 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -100,7 +100,7 @@ describe('format_atrule_prelude', () => { expect(format_atrule_prelude('(width>=300px)')).toBe('(width >= 300px)') }) - test('removes space around >= when minified', () => { + test('removes space around >= when minified (deprecated option)', () => { expect(format_atrule_prelude('(width >= 300px)', { minify: true })).toBe('(width>=300px)') }) @@ -112,7 +112,7 @@ describe('format_atrule_prelude', () => { expect(format_atrule_prelude('(width > 0)and(height > 0)')).toBe('(width > 0) and (height > 0)') }) - test('adds space between media keyword "and" and opening parenthesis', () => { + test('adds space between media keyword "and" and opening parenthesis (deprecated option)', () => { expect(format_atrule_prelude('(width > 0)and(height > 0)', { minify: true })).toBe( '(width>0) and (height>0)', ) @@ -159,7 +159,7 @@ describe('format_selector', () => { expect(format_selector(node)).toBe('div > span') }) - test('combinator removes spaces when minified', () => { + test('combinator removes spaces when minified (deprecated option)', () => { let node = parse_selector('div > span') expect(format_selector(node, { minify: true })).toBe('div>span') }) @@ -169,7 +169,7 @@ describe('format_selector', () => { expect(format_selector_list(node)).toBe('div, span') }) - test('selector list minified', () => { + test('selector list minified (deprecated option)', () => { let node = parse_selector_list('div, span') expect(format_selector_list(node, { minify: true })).toBe('div,span') }) @@ -186,7 +186,7 @@ describe('format_declaration', () => { expect(format_declaration(node)).toBe('color: red') }) - test('minified removes space after colon', () => { + test('minified removes space after colon (deprecated option)', () => { let node = parse_declaration('color: red') expect(format_declaration(node, { minify: true })).toBe('color:red') }) @@ -206,7 +206,7 @@ describe('format_declaration', () => { expect(format_declaration(node)).toBe('color: red !important') }) - test('!important minified', () => { + test('!important minified (deprecated option)', () => { let node = parse_declaration('color: red !important') expect(format_declaration(node, { minify: true })).toBe('color:red!important') }) diff --git a/test/comments.test.ts b/test/comments.test.ts index 0240784..a318e43 100644 --- a/test/comments.test.ts +++ b/test/comments.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'vitest' -import { format } from '../src/lib/index.js' +import { format, minify } from '../src/lib/index.js' describe('comments', () => { test('only comment', () => { @@ -545,7 +545,7 @@ a + b {}` }) test('strips comments in minification mode', () => { - let actual = format( + let actual = minify( ` /* comment 1 */ selector {} @@ -557,7 +557,6 @@ a + b {}` } /* comment 5 */ `, - { minify: true }, ) let expected = `selector{}@media (min-width:1000px){selector{}}` expect(actual).toEqual(expected) diff --git a/test/selectors.test.ts b/test/selectors.test.ts index 97e38de..9160085 100644 --- a/test/selectors.test.ts +++ b/test/selectors.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format } from '../src/lib/index.js' +import { format, minify } from '../src/lib/index.js' test('A single selector is rendered without a trailing comma', () => { let actual = format('a {}') @@ -139,7 +139,7 @@ test.each([ [`li:nth-child(-n+3 of li.important) {}`, `li:nth-child(-n+3 of li.important){}`], [`p:nth-child(n+8):nth-child(-n+15) {}`, `p:nth-child(n+8):nth-child(-n+15){}`], ])('minifies nth selector: %s', (css, expected) => { - let actual = format(css, { minify: true }) + let actual = minify(css) expect(actual).toEqual(expected) }) diff --git a/test/tab-size.test.ts b/test/tab-size.test.ts index 1a2f17a..f6ea5b2 100644 --- a/test/tab-size.test.ts +++ b/test/tab-size.test.ts @@ -65,7 +65,7 @@ test('invalid tab_size does not throw', () => { expect(() => format(fixture, { tab_size: 0 }), 'invalid tab_size should not throw').not.toThrow() }) -test('combine tab_size and minify', () => { +test('combine tab_size and minify (deprecated option)', () => { let actual = format(fixture, { tab_size: 2, minify: true, diff --git a/test/values.test.ts b/test/values.test.ts index d63de21..c798599 100644 --- a/test/values.test.ts +++ b/test/values.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'vitest' -import { format } from '../src/lib/index.js' +import { format, minify } from '../src/lib/index.js' test('collapses abundant whitespace', () => { let actual = format(`a { @@ -257,12 +257,11 @@ test('does not break space toggles', () => { }) test('does not break space toggles (minified)', () => { - let actual = format( + let actual = minify( `a { --ON: initial; --OFF: ; }`, - { minify: true }, ) let expected = `a{--ON:initial;--OFF: }` expect(actual).toEqual(expected) diff --git a/tsdown.config.ts b/tsdown.config.ts index 2112c1a..5b312cf 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -6,13 +6,18 @@ export default defineConfig([ platform: 'neutral', publint: true, }, + { + entry: 'src/lib/minify.ts', + platform: 'neutral', + publint: true, + }, { entry: 'src/cli/cli.ts', platform: 'node', dts: false, // Reference the lib via its package name to avoid bundling it twice deps: { - neverBundle: ['@projectwallace/format-css'], + neverBundle: ['@projectwallace/format-css', '@projectwallace/format-css/minify'], }, }, ]) diff --git a/vitest.config.ts b/vitest.config.ts index 781a89b..d906d0e 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,6 +4,7 @@ import { resolve } from 'node:path' export default defineConfig({ resolve: { alias: { + '@projectwallace/format-css/minify': resolve('./src/lib/minify.ts'), '@projectwallace/format-css': resolve('./src/lib/index.ts'), }, }, From ce8ab37448bef710af158341e057defc8bee3eb4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:11:16 +0000 Subject: [PATCH 2/7] Drop dead OPTIONAL_SPACE/LAST_SEMICOLON indirection in format() 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 Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1 --- src/lib/index.ts | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index f5f0091..fa3df21 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -423,8 +423,6 @@ export function format( } const NEWLINE = '\n' - const OPTIONAL_SPACE = SPACE - const LAST_SEMICOLON = SEMICOLON // First pass: collect all comments let comments: number[] = [] @@ -484,7 +482,7 @@ export function format( } } - let printed = print_selector(selector, OPTIONAL_SPACE) + let printed = print_selector(selector, SPACE) if (selector.has_next) { printed += COMMA } @@ -525,10 +523,8 @@ export function format( } if (is_declaration(child)) { - let is_last = !child.has_next || !is_declaration(child.next_sibling) let declaration = format_declaration(child) - let semi = is_last ? LAST_SEMICOLON : SEMICOLON - lines.push(indent(depth) + declaration + semi) + lines.push(indent(depth) + declaration + SEMICOLON) } else if (is_rule(child)) { if (prev_end !== undefined && lines.length > 0) { lines.push(EMPTY_STRING) @@ -567,7 +563,7 @@ export function format( list += NEWLINE + indent(depth) + comment } - list += OPTIONAL_SPACE + OPEN_BRACE + list += SPACE + OPEN_BRACE if (!block_has_content) { list += CLOSE_BRACE } @@ -590,7 +586,7 @@ export function format( let block_has_content = node.has_block && (!node.block.is_empty || !!get_comment(node.block.start, node.block.end)) if (node.has_block) { - name += OPTIONAL_SPACE + OPEN_BRACE + name += SPACE + OPEN_BRACE if (!block_has_content) { name += CLOSE_BRACE } From c2109c0dd6191ee31341d8eca2ce6bc0d7aaa749 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:18:23 +0000 Subject: [PATCH 3/7] Fix tsc resolution of the /minify subpath in the CLI 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 Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1 --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 7a3d3c5..7c83bfd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,7 +20,8 @@ "outDir": "dist", "paths": { // So we can import like an external in the CLI - "@projectwallace/format-css": ["./src/lib/index.ts"] + "@projectwallace/format-css": ["./src/lib/index.ts"], + "@projectwallace/format-css/minify": ["./src/lib/minify.ts"] } }, "include": ["src/lib/index.ts", "src/cli/cli.ts"], From 60768e133e3a080fa0924b25318d80794d4c4ff7 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:25:18 +0000 Subject: [PATCH 4/7] Address review feedback - 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 Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1 --- src/lib/index.ts | 2 +- src/lib/minify.ts | 8 ++++++-- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index fa3df21..b47346e 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -482,7 +482,7 @@ export function format( } } - let printed = print_selector(selector, SPACE) + let printed = print_selector(selector) if (selector.has_next) { printed += COMMA } diff --git a/src/lib/minify.ts b/src/lib/minify.ts index 2850175..f28c086 100644 --- a/src/lib/minify.ts +++ b/src/lib/minify.ts @@ -62,8 +62,12 @@ function is_brace_close(t: TokenType): boolean { /** * 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). + * always minify. `@projectwallace/css-parser` can parse at-rule preludes + * into a real tree (`parse_atrule_preludes: true`), but format()'s own + * pretty-printer opts out of that and handles preludes as raw text via + * regex instead - see the comment above `format_atrule_prelude` in + * index.ts for why. This reuses that same regex-based approach rather than + * introducing a second, different way to format prelude text. */ function minify_atrule_prelude(prelude: string): string { return prelude From 4c62861e8319377fab45c2e885b8082b4cbd6f15 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:41:19 +0000 Subject: [PATCH 5/7] Radically simplify minify() to a single context-free pass 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 Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1 --- src/lib/minify.ts | 469 +++++------------------------------------ test/minify.test.ts | 20 +- test/selectors.test.ts | 4 +- 3 files changed, 75 insertions(+), 418 deletions(-) diff --git a/src/lib/minify.ts b/src/lib/minify.ts index f28c086..37a77ee 100644 --- a/src/lib/minify.ts +++ b/src/lib/minify.ts @@ -1,442 +1,85 @@ import { tokenize, - TOKEN_IDENT, TOKEN_FUNCTION, - TOKEN_AT_KEYWORD, - TOKEN_DELIM, TOKEN_WHITESPACE, - TOKEN_COLON, - TOKEN_SEMICOLON, - TOKEN_COMMA, - TOKEN_LEFT_BRACKET, - TOKEN_RIGHT_BRACKET, - TOKEN_LEFT_PAREN, - TOKEN_RIGHT_PAREN, - TOKEN_LEFT_BRACE, - TOKEN_RIGHT_BRACE, TOKEN_COMMENT, TOKEN_CDO, TOKEN_CDC, - type Token, - type TokenType, } from '@projectwallace/css-parser' -const SPACE = ' ' -const EMPTY_STRING = '' - -// Nth-child/nth-of-type and friends carry their own An+B micro-syntax whose -// `+`/`-` spacing is optional (unlike calc's, which is mandatory). -const NTH_FUNCTIONS = new Set(['nth-child', 'nth-last-child', 'nth-of-type', 'nth-last-of-type']) - -const ATRULE_COLON_COMMA_RE = /\s*([:,])/g -const ATRULE_PAREN_TEXT_RE = /\)([a-zA-Z])/g -const ATRULE_KEYWORD_PAREN_RE = /\b(and|or|not|only)\(/gi -const ATRULE_ARROW_COMPARE_RE = /\s*(=>|>=|<=)\s*/g -const ATRULE_COMPARE_RE = /([^<>=\s])([<>])([^<>=\s])/g -const ATRULE_COMPARE_SPACED_RE = /([^<>=\s])\s+([<>])\s+([^<>=\s])/g -const ATRULE_COLON_COMMA_SPACE_RE = /([:,]) /g -const ATRULE_CALC_RE = /calc\(\s*([^()+\-*/]+)\s*([*/+-])\s*([^()+\-*/]+)\s*\)/g -const NTH_SIGN_RE = /\s*([+-])\s*/g -const WHITESPACE_RE = /\s+/g - -type Tok = { type: TokenType; start: number; end: number; space_before: boolean } - -function is_paren_open(t: TokenType): boolean { - return t === TOKEN_LEFT_PAREN || t === TOKEN_FUNCTION -} -function is_paren_close(t: TokenType): boolean { - return t === TOKEN_RIGHT_PAREN -} -function is_bracket_open(t: TokenType): boolean { - return t === TOKEN_LEFT_BRACKET -} -function is_bracket_close(t: TokenType): boolean { - return t === TOKEN_RIGHT_BRACKET -} -function is_brace_open(t: TokenType): boolean { - return t === TOKEN_LEFT_BRACE -} -function is_brace_close(t: TokenType): boolean { - return t === TOKEN_RIGHT_BRACE -} - /** - * Adapted from the pretty-printer's at-rule prelude formatter, hardcoded to - * always minify. `@projectwallace/css-parser` can parse at-rule preludes - * into a real tree (`parse_atrule_preludes: true`), but format()'s own - * pretty-printer opts out of that and handles preludes as raw text via - * regex instead - see the comment above `format_atrule_prelude` in - * index.ts for why. This reuses that same regex-based approach rather than - * introducing a second, different way to format prelude text. + * Characters where the space on one particular side is always safe to + * drop, in every CSS context: separators/operators that are unambiguous + * wherever they appear, plus grouping punctuation. + * + * `+`, `-` and `*` are deliberately left out of both sets: they're also + * used where whitespace IS significant (calc's mandatory `+`/`-`, the + * universal selector `*` right before a descendant combinator), and + * telling those uses apart from "just an operator" would mean knowing + * what kind of CSS construct is currently being printed - the whole point + * here is to not track that. A few spots (`calc(1px * 2)`, + * `:nth-child(-n + 3)`, `a + b`) keep an optional space a fussier + * minifier would strip - a good trade for never needing a parser. + * + * `(` and `[` only drop their trailing space: an ident directly before + * either (`and(` vs `and (`, `a[href]` vs `a [href]`) can change meaning, + * so the space before them is left to the default rule below instead. */ -function minify_atrule_prelude(prelude: string): string { - return prelude - .replaceAll(ATRULE_COLON_COMMA_RE, prelude.toLowerCase().includes('selector(') ? '$1' : '$1 ') // force whitespace after colon or comma, except inside `selector()` - .replaceAll(ATRULE_PAREN_TEXT_RE, ') $1') // force whitespace between closing parenthesis and following text (usually and|or) - .replaceAll(ATRULE_KEYWORD_PAREN_RE, '$1 (') // force whitespace between media/supports keywords and opening parenthesis - .replaceAll(ATRULE_ARROW_COMPARE_RE, '$1') // remove optional spacing around =>, >= and <= - .replaceAll(ATRULE_COMPARE_RE, '$1$2$3') // remove spacing around < or > except when it's part of <=, >=, => - .replaceAll(ATRULE_COMPARE_SPACED_RE, '$1$2$3') // remove spaces around < or > when they already have surrounding whitespace - .replaceAll(WHITESPACE_RE, SPACE) // collapse multiple whitespaces into one - .replaceAll(ATRULE_COLON_COMMA_SPACE_RE, '$1') // remove the optional space after : and , added above - .replaceAll(ATRULE_CALC_RE, (_, left, operator, right) => { - // force required whitespace around + and -, remove optional whitespace around * and / - let space = operator === '+' || operator === '-' ? SPACE : EMPTY_STRING - return `calc(${left.trim()}${space}${operator}${space}${right.trim()})` - }) - .trim() -} - -/** Collapses whitespace in an An+B expression and removes the optional space around its `+`/`-`. */ -function minify_nth(text: string): string { - return text.trim().replaceAll(WHITESPACE_RE, SPACE).replaceAll(NTH_SIGN_RE, '$1') -} +const TIGHT_BEFORE = new Set([',', ':', ';', '!', '>', '<', '=', '~', '/', ')', ']', '}', '{']) +const TIGHT_AFTER = new Set([',', ':', ';', '!', '>', '<', '=', '~', '/', '(', '[', '{', '}']) /** - * Minify a string of CSS: print every node, drop every comment. + * Minify a string of CSS: print every token, drop every comment, and drop + * whitespace except the one space needed to keep meaning intact. * - * Unlike `format()`, this never builds an AST - it walks the raw token - * stream once and reconstructs the minimal valid text for it. Whitespace - * and comment tokens are dropped up front; a `space_before` flag records - * whether a kept token had any whitespace/comment immediately before it in - * the source, which is what lets a couple of spots (the selector descendant - * combinator, an attribute selector's flag) tell "meaningful gap" apart from - * "nothing there at all" without needing a real parse tree. + * Unlike `format()`, this never builds a syntax tree - it walks the raw + * token stream once, tracking only the previous token's last character, + * and reconstructs the minimal text for it. */ export function minify(css: string): string { - let tokens: Tok[] = [] - let space_before = false - for (let t of tokenize(css)) { + let out = '' + let had_space = false + let prev_char = '' + let prev_was_function = false + + for (let { type, start, end } of tokenize(css)) { if ( - t.type === TOKEN_WHITESPACE || - t.type === TOKEN_COMMENT || - t.type === TOKEN_CDO || - t.type === TOKEN_CDC + type === TOKEN_WHITESPACE || + type === TOKEN_COMMENT || + type === TOKEN_CDO || + type === TOKEN_CDC ) { - space_before = true + had_space = true continue } - tokens.push({ type: t.type, start: t.start, end: t.end, space_before }) - space_before = false - } - let n = tokens.length - let pos = 0 - - function text(t: Tok): string { - return css.slice(t.start, t.end) - } + let text = css.slice(start, end) + let char = text.length === 1 ? text : '' - /** Indexes `tokens` without the `| undefined` noise - every call site here is already bounds-checked by its loop condition. */ - function at(i: number): Tok { - return tokens[i] as Tok - } - - /** Reconstructs text for a token span, collapsing any original gap to a single space, with comments already gone. */ - function raw_join(start: number, end: number): string { - let out = EMPTY_STRING - for (let i = start; i < end; i++) { - if (i > start && at(i).space_before) out += SPACE - out += text(at(i)) - } - return out - } - - /** Index of the token matching the opener just before `start`, using the given open/close predicates. `start` is already "one level deep". */ - function find_close( - start: number, - is_open: (t: TokenType) => boolean, - is_close: (t: TokenType) => boolean, - ): number { - let depth = 1 - for (let i = start; i < n; i++) { - let ty = at(i).type - if (is_open(ty)) depth++ - else if (is_close(ty)) { - depth-- - if (depth === 0) return i - } + // A redundant `;` right before the block closes can just go. + if (char === '}' && prev_char === ';') { + out = out.slice(0, -1) } - return n - } - /** Index of the first depth-0 `;` or `{` at or after `pos`, within `limit_end` - the boundary between a declaration and a nested rule/at-rule. */ - function find_statement_terminator(limit_end: number): number { - let depth = 0 - for (let i = pos; i < limit_end; i++) { - let ty = at(i).type - if (is_paren_open(ty) || ty === TOKEN_LEFT_BRACKET) depth++ - else if (is_paren_close(ty) || ty === TOKEN_RIGHT_BRACKET) depth-- - else if (depth === 0 && (ty === TOKEN_LEFT_BRACE || ty === TOKEN_SEMICOLON)) return i - } - return limit_end - } - - function is_operator_delim(t: Tok): boolean { - if (t.type !== TOKEN_DELIM) return false - let ch = css.charCodeAt(t.start) - return ch === 43 || ch === 45 || ch === 42 || ch === 47 // + - * / - } - - /** Prints a declaration value or a function's argument list: comma/operator spacing, mandatory single space between plain siblings, everything else passed through verbatim. */ - function print_value(start: number, end: number): string { - let out = EMPTY_STRING - let boundary = false - for (let i = start; i < end; i++) { - let t = at(i) - if (t.type === TOKEN_COMMA) { - out += ',' - boundary = false - continue - } - if (t.type === TOKEN_RIGHT_PAREN) { - out += ')' - boundary = true - continue - } - if (is_operator_delim(t)) { - let ch = css[t.start] - let space = ch === '+' || ch === '-' ? SPACE : EMPTY_STRING - out += space + ch + space - boundary = false - continue - } - if (boundary) out += SPACE - out += text(t) - boundary = !(t.type === TOKEN_LEFT_PAREN || t.type === TOKEN_FUNCTION) - } - return out - } - - /** Detects a trailing `!important` (case preserved) and returns where the real value ends. */ - function extract_important(start: number, end: number): { value_end: number; important: string } { - if (end - start >= 2) { - let last = at(end - 1) - let prev = at(end - 2) - if ( - last.type === TOKEN_IDENT && - text(last).toLowerCase() === 'important' && - prev.type === TOKEN_DELIM && - css.charCodeAt(prev.start) === 33 // ! - ) { - return { value_end: end - 2, important: '!' + text(last) } - } - } - return { value_end: end, important: EMPTY_STRING } - } - - function print_declaration(end: number): string { - let start = pos - pos = end - if (tokens[start]?.type !== TOKEN_IDENT || tokens[start + 1]?.type !== TOKEN_COLON) { - // Not shaped like a declaration (malformed input) - pass it through. - return raw_join(start, end) - } - let property = text(at(start)) - let { value_end, important } = extract_important(start + 2, end) - let value = print_value(start + 2, value_end) - // A custom property's value may be empty/whitespace-only on purpose (the - // "space toggle" trick) - keep one space so it doesn't vanish entirely. - if (value === EMPTY_STRING) value = SPACE - return property + ':' + value + important - } - - /** Prints an attribute selector, e.g. `[href^="https://" i]`. The flag (if any) is the only part with a mandatory space. */ - function print_attribute_selector(open_i: number, close_i: number): string { - let out = '[' - for (let i = open_i + 1; i < close_i; i++) { - let t = at(i) - if (i === close_i - 1 && t.type === TOKEN_IDENT && t.space_before) { - out += SPACE - } - out += text(t) - } - return out + ']' - } - - /** Prints the An+B [of ] argument of :nth-child() and friends. */ - function print_nth_arguments(start: number, end: number): string { - let of_i = -1 - for (let i = start; i < end; i++) { - if (at(i).type === TOKEN_IDENT && text(at(i)).toLowerCase() === 'of') { - of_i = i - break - } - } - let nth_end = of_i === -1 ? end : of_i - let nth_text = minify_nth(raw_join(start, nth_end)) - if (of_i === -1) return nth_text - return nth_text + ' of ' + print_selector_list(of_i + 1, end) - } - - /** Prints a functional pseudo-class/element's `(...)` argument, e.g. `:is(a, b)` or `:nth-child(2n+1 of .foo)`. */ - function print_pseudo_function(fn_tok: Tok, fn_i: number, close_i: number): string { - let name = text(fn_tok) - let lower = name.slice(0, -1).toLowerCase() - let args_start = fn_i + 1 - let args = NTH_FUNCTIONS.has(lower) - ? print_nth_arguments(args_start, close_i) - : print_selector_list(args_start, close_i) - return name + args + ')' - } - - /** Prints one complex selector, e.g. `div > .foo:hover`: compound-selector parts glue tight; a combinator (explicit or a lone descendant space) separates compounds. */ - function print_complex_selector(start: number, end: number): string { - let out = EMPTY_STRING - let i = start - let is_first = true - let after_combinator = false - while (i < end) { - let t = at(i) - let ch = t.type === TOKEN_DELIM ? css[t.start] : EMPTY_STRING - - if (ch === '>' || ch === '~' || ch === '+') { - out += ch - i++ - is_first = false - after_combinator = true - continue - } - - if (!is_first && !after_combinator && t.space_before) { - out += SPACE - } - is_first = false - after_combinator = false - - if (t.type === TOKEN_COLON) { - let double = tokens[i + 1]?.type === TOKEN_COLON - let name_i = double ? i + 2 : i + 1 - let name_tok = tokens[name_i] - out += double ? '::' : ':' - if (name_tok === undefined) { - i = name_i - continue - } - if (name_tok.type === TOKEN_FUNCTION) { - let close = find_close(name_i + 1, is_paren_open, is_paren_close) - out += print_pseudo_function(name_tok, name_i, close) - i = close + 1 - } else { - out += text(name_tok) - i = name_i + 1 - } - continue - } - - if (t.type === TOKEN_LEFT_BRACKET) { - let close = find_close(i + 1, is_bracket_open, is_bracket_close) - out += print_attribute_selector(i, close) - i = close + 1 - continue - } - - out += text(t) - i++ - } - return out - } - - /** Index of the first depth-0 comma in [start, end) - the split points of a selector list. */ - function find_top_level_comma(start: number, end: number): number { - let depth = 0 - for (let i = start; i < end; i++) { - let ty = at(i).type - if (is_paren_open(ty) || ty === TOKEN_LEFT_BRACKET) depth++ - else if (is_paren_close(ty) || ty === TOKEN_RIGHT_BRACKET) depth-- - else if (depth === 0 && ty === TOKEN_COMMA) return i - } - return end - } - - /** Prints a comma-separated list of complex selectors, e.g. `a, b` or the argument list of `:is(a, b)`. */ - function print_selector_list(start: number, end: number): string { - let out = EMPTY_STRING - let i = start - while (i < end) { - let piece_end = find_top_level_comma(i, end) - out += print_complex_selector(i, piece_end) - i = piece_end - if (i < end && at(i).type === TOKEN_COMMA) { - out += ',' - i++ - } - } - return out - } - - function print_atrule(limit_end: number): string { - let out = text(at(pos)) - pos++ - - let depth = 0 - let term = pos - for (; term < limit_end; term++) { - let ty = at(term).type - if (is_paren_open(ty) || ty === TOKEN_LEFT_BRACKET) depth++ - else if (is_paren_close(ty) || ty === TOKEN_RIGHT_BRACKET) depth-- - else if (depth === 0 && (ty === TOKEN_LEFT_BRACE || ty === TOKEN_SEMICOLON)) break - } - - let prelude = raw_join(pos, term) - if (prelude !== EMPTY_STRING) { - out += SPACE + minify_atrule_prelude(prelude) - } - - if (term < limit_end && at(term).type === TOKEN_LEFT_BRACE) { - let close = find_close(term + 1, is_brace_open, is_brace_close) - pos = term + 1 - out += '{' + print_block(close) + '}' - pos = close + 1 - } else { - pos = term < limit_end && at(term).type === TOKEN_SEMICOLON ? term + 1 : term - out += ';' - } - - return out - } - - function print_rule(brace_i: number): string { - let selector = print_selector_list(pos, brace_i) - let close = find_close(brace_i + 1, is_brace_open, is_brace_close) - pos = brace_i + 1 - let body = print_block(close) - pos = close + 1 - return selector + '{' + body + '}' - } - - /** Prints the children of a `{...}` block, or the whole stylesheet (with `close_i` as the token count). */ - function print_block(close_i: number): string { - let out = EMPTY_STRING - let pending_semi = false - - while (pos < close_i) { - if (at(pos).type === TOKEN_SEMICOLON) { - pos++ - continue - } - if (pending_semi) { - out += ';' - pending_semi = false - } - - if (at(pos).type === TOKEN_AT_KEYWORD) { - out += print_atrule(close_i) - continue - } - - let term = find_statement_terminator(close_i) - if (term < close_i && at(term).type === TOKEN_LEFT_BRACE) { - out += print_rule(term) - } else { - out += print_declaration(term) - pos = term < close_i && at(term).type === TOKEN_SEMICOLON ? term + 1 : term - pending_semi = true - } + if (prev_char === ':' && (char === ';' || char === '}')) { + // An empty declaration value (the `--foo: ;` "space toggle" trick) + // must keep at least one space, or it silently becomes invalid. + out += ' ' + } else if ( + out !== '' && + had_space && + !TIGHT_BEFORE.has(char) && + !TIGHT_AFTER.has(prev_char) && + !prev_was_function + ) { + out += ' ' } - return out + out += text + prev_char = char + prev_was_function = type === TOKEN_FUNCTION + had_space = false } - return print_block(n) + return out } diff --git a/test/minify.test.ts b/test/minify.test.ts index 8d9416c..c066807 100644 --- a/test/minify.test.ts +++ b/test/minify.test.ts @@ -40,7 +40,10 @@ a { test('correctly minifies operators', () => { let actual = minify(`a { width: calc(100% - 10px); height: calc(100 * 1%); }`) - let expected = `a{width:calc(100% - 10px);height:calc(100*1%)}` + // `*` is deliberately not stripped of its surrounding space: unlike `+`/`-`/`,`, + // it's ambiguous outside a value (e.g. the universal selector), so this + // minifier leaves it alone rather than tracking what construct it's in. + let expected = `a{width:calc(100% - 10px);height:calc(100 * 1%)}` expect(actual).toEqual(expected) }) @@ -88,8 +91,17 @@ test('minifies complex selectors', () => { expect(actual).toEqual(expected) }) -test('removes whitespace around non-whitespace selector combinators', () => { - let actual = minify(`a + b {} c d {}`) - let expected = `a+b{}c d{}` +test('removes whitespace around unambiguous selector combinators, keeps the descendant space', () => { + let actual = minify(`a > b {} a ~ b {} c d {}`) + // `>` and `~` are unambiguous everywhere, so their surrounding space is + // stripped. `+` is left alone (see the calc test above) and the plain + // descendant-combinator space must never be removed at all. + let expected = `a>b{}a~b{}c d{}` + expect(actual).toEqual(expected) +}) + +test('keeps the optional space around a `+` selector combinator', () => { + let actual = minify(`a + b {}`) + let expected = `a + b{}` expect(actual).toEqual(expected) }) diff --git a/test/selectors.test.ts b/test/selectors.test.ts index 9160085..8f4f3b3 100644 --- a/test/selectors.test.ts +++ b/test/selectors.test.ts @@ -135,7 +135,9 @@ test.each([ [`li:nth-child(0n+1) {}`, `li:nth-child(0n+1){}`], [`li:nth-child(even of .noted) {}`, `li:nth-child(even of .noted){}`], [`li:nth-child(2n of .noted) {}`, `li:nth-child(2n of .noted){}`], - [`li:nth-child(-n + 3 of .noted) {}`, `li:nth-child(-n+3 of .noted){}`], + // An An+B expression's `+`/`-` isn't stripped of its optional space (same + // trade-off as calc's `*`/`/`): already-tight input stays tight either way. + [`li:nth-child(-n + 3 of .noted) {}`, `li:nth-child(-n + 3 of .noted){}`], [`li:nth-child(-n+3 of li.important) {}`, `li:nth-child(-n+3 of li.important){}`], [`p:nth-child(n+8):nth-child(-n+15) {}`, `p:nth-child(n+8):nth-child(-n+15){}`], ])('minifies nth selector: %s', (css, expected) => { From ad409dc7ed3eef96f26e1fdd8b5945e17eaf1e3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 10:53:29 +0000 Subject: [PATCH 6/7] Replace string/char-set comparisons with token type and char codes , : ; ( ) [ ] { } 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. 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 Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1 --- src/lib/minify.ts | 116 ++++++++++++++++++++++++++++++++++------------ 1 file changed, 86 insertions(+), 30 deletions(-) diff --git a/src/lib/minify.ts b/src/lib/minify.ts index 37a77ee..cb69cc7 100644 --- a/src/lib/minify.ts +++ b/src/lib/minify.ts @@ -1,46 +1,104 @@ import { tokenize, + TOKEN_DELIM, TOKEN_FUNCTION, + TOKEN_COMMA, + TOKEN_COLON, + TOKEN_SEMICOLON, + TOKEN_LEFT_PAREN, + TOKEN_RIGHT_PAREN, + TOKEN_LEFT_BRACKET, + TOKEN_RIGHT_BRACKET, + TOKEN_LEFT_BRACE, + TOKEN_RIGHT_BRACE, TOKEN_WHITESPACE, TOKEN_COMMENT, TOKEN_CDO, TOKEN_CDC, + type TokenType, } from '@projectwallace/css-parser' +// Char codes for the DELIM characters that are unambiguous in every CSS +// context: ! / < = > ~ +function is_unambiguous_delim(code: number): boolean { + return code === 33 || code === 47 || code === 60 || code === 61 || code === 62 || code === 126 +} + +/** + * True if a token of this `type` (and, for a DELIM, this char `code`) never + * needs a space immediately BEFORE it, in any CSS context. + * + * `,` `:` `;` and the unambiguous delimiters above are always tight on both + * sides. `)` `]` only drop their leading space here; their trailing space + * is left to the default rule (see `no_trailing_space`), since e.g. the gap + * between `)` and a following `and` in a media query must survive. + */ +function no_leading_space(type: TokenType, code: number): boolean { + switch (type) { + case TOKEN_COMMA: + case TOKEN_COLON: + case TOKEN_SEMICOLON: + case TOKEN_LEFT_BRACE: + case TOKEN_RIGHT_BRACE: + case TOKEN_RIGHT_PAREN: + case TOKEN_RIGHT_BRACKET: + return true + case TOKEN_DELIM: + return is_unambiguous_delim(code) + default: + return false + } +} + /** - * Characters where the space on one particular side is always safe to - * drop, in every CSS context: separators/operators that are unambiguous - * wherever they appear, plus grouping punctuation. + * True if a token of this `type` (and, for a DELIM, this char `code`) never + * needs a space immediately AFTER it, in any CSS context. * - * `+`, `-` and `*` are deliberately left out of both sets: they're also - * used where whitespace IS significant (calc's mandatory `+`/`-`, the - * universal selector `*` right before a descendant combinator), and - * telling those uses apart from "just an operator" would mean knowing - * what kind of CSS construct is currently being printed - the whole point - * here is to not track that. A few spots (`calc(1px * 2)`, - * `:nth-child(-n + 3)`, `a + b`) keep an optional space a fussier - * minifier would strip - a good trade for never needing a parser. + * `(` `[` only drop their trailing space here: an ident directly before + * either (`and(` vs `and (`, `a[href]` vs `a [href]`) can change meaning, so + * their leading space is left to the default rule instead. A FUNCTION token + * already has its own `(` fused into it (`rgb(`), so it's tight the same way. * - * `(` and `[` only drop their trailing space: an ident directly before - * either (`and(` vs `and (`, `a[href]` vs `a [href]`) can change meaning, - * so the space before them is left to the default rule below instead. + * `+`, `-` and `*` are deliberately unhandled by either function: they're + * also used where whitespace IS significant (calc's mandatory `+`/`-`, the + * universal selector `*` right before a descendant combinator), and telling + * those uses apart from "just an operator" would mean knowing what kind of + * CSS construct is currently being printed - the whole point here is to not + * track that. A few spots (`calc(1px * 2)`, `:nth-child(-n + 3)`, `a + b`) + * keep an optional space a fussier minifier would strip - a good trade for + * never needing a parser. */ -const TIGHT_BEFORE = new Set([',', ':', ';', '!', '>', '<', '=', '~', '/', ')', ']', '}', '{']) -const TIGHT_AFTER = new Set([',', ':', ';', '!', '>', '<', '=', '~', '/', '(', '[', '{', '}']) +function no_trailing_space(type: TokenType | -1, code: number): boolean { + switch (type) { + case TOKEN_COMMA: + case TOKEN_COLON: + case TOKEN_SEMICOLON: + case TOKEN_LEFT_BRACE: + case TOKEN_RIGHT_BRACE: + case TOKEN_LEFT_PAREN: + case TOKEN_LEFT_BRACKET: + case TOKEN_FUNCTION: + return true + case TOKEN_DELIM: + return is_unambiguous_delim(code) + default: + return false + } +} /** * Minify a string of CSS: print every token, drop every comment, and drop * whitespace except the one space needed to keep meaning intact. * * Unlike `format()`, this never builds a syntax tree - it walks the raw - * token stream once, tracking only the previous token's last character, - * and reconstructs the minimal text for it. + * token stream once, tracking only the previous token's type and (for a + * DELIM) char code, and reconstructs the minimal text for it. */ export function minify(css: string): string { let out = '' let had_space = false - let prev_char = '' - let prev_was_function = false + let prev_type: TokenType | -1 = -1 + let prev_code = 0 for (let { type, start, end } of tokenize(css)) { if ( @@ -53,31 +111,29 @@ export function minify(css: string): string { continue } - let text = css.slice(start, end) - let char = text.length === 1 ? text : '' + let code = type === TOKEN_DELIM ? css.charCodeAt(start) : 0 // A redundant `;` right before the block closes can just go. - if (char === '}' && prev_char === ';') { + if (type === TOKEN_RIGHT_BRACE && prev_type === TOKEN_SEMICOLON) { out = out.slice(0, -1) } - if (prev_char === ':' && (char === ';' || char === '}')) { + if (prev_type === TOKEN_COLON && (type === TOKEN_SEMICOLON || type === TOKEN_RIGHT_BRACE)) { // An empty declaration value (the `--foo: ;` "space toggle" trick) // must keep at least one space, or it silently becomes invalid. out += ' ' } else if ( out !== '' && had_space && - !TIGHT_BEFORE.has(char) && - !TIGHT_AFTER.has(prev_char) && - !prev_was_function + !no_leading_space(type, code) && + !no_trailing_space(prev_type, prev_code) ) { out += ' ' } - out += text - prev_char = char - prev_was_function = type === TOKEN_FUNCTION + out += css.slice(start, end) + prev_type = type + prev_code = code had_space = false } From 74c045c6f679295f9c9d80e15fab69177f227385 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 23 Jul 2026 11:02:03 +0000 Subject: [PATCH 7/7] Stop write-then-slice for the redundant trailing semicolon 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 Claude-Session: https://claude.ai/code/session_01YWaxL8XDQmReNLkPfytrU1 --- src/lib/minify.ts | 41 ++++++++++++++++++++++++++++++----------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/src/lib/minify.ts b/src/lib/minify.ts index cb69cc7..f6aa7d3 100644 --- a/src/lib/minify.ts +++ b/src/lib/minify.ts @@ -28,16 +28,16 @@ function is_unambiguous_delim(code: number): boolean { * True if a token of this `type` (and, for a DELIM, this char `code`) never * needs a space immediately BEFORE it, in any CSS context. * - * `,` `:` `;` and the unambiguous delimiters above are always tight on both + * `,` `:` and the unambiguous delimiters above are always tight on both * sides. `)` `]` only drop their leading space here; their trailing space * is left to the default rule (see `no_trailing_space`), since e.g. the gap - * between `)` and a following `and` in a media query must survive. + * between `)` and a following `and` in a media query must survive. `;` + * doesn't need an entry here - see `minify()`, it's never written eagerly. */ function no_leading_space(type: TokenType, code: number): boolean { switch (type) { case TOKEN_COMMA: case TOKEN_COLON: - case TOKEN_SEMICOLON: case TOKEN_LEFT_BRACE: case TOKEN_RIGHT_BRACE: case TOKEN_RIGHT_PAREN: @@ -99,6 +99,11 @@ export function minify(css: string): string { let had_space = false let prev_type: TokenType | -1 = -1 let prev_code = 0 + // A `;` is never written right away - only once we see what follows it. + // A redundant one right before a block closes then simply never gets + // written at all, instead of being appended and sliced back off (which, + // on a large accumulated `out`, would copy the whole string every time). + let pending_semicolon = false for (let { type, start, end } of tokenize(css)) { if ( @@ -111,18 +116,28 @@ export function minify(css: string): string { continue } - let code = type === TOKEN_DELIM ? css.charCodeAt(start) : 0 - - // A redundant `;` right before the block closes can just go. - if (type === TOKEN_RIGHT_BRACE && prev_type === TOKEN_SEMICOLON) { - out = out.slice(0, -1) - } - if (prev_type === TOKEN_COLON && (type === TOKEN_SEMICOLON || type === TOKEN_RIGHT_BRACE)) { // An empty declaration value (the `--foo: ;` "space toggle" trick) // must keep at least one space, or it silently becomes invalid. out += ' ' - } else if ( + } + + if (type === TOKEN_SEMICOLON) { + pending_semicolon = true + prev_type = type + prev_code = 0 + had_space = false + continue + } + + if (pending_semicolon) { + pending_semicolon = false + if (type !== TOKEN_RIGHT_BRACE) out += ';' + } + + let code = type === TOKEN_DELIM ? css.charCodeAt(start) : 0 + + if ( out !== '' && had_space && !no_leading_space(type, code) && @@ -137,5 +152,9 @@ export function minify(css: string): string { had_space = false } + // A `;` at the very end of the input (e.g. `@layer test;` with nothing + // after it) never reached a following token to flush it against. + if (pending_semicolon) out += ';' + return out }