Skip to content

Commit fc4e117

Browse files
Copilotnzakas
andauthored
fix: Tailwind 4 @custom-variant parsing for ESLint CSS plugin and expand syntax coverage (#54)
* Initial plan * Add Tailwind 4 custom-variant syntax coverage tests Agent-Logs-Url: https://github.com/humanwhocodes/tailwind-csstree/sessions/e6da0dda-75f9-45ed-a063-266a85fcdf6c Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Strengthen inline custom-variant assertion Agent-Logs-Url: https://github.com/humanwhocodes/tailwind-csstree/sessions/e6da0dda-75f9-45ed-a063-266a85fcdf6c Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Clarify custom-variant test intent and case names Agent-Logs-Url: https://github.com/humanwhocodes/tailwind-csstree/sessions/e6da0dda-75f9-45ed-a063-266a85fcdf6c Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Fix custom-variant parsing in ESLint fixture linting Agent-Logs-Url: https://github.com/humanwhocodes/tailwind-csstree/sessions/9092437b-2f63-47fd-91c2-8eb0d6e9770a Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Refine custom-variant parser diagnostics Agent-Logs-Url: https://github.com/humanwhocodes/tailwind-csstree/sessions/9092437b-2f63-47fd-91c2-8eb0d6e9770a Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Document fixture baseline override and parser recovery Agent-Logs-Url: https://github.com/humanwhocodes/tailwind-csstree/sessions/9092437b-2f63-47fd-91c2-8eb0d6e9770a Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Harden custom-variant prelude assertions Agent-Logs-Url: https://github.com/humanwhocodes/tailwind-csstree/sessions/9092437b-2f63-47fd-91c2-8eb0d6e9770a Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> * Tighten custom-variant parser comments and expectations Agent-Logs-Url: https://github.com/humanwhocodes/tailwind-csstree/sessions/9092437b-2f63-47fd-91c2-8eb0d6e9770a Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: nzakas <38546+nzakas@users.noreply.github.com>
1 parent a287272 commit fc4e117

4 files changed

Lines changed: 113 additions & 10 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
/**
2+
* @fileoverview Tailwind 4 `@custom-variant` rule parser.
3+
*/
4+
5+
//-----------------------------------------------------------------------------
6+
// Imports
7+
//-----------------------------------------------------------------------------
8+
9+
import { tokenTypes } from "../token-types.js";
10+
11+
//-----------------------------------------------------------------------------
12+
// Type Definitions
13+
//-----------------------------------------------------------------------------
14+
15+
/**
16+
* @import { ParserContext, SyntaxConfig } from "@eslint/css-tree";
17+
*/
18+
19+
//-----------------------------------------------------------------------------
20+
// Exports
21+
//-----------------------------------------------------------------------------
22+
23+
export default {
24+
parse: {
25+
/**
26+
* @this {ParserContext}
27+
* @type {SyntaxConfig['atrule']['custom-variant']['parse']['prelude']}
28+
*/
29+
prelude: function () {
30+
const children = this.createList();
31+
32+
if (this.tokenType !== tokenTypes.Ident) {
33+
this.error(
34+
"Expected variant name identifier after @custom-variant",
35+
0,
36+
);
37+
}
38+
39+
children.push(this.Identifier());
40+
this.skipSC();
41+
42+
// Parse the inline selector form: @custom-variant name (selector)
43+
if (this.tokenType === tokenTypes.LeftParenthesis) {
44+
/*
45+
* Consume until semicolon; if `{` is encountered, stop there for
46+
* recovery. This keeps the selector payload and nested parentheses.
47+
*/
48+
children.push(
49+
this.Raw(this.consumeUntilLeftCurlyBracketOrSemicolon, true),
50+
);
51+
}
52+
53+
return children;
54+
},
55+
},
56+
};

src/tailwind4.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import * as TailwindThemeKey from "./node/tailwind-theme-key.js";
1111
import * as TailwindUtilityClass from "./node/tailwind-class.js";
1212
import * as TailwindDeclaration from "./node/tailwind-declaration.js";
1313
import tailwindApply from "./atrule/tailwind-apply.js";
14+
import tailwindCustomVariant from "./atrule/tailwind-custom-variant.js";
1415
import tailwindImport from "./atrule/tailwind-import.js";
1516
import theme from "./scope/theme.js";
1617
import { themeTypes } from "./types/theme-types.js";
@@ -41,6 +42,7 @@ export const tailwind4 = prev => {
4142
atrule: {
4243
...prev.atrule,
4344
apply: tailwindApply,
45+
"custom-variant": tailwindCustomVariant,
4446
import: tailwindImport,
4547
},
4648
atrules: {

tests/fixtures/tailwind4.css

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11

2+
/* eslint css/use-baseline: "off" -- fixture includes nesting selectors for parser coverage */
3+
24
@config 'tailwind.config.js';
35
@plugin 'tailwindcss/typography';
46

@@ -33,7 +35,23 @@
3335
}
3436
}
3537

36-
/* @custom-variant theme-midnight (&:where([data-theme="midnight"] *)); */
38+
@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));
39+
40+
@custom-variant theme-midnight {
41+
&:where([data-theme="midnight"] *) {
42+
@slot;
43+
}
44+
}
45+
46+
@custom-variant theme-midnight (&:where([data-theme="midnight"] *));
47+
48+
@custom-variant any-hover {
49+
@media (any-hover: hover) {
50+
&:hover {
51+
@slot;
52+
}
53+
}
54+
}
3755

