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..b47346e 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,20 @@ 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' // 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 +451,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 } @@ -478,7 +482,7 @@ export function format( } } - let printed = print_selector(selector, OPTIONAL_SPACE) + let printed = print_selector(selector) if (selector.has_next) { printed += COMMA } @@ -519,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, { minify }) - let semi = is_last ? LAST_SEMICOLON : SEMICOLON - lines.push(indent(depth) + declaration + semi) + let declaration = format_declaration(child) + lines.push(indent(depth) + declaration + SEMICOLON) } else if (is_rule(child)) { if (prev_end !== undefined && lines.length > 0) { lines.push(EMPTY_STRING) @@ -561,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 } @@ -578,13 +580,13 @@ 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 = 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 } @@ -650,11 +652,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..f6aa7d3 --- /dev/null +++ b/src/lib/minify.ts @@ -0,0 +1,160 @@ +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. `;` + * 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_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 + } +} + +/** + * 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. + * + * `(` `[` 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 `*` 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. + */ +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 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_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 ( + type === TOKEN_WHITESPACE || + type === TOKEN_COMMENT || + type === TOKEN_CDO || + type === TOKEN_CDC + ) { + had_space = true + continue + } + + 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 += ' ' + } + + 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) && + !no_trailing_space(prev_type, prev_code) + ) { + out += ' ' + } + + out += css.slice(start, end) + prev_type = type + prev_code = code + 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 +} 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/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 97e38de..8f4f3b3 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 {}') @@ -135,11 +135,13 @@ 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) => { - 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/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"], 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'), }, },