From 15cf035bc1e500dd396f4fe5a22c6854b34d83df Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 18 Feb 2024 22:36:25 +0100 Subject: [PATCH 1/5] fix minified spaces --- index.js | 148 +++++++++++++++++++++++++++----------------- test/minify.test.js | 22 +++++-- test/values.test.js | 8 ++- 3 files changed, 114 insertions(+), 64 deletions(-) diff --git a/index.js b/index.js index 162c399..54b2ecf 100644 --- a/index.js +++ b/index.js @@ -1,10 +1,14 @@ // @ts-expect-error Typing of css-tree is incomplete import parse from 'css-tree/parser' +let SPACE = ' ' +let EMPTY_STRING = '' + // Warning: can be overridden when { minify: true } let NEWLINE = '\n' // or '' let TAB = '\t' // or '' -let SPACE = ' ' // or '' +let OPTIONAL_SPACE = ' ' // or '' +let LAST_SEMICOLON = ';' /** * Indent a string @@ -20,13 +24,7 @@ function indent(size) { * @param {string} str */ function is_uppercase(str) { - for (let char of str) { - let code = char.charCodeAt(0) - if (code >= 65 && code <= 90) { - return true - } - } - return false + return /[A-Z]/.test(str) } /** @@ -37,7 +35,7 @@ function is_uppercase(str) { function substr(node, css) { let loc = node.loc - if (!loc) return '' + if (!loc) return EMPTY_STRING let start = loc.start let end = loc.end @@ -49,7 +47,7 @@ function substr(node, css) { } // Multi-line nodes, less common - return str.replace(/\s+/g, ' ') + return str.replace(/\s+/g, SPACE) } /** @@ -59,7 +57,7 @@ function substr(node, css) { */ function substr_raw(node, css) { let loc = node.loc - if (!loc) return '' + if (!loc) return EMPTY_STRING return css.substring(loc.start.offset, loc.end.offset) } @@ -74,13 +72,14 @@ function print_rule(node, css, indent_level) { let prelude = node.prelude let block = node.block - if (prelude !== undefined && prelude.type === 'SelectorList') { + if (prelude.type === 'SelectorList') { buffer = print_selectorlist(prelude, css, indent_level) } else { + // In case parsing the selector list fails we'll print it as-is buffer = print_unknown(prelude, css, indent_level) } - if (block !== null && block.type === 'Block') { + if (block.type === 'Block') { buffer += print_block(block, css, indent_level) } @@ -94,10 +93,10 @@ function print_rule(node, css, indent_level) { * @returns {string} A formatted SelectorList */ function print_selectorlist(node, css, indent_level) { - let buffer = '' + let buffer = EMPTY_STRING let children = node.children - for (let selector of children) { + children.forEach((selector) => { if (selector.type === 'Selector') { buffer += print_selector(selector, css, indent_level) } else { @@ -107,7 +106,8 @@ function print_selectorlist(node, css, indent_level) { if (selector !== children.last) { buffer += `,` + NEWLINE } - } + }) + return buffer } @@ -116,16 +116,17 @@ function print_selectorlist(node, css, indent_level) { * @param {string} css */ function print_simple_selector(node, css) { - let buffer = '' + let buffer = EMPTY_STRING if (node.children) { for (let child of node.children) { switch (child.type) { case 'Combinator': { - // putting spaces around `child.name`, unless the combinator is ' ' - buffer += ' ' + // putting spaces around `child.name` (+ > ~ or ' '), unless the combinator is ' ' + buffer += SPACE + if (child.name !== ' ') { - buffer += child.name + ' ' + buffer += child.name + SPACE } break } @@ -138,15 +139,15 @@ function print_simple_selector(node, css) { break } case 'SelectorList': { - for (let grandchild of child.children) { + child.children.forEach((grandchild, item) => { if (grandchild.type === 'Selector') { buffer += print_simple_selector(grandchild, css) } - if (grandchild !== child.children.last) { - buffer += ', ' + if (item.next) { + buffer += ',' + SPACE } - } + }) break } case 'Nth': { @@ -160,13 +161,13 @@ function print_simple_selector(node, css) { } if (a !== null && b !== null) { - buffer += ' ' + buffer += SPACE } if (b !== null) { // When (1n + x) but not (1n - x) if (a !== null && !b.startsWith('-')) { - buffer += '+ ' + buffer += '+' + SPACE } buffer += b @@ -180,7 +181,7 @@ function print_simple_selector(node, css) { if (child.selector !== null) { // `of .selector` // @ts-expect-error Typing of child.selector is SelectorList, which doesn't seem to be correct - buffer += ' of ' + print_simple_selector(child.selector, css) + buffer += SPACE + 'of' + SPACE + print_simple_selector(child.selector, css) } break } @@ -213,7 +214,7 @@ function print_selector(node, css, indent_level) { */ function print_block(node, css, indent_level) { let children = node.children - let buffer = SPACE + let buffer = OPTIONAL_SPACE if (children.isEmpty) { return buffer + '{}' @@ -223,13 +224,17 @@ function print_block(node, css, indent_level) { indent_level++ - let prev_type - - for (let child of children) { + children.forEach((child, item) => { if (child.type === 'Declaration') { - buffer += print_declaration(child, css, indent_level) + ';' + buffer += print_declaration(child, css, indent_level) + + if (child === children.last) { + buffer += LAST_SEMICOLON + } else { + buffer += ';' + } } else { - if (prev_type === 'Declaration') { + if (item.prev !== null && item.prev.data.type === 'Declaration') { buffer += NEWLINE } @@ -242,16 +247,14 @@ function print_block(node, css, indent_level) { } } - if (child !== children.last) { + if (item.next !== null) { buffer += NEWLINE if (child.type !== 'Declaration') { buffer += NEWLINE } } - - prev_type = child.type - } + }) indent_level-- @@ -276,7 +279,7 @@ function print_atrule(node, css, indent_level) { // @font-face has no prelude if (prelude !== null) { - buffer += ' ' + print_prelude(prelude, css) + buffer += SPACE + print_prelude(prelude, css) } if (block === null) { @@ -317,18 +320,21 @@ function print_prelude(node, css) { function print_declaration(node, css, indent_level) { let property = node.property - if (!property.startsWith('--') && is_uppercase(property)) { - property = property.toLowerCase() + // Lowercase the property, unless it's a custom property (starts with --) + if (!(property.charCodeAt(0) === 45 && property.charCodeAt(1) === 45)) { // 45 == '-' + if (is_uppercase(property)) { + property = property.toLowerCase() + } } - let value = print_value(node.value, css).trim() + let value = print_value(node.value, css) // Special case for `font` shorthand: remove whitespace around / if (property === 'font') { value = value.replace(/\s*\/\s*/, '/') } - return indent(indent_level) + property + ':' + SPACE + value + return indent(indent_level) + property + ':' + OPTIONAL_SPACE + value } /** @@ -336,13 +342,9 @@ function print_declaration(node, css, indent_level) { * @param {string} css */ function print_list(children, css) { - let buffer = '' - - for (let node of children) { - if (node !== children.first && node.type !== 'Operator') { - buffer += ' ' - } + let buffer = EMPTY_STRING + children.forEach((node, item) => { if (node.type === 'Identifier') { buffer += node.name } else if (node.type === 'Function') { @@ -354,16 +356,45 @@ function print_list(children, css) { // var(--prop, VALUE) buffer += print_value(node, css) } else if (node.type === 'Operator') { - // Put extra spacing before + - / * - // but not before a comma - if (node.value !== ',') { - buffer += ' ' + // 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 } - buffer += substr(node, css) } else { buffer += substr(node, css) } - } + + if (node.type !== 'Operator') { + if (item.next) { + if (item.next.data.type !== 'Operator') { + buffer += SPACE + } + } + } + }) + return buffer } @@ -422,7 +453,7 @@ function print_unknown(node, css, indent_level) { * @returns {string} A formatted Stylesheet */ function print(node, css, indent_level = 0) { - let buffer = '' + let buffer = EMPTY_STRING // @ts-expect-error Property 'children' does not exist on type 'AnPlusB', but we're never using that let children = node.children @@ -460,9 +491,10 @@ export function format(css, { minify = false } = {}) { parseValue: true, }) - TAB = minify ? '' : '\t' - NEWLINE = minify ? '' : '\n' - SPACE = minify ? '' : ' ' + TAB = minify ? EMPTY_STRING : '\t' + NEWLINE = minify ? EMPTY_STRING : '\n' + OPTIONAL_SPACE = minify ? EMPTY_STRING : ' ' + LAST_SEMICOLON = minify ? EMPTY_STRING : ';' return print(ast, css, 0) } diff --git a/test/minify.test.js b/test/minify.test.js index b10560d..c9adf42 100644 --- a/test/minify.test.js +++ b/test/minify.test.js @@ -12,13 +12,13 @@ test('empty rule', () => { test('simple declaration', () => { let actual = minify(`:root { --color: red; }`) - let expected = `:root{--color:red;}` + let expected = `:root{--color:red}` assert.equal(actual, expected) }) test('simple atrule', () => { let actual = minify(`@media (min-width: 100px) { body { color: red; } }`) - let expected = `@media (min-width: 100px){body{color:red;}}` + let expected = `@media (min-width: 100px){body{color:red}}` assert.equal(actual, expected) }) @@ -37,10 +37,22 @@ a { 20% green,100% yellow); } `); - let expected = `a{background:linear-gradient(red, 10% blue, 20% green, 100% yellow);}`; + let expected = `a{background:linear-gradient(red,10% blue,20% green,100% yellow)}`; assert.equal(actual, expected); }) +test('correctly minifies operators', () => { + let actual = minify(`a { width: calc(100% - 10px); }`) + let expected = `a{width:calc(100% - 10px)}` + assert.equal(actual, expected) +}) + +test('correctly minifiers modern colors', () => { + let actual = minify(`a { color: rgb(0 0 0 / 0.1); }`) + let expected = `a{color:rgb(0 0 0/0.1)}` + assert.equal(actual, expected) +}) + test('Vadim Makeevs example works', () => { let actual = minify(` @layer what { @@ -55,13 +67,13 @@ test('Vadim Makeevs example works', () => { } } `) - let expected = `@layer what{@container (width > 0){ul:has(:nth-child(1 of li)){@media (height > 0){&:hover{--is:this;}}}}}` + let expected = `@layer what{@container (width > 0){ul:has(:nth-child(1 of li)){@media (height > 0){&:hover{--is:this}}}}}` assert.equal(actual, expected) }) test('minified Vadims example', () => { let actual = minify(`@layer what{@container (width>0){@media (min-height:.001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}`) - let expected = `@layer what{@container (width > 0){@media (min-height: .001px){ul:has(:nth-child(1 of li)):hover{--is:this;}}}}` + let expected = `@layer what{@container (width > 0){@media (min-height: .001px){ul:has(:nth-child(1 of li)):hover{--is:this}}}}` assert.equal(actual, expected) }) diff --git a/test/values.test.js b/test/values.test.js index 426b36c..dad89c7 100644 --- a/test/values.test.js +++ b/test/values.test.js @@ -8,10 +8,12 @@ test('collapses abundant whitespace', () => { let actual = format(`a { transition: all 100ms ease; color: rgb( 0 , 0 , 0 ); + color: red ; }`) let expected = `a { transition: all 100ms ease; color: rgb(0, 0, 0); + color: red; }` assert.is(actual, expected) }) @@ -99,12 +101,16 @@ test('formats whitespace around operators (*/+-) correctly', () => { let actual = format(`a { font: 2em/2 sans-serif; font-size: calc(2em/2); - font-size: calc(2em + 2px) + font-size: calc(2em * 2); + font-size: calc(2em + 2px); + font-size: calc(2em - 2px); }`) let expected = `a { font: 2em/2 sans-serif; font-size: calc(2em / 2); + font-size: calc(2em * 2); font-size: calc(2em + 2px); + font-size: calc(2em - 2px); }` assert.is(actual, expected) }) From f9770193d98f8dff5a7f90e7726d1e884edc1fc3 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 18 Feb 2024 22:41:40 +0100 Subject: [PATCH 2/5] get rid of last for-loops to prevent polyfilling --- index.js | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/index.js b/index.js index 54b2ecf..24bb1ae 100644 --- a/index.js +++ b/index.js @@ -119,7 +119,7 @@ function print_simple_selector(node, css) { let buffer = EMPTY_STRING if (node.children) { - for (let child of node.children) { + node.children.forEach((child) => { switch (child.type) { case 'Combinator': { // putting spaces around `child.name` (+ > ~ or ' '), unless the combinator is ' ' @@ -190,7 +190,7 @@ function print_simple_selector(node, css) { break } } - } + }) } return buffer @@ -454,10 +454,12 @@ function print_unknown(node, css, indent_level) { */ function print(node, css, indent_level = 0) { let buffer = EMPTY_STRING + + /** @type {import('css-tree').List} */ // @ts-expect-error Property 'children' does not exist on type 'AnPlusB', but we're never using that let children = node.children - for (let child of children) { + children.forEach((child) => { if (child.type === 'Rule') { buffer += print_rule(child, css, indent_level) } else if (child.type === 'Atrule') { @@ -469,7 +471,7 @@ function print(node, css, indent_level = 0) { if (child !== children.last) { buffer += NEWLINE + NEWLINE } - } + }) return buffer } @@ -484,6 +486,7 @@ function print(node, css, indent_level = 0) { * @returns {string} The formatted CSS */ export function format(css, { minify = false } = {}) { + /** @type {import('css-tree').CssNode} */ let ast = parse(css, { positions: true, parseAtrulePrelude: false, From be67f8752f8986443cfb4fba175d298095271a13 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 18 Feb 2024 22:57:24 +0100 Subject: [PATCH 3/5] minify more stuff --- index.js | 172 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 91 insertions(+), 81 deletions(-) diff --git a/index.js b/index.js index 24bb1ae..169cc6c 100644 --- a/index.js +++ b/index.js @@ -1,8 +1,15 @@ // @ts-expect-error Typing of css-tree is incomplete import parse from 'css-tree/parser' -let SPACE = ' ' -let EMPTY_STRING = '' +const SPACE = ' ' +const EMPTY_STRING = '' +const TYPE_ATRULE = 'Atrule' +const TYPE_RULE = 'Rule' +const TYPE_BLOCK = 'Block' +const TYPE_SELECTORLIST = 'SelectorList' +const TYPE_SELECTOR = 'Selector' +const TYPE_DECLARATION = 'Declaration' +const TYPE_OPERATOR = 'Operator' // Warning: can be overridden when { minify: true } let NEWLINE = '\n' // or '' @@ -72,14 +79,14 @@ function print_rule(node, css, indent_level) { let prelude = node.prelude let block = node.block - if (prelude.type === 'SelectorList') { + if (prelude.type === TYPE_SELECTORLIST) { buffer = print_selectorlist(prelude, css, indent_level) } else { // In case parsing the selector list fails we'll print it as-is buffer = print_unknown(prelude, css, indent_level) } - if (block.type === 'Block') { + if (block.type === TYPE_BLOCK) { buffer += print_block(block, css, indent_level) } @@ -96,14 +103,14 @@ function print_selectorlist(node, css, indent_level) { let buffer = EMPTY_STRING let children = node.children - children.forEach((selector) => { - if (selector.type === 'Selector') { + children.forEach((selector, item) => { + if (selector.type === TYPE_SELECTOR) { buffer += print_selector(selector, css, indent_level) } else { buffer += print_unknown(selector, css, indent_level) } - if (selector !== children.last) { + if (item.next !== null) { buffer += `,` + NEWLINE } }) @@ -118,80 +125,83 @@ function print_selectorlist(node, css, indent_level) { function print_simple_selector(node, css) { let buffer = EMPTY_STRING - if (node.children) { - node.children.forEach((child) => { - switch (child.type) { - case 'Combinator': { - // putting spaces around `child.name` (+ > ~ or ' '), unless the combinator is ' ' - buffer += SPACE + if (!node.children) { + return buffer + } - if (child.name !== ' ') { - buffer += child.name + SPACE - } - break + node.children.forEach((child) => { + switch (child.type) { + case 'Combinator': { + // putting spaces around `child.name` (+ > ~ or ' '), unless the combinator is ' ' + buffer += SPACE + + if (child.name !== ' ') { + buffer += child.name + SPACE } - case 'PseudoClassSelector': { - buffer += ':' + child.name + break + } + case 'PseudoClassSelector': { + buffer += ':' + child.name - if (child.children) { - buffer += '(' + print_simple_selector(child, css) + ')' - } - break + if (child.children) { + buffer += '(' + print_simple_selector(child, css) + ')' } - case 'SelectorList': { - child.children.forEach((grandchild, item) => { - if (grandchild.type === 'Selector') { - buffer += print_simple_selector(grandchild, css) + break + } + case TYPE_SELECTORLIST: { + child.children.forEach((grandchild, item) => { + if (grandchild.type === TYPE_SELECTOR) { + buffer += print_simple_selector(grandchild, css) + } + + if (item.next) { + buffer += ',' + SPACE + } + }) + break + } + case 'Nth': { + let nth = child.nth + if (nth) { + if (nth.type === 'AnPlusB') { + let a = nth.a + let b = nth.b + + if (a !== null) { + buffer += a + 'n' } - if (item.next) { - buffer += ',' + SPACE + if (a !== null && b !== null) { + buffer += SPACE } - }) - break - } - case 'Nth': { - if (child.nth) { - if (child.nth.type === 'AnPlusB') { - let a = child.nth.a - let b = child.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 } - 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(child.nth, css) + buffer += b } + } else { + // For odd/even or maybe other identifiers later on + buffer += substr(child.nth, css) } - - 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, css) - } - break } - default: { - buffer += substr(child, css) - break + + 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, css) } + break } - }) - } + default: { + buffer += substr(child, css) + break + } + } + }) return buffer } @@ -225,7 +235,7 @@ function print_block(node, css, indent_level) { indent_level++ children.forEach((child, item) => { - if (child.type === 'Declaration') { + if (child.type === TYPE_DECLARATION) { buffer += print_declaration(child, css, indent_level) if (child === children.last) { @@ -234,13 +244,13 @@ function print_block(node, css, indent_level) { buffer += ';' } } else { - if (item.prev !== null && item.prev.data.type === 'Declaration') { + if (item.prev !== null && item.prev.data.type === TYPE_DECLARATION) { buffer += NEWLINE } - if (child.type === 'Rule') { + if (child.type === TYPE_RULE) { buffer += print_rule(child, css, indent_level) - } else if (child.type === 'Atrule') { + } else if (child.type === TYPE_ATRULE) { buffer += print_atrule(child, css, indent_level) } else { buffer += print_unknown(child, css, indent_level) @@ -250,7 +260,7 @@ function print_block(node, css, indent_level) { if (item.next !== null) { buffer += NEWLINE - if (child.type !== 'Declaration') { + if (child.type !== TYPE_DECLARATION) { buffer += NEWLINE } } @@ -285,7 +295,7 @@ function print_atrule(node, css, indent_level) { if (block === null) { // `@import url(style.css);` has no block, neither does `@layer layer1;` buffer += ';' - } else if (block.type === 'Block') { + } else if (block.type === TYPE_BLOCK) { buffer += print_block(block, css, indent_level) } @@ -308,7 +318,7 @@ function print_prelude(node, css) { .replace(/\s*([:,])/g, '$1 ') // force whitespace after colon or comma .replace(/\s*(=>|<=)\s*/g, ' $1 ') // force whitespace around => and <= .replace(/(?)(?])(?![<= ])(?![=> ])(?![ =>])/g, ' $1 ') - .replace(/\s+/g, ' ') // collapse multiple whitespaces into one + .replace(/\s+/g, SPACE) // collapse multiple whitespaces into one } /** @@ -355,7 +365,7 @@ function print_list(children, css) { // Values can be inside var() as fallback // var(--prop, VALUE) buffer += print_value(node, css) - } else if (node.type === 'Operator') { + } else if (node.type === TYPE_OPERATOR) { // 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 @@ -386,9 +396,9 @@ function print_list(children, css) { buffer += substr(node, css) } - if (node.type !== 'Operator') { - if (item.next) { - if (item.next.data.type !== 'Operator') { + if (node.type !== TYPE_OPERATOR) { + if (item.next !== null) { + if (item.next.data.type !== TYPE_OPERATOR) { buffer += SPACE } } @@ -459,16 +469,16 @@ function print(node, css, indent_level = 0) { // @ts-expect-error Property 'children' does not exist on type 'AnPlusB', but we're never using that let children = node.children - children.forEach((child) => { - if (child.type === 'Rule') { + children.forEach((child, item) => { + if (child.type === TYPE_RULE) { buffer += print_rule(child, css, indent_level) - } else if (child.type === 'Atrule') { + } else if (child.type === TYPE_ATRULE) { buffer += print_atrule(child, css, indent_level) } else { buffer += print_unknown(child, css, indent_level) } - if (child !== children.last) { + if (item.next !== null) { buffer += NEWLINE + NEWLINE } }) From 12fd4cdcc42ecb1b75907b7c3d5dd6de9e6fb442 Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 18 Feb 2024 23:04:24 +0100 Subject: [PATCH 4/5] get rid of final `.last` --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 169cc6c..44b4705 100644 --- a/index.js +++ b/index.js @@ -238,7 +238,7 @@ function print_block(node, css, indent_level) { if (child.type === TYPE_DECLARATION) { buffer += print_declaration(child, css, indent_level) - if (child === children.last) { + if (item.next === null) { buffer += LAST_SEMICOLON } else { buffer += ';' From 590d36723e1a046fc2e4b1c1e680b04e50a540bd Mon Sep 17 00:00:00 2001 From: Bart Veneman Date: Sun, 18 Feb 2024 23:08:39 +0100 Subject: [PATCH 5/5] more opertator spacing test --- test/minify.test.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/minify.test.js b/test/minify.test.js index c9adf42..feaec27 100644 --- a/test/minify.test.js +++ b/test/minify.test.js @@ -42,8 +42,8 @@ a { }) test('correctly minifies operators', () => { - let actual = minify(`a { width: calc(100% - 10px); }`) - let expected = `a{width:calc(100% - 10px)}` + let actual = minify(`a { width: calc(100% - 10px); height: calc(100 * 1%); }`) + let expected = `a{width:calc(100% - 10px);height:calc(100*1%)}` assert.equal(actual, expected) })