diff --git a/README.md b/README.md index c66d7a1..ddac2b1 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ We're dedicated to pushing modern WordPress development forward through our open - 🔄 Transforms `@wordpress/*` imports into global `wp.*` references - 📦 Generates dependency manifest for WordPress enqueuing -- 🎨 Generates theme.json from Tailwind CSS configuration +- 🎨 Generates theme.json from Tailwind CSS configuration (colors, fonts, font sizes, border radius) - 🔥 Hot Module Replacement (HMR) support for the WordPress editor ## Installation @@ -153,10 +153,19 @@ export default defineConfig({ lg: 'Large', }, + // Optional: Configure border radius labels + borderRadiusLabels: { + sm: 'Small', + md: 'Medium', + lg: 'Large', + full: 'Full', + }, + // Optional: Disable specific transformations disableTailwindColors: false, disableTailwindFonts: false, disableTailwindFontSizes: false, + disableTailwindBorderRadius: false, // Optional: Configure paths baseThemeJsonPath: './theme.json', diff --git a/src/index.ts b/src/index.ts index 8c778df..5370dab 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,14 @@ interface ThemeJsonPluginOptions { */ fontSizeLabels?: Record; + /** + * Labels for border radius sizes to use in the WordPress editor. + * Keys should be size identifiers (e.g. 'sm', 'lg', 'full') and values are the human-readable labels. + * For example: { sm: 'Small', lg: 'Large', full: 'Full' } + * When provided, border radius names will be formatted as the label instead of the identifier. + */ + borderRadiusLabels?: Record; + /** * Whether to disable generating color palette entries in theme.json. * When true, no color variables will be processed from the @theme block. @@ -64,6 +72,14 @@ interface ThemeJsonPluginOptions { * @default false */ disableTailwindFontSizes?: boolean; + + /** + * Whether to disable generating border radius size entries in theme.json. + * When true, no border-radius variables will be processed from the @theme block. + * + * @default false + */ + disableTailwindBorderRadius?: boolean; } interface ColorPalette { @@ -126,6 +142,26 @@ interface FontSize { size: string; } +interface BorderRadiusSize { + /** + * The human-readable name of the border radius size. + * This will be displayed in the WordPress editor. + */ + name: string; + + /** + * The machine-readable identifier for the border radius size. + * This should be lowercase and URL-safe. + */ + slug: string; + + /** + * The CSS border-radius value. + * Can be any valid CSS size unit (px, rem, em, etc). + */ + size: string; +} + interface ThemeJsonSettings { /** * Color settings including the color palette. @@ -135,6 +171,25 @@ interface ThemeJsonSettings { palette: ColorPalette[]; }; + /** + * Border settings including radius size presets. + * Generated from --radius-* CSS variables in the @theme block. + */ + border?: { + /** + * Whether to enable border radius controls. + */ + radius?: boolean; + + /** + * Available border radius size presets in the editor. + * Generated from --radius-* CSS variables. + */ + radiusSizes?: BorderRadiusSize[]; + + [key: string]: unknown; + }; + /** * Typography settings including font families and sizes. * Generated from --font-* and --text-* CSS variables in the @theme block. @@ -661,10 +716,12 @@ interface TailwindTheme { colors?: Record; fontFamily?: Record; fontSize?: Record]>; + borderRadius?: Record; extend?: { colors?: Record; fontFamily?: Record; fontSize?: Record]>; + borderRadius?: Record; }; } @@ -692,6 +749,10 @@ function mergeThemeWithExtend(theme: TailwindTheme): TailwindTheme { ...theme.fontSize, ...theme.extend.fontSize, }, + borderRadius: { + ...theme.borderRadius, + ...theme.extend.borderRadius, + }, }; } @@ -800,6 +861,70 @@ function processFontSizes( }); } +/** + * Processes border radius sizes from Tailwind config into theme.json format + */ +function processBorderRadiusSizes( + sizes: Record, + borderRadiusLabels?: Record +): Array<{ name: string; slug: string; size: string }> { + return Object.entries(sizes).filter(([, value]) => isStaticRadiusValue(value)).map(([name, value]) => { + const displayName = + borderRadiusLabels && name in borderRadiusLabels + ? borderRadiusLabels[name] + : name; + + return { + name: displayName, + slug: name.toLowerCase(), + size: value, + }; + }); +} + +/** + * Returns true if the value is a valid CSS border-radius preset value. + * Returns false for function-based values (var, clamp, calc) that can + * break the WordPress radius preset selector UI. + */ +function isStaticRadiusValue(value: string): boolean { + return !/\(/.test(value.trim()); +} + +/** + * Attempts to parse a border-radius size to rem for sorting. + * For multi-value radii (e.g. "15px 255px"), parses the first value. + * Returns null if the value cannot be parsed. + */ +function parseRadiusSizeForSort(size: string): number | null { + const firstValue = size.trim().split(/\s+/)[0]; + const result = convertToRem(firstValue); + // convertToRem returns 0 for unrecognized units; distinguish from actual 0 + if (result === 0 && parseFloat(firstValue) !== 0) return null; + return result; +} + +/** + * Sorts border radius sizes from smallest to largest. + * Unparseable values are placed at the end in their original order. + */ +function sortBorderRadiusSizes( + sizes: BorderRadiusSize[] +): BorderRadiusSize[] { + return [...sizes].sort((a, b) => { + const sizeA = parseRadiusSizeForSort(a.size); + const sizeB = parseRadiusSizeForSort(b.size); + + // If both are unparseable, preserve original order + if (sizeA === null && sizeB === null) return 0; + // Push unparseable values to the end + if (sizeA === null) return 1; + if (sizeB === null) return -1; + + return sizeA - sizeB; + }); +} + /** * Loads and resolves the Tailwind configuration from the provided path */ @@ -864,12 +989,14 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { disableTailwindColors = false, disableTailwindFonts = false, disableTailwindFontSizes = false, + disableTailwindBorderRadius = false, baseThemeJsonPath = './theme.json', outputPath = 'assets/theme.json', cssFile = 'app.css', shadeLabels, fontLabels, fontSizeLabels, + borderRadiusLabels, } = config; let cssContent: string | null = null; @@ -1023,6 +1150,7 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { COLOR: /--color-([^:]+):\s*([^;}]+)[;}]?/g, FONT_FAMILY: /--font-([^:]+):\s*([^;}]+)[;}]?/g, FONT_SIZE: /--text-([^:]+):\s*([^;}]+)[;}]?/g, + BORDER_RADIUS: /--radius-([^:]+):\s*([^;}]+)[;}]?/g, } as const; // Process colors from either @theme block or Tailwind config @@ -1184,6 +1312,39 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { ] : undefined; + // Process border radius sizes from either @theme block or Tailwind config + const borderRadiusEntries = !disableTailwindBorderRadius + ? [ + // Process @theme block border radius sizes if available + ...extractVariables( + patterns.BORDER_RADIUS, + themeContent + ) + .filter( + ([, value]) => isStaticRadiusValue(value) + ) + .map(([name, value]) => { + const displayName = + borderRadiusLabels && + name in borderRadiusLabels + ? borderRadiusLabels[name] + : name; + return { + name: displayName, + slug: name.toLowerCase(), + size: value, + }; + }), + // Process Tailwind config border radius if available + ...(resolvedTailwindConfig?.theme?.borderRadius + ? processBorderRadiusSizes( + resolvedTailwindConfig.theme.borderRadius, + borderRadiusLabels + ) + : []), + ] + : undefined; + // Build theme.json const themeJson: ThemeJson = { __processed__: 'This file was generated using Vite', @@ -1244,6 +1405,41 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { ) ), }, + ...(() => { + if (disableTailwindBorderRadius) { + return baseThemeJson.settings?.border + ? { border: baseThemeJson.settings.border } + : {}; + } + const mergedRadiusSizes = sortBorderRadiusSizes( + [ + ...(baseThemeJson.settings?.border + ?.radiusSizes || []), + ...(borderRadiusEntries || []), + ].filter( + (entry, index, self) => + index === + self.findIndex( + (e) => e.slug === entry.slug + ) + ) + ); + // Only add radius settings if there are entries + if (mergedRadiusSizes.length === 0) { + return baseThemeJson.settings?.border + ? { border: baseThemeJson.settings.border } + : {}; + } + return { + border: { + ...baseThemeJson.settings?.border, + radius: + baseThemeJson.settings?.border + ?.radius ?? true, + radiusSizes: mergedRadiusSizes, + }, + }; + })(), }, }; diff --git a/tests/index.test.ts b/tests/index.test.ts index 521475a..5458805 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -1252,4 +1252,504 @@ describe('wordpressThemeJson', () => { fontSizes.some((f: { slug: string }) => f.slug.includes('shadow')) ).toBe(false); }); + + it('should process border radius CSS variables from @theme block', () => { + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --radius-sm: 0.125rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-full: 9999px; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border.radius).toBe(true); + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'sm', + slug: 'sm', + size: '0.125rem', + }); + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'lg', + slug: 'lg', + size: '0.5rem', + }); + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'full', + slug: 'full', + size: '9999px', + }); + }); + + it('should sort border radius sizes from smallest to largest', () => { + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --radius-full: 9999px; + --radius-sm: 0.125rem; + --radius-lg: 0.5rem; + --radius-md: 0.375rem; + --radius-xs: 0.0625rem; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + const radiusSizes = themeJson.settings.border.radiusSizes; + + expect( + radiusSizes.map((r: { size: string }) => r.size) + ).toEqual([ + '0.0625rem', // xs + '0.125rem', // sm + '0.375rem', // md + '0.5rem', // lg + '9999px', // full + ]); + }); + + it('should handle border radius labels', () => { + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + borderRadiusLabels: { + sm: 'Small', + md: 'Medium', + lg: 'Large', + full: 'Full', + }, + }); + + const cssContent = ` + @theme { + --radius-sm: 0.125rem; + --radius-md: 0.375rem; + --radius-lg: 0.5rem; + --radius-full: 9999px; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'Small', + slug: 'sm', + size: '0.125rem', + }); + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'Full', + slug: 'full', + size: '9999px', + }); + }); + + it('should respect disableTailwindBorderRadius flag', () => { + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + disableTailwindBorderRadius: true, + }); + + const cssContent = ` + @theme { + --radius-sm: 0.125rem; + --radius-lg: 0.5rem; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border).toBeUndefined(); + }); + + it('should deduplicate border radius entries with base theme.json', () => { + const existingThemeJson = { + settings: { + border: { + radius: true, + radiusSizes: [ + { name: 'Small', slug: 'sm', size: '0.125rem' }, + ], + }, + typography: { + fontFamilies: [], + fontSizes: [], + }, + }, + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(existingThemeJson) + ); + + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --radius-sm: 0.25rem; + --radius-lg: 0.5rem; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + const smEntries = themeJson.settings.border.radiusSizes.filter( + (r: { slug: string }) => r.slug === 'sm' + ); + + // Base theme.json entry should win (dedup keeps first) + expect(smEntries).toHaveLength(1); + expect(smEntries[0].size).toBe('0.125rem'); + }); + + it('should respect base theme.json border.radius: false', () => { + const existingThemeJson = { + settings: { + border: { + radius: false, + }, + typography: { + fontFamilies: [], + fontSizes: [], + }, + }, + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(existingThemeJson) + ); + + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --radius-sm: 0.125rem; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border.radius).toBe(false); + }); + + it('should filter out function-based radius values but keep multi-value', () => { + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --radius-sm: 0.125rem; + --radius-dynamic: var(--custom-radius); + --radius-clamped: clamp(0.5rem, 2vw, 1rem); + --radius-pill: 15px 255px; + --radius-lg: 0.5rem; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + const slugs = themeJson.settings.border.radiusSizes.map( + (r: { slug: string }) => r.slug + ); + + expect(slugs).toContain('sm'); + expect(slugs).toContain('lg'); + expect(slugs).toContain('pill'); + expect(slugs).not.toContain('dynamic'); + expect(slugs).not.toContain('clamped'); + }); + + it('should sort multi-value radii by first value and zero-value radii correctly', () => { + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --radius-lg: 0.5rem; + --radius-none: 0px; + --radius-pill: 15px 255px; + --radius-sm: 0.125rem; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + const slugs = themeJson.settings.border.radiusSizes.map( + (r: { slug: string }) => r.slug + ); + + // 0px should come first, then sm, lg, pill (sorted by first token) + expect(slugs.indexOf('none')).toBe(0); + expect(slugs.indexOf('sm')).toBeLessThan(slugs.indexOf('lg')); + expect(slugs.indexOf('lg')).toBeLessThan(slugs.indexOf('pill')); + }); + + it('should preserve base border settings without adding radius when no entries exist', () => { + const existingThemeJson = { + settings: { + border: { + color: true, + style: true, + width: true, + }, + typography: { + fontFamilies: [], + fontSizes: [], + }, + }, + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(existingThemeJson) + ); + + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --color-primary: #000000; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border).toEqual({ + color: true, + style: true, + width: true, + }); + expect(themeJson.settings.border.radius).toBeUndefined(); + expect(themeJson.settings.border.radiusSizes).toBeUndefined(); + }); + + it('should not emit border settings when no radius entries exist', () => { + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --color-primary: #000000; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border).toBeUndefined(); + }); + + it('should process border radius from Tailwind config', async () => { + const tailwindConfigWithRadius = { + default: { + theme: { + colors: { primary: '#000000' }, + fontFamily: { sans: ['system-ui'] }, + fontSize: { base: '1rem' }, + borderRadius: { + sm: '0.125rem', + md: '0.375rem', + lg: '0.5rem', + full: '9999px', + }, + }, + }, + }; + + // Mock dynamic import used by loadTailwindConfig + vi.doMock(path.resolve(mockTailwindConfigPath), () => tailwindConfigWithRadius); + + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + borderRadiusLabels: { full: 'Full' }, + }); + + // Load Tailwind config via configResolved + await (plugin.configResolved as any)?.(); + + // No @theme block — radius should come from Tailwind config + const cssContent = `.foo { color: red; }`; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'sm', + slug: 'sm', + size: '0.125rem', + }); + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'Full', + slug: 'full', + size: '9999px', + }); + }); + + it('should merge theme.extend.borderRadius from Tailwind config', async () => { + const tailwindConfigWithExtend = { + default: { + theme: { + borderRadius: { + sm: '0.125rem', + }, + extend: { + borderRadius: { + pill: '9999px', + }, + }, + }, + }, + }; + + vi.doMock(path.resolve(mockTailwindConfigPath), () => tailwindConfigWithExtend); + + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + await (plugin.configResolved as any)?.(); + + const cssContent = `.foo { color: red; }`; + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'sm', + slug: 'sm', + size: '0.125rem', + }); + expect(themeJson.settings.border.radiusSizes).toContainEqual({ + name: 'pill', + slug: 'pill', + size: '9999px', + }); + }); + + it('should preserve existing base border settings when disableTailwindBorderRadius is true', () => { + const existingThemeJson = { + settings: { + border: { + color: true, + style: true, + width: true, + radius: true, + radiusSizes: [ + { name: 'Small', slug: 'sm', size: '0.25rem' }, + ], + }, + typography: { + fontFamilies: [], + fontSizes: [], + }, + }, + }; + + vi.mocked(fs.readFileSync).mockReturnValue( + JSON.stringify(existingThemeJson) + ); + + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + disableTailwindBorderRadius: true, + }); + + const cssContent = ` + @theme { + --radius-lg: 0.5rem; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + + expect(themeJson.settings.border).toEqual(existingThemeJson.settings.border); + }); + + it('should sort unsupported units to the end', () => { + const plugin = wordpressThemeJson({ + tailwindConfig: mockTailwindConfigPath, + }); + + const cssContent = ` + @theme { + --radius-sm: 0.125rem; + --radius-relative: 50%; + --radius-viewport: 5vh; + --radius-lg: 0.5rem; + } + `; + + (plugin.transform as any)(cssContent, 'app.css'); + const emitFile = vi.fn(); + (plugin.generateBundle as any).call({ emitFile }); + + const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); + const slugs = themeJson.settings.border.radiusSizes.map( + (r: { slug: string }) => r.slug + ); + + // Parseable values first, unsupported units at end + expect(slugs.indexOf('sm')).toBeLessThan(slugs.indexOf('relative')); + expect(slugs.indexOf('lg')).toBeLessThan(slugs.indexOf('relative')); + expect(slugs.indexOf('lg')).toBeLessThan(slugs.indexOf('viewport')); + }); });