Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 10 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
9 changes: 5 additions & 4 deletions src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `
Expand Down Expand Up @@ -94,17 +95,17 @@ export async function run(args: string[], io: CliIO): Promise<void> {
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()))
}
}

Expand Down
53 changes: 24 additions & 29 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
type CSSNode,
type Url,
} from '@projectwallace/css-parser'
import { minify } from './minify.js'

const SPACE = ' '
const EMPTY_STRING = ''
Expand All @@ -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
Expand Down Expand Up @@ -403,35 +409,33 @@ 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
// back to the default tab indentation instead of throwing.
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)
}
Expand All @@ -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
}

Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Expand Down Expand Up @@ -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 }
160 changes: 160 additions & 0 deletions src/lib/minify.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading