From 447520654b4034df6f47a9156161c0cc6a9ffcf7 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Mon, 30 Mar 2026 19:47:19 +0200 Subject: [PATCH 1/3] wip: export partials --- src/lib/index.ts | 228 +++++++++++++++++++++-------------------- test/selectors.test.ts | 13 +++ 2 files changed, 130 insertions(+), 111 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index 7e7ad9e..454989f 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -19,6 +19,100 @@ const OPEN_BRACE = '{' const CLOSE_BRACE = '}' const COMMA = ',' +export function unquote(str: string): string { + return str.replace(/(?:^['"])|(?:['"]$)/g, EMPTY_STRING) +} + +function print_string(str: string | number | null): string { + str = str?.toString() || '' + return QUOTE + unquote(str) + QUOTE +} + +function print_operator(node: CSSNode, optional_space = SPACE): string { + // https://developer.mozilla.org/en-US/docs/Web/CSS/calc#notes + // The + and - operators must be surrounded by whitespace + // Whitespace around other operators is optional + let operator = node.text + let code = operator.charCodeAt(0) + // + or - require spaces; comma has no leading space; others use optional space + let space = code === 43 || code === 45 ? SPACE : optional_space + return (code === 44 ? EMPTY_STRING : space) + operator + space +} + +function print_list(nodes: CSSNode[], optional_space = SPACE): string { + let parts = [] + for (let node of nodes) { + if (node.type === NODE.FUNCTION) { + let fn = node.name?.toLowerCase() + parts.push(fn, OPEN_PARENTHESES) + parts.push(print_list(node.children, optional_space)) + parts.push(CLOSE_PARENTHESES) + } else if (node.type === NODE.DIMENSION) { + parts.push(node.value, node.unit?.toLowerCase()) + } else if (node.type === NODE.STRING) { + parts.push(print_string(node.text)) + } else if (node.type === NODE.OPERATOR) { + parts.push(print_operator(node, optional_space)) + } else if (node.type === NODE.PARENTHESIS) { + parts.push(OPEN_PARENTHESES, print_list(node.children), CLOSE_PARENTHESES) + } else if (node.type === NODE.URL && typeof node.value === 'string') { + parts.push('url(') + let { value } = node + // if the value starts with data:, 'data:, "data: + if (/^['"]?data:/i.test(value)) { + parts.push(unquote(value)) + } else { + parts.push(print_string(value)) + } + parts.push(CLOSE_PARENTHESES) + } else { + parts.push(node.text) + } + + if (node.type !== NODE.OPERATOR) { + if (node.has_next) { + if (node.next_sibling?.type !== NODE.OPERATOR) { + parts.push(SPACE) + } + } + } + } + + return parts.join(EMPTY_STRING) +} + +export function print_value(nodes: CSSNode[] | null, optional_space = SPACE): string { + if (nodes === null) return EMPTY_STRING + return print_list(nodes, optional_space) +} + +export function print_declaration(node: CSSNode, optional_space = SPACE): string { + let important = EMPTY_STRING + if (node.is_important) { + let text = node.text + let start = text.lastIndexOf('!') + important = + optional_space + text.slice(start, text.endsWith(SEMICOLON) ? -1 : undefined).toLowerCase() + } + let value = print_value(node.value as CSSNode[] | null, optional_space) + let property = node.property! + + // Special case for `font` shorthand: remove whitespace around / + if (property === 'font') { + value = value.replace(/\s*\/\s*/, '/') + } + + // Hacky: add a space in case of a `space toggle` during minification + if (value === EMPTY_STRING && optional_space === EMPTY_STRING) { + value += SPACE + } + + if (!property.startsWith('--')) { + property = property.toLowerCase() + } + return property + COLON + optional_space + value + important +} + export type FormatOptions = { /** Whether to minify the CSS or keep it formatted */ minify?: boolean @@ -91,118 +185,24 @@ export function format( return buffer } - function unquote(str: string): string { - return str.replace(/(?:^['"])|(?:['"]$)/g, EMPTY_STRING) - } - - function print_string(str: string | number | null): string { - str = str?.toString() || '' - return QUOTE + unquote(str) + QUOTE - } - - function print_operator(node: CSSNode): string { - // https://developer.mozilla.org/en-US/docs/Web/CSS/calc#notes - // The + and - operators must be surrounded by whitespace - // Whitespace around other operators is optional - let operator = node.text - let code = operator.charCodeAt(0) - // + or - require spaces; comma has no leading space; others use optional space - let space = code === 43 || code === 45 ? SPACE : OPTIONAL_SPACE - return (code === 44 ? EMPTY_STRING : space) + operator + space - } - - function print_list(nodes: CSSNode[]): string { - let parts = [] - for (let node of nodes) { - if (node.type === NODE.FUNCTION) { - let fn = node.name?.toLowerCase() - parts.push(fn, OPEN_PARENTHESES) - parts.push(print_list(node.children)) - parts.push(CLOSE_PARENTHESES) - } else if (node.type === NODE.DIMENSION) { - parts.push(node.value, node.unit?.toLowerCase()) - } else if (node.type === NODE.STRING) { - parts.push(print_string(node.text)) - } else if (node.type === NODE.OPERATOR) { - parts.push(print_operator(node)) - } else if (node.type === NODE.PARENTHESIS) { - parts.push(OPEN_PARENTHESES, print_list(node.children), CLOSE_PARENTHESES) - } else if (node.type === NODE.URL && typeof node.value === 'string') { - parts.push('url(') - let { value } = node - // if the value starts with data:, 'data:, "data: - if (/^['"]?data:/i.test(value)) { - parts.push(unquote(value)) - } else { - parts.push(print_string(value)) - } - parts.push(CLOSE_PARENTHESES) - } else { - parts.push(node.text) - } - - if (node.type !== NODE.OPERATOR) { - if (node.has_next) { - if (node.next_sibling?.type !== NODE.OPERATOR) { - parts.push(SPACE) - } - } - } - } - - return parts.join(EMPTY_STRING) - } - - function print_value(nodes: CSSNode[] | null): string { - if (nodes === null) return EMPTY_STRING - return print_list(nodes) - } - - function print_declaration(node: CSSNode): string { - let important = EMPTY_STRING - if (node.is_important) { - let text = node.text - let start = text.lastIndexOf('!') - important = - OPTIONAL_SPACE + text.slice(start, text.endsWith(SEMICOLON) ? -1 : undefined).toLowerCase() - } - let value = print_value(node.value as CSSNode[] | null) - let property = node.property! - - // Special case for `font` shorthand: remove whitespace around / - if (property === 'font') { - value = value.replace(/\s*\/\s*/, '/') - } - - // Hacky: add a space in case of a `space toggle` during minification - if (value === EMPTY_STRING && minify === true) { - value += SPACE - } - - if (!property.startsWith('--')) { - property = property.toLowerCase() - } - return property + COLON + OPTIONAL_SPACE + value + important - } - - function print_nth(node: CSSNode): string { + function print_nth(node: CSSNode, optional_space = SPACE): string { let a = node.nth_a let b = node.nth_b - let result = a ? `${a}` : EMPTY_STRING + let result = a ? a : EMPTY_STRING if (b) { if (a) { - result += OPTIONAL_SPACE - if (!b.startsWith('-')) result += '+' + OPTIONAL_SPACE + result += optional_space + if (!b.startsWith('-')) result += '+' + optional_space } - result += parseFloat(b) + result += b } return result } - function print_nth_of(node: CSSNode): string { + function print_nth_of(node: CSSNode, optional_space = SPACE): string { let result = EMPTY_STRING if (node.children[0]?.type === NODE.NTH_SELECTOR) { - result = print_nth(node.children[0]) + SPACE + 'of' + SPACE + result = print_nth(node.children[0], optional_space) + SPACE + 'of' + SPACE } if (node.children[1]?.type === NODE.SELECTOR_LIST) { result += print_inline_selector_list(node.children[1]) @@ -210,7 +210,11 @@ export function format( return result } - function print_simple_selector(node: CSSNode, is_first: boolean = false): string { + function print_simple_selector( + node: CSSNode, + optional_space = SPACE, + is_first: boolean = false, + ): string { let name = node.name ?? '' switch (node.type) { @@ -224,8 +228,8 @@ export function format( return SPACE } // Skip leading space if this is the first node in the selector - let leading_space = is_first ? EMPTY_STRING : OPTIONAL_SPACE - return leading_space + text + OPTIONAL_SPACE + let leading_space = is_first ? EMPTY_STRING : optional_space + return leading_space + text + optional_space } case NODE.PSEUDO_ELEMENT_SELECTOR: @@ -244,7 +248,7 @@ export function format( parts.push(OPEN_PARENTHESES) if (node.children.length > 0) { if (name === 'highlight') { - parts.push(print_list(node.children)) + parts.push(print_list(node.children, optional_space)) } else { parts.push(print_inline_selector_list(node)) } @@ -279,14 +283,14 @@ export function format( } } - function print_selector(node: CSSNode): string { + function print_selector(node: CSSNode, optional_space = SPACE): string { // Handle special selector types if (node.type === NODE.NTH_SELECTOR) { - return print_nth(node) + return print_nth(node, optional_space) } if (node.type === NODE.NTH_OF_SELECTOR) { - return print_nth_of(node) + return print_nth_of(node, optional_space) } if (node.type === NODE.SELECTOR_LIST) { @@ -298,13 +302,15 @@ export function format( } // Handle compound selector (combination of simple selectors) - return node.children.map((child, i) => print_simple_selector(child, i === 0)).join(EMPTY_STRING) + return node.children + .map((child, i) => print_simple_selector(child, optional_space, i === 0)) + .join(EMPTY_STRING) } function print_inline_selector_list(node: CSSNode): string { let parts = [] for (let selector of node) { - parts.push(print_selector(selector)) + parts.push(print_selector(selector, OPTIONAL_SPACE)) if (selector.has_next) { parts.push(COMMA, OPTIONAL_SPACE) } @@ -367,7 +373,7 @@ export function format( let is_last = child.next_sibling?.type !== NODE.DECLARATION if (child.type === NODE.DECLARATION) { - let declaration = print_declaration(child) + let declaration = print_declaration(child, OPTIONAL_SPACE) let semi = is_last ? LAST_SEMICOLON : SEMICOLON lines.push(indent(depth) + declaration + semi) } else if (child.type === NODE.STYLE_RULE) { diff --git a/test/selectors.test.ts b/test/selectors.test.ts index d172f47..d5cb6c3 100644 --- a/test/selectors.test.ts +++ b/test/selectors.test.ts @@ -143,6 +143,19 @@ test.each([ expect(actual).toEqual(expected) }) +test.each([ + [`li:nth-child(3n-2) {}`, `li:nth-child(3n-2) {}`], + [`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) {}`], + [`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) + expect(actual).toEqual(expected) +}) + test('formats multiline selectors', () => { let actual = format(` a:is( From 3dc1d5868fb666f3678f2112130ca1d48b0984d0 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Mon, 30 Mar 2026 20:36:36 +0200 Subject: [PATCH 2/3] finish up --- src/lib/index.ts | 358 ++++++++++++++++++++++------------------- test/api.test.ts | 130 ++++++++++++++- test/selectors.test.ts | 13 -- 3 files changed, 316 insertions(+), 185 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index 454989f..769282d 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -19,6 +19,13 @@ const OPEN_BRACE = '{' const CLOSE_BRACE = '}' const COMMA = ',' +export type FormatOptions = { + /** Whether to minify the CSS or keep it formatted */ + minify?: boolean + /** Tell the formatter to use N spaces instead of tabs */ + tab_size?: number +} + export function unquote(str: string): string { return str.replace(/(?:^['"])|(?:['"]$)/g, EMPTY_STRING) } @@ -81,12 +88,22 @@ function print_list(nodes: CSSNode[], optional_space = SPACE): string { return parts.join(EMPTY_STRING) } -export function print_value(nodes: CSSNode[] | null, optional_space = SPACE): string { +export function format_value( + nodes: CSSNode[] | null, + { minify = false }: Pick = {}, +): string { + let optional_space = minify ? EMPTY_STRING : SPACE + if (nodes === null) return EMPTY_STRING return print_list(nodes, optional_space) } -export function print_declaration(node: CSSNode, optional_space = SPACE): string { +export function format_declaration( + node: CSSNode, + { minify = false }: Pick = {}, +): string { + let optional_space = minify ? EMPTY_STRING : SPACE + let important = EMPTY_STRING if (node.is_important) { let text = node.text @@ -94,7 +111,7 @@ export function print_declaration(node: CSSNode, optional_space = SPACE): string important = optional_space + text.slice(start, text.endsWith(SEMICOLON) ? -1 : undefined).toLowerCase() } - let value = print_value(node.value as CSSNode[] | null, optional_space) + let value = format_value(node.value as CSSNode[] | null, { minify }) let property = node.property! // Special case for `font` shorthand: remove whitespace around / @@ -113,11 +130,172 @@ export function print_declaration(node: CSSNode, optional_space = SPACE): string return property + COLON + optional_space + value + important } -export type FormatOptions = { - /** Whether to minify the CSS or keep it formatted */ - minify?: boolean - /** Tell the formatter to use N spaces instead of tabs */ - tab_size?: number +function print_nth(node: CSSNode, optional_space = SPACE): string { + let a = node.nth_a + let b = node.nth_b + let result = a ? a : EMPTY_STRING + if (b) { + if (a) { + result += optional_space + if (!b.startsWith('-')) result += '+' + optional_space + } + // the parseFloat removes the leading '+', if present + result += parseFloat(b) + } + return result +} + +function print_nth_of(node: CSSNode, optional_space = SPACE): string { + let result = EMPTY_STRING + if (node.children[0]?.type === NODE.NTH_SELECTOR) { + result = print_nth(node.children[0], optional_space) + SPACE + 'of' + SPACE + } + if (node.children[1]?.type === NODE.SELECTOR_LIST) { + result += print_inline_selector_list(node.children[1], optional_space) + } + return result +} + +function print_simple_selector( + node: CSSNode, + optional_space = SPACE, + is_first: boolean = false, +): string { + let name = node.name ?? '' + + switch (node.type) { + case NODE.TYPE_SELECTOR: { + return name.toLowerCase() ?? '' + } + + case NODE.COMBINATOR: { + let text = node.text + if (/^\s+$/.test(text)) { + return SPACE + } + // Skip leading space if this is the first node in the selector + let leading_space = is_first ? EMPTY_STRING : optional_space + return leading_space + text + optional_space + } + + case NODE.PSEUDO_ELEMENT_SELECTOR: + case NODE.PSEUDO_CLASS_SELECTOR: { + let parts = [COLON] + name = name.toLowerCase() + + // Legacy pseudo-elements or actual pseudo-elements use double colon + if (name === 'before' || name === 'after' || node.type === NODE.PSEUDO_ELEMENT_SELECTOR) { + parts.push(COLON) + } + + parts.push(name) + + if (node.has_children) { + parts.push(OPEN_PARENTHESES) + if (node.children.length > 0) { + if (name === 'highlight') { + parts.push(print_list(node.children, optional_space)) + } else { + parts.push(print_inline_selector_list(node, optional_space)) + } + } + parts.push(CLOSE_PARENTHESES) + } + + return parts.join(EMPTY_STRING) + } + + case NODE.ATTRIBUTE_SELECTOR: { + let parts = [OPEN_BRACKET, name.toLowerCase()] + + if (node.attr_operator) { + parts.push(ATTR_OPERATOR_NAMES[node.attr_operator] ?? '') + if (typeof node.value === 'string') { + parts.push(print_string(node.value)) + } + + if (node.attr_flags) { + parts.push(SPACE, ATTR_FLAG_NAMES[node.attr_flags] ?? '') + } + } + + parts.push(CLOSE_BRACKET) + return parts.join(EMPTY_STRING) + } + + default: { + return node.text + } + } +} + +function print_inline_selector_list(node: CSSNode, optional_space = SPACE): string { + let parts = [] + for (let selector of node) { + parts.push(format_selector(selector, { minify: optional_space === EMPTY_STRING })) + if (selector.has_next) { + parts.push(COMMA, optional_space) + } + } + return parts.join(EMPTY_STRING) +} + +export function format_selector( + node: CSSNode, + { minify = false }: Pick = {}, +): string { + let optional_space = minify ? EMPTY_STRING : SPACE + + // Handle special selector types + if (node.type === NODE.NTH_SELECTOR) { + return print_nth(node, optional_space) + } + + if (node.type === NODE.NTH_OF_SELECTOR) { + return print_nth_of(node, optional_space) + } + + if (node.type === NODE.SELECTOR_LIST) { + return print_inline_selector_list(node, optional_space) + } + + if (node.type === NODE.LANG_SELECTOR) { + return print_string(node.text) + } + + // Handle compound selector (combination of simple selectors) + return node.children + .map((child, i) => print_simple_selector(child, optional_space, i === 0)) + .join(EMPTY_STRING) +} + +/** + * Pretty-printing atrule preludes takes an insane amount of rules, + * so we're opting for a couple of 'good-enough' string replacements + * here to force some nice formatting. + * Should be OK perf-wise, since the amount of atrules in most + * stylesheets are limited, so this won't be called too often. + */ +function print_atrule_prelude( + prelude: string, + { minify = false }: Pick = {}, +): string { + let optional_space = minify ? EMPTY_STRING : SPACE + return prelude + .replace(/\s*([:,])/g, prelude.toLowerCase().includes('selector(') ? '$1' : '$1 ') // force whitespace after colon or comma, except inside `selector()` + .replace(/\)([a-zA-Z])/g, ') $1') // force whitespace between closing parenthesis and following text (usually and|or) + .replace(/\s*(=>|>=|<=)\s*/g, `${optional_space}$1${optional_space}`) // add optional spacing around =>, >= and <= + .replace(/([^<>=\s])([<>])([^<>=\s])/g, `$1${optional_space}$2${optional_space}$3`) // add spacing around < or > except when it's part of <=, >=, => + .replace(/\s+/g, optional_space) // collapse multiple whitespaces into one + .replace( + /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()})` + }, + ) + .replace(/selector|url|supports|layer\(/gi, (match) => match.toLowerCase()) // lowercase function names } /** @@ -185,139 +363,6 @@ export function format( return buffer } - function print_nth(node: CSSNode, optional_space = SPACE): string { - let a = node.nth_a - let b = node.nth_b - let result = a ? a : EMPTY_STRING - if (b) { - if (a) { - result += optional_space - if (!b.startsWith('-')) result += '+' + optional_space - } - result += b - } - return result - } - - function print_nth_of(node: CSSNode, optional_space = SPACE): string { - let result = EMPTY_STRING - if (node.children[0]?.type === NODE.NTH_SELECTOR) { - result = print_nth(node.children[0], optional_space) + SPACE + 'of' + SPACE - } - if (node.children[1]?.type === NODE.SELECTOR_LIST) { - result += print_inline_selector_list(node.children[1]) - } - return result - } - - function print_simple_selector( - node: CSSNode, - optional_space = SPACE, - is_first: boolean = false, - ): string { - let name = node.name ?? '' - - switch (node.type) { - case NODE.TYPE_SELECTOR: { - return name.toLowerCase() ?? '' - } - - case NODE.COMBINATOR: { - let text = node.text - if (/^\s+$/.test(text)) { - return SPACE - } - // Skip leading space if this is the first node in the selector - let leading_space = is_first ? EMPTY_STRING : optional_space - return leading_space + text + optional_space - } - - case NODE.PSEUDO_ELEMENT_SELECTOR: - case NODE.PSEUDO_CLASS_SELECTOR: { - let parts = [COLON] - name = name.toLowerCase() - - // Legacy pseudo-elements or actual pseudo-elements use double colon - if (name === 'before' || name === 'after' || node.type === NODE.PSEUDO_ELEMENT_SELECTOR) { - parts.push(COLON) - } - - parts.push(name) - - if (node.has_children) { - parts.push(OPEN_PARENTHESES) - if (node.children.length > 0) { - if (name === 'highlight') { - parts.push(print_list(node.children, optional_space)) - } else { - parts.push(print_inline_selector_list(node)) - } - } - parts.push(CLOSE_PARENTHESES) - } - - return parts.join(EMPTY_STRING) - } - - case NODE.ATTRIBUTE_SELECTOR: { - let parts = [OPEN_BRACKET, name.toLowerCase()] - - if (node.attr_operator) { - parts.push(ATTR_OPERATOR_NAMES[node.attr_operator] ?? '') - if (typeof node.value === 'string') { - parts.push(print_string(node.value)) - } - - if (node.attr_flags) { - parts.push(SPACE, ATTR_FLAG_NAMES[node.attr_flags] ?? '') - } - } - - parts.push(CLOSE_BRACKET) - return parts.join(EMPTY_STRING) - } - - default: { - return node.text - } - } - } - - function print_selector(node: CSSNode, optional_space = SPACE): string { - // Handle special selector types - if (node.type === NODE.NTH_SELECTOR) { - return print_nth(node, optional_space) - } - - if (node.type === NODE.NTH_OF_SELECTOR) { - return print_nth_of(node, optional_space) - } - - if (node.type === NODE.SELECTOR_LIST) { - return print_inline_selector_list(node) - } - - if (node.type === NODE.LANG_SELECTOR) { - return print_string(node.text) - } - - // Handle compound selector (combination of simple selectors) - return node.children - .map((child, i) => print_simple_selector(child, optional_space, i === 0)) - .join(EMPTY_STRING) - } - - function print_inline_selector_list(node: CSSNode): string { - let parts = [] - for (let selector of node) { - parts.push(print_selector(selector, OPTIONAL_SPACE)) - if (selector.has_next) { - parts.push(COMMA, OPTIONAL_SPACE) - } - } - return parts.join(EMPTY_STRING) - } - function print_selector_list(node: CSSNode): string { let lines = [] let prev_end: number | undefined @@ -329,7 +374,7 @@ export function format( } } - let printed = print_selector(selector) + let printed = format_selector(selector, { minify }) if (selector.has_next) { printed += COMMA } @@ -373,7 +418,7 @@ export function format( let is_last = child.next_sibling?.type !== NODE.DECLARATION if (child.type === NODE.DECLARATION) { - let declaration = print_declaration(child, OPTIONAL_SPACE) + let declaration = format_declaration(child, { minify }) let semi = is_last ? LAST_SEMICOLON : SEMICOLON lines.push(indent(depth) + declaration + semi) } else if (child.type === NODE.STYLE_RULE) { @@ -428,35 +473,10 @@ export function format( return lines.join(NEWLINE) } - /** - * Pretty-printing atrule preludes takes an insane amount of rules, - * so we're opting for a couple of 'good-enough' string replacements - * here to force some nice formatting. - * Should be OK perf-wise, since the amount of atrules in most - * stylesheets are limited, so this won't be called too often. - */ - function print_atrule_prelude(prelude: string): string { - return prelude - .replace(/\s*([:,])/g, prelude.toLowerCase().includes('selector(') ? '$1' : '$1 ') // force whitespace after colon or comma, except inside `selector()` - .replace(/\)([a-zA-Z])/g, ') $1') // force whitespace between closing parenthesis and following text (usually and|or) - .replace(/\s*(=>|>=|<=)\s*/g, `${OPTIONAL_SPACE}$1${OPTIONAL_SPACE}`) // add optional spacing around =>, >= and <= - .replace(/([^<>=\s])([<>])([^<>=\s])/g, `$1${OPTIONAL_SPACE}$2${OPTIONAL_SPACE}$3`) // add spacing around < or > except when it's part of <=, >=, => - .replace(/\s+/g, OPTIONAL_SPACE) // collapse multiple whitespaces into one - .replace( - /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()})` - }, - ) - .replace(/selector|url|supports|layer\(/gi, (match) => match.toLowerCase()) // lowercase function names - } - function print_atrule(node: CSSNode): string { let name = '@' + node.name!.toLowerCase() if (node.prelude) { - name += SPACE + print_atrule_prelude(node.prelude.text) + name += SPACE + print_atrule_prelude(node.prelude.text, { minify }) } let block_has_content = diff --git a/test/api.test.ts b/test/api.test.ts index b6629e0..c3faa2c 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -1,5 +1,13 @@ -import { test, expect } from 'vitest' -import { format } from '../src/lib/index.js' +import { test, expect, describe } from 'vitest' +import { parse_selector, parse_declaration, parse_value } from '@projectwallace/css-parser' +import { + format, + minify, + format_selector, + format_declaration, + format_value, + unquote, +} from '../src/lib/index.js' test('empty input', () => { let actual = format(``) @@ -41,7 +49,7 @@ test('Vadim Makeevs example works', () => { expect(actual).toEqual(expected) }) -test('minified Vadims example', () => { +test('format minified Vadims example', () => { let actual = format( `@layer what{@container (width>0){@media (min-height:.001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}`, ) @@ -57,3 +65,119 @@ test('minified Vadims example', () => { }` expect(actual).toEqual(expected) }) + +test('format minified Vadims example', () => { + let input = `@layer what{@container (width>0){@media (min-height:.001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}` + let actual = minify(input) + expect(actual).toEqual(input) +}) + +describe('format_selector', () => { + test('type selector', () => { + let node = parse_selector('div').children[0]! + expect(format_selector(node)).toBe('div') + }) + + test('class selector', () => { + let node = parse_selector('.foo').children[0]! + expect(format_selector(node)).toBe('.foo') + }) + + test('combinator keeps spaces by default', () => { + let node = parse_selector('div > span').children[0]! + expect(format_selector(node)).toBe('div > span') + }) + + test('combinator removes spaces when minified', () => { + let node = parse_selector('div > span').children[0]! + expect(format_selector(node, { minify: true })).toBe('div>span') + }) + + test('selector list', () => { + let node = parse_selector('div, span') + expect(format_selector(node)).toBe('div, span') + }) + + test('selector list minified', () => { + let node = parse_selector('div, span') + expect(format_selector(node, { minify: true })).toBe('div,span') + }) + + test('pseudo-class', () => { + let node = parse_selector('a:hover').children[0]! + expect(format_selector(node)).toBe('a:hover') + }) +}) + +describe('format_declaration', () => { + test('basic property and value', () => { + let node = parse_declaration('color: red') + expect(format_declaration(node)).toBe('color: red') + }) + + test('minified removes space after colon', () => { + let node = parse_declaration('color: red') + expect(format_declaration(node, { minify: true })).toBe('color:red') + }) + + test('uppercased property is lowercased', () => { + let node = parse_declaration('COLOR: red') + expect(format_declaration(node)).toBe('color: red') + }) + + test('custom property preserves casing', () => { + let node = parse_declaration('--myVar: 1') + expect(format_declaration(node)).toBe('--myVar: 1') + }) + + test('!important', () => { + let node = parse_declaration('color: red !IMPORTANT') + expect(format_declaration(node)).toBe('color: red !important') + }) + + test('!important minified', () => { + let node = parse_declaration('color: red !important') + expect(format_declaration(node, { minify: true })).toBe('color:red!important') + }) +}) + +describe('format_value', () => { + test('null returns empty string', () => { + expect(format_value(null)).toBe('') + }) + + test('simple keyword', () => { + let { children } = parse_value('red') + expect(format_value(children)).toBe('red') + }) + + test('calc with + always keeps spaces', () => { + let { children } = parse_value('calc(1px + 2px)') + expect(format_value(children)).toBe('calc(1px + 2px)') + expect(format_value(children, { minify: true })).toBe('calc(1px + 2px)') + }) + + test('calc with * uses optional space', () => { + let { children } = parse_value('calc(1px * 2)') + expect(format_value(children)).toBe('calc(1px * 2)') + expect(format_value(children, { minify: true })).toBe('calc(1px*2)') + }) +}) + +describe('unquote', () => { + test('removes double quotes', () => { + expect(unquote('"hello"')).toBe('hello') + }) + + test('removes single quotes', () => { + expect(unquote("'hello'")).toBe('hello') + }) + + test('no-op when no quotes', () => { + expect(unquote('bare')).toBe('bare') + }) + + test('removes only surrounding quotes, not inner ones', () => { + expect(unquote('"it\'s"')).toBe("it's") + }) +}) diff --git a/test/selectors.test.ts b/test/selectors.test.ts index d5cb6c3..d172f47 100644 --- a/test/selectors.test.ts +++ b/test/selectors.test.ts @@ -143,19 +143,6 @@ test.each([ expect(actual).toEqual(expected) }) -test.each([ - [`li:nth-child(3n-2) {}`, `li:nth-child(3n-2) {}`], - [`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) {}`], - [`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) - expect(actual).toEqual(expected) -}) - test('formats multiline selectors', () => { let actual = format(` a:is( From fab9be23cbd9652245a292aad980591d55ab2ec3 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Mon, 30 Mar 2026 20:39:33 +0200 Subject: [PATCH 3/3] export format_atrule_prelude --- src/lib/index.ts | 4 ++-- test/api.test.ts | 54 ++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/lib/index.ts b/src/lib/index.ts index 769282d..fa491c9 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -276,7 +276,7 @@ export function format_selector( * Should be OK perf-wise, since the amount of atrules in most * stylesheets are limited, so this won't be called too often. */ -function print_atrule_prelude( +export function format_atrule_prelude( prelude: string, { minify = false }: Pick = {}, ): string { @@ -476,7 +476,7 @@ export function format( function print_atrule(node: CSSNode): string { let name = '@' + node.name!.toLowerCase() if (node.prelude) { - name += SPACE + print_atrule_prelude(node.prelude.text, { minify }) + name += SPACE + format_atrule_prelude(node.prelude.text, { minify }) } let block_has_content = diff --git a/test/api.test.ts b/test/api.test.ts index c3faa2c..1d08f9e 100644 --- a/test/api.test.ts +++ b/test/api.test.ts @@ -3,6 +3,7 @@ import { parse_selector, parse_declaration, parse_value } from '@projectwallace/ import { format, minify, + format_atrule_prelude, format_selector, format_declaration, format_value, @@ -72,6 +73,59 @@ test('format minified Vadims example', () => { expect(actual).toEqual(input) }) +describe('format_atrule_prelude', () => { + test('adds space after colon', () => { + expect(format_atrule_prelude('(min-height:.001px)')).toBe('(min-height: .001px)') + }) + + test('adds space after comma', () => { + expect(format_atrule_prelude('screen,print')).toBe('screen, print') + }) + + test('does not add space after colon inside selector()', () => { + expect(format_atrule_prelude('selector(:hover)')).toBe('selector(:hover)') + }) + + test('adds space around > comparison operator', () => { + expect(format_atrule_prelude('(width>0)')).toBe('(width > 0)') + }) + + test('adds space around >= operator', () => { + expect(format_atrule_prelude('(width>=300px)')).toBe('(width >= 300px)') + }) + + test('removes space around >= when minified', () => { + expect(format_atrule_prelude('(width >= 300px)', { minify: true })).toBe('(width>=300px)') + }) + + test('collapses multiple spaces', () => { + expect(format_atrule_prelude('screen and print')).toBe('screen and print') + }) + + test('collapses all whitespace when minified', () => { + expect(format_atrule_prelude('screen and print', { minify: true })).toBe('screenandprint') + }) + + test('adds space between ) and following word', () => { + expect(format_atrule_prelude('(width > 0)and(height > 0)')).toBe('(width > 0) and(height > 0)') + }) + + test('lowercases function names', () => { + expect(format_atrule_prelude('LAYER(default)')).toBe('layer(default)') + expect(format_atrule_prelude('SUPPORTS(display: grid)')).toBe('supports(display: grid)') + }) + + test('calc with + always keeps spaces', () => { + expect(format_atrule_prelude('calc(1px+2px)')).toBe('calc(1px + 2px)') + expect(format_atrule_prelude('calc(1px+2px)', { minify: true })).toBe('calc(1px + 2px)') + }) + + test('calc with * uses optional space', () => { + expect(format_atrule_prelude('calc(1px*2)')).toBe('calc(1px * 2)') + expect(format_atrule_prelude('calc(1px*2)', { minify: true })).toBe('calc(1px*2)') + }) +}) + describe('format_selector', () => { test('type selector', () => { let node = parse_selector('div').children[0]!