From c7cbc4111dbd3e49417abfac15f0809536ab8dc8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:19:33 +0000 Subject: [PATCH 1/3] Initial plan From 27c5bbd3232d3b97f6f9d96ebf6c1b3141939023 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 18:37:33 +0000 Subject: [PATCH 2/3] Add tests and basic !important syntax support for @apply Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/atrule/tailwind-apply.js | 13 ++- src/node/tailwind-apply-atrule.js | 106 ++++++++++++++++++++++ src/tailwind3.js | 3 +- src/tailwind4.js | 2 +- tests/tailwind3.test.js | 143 ++++++++++++++++++++++++++++++ tests/tailwind4.test.js | 60 +++++++++++++ 6 files changed, 324 insertions(+), 3 deletions(-) create mode 100644 src/node/tailwind-apply-atrule.js diff --git a/src/atrule/tailwind-apply.js b/src/atrule/tailwind-apply.js index ec8a880..6785c18 100644 --- a/src/atrule/tailwind-apply.js +++ b/src/atrule/tailwind-apply.js @@ -24,12 +24,12 @@ import { tokenTypes } from "@eslint/css-tree"; export default { parse: { - /** * @this {ParserContext} */ prelude: function() { const children = this.createList(); + let hasImportant = false; while (this.tokenType === tokenTypes.Ident) { @@ -42,6 +42,17 @@ export default { this.skipSC(); } + // Check for !important at the end + if (this.tokenType === tokenTypes.Delim && this.source.charCodeAt(this.tokenStart) === 33) { // 33 is '!' + this.next(); // consume ! + this.skipSC(); + + if (this.tokenType === tokenTypes.Ident && this.source.slice(this.tokenStart, this.tokenEnd) === "important") { + this.next(); // consume important + hasImportant = true; + } + } + return children; }, block: null diff --git a/src/node/tailwind-apply-atrule.js b/src/node/tailwind-apply-atrule.js new file mode 100644 index 0000000..270d377 --- /dev/null +++ b/src/node/tailwind-apply-atrule.js @@ -0,0 +1,106 @@ +/** + * @fileoverview Tailwind @apply atrule node with !important support + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { tokenTypes } from "@eslint/css-tree"; + +//----------------------------------------------------------------------------- +// Type Definitions +//----------------------------------------------------------------------------- + +/** + * @import { ParserContext } from "@eslint/css-tree"; + */ + +//----------------------------------------------------------------------------- +// Exports +//----------------------------------------------------------------------------- + +/** + * Parse @apply atrule with !important support + * @this {ParserContext} + */ +export function parse() { + const node = { + type: 'Atrule', + loc: this.getLocation(this.tokenStart, this.tokenEnd), + name: null, + prelude: null, + block: null, + important: false + }; + + this.eat(3); // eat @ + node.name = this.consume(1); // consume identifier (should be "apply") + + this.skipSC(); + + // Parse the prelude (identifiers and !important) + if (this.tokenType !== 23 && this.tokenType !== 17) { // not { and not ; + const children = this.createList(); + + while (this.tokenType === 1) { // Ident + if (this.lookupType(1) === 16) { // Colon - variant syntax + children.push(this.TailwindUtilityClass()); + } else { + children.push(this.Identifier()); + } + this.skipSC(); + } + + // Check for !important at the end + if (this.tokenType === 9 && this.source.charCodeAt(this.tokenStart) === 33) { // 33 is '!' + this.next(); // consume ! + this.skipSC(); + + if (this.tokenType === 1 && this.source.slice(this.tokenStart, this.tokenEnd) === "important") { + this.next(); // consume important + node.important = true; + } + } + + node.prelude = { + type: 'AtrulePrelude', + loc: this.getLocationFromList(children), + children: children + }; + } + + if (this.tokenType === 23) { // { + node.block = this.Block(true); + } else { + this.eat(17); // ; + } + + return node; +} + +/** + * Generate @apply atrule + * @param {*} node + */ +export function generate(node) { + this.token(3, '@'); + this.token(1, node.name); + + if (node.prelude) { + this.token(13, ' '); + this.node(node.prelude); + if (node.important) { + this.token(13, ' '); + this.token(9, '!'); + this.token(1, 'important'); + } + } + + if (node.block) { + this.node(node.block); + } else { + this.token(17, ';'); + } +} \ No newline at end of file diff --git a/src/tailwind3.js b/src/tailwind3.js index 6f9f457..30d7803 100644 --- a/src/tailwind3.js +++ b/src/tailwind3.js @@ -10,6 +10,7 @@ import defaultSyntax from "@eslint/css-tree/definition-syntax-data"; import * as TailwindThemeKey from "./node/tailwind-theme-key.js"; import * as TailwindUtilityClass from "./node/tailwind-class.js"; +import * as TailwindApplyAtrule from "./node/tailwind-apply-atrule.js"; import tailwindApply from "./atrule/tailwind-apply.js"; import theme from "./scope/theme.js"; import { themeTypes } from "./types/theme-types.js"; @@ -30,7 +31,7 @@ export const tailwind3 = { }, atrules: { apply: { - prelude: "+", + prelude: "+ [ '!' important ]?", }, tailwind: { prelude: "base | components | utilities | variants", diff --git a/src/tailwind4.js b/src/tailwind4.js index 7f6feb8..c45b2c6 100644 --- a/src/tailwind4.js +++ b/src/tailwind4.js @@ -25,7 +25,7 @@ import { themeTypes } from "./types/theme-types.js"; export const tailwind4 = { atrules: { apply: { - prelude: "+", + prelude: "+ [ '!' important ]?", }, config: { prelude: "", diff --git a/tests/tailwind3.test.js b/tests/tailwind3.test.js index 70e323d..33b6382 100644 --- a/tests/tailwind3.test.js +++ b/tests/tailwind3.test.js @@ -332,6 +332,146 @@ describe("Tailwind 3", function () { ] }); }); + + it("should parse @apply with !important", () => { + const tree = toPlainObject(parse("a { @apply text-center bg-blue-500 !important; }")); + + assert.deepStrictEqual(tree, { + type: "StyleSheet", + loc: null, + children: [ + { + type: "Rule", + loc: null, + prelude: { + type: "SelectorList", + loc: null, + children: [ + { + type: "Selector", + loc: null, + children: [ + { + type: "TypeSelector", + loc: null, + name: "a" + } + ] + } + ] + }, + block: { + type: "Block", + loc: null, + children: [ + { + type: "Atrule", + name: "apply", + important: true, + prelude: { + type: "AtrulePrelude", + loc: null, + children: [ + { + type: "Identifier", + loc: null, + name: "text-center" + }, + { + type: "Identifier", + loc: null, + name: "bg-blue-500" + } + ] + }, + block: null, + loc: null + } + ] + } + } + ] + }); + }); + + it("should parse @apply with variant and !important", () => { + const tree = toPlainObject(parse("a { @apply hover:bg-blue-500 focus:ring-blue-500 !important; }")); + + assert.deepStrictEqual(tree, { + type: "StyleSheet", + loc: null, + children: [ + { + type: "Rule", + loc: null, + prelude: { + type: "SelectorList", + loc: null, + children: [ + { + type: "Selector", + loc: null, + children: [ + { + type: "TypeSelector", + loc: null, + name: "a" + } + ] + } + ] + }, + block: { + type: "Block", + loc: null, + children: [ + { + type: "Atrule", + name: "apply", + important: true, + prelude: { + type: "AtrulePrelude", + loc: null, + children: [ + { + type: "TailwindUtilityClass", + loc: null, + variant: { + type: "Identifier", + name: "hover", + loc: null + }, + name: { + type: "Identifier", + name: "bg-blue-500", + loc: null + }, + }, + { + type: "TailwindUtilityClass", + loc: null, + variant: { + type: "Identifier", + name: "focus", + loc: null + }, + name: { + type: "Identifier", + name: "ring-blue-500", + loc: null + }, + } + ] + }, + block: null, + loc: null + } + ] + } + } + ] + }); + }); }); describe("Validation", () => { @@ -354,6 +494,9 @@ describe("Tailwind 3", function () { "bg-blue-500", "hover:bg-blue-700", "bg-blue-500 focus:ring-blue-500", + "bg-blue-500 !important", + "hover:bg-blue-700 !important", + "bg-blue-500 focus:ring-blue-500 !important", ].forEach((value) => { it(`should validate @apply ${value}`, () => { assert.strictEqual(lexer.matchAtrulePrelude("apply", value).error, null); diff --git a/tests/tailwind4.test.js b/tests/tailwind4.test.js index 4072274..18936b6 100644 --- a/tests/tailwind4.test.js +++ b/tests/tailwind4.test.js @@ -433,6 +433,66 @@ describe("Tailwind 4", function () { ] }); }); + + it("should parse @apply with !important", () => { + const tree = toPlainObject(parse(".example { @apply bg-red-500 text-white !important; }")); + assert.deepStrictEqual(tree, { + type: "StyleSheet", + loc: null, + children: [ + { + type: "Rule", + loc: null, + prelude: { + type: "SelectorList", + loc: null, + children: [ + { + type: "Selector", + loc: null, + children: [ + { + type: "ClassSelector", + name: "example", + loc: null + } + ] + } + ] + }, + block: { + type: "Block", + loc: null, + children: [ + { + type: "Atrule", + name: "apply", + important: true, + prelude: { + type: "AtrulePrelude", + loc: null, + children: [ + { + type: "Identifier", + name: "bg-red-500", + loc: null + }, + { + type: "Identifier", + name: "text-white", + loc: null + } + ] + }, + block: null, + loc: null + } + ] + } + } + ] + }); + }); }); describe("@reference", () => { From 05068b33452393af4aff03358f77c939ae2db7c4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 31 Jul 2025 19:00:40 +0000 Subject: [PATCH 3/3] Changes before error encountered Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --- src/index.js | 1 + src/node/tailwind-apply-atrule.js | 106 ------------ src/tailwind3.js | 1 - src/utils/apply-important.js | 88 ++++++++++ test_output.txt | 258 ++++++++++++++++++++++++++++++ 5 files changed, 347 insertions(+), 107 deletions(-) delete mode 100644 src/node/tailwind-apply-atrule.js create mode 100644 src/utils/apply-important.js create mode 100644 test_output.txt diff --git a/src/index.js b/src/index.js index 5a3a1ab..f8b23e8 100644 --- a/src/index.js +++ b/src/index.js @@ -6,3 +6,4 @@ /* @ts-self-types="./index.d.ts" */ export { tailwind4 } from "./tailwind4.js"; export { tailwind3 } from "./tailwind3.js"; +export { addImportantToApplyAtrules, forkWithApplyImportant } from "./utils/apply-important.js"; diff --git a/src/node/tailwind-apply-atrule.js b/src/node/tailwind-apply-atrule.js deleted file mode 100644 index 270d377..0000000 --- a/src/node/tailwind-apply-atrule.js +++ /dev/null @@ -1,106 +0,0 @@ -/** - * @fileoverview Tailwind @apply atrule node with !important support - * @author Nicholas C. Zakas - */ - -//----------------------------------------------------------------------------- -// Imports -//----------------------------------------------------------------------------- - -import { tokenTypes } from "@eslint/css-tree"; - -//----------------------------------------------------------------------------- -// Type Definitions -//----------------------------------------------------------------------------- - -/** - * @import { ParserContext } from "@eslint/css-tree"; - */ - -//----------------------------------------------------------------------------- -// Exports -//----------------------------------------------------------------------------- - -/** - * Parse @apply atrule with !important support - * @this {ParserContext} - */ -export function parse() { - const node = { - type: 'Atrule', - loc: this.getLocation(this.tokenStart, this.tokenEnd), - name: null, - prelude: null, - block: null, - important: false - }; - - this.eat(3); // eat @ - node.name = this.consume(1); // consume identifier (should be "apply") - - this.skipSC(); - - // Parse the prelude (identifiers and !important) - if (this.tokenType !== 23 && this.tokenType !== 17) { // not { and not ; - const children = this.createList(); - - while (this.tokenType === 1) { // Ident - if (this.lookupType(1) === 16) { // Colon - variant syntax - children.push(this.TailwindUtilityClass()); - } else { - children.push(this.Identifier()); - } - this.skipSC(); - } - - // Check for !important at the end - if (this.tokenType === 9 && this.source.charCodeAt(this.tokenStart) === 33) { // 33 is '!' - this.next(); // consume ! - this.skipSC(); - - if (this.tokenType === 1 && this.source.slice(this.tokenStart, this.tokenEnd) === "important") { - this.next(); // consume important - node.important = true; - } - } - - node.prelude = { - type: 'AtrulePrelude', - loc: this.getLocationFromList(children), - children: children - }; - } - - if (this.tokenType === 23) { // { - node.block = this.Block(true); - } else { - this.eat(17); // ; - } - - return node; -} - -/** - * Generate @apply atrule - * @param {*} node - */ -export function generate(node) { - this.token(3, '@'); - this.token(1, node.name); - - if (node.prelude) { - this.token(13, ' '); - this.node(node.prelude); - if (node.important) { - this.token(13, ' '); - this.token(9, '!'); - this.token(1, 'important'); - } - } - - if (node.block) { - this.node(node.block); - } else { - this.token(17, ';'); - } -} \ No newline at end of file diff --git a/src/tailwind3.js b/src/tailwind3.js index 30d7803..d29a8c5 100644 --- a/src/tailwind3.js +++ b/src/tailwind3.js @@ -10,7 +10,6 @@ import defaultSyntax from "@eslint/css-tree/definition-syntax-data"; import * as TailwindThemeKey from "./node/tailwind-theme-key.js"; import * as TailwindUtilityClass from "./node/tailwind-class.js"; -import * as TailwindApplyAtrule from "./node/tailwind-apply-atrule.js"; import tailwindApply from "./atrule/tailwind-apply.js"; import theme from "./scope/theme.js"; import { themeTypes } from "./types/theme-types.js"; diff --git a/src/utils/apply-important.js b/src/utils/apply-important.js new file mode 100644 index 0000000..ea02d0f --- /dev/null +++ b/src/utils/apply-important.js @@ -0,0 +1,88 @@ +/** + * @fileoverview Utility functions for handling !important in @apply directives + * @author Nicholas C. Zakas + */ + +//----------------------------------------------------------------------------- +// Imports +//----------------------------------------------------------------------------- + +import { fork as baseFork } from "@eslint/css-tree"; + +//----------------------------------------------------------------------------- +// Helper Functions +//----------------------------------------------------------------------------- + +/** + * Walk through AST and add important property to @apply atrules that need it + * @param {Object} ast - CSS AST + * @param {string} originalSource - Original CSS source code + * @returns {Object} Modified AST + */ +export function addImportantToApplyAtrules(ast, originalSource) { + + function walk(/** @type {any} */ node) { + if (!node || typeof node !== 'object') { + return; + } + + // Check if this is an @apply atrule + if (node.type === 'Atrule' && node.name === 'apply' && node.loc) { + // Extract the original source for this atrule to see if it had !important + const start = node.loc.start.offset; + const end = node.loc.end.offset; + const atruleSource = originalSource.slice(start, end); + + // If the original source contains !important, add the flag + if (atruleSource.includes('!important')) { + /** @type {any} */ (node).important = true; + } + } + + // Recursively walk children + if (node.children) { + if (Array.isArray(node.children)) { + node.children.forEach(/** @param {any} child */ (child) => walk(child)); + } else if (node.children.forEach) { + // Handle CSS Tree List objects + node.children.forEach(/** @param {any} child */ (child) => walk(child)); + } + } + + if (node.prelude) { + walk(node.prelude); + } + + if (node.block) { + walk(node.block); + } + } + + walk(ast); + return ast; +} + +/** + * Create a CSS Tree fork with automatic !important support for @apply + * @param {Object} config - CSS Tree configuration + * @returns {Object} Enhanced CSS Tree fork + */ +export function forkWithApplyImportant(config) { + const parser = baseFork(config); + const originalParse = parser.parse; + + parser.parse = function(source, options) { + // Parse with positions enabled to get offset information + const parseOptions = { ...options, positions: true }; + const result = originalParse.call(this, source, parseOptions); + + // Apply !important post-processing for Tailwind configs + if (config && config.atrule && config.atrule.apply) { + return addImportantToApplyAtrules(result, source); + } + + return result; + }; + + return parser; +} \ No newline at end of file diff --git a/test_output.txt b/test_output.txt new file mode 100644 index 0000000..fd0cc37 --- /dev/null +++ b/test_output.txt @@ -0,0 +1,258 @@ + +> tailwind-csstree@0.1.2 test:unit +> mocha tests/*.{js,mjs} --exit --forbid-only + + + + Tailwind 3 + ✔ tests that tailwind3 is a valid SyntaxExtension + theme() function + Parsing + ✔ should parse theme() with colors.green.500 + ✔ should parse theme() with colors.red.500 / 50% + ✔ should parse theme() with colors.gray.900/75% + ✔ should parse theme() with spacing[2.5] + Validation + Type Validation + ✔ should validate type with theme(spacing.12) + ✔ should validate type with theme(spacing.4) + ✔ should validate type with theme(colors.gray.900) + ✔ should validate type with theme(colors.red.500 / 50%) + ✔ should validate type with theme(colors.green.225) + Property Validation + ✔ should validate margin: theme(spacing.4) + ✔ should validate color: theme(colors.red.500 / 50%) + @config + ✔ should parse @config with a string value + @tailwind + ✔ should parse @tailwind with valid prelude + ✔ should parse @tailwind with multiple values + @apply + ✔ should parse @apply with multiple identifiers + ✔ should parse @apply with a variant + ✔ should parse @apply with a variant and multiple identifiers + 1) should parse @apply with !important + 2) should parse @apply with variant and !important + Validation + Type Validation + ✔ should validate type with bg-blue-500 + ✔ should validate type with hover:bg-blue-700 + ✔ should validate type with focus:ring-blue-500 + Property Validation + ✔ should validate @apply bg-blue-500 + ✔ should validate @apply hover:bg-blue-700 + ✔ should validate @apply bg-blue-500 focus:ring-blue-500 + ✔ should validate @apply bg-blue-500 !important + ✔ should validate @apply hover:bg-blue-700 !important + ✔ should validate @apply bg-blue-500 focus:ring-blue-500 !important + ✔ should validate @container + @import + ✔ should parse @import with prefix function without crashing + Canonical Tailwind 3 File + ✔ should parse a canonical Tailwind 3 file + + Tailwind 4 + ✔ tests that tailwind4 is a valid SyntaxExtension + theme() function + Parsing + ✔ should parse theme() with colors.green.500 + ✔ should parse theme() with colors.red.500 / 50% + ✔ should parse theme() with colors.gray.900/75% + ✔ should parse theme() with spacing[2.5] + Validation + Type Validation + ✔ should validate type with theme(spacing.12) + ✔ should validate type with theme(spacing.4) + ✔ should validate type with theme(colors.gray.900) + ✔ should validate type with theme(colors.red.500 / 50%) + ✔ should validate type with theme(colors.green.225) + Property Validation + ✔ should validate margin: theme(spacing.4) + ✔ should validate color: theme(colors.red.500 / 50%) + @import + ✔ should parse @import with a string value + ✔ should parse @import with prefix function without crashing + @config + ✔ should parse @config with a string value + @plugin + ✔ should parse @plugin with a string value + @theme + ✔ should parse @theme with a valid prelude + @source + ✔ should parse @source with a valid URL + @variant + ✔ should parse @variant with a valid prelude + @custom-variant + ✔ should parse @custom-variant with a valid prelude + @apply + ✔ should parse @apply with valid classes + 3) should parse @apply with !important + @reference + ✔ should parse @reference with a valid prelude + Validation + Type Validation + ✔ should validate type with --alpha(#000 / 50%) + ✔ should validate type with --alpha(#000 / 50%) + ✔ should validate type with --alpha(white / 70%) + ✔ should validate type with --alpha(white / 70%) + ✔ should validate type with --alpha(rgba(0, 0, 0, 0.5) / 1%) + ✔ should validate type with --alpha(rgba(0, 0, 0, 0.5) / 1%) + ✔ should validate type with --spacing(4) + ✔ should validate type with --spacing(4) + ✔ should validate type with --spacing(12) + ✔ should validate type with --spacing(12) + ✔ should validate type with --spacing(0.5) + ✔ should validate type with --spacing(0.5) + Property Validation + ✔ should validate margin: --spacing(4) + ✔ should validate color: --alpha(#000 / 50%) + Canonical Tailwind 4 File + ✔ should parse a canonical Tailwind 4 file + + + 67 passing (412ms) + 3 failing + + 1) Tailwind 3 + @apply + should parse @apply with !important: + + AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: ++ actual - expected ... Lines skipped + + { + children: [ +... + { + block: null, +- important: true, + loc: null, +... + loc: null, + type: 'StyleSheet' + } + + expected - actual + + "block": { + "children": [ + { + "block": [null] + + "important": true + "loc": [null] + "name": "apply" + "prelude": { + "children": [ + + at Context. (file:///home/runner/work/tailwind-csstree/tailwind-csstree/tests/tailwind3.test.js:339:20) + at process.processImmediate (node:internal/timers:483:21) + + 2) Tailwind 3 + @apply + should parse @apply with variant and !important: + + AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: ++ actual - expected ... Lines skipped + + { + children: [ +... + { + block: null, +- important: true, + loc: null, +... + loc: null, + type: 'StyleSheet' + } + + expected - actual + + "block": { + "children": [ + { + "block": [null] + + "important": true + "loc": [null] + "name": "apply" + "prelude": { + "children": [ + + at Context. (file:///home/runner/work/tailwind-csstree/tailwind-csstree/tests/tailwind3.test.js:400:20) + at process.processImmediate (node:internal/timers:483:21) + + 3) Tailwind 4 + @apply + should parse @apply with !important: + + AssertionError [ERR_ASSERTION]: Expected values to be strictly deep-equal: ++ actual - expected ... Lines skipped + + { + children: [ +... + { + block: null, ++ loc: null, ++ name: 'apply', ++ prelude: { ++ loc: null, ++ type: 'Raw', ++ value: 'bg-red-500 text-white !important' +- important: true, +- loc: null, +- name: 'apply', +- prelude: { +- children: [ +- { +- loc: null, +- name: 'bg-red-500', +- type: 'Identifier' +- }, +- { +- loc: null, +- name: 'text-white', +- type: 'Identifier' +- } +- ], +- loc: null, +- type: 'AtrulePrelude' + }, +... + loc: null, + type: 'StyleSheet' + } + + expected - actual + + "block": { + "children": [ + { + "block": [null] + + "important": true + "loc": [null] + "name": "apply" + "prelude": { + + "children": [ + + { + + "loc": [null] + + "name": "bg-red-500" + + "type": "Identifier" + + } + + { + + "loc": [null] + + "name": "text-white" + + "type": "Identifier" + + } + + ] + "loc": [null] + - "type": "Raw" + - "value": "bg-red-500 text-white !important" + + "type": "AtrulePrelude" + } + "type": "Atrule" + } + ] + + at Context. (file:///home/runner/work/tailwind-csstree/tailwind-csstree/tests/tailwind4.test.js:439:20) + at process.processImmediate (node:internal/timers:483:21) + + +