Skip to content
Merged
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
58 changes: 34 additions & 24 deletions src/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,22 @@ export type FormatOptions = {
tab_size?: number
}

const UNQUOTE_RE = /(?:^['"])|(?:['"]$)/g
const DATA_URL_RE = /^['"]?data:/i
const FONT_SLASH_RE = /\s*\/\s*/
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_WHITESPACE_RE = /\s+/g
const ATRULE_COLON_COMMA_SPACE_RE = /([:,]) /g
const ATRULE_CALC_RE = /calc\(\s*([^()+\-*/]+)\s*([*/+-])\s*([^()+\-*/]+)\s*\)/g
const ATRULE_FN_NAME_RE = /selector|url|supports|layer\(/gi

export function unquote(str: string): string {
return str.replaceAll(/(?:^['"])|(?:['"]$)/g, EMPTY_STRING)
return str.replaceAll(UNQUOTE_RE, EMPTY_STRING)
}

function print_string(str: string | number | null, quote?: '"' | "'"): string {
Expand All @@ -76,7 +90,7 @@ function print_url(node: Url): string {
let unquoted = unquote(value)

let inner: string
if (/^['"]?data:/i.test(value)) {
if (DATA_URL_RE.test(value)) {
let has_double = unquoted.includes('"')
let has_single = unquoted.includes("'")
if (has_double && has_single) {
Expand Down Expand Up @@ -109,9 +123,7 @@ function print_list(nodes: CSSNode[], optional_space = SPACE): string {
for (let node of nodes) {
if (is_function(node)) {
let fn = node.name.toLowerCase()
parts.push(fn, OPEN_PARENTHESES)
parts.push(print_list(node.children, optional_space))
parts.push(CLOSE_PARENTHESES)
parts.push(fn, OPEN_PARENTHESES, print_list(node.children, optional_space), CLOSE_PARENTHESES)
} else if (is_dimension(node)) {
parts.push(node.value, node.unit?.toLowerCase())
} else if (is_string(node)) {
Expand Down Expand Up @@ -161,7 +173,7 @@ export function format_declaration(

// Special case for `font` shorthand: remove whitespace around /
if (property === 'font') {
value = value.replace(/\s*\/\s*/, '/')
value = value.replace(FONT_SLASH_RE, '/')
}

// Hacky: add a space in case of a `space toggle` during minification
Expand All @@ -184,7 +196,8 @@ function print_nth(node: NthSelector, optional_space = SPACE): string {
result += optional_space
if (!b.startsWith('-')) result += '+' + optional_space
}
// the parseFloat removes the leading '+', if present
// parseFloat tolerates trailing non-numeric characters that Number() would reject as NaN
// oxlint-disable-next-line unicorn/prefer-number-coercion
result += parseFloat(b)
}
return result
Expand Down Expand Up @@ -335,23 +348,20 @@ export function format_atrule_prelude(
): string {
let optional_space = minify ? EMPTY_STRING : SPACE
return prelude
.replaceAll(/\s*([:,])/g, prelude.toLowerCase().includes('selector(') ? '$1' : '$1 ') // force whitespace after colon or comma, except inside `selector()`
.replaceAll(/\)([a-zA-Z])/g, ') $1') // force whitespace between closing parenthesis and following text (usually and|or)
.replaceAll(/\b(and|or|not|only)\(/gi, '$1 (') // force whitespace between media/supports keywords and opening parenthesis
.replaceAll(/\s*(=>|>=|<=)\s*/g, `${optional_space}$1${optional_space}`) // add optional spacing around =>, >= and <=
.replaceAll(/([^<>=\s])([<>])([^<>=\s])/g, `$1${optional_space}$2${optional_space}$3`) // add spacing around < or > except when it's part of <=, >=, =>
.replaceAll(/([^<>=\s])\s+([<>])\s+([^<>=\s])/g, `$1${optional_space}$2${optional_space}$3`) // handle spaces around < or > when they already have surrounding whitespace
.replaceAll(/\s+/g, SPACE) // collapse multiple whitespaces into one
.replaceAll(/([:,]) /g, minify ? '$1' : '$1 ') // in minify mode, remove optional spaces after : and ,
.replaceAll(
/calc\(\s*([^()+\-*/]+)\s*([*/+-])\s*([^()+\-*/]+)\s*\)/g,
(_, left, operator, right) => {
// force required or optional whitespace around * and / in calc()
let space = operator === '+' || operator === '-' ? SPACE : optional_space
return `calc(${left.trim()}${space}${operator}${space}${right.trim()})`
},
)
.replaceAll(/selector|url|supports|layer\(/gi, (match) => match.toLowerCase()) // lowercase function names
.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, `${optional_space}$1${optional_space}`) // add optional spacing around =>, >= and <=
.replaceAll(ATRULE_COMPARE_RE, `$1${optional_space}$2${optional_space}$3`) // add spacing around < or > except when it's part of <=, >=, =>
.replaceAll(ATRULE_COMPARE_SPACED_RE, `$1${optional_space}$2${optional_space}$3`) // handle spaces around < or > when they already have surrounding whitespace
.replaceAll(ATRULE_WHITESPACE_RE, SPACE) // collapse multiple whitespaces into one
.replaceAll(ATRULE_COLON_COMMA_SPACE_RE, minify ? '$1' : '$1 ') // in minify mode, remove optional spaces after : and ,
.replaceAll(ATRULE_CALC_RE, (_, left, operator, right) => {
// force required or optional whitespace around * and / in calc()
let space = operator === '+' || operator === '-' ? SPACE : optional_space
return `calc(${left.trim()}${space}${operator}${space}${right.trim()})`
})
.replaceAll(ATRULE_FN_NAME_RE, (match) => match.toLowerCase()) // lowercase function names
}

/**
Expand Down
Loading