@culur/prettier-plugin-tailwindcss-group 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/LICENSE +21 -0
  3. package/README.md +558 -0
  4. package/dist/index.d.mts +673 -0
  5. package/dist/index.mjs +4194 -0
  6. package/dist/index.mjs.map +1 -0
  7. package/package.json +66 -0
  8. package/src/__tests__/comments.test.ts +171 -0
  9. package/src/__tests__/plugin-official.test.ts +32 -0
  10. package/src/__tests__/readme.test.ts +13 -0
  11. package/src/__tests__/utils/ast-estree.ts +22 -0
  12. package/src/__tests__/utils/ast-md.ts +101 -0
  13. package/src/__tests__/utils/test-format.ts +35 -0
  14. package/src/ast/ast-array-expression.test.ts +33 -0
  15. package/src/ast/ast-array-expression.ts +20 -0
  16. package/src/ast/ast-call-expression.test.ts +17 -0
  17. package/src/ast/ast-call-expression.ts +5 -0
  18. package/src/ast/ast-comment.test.ts +45 -0
  19. package/src/ast/ast-comment.ts +25 -0
  20. package/src/ast/ast-common.ts +29 -0
  21. package/src/ast/ast-jsx.test.ts +56 -0
  22. package/src/ast/ast-jsx.ts +53 -0
  23. package/src/ast/ast-literal.test.ts +47 -0
  24. package/src/ast/ast-literal.ts +17 -0
  25. package/src/ast/ast-property.test.ts +96 -0
  26. package/src/ast/ast-property.ts +32 -0
  27. package/src/ast/index.ts +8 -0
  28. package/src/ast/key.test.ts +21 -0
  29. package/src/ast/key.ts +4 -0
  30. package/src/group-classes/group-classes.bucket.ts +61 -0
  31. package/src/group-classes/group-classes.format.ts +39 -0
  32. package/src/group-classes/group-classes.test.ts +126 -0
  33. package/src/group-classes/group-classes.ts +34 -0
  34. package/src/group-classes/group-classes.types.ts +5 -0
  35. package/src/handle-node/handle-cva-class-value.test.ts +133 -0
  36. package/src/handle-node/handle-cva-class-value.ts +47 -0
  37. package/src/handle-node/handle-expressions.test.ts +140 -0
  38. package/src/handle-node/handle-expressions.ts +63 -0
  39. package/src/handle-node/handle-string-literal.test.ts +135 -0
  40. package/src/handle-node/handle-string-literal.ts +53 -0
  41. package/src/handle-targets/index.ts +3 -0
  42. package/src/handle-targets/target-cva-base.test.ts +103 -0
  43. package/src/handle-targets/target-cva-base.ts +17 -0
  44. package/src/handle-targets/target-cva-compound-variants.test.ts +239 -0
  45. package/src/handle-targets/target-cva-compound-variants.ts +45 -0
  46. package/src/handle-targets/target-cva-variants.test.ts +178 -0
  47. package/src/handle-targets/target-cva-variants.ts +50 -0
  48. package/src/handle-targets/target-cva.test.ts +126 -0
  49. package/src/handle-targets/target-cva.ts +33 -0
  50. package/src/handle-targets/target-jsx.test.ts +242 -0
  51. package/src/handle-targets/target-jsx.ts +65 -0
  52. package/src/handle-targets/target-object-properties.test.ts +140 -0
  53. package/src/handle-targets/target-object-properties.ts +41 -0
  54. package/src/index.ts +13 -0
  55. package/src/options.test.ts +73 -0
  56. package/src/options.ts +94 -0
  57. package/src/orders/class-groups.test.ts +74 -0
  58. package/src/orders/class-groups.ts +178 -0
  59. package/src/orders/class-order.tailwind-merge.ts +26 -0
  60. package/src/orders/modifier-order.prettier-plugin-tailwindcss.test.ts +33 -0
  61. package/src/orders/modifier-order.prettier-plugin-tailwindcss.ts +23 -0
  62. package/src/prettier.d.ts +21 -0
  63. package/src/printers.test.ts +58 -0
  64. package/src/printers.ts +29 -0
  65. package/src/printers.walk-ast.test.ts +58 -0
  66. package/src/printers.walk-ast.ts +41 -0
  67. package/src/utils/split-classes.ts +3 -0
