From 277e2cdd4c4846ea3af04e93c90393c551a24050 Mon Sep 17 00:00:00 2001 From: Adam Wathan Date: Fri, 8 Mar 2024 15:28:58 -0500 Subject: [PATCH] Parse CSS files with CRLF line endings (#13162) * Parse CSS files with CRLF line endings * Update changelog --------- Co-authored-by: Adam Wathan <4323180+adamwathan@users.noreply.github.com> --- CHANGELOG.md | 1 + packages/tailwindcss/src/css-parser.test.ts | 1731 ++++++++++--------- packages/tailwindcss/src/css-parser.ts | 2 + 3 files changed, 875 insertions(+), 859 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 21fa52d30eec..fc3c34b441ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Don't error on `@apply` with leading/trailing whitespace ([#13144](https://github.com/tailwindlabs/tailwindcss/pull/13144)) +- Correctly parse CSS using Windows line endings ([#13162](https://github.com/tailwindlabs/tailwindcss/pull/13162)) ### Added diff --git a/packages/tailwindcss/src/css-parser.test.ts b/packages/tailwindcss/src/css-parser.test.ts index 9d4b26d862df..d7aa41b26689 100644 --- a/packages/tailwindcss/src/css-parser.test.ts +++ b/packages/tailwindcss/src/css-parser.test.ts @@ -1,995 +1,1008 @@ import { describe, expect, it } from 'vitest' -import { parse } from './css-parser' +import * as CSS from './css-parser' const css = String.raw -describe('comments', () => { - it('should parse a comment and ignore it', () => { - expect( - parse(css` - /*Hello, world!*/ - `), - ).toEqual([]) - }) - - it('should parse a comment with an escaped ending and ignore it', () => { - expect( - parse(css` - /*Hello, \*\/ world!*/ - `), - ).toEqual([]) - }) - - it('should parse a comment inside of a selector and ignore it', () => { - expect( - parse(css` - .foo { - /*Example comment*/ - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [], - }, - ]) - }) - - it('should remove comments in between selectors while maintaining the correct whitespace', () => { - expect( - parse(css` - .foo/*.bar*/.baz { - } - .foo/*.bar*//*.baz*/.qux - { - } - .foo/*.bar*/ /*.baz*/.qux { - /* ^ whitespace */ - } - .foo /*.bar*/.baz { - /*^ whitespace */ - } - .foo/*.bar*/ .baz { - /* ^ whitespace */ - } - .foo/*.bar*/ - .baz { - } - `), - ).toEqual([ - { kind: 'rule', selector: '.foo.baz', nodes: [] }, - { kind: 'rule', selector: '.foo.qux', nodes: [] }, - { kind: 'rule', selector: '.foo .qux', nodes: [] }, - { kind: 'rule', selector: '.foo .baz', nodes: [] }, - { kind: 'rule', selector: '.foo .baz', nodes: [] }, - { kind: 'rule', selector: '.foo .baz', nodes: [] }, - ]) - }) - - it('should collect license comments', () => { - expect( - parse(css` - /*! License #1 */ - /*! - * License #2 - */ - `), - ).toEqual([ - { kind: 'comment', value: '! License #1 ' }, - { - kind: 'comment', - value: `! - * License #2 - `, - }, - ]) - }) - - it('should hoist all license comments', () => { - expect( - parse(css` - /*! License #1 */ - .foo { - color: red; /*! License #1.5 */ - } - /*! License #2 */ - .bar { - /*! License #2.5 */ - color: blue; - } - /*! License #3 */ - `), - ).toEqual([ - { kind: 'comment', value: '! License #1 ' }, - { kind: 'comment', value: '! License #1.5 ' }, - { kind: 'comment', value: '! License #2 ' }, - { kind: 'comment', value: '! License #2.5 ' }, - { kind: 'comment', value: '! License #3 ' }, - { - kind: 'rule', - selector: '.foo', - nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], - }, - { - kind: 'rule', - selector: '.bar', - nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }], - }, - ]) - }) - - it('should handle comments before element selectors', () => { - expect( - parse(css` - .dark /* comment */p { - color: black; - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.dark p', - nodes: [ - { - kind: 'declaration', - property: 'color', - value: 'black', - important: false, - }, - ], - }, - ]) - }) -}) - -describe('declarations', () => { - it('should parse a simple declaration', () => { - expect( - parse(css` - color: red; - `), - ).toEqual([{ kind: 'declaration', property: 'color', value: 'red', important: false }]) - }) - - it('should parse declarations with strings', () => { - expect( - parse(css` - content: 'Hello, world!'; - `), - ).toEqual([ - { kind: 'declaration', property: 'content', value: "'Hello, world!'", important: false }, - ]) - }) - - it('should parse declarations with nested strings', () => { - expect( - parse(css` - content: 'Good, "monday", morning!'; - `), - ).toEqual([ - { - kind: 'declaration', - property: 'content', - value: `'Good, "monday", morning!'`, - important: false, - }, - ]) - }) +describe.each(['Unix', 'Windows'])('Line endings: %s', (lineEndings) => { + function parse(string: string) { + return CSS.parse(string.replaceAll(/\r?\n/g, lineEndings === 'Windows' ? '\r\n' : '\n')) + } - it('should parse declarations with nested strings that are not balanced', () => { - expect( - parse(css` - content: "It's a beautiful day!"; - `), - ).toEqual([ - { - kind: 'declaration', - property: 'content', - value: `"It's a beautiful day!"`, - important: false, - }, - ]) - }) + describe('comments', () => { + it('should parse a comment and ignore it', () => { + expect( + parse(css` + /*Hello, world!*/ + `), + ).toEqual([]) + }) - it('should parse declarations with with strings and escaped string endings', () => { - expect( - parse(css` - content: 'These are not the end "\' of the string'; - `), - ).toEqual([ - { - kind: 'declaration', - property: 'content', - value: `'These are not the end \"\\' of the string'`, - important: false, - }, - ]) - }) + it('should parse a comment with an escaped ending and ignore it', () => { + expect( + parse(css` + /*Hello, \*\/ world!*/ + `), + ).toEqual([]) + }) - describe('important', () => { - it('should parse declarations with `!important`', () => { + it('should parse a comment inside of a selector and ignore it', () => { expect( parse(css` - width: 123px !important; + .foo { + /*Example comment*/ + } `), ).toEqual([ { - kind: 'declaration', - property: 'width', - value: '123px', - important: true, + kind: 'rule', + selector: '.foo', + nodes: [], }, ]) }) - it('should parse declarations with `!important` when there is a trailing comment', () => { + it('should remove comments in between selectors while maintaining the correct whitespace', () => { expect( parse(css` - width: 123px !important /* Very important */; + .foo/*.bar*/.baz { + } + .foo/*.bar*//*.baz*/.qux + { + } + .foo/*.bar*/ /*.baz*/.qux { + /* ^ whitespace */ + } + .foo /*.bar*/.baz { + /*^ whitespace */ + } + .foo/*.bar*/ .baz { + /* ^ whitespace */ + } + .foo/*.bar*/ + .baz { + } `), ).toEqual([ - { - kind: 'declaration', - property: 'width', - value: '123px', - important: true, - }, + { kind: 'rule', selector: '.foo.baz', nodes: [] }, + { kind: 'rule', selector: '.foo.qux', nodes: [] }, + { kind: 'rule', selector: '.foo .qux', nodes: [] }, + { kind: 'rule', selector: '.foo .baz', nodes: [] }, + { kind: 'rule', selector: '.foo .baz', nodes: [] }, + { kind: 'rule', selector: '.foo .baz', nodes: [] }, ]) }) - }) - describe('Custom properties', () => { - it('should parse a custom property', () => { + it('should collect license comments', () => { expect( parse(css` - --foo: bar; + /*! License #1 */ + /*! + * License #2 + */ `), - ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: false }]) + ).toEqual([ + { kind: 'comment', value: '! License #1 ' }, + { + kind: 'comment', + value: `! + * License #2 + `, + }, + ]) }) - it('should parse a minified custom property', () => { - expect(parse(':root{--foo:bar;}')).toEqual([ + it('should hoist all license comments', () => { + expect( + parse(css` + /*! License #1 */ + .foo { + color: red; /*! License #1.5 */ + } + /*! License #2 */ + .bar { + /*! License #2.5 */ + color: blue; + } + /*! License #3 */ + `), + ).toEqual([ + { kind: 'comment', value: '! License #1 ' }, + { kind: 'comment', value: '! License #1.5 ' }, + { kind: 'comment', value: '! License #2 ' }, + { kind: 'comment', value: '! License #2.5 ' }, + { kind: 'comment', value: '! License #3 ' }, { kind: 'rule', - selector: ':root', - nodes: [ - { - kind: 'declaration', - property: '--foo', - value: 'bar', - important: false, - }, - ], + selector: '.foo', + nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], + }, + { + kind: 'rule', + selector: '.bar', + nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }], }, ]) }) - it('should parse a minified custom property with no semicolon ', () => { - expect(parse(':root{--foo:bar}')).toEqual([ + it('should handle comments before element selectors', () => { + expect( + parse(css` + .dark /* comment */p { + color: black; + } + `), + ).toEqual([ { kind: 'rule', - selector: ':root', + selector: '.dark p', nodes: [ { kind: 'declaration', - property: '--foo', - value: 'bar', + property: 'color', + value: 'black', important: false, }, ], }, ]) }) + }) - it('should parse a custom property with a missing ending `;`', () => { - expect( - parse(` - --foo: bar - `), - ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: false }]) - }) - - it('should parse a custom property with a missing ending `;` and `!important`', () => { - expect( - parse(` - --foo: bar !important - `), - ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: true }]) - }) - - it('should parse a custom property with an embedded programming language', () => { + describe('declarations', () => { + it('should parse a simple declaration', () => { expect( parse(css` - --foo: if(x > 5) this.width = 10; + color: red; `), - ).toEqual([ - { - kind: 'declaration', - property: '--foo', - value: 'if(x > 5) this.width = 10', - important: false, - }, - ]) + ).toEqual([{ kind: 'declaration', property: 'color', value: 'red', important: false }]) }) - it('should parse a custom property with an empty block as the value', () => { - expect(parse('--foo: {};')).toEqual([ - { - kind: 'declaration', - property: '--foo', - value: '{}', - important: false, - }, - ]) - }) - - it('should parse a custom property with a block including nested "css"', () => { + it('should parse declarations with strings', () => { expect( parse(css` - --foo: { - background-color: red; - /* A comment */ - content: 'Hello, world!'; - }; + content: 'Hello, world!'; `), ).toEqual([ - { - kind: 'declaration', - property: '--foo', - value: `{ - background-color: red; - /* A comment */ - content: 'Hello, world!'; - }`, - important: false, - }, + { kind: 'declaration', property: 'content', value: "'Hello, world!'", important: false }, ]) }) - it('should parse a custom property with a block including nested "css" and comments with end characters inside them', () => { + it('should parse declarations with nested strings', () => { expect( parse(css` - --foo: { - background-color: red; - /* A comment ; */ - content: 'Hello, world!'; - }; - --bar: { - background-color: red; - /* A comment } */ - content: 'Hello, world!'; - }; + content: 'Good, "monday", morning!'; `), ).toEqual([ { kind: 'declaration', - property: '--foo', - value: `{ - background-color: red; - /* A comment ; */ - content: 'Hello, world!'; - }`, - important: false, - }, - { - kind: 'declaration', - property: '--bar', - value: `{ - background-color: red; - /* A comment } */ - content: 'Hello, world!'; - }`, + property: 'content', + value: `'Good, "monday", morning!'`, important: false, }, ]) }) - it('should parse a custom property with escaped characters in the value', () => { + it('should parse declarations with nested strings that are not balanced', () => { expect( parse(css` - --foo: This is not the end \;, but this is; + content: "It's a beautiful day!"; `), ).toEqual([ { kind: 'declaration', - property: '--foo', - value: 'This is not the end \\;, but this is', + property: 'content', + value: `"It's a beautiful day!"`, important: false, }, ]) }) - it('should parse a custom property with escaped characters inside a comment in the value', () => { + it('should parse declarations with with strings and escaped string endings', () => { expect( parse(css` - --foo: /* This is not the end \; this is also not the end ; */ but this is; + content: 'These are not the end "\' of the string'; `), ).toEqual([ { kind: 'declaration', - property: '--foo', - value: '/* This is not the end \\; this is also not the end ; */ but this is', + property: 'content', + value: `'These are not the end \"\\' of the string'`, important: false, }, ]) }) - it('should parse empty custom properties', () => { - expect( - parse(css` - --foo: ; - `), - ).toEqual([{ kind: 'declaration', property: '--foo', value: '', important: false }]) - }) - - it('should parse custom properties with `!important`', () => { - expect( - parse(css` - --foo: bar !important; - `), - ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: true }]) - }) - }) - - it('should parse multiple declarations', () => { - expect( - parse(css` - color: red; - background-color: blue; - `), - ).toEqual([ - { kind: 'declaration', property: 'color', value: 'red', important: false }, - { kind: 'declaration', property: 'background-color', value: 'blue', important: false }, - ]) - }) - - /** - * - */ - it('should correctly parse comments with `:` inside of them', () => { - expect( - parse(css` - color/* color: #f00; */: red; - font-weight:/* font-size: 12px */ bold; - - .foo { - background-color/* background-color: #f00; */: red; - } - `), - ).toEqual([ - { kind: 'declaration', property: 'color', value: 'red', important: false }, - { kind: 'declaration', property: 'font-weight', value: 'bold', important: false }, - { - kind: 'rule', - selector: '.foo', - nodes: [ + describe('important', () => { + it('should parse declarations with `!important`', () => { + expect( + parse(css` + width: 123px !important; + `), + ).toEqual([ { kind: 'declaration', - property: 'background-color', - value: 'red', - important: false, + property: 'width', + value: '123px', + important: true, }, - ], - }, - ]) - }) - - it('should parse mutlti-line declarations', () => { - expect( - parse(css` - .foo { - grid-template-areas: - 'header header header' - 'sidebar main main' - 'footer footer footer'; - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ + ]) + }) + + it('should parse declarations with `!important` when there is a trailing comment', () => { + expect( + parse(css` + width: 123px !important /* Very important */; + `), + ).toEqual([ { kind: 'declaration', - property: 'grid-template-areas', - value: "'header header header' 'sidebar main main' 'footer footer footer'", - important: false, + property: 'width', + value: '123px', + important: true, }, - ], - }, - ]) - }) -}) - -describe('selectors', () => { - it('should parse a simple selector', () => { - expect( - parse(css` - .foo { - } - `), - ).toEqual([{ kind: 'rule', selector: '.foo', nodes: [] }]) - }) - - it('should parse selectors with escaped characters', () => { - expect( - parse(css` - .hover\:foo:hover { - } - .\32 xl\:foo { - } - `), - ).toEqual([ - { kind: 'rule', selector: '.hover\\:foo:hover', nodes: [] }, - { kind: 'rule', selector: '.\\32 xl\\:foo', nodes: [] }, - ]) - }) - - it('should parse multiple simple selectors', () => { - expect( - parse(css` - .foo, - .bar { - } - `), - ).toEqual([{ kind: 'rule', selector: '.foo, .bar', nodes: [] }]) - }) - - it('should parse multiple declarations inside of a selector', () => { - expect( - parse(css` - .foo { - color: red; - font-size: 16px; - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ - { kind: 'declaration', property: 'color', value: 'red', important: false }, - { kind: 'declaration', property: 'font-size', value: '16px', important: false }, - ], - }, - ]) - }) - - it('should parse rules with declarations that end with a missing `;`', () => { - expect( - parse(` - .foo { - color: red; - font-size: 16px - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ - { kind: 'declaration', property: 'color', value: 'red', important: false }, - { kind: 'declaration', property: 'font-size', value: '16px', important: false }, - ], - }, - ]) - }) - - it('should parse rules with declarations that end with a missing `;` and `!important`', () => { - expect( - parse(` - .foo { - color: red; - font-size: 16px !important - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ - { kind: 'declaration', property: 'color', value: 'red', important: false }, - { kind: 'declaration', property: 'font-size', value: '16px', important: true }, - ], - }, - ]) - }) -}) - -describe('at-rules', () => { - it('should parse an at-rule without a block', () => { - expect( - parse(css` - @charset "UTF-8"; - `), - ).toEqual([{ kind: 'rule', selector: '@charset "UTF-8"', nodes: [] }]) - }) - - it("should parse an at-rule without a block or semicolon when it's the last rule in a block", () => { - expect( - parse(` - @layer utilities { - @tailwind utilities - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '@layer utilities', - nodes: [{ kind: 'rule', selector: '@tailwind utilities', nodes: [] }], - }, - ]) - }) - - it('should parse a nested at-rule without a block', () => { - expect( - parse(css` - @layer utilities { - @charset "UTF-8"; - } - - .foo { - @apply font-bold hover:text-red-500; - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '@layer utilities', - nodes: [{ kind: 'rule', selector: '@charset "UTF-8"', nodes: [] }], - }, - { - kind: 'rule', - selector: '.foo', - nodes: [{ kind: 'rule', selector: '@apply font-bold hover:text-red-500', nodes: [] }], - }, - ]) - }) - - it('should parse custom at-rules without a block', () => { - expect( - parse(css` - @tailwind; - @tailwind base; - `), - ).toEqual([ - { kind: 'rule', selector: '@tailwind', nodes: [] }, - { kind: 'rule', selector: '@tailwind base', nodes: [] }, - ]) - }) + ]) + }) + }) - it('should parse (nested) media queries', () => { - expect( - parse(css` - @media (width >= 600px) { - .foo { - color: red; - @media (width >= 800px) { - color: blue; - } - @media (width >= 1000px) { - color: green; - } - } - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '@media (width >= 600px)', - nodes: [ + describe('Custom properties', () => { + it('should parse a custom property', () => { + expect( + parse(css` + --foo: bar; + `), + ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: false }]) + }) + + it('should parse a minified custom property', () => { + expect(parse(':root{--foo:bar;}')).toEqual([ { kind: 'rule', - selector: '.foo', + selector: ':root', nodes: [ - { kind: 'declaration', property: 'color', value: 'red', important: false }, - { - kind: 'rule', - selector: '@media (width >= 800px)', - nodes: [ - { kind: 'declaration', property: 'color', value: 'blue', important: false }, - ], - }, { - kind: 'rule', - selector: '@media (width >= 1000px)', - nodes: [ - { kind: 'declaration', property: 'color', value: 'green', important: false }, - ], + kind: 'declaration', + property: '--foo', + value: 'bar', + important: false, }, ], }, - ], - }, - ]) - }) - - it('should parse at-rules that span multiple lines', () => { - expect( - parse(css` - .foo { - @apply hover:text-red-100 - sm:hover:text-red-200 - md:hover:text-red-300 - lg:hover:text-red-400 - xl:hover:text-red-500; - } - `), - ).toEqual([ - { - kind: 'rule', - nodes: [ - { - kind: 'rule', - nodes: [], - selector: - '@apply hover:text-red-100 sm:hover:text-red-200 md:hover:text-red-300 lg:hover:text-red-400 xl:hover:text-red-500', - }, - ], - selector: '.foo', - }, - ]) - }) -}) + ]) + }) -describe('nesting', () => { - it('should parse nested rules', () => { - expect( - parse(css` - .foo { - .bar { - .baz { - color: red; - } - } - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ + it('should parse a minified custom property with no semicolon ', () => { + expect(parse(':root{--foo:bar}')).toEqual([ { kind: 'rule', - selector: '.bar', + selector: ':root', nodes: [ { - kind: 'rule', - selector: '.baz', - nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], + kind: 'declaration', + property: '--foo', + value: 'bar', + important: false, }, ], }, - ], - }, - ]) - }) - - it('should parse nested selector with `&`', () => { - expect( - parse(css` - .foo { - color: red; - - &:hover { - color: blue; - } - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ - { kind: 'declaration', property: 'color', value: 'red', important: false }, + ]) + }) + + it('should parse a custom property with a missing ending `;`', () => { + expect( + parse(` + --foo: bar + `), + ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: false }]) + }) + + it('should parse a custom property with a missing ending `;` and `!important`', () => { + expect( + parse(` + --foo: bar !important + `), + ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: true }]) + }) + + it('should parse a custom property with an embedded programming language', () => { + expect( + parse(css` + --foo: if(x > 5) this.width = 10; + `), + ).toEqual([ { - kind: 'rule', - selector: '&:hover', - nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }], + kind: 'declaration', + property: '--foo', + value: 'if(x > 5) this.width = 10', + important: false, }, - ], - }, - ]) - }) - - it('should parse nested sibling selectors', () => { - expect( - parse(css` - .foo { - .bar { - color: red; - } + ]) + }) - .baz { - color: blue; - } - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ + it('should parse a custom property with an empty block as the value', () => { + expect(parse('--foo: {};')).toEqual([ { - kind: 'rule', - selector: '.bar', - nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], + kind: 'declaration', + property: '--foo', + value: '{}', + important: false, }, + ]) + }) + + it('should parse a custom property with a block including nested "css"', () => { + expect( + parse(css` + --foo: { + background-color: red; + /* A comment */ + content: 'Hello, world!'; + }; + `), + ).toEqual([ { - kind: 'rule', - selector: '.baz', - nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }], + kind: 'declaration', + property: '--foo', + value: `{ + background-color: red; + /* A comment */ + content: 'Hello, world!'; + }`, + important: false, }, - ], - }, - ]) - }) - - it('should parse nested sibling selectors and sibling declarations', () => { - expect( - parse(css` - .foo { - font-weight: bold; - text-declaration-line: underline; - - .bar { - color: red; - } - - --in-between: 1; - - .baz { - color: blue; - } - - --at-the-end: 2; - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ - { kind: 'declaration', property: 'font-weight', value: 'bold', important: false }, + ]) + }) + + it('should parse a custom property with a block including nested "css" and comments with end characters inside them', () => { + expect( + parse(css` + --foo: { + background-color: red; + /* A comment ; */ + content: 'Hello, world!'; + }; + --bar: { + background-color: red; + /* A comment } */ + content: 'Hello, world!'; + }; + `), + ).toEqual([ { kind: 'declaration', - property: 'text-declaration-line', - value: 'underline', + property: '--foo', + value: `{ + background-color: red; + /* A comment ; */ + content: 'Hello, world!'; + }`, important: false, }, { - kind: 'rule', - selector: '.bar', - nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], + kind: 'declaration', + property: '--bar', + value: `{ + background-color: red; + /* A comment } */ + content: 'Hello, world!'; + }`, + important: false, }, - { kind: 'declaration', property: '--in-between', value: '1', important: false }, + ]) + }) + + it('should parse a custom property with escaped characters in the value', () => { + expect( + parse(css` + --foo: This is not the end \;, but this is; + `), + ).toEqual([ { - kind: 'rule', - selector: '.baz', - nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }], + kind: 'declaration', + property: '--foo', + value: 'This is not the end \\;, but this is', + important: false, }, - { kind: 'declaration', property: '--at-the-end', value: '2', important: false }, - ], - }, - ]) - }) -}) - -describe('complex', () => { - it('should parse complex examples', () => { - expect( - parse(css` - @custom \{ { - foo: bar; - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '@custom \\{', - nodes: [{ kind: 'declaration', property: 'foo', value: 'bar', important: false }], - }, - ]) - }) - - it('should parse minified nested CSS', () => { - expect( - parse('.foo{color:red;@media(width>=600px){.bar{color:blue;font-weight:bold}}}'), - ).toEqual([ - { - kind: 'rule', - selector: '.foo', - nodes: [ - { kind: 'declaration', property: 'color', value: 'red', important: false }, + ]) + }) + + it('should parse a custom property with escaped characters inside a comment in the value', () => { + expect( + parse(css` + --foo: /* This is not the end \; this is also not the end ; */ but this is; + `), + ).toEqual([ { - kind: 'rule', - selector: '@media(width>=600px)', - nodes: [ - { - kind: 'rule', - selector: '.bar', - nodes: [ - { kind: 'declaration', property: 'color', value: 'blue', important: false }, - { kind: 'declaration', property: 'font-weight', value: 'bold', important: false }, - ], - }, - ], + kind: 'declaration', + property: '--foo', + value: '/* This is not the end \\; this is also not the end ; */ but this is', + important: false, }, - ], - }, - ]) - }) - - it('should ignore everything inside of comments', () => { - expect( - parse(css` - .foo:has(.bar /* instead \*\/ of .baz { */) { - color: red; - } - `), - ).toEqual([ - { - kind: 'rule', - selector: '.foo:has(.bar )', - nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], - }, - ]) - }) -}) - -describe('errors', () => { - it('should error when curly brackets are unbalanced (opening)', () => { - expect(() => - parse(` - .foo { - color: red; - } - - .bar - /* ^ Missing opening { */ - color: blue; - } - `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Missing opening {]`) - }) + ]) + }) + + it('should parse empty custom properties', () => { + expect( + parse(css` + --foo: ; + `), + ).toEqual([{ kind: 'declaration', property: '--foo', value: '', important: false }]) + }) + + it('should parse custom properties with `!important`', () => { + expect( + parse(css` + --foo: bar !important; + `), + ).toEqual([{ kind: 'declaration', property: '--foo', value: 'bar', important: true }]) + }) + }) - it('should error when curly brackets are unbalanced (closing)', () => { - expect(() => - parse(` - .foo { + it('should parse multiple declarations', () => { + expect( + parse(css` color: red; - } - - .bar { - color: blue; - - /* ^ Missing closing } */ - `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Missing closing } at .bar]`) - }) + background-color: blue; + `), + ).toEqual([ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + { kind: 'declaration', property: 'background-color', value: 'blue', important: false }, + ]) + }) - it('should error when an unterminated string is used', () => { - expect(() => - parse(css` - .foo { - content: "Hello world! - /* ^ missing " */ - font-weight: bold; - } - `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`) - }) + /** + * + */ + it('should correctly parse comments with `:` inside of them', () => { + expect( + parse(css` + color/* color: #f00; */: red; + font-weight:/* font-size: 12px */ bold; - it('should error when an unterminated string is used with a `;`', () => { - expect(() => - parse(css` - .foo { - content: "Hello world!; - /* ^ missing " */ - font-weight: bold; - } - `), - ).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!;"]`) + .foo { + background-color/* background-color: #f00; */: red; + } + `), + ).toEqual([ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + { kind: 'declaration', property: 'font-weight', value: 'bold', important: false }, + { + kind: 'rule', + selector: '.foo', + nodes: [ + { + kind: 'declaration', + property: 'background-color', + value: 'red', + important: false, + }, + ], + }, + ]) + }) + + it('should parse mutlti-line declarations', () => { + expect( + parse(css` + .foo { + grid-template-areas: + 'header header header' + 'sidebar main main' + 'footer footer footer'; + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { + kind: 'declaration', + property: 'grid-template-areas', + value: "'header header header' 'sidebar main main' 'footer footer footer'", + important: false, + }, + ], + }, + ]) + }) + }) + + describe('selectors', () => { + it('should parse a simple selector', () => { + expect( + parse(css` + .foo { + } + `), + ).toEqual([{ kind: 'rule', selector: '.foo', nodes: [] }]) + }) + + it('should parse selectors with escaped characters', () => { + expect( + parse(css` + .hover\:foo:hover { + } + .\32 xl\:foo { + } + `), + ).toEqual([ + { kind: 'rule', selector: '.hover\\:foo:hover', nodes: [] }, + { kind: 'rule', selector: '.\\32 xl\\:foo', nodes: [] }, + ]) + }) + + it('should parse multiple simple selectors', () => { + expect( + parse(css` + .foo, + .bar { + } + `), + ).toEqual([{ kind: 'rule', selector: '.foo, .bar', nodes: [] }]) + }) + + it('should parse multiple declarations inside of a selector', () => { + expect( + parse(css` + .foo { + color: red; + font-size: 16px; + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + { kind: 'declaration', property: 'font-size', value: '16px', important: false }, + ], + }, + ]) + }) + + it('should parse rules with declarations that end with a missing `;`', () => { + expect( + parse(` + .foo { + color: red; + font-size: 16px + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + { kind: 'declaration', property: 'font-size', value: '16px', important: false }, + ], + }, + ]) + }) + + it('should parse rules with declarations that end with a missing `;` and `!important`', () => { + expect( + parse(` + .foo { + color: red; + font-size: 16px !important + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + { kind: 'declaration', property: 'font-size', value: '16px', important: true }, + ], + }, + ]) + }) + }) + + describe('at-rules', () => { + it('should parse an at-rule without a block', () => { + expect( + parse(css` + @charset "UTF-8"; + `), + ).toEqual([{ kind: 'rule', selector: '@charset "UTF-8"', nodes: [] }]) + }) + + it("should parse an at-rule without a block or semicolon when it's the last rule in a block", () => { + expect( + parse(` + @layer utilities { + @tailwind utilities + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '@layer utilities', + nodes: [{ kind: 'rule', selector: '@tailwind utilities', nodes: [] }], + }, + ]) + }) + + it('should parse a nested at-rule without a block', () => { + expect( + parse(css` + @layer utilities { + @charset "UTF-8"; + } + + .foo { + @apply font-bold hover:text-red-500; + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '@layer utilities', + nodes: [{ kind: 'rule', selector: '@charset "UTF-8"', nodes: [] }], + }, + { + kind: 'rule', + selector: '.foo', + nodes: [{ kind: 'rule', selector: '@apply font-bold hover:text-red-500', nodes: [] }], + }, + ]) + }) + + it('should parse custom at-rules without a block', () => { + expect( + parse(css` + @tailwind; + @tailwind base; + `), + ).toEqual([ + { kind: 'rule', selector: '@tailwind', nodes: [] }, + { kind: 'rule', selector: '@tailwind base', nodes: [] }, + ]) + }) + + it('should parse (nested) media queries', () => { + expect( + parse(css` + @media (width >= 600px) { + .foo { + color: red; + @media (width >= 800px) { + color: blue; + } + @media (width >= 1000px) { + color: green; + } + } + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '@media (width >= 600px)', + nodes: [ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + { + kind: 'rule', + selector: '@media (width >= 800px)', + nodes: [ + { kind: 'declaration', property: 'color', value: 'blue', important: false }, + ], + }, + { + kind: 'rule', + selector: '@media (width >= 1000px)', + nodes: [ + { kind: 'declaration', property: 'color', value: 'green', important: false }, + ], + }, + ], + }, + ], + }, + ]) + }) + + it('should parse at-rules that span multiple lines', () => { + expect( + parse(css` + .foo { + @apply hover:text-red-100 + sm:hover:text-red-200 + md:hover:text-red-300 + lg:hover:text-red-400 + xl:hover:text-red-500; + } + `), + ).toEqual([ + { + kind: 'rule', + nodes: [ + { + kind: 'rule', + nodes: [], + selector: + '@apply hover:text-red-100 sm:hover:text-red-200 md:hover:text-red-300 lg:hover:text-red-400 xl:hover:text-red-500', + }, + ], + selector: '.foo', + }, + ]) + }) + }) + + describe('nesting', () => { + it('should parse nested rules', () => { + expect( + parse(css` + .foo { + .bar { + .baz { + color: red; + } + } + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { + kind: 'rule', + selector: '.bar', + nodes: [ + { + kind: 'rule', + selector: '.baz', + nodes: [ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + ], + }, + ], + }, + ], + }, + ]) + }) + + it('should parse nested selector with `&`', () => { + expect( + parse(css` + .foo { + color: red; + + &:hover { + color: blue; + } + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + { + kind: 'rule', + selector: '&:hover', + nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }], + }, + ], + }, + ]) + }) + + it('should parse nested sibling selectors', () => { + expect( + parse(css` + .foo { + .bar { + color: red; + } + + .baz { + color: blue; + } + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { + kind: 'rule', + selector: '.bar', + nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], + }, + { + kind: 'rule', + selector: '.baz', + nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }], + }, + ], + }, + ]) + }) + + it('should parse nested sibling selectors and sibling declarations', () => { + expect( + parse(css` + .foo { + font-weight: bold; + text-declaration-line: underline; + + .bar { + color: red; + } + + --in-between: 1; + + .baz { + color: blue; + } + + --at-the-end: 2; + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { kind: 'declaration', property: 'font-weight', value: 'bold', important: false }, + { + kind: 'declaration', + property: 'text-declaration-line', + value: 'underline', + important: false, + }, + { + kind: 'rule', + selector: '.bar', + nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], + }, + { kind: 'declaration', property: '--in-between', value: '1', important: false }, + { + kind: 'rule', + selector: '.baz', + nodes: [{ kind: 'declaration', property: 'color', value: 'blue', important: false }], + }, + { kind: 'declaration', property: '--at-the-end', value: '2', important: false }, + ], + }, + ]) + }) + }) + + describe('complex', () => { + it('should parse complex examples', () => { + expect( + parse(css` + @custom \{ { + foo: bar; + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '@custom \\{', + nodes: [{ kind: 'declaration', property: 'foo', value: 'bar', important: false }], + }, + ]) + }) + + it('should parse minified nested CSS', () => { + expect( + parse('.foo{color:red;@media(width>=600px){.bar{color:blue;font-weight:bold}}}'), + ).toEqual([ + { + kind: 'rule', + selector: '.foo', + nodes: [ + { kind: 'declaration', property: 'color', value: 'red', important: false }, + { + kind: 'rule', + selector: '@media(width>=600px)', + nodes: [ + { + kind: 'rule', + selector: '.bar', + nodes: [ + { kind: 'declaration', property: 'color', value: 'blue', important: false }, + { + kind: 'declaration', + property: 'font-weight', + value: 'bold', + important: false, + }, + ], + }, + ], + }, + ], + }, + ]) + }) + + it('should ignore everything inside of comments', () => { + expect( + parse(css` + .foo:has(.bar /* instead \*\/ of .baz { */) { + color: red; + } + `), + ).toEqual([ + { + kind: 'rule', + selector: '.foo:has(.bar )', + nodes: [{ kind: 'declaration', property: 'color', value: 'red', important: false }], + }, + ]) + }) + }) + + describe('errors', () => { + it('should error when curly brackets are unbalanced (opening)', () => { + expect(() => + parse(` + .foo { + color: red; + } + + .bar + /* ^ Missing opening { */ + color: blue; + } + `), + ).toThrowErrorMatchingInlineSnapshot(`[Error: Missing opening {]`) + }) + + it('should error when curly brackets are unbalanced (closing)', () => { + expect(() => + parse(` + .foo { + color: red; + } + + .bar { + color: blue; + + /* ^ Missing closing } */ + `), + ).toThrowErrorMatchingInlineSnapshot(`[Error: Missing closing } at .bar]`) + }) + + it('should error when an unterminated string is used', () => { + expect(() => + parse(css` + .foo { + content: "Hello world! + /* ^ missing " */ + font-weight: bold; + } + `), + ).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!"]`) + }) + + it('should error when an unterminated string is used with a `;`', () => { + expect(() => + parse(css` + .foo { + content: "Hello world!; + /* ^ missing " */ + font-weight: bold; + } + `), + ).toThrowErrorMatchingInlineSnapshot(`[Error: Unterminated string: "Hello world!;"]`) + }) }) }) diff --git a/packages/tailwindcss/src/css-parser.ts b/packages/tailwindcss/src/css-parser.ts index b6978d8b0281..c0e0dccac507 100644 --- a/packages/tailwindcss/src/css-parser.ts +++ b/packages/tailwindcss/src/css-parser.ts @@ -1,6 +1,8 @@ import { comment, rule, type AstNode, type Comment, type Declaration, type Rule } from './ast' export function parse(input: string) { + input = input.replaceAll('\r\n', '\n') + let ast: AstNode[] = [] let licenseComments: Comment[] = []