diff --git a/index.js b/index.js index 88b44d3..1c8b67b 100644 --- a/index.js +++ b/index.js @@ -10,6 +10,9 @@ const OPEN_PARENTHESES = '(' const CLOSE_PARENTHESES = ')' const OPEN_BRACKET = '[' const CLOSE_BRACKET = ']' +const OPEN_BRACE = '{' +const CLOSE_BRACE = '}' +const EMPTY_BLOCK = '{}' const TYPE_ATRULE = 'Atrule' const TYPE_RULE = 'Rule' const TYPE_BLOCK = 'Block' @@ -37,12 +40,18 @@ function lowercase(str) { * @returns {string} The formatted CSS */ export function format(css, { minify = false } = {}) { + /** @type {number[]} */ + let comments = [] + /** @type {import('css-tree').CssNode} */ let ast = parse(css, { positions: true, parseAtrulePrelude: false, parseCustomProperty: true, parseValue: true, + onComment: (/** @type {string} */ _, /** @type {import('css-tree').CssLocation} */ position) => { + comments.push(position.start.offset, position.end.offset) + } }) const NEWLINE = minify ? EMPTY_STRING : '\n' @@ -63,10 +72,52 @@ export function format(css, { minify = false } = {}) { /** @param {import('css-tree').CssNode} node */ function substr(node) { let loc = node.loc + // If the node has no location, return an empty string + // This is necessary for space toggles if (!loc) return EMPTY_STRING return css.slice(loc.start.offset, loc.end.offset) } + /** @param {import('css-tree').CssNode} node */ + function start_offset(node) { + let loc = /** @type {import('css-tree').CssLocation} */(node.loc) + return loc.start.offset + } + + /** @param {import('css-tree').CssNode} node */ + function end_offset(node) { + let loc = /** @type {import('css-tree').CssLocation} */(node.loc) + return loc.end.offset + } + + /** + * Get a comment from the CSS string after the first offset and before the second offset + * @param {number | undefined} after After which offset to look for comments + * @param {number | undefined} before Before which offset to look for comments + * @returns {string | undefined} The comment string, if found + */ + function print_comment(after, before) { + if (minify || after === undefined || before === undefined) { + return EMPTY_STRING + } + + let buffer = '' + 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 + } + /** @param {import('css-tree').Rule} node */ function print_rule(node) { let buffer @@ -77,6 +128,11 @@ export function format(css, { minify = false } = {}) { 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) } @@ -96,6 +152,12 @@ export function format(css, { minify = false } = {}) { if (item.next !== null) { buffer += `,` + 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 @@ -231,14 +293,34 @@ export function format(css, { minify = false } = {}) { let buffer = OPTIONAL_SPACE if (children.isEmpty) { - return buffer + '{}' + // 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 += '{' + NEWLINE + buffer += OPEN_BRACE + NEWLINE indent_level++ + let opening_comment = print_comment(start_offset(node), start_offset(/** @type {import('css-tree').CssNode} */(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) @@ -270,10 +352,13 @@ export function format(css, { minify = false } = {}) { } }) - indent_level-- + let closing_comment = print_comment(end_offset(/** @type {import('css-tree').CssNode} */(children.last)), end_offset(node)) + if (closing_comment) { + buffer += NEWLINE + indent(indent_level) + closing_comment + } - buffer += NEWLINE - buffer += indent(indent_level) + '}' + indent_level-- + buffer += NEWLINE + indent(indent_level) + CLOSE_BRACE return buffer } @@ -443,19 +528,40 @@ export function format(css, { minify = false } = {}) { let children = ast.children let buffer = EMPTY_STRING - 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 (children.first) { + let opening_comment = print_comment(0, start_offset(children.first)) + if (opening_comment) { + buffer += opening_comment + NEWLINE } - if (item.next !== null) { - buffer += NEWLINE + 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(/** @type {import('css-tree').CssNode} */(children.last)), end_offset(ast)) + if (closing_comment) { + buffer += NEWLINE + closing_comment } - }) + } else { + buffer += print_comment(0, end_offset(ast)) + } return buffer } diff --git a/test/comments.test.js b/test/comments.test.js index dcee547..f070fda 100644 --- a/test/comments.test.js +++ b/test/comments.test.js @@ -4,25 +4,451 @@ import { format } from '../index.js' let test = suite('Comments') -test.skip('regular comment before rule', () => { +test('only comment', () => { + let actual = format(`/* comment */`) + let expected = `/* comment */` + assert.is(actual, expected) +}) + +test('bang comment before rule', () => { let actual = format(` - /* comment */ + /*! comment */ selector {} `) - let expected = `/* comment */ + let expected = `/*! comment */ selector {}` assert.is(actual, expected) }) -test('bang comment before rule', () => { +test('before selectors', () => { let actual = format(` - /*! comment */ + /* comment */ + selector1, + selector2 { + property: value; + } + `) + let expected = `/* comment */ +selector1, +selector2 { + property: value; +}` + assert.is(actual, expected) +}) + +test('before nested selectors', () => { + let actual = format(` + a { + /* comment */ + & nested1, + & nested2 { + property: value; + } + } + `) + let expected = `a { + /* comment */ + & nested1, + & nested2 { + property: value; + } +}` + assert.is(actual, expected) +}) + +test('after selectors', () => { + let actual = format(` + selector1, + selector2 + /* comment */ { + property: value; + } + `) + let expected = `selector1, +selector2 +/* comment */ { + property: value; +}` + assert.is(actual, expected) +}) + +test('in between selectors', () => { + let actual = format(` + selector1, + /* comment */ + selector2 { + property: value; + } + `) + let expected = `selector1, +/* comment */ +selector2 { + property: value; +}` + assert.is(actual, expected) +}) + +test('in between nested selectors', () => { + let actual = format(` + a { + & nested1, + /* comment */ + & nested2 { + property: value; + } + } + `) + let expected = `a { + & nested1, + /* comment */ + & nested2 { + property: value; + } +}` + assert.is(actual, expected) +}) + +test('as first child in rule', () => { + let actual = format(` + selector { + /* comment */ + property: value; + } + `) + let expected = `selector { + /* comment */ + property: value; +}` + assert.is(actual, expected) +}) + +test('as last child in rule', () => { + let actual = format(` + selector { + property: value; + /* comment */ + } + `) + let expected = `selector { + property: value; + /* comment */ +}` + assert.is(actual, expected) +}) + +test('as last child in nested rule', () => { + let actual = format(` + a { + & selector { + property: value; + /* comment */ + } + } + `) + let expected = `a { + & selector { + property: value; + /* comment */ + } +}` + assert.is(actual, expected) +}) + +test('as only child in rule', () => { + let actual = format(` + selector { + /* comment */ + } + `) + let expected = `selector { + /* comment */ +}` + assert.is(actual, expected) +}) + +test('as only child in nested rule', () => { + let actual = format(`a { + & selector { + /* comment */ + } +}`) + let expected = `a { + & selector { + /* comment */ + } +}` + assert.is(actual, expected) +}) + +test('in between declarations', () => { + let actual = format(` + selector { + property: value; + /* comment */ + property: value; + } + `) + let expected = `selector { + property: value; + /* comment */ + property: value; +}` + assert.is(actual, expected) +}) + +test('in between nested declarations', () => { + let actual = format(` + a { + & selector { + property: value; + /* comment */ + property: value; + } + } + `) + let expected = `a { + & selector { + property: value; + /* comment */ + property: value; + } +}` + assert.is(actual, expected) +}) + +test('as first child in atrule', () => { + let actual = format(` + @media (min-width: 1000px) { + /* comment */ + selector { + property: value; + } + } + `) + let expected = `@media (min-width: 1000px) { + /* comment */ + selector { + property: value; + } +}` + assert.is(actual, expected) +}) + +test('as first child in nested atrule', () => { + let actual = format(` + @media all { + @media (min-width: 1000px) { + /* comment */ + selector { + property: value; + } + } + } + `) + let expected = `@media all { + @media (min-width: 1000px) { + /* comment */ + selector { + property: value; + } + } +}` + assert.is(actual, expected) +}) + +test('as last child in atrule', () => { + let actual = format(` + @media (min-width: 1000px) { + selector { + property: value; + } + /* comment */ + } + `) + let expected = `@media (min-width: 1000px) { + selector { + property: value; + } + /* comment */ +}` + assert.is(actual, expected) +}) + +test('as last child in nested atrule', () => { + let actual = format(` + @media all { + @media (min-width: 1000px) { + selector { + property: value; + } + /* comment */ + } + } + `) + let expected = `@media all { + @media (min-width: 1000px) { + selector { + property: value; + } + /* comment */ + } +}` + assert.is(actual, expected) +}) + +test('as only child in atrule', () => { + let actual = format(` + @media (min-width: 1000px) { + /* comment */ + } + `) + let expected = `@media (min-width: 1000px) { + /* comment */ +}` + assert.is(actual, expected) +}) + +test('as only child in nested atrule', () => { + let actual = format(` + @media all { + @media (min-width: 1000px) { + /* comment */ + } + } + `) + let expected = `@media all { + @media (min-width: 1000px) { + /* comment */ + } +}` + assert.is(actual, expected) +}) + +test('in between rules and atrules', () => { + let actual = format(` + /* comment 1 */ selector {} + /* comment 2 */ + @media (min-width: 1000px) { + /* comment 3 */ + selector {} + /* comment 4 */ + } + /* comment 5 */ `) - let expected = `/*! comment */ + let expected = `/* comment 1 */ +selector {} +/* comment 2 */ +@media (min-width: 1000px) { + /* comment 3 */ + selector {} + /* comment 4 */ +} +/* comment 5 */` + assert.is(actual, expected) +}) -selector {}` +test('comment before rule and atrule should not be separated by newline', () => { + let actual = format(` + /* comment 1 */ + selector {} + + /* comment 2 */ + @media (min-width: 1000px) { + /* comment 3 */ + + selector {} + /* comment 4 */ + } + `) + let expected = `/* comment 1 */ +selector {} +/* comment 2 */ +@media (min-width: 1000px) { + /* comment 3 */ + selector {} + /* comment 4 */ +}` + assert.is(actual, expected) +}) + +test('a declaration after multiple comments starts on a new line', () => { + let actual = format(` + selector { + /* comment 1 */ + /* comment 2 */ + --custom-property: value; + + /* comment 3 */ + /* comment 4 */ + --custom-property: value; + + /* comment 5 */ + /* comment 6 */ + --custom-property: value; + } + `) + let expected = `selector { + /* comment 1 */ + /* comment 2 */ + --custom-property: value; + /* comment 3 */ + /* comment 4 */ + --custom-property: value; + /* comment 5 */ + /* comment 6 */ + --custom-property: value; +}` + assert.is(actual, expected) +}) + +test('multiple comments in between rules and atrules', () => { + let actual = format(` + /* comment 1 */ + /* comment 1.1 */ + selector {} + /* comment 2 */ + /* comment 2.1 */ + @media (min-width: 1000px) { + /* comment 3 */ + /* comment 3.1 */ + selector {} + /* comment 4 */ + /* comment 4.1 */ + } + /* comment 5 */ + /* comment 5.1 */ + `) + let expected = `/* comment 1 */ +/* comment 1.1 */ +selector {} +/* comment 2 */ +/* comment 2.1 */ +@media (min-width: 1000px) { + /* comment 3 */ + /* comment 3.1 */ + selector {} + /* comment 4 */ + /* comment 4.1 */ +} +/* comment 5 */ +/* comment 5.1 */` + assert.is(actual, expected) +}) + +test('puts every comment on a new line', () => { + let actual = format(` + x { + /*--font-family: inherit;*/ /*--font-style: normal;*/ + --border-top-color: var(--root-color--support); + } +`) + let expected = `x { + /*--font-family: inherit;*/ + /*--font-style: normal;*/ + --border-top-color: var(--root-color--support); +}` assert.is(actual, expected) }) @@ -40,9 +466,9 @@ test('in @supports prelude', () => { assert.is(actual, expected) }) -test.skip('in @import prelude before specifier', () => { - let actual = format('@import /*test*/"foo"/*test*/;') - let expected = '@import /*test*/"foo"/*test*/;' +test('skip in @import prelude before specifier', () => { + let actual = format('@import /*test*/"foo";') + let expected = '@import "foo";' assert.is(actual, expected) }) @@ -52,13 +478,13 @@ test('in @import prelude after specifier', () => { assert.is(actual, expected) }) -test.skip('in selector combinator', () => { +test('skip in selector combinator', () => { let actual = format(` a/*test*/ /*test*/b, a/*test*/+/*test*/b {} `) - let expected = `a/*test*/ /*test*/b, -a /*test*/ + /*test*/ b {}` + let expected = `a b, +a + b {}` assert.is(actual, expected) }) @@ -68,41 +494,41 @@ test('in attribute selector', () => { assert.is(actual, expected) }) -test.skip('in var() with fallback', () => { +test('skip in var() with fallback', () => { let actual = format(`a { prop: var( /* 1 */ --name /* 2 */ , /* 3 */ 1 /* 4 */ ) }`) let expected = `a { - prop: var(/* 1 */ --name /* 2 */, /* 3 */ 1 /* 4 */); + prop: var(--name, 1); }` assert.is(actual, expected) }) -test.skip('in custom property declaration', () => { +test('skip in custom property declaration (space toggle)', () => { let actual = format(`a { --test: /*test*/; }`) let expected = `a { - --test: /*test*/; + --test: ; }` assert.is(actual, expected) }) -test.skip('before value', () => { +test('before value', () => { let actual = format(`a { prop: /*test*/value; }`) let expected = `a { - prop: /*test*/value; + prop: value; }` assert.is(actual, expected) }) -test.skip('after value', () => { +test('after value', () => { let actual = format(`a { prop: value/*test*/; }`) let expected = `a { - prop: value/*test*/; + prop: value; }` assert.is(actual, expected) }) -test.skip('in value functions', () => { +test('skip in value functions', () => { let actual = format(` a { background-image: linear-gradient(/* comment */red, green); @@ -112,12 +538,28 @@ test.skip('in value functions', () => { } `) let expected = `a { - background-image: linear-gradient(/* comment */red, green); - background-image: linear-gradient(red/* comment */, green); - background-image: linear-gradient(red, green/* comment */); - background-image: linear-gradient(red, green)/* comment */ + background-image: linear-gradient(red, green); + background-image: linear-gradient(red, green); + background-image: linear-gradient(red, green); + background-image: linear-gradient(red, green); }` assert.is(actual, expected) }) +test('strips comments in minification mode', () => { + let actual = format(` + /* comment 1 */ + selector {} + /* comment 2 */ + @media (min-width: 1000px) { + /* comment 3 */ + selector {} + /* comment 4 */ + } + /* comment 5 */ + `, { minify: true }) + let expected = `selector{}@media (min-width: 1000px){selector{}}` + assert.is(actual, expected) +}) + test.run() diff --git a/test/rules.test.js b/test/rules.test.js index b28e918..821a1de 100644 --- a/test/rules.test.js +++ b/test/rules.test.js @@ -146,7 +146,7 @@ test('formats nested rules with selectors starting with', () => { }) test('newlines between declarations, nested rules and more declarations', () => { - let actual = format(`/* test */ a { font: 0/0; & b { color: red; } color: green;}`) + let actual = format(`a { font: 0/0; & b { color: red; } color: green;}`) let expected = `a { font: 0/0;