diff --git a/.changeset/great-pots-cover.md b/.changeset/great-pots-cover.md
new file mode 100644
index 0000000..92818b4
--- /dev/null
+++ b/.changeset/great-pots-cover.md
@@ -0,0 +1,6 @@
+---
+"@rpxl/recast": minor
+"@rpxl/recast-tailwind-plugin": patch
+---
+
+Remove responsive values from recast components that have no breakpoints defined
diff --git a/.changeset/shiny-chefs-fix.md b/.changeset/shiny-chefs-fix.md
new file mode 100644
index 0000000..be0bbe2
--- /dev/null
+++ b/.changeset/shiny-chefs-fix.md
@@ -0,0 +1,5 @@
+---
+"@rpxl/recast-tailwind-plugin": patch
+---
+
+Fix string literal variant extraction from recast components
diff --git a/packages/lib/src/__tests__/recast.test.tsx b/packages/lib/src/__tests__/recast.test.tsx
index 510bc1b..3148da7 100644
--- a/packages/lib/src/__tests__/recast.test.tsx
+++ b/packages/lib/src/__tests__/recast.test.tsx
@@ -658,7 +658,7 @@ describe("recast function", () => {
});
it("should correctly type breakpoints", () => {
- const Button = recast(BaseButton, {
+ const ButtonWithBreakpoints = recast(BaseButton, {
base: "text-base",
variants: {
size: {
@@ -670,10 +670,26 @@ describe("recast function", () => {
});
// This should compile without errors
- ;
+ ;
// @ts-expect-error - xl breakpoint not specified in component definition
- ;
+ ;
+
+ const ButtonWithoutBreakpoints = recast(BaseButton, {
+ base: "text-base",
+ variants: {
+ size: {
+ sm: "text-sm",
+ lg: "text-lg",
+ },
+ },
+ });
+
+ // This should compile without errors
+ ;
+
+ // @ts-expect-error - Responsive object not allowed when no breakpoints are specified
+ ;
});
});
});
diff --git a/packages/lib/src/recast.tsx b/packages/lib/src/recast.tsx
index 9046e2d..81de0e8 100644
--- a/packages/lib/src/recast.tsx
+++ b/packages/lib/src/recast.tsx
@@ -30,7 +30,7 @@ export function recast<
P extends RecastProps
,
V extends { [K in keyof V]: { [S in keyof V[K]]: string | string[] } },
M extends { [K in keyof M]: string | string[] },
- B extends keyof RecastBreakpoints = keyof RecastBreakpoints,
+ B extends keyof RecastBreakpoints | never = never,
>(Component: React.ComponentType
, styles: RecastStyles, B>, mergeFn?: MergeFn) {
type Props = Omit | keyof ExtractModifierProps> &
ExtractVariantProps &
diff --git a/packages/lib/src/types.ts b/packages/lib/src/types.ts
index 16be91a..eb9d2fb 100644
--- a/packages/lib/src/types.ts
+++ b/packages/lib/src/types.ts
@@ -193,7 +193,7 @@ export type RelaxedRecastStyleProps = {
/**
* Relaxed version of variant props for internal use.
*/
-export type RelaxedVariantProps = {
+export type RelaxedVariantProps = {
[key: string]: ResponsiveValue;
};
@@ -215,7 +215,7 @@ export interface RecastBreakpoints {}
export type Breakpoint = keyof RecastBreakpoints;
/**
- * Represents a value that can be responsive (i.e., different for different breakpoints).
+ * Represents a value that can be responsive (i.e. tailwind breakpoints).
*/
export type ResponsiveValue = B extends never
? T
diff --git a/packages/recast-tailwind-plugin/src/__tests__/index.test.ts b/packages/recast-tailwind-plugin/src/__tests__/index.test.ts
index 2eaa5d4..4b369bf 100644
--- a/packages/recast-tailwind-plugin/src/__tests__/index.test.ts
+++ b/packages/recast-tailwind-plugin/src/__tests__/index.test.ts
@@ -60,11 +60,6 @@ async function run(
],
} as Config);
- console.log(
- "Tailwind instance config:",
- JSON.stringify(tailwindInstance, null, 2)
- );
-
const result = await postcss(tailwindInstance).process(input, {
from: `${path.resolve(__filename)}?test=${currentTestName}`,
});
@@ -84,69 +79,17 @@ describe("Recast Tailwind Plugin", () => {
vi.restoreAllMocks();
});
- describe("DEBUG", () => {
- it("typescript component", async () => {
+ describe("Component Extraction", () => {
+ it("should extract basic component definitions", async () => {
let config = {
content: [
{
raw: js`
- import { Slot } from "@radix-ui/react-slot";
- import { recast } from "@rpxl/recast";
- import React, { ButtonHTMLAttributes, forwardRef } from "react";
-
- type Props = ButtonHTMLAttributes & {
- asChild?: boolean;
- };
-
- const Component = forwardRef(
- ({ asChild = false, ...props }, ref) => {
- const Comp = asChild ? Slot : "button";
-
- return ;
- },
- );
-
- Component.displayName = "Button";
-
- export const Button = recast(Component, {
- base: [
- "flex",
- "items-center",
- "justify-start",
- "disabled:cursor-not-allowed",
- "transition-all",
- "text-sm",
- "uppercase",
- "relative",
- "rounded-full",
- "focus:outline-none",
- ],
- variants: {
- variant: {
- primary: [
- "px-10",
- "py-4",
- "text-white",
- "after:absolute",
- "after:inset-0",
- "after:bg-eonarc-mine-shaft",
- "after:scale-100",
- "after:transition-all",
- "after:hover:scale-x-[102%]",
- "after:transform",
- "after:ease-in-out",
- "after:duration-300",
- "after:-z-1",
- "after:rounded-full",
- "after:origin-left",
- "after:focus:outline-none",
- "after:focus-visible:ring-4",
- "after:focus-visible:ring-blue-400",
- ],
- ghost: "px-4 py-1",
- },
- },
- });
+ export const Button = recast(ButtonPrimitive, {
+ base: "bg-blue-500 text-white",
+ variants: { size: { sm: "text-sm", md: "text-base", lg: "text-lg" } },
+ breakpoints: ["md"],
+ });
`,
},
],
@@ -158,13 +101,58 @@ describe("Recast Tailwind Plugin", () => {
},
};
- const { result, pluginResult } = await run(config);
- console.log(pluginResult);
+ const { pluginResult } = await run(config);
+
+ expect(pluginResult.extractedComponents).toHaveProperty("Button");
+ expect(pluginResult.extractedComponents.Button).toEqual({
+ base: "bg-blue-500 text-white",
+ variants: {
+ size: {
+ sm: "text-sm",
+ md: "text-base",
+ lg: "text-lg",
+ },
+ },
+ breakpoints: ["md"],
+ });
});
+ it("should handle multiple components in a single file", async () => {
+ let config = {
+ content: [
+ {
+ raw: js`
+ export const Button = recast(ButtonPrimitive, {
+ base: "bg-blue-500 text-white",
+ variants: { size: { sm: "text-sm", lg: "text-lg" } },
+ breakpoints: ["md"],
+ });
+ export const Input = recast(InputPrimitive, {
+ base: "border rounded",
+ variants: {
+ color: { red: "border-red-500", blue: "border-blue-500" },
+ },
+ breakpoints: ["lg"],
+ });
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ theme: {
+ screens: {
+ md: "768px",
+ lg: "1024px",
+ },
+ },
+ };
+
+ const { pluginResult } = await run(config);
+ expect(pluginResult.extractedComponents).toHaveProperty("Button");
+ expect(pluginResult.extractedComponents).toHaveProperty("Input");
+ });
- it("should handle nested variant structures", async () => {
+ it("should extract components with nested variant structures", async () => {
let config = {
content: [
{
@@ -209,7 +197,6 @@ describe("Recast Tailwind Plugin", () => {
},
},
});
-
`,
},
],
@@ -221,27 +208,59 @@ describe("Recast Tailwind Plugin", () => {
},
};
- const { result, pluginResult } = await run(config);
- expect(result.css).toContain(".md\\:bg-blue-500");
- expect(result.css).toContain(".md\\:max-w-4xl");
- expect(result.css).toContain(".md\\:bg-red-500");
- expect(result.css).toContain(".md\\:max-w-6xl");
- expect(result.css).toContain(".md\\:bg-green-500");
- expect(result.css).toContain(".md\\:max-w-7xl");
+ const { pluginResult } = await run(config);
+ expect(pluginResult.extractedComponents).toHaveProperty("SectionWrapper");
+ expect(
+ pluginResult.extractedComponents.SectionWrapper.variants
+ ).toHaveProperty("width");
+ expect(
+ pluginResult.extractedComponents.SectionWrapper.variants.width
+ ).toHaveProperty("sm");
+ expect(
+ pluginResult.extractedComponents.SectionWrapper.variants.width.sm
+ ).toHaveProperty("root");
+ expect(
+ pluginResult.extractedComponents.SectionWrapper.variants.width.sm
+ ).toHaveProperty("inner");
});
- });
- describe("Basic functionality", () => {
- it("should generate base classes and responsive variants", async () => {
+ it("should handle components with string literal keys", async () => {
let config = {
content: [
{
raw: js`
- export const Button = recast(ButtonPrimitive, {
- base: "bg-blue-500 text-white",
- variants: { size: { sm: "text-sm", md: "text-base", lg: "text-lg" } },
- breakpoints: ["md"],
- });
+ import { recast } from '@rpxl/recast';
+ import React, { ElementType, forwardRef } from 'react';
+
+ type Props = React.HTMLAttributes & {
+ as?: ElementType<
+ React.DetailedHTMLProps, HTMLElement>
+ >;
+ };
+
+ const Component = forwardRef(
+ ({ as: Tag = 'div', ...props }, ref) => {
+ return ;
+ },
+ );
+
+ Component.displayName = 'Stack';
+
+ export const Stack = recast(Component, {
+ base: 'flex flex-col',
+ breakpoints: ['md', 'lg'],
+ variants: {
+ gap: {
+ none: 'gap-0',
+ xs: 'gap-1',
+ sm: 'gap-2',
+ md: 'gap-4',
+ lg: 'gap-8',
+ xl: 'gap-3',
+ '2xl': 'gap-24',
+ },
+ },
+ });
`,
},
],
@@ -249,242 +268,142 @@ describe("Recast Tailwind Plugin", () => {
theme: {
screens: {
md: DEFAULT_SCREEN_SIZE,
+ lg: "1024px",
},
},
};
- const { result, pluginResult } = await run(config);
-
- expect(pluginResult.extractedComponents).toHaveProperty("Button");
- expect(pluginResult.extractedComponents.Button).toEqual({
- base: "bg-blue-500 text-white",
- variants: {
- size: {
- sm: "text-sm",
- md: "text-base",
- lg: "text-lg",
- },
- },
- breakpoints: ["md"],
- });
+ const { pluginResult } = await run(config);
- expect(result.css).toContain(".bg-blue-500");
- expect(result.css).toContain(".text-white");
- expect(result.css).toContain(".text-sm");
- expect(result.css).toContain(".text-base");
- expect(result.css).toContain(".text-lg");
- expect(result.css).toContain(".md\\:text-sm");
- expect(result.css).toContain(".md\\:text-base");
- expect(result.css).toContain(".md\\:text-lg");
+ expect(pluginResult.extractedComponents).toHaveProperty("Stack");
+ expect(
+ pluginResult.extractedComponents.Stack.variants.gap
+ ).toHaveProperty("2xl", "gap-24");
});
- it("should handle multiple components and variants", async () => {
+ it("should extract TypeScript components correctly", async () => {
let config = {
content: [
{
raw: js`
- export const Button = recast(ButtonPrimitive, {
- base: "bg-blue-500 text-white",
- variants: { size: { sm: "text-sm", lg: "text-lg" } },
- breakpoints: ["md"],
- });
- export const Input = recast(InputPrimitive, {
- base: "border rounded",
- variants: {
- color: { red: "border-red-500", blue: "border-blue-500" },
- },
- breakpoints: ["lg"],
- });
+ import { Slot } from "@radix-ui/react-slot";
+ import { recast } from "@rpxl/recast";
+ import React, { ButtonHTMLAttributes, forwardRef } from "react";
+
+ type Props = ButtonHTMLAttributes & {
+ asChild?: boolean;
+ };
+
+ const Component = forwardRef(
+ ({ asChild = false, ...props }, ref) => {
+ const Comp = asChild ? Slot : "button";
+
+ return ;
+ },
+ );
+
+ Component.displayName = "Button";
+
+ export const Button = recast(Component, {
+ base: [
+ "flex",
+ "items-center",
+ "justify-start",
+ "disabled:cursor-not-allowed",
+ "transition-all",
+ "text-sm",
+ "uppercase",
+ "relative",
+ "rounded-full",
+ "focus:outline-none",
+ ],
+ variants: {
+ variant: {
+ primary: [
+ "px-10",
+ "py-4",
+ "text-white",
+ "after:absolute",
+ "after:inset-0",
+ "after:bg-eonarc-mine-shaft",
+ "after:scale-100",
+ "after:transition-all",
+ "after:hover:scale-x-[102%]",
+ "after:transform",
+ "after:ease-in-out",
+ "after:duration-300",
+ "after:-z-1",
+ "after:rounded-full",
+ "after:origin-left",
+ "after:focus:outline-none",
+ "after:focus-visible:ring-4",
+ "after:focus-visible:ring-blue-400",
+ ],
+ ghost: "px-4 py-1",
+ },
+ },
+ });
`,
},
],
corePlugins: { preflight: false },
theme: {
screens: {
- md: "768px",
- lg: "1024px",
+ md: DEFAULT_SCREEN_SIZE,
},
},
};
- const { result, pluginResult } = await run(config);
-
+ const { pluginResult } = await run(config);
expect(pluginResult.extractedComponents).toHaveProperty("Button");
- expect(pluginResult.extractedComponents).toHaveProperty("Input");
-
- expect(result.css).toContain(".bg-blue-500");
- expect(result.css).toContain(".border-red-500");
- expect(result.css).toContain(".md\\:text-sm");
- expect(result.css).toContain(".lg\\:border-blue-500");
+ expect(pluginResult.extractedComponents.Button.variants).toHaveProperty(
+ "variant"
+ );
+ expect(
+ pluginResult.extractedComponents.Button.variants.variant
+ ).toHaveProperty("primary");
+ expect(
+ pluginResult.extractedComponents.Button.variants.variant
+ ).toHaveProperty("ghost");
});
- });
- describe("Edge cases", () => {
- it("should handle empty content", async () => {
+ it("should handle variant keys that start with a number", async () => {
let config = {
- content: [{ raw: "" }],
- corePlugins: { preflight: false },
- };
+ content: [
+ {
+ raw: js`
+ import { recast } from '@rpxl/recast';
+ import React, { ElementType, forwardRef } from 'react';
- const { pluginResult } = await run(config);
+ type Props = React.HTMLAttributes & {
+ as?: ElementType<
+ React.DetailedHTMLProps, HTMLElement>
+ >;
+ };
- expect(pluginResult.extractedComponents).toEqual({});
- expect(pluginResult.safelist).toEqual([]);
- });
-
- it("should handle content with no Recast components", async () => {
- let config = {
- content: [
- {
- raw: html`
- const regularComponent = () =>
- Regular
- ;
- `,
- },
- ],
- corePlugins: { preflight: false },
- };
-
- const { pluginResult } = await run(config);
-
- expect(pluginResult.extractedComponents).toEqual({});
- expect(pluginResult.safelist).toEqual([]);
- });
-
- it("should handle components with no variants or breakpoints", async () => {
- let config = {
- content: [
- {
- raw: js`
- export const Button = recast(ButtonPrimitive, {
- base: "bg-blue-500 text-white",
- });
- `,
- },
- ],
- corePlugins: { preflight: false },
- };
-
- const { pluginResult } = await run(config);
-
- expect(pluginResult.extractedComponents).toHaveProperty("Button");
- expect(pluginResult.extractedComponents.Button).toEqual({
- base: "bg-blue-500 text-white",
- breakpoints: [],
- });
- });
-
- it("should handle malformed Recast component definitions", async () => {
- let config = {
- content: [
- {
- raw: html`
- export const Button = recast(ButtonPrimitive, "not an object");
- `,
- },
- ],
- corePlugins: { preflight: false },
- };
-
- const { pluginResult } = await run(config);
-
- expect(pluginResult.extractedComponents).toEqual({});
- expect(pluginResult.safelist).toEqual([]);
- });
-
- it("should warn about missing component name or definition", () => {
- const warnSpy = vi.spyOn(console, "warn");
- const content = js`
- const MissingName = recast(Component, {
- base: "bg-blue-500",
- });
-
- const MissingDefinition = recast();
- `;
- extractRecastComponents(content);
- expect(warnSpy).toHaveBeenCalledWith(
- "Error parsing recast component:",
- expect.any(Error)
- );
- warnSpy.mockRestore();
- });
-
- it("should handle components with empty variants", async () => {
- let config = {
- content: [
- {
- raw: html`
- export const Button = recast(ButtonPrimitive, { base:
- "bg-blue-500", variants: {} });
- `,
- },
- ],
- corePlugins: { preflight: false },
- };
-
- const { pluginResult } = await run(config);
-
- expect(pluginResult.extractedComponents).toHaveProperty("Button");
- expect(pluginResult.extractedComponents.Button.variants).toEqual({});
- });
- });
-
- describe("File handling", () => {
- it("should handle file patterns and extract components from files", async () => {
- const tempFile = `/tmp/Button.tsx`;
- const fileContent = `
- export const Button = recast(ButtonPrimitive, {
- base: "bg-blue-500 text-white",
- variants: {
- size: {
- sm: "text-sm",
- lg: "text-lg"
- }
- },
- breakpoints: ["md"]
- });
- `;
-
- vi.mocked(glob.sync).mockReturnValue([tempFile]);
- vi.mocked(fs.readFileSync).mockReturnValue(fileContent);
-
- let config = {
- content: [tempFile],
- corePlugins: { preflight: false },
- theme: {
- screens: {
- md: DEFAULT_SCREEN_SIZE,
- },
- },
- };
-
- const { pluginResult } = await run(config);
+ const Component = forwardRef(
+ ({ as: Tag = 'div', ...props }, ref) => {
+ return ;
+ },
+ );
- expect(pluginResult.extractedComponents).toHaveProperty("Button");
- expect(pluginResult.extractedComponents.Button).toEqual({
- base: "bg-blue-500 text-white",
- variants: {
- size: {
- sm: "text-sm",
- lg: "text-lg",
- },
- },
- breakpoints: ["md"],
- });
- });
- });
+ Component.displayName = 'Stack';
- describe("Error handling", () => {
- it("should log warnings for undefined breakpoints", async () => {
- const consoleSpy = vi.spyOn(console, "warn");
- let config = {
- content: [
- {
- raw: html`
- export const Button = recast(ButtonPrimitive, { base:
- "bg-blue-500", variants: { size: { sm: "text-sm" } }, breakpoints:
- ["undefined-breakpoint"] });
+ export const Stack = recast(Component, {
+ base: 'flex flex-col',
+ breakpoints: ['md', 'lg'],
+ variants: {
+ gap: {
+ none: 'gap-0',
+ xs: 'gap-1',
+ sm: 'gap-2',
+ md: 'gap-4',
+ lg: 'gap-8',
+ xl: 'gap-3',
+ '2xl': 'gap-24',
+ },
+ },
+ });
`,
},
],
@@ -492,183 +411,22 @@ describe("Recast Tailwind Plugin", () => {
theme: {
screens: {
md: DEFAULT_SCREEN_SIZE,
+ lg: "1024px",
},
},
};
- await run(config);
-
- expect(consoleSpy).toHaveBeenCalledWith(
- expect.stringContaining(
- 'Breakpoint "undefined-breakpoint" is not defined'
- )
- );
- });
-
-
- });
-
- describe("getFilePatterns", () => {
- it("should handle string input", () => {
- expect(getFilePatterns("src/**/*.tsx")).toEqual(["src/**/*.tsx"]);
- });
-
- it("should handle array input", () => {
- expect(getFilePatterns(["src/**/*.tsx", "components/**/*.tsx"])).toEqual([
- "src/**/*.tsx",
- "components/**/*.tsx",
- ]);
- });
-
- it("should handle object input with files array", () => {
- expect(
- getFilePatterns({ files: ["src/**/*.tsx", "components/**/*.tsx"] })
- ).toEqual(["src/**/*.tsx", "components/**/*.tsx"]);
- });
+ const { result, pluginResult } = await run(config);
- it("should handle nested object input", () => {
expect(
- getFilePatterns({
- content: { files: ["src/**/*.tsx", "components/**/*.tsx"] },
- })
- ).toEqual(["src/**/*.tsx", "components/**/*.tsx"]);
- });
-
- it("should return an empty array for invalid input", () => {
- expect(getFilePatterns(null)).toEqual([]);
- expect(getFilePatterns(undefined)).toEqual([]);
- expect(getFilePatterns({})).toEqual([]);
- });
- });
-
- describe("extractRecastComponents", () => {
- it("should extract a simple component", () => {
- const content = `
- export const Button = recast(ButtonPrimitive, {
- base: "bg-blue-500 text-white",
- variants: { size: { sm: "text-sm", lg: "text-lg" } }
- });
- `;
- const result = extractRecastComponents(content);
- expect(result).toHaveProperty("Button");
- expect(result.Button).toHaveProperty("base", "bg-blue-500 text-white");
- expect(result.Button.variants).toHaveProperty("size");
- });
-
- it("should handle multiple components", () => {
- const content = `
- const Button = recast(ButtonPrimitive, { base: "bg-blue-500" });
- export const Input = recast(InputPrimitive, { base: "border rounded" });
- `;
- const result = extractRecastComponents(content);
- expect(Object.keys(result)).toHaveLength(2);
- expect(result).toHaveProperty("Button");
- expect(result).toHaveProperty("Input");
- });
-
- it("should ignore non-recast declarations", () => {
- const content = `
- const regularComponent = () => Regular
;
- export const Button = recast(ButtonPrimitive, { base: "bg-blue-500" });
- `;
- const result = extractRecastComponents(content);
- expect(Object.keys(result)).toHaveLength(1);
- expect(result).toHaveProperty("Button");
- expect(result).not.toHaveProperty("regularComponent");
- });
-
- it("should handle components with no variants", () => {
- const content = `
- export const Text = recast(TextPrimitive, { base: "font-sans" });
- `;
- const result = extractRecastComponents(content);
- expect(result.Text).toHaveProperty("base", "font-sans");
- expect(result.Text).not.toHaveProperty("variants");
- });
-
- it("should handle components with const declarations", () => {
- const content = `
- const Text = recast(TextPrimitive, {
- base: "font-sans",
- variants: {
- size: {
- sm: "text-sm",
- md: "text-base",
- lg: "text-lg"
- }
- }
- });
- export { Text };
- `;
- const result = extractRecastComponents(content);
- expect(result.Text).toBeDefined();
- expect(result.Text.base).toBe("font-sans");
- });
-
- it("should parse components with nested variants", () => {
- const content = `
- export const NestedButton = recast(ButtonPrimitive, {
- base: "bg-blue-500 text-white",
- variants: {
- size: {
- sm: {
- text: "text-sm",
- padding: "px-2 py-1"
- },
- lg: {
- text: "text-lg",
- padding: "px-4 py-2"
- }
- }
- }
- });
- `;
- const result = extractRecastComponents(content);
- expect(result.NestedButton).toEqual({
- base: "bg-blue-500 text-white",
- variants: {
- size: {
- sm: {
- text: "text-sm",
- padding: "px-2 py-1",
- },
- lg: {
- text: "text-lg",
- padding: "px-4 py-2",
- },
- },
- },
- breakpoints: [],
- });
- });
-
- it("should handle classes provided as arrays", () => {
- const content = `
- const Button = recast(ButtonPrimitive, {
- base: ['flex', 'items-center', 'justify-center'],
- variants: {
- variant: {
- primary: 'bg-blue-500 text-white',
- secondary: 'bg-red-500 text-white',
- },
- },
- });
- `;
- const result = extractRecastComponents(content);
- expect(result.Button).toEqual({
- base: "flex items-center justify-center",
- variants: {
- variant: {
- primary: "bg-blue-500 text-white",
- secondary: "bg-red-500 text-white",
- },
- },
- breakpoints: [],
- });
+ pluginResult.extractedComponents.Stack.variants.gap
+ ).toHaveProperty("2xl", "gap-24");
+ expect(result.css).toContain(".md\\:gap-24");
+ expect(result.css).toContain(".lg\\:gap-24");
});
});
- describe("generateSafelist", () => {
+ describe("Safelist Generation", () => {
it("should generate safelist for components with variants", () => {
const components = {
Button: {
@@ -720,87 +478,95 @@ describe("Recast Tailwind Plugin", () => {
expect.stringContaining('Breakpoint "xl" is not defined')
);
});
- });
- describe("addToSafelist", () => {
- it("should add classes to safelist", () => {
- const safelist = new Set();
- addToSafelist(safelist, "bg-blue-500 text-white");
- expect(safelist).toContain("bg-blue-500");
- expect(safelist).toContain("text-white");
- });
+ it("should not include base classes in the safelist", async () => {
+ let config = {
+ content: [
+ {
+ raw: js`
+ export const Button = recast(ButtonPrimitive, {
+ base: "bg-blue-500 text-white",
+ variants: {
+ size: {
+ sm: "text-sm",
+ lg: "text-lg"
+ }
+ },
+ breakpoints: ["sm", "md", "lg"]
+ });
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ theme: {
+ screens: {
+ sm: "640px",
+ md: "768px",
+ lg: "1024px",
+ },
+ },
+ };
- it("should add classes with prefix", () => {
- const safelist = new Set();
- addToSafelist(safelist, "bg-blue-500 text-white", "sm");
- expect(safelist).toContain("sm:bg-blue-500");
- expect(safelist).toContain("sm:text-white");
- });
+ const { pluginResult } = await run(config);
- it("should handle array input", () => {
- const safelist = new Set();
- addToSafelist(safelist, ["bg-blue-500", "text-white"]);
- expect(safelist).toContain("bg-blue-500");
- expect(safelist).toContain("text-white");
+ expect(pluginResult.safelist).not.toContain("bg-blue-500");
+ expect(pluginResult.safelist).not.toContain("text-white");
+ expect(pluginResult.safelist).toContain("sm:text-sm");
+ expect(pluginResult.safelist).toContain("md:text-sm");
+ expect(pluginResult.safelist).toContain("lg:text-sm");
+ expect(pluginResult.safelist).toContain("sm:text-lg");
+ expect(pluginResult.safelist).toContain("md:text-lg");
+ expect(pluginResult.safelist).toContain("lg:text-lg");
});
});
- describe("processContent", () => {
- it("should process file pattern", async () => {
- const tempDir = "/tmp/recast-test";
- const tempFile = `${tempDir}/TestComponent.tsx`;
- const fileContent = `
- export const TestComponent = recast(TestPrimitive, {
- base: "bg-gray-100 p-4",
- variants: {
- size: {
- sm: "text-sm",
- lg: "text-lg"
- }
+ describe("CSS Generation", () => {
+ it("should generate base classes and responsive variants", async () => {
+ let config = {
+ content: [
+ {
+ raw: js`
+ export const Button = recast(ButtonPrimitive, {
+ base: "bg-blue-500 text-white",
+ variants: { size: { sm: "text-sm", md: "text-base", lg: "text-lg" } },
+ breakpoints: ["md"],
+ });
+ `,
},
- breakpoints: ["md"]
- });
- `;
-
- vi.mocked(glob.sync).mockReturnValue([tempFile]);
- vi.mocked(fs.readFileSync).mockReturnValue(fileContent);
-
- const extractedComponents: Record = {};
- const errors: string[] = [];
- await processContent(tempFile, extractedComponents, errors);
- expect(extractedComponents).toHaveProperty("TestComponent");
- expect(errors).toHaveLength(0);
- });
-
- it("should process raw content", () => {
- const rawContent = {
- raw: `export const Input = recast(InputPrimitive, { base: "border rounded" });`,
+ ],
+ corePlugins: { preflight: false },
+ theme: {
+ screens: {
+ md: DEFAULT_SCREEN_SIZE,
+ },
+ },
};
- const extractedComponents: Record = {};
- const errors: string[] = [];
- processContent(rawContent, extractedComponents, errors);
- expect(extractedComponents).toHaveProperty("Input");
- expect(errors).toHaveLength(0);
- });
- it("should handle file reading errors", async () => {
- const tempDir = "/tmp/recast-test";
- const tempFile = `${tempDir}/NonExistentComponent.tsx`;
+ const { result, pluginResult } = await run(config);
- vi.mocked(glob.sync).mockReturnValue([tempFile]);
- vi.mocked(fs.readFileSync).mockImplementation(() => {
- throw new Error("ENOENT: no such file or directory");
+ expect(pluginResult.extractedComponents).toHaveProperty("Button");
+ expect(pluginResult.extractedComponents.Button).toEqual({
+ base: "bg-blue-500 text-white",
+ variants: {
+ size: {
+ sm: "text-sm",
+ md: "text-base",
+ lg: "text-lg",
+ },
+ },
+ breakpoints: ["md"],
});
- const extractedComponents: Record = {};
- const errors: string[] = [];
- await processContent(tempFile, extractedComponents, errors);
- expect(errors).toHaveLength(1);
- expect(errors[0]).toContain("Error reading file");
+ expect(result.css).toContain(".bg-blue-500");
+ expect(result.css).toContain(".text-white");
+ expect(result.css).toContain(".text-sm");
+ expect(result.css).toContain(".text-base");
+ expect(result.css).toContain(".text-lg");
+ expect(result.css).toContain(".md\\:text-sm");
+ expect(result.css).toContain(".md\\:text-base");
+ expect(result.css).toContain(".md\\:text-lg");
});
- });
- describe("Advanced functionality", () => {
it("should handle array-based class definitions", async () => {
let config = {
content: [
@@ -844,23 +610,6 @@ describe("Recast Tailwind Plugin", () => {
expect(result.css).toContain(".md\\:text-lg");
});
- it("should handle invalid file patterns gracefully", async () => {
- let config = {
- content: ["non-existent-file.ts"],
- corePlugins: { preflight: false },
- theme: {
- screens: {
- md: DEFAULT_SCREEN_SIZE,
- },
- },
- };
-
- const { pluginResult } = await run(config);
-
- expect(pluginResult.extractedComponents).toEqual({});
- expect(pluginResult.safelist).toEqual([]);
- });
-
it("should handle complex nested breakpoints", async () => {
let config = {
content: [
@@ -903,4 +652,244 @@ describe("Recast Tailwind Plugin", () => {
expect(result.css).toContain("@media (min-width: 1536px)");
});
});
+
+ describe("Error Handling and Edge Cases", () => {
+ it("should handle empty content", async () => {
+ let config = {
+ content: [{ raw: "" }],
+ corePlugins: { preflight: false },
+ };
+
+ const { pluginResult } = await run(config);
+
+ expect(pluginResult.extractedComponents).toEqual({});
+ expect(pluginResult.safelist).toEqual([]);
+ });
+
+ it("should handle content with no Recast components", async () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+ const regularComponent = () =>
+ Regular
+ ;
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ };
+
+ const { pluginResult } = await run(config);
+
+ expect(pluginResult.extractedComponents).toEqual({});
+ expect(pluginResult.safelist).toEqual([]);
+ });
+
+ it("should handle components with no variants or breakpoints", async () => {
+ let config = {
+ content: [
+ {
+ raw: js`
+ export const Button = recast(ButtonPrimitive, {
+ base: "bg-blue-500 text-white",
+ });
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ };
+
+ const { pluginResult } = await run(config);
+
+ expect(pluginResult.extractedComponents).toHaveProperty("Button");
+ expect(pluginResult.extractedComponents.Button).toEqual({
+ base: "bg-blue-500 text-white",
+ breakpoints: [],
+ });
+ });
+
+ it("should handle malformed Recast component definitions", async () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+ export const Button = recast(ButtonPrimitive, "not an object");
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ };
+
+ const { pluginResult } = await run(config);
+
+ expect(pluginResult.extractedComponents).toEqual({});
+ expect(pluginResult.safelist).toEqual([]);
+ });
+
+ it("should handle components with empty variants", async () => {
+ let config = {
+ content: [
+ {
+ raw: html`
+ export const Button = recast(ButtonPrimitive, { base:
+ "bg-blue-500", variants: {} });
+ `,
+ },
+ ],
+ corePlugins: { preflight: false },
+ };
+
+ const { pluginResult } = await run(config);
+
+ expect(pluginResult.extractedComponents).toHaveProperty("Button");
+ expect(pluginResult.extractedComponents.Button.variants).toEqual({});
+ });
+
+ it("should handle invalid file patterns gracefully", async () => {
+ let config = {
+ content: ["non-existent-file.ts"],
+ corePlugins: { preflight: false },
+ theme: {
+ screens: {
+ md: DEFAULT_SCREEN_SIZE,
+ },
+ },
+ };
+
+ const { pluginResult } = await run(config);
+
+ expect(pluginResult.extractedComponents).toEqual({});
+ expect(pluginResult.safelist).toEqual([]);
+ });
+ });
+
+ describe("File Handling", () => {
+ it("should process file patterns and extract components", async () => {
+ const tempFile = `/tmp/Button.tsx`;
+ const fileContent = `
+ export const Button = recast(ButtonPrimitive, {
+ base: "bg-blue-500 text-white",
+ variants: {
+ size: {
+ sm: "text-sm",
+ lg: "text-lg"
+ }
+ },
+ breakpoints: ["md"]
+ });
+ `;
+
+ vi.mocked(glob.sync).mockReturnValue([tempFile]);
+ vi.mocked(fs.readFileSync).mockReturnValue(fileContent);
+
+ let config = {
+ content: [tempFile],
+ corePlugins: { preflight: false },
+ theme: {
+ screens: {
+ md: DEFAULT_SCREEN_SIZE,
+ },
+ },
+ };
+
+ const { pluginResult } = await run(config);
+
+ expect(pluginResult.extractedComponents).toHaveProperty("Button");
+ expect(pluginResult.extractedComponents.Button).toEqual({
+ base: "bg-blue-500 text-white",
+ variants: {
+ size: {
+ sm: "text-sm",
+ lg: "text-lg",
+ },
+ },
+ breakpoints: ["md"],
+ });
+ });
+
+ it("should process raw content", () => {
+ const rawContent = {
+ raw: `export const Input = recast(InputPrimitive, { base: "border rounded" });`,
+ };
+ const extractedComponents: Record = {};
+ const errors: string[] = [];
+ processContent(rawContent, extractedComponents, errors);
+ expect(extractedComponents).toHaveProperty("Input");
+ expect(errors).toHaveLength(0);
+ });
+
+ it("should handle file reading errors", async () => {
+ const tempDir = "/tmp/recast-test";
+ const tempFile = `${tempDir}/NonExistentComponent.tsx`;
+
+ vi.mocked(glob.sync).mockReturnValue([tempFile]);
+ vi.mocked(fs.readFileSync).mockImplementation(() => {
+ throw new Error("ENOENT: no such file or directory");
+ });
+
+ const extractedComponents: Record = {};
+ const errors: string[] = [];
+ await processContent(tempFile, extractedComponents, errors);
+ expect(errors).toHaveLength(1);
+ expect(errors[0]).toContain("Error reading file");
+ });
+ });
+
+ describe("Utility Functions", () => {
+ describe("getFilePatterns", () => {
+ it("should handle string input", () => {
+ expect(getFilePatterns("src/**/*.tsx")).toEqual(["src/**/*.tsx"]);
+ });
+
+ it("should handle array input", () => {
+ expect(
+ getFilePatterns(["src/**/*.tsx", "components/**/*.tsx"])
+ ).toEqual(["src/**/*.tsx", "components/**/*.tsx"]);
+ });
+
+ it("should handle object input with files array", () => {
+ expect(
+ getFilePatterns({ files: ["src/**/*.tsx", "components/**/*.tsx"] })
+ ).toEqual(["src/**/*.tsx", "components/**/*.tsx"]);
+ });
+
+ it("should handle nested object input", () => {
+ expect(
+ getFilePatterns({
+ content: { files: ["src/**/*.tsx", "components/**/*.tsx"] },
+ })
+ ).toEqual(["src/**/*.tsx", "components/**/*.tsx"]);
+ });
+
+ it("should return an empty array for invalid input", () => {
+ expect(getFilePatterns(null)).toEqual([]);
+ expect(getFilePatterns(undefined)).toEqual([]);
+ expect(getFilePatterns({})).toEqual([]);
+ });
+ });
+
+ describe("addToSafelist", () => {
+ it("should add classes to safelist", () => {
+ const safelist = new Set();
+ addToSafelist(safelist, "bg-blue-500 text-white");
+ expect(safelist).toContain("bg-blue-500");
+ expect(safelist).toContain("text-white");
+ });
+
+ it("should add classes with prefix", () => {
+ const safelist = new Set();
+ addToSafelist(safelist, "bg-blue-500 text-white", "sm");
+ expect(safelist).toContain("sm:bg-blue-500");
+ expect(safelist).toContain("sm:text-white");
+ });
+
+ it("should handle array input", () => {
+ const safelist = new Set();
+ addToSafelist(safelist, ["bg-blue-500", "text-white"]);
+ expect(safelist).toContain("bg-blue-500");
+ expect(safelist).toContain("text-white");
+ });
+ });
+ });
});
diff --git a/packages/recast-tailwind-plugin/src/index.ts b/packages/recast-tailwind-plugin/src/index.ts
index 228607a..fd7a097 100644
--- a/packages/recast-tailwind-plugin/src/index.ts
+++ b/packages/recast-tailwind-plugin/src/index.ts
@@ -175,8 +175,13 @@ function parseVariants(node: t.Node): Record {
if (t.isObjectExpression(node)) {
const variants: Record = {};
for (const prop of node.properties) {
- if (t.isObjectProperty(prop) && t.isIdentifier(prop.key)) {
- variants[prop.key.name] = parseVariantValue(prop.value);
+ if (t.isObjectProperty(prop)) {
+ const key = t.isIdentifier(prop.key)
+ ? prop.key.name
+ : t.isStringLiteral(prop.key)
+ ? prop.key.value
+ : String(prop.key);
+ variants[key] = parseVariantValue(prop.value);
}
}
return variants;
@@ -191,7 +196,10 @@ function parseVariantValue(node: t.Node): any {
return node.value;
} else if (t.isArrayExpression(node)) {
return parseClassValue(node);
+ } else if (t.isIdentifier(node)) {
+ return node.name;
}
+
return "";
}
@@ -228,16 +236,6 @@ export function generateSafelist(
}
});
- // Add base classes
- if (component.base) {
- addToSafelist(safelist, component.base);
- breakpoints.forEach((breakpoint) => {
- if (screens[breakpoint] && component.base) {
- addToSafelist(safelist, component.base, breakpoint);
- }
- });
- }
-
if (component.variants) {
Object.entries(component.variants).forEach(
([variantName, variantOptions]) => {
@@ -337,53 +335,4 @@ export function processContent(
}
}
-/**
- * Process a file pattern and extract Recast components from matching files
- * @param pattern - Glob pattern to match files
- * @param extractedComponents - Object to store extracted components
- * @param errors - Array to store any errors encountered during processing
- */
-function processFilePattern(
- pattern: string,
- extractedComponents: Record,
- errors: string[]
-): void {
- const files = glob.sync(pattern) || [];
- files.forEach((file) => processFile(file, extractedComponents, errors));
-}
-
-/**
- * Process a single file and extract Recast components
- * @param file - Path to the file to process
- * @param extractedComponents - Object to store extracted components
- * @param errors - Array to store any errors encountered during processing
- */
-function processFile(
- file: string,
- extractedComponents: Record,
- errors: string[]
-) {
- try {
- const content = fs.readFileSync(file, "utf8");
- const extractedFromFile = extractRecastComponents(content);
- Object.assign(extractedComponents, extractedFromFile);
- } catch (error) {
- errors.push(`Error reading file ${file}: ${error}`);
- }
-}
-
-/**
- * Type guard to check if an item is a raw content object
- * @param item - Item to check
- * @returns True if the item is a raw content object, false otherwise
- */
-function isRawContent(item: unknown): item is { raw: string } {
- return (
- typeof item === "object" &&
- item !== null &&
- "raw" in item &&
- typeof item.raw === "string"
- );
-}
-
export default recastTailwindPlugin;