package/package.json ADDED
@@ -0,0 +1,66 @@
1
+ {
2
+ "name": "@culur/prettier-plugin-tailwindcss-group",
3
+ "version": "1.1.0",
4
+ "description": "Prettier plugin to automatically format and wrap long className attributes",
5
+ "publishConfig": {
6
+ "access": "public"
7
+ },
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "git+https://github.com/culur/culur.git",
11
+ "directory": "packages/prettier-plugin-tailwindcss-group"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/culur/culur/issues"
15
+ },
16
+ "homepage": "https://github.com/culur/culur/tree/main/packages/prettier-plugin-tailwindcss-group#readme",
17
+ "author": "culur <culur.net@gmail.com>",
18
+ "license": "MIT",
19
+ "keywords": [
20
+ "culur",
21
+ "prettier",
22
+ "prettier-plugin",
23
+ "classnames"
24
+ ],
25
+ "type": "module",
26
+ "exports": {
27
+ ".": {
28
+ "types": "./dist/index.d.mts",
29
+ "default": "./dist/index.mjs"
30
+ }
31
+ },
32
+ "files": [
33
+ "!**/*.test.*",
34
+ "CHANGELOG.md",
35
+ "LICENSE",
36
+ "README.md",
37
+ "dist",
38
+ "src"
39
+ ],
40
+ "volta": {
41
+ "extends": "../../package.json"
42
+ },
43
+ "engines": {
44
+ "node": ">=20"
45
+ },
46
+ "devDependencies": {
47
+ "@babel/parser": "^8.0.4",
48
+ "@types/estree-jsx": "^1.0.5",
49
+ "@types/react": "^19.2.18",
50
+ "dedent": "^1.7.2",
51
+ "prettier": "^3.9.6",
52
+ "prettier-plugin-tailwindcss": "^0.8.1",
53
+ "remark": "^15.0.1",
54
+ "remark-parse": "^11.0.0",
55
+ "tailwind-merge": "^3.6.0",
56
+ "@culur/config-eslint": "1.9.0",
57
+ "@culur/config-tsdown": "1.1.2",
58
+ "@culur/config-prettier": "1.5.0",
59
+ "@culur/config-typescript": "2.0.0",
60
+ "@culur/config-vite": "1.5.2"
61
+ },
62
+ "peerDependencies": {
63
+ "prettier": ">=3.0.0",
64
+ "prettier-plugin-tailwindcss": ">=0.6.0"
65
+ }
66
+ }
@@ -0,0 +1,171 @@
1
+ import type { Plugin } from 'prettier';
2
+ import dedent from 'dedent';
3
+ import * as tailwindPlugin from 'prettier-plugin-tailwindcss';
4
+ import { describe } from 'vitest';
5
+ import plugin from '../index';
6
+ import { testFormat } from './utils/test-format';
7
+
8
+ describe('comment preservation in cn() and cva([])', () => {
9
+ const options = {
10
+ plugins: [plugin, tailwindPlugin as unknown as Plugin],
11
+ classNameThreshold: 5,
12
+ tailwindFunctions: ['cn'],
13
+ };
14
+
15
+ testFormat(
16
+ 'succeeds for comments outside of cn() arguments',
17
+ options,
18
+ dedent`
19
+ // Component comment
20
+ <div
21
+ // Attribute comment
22
+ className={cn('flex items-center justify-center text-sm font-bold bg-white shadow-md')} // End of JSX attribute
23
+ />;
24
+ `,
25
+ dedent`
26
+ // Component comment
27
+ <div
28
+ // Attribute comment
29
+ className={cn(
30
+ 'flex items-center justify-center',
31
+ 'text-sm font-bold',
32
+ 'bg-white',
33
+ 'shadow-md',
34
+ )} // End of JSX attribute
35
+ />;
36
+ `,
37
+ );
38
+
39
+ testFormat(
40
+ 'bails out and preserves comments when placed inside cn() or end-of-line',
41
+ options,
42
+ dedent`
43
+ <div
44
+ className={cn(
45
+ // Inner layout comment
46
+ 'flex items-center', // Trailing comment
47
+ 'bg-red-500 text-white font-bold p-4 shadow-md',
48
+ )}
49
+ />;
50
+ `,
51
+ // Since it bails out, it keeps the original formatting of the strings,
52
+ // but prettier-plugin-tailwindcss core still sorts the classes inside the string.
53
+ dedent`
54
+ <div
55
+ className={cn(
56
+ // Inner layout comment
57
+ 'flex items-center', // Trailing comment
58
+ 'bg-red-500 p-4 font-bold text-white shadow-md',
59
+ )}
60
+ />;
61
+ `,
62
+ );
63
+
64
+ testFormat(
65
+ 'bails out for comments inside cva([]) array',
66
+ options,
67
+ dedent`
68
+ const button = cva([
69
+ // Array item comment
70
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium p-4 bg-primary',
71
+ ]);
72
+ `,
73
+ dedent`
74
+ const button = cva([
75
+ // Array item comment
76
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium p-4 bg-primary',
77
+ ]);
78
+ `,
79
+ );
80
+
81
+ testFormat(
82
+ 'preserves sparse array hole on comment bail-out in cva([]) array',
83
+ options,
84
+ dedent`
85
+ const button = cva([
86
+ ,
87
+ // Array item comment
88
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium p-4 bg-primary',
89
+ ]);
90
+ `,
91
+ dedent`
92
+ const button = cva([
93
+ ,
94
+ // Array item comment
95
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium p-4 bg-primary',
96
+ ]);
97
+ `,
98
+ );
99
+
100
+ testFormat(
101
+ 'bails out when simplifying short string with comments',
102
+ { ...options, classNameThreshold: 10 },
103
+ dedent`
104
+ <div
105
+ className={cn(
106
+ // short comment
107
+ 'flex items-center',
108
+ )}
109
+ />;
110
+ `,
111
+ dedent`
112
+ <div
113
+ className={cn(
114
+ // short comment
115
+ 'flex items-center',
116
+ )}
117
+ />;
118
+ `,
119
+ );
120
+
121
+ testFormat(
122
+ 'bails out when a bare className string literal has comments attached',
123
+ options,
124
+ dedent`
125
+ <div
126
+ className={
127
+ /* comment attached to literal */
128
+ 'flex items-center justify-center p-4 bg-red-500 shadow-md text-white'
129
+ }
130
+ />;
131
+ `,
132
+ // Note: prettier core simplifies literal string in {} when possible,
133
+ // but because there is a comment inside {}, it preserves the block.
134
+ dedent`
135
+ <div
136
+ className={
137
+ /* comment attached to literal */
138
+ 'flex items-center justify-center bg-red-500 p-4 text-white shadow-md'
139
+ }
140
+ />;
141
+ `,
142
+ );
143
+
144
+ testFormat(
145
+ 'bails out when a plain className string literal has comments attached',
146
+ options,
147
+ dedent`
148
+ <div className=/* comment */"flex items-center justify-center p-4 bg-red-500 shadow-md text-white" />;
149
+ `,
150
+ dedent`
151
+ <div className=/* comment */ "flex items-center justify-center bg-red-500 p-4 text-white shadow-md" />;
152
+ `,
153
+ );
154
+
155
+ testFormat(
156
+ 'bails out for comments on a bare cva string literal',
157
+ options,
158
+ dedent`
159
+ const button = cva(
160
+ /* comment */
161
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium p-4 bg-primary'
162
+ );
163
+ `,
164
+ dedent`
165
+ const button = cva(
166
+ /* comment */
167
+ 'inline-flex items-center justify-center rounded-md text-sm font-medium p-4 bg-primary',
168
+ );
169
+ `,
170
+ );
171
+ });
@@ -0,0 +1,32 @@
1
+ import type { Plugin } from 'prettier';
2
+ import dedent from 'dedent';
3
+ import * as tailwindPlugin from 'prettier-plugin-tailwindcss';
4
+ import { describe } from 'vitest';
5
+ import plugin from '../index';
6
+ import { testFormat } from './utils/test-format';
7
+
8
+ describe('combined mode (with prettier-plugin-tailwindcss)', () => {
9
+ testFormat(
10
+ 'allows prettier-plugin-tailwindcss to sort classes within group',
11
+ {
12
+ classNameThreshold: 5,
13
+ plugins: [plugin, tailwindPlugin as unknown as Plugin],
14
+ },
15
+ dedent /* tsx */ `
16
+ <div className="z-10 flex justify-center items-center p-4 font-bold text-white bg-red-500 bg-opacity-50 shadow-md shadow-black/50 opacity-50 mix-blend-multiply" />;
17
+ `,
18
+ dedent /* tsx */ `
19
+ <div
20
+ className={cn(
21
+ 'z-10',
22
+ 'flex items-center justify-center',
23
+ 'p-4',
24
+ 'font-bold text-white',
25
+ 'bg-opacity-50 bg-red-500',
26
+ 'shadow-md shadow-black/50',
27
+ 'opacity-50 mix-blend-multiply',
28
+ )}
29
+ />;
30
+ `,
31
+ );
32
+ });
@@ -0,0 +1,13 @@
1
+ import path from 'node:path';
2
+ import { describe } from 'vitest';
3
+ import { extractTestCasesFromMarkdown } from './utils/ast-md';
4
+ import { testFormat } from './utils/test-format';
5
+
6
+ describe('readme.md test cases', () => {
7
+ const readmePath = path.resolve(__dirname, '../../README.md');
8
+ const testCases = extractTestCasesFromMarkdown(readmePath);
9
+
10
+ for (const testCase of testCases) {
11
+ testFormat(testCase.id, testCase.options, testCase.input, testCase.expected);
12
+ }
13
+ });
@@ -0,0 +1,22 @@
1
+ import type { Expression, ExpressionStatement, Node, Program } from 'estree-jsx';
2
+ import { parse } from '@babel/parser';
3
+
4
+ /**
5
+ * Parses source code into a standard ESTree Program AST node using @babel/parser with estree plugin.
6
+ */
7
+ export function parseEstree(code: string): Program {
8
+ const file = parse(code, {
9
+ sourceType: 'module',
10
+ plugins: ['jsx', 'typescript', 'estree'],
11
+ });
12
+ return file.program as unknown as Program;
13
+ }
14
+
15
+ /**
16
+ * Parses a single JS/TS/JSX expression into an ESTree AST node.
17
+ */
18
+ export function parseExpression<T extends Node = Expression>(code: string): T {
19
+ const program = parseEstree(`(${code})`);
20
+ const stmt = program.body[0] as ExpressionStatement;
21
+ return stmt.expression as T;
22
+ }
@@ -0,0 +1,101 @@
1
+ import type { Options, Plugin } from 'prettier';
2
+ import fs from 'node:fs';
3
+ import * as tailwindPlugin from 'prettier-plugin-tailwindcss';
4
+ import { remark } from 'remark';
5
+ import remarkParse from 'remark-parse';
6
+ import plugin from '../..';
7
+
8
+ export interface AstNode {
9
+ type: string;
10
+ [key: string]: unknown;
11
+ }
12
+
13
+ export interface ParentNode extends AstNode {
14
+ children: AstNode[];
15
+ }
16
+
17
+ export interface HtmlNode extends AstNode {
18
+ type: 'html';
19
+ value: string;
20
+ }
21
+
22
+ export interface CodeNode extends AstNode {
23
+ type: 'code';
24
+ lang?: string;
25
+ value: string;
26
+ }
27
+
28
+ export interface TestCase {
29
+ id: string;
30
+ options: Options;
31
+ input: string;
32
+ expected: string;
33
+ }
34
+
35
+ export function parseTestCaseOptions(metaStr: string): {
36
+ id: string;
37
+ options: Options;
38
+ } {
39
+ const parts = metaStr.trim().split(/\s+/);
40
+ const id = parts[0] || 'unnamed-test';
41
+ const options: Options = {
42
+ plugins: [plugin, tailwindPlugin as unknown as Plugin],
43
+ };
44
+
45
+ for (let i = 1; i < parts.length; i++) {
46
+ const [key, val] = parts[i].split('=');
47
+ if (key && val) {
48
+ if (!Number.isNaN(Number(val))) {
49
+ options[key] = Number(val);
50
+ } else if (val === 'true' || val === 'false') {
51
+ options[key] = val === 'true';
52
+ } else {
53
+ options[key] = val;
54
+ }
55
+ }
56
+ }
57
+
58
+ return { id, options };
59
+ }
60
+
61
+ function normalizeJsx(code: string): string {
62
+ const trimmed = code.trim();
63
+ return trimmed.endsWith(';') ? trimmed : `${trimmed};`;
64
+ }
65
+
66
+ export function extractTestCasesFromMarkdown(filePath: string): TestCase[] {
67
+ const fileContent = fs.readFileSync(filePath, 'utf8');
68
+ const tree = remark().use(remarkParse).parse(fileContent) as unknown as ParentNode;
69
+ const testCases: TestCase[] = [];
70
+
71
+ const children = tree.children;
72
+ for (let i = 0; i < children.length; i++) {
73
+ const node = children[i];
74
+ if (node.type === 'html') {
75
+ const htmlNode = node as HtmlNode;
76
+ const match = htmlNode.value.match(/<!--\s*test-case:([^\n]+?)-->/);
77
+ if (match) {
78
+ const { id, options } = parseTestCaseOptions(match[1]);
79
+
80
+ // Look ahead for the next two `code` nodes
81
+ const codeNodes: CodeNode[] = [];
82
+ for (let j = i + 1; j < children.length && codeNodes.length < 2; j++) {
83
+ if (children[j].type === 'code') {
84
+ codeNodes.push(children[j] as CodeNode);
85
+ }
86
+ }
87
+
88
+ if (codeNodes.length === 2) {
89
+ testCases.push({
90
+ id,
91
+ options,
92
+ input: normalizeJsx(codeNodes[0].value),
93
+ expected: normalizeJsx(codeNodes[1].value),
94
+ });
95
+ }
96
+ }
97
+ }
98
+ }
99
+
100
+ return testCases;
101
+ }
@@ -0,0 +1,35 @@
1
+ import type { Options } from 'prettier';
2
+ import defineConfig from '@culur/config-prettier/factory';
3
+ import prettier from 'prettier';
4
+ import { expect, it } from 'vitest';
5
+ import plugin from '../../index';
6
+
7
+ const baseConfig = defineConfig();
8
+
9
+ export function testFormat(
10
+ name: string,
11
+ options: Options,
12
+ code: string,
13
+ expected: string,
14
+ ): void {
15
+ it(name, async () => {
16
+ const pass1Config = {
17
+ ...baseConfig,
18
+ parser: 'typescript' as const,
19
+ plugins: [plugin],
20
+ ...options,
21
+ };
22
+
23
+ const formatted = await prettier.format(code, pass1Config);
24
+
25
+ expect(formatted).toBe(`${expected}\n`);
26
+
27
+ const pass2Config = {
28
+ ...pass1Config,
29
+ plugins: (pass1Config.plugins as any[]).filter((p: any) => p !== plugin),
30
+ };
31
+
32
+ const formattedWithoutPlugin = await prettier.format(formatted, pass2Config);
33
+ expect(formattedWithoutPlugin).toBe(formatted);
34
+ });
35
+ }
@@ -0,0 +1,33 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseExpression } from '../__tests__/utils/ast-estree';
3
+ import {
4
+ buildStringArrayExpression,
5
+ isArrayExpression,
6
+ } from './ast-array-expression';
7
+
8
+ describe('ast-array-expression utils', () => {
9
+ describe('isArrayExpression', () => {
10
+ it('returns true for ArrayExpression nodes', () => {
11
+ const node = parseExpression('["flex", "p-4"]');
12
+ expect(isArrayExpression(node)).toBe(true);
13
+ });
14
+
15
+ it('returns false for other node types', () => {
16
+ const node = parseExpression('foo');
17
+ expect(isArrayExpression(node)).toBe(false);
18
+ });
19
+ });
20
+
21
+ describe('buildStringArrayExpression', () => {
22
+ it('builds an ArrayExpression from array of string groups', () => {
23
+ const result = buildStringArrayExpression(['flex items-center', 'p-4']);
24
+ expect(result.type).toBe('ArrayExpression');
25
+ expect(result.elements).toHaveLength(2);
26
+ expect(result.elements[0]).toEqual({
27
+ type: 'Literal',
28
+ value: 'flex items-center',
29
+ raw: JSON.stringify('flex items-center'),
30
+ });
31
+ });
32
+ });
33
+ });
@@ -0,0 +1,20 @@
1
+ import type { ArrayExpression, Node } from 'estree-jsx';
2
+ import { buildStringLiteral } from './ast-literal';
3
+
4
+ export type StringArrayExpression = ArrayExpression & {
5
+ elements: NonNullable<ArrayExpression['elements'][number]>[];
6
+ };
7
+
8
+ export function isArrayExpression(node: Node): node is ArrayExpression {
9
+ return node.type === 'ArrayExpression';
10
+ }
11
+
12
+ /** Builds an ArrayExpression of StringLiterals from grouped class strings. */
13
+ export function buildStringArrayExpression(
14
+ groups: string[],
15
+ ): StringArrayExpression {
16
+ return {
17
+ type: 'ArrayExpression',
18
+ elements: groups.map(buildStringLiteral),
19
+ } satisfies StringArrayExpression;
20
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseExpression } from '../__tests__/utils/ast-estree';
3
+ import { isCallExpression } from './ast-call-expression';
4
+
5
+ describe('ast-call-expression utils', () => {
6
+ describe('isCallExpression', () => {
7
+ it('returns true for CallExpression nodes', () => {
8
+ const node = parseExpression('cn("flex")');
9
+ expect(isCallExpression(node)).toBe(true);
10
+ });
11
+
12
+ it('returns false for other node types', () => {
13
+ const node = parseExpression('foo');
14
+ expect(isCallExpression(node)).toBe(false);
15
+ });
16
+ });
17
+ });
@@ -0,0 +1,5 @@
1
+ import type { CallExpression, Node } from 'estree-jsx';
2
+
3
+ export function isCallExpression(node: Node): node is CallExpression {
4
+ return node.type === 'CallExpression';
5
+ }
@@ -0,0 +1,45 @@
1
+ import type { ArrayExpression } from 'estree-jsx';
2
+ import { assert, describe, expect, it } from 'vitest';
3
+ import { parseExpression } from '../__tests__/utils/ast-estree';
4
+ import { isNodeWithComments, type NodeWithComments } from './ast-comment';
5
+
6
+ describe('isNodeWithComments (real ESTree parser output)', () => {
7
+ it('returns false for nodes without comments', () => {
8
+ const node = parseExpression('foo');
9
+ expect(isNodeWithComments(node)).toBe(false);
10
+ expect(isNodeWithComments(null)).toBe(false);
11
+ expect(isNodeWithComments(undefined)).toBe(false);
12
+ });
13
+
14
+ it('returns true when Prettier injects node.comments', () => {
15
+ const node: NodeWithComments = parseExpression('foo');
16
+ // Prettier internally maps leading/trailing comments into node.comments during print
17
+ node.comments = [{ type: 'Line', value: ' test' }];
18
+ expect(isNodeWithComments(node)).toBe(true);
19
+ });
20
+
21
+ it('detects leading comments from raw Babel ESTree', () => {
22
+ // We use an array so Babel attaches the comment to the inner element
23
+ const arrayNode = parseExpression<ArrayExpression>('[ /* lead */ foo ]');
24
+ const fooNode = arrayNode.elements[0]!;
25
+
26
+ assert(isNodeWithComments(fooNode));
27
+ expect(fooNode.leadingComments?.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ it('detects trailing comments from raw Babel ESTree', () => {
31
+ const arrayNode = parseExpression<ArrayExpression>('[ foo /* trail */ ]');
32
+ const fooNode = arrayNode.elements[0]!;
33
+
34
+ assert(isNodeWithComments(fooNode));
35
+ expect(fooNode.trailingComments?.length).toBeGreaterThan(0);
36
+ });
37
+
38
+ it('returns false when comment arrays are explicitly empty', () => {
39
+ const node: NodeWithComments = parseExpression('foo');
40
+ node.comments = [];
41
+ node.leadingComments = [];
42
+ node.trailingComments = [];
43
+ expect(isNodeWithComments(node)).toBe(false);
44
+ });
45
+ });
@@ -0,0 +1,25 @@
1
+ import type { Node } from 'estree-jsx';
2
+
3
+ export interface Comment {
4
+ type: 'Line' | 'Block' | string;
5
+ value: string;
6
+ [key: string]: unknown;
7
+ }
8
+
9
+ export type NodeWithComments<T extends Node = Node> = T & {
10
+ comments?: Comment[];
11
+ leadingComments?: Comment[];
12
+ trailingComments?: Comment[];
13
+ };
14
+
15
+ export function isNodeWithComments<T extends Node = Node>(
16
+ node?: T | null,
17
+ ): node is NodeWithComments<T> {
18
+ if (!node) return false;
19
+ const n = node as NodeWithComments<T>;
20
+ return Boolean(
21
+ (n.comments && n.comments.length > 0) ||
22
+ (n.leadingComments && n.leadingComments.length > 0) ||
23
+ (n.trailingComments && n.trailingComments.length > 0),
24
+ );
25
+ }
@@ -0,0 +1,29 @@
1
+ import type { Identifier, Node } from 'estree-jsx';
2
+ import type { Printer } from 'prettier';
3
+ import estreePlugin from 'prettier/plugins/estree.js';
4
+
5
+ export const estree = estreePlugin.printers.estree as Printer<Node>;
6
+
7
+ export function isNode(value: unknown): value is Node {
8
+ const node = value as Node;
9
+ return (
10
+ node !== null && //
11
+ typeof node === 'object' &&
12
+ typeof node.type === 'string'
13
+ );
14
+ }
15
+
16
+ export function isIdentifier(
17
+ node: Node,
18
+ names?: string | string[],
19
+ ): node is Identifier {
20
+ if (node.type !== 'Identifier') return false;
21
+ if (names !== undefined) {
22
+ if (Array.isArray(names)) {
23
+ if (!names.includes(node.name)) return false;
24
+ } else if (node.name !== names) {
25
+ return false;
26
+ }
27
+ }
28
+ return true;
29
+ }
@@ -0,0 +1,56 @@
1
+ import type { JSXAttribute, JSXElement } from 'estree-jsx';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { parseExpression } from '../__tests__/utils/ast-estree';
4
+ import {
5
+ isJSXAttribute,
6
+ isJSXExpressionContainer,
7
+ isJSXIdentifier,
8
+ } from './ast-jsx';
9
+
10
+ describe('ast-jsx utils', () => {
11
+ describe('isJSXAttribute', () => {
12
+ it('returns true for JSXAttribute nodes', () => {
13
+ const element = parseExpression<JSXElement>('<div className="flex" />');
14
+ const attr = element.openingElement.attributes[0];
15
+ expect(isJSXAttribute(attr)).toBe(true);
16
+ });
17
+
18
+ it('returns false for other node types', () => {
19
+ const node = parseExpression('foo');
20
+ expect(isJSXAttribute(node)).toBe(false);
21
+ });
22
+ });
23
+
24
+ describe('isJSXIdentifier', () => {
25
+ it('returns false if node type is not JSXIdentifier', () => {
26
+ const node = parseExpression('foo');
27
+ expect(isJSXIdentifier(node)).toBe(false);
28
+ });
29
+
30
+ it('returns true for JSXIdentifier when name is undefined', () => {
31
+ const element = parseExpression<JSXElement>('<div className="flex" />');
32
+ const attr = element.openingElement.attributes[0] as JSXAttribute;
33
+ expect(isJSXIdentifier(attr.name)).toBe(true);
34
+ });
35
+
36
+ it('handles name argument matching and non-matching', () => {
37
+ const element = parseExpression<JSXElement>('<div className="flex" />');
38
+ const attr = element.openingElement.attributes[0] as JSXAttribute;
39
+ expect(isJSXIdentifier(attr.name, 'className')).toBe(true);
40
+ expect(isJSXIdentifier(attr.name, 'id')).toBe(false);
41
+ });
42
+ });
43
+
44
+ describe('isJSXExpressionContainer', () => {
45
+ it('returns true for JSXExpressionContainer nodes', () => {
46
+ const element = parseExpression<JSXElement>('<div className={"flex"} />');
47
+ const attr = element.openingElement.attributes[0] as JSXAttribute;
48
+ expect(isJSXExpressionContainer(attr.value!)).toBe(true);
49
+ });
50
+
51
+ it('returns false for other node types', () => {
52
+ const node = parseExpression('foo');
53
+ expect(isJSXExpressionContainer(node)).toBe(false);
54
+ });
55
+ });
56
+ });