From afa6b376f539c5a810b83a32a456eddf10f33e3d Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sat, 25 Oct 2025 23:37:34 +0200 Subject: [PATCH 1/6] feat: fix nested css by swapping css-tree for @eslint/css-tree --- index.ts | 1183 +++++++++++++++++++++++--------------------- package-lock.json | 52 +- package.json | 6 +- test/rules.test.ts | 57 ++- 4 files changed, 687 insertions(+), 611 deletions(-) diff --git a/index.ts b/index.ts index eebde46..0306846 100644 --- a/index.ts +++ b/index.ts @@ -1,580 +1,627 @@ import { - parse, - type CssNode, - type List, - type CssLocation, - type Raw, - type StyleSheet, - type Atrule, - type AtrulePrelude, - type Rule, - type SelectorList, - type Selector, - type PseudoClassSelector, - type PseudoElementSelector, - type Block, - type Declaration, - type Value, - type Operator, -} from 'css-tree' - -const SPACE = ' ' -const EMPTY_STRING = '' -const COLON = ':' -const SEMICOLON = ';' -const QUOTE = '"' -const OPEN_PARENTHESES = '(' -const CLOSE_PARENTHESES = ')' -const OPEN_BRACKET = '[' -const CLOSE_BRACKET = ']' -const OPEN_BRACE = '{' -const CLOSE_BRACE = '}' -const EMPTY_BLOCK = '{}' -const COMMA = ',' -const TYPE_ATRULE = 'Atrule' -const TYPE_RULE = 'Rule' -const TYPE_BLOCK = 'Block' -const TYPE_SELECTORLIST = 'SelectorList' -const TYPE_SELECTOR = 'Selector' -const TYPE_PSEUDO_ELEMENT_SELECTOR = 'PseudoElementSelector' -const TYPE_DECLARATION = 'Declaration' -const TYPE_OPERATOR = 'Operator' + parse, + type CssNode, + type List, + type CssLocation, + type Raw, + type StyleSheet, + type Atrule, + type AtrulePrelude, + type Rule, + type SelectorList, + type Selector, + type PseudoClassSelector, + type PseudoElementSelector, + type Block, + type Declaration, + type Value, + type Operator, +} from "@eslint/css-tree"; + +const SPACE = " "; +const EMPTY_STRING = ""; +const COLON = ":"; +const SEMICOLON = ";"; +const QUOTE = '"'; +const OPEN_PARENTHESES = "("; +const CLOSE_PARENTHESES = ")"; +const OPEN_BRACKET = "["; +const CLOSE_BRACKET = "]"; +const OPEN_BRACE = "{"; +const CLOSE_BRACE = "}"; +const EMPTY_BLOCK = "{}"; +const COMMA = ","; +const TYPE_ATRULE = "Atrule"; +const TYPE_RULE = "Rule"; +const TYPE_BLOCK = "Block"; +const TYPE_SELECTORLIST = "SelectorList"; +const TYPE_SELECTOR = "Selector"; +const TYPE_PSEUDO_ELEMENT_SELECTOR = "PseudoElementSelector"; +const TYPE_DECLARATION = "Declaration"; +const TYPE_OPERATOR = "Operator"; function lowercase(str: string) { - // Only create new strings in memory if we need to - if (/[A-Z]/.test(str)) { - return str.toLowerCase() - } - return str + // Only create new strings in memory if we need to + if (/[A-Z]/.test(str)) { + return str.toLowerCase(); + } + return str; } 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 -} + /** Whether to minify the CSS or keep it formatted */ + minify?: boolean; + /** Tell the formatter to use N spaces instead of tabs */ + tab_size?: number; +}; /** * Format a string of CSS using some simple rules */ -export function format(css: string, { minify = false, tab_size = undefined }: FormatOptions = Object.create(null)): string { - if (tab_size !== undefined && Number(tab_size) < 1) { - throw new TypeError('tab_size must be a number greater than 0') - } - - /** [start0, end0, start1, end1, etc.]*/ - let comments: number[] = [] - - function on_comment(_: string, position: CssLocation) { - comments.push(position.start.offset, position.end.offset) - } - - let ast = parse(css, { - positions: true, - parseAtrulePrelude: false, - parseCustomProperty: true, - parseValue: true, - onComment: on_comment, - }) as StyleSheet - - const NEWLINE = minify ? EMPTY_STRING : '\n' - const OPTIONAL_SPACE = minify ? EMPTY_STRING : SPACE - const LAST_SEMICOLON = minify ? EMPTY_STRING : SEMICOLON - - let indent_level = 0 - - function indent(size: number) { - if (minify === true) return EMPTY_STRING - - if (tab_size !== undefined) { - return SPACE.repeat(tab_size * size) - } - - return '\t'.repeat(size) - } - - function substr(node: CssNode) { - let loc = node.loc - // If the node has no location, return an empty string - // This is necessary for space toggles - if (loc === undefined || loc === null) return EMPTY_STRING - return css.slice(loc.start.offset, loc.end.offset) - } - - function start_offset(node: CssNode) { - return node.loc?.start.offset - } - - function end_offset(node: CssNode) { - return node.loc?.end.offset - } - - /** - * Get a comment from the CSS string after the first offset and before the second offset - * @param after After which offset to look for comments - * @param before Before which offset to look for comments - * @returns The comment string, if found - */ - function print_comment(after?: number, before?: number): string | undefined { - if (minify === true || after === undefined || before === undefined) { - return EMPTY_STRING - } - - let buffer = EMPTY_STRING - for (let i = 0; i < comments.length; i += 2) { - // Check that the comment is within the range - let start = comments[i] - if (start === undefined || start < after) continue - let end = comments[i + 1] - if (end === undefined || end > before) break - - // Special case for comments that follow another comment: - if (buffer.length > 0) { - buffer += NEWLINE + indent(indent_level) - } - buffer += css.slice(start, end) - } - return buffer - } - - function print_rule(node: Rule) { - let buffer = '' - let prelude = node.prelude - let block = node.block - - if (prelude.type === TYPE_SELECTORLIST) { - buffer = print_selectorlist(prelude) - } - - let comment = print_comment(end_offset(prelude), start_offset(block)) - if (comment) { - buffer += NEWLINE + indent(indent_level) + comment - } - - if (block.type === TYPE_BLOCK) { - buffer += print_block(block) - } - - return buffer - } - - function print_selectorlist(node: SelectorList) { - let buffer = EMPTY_STRING - - node.children.forEach((selector, item) => { - if (selector.type === TYPE_SELECTOR) { - buffer += indent(indent_level) + print_simple_selector(selector) - } - - if (item.next !== null) { - buffer += COMMA + NEWLINE - } - - let end = item.next !== null ? start_offset(item.next.data) : end_offset(node) - let comment = print_comment(end_offset(selector), end) - if (comment) { - buffer += indent(indent_level) + comment + NEWLINE - } - }) - - return buffer - } - - function print_simple_selector(node: Selector | PseudoClassSelector | PseudoElementSelector) { - let buffer = EMPTY_STRING - let children = node.children - - children?.forEach((child) => { - switch (child.type) { - case 'TypeSelector': { - buffer += lowercase(child.name) - break - } - case 'Combinator': { - // putting spaces around `child.name` (+ > ~ or ' '), unless the combinator is ' ' - buffer += SPACE - - if (child.name !== ' ') { - buffer += child.name + SPACE - } - break - } - case 'PseudoClassSelector': - case TYPE_PSEUDO_ELEMENT_SELECTOR: { - buffer += COLON - - // Special case for `:before` and `:after` which were used in CSS2 and are usually minified - // as `:before` and `:after`, but we want to print them as `::before` and `::after` - let pseudo = lowercase(child.name) - - if (pseudo === 'before' || pseudo === 'after' || child.type === TYPE_PSEUDO_ELEMENT_SELECTOR) { - buffer += COLON - } - - buffer += pseudo - - if (child.children !== null) { - buffer += OPEN_PARENTHESES + print_simple_selector(child) + CLOSE_PARENTHESES - } - break - } - case TYPE_SELECTORLIST: { - child.children.forEach((selector_list_item, item) => { - if (selector_list_item.type === TYPE_SELECTOR) { - buffer += print_simple_selector(selector_list_item) - } - - if (item.next !== null && item.next.data.type === TYPE_SELECTOR) { - buffer += COMMA + OPTIONAL_SPACE - } - }) - break - } - case 'Nth': { - let nth = child.nth - if (nth.type === 'AnPlusB') { - let a = nth.a - let b = nth.b - - if (a !== null) { - buffer += a + 'n' - } - - if (a !== null && b !== null) { - buffer += SPACE - } - - if (b !== null) { - // When (1n + x) but not (1n - x) - if (a !== null && !b.startsWith('-')) { - buffer += '+' + SPACE - } - - buffer += b - } - } else { - // For odd/even or maybe other identifiers later on - buffer += substr(nth) - } - - if (child.selector !== null) { - // `of .selector` - // @ts-expect-error Typing of child.selector is SelectorList, which doesn't seem to be correct - buffer += SPACE + 'of' + SPACE + print_simple_selector(child.selector) - } - break - } - case 'AttributeSelector': { - buffer += OPEN_BRACKET - buffer += child.name.name - - if (child.matcher !== null && child.value !== null) { - buffer += child.matcher - buffer += QUOTE - - if (child.value.type === 'String') { - buffer += child.value.value - } else if (child.value.type === 'Identifier') { - buffer += child.value.name - } - buffer += QUOTE - } - - if (child.flags !== null) { - buffer += SPACE + child.flags - } - - buffer += CLOSE_BRACKET - break - } - case 'NestingSelector': { - buffer += '&' - break - } - default: { - buffer += substr(child) - break - } - } - }) - - return buffer - } - - function print_block(node: Block) { - let children = node.children - let buffer = OPTIONAL_SPACE - - if (children.isEmpty) { - // Check if the block maybe contains comments - let comment = print_comment(start_offset(node), end_offset(node)) - if (comment) { - buffer += OPEN_BRACE + NEWLINE - buffer += indent(indent_level + 1) + comment - buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE - return buffer - } - return buffer + EMPTY_BLOCK - } - - buffer += OPEN_BRACE + NEWLINE - - indent_level++ - - let opening_comment = print_comment(start_offset(node), start_offset(children.first!)) - if (opening_comment) { - buffer += indent(indent_level) + opening_comment + NEWLINE - } - - children.forEach((child, item) => { - if (item.prev !== null) { - let comment = print_comment(end_offset(item.prev.data), start_offset(child)) - if (comment) { - buffer += indent(indent_level) + comment + NEWLINE - } - } - - if (child.type === TYPE_DECLARATION) { - buffer += print_declaration(child) - - if (item.next === null) { - buffer += LAST_SEMICOLON - } else { - buffer += SEMICOLON - } - } else { - if (item.prev !== null && item.prev.data.type === TYPE_DECLARATION) { - buffer += NEWLINE - } - - if (child.type === TYPE_RULE) { - buffer += print_rule(child) - } else if (child.type === TYPE_ATRULE) { - buffer += print_atrule(child) - } else { - buffer += print_unknown(child, indent_level) - } - } - - if (item.next !== null) { - buffer += NEWLINE - - if (child.type !== TYPE_DECLARATION) { - buffer += NEWLINE - } - } - }) - - let closing_comment = print_comment(end_offset(children.last!), end_offset(node)) - if (closing_comment) { - buffer += NEWLINE + indent(indent_level) + closing_comment - } - - indent_level-- - buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE - - return buffer - } - - function print_atrule(node: Atrule) { - let buffer = indent(indent_level) + '@' - let prelude = node.prelude - let block = node.block - buffer += lowercase(node.name) - - // @font-face and anonymous @layer have no prelude - if (prelude !== null) { - buffer += SPACE + print_prelude(prelude) - } - - if (block === null) { - // `@import url(style.css);` has no block, neither does `@layer layer1;` - buffer += SEMICOLON - } else if (block.type === TYPE_BLOCK) { - buffer += print_block(block) - } - - return buffer - } - - /** - * 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_prelude(node: AtrulePrelude | Raw) { - let buffer = substr(node) - - return buffer - .replace(/\s*([:,])/g, buffer.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, ' $1 ') // force whitespace 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) => lowercase(match)) // lowercase function names - } - - function print_declaration(node: Declaration) { - let property = node.property - - // Lowercase the property, unless it's a custom property (starts with --) - if (!(property.charCodeAt(0) === 45 && property.charCodeAt(1) === 45)) { - // 45 == '-' - property = lowercase(property) - } - - let value = print_value(node.value) - - // 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 (node.important === true) { - value += OPTIONAL_SPACE + '!important' - } else if (typeof node.important === 'string') { - value += OPTIONAL_SPACE + '!' + lowercase(node.important) - } - - return indent(indent_level) + property + COLON + OPTIONAL_SPACE + value - } - - function print_list(children: List) { - let buffer = EMPTY_STRING - - children.forEach((node, item) => { - if (node.type === 'Identifier') { - buffer += node.name - } else if (node.type === 'Function') { - buffer += lowercase(node.name) + OPEN_PARENTHESES + print_list(node.children) + CLOSE_PARENTHESES - } else if (node.type === 'Dimension') { - buffer += node.value + lowercase(node.unit) - } else if (node.type === 'Value') { - // Values can be inside var() as fallback - // var(--prop, VALUE) - buffer += print_value(node) - } else if (node.type === TYPE_OPERATOR) { - buffer += print_operator(node) - } else if (node.type === 'Parentheses') { - buffer += OPEN_PARENTHESES + print_list(node.children) + CLOSE_PARENTHESES - } else if (node.type === 'Url') { - buffer += 'url(' + QUOTE + node.value + QUOTE + CLOSE_PARENTHESES - } else { - buffer += substr(node) - } - - // Add space after the item coming after an operator - if (node.type !== TYPE_OPERATOR) { - if (item.next !== null) { - if (item.next.data.type !== TYPE_OPERATOR) { - buffer += SPACE - } - } - } - }) - - return buffer - } - - function print_operator(node: Operator) { - let buffer = EMPTY_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 - - // Trim the operator because CSSTree adds whitespace around it - let operator = node.value.trim() - let code = operator.charCodeAt(0) - - if (code === 43 || code === 45) { - // + or - - // Add required space before + and - operators - buffer += SPACE - } else if (code !== 44) { - // , - // Add optional space before operator - buffer += OPTIONAL_SPACE - } - - // FINALLY, render the operator - buffer += operator - - if (code === 43 || code === 45) { - // + or - - // Add required space after + and - operators - buffer += SPACE - } else { - // Add optional space after other operators (like *, /, and ,) - buffer += OPTIONAL_SPACE - } - - return buffer - } - - function print_value(node: Value | Raw) { - if (node.type === 'Raw') { - return print_unknown(node, 0) - } - - return print_list(node.children) - } - - function print_unknown(node: CssNode, indent_level: number) { - return indent(indent_level) + substr(node).trim() - } - - let children = ast.children - let buffer = EMPTY_STRING - - if (children.first !== null) { - let opening_comment = print_comment(0, start_offset(children.first)) - if (opening_comment) { - buffer += opening_comment + NEWLINE - } - - children.forEach((child, item) => { - if (child.type === TYPE_RULE) { - buffer += print_rule(child) - } else if (child.type === TYPE_ATRULE) { - buffer += print_atrule(child) - } else { - buffer += print_unknown(child, indent_level) - } - - if (item.next !== null) { - buffer += NEWLINE - - let comment = print_comment(end_offset(child), start_offset(item.next.data)) - if (comment) { - buffer += indent(indent_level) + comment - } - - buffer += NEWLINE - } - }) - - let closing_comment = print_comment(end_offset(children.last!), end_offset(ast)) - if (closing_comment) { - buffer += NEWLINE + closing_comment - } - } else { - buffer += print_comment(0, end_offset(ast)) - } - - return buffer +export function format( + css: string, + { minify = false, tab_size = undefined }: FormatOptions = Object.create(null) +): string { + if (tab_size !== undefined && Number(tab_size) < 1) { + throw new TypeError("tab_size must be a number greater than 0"); + } + + /** [start0, end0, start1, end1, etc.]*/ + let comments: number[] = []; + + function on_comment(_: string, position: CssLocation) { + comments.push(position.start.offset, position.end.offset); + } + + let ast = parse(css, { + positions: true, + parseAtrulePrelude: false, + parseCustomProperty: true, + parseValue: true, + onComment: on_comment, + }) as StyleSheet; + + const NEWLINE = minify ? EMPTY_STRING : "\n"; + const OPTIONAL_SPACE = minify ? EMPTY_STRING : SPACE; + const LAST_SEMICOLON = minify ? EMPTY_STRING : SEMICOLON; + + let indent_level = 0; + + function indent(size: number) { + if (minify === true) return EMPTY_STRING; + + if (tab_size !== undefined) { + return SPACE.repeat(tab_size * size); + } + + return "\t".repeat(size); + } + + function substr(node: CssNode) { + let loc = node.loc; + // If the node has no location, return an empty string + // This is necessary for space toggles + if (loc === undefined || loc === null) return EMPTY_STRING; + return css.slice(loc.start.offset, loc.end.offset); + } + + function start_offset(node: CssNode) { + return node.loc?.start.offset; + } + + function end_offset(node: CssNode) { + return node.loc?.end.offset; + } + + /** + * Get a comment from the CSS string after the first offset and before the second offset + * @param after After which offset to look for comments + * @param before Before which offset to look for comments + * @returns The comment string, if found + */ + function print_comment(after?: number, before?: number): string | undefined { + if (minify === true || after === undefined || before === undefined) { + return EMPTY_STRING; + } + + let buffer = EMPTY_STRING; + for (let i = 0; i < comments.length; i += 2) { + // Check that the comment is within the range + let start = comments[i]; + if (start === undefined || start < after) continue; + let end = comments[i + 1]; + if (end === undefined || end > before) break; + + // Special case for comments that follow another comment: + if (buffer.length > 0) { + buffer += NEWLINE + indent(indent_level); + } + buffer += css.slice(start, end); + } + return buffer; + } + + function print_rule(node: Rule) { + let buffer = ""; + let prelude = node.prelude; + let block = node.block; + + if (prelude.type === TYPE_SELECTORLIST) { + buffer = print_selectorlist(prelude); + } + + let comment = print_comment(end_offset(prelude), start_offset(block)); + if (comment) { + buffer += NEWLINE + indent(indent_level) + comment; + } + + if (block.type === TYPE_BLOCK) { + buffer += print_block(block); + } + + return buffer; + } + + function print_selectorlist(node: SelectorList) { + let buffer = EMPTY_STRING; + + node.children.forEach((selector, item) => { + if (selector.type === TYPE_SELECTOR) { + buffer += indent(indent_level) + print_simple_selector(selector); + } + + if (item.next !== null) { + buffer += COMMA + NEWLINE; + } + + let end = + item.next !== null ? start_offset(item.next.data) : end_offset(node); + let comment = print_comment(end_offset(selector), end); + if (comment) { + buffer += indent(indent_level) + comment + NEWLINE; + } + }); + + return buffer; + } + + function print_simple_selector( + node: Selector | PseudoClassSelector | PseudoElementSelector + ) { + let buffer = EMPTY_STRING; + let children = node.children; + + children?.forEach((child) => { + switch (child.type) { + case "TypeSelector": { + buffer += lowercase(child.name); + break; + } + case "Combinator": { + // putting spaces around `child.name` (+ > ~ or ' '), unless the combinator is ' ' + // and the combinator is not the first in a nested selectorlist + if (child !== children.first) { + buffer += SPACE; + } + + if (child.name !== " ") { + buffer += child.name + SPACE; + } + break; + } + case "PseudoClassSelector": + case TYPE_PSEUDO_ELEMENT_SELECTOR: { + buffer += COLON; + + // Special case for `:before` and `:after` which were used in CSS2 and are usually minified + // as `:before` and `:after`, but we want to print them as `::before` and `::after` + let pseudo = lowercase(child.name); + + if ( + pseudo === "before" || + pseudo === "after" || + child.type === TYPE_PSEUDO_ELEMENT_SELECTOR + ) { + buffer += COLON; + } + + buffer += pseudo; + + if (child.children !== null) { + buffer += + OPEN_PARENTHESES + + print_simple_selector(child) + + CLOSE_PARENTHESES; + } + break; + } + case TYPE_SELECTORLIST: { + child.children.forEach((selector_list_item, item) => { + if (selector_list_item.type === TYPE_SELECTOR) { + buffer += print_simple_selector(selector_list_item); + } + + if (item.next !== null && item.next.data.type === TYPE_SELECTOR) { + buffer += COMMA + OPTIONAL_SPACE; + } + }); + break; + } + case "Nth": { + let nth = child.nth; + if (nth.type === "AnPlusB") { + let a = nth.a; + let b = nth.b; + + if (a !== null) { + buffer += a + "n"; + } + + if (a !== null && b !== null) { + buffer += SPACE; + } + + if (b !== null) { + // When (1n + x) but not (1n - x) + if (a !== null && !b.startsWith("-")) { + buffer += "+" + SPACE; + } + + buffer += b; + } + } else { + // For odd/even or maybe other identifiers later on + buffer += substr(nth); + } + + if (child.selector !== null) { + // `of .selector` + // @ts-expect-error Typing of child.selector is SelectorList, which doesn't seem to be correct + buffer += + SPACE + "of" + SPACE + print_simple_selector(child.selector); + } + break; + } + case "AttributeSelector": { + buffer += OPEN_BRACKET; + buffer += child.name.name; + + if (child.matcher !== null && child.value !== null) { + buffer += child.matcher; + buffer += QUOTE; + + if (child.value.type === "String") { + buffer += child.value.value; + } else if (child.value.type === "Identifier") { + buffer += child.value.name; + } + buffer += QUOTE; + } + + if (child.flags !== null) { + buffer += SPACE + child.flags; + } + + buffer += CLOSE_BRACKET; + break; + } + case "NestingSelector": { + buffer += "&"; + break; + } + default: { + buffer += substr(child); + break; + } + } + }); + + return buffer; + } + + function print_block(node: Block) { + let children = node.children; + let buffer = OPTIONAL_SPACE; + + if (children.isEmpty) { + // Check if the block maybe contains comments + let comment = print_comment(start_offset(node), end_offset(node)); + if (comment) { + buffer += OPEN_BRACE + NEWLINE; + buffer += indent(indent_level + 1) + comment; + buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE; + return buffer; + } + return buffer + EMPTY_BLOCK; + } + + buffer += OPEN_BRACE + NEWLINE; + + indent_level++; + + let opening_comment = print_comment( + start_offset(node), + start_offset(children.first!) + ); + if (opening_comment) { + buffer += indent(indent_level) + opening_comment + NEWLINE; + } + + children.forEach((child, item) => { + if (item.prev !== null) { + let comment = print_comment( + end_offset(item.prev.data), + start_offset(child) + ); + if (comment) { + buffer += indent(indent_level) + comment + NEWLINE; + } + } + + if (child.type === TYPE_DECLARATION) { + buffer += print_declaration(child); + + if (item.next === null) { + buffer += LAST_SEMICOLON; + } else { + buffer += SEMICOLON; + } + } else { + if (item.prev !== null && item.prev.data.type === TYPE_DECLARATION) { + buffer += NEWLINE; + } + + if (child.type === TYPE_RULE) { + buffer += print_rule(child); + } else if (child.type === TYPE_ATRULE) { + buffer += print_atrule(child); + } else { + buffer += print_unknown(child, indent_level); + } + } + + if (item.next !== null) { + buffer += NEWLINE; + + if (child.type !== TYPE_DECLARATION) { + buffer += NEWLINE; + } + } + }); + + let closing_comment = print_comment( + end_offset(children.last!), + end_offset(node) + ); + if (closing_comment) { + buffer += NEWLINE + indent(indent_level) + closing_comment; + } + + indent_level--; + buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE; + + return buffer; + } + + function print_atrule(node: Atrule) { + let buffer = indent(indent_level) + "@"; + let prelude = node.prelude; + let block = node.block; + buffer += lowercase(node.name); + + // @font-face and anonymous @layer have no prelude + if (prelude !== null) { + buffer += SPACE + print_prelude(prelude); + } + + if (block === null) { + // `@import url(style.css);` has no block, neither does `@layer layer1;` + buffer += SEMICOLON; + } else if (block.type === TYPE_BLOCK) { + buffer += print_block(block); + } + + return buffer; + } + + /** + * 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_prelude(node: AtrulePrelude | Raw) { + let buffer = substr(node); + + return buffer + .replace( + /\s*([:,])/g, + buffer.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, " $1 ") // force whitespace 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) => lowercase(match)); // lowercase function names + } + + function print_declaration(node: Declaration) { + let property = node.property; + + // Lowercase the property, unless it's a custom property (starts with --) + if (!(property.charCodeAt(0) === 45 && property.charCodeAt(1) === 45)) { + // 45 == '-' + property = lowercase(property); + } + + let value = print_value(node.value); + + // 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 (node.important === true) { + value += OPTIONAL_SPACE + "!important"; + } else if (typeof node.important === "string") { + value += OPTIONAL_SPACE + "!" + lowercase(node.important); + } + + return indent(indent_level) + property + COLON + OPTIONAL_SPACE + value; + } + + function print_list(children: List) { + let buffer = EMPTY_STRING; + + children.forEach((node, item) => { + if (node.type === "Identifier") { + buffer += node.name; + } else if (node.type === "Function") { + buffer += + lowercase(node.name) + + OPEN_PARENTHESES + + print_list(node.children) + + CLOSE_PARENTHESES; + } else if (node.type === "Dimension") { + buffer += node.value + lowercase(node.unit); + } else if (node.type === "Value") { + // Values can be inside var() as fallback + // var(--prop, VALUE) + buffer += print_value(node); + } else if (node.type === TYPE_OPERATOR) { + buffer += print_operator(node); + } else if (node.type === "Parentheses") { + buffer += + OPEN_PARENTHESES + print_list(node.children) + CLOSE_PARENTHESES; + } else if (node.type === "Url") { + buffer += "url(" + QUOTE + node.value + QUOTE + CLOSE_PARENTHESES; + } else { + buffer += substr(node); + } + + // Add space after the item coming after an operator + if (node.type !== TYPE_OPERATOR) { + if (item.next !== null) { + if (item.next.data.type !== TYPE_OPERATOR) { + buffer += SPACE; + } + } + } + }); + + return buffer; + } + + function print_operator(node: Operator) { + let buffer = EMPTY_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 + + // Trim the operator because CSSTree adds whitespace around it + let operator = node.value.trim(); + let code = operator.charCodeAt(0); + + if (code === 43 || code === 45) { + // + or - + // Add required space before + and - operators + buffer += SPACE; + } else if (code !== 44) { + // , + // Add optional space before operator + buffer += OPTIONAL_SPACE; + } + + // FINALLY, render the operator + buffer += operator; + + if (code === 43 || code === 45) { + // + or - + // Add required space after + and - operators + buffer += SPACE; + } else { + // Add optional space after other operators (like *, /, and ,) + buffer += OPTIONAL_SPACE; + } + + return buffer; + } + + function print_value(node: Value | Raw) { + if (node.type === "Raw") { + return print_unknown(node, 0); + } + + return print_list(node.children); + } + + function print_unknown(node: CssNode, indent_level: number) { + return indent(indent_level) + substr(node).trim(); + } + + let children = ast.children; + let buffer = EMPTY_STRING; + + if (children.first !== null) { + let opening_comment = print_comment(0, start_offset(children.first)); + if (opening_comment) { + buffer += opening_comment + NEWLINE; + } + + children.forEach((child, item) => { + if (child.type === TYPE_RULE) { + buffer += print_rule(child); + } else if (child.type === TYPE_ATRULE) { + buffer += print_atrule(child); + } else { + buffer += print_unknown(child, indent_level); + } + + if (item.next !== null) { + buffer += NEWLINE; + + let comment = print_comment( + end_offset(child), + start_offset(item.next.data) + ); + if (comment) { + buffer += indent(indent_level) + comment; + } + + buffer += NEWLINE; + } + }); + + let closing_comment = print_comment( + end_offset(children.last!), + end_offset(ast) + ); + if (closing_comment) { + buffer += NEWLINE + closing_comment; + } + } else { + buffer += print_comment(0, end_offset(ast)); + } + + return buffer; } /** @@ -583,5 +630,5 @@ export function format(css: string, { minify = false, tab_size = undefined }: Fo * @returns {string} The minified CSS */ export function minify(css: string): string { - return format(css, { minify: true }) + return format(css, { minify: true }); } diff --git a/package-lock.json b/package-lock.json index c235e0a..5ab530a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,12 +9,19 @@ "version": "2.1.1", "license": "MIT", "dependencies": { - "css-tree": "^3.1.0" + "@eslint/css-tree": "^3.6.6" }, "devDependencies": { +<<<<<<< HEAD "@codecov/vite-plugin": "^1.9.1", "@types/css-tree": "^2.3.11", "@vitest/coverage-v8": "^4.0.3", +||||||| parent of 3d5ba46 (feat: fix nested css by swapping css-tree for @eslint/css-tree) + "@codecov/vite-plugin": "^1.9.1", + "@types/css-tree": "^2.3.11", +======= + "@codecov/vite-plugin": "^1.9.0", +>>>>>>> 3d5ba46 (feat: fix nested css by swapping css-tree for @eslint/css-tree) "c8": "^10.1.3", "oxlint": "^1.24.0", "prettier": "^3.6.2", @@ -603,6 +610,19 @@ "node": ">=18" } }, + "node_modules/@eslint/css-tree": { + "version": "3.6.6", + "resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-3.6.6.tgz", + "integrity": "sha512-C3YiJMY9OZyZ/3vEMFWJIesdGaRY6DmIYvmtyxMT934CbrOKqRs+Iw7NWSRlJQEaK4dPYy2lZ2y1zkaj8z0p5A==", + "license": "MIT", + "dependencies": { + "mdn-data": "2.23.0", + "source-map-js": "^1.0.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, "node_modules/@fastify/busboy": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@fastify/busboy/-/busboy-2.1.1.tgz", @@ -1625,6 +1645,7 @@ "dev": true, "license": "MIT" }, +<<<<<<< HEAD "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1650,6 +1671,16 @@ "dev": true, "license": "MIT" }, +||||||| parent of 3d5ba46 (feat: fix nested css by swapping css-tree for @eslint/css-tree) + "node_modules/@types/css-tree": { + "version": "2.3.11", + "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.11.tgz", + "integrity": "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==", + "dev": true, + "license": "MIT" + }, +======= +>>>>>>> 3d5ba46 (feat: fix nested css by swapping css-tree for @eslint/css-tree) "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2303,19 +2334,6 @@ "node": ">= 8" } }, - "node_modules/css-tree": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", - "integrity": "sha512-0eW44TGN5SQXU1mWSkKwFstI/22X2bG1nYzZTYMAWjylYURhse752YgbE4Cx46AC+bAvI+/dYTPRk1LqSUnu6w==", - "license": "MIT", - "dependencies": { - "mdn-data": "2.12.2", - "source-map-js": "^1.0.1" - }, - "engines": { - "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" - } - }, "node_modules/de-indent": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz", @@ -2848,9 +2866,9 @@ } }, "node_modules/mdn-data": { - "version": "2.12.2", - "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.12.2.tgz", - "integrity": "sha512-IEn+pegP1aManZuckezWCO+XZQDplx1366JoVhTpMpBB1sPey/SbveZQUosKiKiGYjg1wH4pMlNgXbCiYgihQA==", + "version": "2.23.0", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.23.0.tgz", + "integrity": "sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==", "license": "CC0-1.0" }, "node_modules/minimatch": { diff --git a/package.json b/package.json index 6c61d77..1f74dc5 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,6 @@ "vite-plugin-dts": "^4.5.4", "vitest": "^4.0.3" }, - "dependencies": { - "css-tree": "^3.1.0" - }, "files": [ "dist" ], @@ -62,5 +59,8 @@ "useTabs": true, "printWidth": 140, "singleQuote": true + }, + "dependencies": { + "@eslint/css-tree": "^3.6.6" } } diff --git a/test/rules.test.ts b/test/rules.test.ts index 9524a2a..a2260e5 100644 --- a/test/rules.test.ts +++ b/test/rules.test.ts @@ -182,19 +182,21 @@ test('formats unknown stuff in curly braces', () => { expect(actual).toEqual(expected) }) -test('[check broken test] Relaxed nesting: formats nested rules with a selector with a &', () => { +test('Relaxed nesting: formats nested rules with a selector with a &', () => { let actual = format(` selector { a & { color:red } } `) let expected = `selector { - a & { color:red } + a & { + color: red; + } }` expect(actual).toEqual(expected) }) -test.skip('Relaxed nesting: formats nested rules with a selector with a &', () => { +test('Relaxed nesting: formats nested rules with a selector with a &', () => { let actual = format(` selector { a & { color:red } @@ -208,19 +210,21 @@ test.skip('Relaxed nesting: formats nested rules with a selector with a &', () = expect(actual).toEqual(expected) }) -test('[check broken test] Relaxed nesting: formats nested rules with a selector without a &', () => { +test('Relaxed nesting: formats nested rules with a selector without a &', () => { let actual = format(` selector { a { color:red } } `) let expected = `selector { - a { color:red } + a { + color: red; + } }` expect(actual).toEqual(expected) }) -test.skip('Relaxed nesting: formats nested rules with a selector without a &', () => { +test('Relaxed nesting: formats nested rules with a selector without a &', () => { let actual = format(` selector { a { color:red } @@ -234,23 +238,7 @@ test.skip('Relaxed nesting: formats nested rules with a selector without a &', ( expect(actual).toEqual(expected) }) -test('[check broken test] Relaxed nesting: formats nested rules with a selector starting with a selector combinator', () => { - let actual = format(` - selector { - > a { color:red } - ~ a { color:red } - + a { color:red } - } - `) - let expected = `selector { - > a { color:red } - ~ a { color:red } - + a { color:red } -}` - expect(actual).toEqual(expected) -}) - -test.skip('Relaxed nesting: formats nested rules with a selector starting with a selector combinator', () => { +test('Relaxed nesting: formats nested rules with a selector starting with a selector combinator', () => { let actual = format(` selector { > a { color:red } @@ -285,3 +273,26 @@ test('handles syntax errors: premature closed block', () => { let expected = 'a {\n\tmumblejumble: ;\n}' expect(actual).toEqual(expected) }) +test('Relaxed nesting: formats nested rules with a selector starting with a selector combinator and &', () => { + let actual = format(` + selector { + & > a { color:red } + & ~ a { color:red } + & + a { color:red } + } + `) + let expected = `selector { + & > a { + color: red; + } + + & ~ a { + color: red; + } + + & + a { + color: red; + } +}` + expect(actual).toEqual(expected) +}) From d2951121e7872c398977e2afa78b23ae0be6e374 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 26 Oct 2025 10:40:02 +0100 Subject: [PATCH 2/6] re-upgrade codecov --- package-lock.json | 18 ------------------ package.json | 1 - 2 files changed, 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 5ab530a..584701c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,16 +12,9 @@ "@eslint/css-tree": "^3.6.6" }, "devDependencies": { -<<<<<<< HEAD "@codecov/vite-plugin": "^1.9.1", "@types/css-tree": "^2.3.11", "@vitest/coverage-v8": "^4.0.3", -||||||| parent of 3d5ba46 (feat: fix nested css by swapping css-tree for @eslint/css-tree) - "@codecov/vite-plugin": "^1.9.1", - "@types/css-tree": "^2.3.11", -======= - "@codecov/vite-plugin": "^1.9.0", ->>>>>>> 3d5ba46 (feat: fix nested css by swapping css-tree for @eslint/css-tree) "c8": "^10.1.3", "oxlint": "^1.24.0", "prettier": "^3.6.2", @@ -1645,7 +1638,6 @@ "dev": true, "license": "MIT" }, -<<<<<<< HEAD "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1671,16 +1663,6 @@ "dev": true, "license": "MIT" }, -||||||| parent of 3d5ba46 (feat: fix nested css by swapping css-tree for @eslint/css-tree) - "node_modules/@types/css-tree": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.11.tgz", - "integrity": "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==", - "dev": true, - "license": "MIT" - }, -======= ->>>>>>> 3d5ba46 (feat: fix nested css by swapping css-tree for @eslint/css-tree) "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", diff --git a/package.json b/package.json index 1f74dc5..09ad592 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ }, "devDependencies": { "@codecov/vite-plugin": "^1.9.1", - "@types/css-tree": "^2.3.11", "@vitest/coverage-v8": "^4.0.3", "c8": "^10.1.3", "oxlint": "^1.24.0", From 66164cc0d5a54efeb95ebf21daf5fdfa82518435 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 26 Oct 2025 10:43:54 +0100 Subject: [PATCH 3/6] fix: update vite config to exclude eslint/csstree --- vite.config.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/vite.config.js b/vite.config.js index 54c661d..aa01964 100644 --- a/vite.config.js +++ b/vite.config.js @@ -13,7 +13,7 @@ export default defineConfig({ rollupOptions: { // make sure to externalize deps that shouldn't be bundled // into your library - external: ['css-tree'], + external: ['@eslint/css-tree'], }, }, plugins: [ From 8db17b34899ef769ed531cfcff7a394b41bc1e5a Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 26 Oct 2025 14:47:43 +0100 Subject: [PATCH 4/6] fix package.json conflict --- package-lock.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 584701c..1a7b635 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,6 @@ }, "devDependencies": { "@codecov/vite-plugin": "^1.9.1", - "@types/css-tree": "^2.3.11", "@vitest/coverage-v8": "^4.0.3", "c8": "^10.1.3", "oxlint": "^1.24.0", @@ -1649,13 +1648,6 @@ "assertion-error": "^2.0.1" } }, - "node_modules/@types/css-tree": { - "version": "2.3.11", - "resolved": "https://registry.npmjs.org/@types/css-tree/-/css-tree-2.3.11.tgz", - "integrity": "sha512-aEokibJOI77uIlqoBOkVbaQGC9zII0A+JH1kcTNKW2CwyYWD8KM6qdo+4c77wD3wZOQfJuNWAr9M4hdk+YhDIg==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", From 5e4d5b2d01233b900e7a78fddbd9f92e9a6079b6 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 26 Oct 2025 14:49:42 +0100 Subject: [PATCH 5/6] fixup --- index.ts | 1186 ++++++++++++++++++++++++++---------------------------- 1 file changed, 571 insertions(+), 615 deletions(-) diff --git a/index.ts b/index.ts index 0306846..b22f8b4 100644 --- a/index.ts +++ b/index.ts @@ -1,627 +1,583 @@ import { - parse, - type CssNode, - type List, - type CssLocation, - type Raw, - type StyleSheet, - type Atrule, - type AtrulePrelude, - type Rule, - type SelectorList, - type Selector, - type PseudoClassSelector, - type PseudoElementSelector, - type Block, - type Declaration, - type Value, - type Operator, -} from "@eslint/css-tree"; - -const SPACE = " "; -const EMPTY_STRING = ""; -const COLON = ":"; -const SEMICOLON = ";"; -const QUOTE = '"'; -const OPEN_PARENTHESES = "("; -const CLOSE_PARENTHESES = ")"; -const OPEN_BRACKET = "["; -const CLOSE_BRACKET = "]"; -const OPEN_BRACE = "{"; -const CLOSE_BRACE = "}"; -const EMPTY_BLOCK = "{}"; -const COMMA = ","; -const TYPE_ATRULE = "Atrule"; -const TYPE_RULE = "Rule"; -const TYPE_BLOCK = "Block"; -const TYPE_SELECTORLIST = "SelectorList"; -const TYPE_SELECTOR = "Selector"; -const TYPE_PSEUDO_ELEMENT_SELECTOR = "PseudoElementSelector"; -const TYPE_DECLARATION = "Declaration"; -const TYPE_OPERATOR = "Operator"; + parse, + type CssNode, + type List, + type CssLocation, + type Raw, + type StyleSheet, + type Atrule, + type AtrulePrelude, + type Rule, + type SelectorList, + type Selector, + type PseudoClassSelector, + type PseudoElementSelector, + type Block, + type Declaration, + type Value, + type Operator, +} from '@eslint/css-tree' + +const SPACE = ' ' +const EMPTY_STRING = '' +const COLON = ':' +const SEMICOLON = ';' +const QUOTE = '"' +const OPEN_PARENTHESES = '(' +const CLOSE_PARENTHESES = ')' +const OPEN_BRACKET = '[' +const CLOSE_BRACKET = ']' +const OPEN_BRACE = '{' +const CLOSE_BRACE = '}' +const EMPTY_BLOCK = '{}' +const COMMA = ',' +const TYPE_ATRULE = 'Atrule' +const TYPE_RULE = 'Rule' +const TYPE_BLOCK = 'Block' +const TYPE_SELECTORLIST = 'SelectorList' +const TYPE_SELECTOR = 'Selector' +const TYPE_PSEUDO_ELEMENT_SELECTOR = 'PseudoElementSelector' +const TYPE_DECLARATION = 'Declaration' +const TYPE_OPERATOR = 'Operator' function lowercase(str: string) { - // Only create new strings in memory if we need to - if (/[A-Z]/.test(str)) { - return str.toLowerCase(); - } - return str; + // Only create new strings in memory if we need to + if (/[A-Z]/.test(str)) { + return str.toLowerCase() + } + return str } 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; -}; + /** Whether to minify the CSS or keep it formatted */ + minify?: boolean + /** Tell the formatter to use N spaces instead of tabs */ + tab_size?: number +} /** * Format a string of CSS using some simple rules */ -export function format( - css: string, - { minify = false, tab_size = undefined }: FormatOptions = Object.create(null) -): string { - if (tab_size !== undefined && Number(tab_size) < 1) { - throw new TypeError("tab_size must be a number greater than 0"); - } - - /** [start0, end0, start1, end1, etc.]*/ - let comments: number[] = []; - - function on_comment(_: string, position: CssLocation) { - comments.push(position.start.offset, position.end.offset); - } - - let ast = parse(css, { - positions: true, - parseAtrulePrelude: false, - parseCustomProperty: true, - parseValue: true, - onComment: on_comment, - }) as StyleSheet; - - const NEWLINE = minify ? EMPTY_STRING : "\n"; - const OPTIONAL_SPACE = minify ? EMPTY_STRING : SPACE; - const LAST_SEMICOLON = minify ? EMPTY_STRING : SEMICOLON; - - let indent_level = 0; - - function indent(size: number) { - if (minify === true) return EMPTY_STRING; - - if (tab_size !== undefined) { - return SPACE.repeat(tab_size * size); - } - - return "\t".repeat(size); - } - - function substr(node: CssNode) { - let loc = node.loc; - // If the node has no location, return an empty string - // This is necessary for space toggles - if (loc === undefined || loc === null) return EMPTY_STRING; - return css.slice(loc.start.offset, loc.end.offset); - } - - function start_offset(node: CssNode) { - return node.loc?.start.offset; - } - - function end_offset(node: CssNode) { - return node.loc?.end.offset; - } - - /** - * Get a comment from the CSS string after the first offset and before the second offset - * @param after After which offset to look for comments - * @param before Before which offset to look for comments - * @returns The comment string, if found - */ - function print_comment(after?: number, before?: number): string | undefined { - if (minify === true || after === undefined || before === undefined) { - return EMPTY_STRING; - } - - let buffer = EMPTY_STRING; - for (let i = 0; i < comments.length; i += 2) { - // Check that the comment is within the range - let start = comments[i]; - if (start === undefined || start < after) continue; - let end = comments[i + 1]; - if (end === undefined || end > before) break; - - // Special case for comments that follow another comment: - if (buffer.length > 0) { - buffer += NEWLINE + indent(indent_level); - } - buffer += css.slice(start, end); - } - return buffer; - } - - function print_rule(node: Rule) { - let buffer = ""; - let prelude = node.prelude; - let block = node.block; - - if (prelude.type === TYPE_SELECTORLIST) { - buffer = print_selectorlist(prelude); - } - - let comment = print_comment(end_offset(prelude), start_offset(block)); - if (comment) { - buffer += NEWLINE + indent(indent_level) + comment; - } - - if (block.type === TYPE_BLOCK) { - buffer += print_block(block); - } - - return buffer; - } - - function print_selectorlist(node: SelectorList) { - let buffer = EMPTY_STRING; - - node.children.forEach((selector, item) => { - if (selector.type === TYPE_SELECTOR) { - buffer += indent(indent_level) + print_simple_selector(selector); - } - - if (item.next !== null) { - buffer += COMMA + NEWLINE; - } - - let end = - item.next !== null ? start_offset(item.next.data) : end_offset(node); - let comment = print_comment(end_offset(selector), end); - if (comment) { - buffer += indent(indent_level) + comment + NEWLINE; - } - }); - - return buffer; - } - - function print_simple_selector( - node: Selector | PseudoClassSelector | PseudoElementSelector - ) { - let buffer = EMPTY_STRING; - let children = node.children; - - children?.forEach((child) => { - switch (child.type) { - case "TypeSelector": { - buffer += lowercase(child.name); - break; - } - case "Combinator": { - // putting spaces around `child.name` (+ > ~ or ' '), unless the combinator is ' ' - // and the combinator is not the first in a nested selectorlist - if (child !== children.first) { - buffer += SPACE; - } - - if (child.name !== " ") { - buffer += child.name + SPACE; - } - break; - } - case "PseudoClassSelector": - case TYPE_PSEUDO_ELEMENT_SELECTOR: { - buffer += COLON; - - // Special case for `:before` and `:after` which were used in CSS2 and are usually minified - // as `:before` and `:after`, but we want to print them as `::before` and `::after` - let pseudo = lowercase(child.name); - - if ( - pseudo === "before" || - pseudo === "after" || - child.type === TYPE_PSEUDO_ELEMENT_SELECTOR - ) { - buffer += COLON; - } - - buffer += pseudo; - - if (child.children !== null) { - buffer += - OPEN_PARENTHESES + - print_simple_selector(child) + - CLOSE_PARENTHESES; - } - break; - } - case TYPE_SELECTORLIST: { - child.children.forEach((selector_list_item, item) => { - if (selector_list_item.type === TYPE_SELECTOR) { - buffer += print_simple_selector(selector_list_item); - } - - if (item.next !== null && item.next.data.type === TYPE_SELECTOR) { - buffer += COMMA + OPTIONAL_SPACE; - } - }); - break; - } - case "Nth": { - let nth = child.nth; - if (nth.type === "AnPlusB") { - let a = nth.a; - let b = nth.b; - - if (a !== null) { - buffer += a + "n"; - } - - if (a !== null && b !== null) { - buffer += SPACE; - } - - if (b !== null) { - // When (1n + x) but not (1n - x) - if (a !== null && !b.startsWith("-")) { - buffer += "+" + SPACE; - } - - buffer += b; - } - } else { - // For odd/even or maybe other identifiers later on - buffer += substr(nth); - } - - if (child.selector !== null) { - // `of .selector` - // @ts-expect-error Typing of child.selector is SelectorList, which doesn't seem to be correct - buffer += - SPACE + "of" + SPACE + print_simple_selector(child.selector); - } - break; - } - case "AttributeSelector": { - buffer += OPEN_BRACKET; - buffer += child.name.name; - - if (child.matcher !== null && child.value !== null) { - buffer += child.matcher; - buffer += QUOTE; - - if (child.value.type === "String") { - buffer += child.value.value; - } else if (child.value.type === "Identifier") { - buffer += child.value.name; - } - buffer += QUOTE; - } - - if (child.flags !== null) { - buffer += SPACE + child.flags; - } - - buffer += CLOSE_BRACKET; - break; - } - case "NestingSelector": { - buffer += "&"; - break; - } - default: { - buffer += substr(child); - break; - } - } - }); - - return buffer; - } - - function print_block(node: Block) { - let children = node.children; - let buffer = OPTIONAL_SPACE; - - if (children.isEmpty) { - // Check if the block maybe contains comments - let comment = print_comment(start_offset(node), end_offset(node)); - if (comment) { - buffer += OPEN_BRACE + NEWLINE; - buffer += indent(indent_level + 1) + comment; - buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE; - return buffer; - } - return buffer + EMPTY_BLOCK; - } - - buffer += OPEN_BRACE + NEWLINE; - - indent_level++; - - let opening_comment = print_comment( - start_offset(node), - start_offset(children.first!) - ); - if (opening_comment) { - buffer += indent(indent_level) + opening_comment + NEWLINE; - } - - children.forEach((child, item) => { - if (item.prev !== null) { - let comment = print_comment( - end_offset(item.prev.data), - start_offset(child) - ); - if (comment) { - buffer += indent(indent_level) + comment + NEWLINE; - } - } - - if (child.type === TYPE_DECLARATION) { - buffer += print_declaration(child); - - if (item.next === null) { - buffer += LAST_SEMICOLON; - } else { - buffer += SEMICOLON; - } - } else { - if (item.prev !== null && item.prev.data.type === TYPE_DECLARATION) { - buffer += NEWLINE; - } - - if (child.type === TYPE_RULE) { - buffer += print_rule(child); - } else if (child.type === TYPE_ATRULE) { - buffer += print_atrule(child); - } else { - buffer += print_unknown(child, indent_level); - } - } - - if (item.next !== null) { - buffer += NEWLINE; - - if (child.type !== TYPE_DECLARATION) { - buffer += NEWLINE; - } - } - }); - - let closing_comment = print_comment( - end_offset(children.last!), - end_offset(node) - ); - if (closing_comment) { - buffer += NEWLINE + indent(indent_level) + closing_comment; - } - - indent_level--; - buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE; - - return buffer; - } - - function print_atrule(node: Atrule) { - let buffer = indent(indent_level) + "@"; - let prelude = node.prelude; - let block = node.block; - buffer += lowercase(node.name); - - // @font-face and anonymous @layer have no prelude - if (prelude !== null) { - buffer += SPACE + print_prelude(prelude); - } - - if (block === null) { - // `@import url(style.css);` has no block, neither does `@layer layer1;` - buffer += SEMICOLON; - } else if (block.type === TYPE_BLOCK) { - buffer += print_block(block); - } - - return buffer; - } - - /** - * 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_prelude(node: AtrulePrelude | Raw) { - let buffer = substr(node); - - return buffer - .replace( - /\s*([:,])/g, - buffer.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, " $1 ") // force whitespace 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) => lowercase(match)); // lowercase function names - } - - function print_declaration(node: Declaration) { - let property = node.property; - - // Lowercase the property, unless it's a custom property (starts with --) - if (!(property.charCodeAt(0) === 45 && property.charCodeAt(1) === 45)) { - // 45 == '-' - property = lowercase(property); - } - - let value = print_value(node.value); - - // 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 (node.important === true) { - value += OPTIONAL_SPACE + "!important"; - } else if (typeof node.important === "string") { - value += OPTIONAL_SPACE + "!" + lowercase(node.important); - } - - return indent(indent_level) + property + COLON + OPTIONAL_SPACE + value; - } - - function print_list(children: List) { - let buffer = EMPTY_STRING; - - children.forEach((node, item) => { - if (node.type === "Identifier") { - buffer += node.name; - } else if (node.type === "Function") { - buffer += - lowercase(node.name) + - OPEN_PARENTHESES + - print_list(node.children) + - CLOSE_PARENTHESES; - } else if (node.type === "Dimension") { - buffer += node.value + lowercase(node.unit); - } else if (node.type === "Value") { - // Values can be inside var() as fallback - // var(--prop, VALUE) - buffer += print_value(node); - } else if (node.type === TYPE_OPERATOR) { - buffer += print_operator(node); - } else if (node.type === "Parentheses") { - buffer += - OPEN_PARENTHESES + print_list(node.children) + CLOSE_PARENTHESES; - } else if (node.type === "Url") { - buffer += "url(" + QUOTE + node.value + QUOTE + CLOSE_PARENTHESES; - } else { - buffer += substr(node); - } - - // Add space after the item coming after an operator - if (node.type !== TYPE_OPERATOR) { - if (item.next !== null) { - if (item.next.data.type !== TYPE_OPERATOR) { - buffer += SPACE; - } - } - } - }); - - return buffer; - } - - function print_operator(node: Operator) { - let buffer = EMPTY_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 - - // Trim the operator because CSSTree adds whitespace around it - let operator = node.value.trim(); - let code = operator.charCodeAt(0); - - if (code === 43 || code === 45) { - // + or - - // Add required space before + and - operators - buffer += SPACE; - } else if (code !== 44) { - // , - // Add optional space before operator - buffer += OPTIONAL_SPACE; - } - - // FINALLY, render the operator - buffer += operator; - - if (code === 43 || code === 45) { - // + or - - // Add required space after + and - operators - buffer += SPACE; - } else { - // Add optional space after other operators (like *, /, and ,) - buffer += OPTIONAL_SPACE; - } - - return buffer; - } - - function print_value(node: Value | Raw) { - if (node.type === "Raw") { - return print_unknown(node, 0); - } - - return print_list(node.children); - } - - function print_unknown(node: CssNode, indent_level: number) { - return indent(indent_level) + substr(node).trim(); - } - - let children = ast.children; - let buffer = EMPTY_STRING; - - if (children.first !== null) { - let opening_comment = print_comment(0, start_offset(children.first)); - if (opening_comment) { - buffer += opening_comment + NEWLINE; - } - - children.forEach((child, item) => { - if (child.type === TYPE_RULE) { - buffer += print_rule(child); - } else if (child.type === TYPE_ATRULE) { - buffer += print_atrule(child); - } else { - buffer += print_unknown(child, indent_level); - } - - if (item.next !== null) { - buffer += NEWLINE; - - let comment = print_comment( - end_offset(child), - start_offset(item.next.data) - ); - if (comment) { - buffer += indent(indent_level) + comment; - } - - buffer += NEWLINE; - } - }); - - let closing_comment = print_comment( - end_offset(children.last!), - end_offset(ast) - ); - if (closing_comment) { - buffer += NEWLINE + closing_comment; - } - } else { - buffer += print_comment(0, end_offset(ast)); - } - - return buffer; +export function format(css: string, { minify = false, tab_size = undefined }: FormatOptions = Object.create(null)): string { + if (tab_size !== undefined && Number(tab_size) < 1) { + throw new TypeError('tab_size must be a number greater than 0') + } + + /** [start0, end0, start1, end1, etc.]*/ + let comments: number[] = [] + + function on_comment(_: string, position: CssLocation) { + comments.push(position.start.offset, position.end.offset) + } + + let ast = parse(css, { + positions: true, + parseAtrulePrelude: false, + parseCustomProperty: true, + parseValue: true, + onComment: on_comment, + }) as StyleSheet + + const NEWLINE = minify ? EMPTY_STRING : '\n' + const OPTIONAL_SPACE = minify ? EMPTY_STRING : SPACE + const LAST_SEMICOLON = minify ? EMPTY_STRING : SEMICOLON + + let indent_level = 0 + + function indent(size: number) { + if (minify === true) return EMPTY_STRING + + if (tab_size !== undefined) { + return SPACE.repeat(tab_size * size) + } + + return '\t'.repeat(size) + } + + function substr(node: CssNode) { + let loc = node.loc + // If the node has no location, return an empty string + // This is necessary for space toggles + if (loc === undefined || loc === null) return EMPTY_STRING + return css.slice(loc.start.offset, loc.end.offset) + } + + function start_offset(node: CssNode) { + return node.loc?.start.offset + } + + function end_offset(node: CssNode) { + return node.loc?.end.offset + } + + /** + * Get a comment from the CSS string after the first offset and before the second offset + * @param after After which offset to look for comments + * @param before Before which offset to look for comments + * @returns The comment string, if found + */ + function print_comment(after?: number, before?: number): string | undefined { + if (minify === true || after === undefined || before === undefined) { + return EMPTY_STRING + } + + let buffer = EMPTY_STRING + for (let i = 0; i < comments.length; i += 2) { + // Check that the comment is within the range + let start = comments[i] + if (start === undefined || start < after) continue + let end = comments[i + 1] + if (end === undefined || end > before) break + + // Special case for comments that follow another comment: + if (buffer.length > 0) { + buffer += NEWLINE + indent(indent_level) + } + buffer += css.slice(start, end) + } + return buffer + } + + function print_rule(node: Rule) { + let buffer = '' + let prelude = node.prelude + let block = node.block + + if (prelude.type === TYPE_SELECTORLIST) { + buffer = print_selectorlist(prelude) + } + + let comment = print_comment(end_offset(prelude), start_offset(block)) + if (comment) { + buffer += NEWLINE + indent(indent_level) + comment + } + + if (block.type === TYPE_BLOCK) { + buffer += print_block(block) + } + + return buffer + } + + function print_selectorlist(node: SelectorList) { + let buffer = EMPTY_STRING + + node.children.forEach((selector, item) => { + if (selector.type === TYPE_SELECTOR) { + buffer += indent(indent_level) + print_simple_selector(selector) + } + + if (item.next !== null) { + buffer += COMMA + NEWLINE + } + + let end = item.next !== null ? start_offset(item.next.data) : end_offset(node) + let comment = print_comment(end_offset(selector), end) + if (comment) { + buffer += indent(indent_level) + comment + NEWLINE + } + }) + + return buffer + } + + function print_simple_selector(node: Selector | PseudoClassSelector | PseudoElementSelector) { + let buffer = EMPTY_STRING + let children = node.children + + children?.forEach((child) => { + switch (child.type) { + case 'TypeSelector': { + buffer += lowercase(child.name) + break + } + case 'Combinator': { + // putting spaces around `child.name` (+ > ~ or ' '), unless the combinator is ' ' + // and the combinator is not the first in a nested selectorlist + if (child !== children.first) { + buffer += SPACE + } + + if (child.name !== ' ') { + buffer += child.name + SPACE + } + break + } + case 'PseudoClassSelector': + case TYPE_PSEUDO_ELEMENT_SELECTOR: { + buffer += COLON + + // Special case for `:before` and `:after` which were used in CSS2 and are usually minified + // as `:before` and `:after`, but we want to print them as `::before` and `::after` + let pseudo = lowercase(child.name) + + if (pseudo === 'before' || pseudo === 'after' || child.type === TYPE_PSEUDO_ELEMENT_SELECTOR) { + buffer += COLON + } + + buffer += pseudo + + if (child.children !== null) { + buffer += OPEN_PARENTHESES + print_simple_selector(child) + CLOSE_PARENTHESES + } + break + } + case TYPE_SELECTORLIST: { + child.children.forEach((selector_list_item, item) => { + if (selector_list_item.type === TYPE_SELECTOR) { + buffer += print_simple_selector(selector_list_item) + } + + if (item.next !== null && item.next.data.type === TYPE_SELECTOR) { + buffer += COMMA + OPTIONAL_SPACE + } + }) + break + } + case 'Nth': { + let nth = child.nth + if (nth.type === 'AnPlusB') { + let a = nth.a + let b = nth.b + + if (a !== null) { + buffer += a + 'n' + } + + if (a !== null && b !== null) { + buffer += SPACE + } + + if (b !== null) { + // When (1n + x) but not (1n - x) + if (a !== null && !b.startsWith('-')) { + buffer += '+' + SPACE + } + + buffer += b + } + } else { + // For odd/even or maybe other identifiers later on + buffer += substr(nth) + } + + if (child.selector !== null) { + // `of .selector` + // @ts-expect-error Typing of child.selector is SelectorList, which doesn't seem to be correct + buffer += SPACE + 'of' + SPACE + print_simple_selector(child.selector) + } + break + } + case 'AttributeSelector': { + buffer += OPEN_BRACKET + buffer += child.name.name + + if (child.matcher !== null && child.value !== null) { + buffer += child.matcher + buffer += QUOTE + + if (child.value.type === 'String') { + buffer += child.value.value + } else if (child.value.type === 'Identifier') { + buffer += child.value.name + } + buffer += QUOTE + } + + if (child.flags !== null) { + buffer += SPACE + child.flags + } + + buffer += CLOSE_BRACKET + break + } + case 'NestingSelector': { + buffer += '&' + break + } + default: { + buffer += substr(child) + break + } + } + }) + + return buffer + } + + function print_block(node: Block) { + let children = node.children + let buffer = OPTIONAL_SPACE + + if (children.isEmpty) { + // Check if the block maybe contains comments + let comment = print_comment(start_offset(node), end_offset(node)) + if (comment) { + buffer += OPEN_BRACE + NEWLINE + buffer += indent(indent_level + 1) + comment + buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE + return buffer + } + return buffer + EMPTY_BLOCK + } + + buffer += OPEN_BRACE + NEWLINE + + indent_level++ + + let opening_comment = print_comment(start_offset(node), start_offset(children.first!)) + if (opening_comment) { + buffer += indent(indent_level) + opening_comment + NEWLINE + } + + children.forEach((child, item) => { + if (item.prev !== null) { + let comment = print_comment(end_offset(item.prev.data), start_offset(child)) + if (comment) { + buffer += indent(indent_level) + comment + NEWLINE + } + } + + if (child.type === TYPE_DECLARATION) { + buffer += print_declaration(child) + + if (item.next === null) { + buffer += LAST_SEMICOLON + } else { + buffer += SEMICOLON + } + } else { + if (item.prev !== null && item.prev.data.type === TYPE_DECLARATION) { + buffer += NEWLINE + } + + if (child.type === TYPE_RULE) { + buffer += print_rule(child) + } else if (child.type === TYPE_ATRULE) { + buffer += print_atrule(child) + } else { + buffer += print_unknown(child, indent_level) + } + } + + if (item.next !== null) { + buffer += NEWLINE + + if (child.type !== TYPE_DECLARATION) { + buffer += NEWLINE + } + } + }) + + let closing_comment = print_comment(end_offset(children.last!), end_offset(node)) + if (closing_comment) { + buffer += NEWLINE + indent(indent_level) + closing_comment + } + + indent_level-- + buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE + + return buffer + } + + function print_atrule(node: Atrule) { + let buffer = indent(indent_level) + '@' + let prelude = node.prelude + let block = node.block + buffer += lowercase(node.name) + + // @font-face and anonymous @layer have no prelude + if (prelude !== null) { + buffer += SPACE + print_prelude(prelude) + } + + if (block === null) { + // `@import url(style.css);` has no block, neither does `@layer layer1;` + buffer += SEMICOLON + } else if (block.type === TYPE_BLOCK) { + buffer += print_block(block) + } + + return buffer + } + + /** + * 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_prelude(node: AtrulePrelude | Raw) { + let buffer = substr(node) + + return buffer + .replace(/\s*([:,])/g, buffer.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, ' $1 ') // force whitespace 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) => lowercase(match)) // lowercase function names + } + + function print_declaration(node: Declaration) { + let property = node.property + + // Lowercase the property, unless it's a custom property (starts with --) + if (!(property.charCodeAt(0) === 45 && property.charCodeAt(1) === 45)) { + // 45 == '-' + property = lowercase(property) + } + + let value = print_value(node.value) + + // 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 (node.important === true) { + value += OPTIONAL_SPACE + '!important' + } else if (typeof node.important === 'string') { + value += OPTIONAL_SPACE + '!' + lowercase(node.important) + } + + return indent(indent_level) + property + COLON + OPTIONAL_SPACE + value + } + + function print_list(children: List) { + let buffer = EMPTY_STRING + + children.forEach((node, item) => { + if (node.type === 'Identifier') { + buffer += node.name + } else if (node.type === 'Function') { + buffer += lowercase(node.name) + OPEN_PARENTHESES + print_list(node.children) + CLOSE_PARENTHESES + } else if (node.type === 'Dimension') { + buffer += node.value + lowercase(node.unit) + } else if (node.type === 'Value') { + // Values can be inside var() as fallback + // var(--prop, VALUE) + buffer += print_value(node) + } else if (node.type === TYPE_OPERATOR) { + buffer += print_operator(node) + } else if (node.type === 'Parentheses') { + buffer += OPEN_PARENTHESES + print_list(node.children) + CLOSE_PARENTHESES + } else if (node.type === 'Url') { + buffer += 'url(' + QUOTE + node.value + QUOTE + CLOSE_PARENTHESES + } else { + buffer += substr(node) + } + + // Add space after the item coming after an operator + if (node.type !== TYPE_OPERATOR) { + if (item.next !== null) { + if (item.next.data.type !== TYPE_OPERATOR) { + buffer += SPACE + } + } + } + }) + + return buffer + } + + function print_operator(node: Operator) { + let buffer = EMPTY_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 + + // Trim the operator because CSSTree adds whitespace around it + let operator = node.value.trim() + let code = operator.charCodeAt(0) + + if (code === 43 || code === 45) { + // + or - + // Add required space before + and - operators + buffer += SPACE + } else if (code !== 44) { + // , + // Add optional space before operator + buffer += OPTIONAL_SPACE + } + + // FINALLY, render the operator + buffer += operator + + if (code === 43 || code === 45) { + // + or - + // Add required space after + and - operators + buffer += SPACE + } else { + // Add optional space after other operators (like *, /, and ,) + buffer += OPTIONAL_SPACE + } + + return buffer + } + + function print_value(node: Value | Raw) { + if (node.type === 'Raw') { + return print_unknown(node, 0) + } + + return print_list(node.children) + } + + function print_unknown(node: CssNode, indent_level: number) { + return indent(indent_level) + substr(node).trim() + } + + let children = ast.children + let buffer = EMPTY_STRING + + if (children.first !== null) { + let opening_comment = print_comment(0, start_offset(children.first)) + if (opening_comment) { + buffer += opening_comment + NEWLINE + } + + children.forEach((child, item) => { + if (child.type === TYPE_RULE) { + buffer += print_rule(child) + } else if (child.type === TYPE_ATRULE) { + buffer += print_atrule(child) + } else { + buffer += print_unknown(child, indent_level) + } + + if (item.next !== null) { + buffer += NEWLINE + + let comment = print_comment(end_offset(child), start_offset(item.next.data)) + if (comment) { + buffer += indent(indent_level) + comment + } + + buffer += NEWLINE + } + }) + + let closing_comment = print_comment(end_offset(children.last!), end_offset(ast)) + if (closing_comment) { + buffer += NEWLINE + closing_comment + } + } else { + buffer += print_comment(0, end_offset(ast)) + } + + return buffer } /** @@ -630,5 +586,5 @@ export function format( * @returns {string} The minified CSS */ export function minify(css: string): string { - return format(css, { minify: true }); + return format(css, { minify: true }) } From 7c3692562b0ff745b8ba39eb60fabfe62fa04274 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Wed, 5 Nov 2025 09:42:00 +0100 Subject: [PATCH 6/6] fix types --- index.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.ts b/index.ts index b22f8b4..4106b4b 100644 --- a/index.ts +++ b/index.ts @@ -2,7 +2,6 @@ import { parse, type CssNode, type List, - type CssLocation, type Raw, type StyleSheet, type Atrule, @@ -16,6 +15,7 @@ import { type Declaration, type Value, type Operator, + type CssLocationRange, } from '@eslint/css-tree' const SPACE = ' ' @@ -66,7 +66,7 @@ export function format(css: string, { minify = false, tab_size = undefined }: Fo /** [start0, end0, start1, end1, etc.]*/ let comments: number[] = [] - function on_comment(_: string, position: CssLocation) { + function on_comment(_: string, position: CssLocationRange) { comments.push(position.start.offset, position.end.offset) }