3856
@source "../node_modules/@my-company/ui-lib";
3957

tests/tailwind4.test.js

Lines changed: 36 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -901,22 +901,23 @@ describe("Tailwind 4", function () {
901901
});
902902

903903
describe("@custom-variant", () => {
904-
it("should parse @custom-variant with an inline selector", () => {
905-
const tree = toPlainObject(parse("@custom-variant theme-midnight (&:where([data-theme='midnight'] *));"));
904+
it("should parse @custom-variant with an inline selector list", () => {
905+
const tree = toPlainObject(parse("@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));"));
906906
const atrule = tree.children[0];
907907

908908
assert.equal(atrule.type, "Atrule");
909909
assert.equal(atrule.name, "custom-variant");
910910
assert.equal(atrule.block, null);
911-
assert.equal(atrule.prelude.type, "Raw");
911+
assert.equal(atrule.prelude.type, "AtrulePrelude");
912+
assert.equal(atrule.prelude.children.length, 2);
912913
assert.equal(
913-
atrule.prelude.value,
914-
"theme-midnight (&:where([data-theme='midnight'] *))",
914+
atrule.prelude.children[1].value,
915+
"(&:where([data-theme=dark], [data-theme=dark] *))",
915916
);
916917
});
917918

918919
it("should parse @custom-variant with a block body using @slot", () => {
919-
const tree = toPlainObject(parse("@custom-variant theme-midnight { &:where([data-theme='midnight'] *) { @slot; } }"));
920+
const tree = toPlainObject(parse('@custom-variant theme-midnight { &:where([data-theme="midnight"] *) { @slot; } }'));
920921
const atrule = tree.children[0];
921922

922923
assert.equal(atrule.type, "Atrule");
@@ -927,12 +928,38 @@ describe("Tailwind 4", function () {
927928
assert.equal(atrule.block.children[0].block.children[0].name, "slot");
928929
});
929930

931+
it("should parse @custom-variant with a single inline selector", () => {
932+
const tree = toPlainObject(parse('@custom-variant theme-midnight (&:where([data-theme="midnight"] *));'));
933+
const atrule = tree.children[0];
934+
935+
assert.equal(atrule.type, "Atrule");
936+
assert.equal(atrule.name, "custom-variant");
937+
assert.equal(atrule.block, null);
938+
assert.equal(atrule.prelude.type, "AtrulePrelude");
939+
assert.equal(atrule.prelude.children.length, 2);
940+
assert.equal(atrule.prelude.children[1].value, '(&:where([data-theme="midnight"] *))');
941+
});
942+
943+
it("should parse @custom-variant with nested at-rules in a block body", () => {
944+
const tree = toPlainObject(parse("@custom-variant any-hover { @media (any-hover: hover) { &:hover { @slot; } } }"));
945+
const atrule = tree.children[0];
946+
947+
assert.equal(atrule.type, "Atrule");
948+
assert.equal(atrule.name, "custom-variant");
949+
assert.equal(atrule.prelude.type, "AtrulePrelude");
950+
assert.equal(atrule.prelude.children[0].name, "any-hover");
951+
assert.equal(atrule.block.children[0].name, "media");
952+
assert.equal(atrule.block.children[0].block.children[0].type, "Rule");
953+
});
954+
930955
describe("Validation", () => {
931956
[
932-
"@custom-variant theme-midnight (&:where([data-theme='midnight'] *));",
933-
"@custom-variant theme-midnight { &:where([data-theme='midnight'] *) { @slot; } }",
957+
"@custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *));",
958+
'@custom-variant theme-midnight { &:where([data-theme="midnight"] *) { @slot; } }',
959+
'@custom-variant theme-midnight (&:where([data-theme="midnight"] *));',
960+
"@custom-variant any-hover { @media (any-hover: hover) { &:hover { @slot; } } }",
934961
].forEach((cssRule) => {
935-
it("should allow valid prelude", () => {
962+
it(`should allow valid prelude: ${cssRule}`, () => {
936963
const tree = toPlainObject(parse(cssRule));
937964
const { error } = lexer.matchAtrulePrelude("custom-variant", tree.children[0].prelude);
938965
assert.equal(error, null);

0 commit comments

Comments
 (0)