├── .gitignore ├── vitest.config.ts ├── .editorconfig ├── tsconfig.json ├── src ├── types │ └── wordpress-dependency-extraction-webpack-plugin.d.ts └── index.ts ├── eslint.config.cjs ├── LICENSE.md ├── package.json ├── README.md └── tests └── index.test.ts /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /npm-debug.log 4 | /package-lock.json 5 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config'; 2 | 3 | export default defineConfig({ 4 | test: { 5 | globals: true, 6 | environment: 'node', 7 | }, 8 | }); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | insert_final_newline = true 7 | indent_style = space 8 | indent_size = 4 9 | trim_trailing_whitespace = true 10 | quote_type = single 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "./dist", 4 | "target": "ES2020", 5 | "module": "NodeNext", 6 | "moduleResolution": "NodeNext", 7 | "resolveJsonModule": true, 8 | "strict": true, 9 | "declaration": true, 10 | "esModuleInterop": true, 11 | "typeRoots": [ 12 | "./node_modules/@types", 13 | "./src/types" 14 | ] 15 | }, 16 | "include": [ 17 | "./src/**/*.ts", 18 | "./src/**/*.d.ts", 19 | "./tests/**/*.ts" 20 | ] 21 | } -------------------------------------------------------------------------------- /src/types/wordpress-dependency-extraction-webpack-plugin.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@wordpress/dependency-extraction-webpack-plugin/lib/util' { 2 | export function defaultRequestToExternal(request: string): string[] | null; 3 | export function defaultRequestToHandle(request: string): string | null; 4 | } 5 | 6 | // Backward compatibility for older imports with .js extension 7 | declare module '@wordpress/dependency-extraction-webpack-plugin/lib/util.js' { 8 | export function defaultRequestToExternal(request: string): string[] | null; 9 | export function defaultRequestToHandle(request: string): string | null; 10 | } 11 | -------------------------------------------------------------------------------- /eslint.config.cjs: -------------------------------------------------------------------------------- 1 | const { defineConfig } = require('eslint/config'); 2 | 3 | const globals = require('globals'); 4 | const tsParser = require('@typescript-eslint/parser'); 5 | const typescriptEslint = require('@typescript-eslint/eslint-plugin'); 6 | const js = require('@eslint/js'); 7 | 8 | const { FlatCompat } = require('@eslint/eslintrc'); 9 | 10 | const compat = new FlatCompat({ 11 | baseDirectory: __dirname, 12 | recommendedConfig: js.configs.recommended, 13 | allConfig: js.configs.all, 14 | }); 15 | 16 | module.exports = defineConfig([ 17 | { 18 | languageOptions: { 19 | globals: { 20 | ...globals.node, 21 | }, 22 | 23 | parser: tsParser, 24 | }, 25 | 26 | plugins: { 27 | '@typescript-eslint': typescriptEslint, 28 | }, 29 | 30 | extends: compat.extends( 31 | 'eslint:recommended', 32 | 'plugin:@typescript-eslint/recommended' 33 | ), 34 | 35 | rules: { 36 | 'no-trailing-spaces': 'error', 37 | }, 38 | }, 39 | ]); 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) Roots Software LLC 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@roots/vite-plugin", 3 | "version": "1.2.3", 4 | "description": "A Vite plugin for working with WordPress.", 5 | "keywords": [ 6 | "wordpress", 7 | "vite", 8 | "vite-plugin" 9 | ], 10 | "contributors": [ 11 | { 12 | "email": "brandon@tendency.me", 13 | "name": "Brandon Nifong", 14 | "url": "https://github.com/log1x" 15 | }, 16 | { 17 | "email": "ben@benword.com", 18 | "name": "Ben Word", 19 | "url": "https://github.com/retlehs" 20 | } 21 | ], 22 | "license": "MIT", 23 | "homepage": "https://github.com/roots/vite-plugin", 24 | "funding": { 25 | "type": "github sponsors", 26 | "url": "https://github.com/sponsors/roots" 27 | }, 28 | "repository": { 29 | "type": "git", 30 | "url": "git+https://github.com/roots/vite-plugin.git" 31 | }, 32 | "type": "module", 33 | "exports": { 34 | ".": { 35 | "types": "./dist/src/index.d.ts", 36 | "default": "./dist/index.js" 37 | } 38 | }, 39 | "types": "./dist/src/index.d.ts", 40 | "files": [ 41 | "/dist" 42 | ], 43 | "scripts": { 44 | "build": "npm run build-plugin", 45 | "build-plugin": "rm -rf dist && npm run build-plugin-types && npm run build-plugin-esm", 46 | "build-plugin-types": "tsc --emitDeclarationOnly", 47 | "build-plugin-esm": "esbuild src/index.ts --platform=node --format=esm --outfile=dist/index.js", 48 | "lint": "eslint --ext .ts ./src ./tests", 49 | "test": "vitest run" 50 | }, 51 | "dependencies": { 52 | "@wordpress/dependency-extraction-webpack-plugin": "^6.27.0" 53 | }, 54 | "devDependencies": { 55 | "@eslint/eslintrc": "^3.3.1", 56 | "@eslint/js": "^9.32.0", 57 | "@types/node": "^20.19.0 || >=22.12.0", 58 | "@typescript-eslint/eslint-plugin": "^8.0.0", 59 | "@typescript-eslint/parser": "^8.0.0", 60 | "esbuild": "0.25.6", 61 | "eslint": "^9.0.0", 62 | "globals": "^16.3.0", 63 | "tailwindcss": "^4.1.11", 64 | "typescript": "^5.0.0", 65 | "vite": "^7.0.0", 66 | "vitest": "^3.0.0" 67 | }, 68 | "peerDependencies": { 69 | "vite": "^5.0.0 || ^6.0.0 || ^7.0.0" 70 | }, 71 | "engines": { 72 | "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Vite Plugin for WordPress 2 | 3 | MIT License 4 | npm version 5 | Build Status 6 | Follow roots.dev on Bluesky 7 | 8 | Here lives a Vite plugin for WordPress development. 9 | 10 | ## Features 11 | 12 | - 🔄 Transforms `@wordpress/*` imports into global `wp.*` references 13 | - 📦 Generates dependency manifest for WordPress enqueuing 14 | - 🎨 Generates theme.json from Tailwind CSS configuration 15 | - 🔥 Hot Module Replacement (HMR) support for the WordPress editor 16 | 17 | ## Installation 18 | 19 | ```bash 20 | npm install @roots/vite-plugin --save-dev 21 | ``` 22 | 23 | ## Usage 24 | 25 | Start by adding the base plugin to your Vite config: 26 | 27 | ```js 28 | // vite.config.js 29 | import { defineConfig } from 'vite'; 30 | import { wordpressPlugin } from '@roots/vite-plugin'; 31 | 32 | export default defineConfig({ 33 | plugins: [wordpressPlugin()], 34 | }); 35 | ``` 36 | 37 | Once you've added the plugin, WordPress dependencies referenced in your code will be transformed into global `wp.*` references. 38 | 39 | When WordPress dependencies are transformed, a manifest containing the required dependencies will be generated called `editor.deps.json`. 40 | 41 | ### External Mappings for Third-Party Plugins 42 | 43 | The plugin can also handle third-party WordPress plugins that expose global JavaScript APIs, such as Advanced Custom Fields (ACF) or WooCommerce. This allows you to import these dependencies in your code while ensuring they're treated as external dependencies and properly enqueued by WordPress. 44 | 45 | ```js 46 | // vite.config.js 47 | import { defineConfig } from 'vite'; 48 | import { wordpressPlugin } from '@roots/vite-plugin'; 49 | 50 | export default defineConfig({ 51 | plugins: [ 52 | wordpressPlugin({ 53 | externalMappings: { 54 | 'acf-input': { 55 | global: ['acf', 'input'], 56 | handle: 'acf-input' 57 | }, 58 | 'woocommerce-blocks': { 59 | global: ['wc', 'blocks'], 60 | handle: 'wc-blocks' 61 | } 62 | } 63 | }), 64 | ], 65 | }); 66 | ``` 67 | 68 | With this configuration, you can import from these packages in your code: 69 | 70 | ```js 71 | import { Field, FieldGroup } from 'acf-input'; 72 | import { registerBlockType } from 'woocommerce-blocks'; 73 | ``` 74 | 75 | The plugin will transform these imports into global references: 76 | 77 | ```js 78 | const Field = acf.input.Field; 79 | const FieldGroup = acf.input.FieldGroup; 80 | const registerBlockType = wc.blocks.registerBlockType; 81 | ``` 82 | 83 | The `handle` value is added to the dependency manifest (`editor.deps.json`) so WordPress knows to enqueue these scripts before your code runs. 84 | 85 | ### Editor HMR Support 86 | 87 | The plugin automatically enables CSS Hot Module Replacement (HMR) for the WordPress editor. 88 | 89 | > [!NOTE] 90 | > JavaScript HMR is not supported at this time. JS changes will trigger a full page reload. 91 | 92 | You can customize the HMR behavior in your Vite config: 93 | 94 | ```js 95 | // vite.config.js 96 | import { defineConfig } from 'vite'; 97 | import { wordpressPlugin } from '@roots/vite-plugin'; 98 | 99 | export default defineConfig({ 100 | plugins: [ 101 | wordpressPlugin({ 102 | hmr: { 103 | // Enable/disable HMR (default: true) 104 | enabled: true, 105 | 106 | // Pattern to match editor entry points (default: /editor/) 107 | editorPattern: /editor/, 108 | 109 | // Name of the editor iframe element (default: 'editor-canvas') 110 | iframeName: 'editor-canvas', 111 | }, 112 | }), 113 | ], 114 | }); 115 | ``` 116 | 117 | ### Theme.json Generation 118 | 119 | When using this plugin for theme development, you have the option of generating a `theme.json` file from your Tailwind CSS configuration. 120 | 121 | To enable this feature, add the `wordpressThemeJson` plugin to your Vite config: 122 | 123 | ```js 124 | // vite.config.js 125 | import { defineConfig } from 'vite'; 126 | import { wordpressThemeJson } from '@roots/vite-plugin'; 127 | 128 | export default defineConfig({ 129 | plugins: [ 130 | wordpressThemeJson({ 131 | // Optional: Configure shade labels 132 | shadeLabels: { 133 | 100: 'Lightest', 134 | 900: 'Darkest', 135 | }, 136 | 137 | // Optional: Configure font family labels 138 | fontLabels: { 139 | sans: 'Sans Serif', 140 | mono: 'Monospace', 141 | inter: 'Inter Font', 142 | }, 143 | 144 | // Optional: Configure font size labels 145 | fontSizeLabels: { 146 | sm: 'Small', 147 | base: 'Default', 148 | lg: 'Large', 149 | }, 150 | 151 | // Optional: Disable specific transformations 152 | disableTailwindColors: false, 153 | disableTailwindFonts: false, 154 | disableTailwindFontSizes: false, 155 | 156 | // Optional: Configure paths 157 | baseThemeJsonPath: './theme.json', 158 | outputPath: 'assets/theme.json', 159 | cssFile: 'app.css', 160 | 161 | // Optional: Legacy Tailwind v3 config path 162 | tailwindConfig: './tailwind.config.js', 163 | }), 164 | ], 165 | }); 166 | ``` 167 | 168 | By default, Tailwind v4 will only [generate CSS variables](https://tailwindcss.com/docs/theme#generating-all-css-variables) that are discovered in your source files. 169 | 170 | To generate the full default Tailwind color palette into your `theme.json`, you can use the `static` theme option when importing Tailwind: 171 | 172 | ```css 173 | @import 'tailwindcss' theme(static); 174 | ``` 175 | 176 | The same applies for customized colors in the `@theme` directive. To ensure your colors get generated, you can use another form of the `static` theme option: 177 | 178 | ```css 179 | @theme static { 180 | --color-white: #fff; 181 | --color-purple: #3f3cbb; 182 | --color-midnight: #121063; 183 | --color-tahiti: #3ab7bf; 184 | --color-bermuda: #78dcca; 185 | } 186 | ``` 187 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { describe, expect, it, afterEach, beforeEach, vi } from 'vitest'; 3 | import type { Plugin, TransformResult } from 'vite'; 4 | import type { InputOptions } from 'rollup'; 5 | import fs from 'fs'; 6 | import path from 'path'; 7 | 8 | // Mock WordPress dependency extraction plugin before importing our plugin 9 | vi.mock('@wordpress/dependency-extraction-webpack-plugin/lib/util', () => ({ 10 | defaultRequestToExternal: vi.fn((request: string) => { 11 | if (request.startsWith('@wordpress/')) { 12 | const pkg = request.substring('@wordpress/'.length); 13 | return ['wp', pkg.replace(/-([a-z])/g, (_: string, letter: string) => letter.toUpperCase())]; 14 | } 15 | return null; 16 | }), 17 | defaultRequestToHandle: vi.fn((request: string) => { 18 | if (request.startsWith('@wordpress/')) { 19 | return 'wp-' + request.substring('@wordpress/'.length); 20 | } 21 | return null; 22 | }), 23 | })); 24 | 25 | vi.mock('fs'); 26 | vi.mock('path'); 27 | 28 | import { wordpressPlugin, wordpressThemeJson } from '../src/index.js'; 29 | 30 | describe('wordpressPlugin', () => { 31 | let plugin: Plugin; 32 | 33 | beforeEach(() => { 34 | plugin = wordpressPlugin(); 35 | }); 36 | 37 | describe('import transformation', () => { 38 | it('should transform named imports', () => { 39 | const code = `import { useState, useEffect } from '@wordpress/element';`; 40 | const result = (plugin.transform as any)?.( 41 | code, 42 | 'test.tsx' 43 | ) as TransformResult; 44 | 45 | expect(result).toBeDefined(); 46 | expect(result?.code).toContain( 47 | 'const useState = wp.element.useState;' 48 | ); 49 | expect(result?.code).toContain( 50 | 'const useEffect = wp.element.useEffect;' 51 | ); 52 | }); 53 | 54 | it('should transform aliased named imports', () => { 55 | const code = `import { useState as useStateWP } from '@wordpress/element';`; 56 | const result = (plugin.transform as any)?.( 57 | code, 58 | 'test.tsx' 59 | ) as TransformResult; 60 | 61 | expect(result).toBeDefined(); 62 | expect(result?.code).toContain( 63 | 'const useStateWP = wp.element.useState;' 64 | ); 65 | }); 66 | 67 | it('should transform namespace imports', () => { 68 | const code = `import * as element from '@wordpress/element';`; 69 | const result = (plugin.transform as any)?.( 70 | code, 71 | 'test.tsx' 72 | ) as TransformResult; 73 | 74 | expect(result).toBeDefined(); 75 | expect(result?.code).toContain('const element = wp.element;'); 76 | }); 77 | 78 | it('should transform default imports', () => { 79 | const code = `import apiFetch from '@wordpress/api-fetch';`; 80 | const result = (plugin.transform as any)?.( 81 | code, 82 | 'test.tsx' 83 | ) as TransformResult; 84 | 85 | expect(result).toBeDefined(); 86 | expect(result?.code).toContain('const apiFetch = wp.apiFetch;'); 87 | }); 88 | 89 | it('should transform side-effect imports', () => { 90 | const code = `import '@wordpress/block-editor';`; 91 | const result = (plugin.transform as any)?.( 92 | code, 93 | 'test.tsx' 94 | ) as TransformResult; 95 | 96 | expect(result).toBeDefined(); 97 | expect(result?.code).toBe(''); 98 | }); 99 | 100 | it('should handle multiple imports', () => { 101 | const code = ` 102 | import { useState } from '@wordpress/element'; 103 | import apiFetch from '@wordpress/api-fetch'; 104 | import * as blocks from '@wordpress/blocks'; 105 | `.trim(); 106 | const result = (plugin.transform as any)?.( 107 | code, 108 | 'test.tsx' 109 | ) as TransformResult; 110 | 111 | expect(result).toBeDefined(); 112 | expect(result?.code).toContain( 113 | 'const useState = wp.element.useState;' 114 | ); 115 | expect(result?.code).toContain('const apiFetch = wp.apiFetch;'); 116 | expect(result?.code).toContain('const blocks = wp.blocks;'); 117 | }); 118 | 119 | it('should only transform files with supported extensions', () => { 120 | const code = `import { useState } from '@wordpress/element';`; 121 | const result = (plugin.transform as any)?.(code, 'test.md'); 122 | 123 | expect(result).toBeNull(); 124 | }); 125 | 126 | it('should preserve non-WordPress imports', () => { 127 | const code = ` 128 | import { useState } from '@wordpress/element'; 129 | import React from 'react'; 130 | import styles from './styles.css'; 131 | `.trim(); 132 | const result = (plugin.transform as any)?.( 133 | code, 134 | 'test.tsx' 135 | ) as TransformResult; 136 | 137 | expect(result).toBeDefined(); 138 | expect(result?.code).toContain( 139 | 'const useState = wp.element.useState;' 140 | ); 141 | expect(result?.code).toContain("import React from 'react';"); 142 | expect(result?.code).toContain( 143 | "import styles from './styles.css';" 144 | ); 145 | }); 146 | }); 147 | 148 | describe('dependency tracking', () => { 149 | it('should track WordPress dependencies and generate manifest', () => { 150 | const code = ` 151 | import { useState } from '@wordpress/element'; 152 | import apiFetch from '@wordpress/api-fetch'; 153 | `.trim(); 154 | 155 | // Transform to trigger dependency tracking 156 | (plugin.transform as any)?.(code, 'test.tsx'); 157 | 158 | // Mock emitFile to capture dependencies 159 | const emitFile = vi.fn(); 160 | if ( 161 | plugin.generateBundle && 162 | typeof plugin.generateBundle === 'function' 163 | ) { 164 | const context = { 165 | emitFile, 166 | meta: {}, 167 | /* eslint-disable @typescript-eslint/no-unused-vars */ 168 | warn: (_message: string) => { 169 | /* intentionally empty for tests */ 170 | }, 171 | error: (_message: string) => { 172 | /* intentionally empty for tests */ 173 | }, 174 | /* eslint-enable @typescript-eslint/no-unused-vars */ 175 | }; 176 | 177 | plugin.generateBundle.call( 178 | context as any, 179 | {} as any, 180 | {}, 181 | false 182 | ); 183 | } 184 | 185 | expect(emitFile).toHaveBeenCalledWith( 186 | expect.objectContaining({ 187 | name: 'editor.deps.json', 188 | originalFileName: 'editor.deps.json', 189 | type: 'asset', 190 | source: JSON.stringify( 191 | ['wp-element', 'wp-api-fetch'], 192 | null, 193 | 2 194 | ), 195 | }) 196 | ); 197 | }); 198 | }); 199 | 200 | describe('external handling', () => { 201 | it('should mark WordPress packages as external', () => { 202 | const result = (plugin.options as any)({ 203 | input: 'src/index.ts', 204 | }) as InputOptions; 205 | 206 | const external = result.external as (id: string) => boolean; 207 | 208 | expect(external('@wordpress/element')).toBe(true); 209 | expect(external('@wordpress/components')).toBe(true); 210 | expect(external('@wordpress/blocks')).toBe(true); 211 | }); 212 | 213 | it('should not mark non-WordPress packages as external', () => { 214 | const result = (plugin.options as any)({ 215 | input: 'src/index.ts', 216 | }) as InputOptions; 217 | 218 | const external = result.external as (id: string) => boolean; 219 | 220 | expect(external('react')).toBe(false); 221 | expect(external('@emotion/react')).toBe(false); 222 | expect(external('./local-file')).toBe(false); 223 | }); 224 | 225 | it('should handle non-string input IDs in external check', () => { 226 | const result = (plugin.options as any)({ 227 | input: 'src/index.ts', 228 | }) as InputOptions; 229 | const external = result.external as (id: unknown) => boolean; 230 | 231 | expect(external(null)).toBe(false); 232 | expect(external(undefined)).toBe(false); 233 | expect(external(123)).toBe(false); 234 | }); 235 | 236 | it('should preserve existing options while adding external handling', () => { 237 | const result = (plugin.options as any)({ 238 | input: 'src/index.ts', 239 | treeshake: true, 240 | preserveEntrySignatures: 'strict' as const, 241 | }) as InputOptions; 242 | 243 | expect(result).toEqual( 244 | expect.objectContaining({ 245 | input: 'src/index.ts', 246 | treeshake: true, 247 | preserveEntrySignatures: 'strict', 248 | external: expect.any(Function), 249 | }) 250 | ); 251 | }); 252 | 253 | it('should not mark exempted WordPress packages as external', () => { 254 | const result = (plugin.options as any)({ 255 | input: 'src/index.ts', 256 | }) as InputOptions; 257 | 258 | const external = result.external as (id: string) => boolean; 259 | 260 | expect(external('@wordpress/icons')).toBe(false); 261 | expect(external('@wordpress/dataviews')).toBe(false); 262 | }); 263 | }); 264 | 265 | describe('custom external mappings', () => { 266 | it('should transform named imports from custom packages', () => { 267 | const plugin = wordpressPlugin({ 268 | externalMappings: { 269 | 'acf-input': { 270 | global: ['acf', 'input'], 271 | handle: 'acf-input', 272 | }, 273 | }, 274 | }); 275 | 276 | const code = `import { Field, FieldGroup } from 'acf-input';`; 277 | const result = (plugin.transform as any)?.( 278 | code, 279 | 'test.tsx' 280 | ) as TransformResult; 281 | 282 | expect(result).toBeDefined(); 283 | expect(result?.code).toContain('const Field = acf.input.Field;'); 284 | expect(result?.code).toContain( 285 | 'const FieldGroup = acf.input.FieldGroup;' 286 | ); 287 | }); 288 | 289 | it('should transform default imports from custom packages', () => { 290 | const plugin = wordpressPlugin({ 291 | externalMappings: { 292 | 'woocommerce-blocks': { 293 | global: ['wc', 'blocks'], 294 | handle: 'wc-blocks', 295 | }, 296 | }, 297 | }); 298 | 299 | const code = `import WCBlocks from 'woocommerce-blocks';`; 300 | const result = (plugin.transform as any)?.( 301 | code, 302 | 'test.tsx' 303 | ) as TransformResult; 304 | 305 | expect(result).toBeDefined(); 306 | expect(result?.code).toContain('const WCBlocks = wc.blocks;'); 307 | }); 308 | 309 | it('should transform namespace imports from custom packages', () => { 310 | const plugin = wordpressPlugin({ 311 | externalMappings: { 312 | 'acf-input': { 313 | global: ['acf', 'input'], 314 | handle: 'acf-input', 315 | }, 316 | }, 317 | }); 318 | 319 | const code = `import * as ACF from 'acf-input';`; 320 | const result = (plugin.transform as any)?.( 321 | code, 322 | 'test.tsx' 323 | ) as TransformResult; 324 | 325 | expect(result).toBeDefined(); 326 | expect(result?.code).toContain('const ACF = acf.input;'); 327 | }); 328 | 329 | it('should transform aliased named imports from custom packages', () => { 330 | const plugin = wordpressPlugin({ 331 | externalMappings: { 332 | 'acf-input': { 333 | global: ['acf', 'input'], 334 | handle: 'acf-input', 335 | }, 336 | }, 337 | }); 338 | 339 | const code = `import { Field as ACFField } from 'acf-input';`; 340 | const result = (plugin.transform as any)?.( 341 | code, 342 | 'test.tsx' 343 | ) as TransformResult; 344 | 345 | expect(result).toBeDefined(); 346 | expect(result?.code).toContain('const ACFField = acf.input.Field;'); 347 | }); 348 | 349 | it('should handle side-effect imports from custom packages', () => { 350 | const plugin = wordpressPlugin({ 351 | externalMappings: { 352 | 'acf-input': { 353 | global: ['acf', 'input'], 354 | handle: 'acf-input', 355 | }, 356 | }, 357 | }); 358 | 359 | const code = `import 'acf-input';`; 360 | const result = (plugin.transform as any)?.( 361 | code, 362 | 'test.tsx' 363 | ) as TransformResult; 364 | 365 | expect(result).toBeDefined(); 366 | expect(result?.code).toBe(''); 367 | }); 368 | 369 | it('should track custom package dependencies in manifest', () => { 370 | const plugin = wordpressPlugin({ 371 | externalMappings: { 372 | 'acf-input': { 373 | global: ['acf', 'input'], 374 | handle: 'acf-input', 375 | }, 376 | 'woocommerce-blocks': { 377 | global: ['wc', 'blocks'], 378 | handle: 'wc-blocks', 379 | }, 380 | }, 381 | }); 382 | 383 | const code = ` 384 | import { Field } from 'acf-input'; 385 | import WCBlocks from 'woocommerce-blocks'; 386 | import { useState } from '@wordpress/element'; 387 | `.trim(); 388 | 389 | // Transform to trigger dependency tracking 390 | (plugin.transform as any)?.(code, 'test.tsx'); 391 | 392 | // Mock emitFile to capture dependencies 393 | const emitFile = vi.fn(); 394 | if ( 395 | plugin.generateBundle && 396 | typeof plugin.generateBundle === 'function' 397 | ) { 398 | const context = { 399 | emitFile, 400 | meta: {}, 401 | /* eslint-disable @typescript-eslint/no-unused-vars */ 402 | warn: (_message: string) => { 403 | /* intentionally empty for tests */ 404 | }, 405 | error: (_message: string) => { 406 | /* intentionally empty for tests */ 407 | }, 408 | /* eslint-enable @typescript-eslint/no-unused-vars */ 409 | }; 410 | 411 | plugin.generateBundle.call( 412 | context as any, 413 | {} as any, 414 | {}, 415 | false 416 | ); 417 | } 418 | 419 | expect(emitFile).toHaveBeenCalledWith( 420 | expect.objectContaining({ 421 | name: 'editor.deps.json', 422 | source: JSON.stringify( 423 | ['acf-input', 'wc-blocks', 'wp-element'], 424 | null, 425 | 2 426 | ), 427 | }) 428 | ); 429 | }); 430 | 431 | it('should mark custom packages as external', () => { 432 | const plugin = wordpressPlugin({ 433 | externalMappings: { 434 | 'acf-input': { 435 | global: ['acf', 'input'], 436 | handle: 'acf-input', 437 | }, 438 | }, 439 | }); 440 | 441 | const result = (plugin.options as any)({ 442 | input: 'src/index.ts', 443 | }) as InputOptions; 444 | 445 | const external = result.external as (id: string) => boolean; 446 | 447 | expect(external('acf-input')).toBe(true); 448 | }); 449 | 450 | it('should not mark non-configured packages as external', () => { 451 | const plugin = wordpressPlugin({ 452 | externalMappings: { 453 | 'acf-input': { 454 | global: ['acf', 'input'], 455 | handle: 'acf-input', 456 | }, 457 | }, 458 | }); 459 | 460 | const result = (plugin.options as any)({ 461 | input: 'src/index.ts', 462 | }) as InputOptions; 463 | 464 | const external = result.external as (id: string) => boolean; 465 | 466 | expect(external('some-other-package')).toBe(false); 467 | }); 468 | 469 | it('should handle multiple custom packages in same file', () => { 470 | const plugin = wordpressPlugin({ 471 | externalMappings: { 472 | 'acf-input': { 473 | global: ['acf', 'input'], 474 | handle: 'acf-input', 475 | }, 476 | 'woocommerce-blocks': { 477 | global: ['wc', 'blocks'], 478 | handle: 'wc-blocks', 479 | }, 480 | }, 481 | }); 482 | 483 | const code = ` 484 | import { Field } from 'acf-input'; 485 | import WCBlocks from 'woocommerce-blocks'; 486 | `.trim(); 487 | const result = (plugin.transform as any)?.( 488 | code, 489 | 'test.tsx' 490 | ) as TransformResult; 491 | 492 | expect(result).toBeDefined(); 493 | expect(result?.code).toContain('const Field = acf.input.Field;'); 494 | expect(result?.code).toContain('const WCBlocks = wc.blocks;'); 495 | }); 496 | 497 | it('should preserve non-custom imports', () => { 498 | const plugin = wordpressPlugin({ 499 | externalMappings: { 500 | 'acf-input': { 501 | global: ['acf', 'input'], 502 | handle: 'acf-input', 503 | }, 504 | }, 505 | }); 506 | 507 | const code = ` 508 | import { Field } from 'acf-input'; 509 | import React from 'react'; 510 | import styles from './styles.css'; 511 | `.trim(); 512 | const result = (plugin.transform as any)?.( 513 | code, 514 | 'test.tsx' 515 | ) as TransformResult; 516 | 517 | expect(result).toBeDefined(); 518 | expect(result?.code).toContain('const Field = acf.input.Field;'); 519 | expect(result?.code).toContain("import React from 'react';"); 520 | expect(result?.code).toContain( 521 | "import styles from './styles.css';" 522 | ); 523 | }); 524 | 525 | it('should handle packages with special characters in names', () => { 526 | const plugin = wordpressPlugin({ 527 | externalMappings: { 528 | '@my/scoped-package': { 529 | global: ['myPackage'], 530 | handle: 'my-scoped-package', 531 | }, 532 | }, 533 | }); 534 | 535 | const code = `import { Component } from '@my/scoped-package';`; 536 | const result = (plugin.transform as any)?.( 537 | code, 538 | 'test.tsx' 539 | ) as TransformResult; 540 | 541 | expect(result).toBeDefined(); 542 | expect(result?.code).toContain( 543 | 'const Component = myPackage.Component;' 544 | ); 545 | }); 546 | 547 | it('should work with both custom and WordPress packages', () => { 548 | const plugin = wordpressPlugin({ 549 | externalMappings: { 550 | 'acf-input': { 551 | global: ['acf', 'input'], 552 | handle: 'acf-input', 553 | }, 554 | }, 555 | }); 556 | 557 | const code = ` 558 | import { Field } from 'acf-input'; 559 | import { useState } from '@wordpress/element'; 560 | `.trim(); 561 | const result = (plugin.transform as any)?.( 562 | code, 563 | 'test.tsx' 564 | ) as TransformResult; 565 | 566 | expect(result).toBeDefined(); 567 | expect(result?.code).toContain('const Field = acf.input.Field;'); 568 | expect(result?.code).toContain( 569 | 'const useState = wp.element.useState;' 570 | ); 571 | }); 572 | }); 573 | }); 574 | 575 | describe('wordpressThemeJson', () => { 576 | const mockTailwindConfigPath = './tailwind.config.js'; 577 | const mockTailwindConfig = { 578 | theme: { 579 | colors: { 580 | primary: '#000000', 581 | 'red-500': '#ef4444', 582 | }, 583 | fontFamily: { 584 | inter: ['Inter', 'sans-serif'], 585 | sans: ['system-ui', 'sans-serif'], 586 | }, 587 | fontSize: { 588 | sm: '0.875rem', 589 | lg: '1.125rem', 590 | }, 591 | }, 592 | }; 593 | 594 | const mockBaseThemeJson = { 595 | settings: { 596 | color: { 597 | palette: [], 598 | }, 599 | typography: { 600 | fontFamilies: [], 601 | fontSizes: [], 602 | }, 603 | }, 604 | }; 605 | 606 | beforeEach(() => { 607 | vi.mocked(fs.readFileSync).mockImplementation( 608 | (path: fs.PathOrFileDescriptor) => { 609 | if ( 610 | typeof path === 'string' && 611 | path.includes('tailwind.config.js') 612 | ) { 613 | return `module.exports = ${JSON.stringify( 614 | mockTailwindConfig 615 | )}`; 616 | } 617 | return JSON.stringify(mockBaseThemeJson); 618 | } 619 | ); 620 | 621 | vi.mocked(path.resolve).mockImplementation((...paths: string[]) => 622 | paths.join('/') 623 | ); 624 | }); 625 | 626 | afterEach(() => { 627 | vi.clearAllMocks(); 628 | }); 629 | 630 | it('should process CSS variables from @theme block', () => { 631 | const plugin = wordpressThemeJson({ 632 | tailwindConfig: mockTailwindConfigPath, 633 | }); 634 | 635 | const cssContent = ` 636 | @theme { 637 | --color-primary: #000000; 638 | --color-red-500: #ef4444; 639 | --font-inter: "Inter"; 640 | --text-lg: 1.125rem; 641 | } 642 | `; 643 | 644 | (plugin.transform as any)(cssContent, 'app.css'); 645 | const emitFile = vi.fn(); 646 | (plugin.generateBundle as any).call({ emitFile }); 647 | 648 | expect(emitFile).toHaveBeenCalledWith( 649 | expect.objectContaining({ 650 | fileName: 'assets/theme.json', 651 | source: expect.stringContaining('"name": "Primary"'), 652 | }) 653 | ); 654 | }); 655 | 656 | it('should handle invalid tailwind config path', async () => { 657 | const plugin = wordpressThemeJson({ 658 | tailwindConfig: './nonexistent.config.js', 659 | }); 660 | 661 | await expect((plugin.configResolved as any)?.()).rejects.toThrow( 662 | /Failed to load Tailwind config/ 663 | ); 664 | }); 665 | 666 | it('should handle numeric color shades', () => { 667 | const plugin = wordpressThemeJson({ 668 | tailwindConfig: mockTailwindConfigPath, 669 | }); 670 | 671 | const cssContent = ` 672 | @theme { 673 | --color-red-500: #ef4444; 674 | --color-blue-100: #e0f2fe; 675 | --color-primary: #000000; 676 | --color-white: #ffffff; 677 | --color-black: #000000; 678 | } 679 | `; 680 | 681 | (plugin.transform as any)(cssContent, 'app.css'); 682 | const emitFile = vi.fn(); 683 | (plugin.generateBundle as any).call({ emitFile }); 684 | 685 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 686 | 687 | expect(themeJson.settings.color.palette).toContainEqual({ 688 | name: 'Red (500)', 689 | slug: 'red-500', 690 | color: '#ef4444', 691 | }); 692 | 693 | expect(themeJson.settings.color.palette).toContainEqual({ 694 | name: 'Blue (100)', 695 | slug: 'blue-100', 696 | color: '#e0f2fe', 697 | }); 698 | 699 | expect(themeJson.settings.color.palette).toContainEqual({ 700 | name: 'Primary', 701 | slug: 'primary', 702 | color: '#000000', 703 | }); 704 | 705 | expect(themeJson.settings.color.palette).toContainEqual({ 706 | name: 'White', 707 | slug: 'white', 708 | color: '#ffffff', 709 | }); 710 | 711 | expect(themeJson.settings.color.palette).toContainEqual({ 712 | name: 'Black', 713 | slug: 'black', 714 | color: '#000000', 715 | }); 716 | }); 717 | 718 | it('should respect disable flags', () => { 719 | const plugin = wordpressThemeJson({ 720 | tailwindConfig: mockTailwindConfigPath, 721 | disableTailwindColors: true, 722 | disableTailwindFonts: true, 723 | disableTailwindFontSizes: true, 724 | }); 725 | 726 | const cssContent = ` 727 | @theme { 728 | --color-primary: #000000; 729 | --font-inter: "Inter"; 730 | --text-lg: 1.125rem; 731 | } 732 | `; 733 | 734 | (plugin.transform as any)(cssContent, 'app.css'); 735 | const emitFile = vi.fn(); 736 | (plugin.generateBundle as any).call({ emitFile }); 737 | 738 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 739 | 740 | expect(themeJson.settings.color?.palette).toEqual([]); 741 | expect(themeJson.settings.typography.fontFamilies).toEqual([]); 742 | expect(themeJson.settings.typography.fontSizes).toEqual([]); 743 | }); 744 | 745 | it('should handle invalid font properties', () => { 746 | const plugin = wordpressThemeJson({ 747 | tailwindConfig: mockTailwindConfigPath, 748 | }); 749 | 750 | const cssContent = ` 751 | @theme { 752 | --font-feature-settings: "ss01"; 753 | --font-weight: 500; 754 | --font-inter: "Inter"; 755 | } 756 | `; 757 | 758 | (plugin.transform as any)(cssContent, 'app.css'); 759 | const emitFile = vi.fn(); 760 | (plugin.generateBundle as any).call({ emitFile }); 761 | 762 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 763 | const fontFamilies = themeJson.settings.typography.fontFamilies; 764 | 765 | expect(fontFamilies).toHaveLength(1); 766 | expect(fontFamilies[0]).toEqual({ 767 | name: 'inter', 768 | slug: 'inter', 769 | fontFamily: 'Inter', 770 | }); 771 | }); 772 | 773 | it('should handle missing @theme block', () => { 774 | const plugin = wordpressThemeJson({ 775 | tailwindConfig: mockTailwindConfigPath, 776 | }); 777 | 778 | const cssContent = ` 779 | .some-class { 780 | color: red; 781 | } 782 | `; 783 | 784 | (plugin.transform as any)(cssContent, 'app.css'); 785 | const emitFile = vi.fn(); 786 | (plugin.generateBundle as any).call({ emitFile }); 787 | 788 | expect(emitFile).not.toHaveBeenCalled(); 789 | }); 790 | 791 | it('should handle malformed @theme block', async () => { 792 | const plugin = wordpressThemeJson({ 793 | tailwindConfig: mockTailwindConfigPath, 794 | }); 795 | 796 | const cssContent = ` 797 | @theme { 798 | --color-primary: #000000; 799 | /* missing closing brace */ 800 | `; 801 | 802 | (plugin.transform as any)(cssContent, 'app.css'); 803 | const emitFile = vi.fn(); 804 | 805 | await expect( 806 | (plugin.generateBundle as any).call({ emitFile }) 807 | ).rejects.toThrow('Unclosed @theme { block - missing closing brace'); 808 | }); 809 | 810 | it('should handle shade labels', () => { 811 | const plugin = wordpressThemeJson({ 812 | tailwindConfig: mockTailwindConfigPath, 813 | shadeLabels: { 814 | '50': 'Lightest', 815 | '100': 'Lighter', 816 | '500': 'Default', 817 | '900': 'Darkest', 818 | }, 819 | }); 820 | 821 | const cssContent = ` 822 | @theme { 823 | --color-blue-50: #f0f9ff; 824 | --color-blue-100: #e0f2fe; 825 | --color-blue-500: #3b82f6; 826 | --color-blue-900: #1e3a8a; 827 | --color-primary: #000000; 828 | } 829 | `; 830 | 831 | (plugin.transform as any)(cssContent, 'app.css'); 832 | const emitFile = vi.fn(); 833 | (plugin.generateBundle as any).call({ emitFile }); 834 | 835 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 836 | 837 | expect(themeJson.settings.color.palette).toContainEqual({ 838 | name: 'Lightest Blue', 839 | slug: 'blue-50', 840 | color: '#f0f9ff', 841 | }); 842 | 843 | expect(themeJson.settings.color.palette).toContainEqual({ 844 | name: 'Lighter Blue', 845 | slug: 'blue-100', 846 | color: '#e0f2fe', 847 | }); 848 | 849 | expect(themeJson.settings.color.palette).toContainEqual({ 850 | name: 'Default Blue', 851 | slug: 'blue-500', 852 | color: '#3b82f6', 853 | }); 854 | 855 | expect(themeJson.settings.color.palette).toContainEqual({ 856 | name: 'Darkest Blue', 857 | slug: 'blue-900', 858 | color: '#1e3a8a', 859 | }); 860 | 861 | expect(themeJson.settings.color.palette).toContainEqual({ 862 | name: 'Primary', 863 | slug: 'primary', 864 | color: '#000000', 865 | }); 866 | }); 867 | 868 | it('should format shades without labels as Color (shade)', () => { 869 | const plugin = wordpressThemeJson({ 870 | tailwindConfig: mockTailwindConfigPath, 871 | // No shade labels configured 872 | }); 873 | 874 | const cssContent = ` 875 | @theme { 876 | --color-blue-50: #f0f9ff; 877 | --color-blue-100: #e0f2fe; 878 | --color-red-500: #ef4444; 879 | --color-gray-900: #111827; 880 | --color-primary: #000000; 881 | } 882 | `; 883 | 884 | (plugin.transform as any)(cssContent, 'app.css'); 885 | const emitFile = vi.fn(); 886 | (plugin.generateBundle as any).call({ emitFile }); 887 | 888 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 889 | 890 | expect(themeJson.settings.color.palette).toContainEqual({ 891 | name: 'Blue (50)', 892 | slug: 'blue-50', 893 | color: '#f0f9ff', 894 | }); 895 | 896 | expect(themeJson.settings.color.palette).toContainEqual({ 897 | name: 'Blue (100)', 898 | slug: 'blue-100', 899 | color: '#e0f2fe', 900 | }); 901 | 902 | expect(themeJson.settings.color.palette).toContainEqual({ 903 | name: 'Red (500)', 904 | slug: 'red-500', 905 | color: '#ef4444', 906 | }); 907 | 908 | expect(themeJson.settings.color.palette).toContainEqual({ 909 | name: 'Gray (900)', 910 | slug: 'gray-900', 911 | color: '#111827', 912 | }); 913 | 914 | expect(themeJson.settings.color.palette).toContainEqual({ 915 | name: 'Primary', 916 | slug: 'primary', 917 | color: '#000000', 918 | }); 919 | }); 920 | 921 | it('should handle multi-hyphen color names', () => { 922 | const plugin = wordpressThemeJson({ 923 | tailwindConfig: mockTailwindConfigPath, 924 | }); 925 | 926 | const cssContent = ` 927 | @theme { 928 | --color-fancy-test-example: #123456; 929 | --color-button-hover-state: #234567; 930 | --color-social-twitter-blue: #1DA1F2; 931 | --color-primary: #000000; 932 | } 933 | `; 934 | 935 | (plugin.transform as any)(cssContent, 'app.css'); 936 | const emitFile = vi.fn(); 937 | (plugin.generateBundle as any).call({ emitFile }); 938 | 939 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 940 | 941 | expect(themeJson.settings.color.palette).toContainEqual({ 942 | name: 'Fancy (Test Example)', 943 | slug: 'fancy-test-example', 944 | color: '#123456', 945 | }); 946 | 947 | expect(themeJson.settings.color.palette).toContainEqual({ 948 | name: 'Button (Hover State)', 949 | slug: 'button-hover-state', 950 | color: '#234567', 951 | }); 952 | 953 | expect(themeJson.settings.color.palette).toContainEqual({ 954 | name: 'Social (Twitter Blue)', 955 | slug: 'social-twitter-blue', 956 | color: '#1DA1F2', 957 | }); 958 | 959 | expect(themeJson.settings.color.palette).toContainEqual({ 960 | name: 'Primary', 961 | slug: 'primary', 962 | color: '#000000', 963 | }); 964 | }); 965 | 966 | it('should preserve existing theme.json settings', () => { 967 | const existingThemeJson = { 968 | settings: { 969 | color: { 970 | palette: [ 971 | { 972 | name: 'existing-color', 973 | slug: 'existing-color', 974 | color: '#cccccc', 975 | }, 976 | ], 977 | }, 978 | typography: { 979 | fontFamilies: [ 980 | { 981 | name: 'existing-font', 982 | slug: 'existing-font', 983 | fontFamily: 'Arial', 984 | }, 985 | ], 986 | fontSizes: [ 987 | { 988 | name: 'existing-size', 989 | slug: 'existing-size', 990 | size: '1rem', 991 | }, 992 | ], 993 | }, 994 | }, 995 | }; 996 | 997 | vi.mocked(fs.readFileSync).mockReturnValue( 998 | JSON.stringify(existingThemeJson) 999 | ); 1000 | 1001 | const plugin = wordpressThemeJson({ 1002 | tailwindConfig: mockTailwindConfigPath, 1003 | }); 1004 | 1005 | const cssContent = ` 1006 | @theme { 1007 | --color-primary: #000000; 1008 | --font-inter: "Inter"; 1009 | --text-lg: 1.125rem; 1010 | } 1011 | `; 1012 | 1013 | (plugin.transform as any)(cssContent, 'app.css'); 1014 | 1015 | const emitFile = vi.fn(); 1016 | (plugin.generateBundle as any).call({ emitFile }); 1017 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 1018 | 1019 | expect(themeJson.settings.color.palette).toContainEqual({ 1020 | name: 'existing-color', 1021 | slug: 'existing-color', 1022 | color: '#cccccc', 1023 | }); 1024 | 1025 | expect(themeJson.settings.typography.fontFamilies).toContainEqual({ 1026 | name: 'existing-font', 1027 | slug: 'existing-font', 1028 | fontFamily: 'Arial', 1029 | }); 1030 | 1031 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 1032 | name: 'existing-size', 1033 | slug: 'existing-size', 1034 | size: '1rem', 1035 | }); 1036 | }); 1037 | 1038 | it('should handle font labels', () => { 1039 | const plugin = wordpressThemeJson({ 1040 | tailwindConfig: mockTailwindConfigPath, 1041 | fontLabels: { 1042 | inter: 'Inter Font', 1043 | sans: 'System Sans', 1044 | }, 1045 | }); 1046 | 1047 | const cssContent = ` 1048 | @theme { 1049 | --font-inter: "Inter"; 1050 | --font-sans: "system-ui"; 1051 | } 1052 | `; 1053 | 1054 | (plugin.transform as any)(cssContent, 'app.css'); 1055 | const emitFile = vi.fn(); 1056 | (plugin.generateBundle as any).call({ emitFile }); 1057 | 1058 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 1059 | 1060 | expect(themeJson.settings.typography.fontFamilies).toContainEqual({ 1061 | name: 'Inter Font', 1062 | slug: 'inter', 1063 | fontFamily: 'Inter', 1064 | }); 1065 | 1066 | expect(themeJson.settings.typography.fontFamilies).toContainEqual({ 1067 | name: 'System Sans', 1068 | slug: 'sans', 1069 | fontFamily: 'system-ui', 1070 | }); 1071 | }); 1072 | 1073 | it('should handle font size labels', () => { 1074 | const plugin = wordpressThemeJson({ 1075 | tailwindConfig: mockTailwindConfigPath, 1076 | fontSizeLabels: { 1077 | sm: 'Small', 1078 | lg: 'Large', 1079 | '2xs': 'Extra Extra Small', 1080 | }, 1081 | }); 1082 | 1083 | const cssContent = ` 1084 | @theme { 1085 | --text-sm: 0.875rem; 1086 | --text-lg: 1.125rem; 1087 | --text-2xs: 0.625rem; 1088 | } 1089 | `; 1090 | 1091 | (plugin.transform as any)(cssContent, 'app.css'); 1092 | const emitFile = vi.fn(); 1093 | (plugin.generateBundle as any).call({ emitFile }); 1094 | 1095 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 1096 | 1097 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 1098 | name: 'Small', 1099 | slug: 'sm', 1100 | size: '0.875rem', 1101 | }); 1102 | 1103 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 1104 | name: 'Large', 1105 | slug: 'lg', 1106 | size: '1.125rem', 1107 | }); 1108 | 1109 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 1110 | name: 'Extra Extra Small', 1111 | slug: '2xs', 1112 | size: '0.625rem', 1113 | }); 1114 | }); 1115 | 1116 | it('should handle missing font and font size labels', () => { 1117 | const plugin = wordpressThemeJson({ 1118 | tailwindConfig: mockTailwindConfigPath, 1119 | }); 1120 | 1121 | const cssContent = ` 1122 | @theme { 1123 | --font-inter: "Inter"; 1124 | --text-2xs: 0.625rem; 1125 | } 1126 | `; 1127 | 1128 | (plugin.transform as any)(cssContent, 'app.css'); 1129 | const emitFile = vi.fn(); 1130 | (plugin.generateBundle as any).call({ emitFile }); 1131 | 1132 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 1133 | 1134 | expect(themeJson.settings.typography.fontFamilies).toContainEqual({ 1135 | name: 'inter', 1136 | slug: 'inter', 1137 | fontFamily: 'Inter', 1138 | }); 1139 | 1140 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 1141 | name: '2xs', 1142 | slug: '2xs', 1143 | size: '0.625rem', 1144 | }); 1145 | }); 1146 | 1147 | it('should sort font sizes from smallest to largest', () => { 1148 | const plugin = wordpressThemeJson({ 1149 | tailwindConfig: mockTailwindConfigPath, 1150 | }); 1151 | 1152 | const cssContent = ` 1153 | @theme { 1154 | --text-4xl: 2.25rem; 1155 | --text-sm: 0.875rem; 1156 | --text-base: 1rem; 1157 | --text-xs: 0.75rem; 1158 | --text-2xl: 1.5rem; 1159 | --text-lg: 1.125rem; 1160 | } 1161 | `; 1162 | 1163 | (plugin.transform as any)(cssContent, 'app.css'); 1164 | const emitFile = vi.fn(); 1165 | (plugin.generateBundle as any).call({ emitFile }); 1166 | 1167 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 1168 | const fontSizes = themeJson.settings.typography.fontSizes; 1169 | 1170 | // Verify the order is correct 1171 | expect(fontSizes.map((f: { size: string }) => f.size)).toEqual([ 1172 | '0.75rem', // xs 1173 | '0.875rem', // sm 1174 | '1rem', // base 1175 | '1.125rem', // lg 1176 | '1.5rem', // 2xl 1177 | '2.25rem', // 4xl 1178 | ]); 1179 | }); 1180 | 1181 | it('should handle sorting of mixed units', () => { 1182 | const plugin = wordpressThemeJson({ 1183 | tailwindConfig: mockTailwindConfigPath, 1184 | }); 1185 | 1186 | const cssContent = ` 1187 | @theme { 1188 | --text-px: 16px; 1189 | --text-em: 1em; 1190 | --text-rem: 1rem; 1191 | --text-small: 12px; 1192 | --text-large: 1.5rem; 1193 | } 1194 | `; 1195 | 1196 | (plugin.transform as any)(cssContent, 'app.css'); 1197 | const emitFile = vi.fn(); 1198 | (plugin.generateBundle as any).call({ emitFile }); 1199 | 1200 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 1201 | const fontSizes = themeJson.settings.typography.fontSizes; 1202 | 1203 | // Verify the order is correct (12px = 0.75rem, 16px = 1rem) 1204 | expect(fontSizes.map((f: { size: string }) => f.size)).toEqual([ 1205 | '12px', // 0.75rem 1206 | '16px', // 1rem 1207 | '1em', // 1rem 1208 | '1rem', // 1rem 1209 | '1.5rem', // 1.5rem 1210 | ]); 1211 | }); 1212 | 1213 | it('should not include text shadow variables as font sizes', () => { 1214 | const plugin = wordpressThemeJson({ 1215 | tailwindConfig: mockTailwindConfigPath, 1216 | }); 1217 | 1218 | const cssContent = ` 1219 | @theme { 1220 | --text-shadow-xs: 0px 1px 1px #0003; 1221 | --text-shadow-md: 0px 1px 2px #0000001a; 1222 | --text-lg: 1.125rem; 1223 | --text-base: 1rem; 1224 | } 1225 | `; 1226 | 1227 | (plugin.transform as any)(cssContent, 'app.css'); 1228 | const emitFile = vi.fn(); 1229 | (plugin.generateBundle as any).call({ emitFile }); 1230 | 1231 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 1232 | const fontSizes = themeJson.settings.typography.fontSizes; 1233 | 1234 | expect(fontSizes).toContainEqual({ 1235 | name: 'lg', 1236 | slug: 'lg', 1237 | size: '1.125rem', 1238 | }); 1239 | 1240 | expect(fontSizes).toContainEqual({ 1241 | name: 'base', 1242 | slug: 'base', 1243 | size: '1rem', 1244 | }); 1245 | 1246 | expect( 1247 | fontSizes.some((f: { slug: string }) => f.slug.includes('shadow')) 1248 | ).toBe(false); 1249 | }); 1250 | }); 1251 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultRequestToExternal, 3 | defaultRequestToHandle, 4 | } from '@wordpress/dependency-extraction-webpack-plugin/lib/util'; 5 | import type { Plugin as VitePlugin } from 'vite'; 6 | import type { InputOptions } from 'rollup'; 7 | import fs from 'fs'; 8 | import path from 'path'; 9 | 10 | interface ThemeJsonPluginOptions { 11 | /** 12 | * Path to the Tailwind configuration file. 13 | * This is used as a source of truth for generating theme.json settings. 14 | * If not provided, only CSS variables from the @theme block will be processed. 15 | * 16 | * @example './tailwind.config.js' 17 | */ 18 | tailwindConfig?: string; 19 | 20 | /** 21 | * Labels for color shades to use in the WordPress editor. 22 | * Keys should be shade numbers (e.g. 50, 100, 500) and values are the human-readable labels. 23 | * For example: { 50: 'Lightest', 100: 'Lighter', 500: 'Default' } 24 | * When provided, color names will be formatted as '{label} {color}' instead of '{color} ({shade})'. 25 | */ 26 | shadeLabels?: Record; 27 | 28 | /** 29 | * Labels for font families to use in the WordPress editor. 30 | * Keys should be font identifiers (e.g. 'sans', 'mono') and values are the human-readable labels. 31 | * For example: { sans: 'Sans Serif', mono: 'Monospace' } 32 | * When provided, font names will be formatted as the label instead of the identifier. 33 | */ 34 | fontLabels?: Record; 35 | 36 | /** 37 | * Labels for font sizes to use in the WordPress editor. 38 | * Keys should be size identifiers (e.g. '2xs', 'sm', 'lg') and values are the human-readable labels. 39 | * For example: { '2xs': 'Extra Extra Small', sm: 'Small', lg: 'Large' } 40 | * When provided, font size names will be formatted as the label instead of the identifier. 41 | */ 42 | fontSizeLabels?: Record; 43 | 44 | /** 45 | * Whether to disable generating color palette entries in theme.json. 46 | * When true, no color variables will be processed from the @theme block. 47 | * 48 | * @default false 49 | */ 50 | disableTailwindColors?: boolean; 51 | 52 | /** 53 | * Whether to disable generating font family entries in theme.json. 54 | * When true, no font-family variables will be processed from the @theme block. 55 | * 56 | * @default false 57 | */ 58 | disableTailwindFonts?: boolean; 59 | 60 | /** 61 | * Whether to disable generating font size entries in theme.json. 62 | * When true, no font-size variables will be processed from the @theme block. 63 | * 64 | * @default false 65 | */ 66 | disableTailwindFontSizes?: boolean; 67 | } 68 | 69 | interface ColorPalette { 70 | /** 71 | * The human-readable name of the color. 72 | * This will be displayed in the WordPress editor. 73 | */ 74 | name: string; 75 | 76 | /** 77 | * The machine-readable identifier for the color. 78 | * This should be lowercase and URL-safe. 79 | */ 80 | slug: string; 81 | 82 | /** 83 | * The CSS color value. 84 | * Can be any valid CSS color format (hex, rgb, hsl, etc). 85 | */ 86 | color: string; 87 | } 88 | 89 | interface FontFamily { 90 | /** 91 | * The human-readable name of the font family. 92 | * This will be displayed in the WordPress editor. 93 | */ 94 | name: string; 95 | 96 | /** 97 | * The machine-readable identifier for the font family. 98 | * This should be lowercase and URL-safe. 99 | */ 100 | slug: string; 101 | 102 | /** 103 | * The CSS font-family value. 104 | * Can include fallback fonts (e.g. '"Inter", sans-serif'). 105 | */ 106 | fontFamily: string; 107 | } 108 | 109 | interface FontSize { 110 | /** 111 | * The human-readable name of the font size. 112 | * This will be displayed in the WordPress editor. 113 | */ 114 | name: string; 115 | 116 | /** 117 | * The machine-readable identifier for the font size. 118 | * This should be lowercase and URL-safe. 119 | */ 120 | slug: string; 121 | 122 | /** 123 | * The CSS font-size value. 124 | * Can be any valid CSS size unit (px, rem, em, etc). 125 | */ 126 | size: string; 127 | } 128 | 129 | interface ThemeJsonSettings { 130 | /** 131 | * Color settings including the color palette. 132 | * Generated from --color-* CSS variables in the @theme block. 133 | */ 134 | color?: { 135 | palette: ColorPalette[]; 136 | }; 137 | 138 | /** 139 | * Typography settings including font families and sizes. 140 | * Generated from --font-* and --text-* CSS variables in the @theme block. 141 | */ 142 | typography: { 143 | /** 144 | * Whether to include WordPress's default font sizes. 145 | * 146 | * @default false 147 | */ 148 | defaultFontSizes: boolean; 149 | 150 | /** 151 | * Whether to allow custom font size input. 152 | * 153 | * @default false 154 | */ 155 | customFontSize: boolean; 156 | 157 | /** 158 | * Available font families in the editor. 159 | * Generated from --font-* CSS variables. 160 | */ 161 | fontFamilies?: FontFamily[]; 162 | 163 | /** 164 | * Available font sizes in the editor. 165 | * Generated from --text-* CSS variables. 166 | */ 167 | fontSizes?: FontSize[]; 168 | }; 169 | } 170 | 171 | interface ThemeJson { 172 | /** 173 | * Internal flag indicating the file was processed by the plugin. 174 | */ 175 | __processed__?: string; 176 | 177 | /** 178 | * Theme.json settings object containing colors, typography, etc. 179 | */ 180 | settings: ThemeJsonSettings; 181 | 182 | /** 183 | * Additional theme.json properties that will be preserved. 184 | */ 185 | [key: string]: unknown; 186 | } 187 | 188 | /** 189 | * Supported file extensions for WordPress imports transformation 190 | */ 191 | const SUPPORTED_EXTENSIONS = ['.js', '.jsx', '.ts', '.tsx'] as const; 192 | type SupportedExtension = (typeof SUPPORTED_EXTENSIONS)[number]; 193 | 194 | /** 195 | * External package mapping configuration 196 | */ 197 | interface ExternalMapping { 198 | /** 199 | * Global path where the package is exposed. 200 | * For example, ['acf', 'input'] maps to window.acf.input 201 | * 202 | * @example ['acf', 'input'] 203 | * @example ['wc', 'blocks'] 204 | */ 205 | global: string[]; 206 | 207 | /** 208 | * WordPress script handle for dependency enqueueing. 209 | * This is the handle used in wp_enqueue_script. 210 | * 211 | * @example 'acf-input' 212 | * @example 'wc-blocks' 213 | */ 214 | handle: string; 215 | } 216 | 217 | /** 218 | * Configuration for the WordPress plugin 219 | */ 220 | interface WordPressPluginConfig { 221 | /** 222 | * File extensions to process for WordPress imports. 223 | * 224 | * @default ['.js', '.jsx', '.ts', '.tsx'] 225 | */ 226 | extensions?: SupportedExtension[]; 227 | 228 | /** 229 | * Additional external mappings for non-WordPress packages that expose globals. 230 | * Useful for third-party WordPress plugins like ACF, WooCommerce, etc. 231 | * 232 | * @example 233 | * ```ts 234 | * { 235 | * 'acf-input': { 236 | * global: ['acf', 'input'], 237 | * handle: 'acf-input' 238 | * }, 239 | * 'woocommerce-blocks': { 240 | * global: ['wc', 'blocks'], 241 | * handle: 'wc-blocks' 242 | * } 243 | * } 244 | * ``` 245 | */ 246 | externalMappings?: Record; 247 | 248 | /** 249 | * HMR configuration for the WordPress editor 250 | */ 251 | hmr?: { 252 | /** 253 | * Pattern to match editor entry points. 254 | * Can be a string (exact match) or RegExp. 255 | * 256 | * @default /editor/ 257 | */ 258 | editorPattern?: string | RegExp; 259 | 260 | /** 261 | * Pattern to match editor CSS files. 262 | * Can be a string (exact match) or RegExp. 263 | * 264 | * @default 'editor.css' 265 | */ 266 | cssPattern?: string | RegExp; 267 | 268 | /** 269 | * Whether to enable HMR for the WordPress editor. 270 | * 271 | * @default true 272 | */ 273 | enabled?: boolean; 274 | 275 | /** 276 | * Name of the editor iframe element. 277 | * 278 | * @default 'editor-canvas' 279 | */ 280 | iframeName?: string; 281 | }; 282 | } 283 | 284 | /** 285 | * Creates a Vite plugin that handles WordPress dependencies. 286 | * This plugin transforms @wordpress/* imports into global wp.* references, 287 | * generates a dependency manifest for WordPress enqueuing, and handles 288 | * external dependencies. 289 | * 290 | * @example 291 | * ```ts 292 | * // vite.config.ts 293 | * import { wordpressPlugin } from '@wordpress/vite-plugin' 294 | * 295 | * export default defineConfig({ 296 | * plugins: [ 297 | * wordpressPlugin() 298 | * ] 299 | * }) 300 | * ``` 301 | * 302 | * The plugin will: 303 | * 1. Transform imports like `import { useState } from '@wordpress/element'` 304 | * into `const useState = wp.element.useState` 305 | * 2. Track WordPress script dependencies (e.g. 'wp-element') 306 | * 3. Generate an editor.deps.json file listing all dependencies 307 | * 4. Mark all @wordpress/* packages as external dependencies 308 | * 5. Prevent WordPress core libraries from being bundled 309 | * 310 | * @returns A Vite plugin configured to handle WordPress dependencies 311 | */ 312 | export function wordpressPlugin( 313 | config: WordPressPluginConfig = {} 314 | ): VitePlugin { 315 | const extensions = config.extensions ?? SUPPORTED_EXTENSIONS; 316 | const externalMappings = config.externalMappings ?? {}; 317 | const dependencies = new Set(); 318 | 319 | /** 320 | * Do not rewrite imports or mark these packages as external 321 | * @see https://github.com/WordPress/gutenberg/blob/trunk/packages/dependency-extraction-webpack-plugin/lib/util.js 322 | */ 323 | const exemptPackages = [ 324 | '@wordpress/dataviews', 325 | '@wordpress/dataviews/wp', 326 | '@wordpress/icons', 327 | '@wordpress/interface', 328 | '@wordpress/sync', 329 | '@wordpress/undo-manager', 330 | '@wordpress/upload-media', 331 | '@wordpress/fields', 332 | ]; 333 | 334 | // HMR configuration with defaults 335 | const hmrConfig = { 336 | enabled: true, 337 | editorPattern: /editor/, 338 | iframeName: 'editor-canvas', 339 | ...config.hmr, 340 | }; 341 | 342 | // HMR code to inject 343 | const hmrCode = ` 344 | if (import.meta.hot) { 345 | import.meta.hot.on('vite:beforeUpdate', ({ updates }) => { 346 | const editorIframe = document.querySelector('iframe[name="${hmrConfig.iframeName}"]'); 347 | const editor = editorIframe?.contentDocument; 348 | 349 | if (!editor) { 350 | return; 351 | } 352 | 353 | updates.forEach(({ path, type }) => { 354 | if (type !== 'css-update') { 355 | return; 356 | } 357 | 358 | const key = path.split('?')[0]; 359 | 360 | editor.querySelectorAll('link[rel="stylesheet"]').forEach(link => { 361 | if (!link.href.includes(key)) { 362 | return; 363 | } 364 | 365 | const updated = link.href.split('?')[0] + '?direct&t=' + Date.now(); 366 | 367 | link.href = updated; 368 | }); 369 | 370 | editor.querySelectorAll('style').forEach(style => { 371 | if (!style.textContent.includes(key)) { 372 | return; 373 | } 374 | 375 | const importRegex = new RegExp(\`(@import\\\\s*(?:url\\\\(['"]?|['"]))(.*?\${key}[^'"\\\\)]*?)(?:\\\\?[^'"\\\\)]*)?(['"]?\\\\))\`, 'g'); 376 | 377 | style.textContent = style.textContent.replace(importRegex, (_, prefix, importPath, suffix) => { 378 | const updated = importPath.split('?')[0]; 379 | 380 | return prefix + updated + '?direct&t=' + Date.now() + suffix; 381 | }); 382 | }); 383 | }); 384 | }); 385 | }`; 386 | 387 | /** 388 | * Extracts named imports from a WordPress import statement. 389 | * Handles both single and multiple imports with aliases. 390 | */ 391 | function extractNamedImports(imports: string): string[] { 392 | const match = imports.match(/{([^}]+)}/); 393 | return ( 394 | match?.[1] 395 | ?.split(',') 396 | .map((s: string) => s.trim()) 397 | .filter(Boolean) ?? [] 398 | ); 399 | } 400 | 401 | /** 402 | * Transforms WordPress named imports into global variable assignments. 403 | * Handles both direct imports and aliased imports. 404 | */ 405 | function handleNamedReplacement( 406 | namedImports: string[], 407 | external: string[] 408 | ): string { 409 | const externalPath = external.join('.'); 410 | 411 | return namedImports 412 | .map((importStr) => { 413 | const parts = importStr.split(' as ').map((s) => s.trim()); 414 | const name = parts[0]; 415 | const alias = parts[1] ?? name; 416 | 417 | return `const ${alias} = ${externalPath}.${name};`; 418 | }) 419 | .join('\n'); 420 | } 421 | 422 | return { 423 | name: 'wordpress-plugin', 424 | enforce: 'pre', 425 | 426 | options(opts: InputOptions) { 427 | return { 428 | ...opts, 429 | external: (id: string): boolean => { 430 | if (typeof id !== 'string') return false; 431 | 432 | // Check if it's a custom external mapping 433 | if (id in externalMappings) { 434 | return true; 435 | } 436 | 437 | // Check if it's a WordPress package 438 | return ( 439 | id.startsWith('@wordpress/') && 440 | !exemptPackages.includes(id) 441 | ); 442 | }, 443 | }; 444 | }, 445 | 446 | resolveId(id: string) { 447 | // Handle custom external mappings 448 | if (id in externalMappings) { 449 | const mapping = externalMappings[id]; 450 | dependencies.add(mapping.handle); 451 | return { id, external: true }; 452 | } 453 | 454 | // Handle WordPress packages 455 | if (!id?.startsWith('@wordpress/') || exemptPackages.includes(id)) 456 | return null; 457 | 458 | const [external, handle] = [ 459 | defaultRequestToExternal(id), 460 | defaultRequestToHandle(id), 461 | ]; 462 | 463 | if (!external || !handle) return null; 464 | 465 | dependencies.add(handle); 466 | return { id, external: true }; 467 | }, 468 | 469 | transform(code: string, id: string) { 470 | const cleanId = id.split('?')[0]; 471 | if (!extensions.some((ext) => cleanId.endsWith(ext))) return null; 472 | 473 | let transformedCode = code; 474 | 475 | // Handle custom external mappings 476 | for (const [packageName, mapping] of Object.entries(externalMappings)) { 477 | const customImportRegex = new RegExp( 478 | `^[\\s\\n]*import[\\s\\n]+(?:([^;'"]+?)[\\s\\n]+from[\\s\\n]+)?['"]${packageName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}['"][\\s\\n]*;?`, 479 | 'gm' 480 | ); 481 | let match; 482 | 483 | while ((match = customImportRegex.exec(code)) !== null) { 484 | const [fullMatch, imports] = match; 485 | 486 | // Add dependency 487 | dependencies.add(mapping.handle); 488 | 489 | // For side-effect only imports, just remove them 490 | if (!imports) { 491 | transformedCode = transformedCode.replace(fullMatch, ''); 492 | continue; 493 | } 494 | 495 | // Handle different import types 496 | let replacement; 497 | 498 | if (imports.includes('{')) { 499 | // Named imports 500 | replacement = handleNamedReplacement( 501 | extractNamedImports(imports), 502 | mapping.global 503 | ); 504 | } else if (imports.includes('*')) { 505 | // Namespace imports 506 | const namespaceAlias = 507 | imports.match(/\*\s+as\s+(\w+)/)?.[1]; 508 | 509 | if (namespaceAlias) { 510 | replacement = `const ${namespaceAlias} = ${mapping.global.join( 511 | '.' 512 | )};`; 513 | } 514 | } else { 515 | // Default imports 516 | const defaultImport = imports.match(/^(\w+)/)?.[1]; 517 | 518 | if (defaultImport) { 519 | replacement = `const ${defaultImport} = ${mapping.global.join( 520 | '.' 521 | )};`; 522 | } 523 | } 524 | 525 | if (replacement) { 526 | transformedCode = transformedCode.replace( 527 | fullMatch, 528 | replacement 529 | ); 530 | } 531 | } 532 | } 533 | 534 | // Handle all WordPress imports 535 | const importRegex = 536 | /^[\s\n]*import[\s\n]+(?:([^;'"]+?)[\s\n]+from[\s\n]+)?['"]@wordpress\/([^'"]+)['"][\s\n]*;?/gm; 537 | let match; 538 | 539 | while ((match = importRegex.exec(code)) !== null) { 540 | const [fullMatch, imports, pkg] = match; 541 | 542 | if (exemptPackages.includes(`@wordpress/${pkg}`)) { 543 | continue; 544 | } 545 | 546 | const external = defaultRequestToExternal(`@wordpress/${pkg}`); 547 | const handle = defaultRequestToHandle(`@wordpress/${pkg}`); 548 | 549 | if (!external || !handle) continue; 550 | 551 | // Add dependency 552 | dependencies.add(handle); 553 | 554 | // For side-effect only imports, just remove them 555 | if (!imports) { 556 | transformedCode = transformedCode.replace(fullMatch, ''); 557 | continue; 558 | } 559 | 560 | // Handle different import types 561 | let replacement; 562 | 563 | if (imports.includes('{')) { 564 | // Named imports 565 | replacement = handleNamedReplacement( 566 | extractNamedImports(imports), 567 | external 568 | ); 569 | } else if (imports.includes('*')) { 570 | // Namespace imports 571 | const namespaceAlias = 572 | imports.match(/\*\s+as\s+(\w+)/)?.[1]; 573 | 574 | if (namespaceAlias) { 575 | replacement = `const ${namespaceAlias} = ${external.join( 576 | '.' 577 | )};`; 578 | } 579 | } else { 580 | // Default imports 581 | const defaultImport = imports.match(/^(\w+)/)?.[1]; 582 | 583 | if (defaultImport) { 584 | replacement = `const ${defaultImport} = ${external.join( 585 | '.' 586 | )};`; 587 | } 588 | } 589 | 590 | if (replacement) { 591 | transformedCode = transformedCode.replace( 592 | fullMatch, 593 | replacement 594 | ); 595 | } 596 | } 597 | 598 | // Inject HMR code if this is the editor entry point 599 | if ( 600 | hmrConfig.enabled && 601 | !transformedCode.includes('vite:beforeUpdate') && 602 | ((typeof hmrConfig.editorPattern === 'string' && 603 | id.includes(hmrConfig.editorPattern)) || 604 | (hmrConfig.editorPattern instanceof RegExp && 605 | hmrConfig.editorPattern.test(id))) 606 | ) { 607 | transformedCode = `${transformedCode}\n${hmrCode}`; 608 | } 609 | 610 | return { 611 | code: transformedCode, 612 | map: null, 613 | }; 614 | }, 615 | 616 | generateBundle() { 617 | this.emitFile({ 618 | type: 'asset', 619 | name: 'editor.deps.json', 620 | originalFileName: 'editor.deps.json', 621 | source: JSON.stringify([...dependencies], null, 2), 622 | }); 623 | }, 624 | }; 625 | } 626 | 627 | /** 628 | * Configuration for the WordPress theme.json plugin 629 | */ 630 | interface ThemeJsonConfig extends ThemeJsonPluginOptions { 631 | /** 632 | * The Tailwind configuration object containing design tokens. 633 | * This is used as a source of truth for generating theme.json settings. 634 | * If not provided, only CSS variables from the @theme block will be processed. 635 | */ 636 | tailwindConfig?: string; 637 | 638 | /** 639 | * The path to the base theme.json file. 640 | * 641 | * @default './theme.json' 642 | */ 643 | baseThemeJsonPath?: string; 644 | 645 | /** 646 | * The path where the generated theme.json will be written. 647 | * 648 | * @default 'assets/theme.json' 649 | */ 650 | outputPath?: string; 651 | 652 | /** 653 | * The CSS file to process for theme variables. 654 | * 655 | * @default 'app.css' 656 | */ 657 | cssFile?: string; 658 | } 659 | 660 | interface TailwindTheme { 661 | colors?: Record; 662 | fontFamily?: Record; 663 | fontSize?: Record]>; 664 | extend?: { 665 | colors?: Record; 666 | fontFamily?: Record; 667 | fontSize?: Record]>; 668 | }; 669 | } 670 | 671 | interface TailwindConfig { 672 | theme?: TailwindTheme; 673 | } 674 | 675 | /** 676 | * Merges base theme with extended theme properties 677 | */ 678 | function mergeThemeWithExtend(theme: TailwindTheme): TailwindTheme { 679 | if (!theme.extend) return theme; 680 | 681 | return { 682 | ...theme, 683 | colors: { 684 | ...theme.colors, 685 | ...theme.extend.colors, 686 | }, 687 | fontFamily: { 688 | ...theme.fontFamily, 689 | ...theme.extend.fontFamily, 690 | }, 691 | fontSize: { 692 | ...theme.fontSize, 693 | ...theme.extend.fontSize, 694 | }, 695 | }; 696 | } 697 | 698 | /** 699 | * Flattens a nested color object into an array of [name, value] pairs 700 | */ 701 | function flattenColors( 702 | colors: Record 703 | ): Array<[string, string]> { 704 | const flattened: Array<[string, string]> = []; 705 | 706 | for (const [name, value] of Object.entries(colors)) { 707 | if (typeof value === 'string') { 708 | flattened.push([name, value]); 709 | } else if (typeof value === 'object' && value !== null) { 710 | // Handle nested color objects (e.g. { orange: { 500: '#...' } }) 711 | for (const [shade, shadeValue] of Object.entries(value)) { 712 | if (typeof shadeValue === 'string') { 713 | flattened.push([`${name}-${shade}`, shadeValue]); 714 | } 715 | } 716 | } 717 | } 718 | 719 | return flattened; 720 | } 721 | 722 | /** 723 | * Processes font families from Tailwind config into theme.json format 724 | */ 725 | function processFontFamilies( 726 | fonts: Record, 727 | fontLabels?: Record 728 | ): Array<{ name: string; slug: string; fontFamily: string }> { 729 | return Object.entries(fonts).map(([name, value]) => { 730 | const fontFamily = Array.isArray(value) ? value.join(', ') : value; 731 | const displayName = 732 | fontLabels && name in fontLabels ? fontLabels[name] : name; 733 | 734 | return { 735 | name: displayName, 736 | slug: name.toLowerCase(), 737 | fontFamily, 738 | }; 739 | }); 740 | } 741 | 742 | /** 743 | * Converts a CSS size value to a numeric value in rem units for comparison 744 | */ 745 | function convertToRem(size: string): number { 746 | // Remove any spaces and convert to lowercase 747 | size = size.trim().toLowerCase(); 748 | 749 | // Convert px to rem (assuming 16px = 1rem) 750 | if (size.endsWith('px')) { 751 | return parseFloat(size) / 16; 752 | } 753 | 754 | // Convert em to rem (they're equivalent) 755 | if (size.endsWith('em')) { 756 | return parseFloat(size); 757 | } 758 | 759 | // Already in rem 760 | if (size.endsWith('rem')) { 761 | return parseFloat(size); 762 | } 763 | 764 | // For other units or invalid values, return 0 765 | return 0; 766 | } 767 | 768 | /** 769 | * Sorts font sizes from smallest to largest 770 | */ 771 | function sortFontSizes(fontSizes: FontSize[]): FontSize[] { 772 | return [...fontSizes].sort((a, b) => { 773 | const sizeA = convertToRem(a.size); 774 | const sizeB = convertToRem(b.size); 775 | 776 | return sizeA - sizeB; 777 | }); 778 | } 779 | 780 | /** 781 | * Processes font sizes from Tailwind config into theme.json format 782 | */ 783 | function processFontSizes( 784 | sizes: Record]>, 785 | fontSizeLabels?: Record 786 | ): Array<{ name: string; slug: string; size: string }> { 787 | return Object.entries(sizes).map(([name, value]) => { 788 | // Handle both simple sizes and sizes with line height config 789 | const size = Array.isArray(value) ? value[0] : value; 790 | const displayName = 791 | fontSizeLabels && name in fontSizeLabels 792 | ? fontSizeLabels[name] 793 | : name; 794 | 795 | return { 796 | name: displayName, 797 | slug: name.toLowerCase(), 798 | size, 799 | }; 800 | }); 801 | } 802 | 803 | /** 804 | * Loads and resolves the Tailwind configuration from the provided path 805 | */ 806 | async function loadTailwindConfig(configPath: string): Promise { 807 | try { 808 | const absolutePath = path.resolve(configPath); 809 | const config = await import(absolutePath); 810 | const resolvedConfig = config.default || config; 811 | 812 | // Merge extended theme properties if they exist 813 | if (resolvedConfig.theme?.extend) { 814 | resolvedConfig.theme = mergeThemeWithExtend(resolvedConfig.theme); 815 | } 816 | 817 | return resolvedConfig; 818 | } catch (error) { 819 | throw new Error( 820 | `Failed to load Tailwind config from ${configPath}: ${ 821 | error instanceof Error ? error.message : String(error) 822 | }` 823 | ); 824 | } 825 | } 826 | 827 | /** 828 | * Creates a Vite plugin that generates a WordPress theme.json file from Tailwind CSS variables. 829 | * This allows theme.json settings to stay in sync with your Tailwind design tokens. 830 | * 831 | * @example 832 | * ```ts 833 | * // vite.config.ts 834 | * import { wordpressThemeJson } from '@wordpress/vite-plugin' 835 | * import tailwindConfig from './tailwind.config.js' 836 | * 837 | * export default defineConfig({ 838 | * plugins: [ 839 | * wordpressThemeJson({ 840 | * disableTailwindColors: false, 841 | * disableTailwindFonts: false, 842 | * disableTailwindFontSizes: false, 843 | * }), 844 | * ] 845 | * }) 846 | * ``` 847 | * 848 | * CSS variables in an @theme block will be transformed into theme.json: 849 | * ```css 850 | * @theme { 851 | * --color-primary: #000000; -> { name: "primary", color: "#000000" } 852 | * --color-red-500: #ef4444; -> { name: "red-500", color: "#ef4444" } 853 | * --font-inter: "Inter"; -> { name: "inter", fontFamily: "Inter" } 854 | * --text-lg: 1.125rem; -> { name: "lg", size: "1.125rem" } 855 | * } 856 | * ``` 857 | * 858 | * @param options - Configuration options for the theme.json generator 859 | * @returns A Vite plugin configured to generate theme.json from CSS variables 860 | */ 861 | export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { 862 | const { 863 | tailwindConfig, 864 | disableTailwindColors = false, 865 | disableTailwindFonts = false, 866 | disableTailwindFontSizes = false, 867 | baseThemeJsonPath = './theme.json', 868 | outputPath = 'assets/theme.json', 869 | cssFile = 'app.css', 870 | shadeLabels, 871 | fontLabels, 872 | fontSizeLabels, 873 | } = config; 874 | 875 | let cssContent: string | null = null; 876 | let resolvedTailwindConfig: TailwindConfig | undefined; 877 | 878 | if (tailwindConfig !== undefined && typeof tailwindConfig !== 'string') { 879 | throw new Error('tailwindConfig must be a string path or undefined'); 880 | } 881 | 882 | /** 883 | * Safely extracts CSS content between matched braces while handling: 884 | * - Nested braces within the block 885 | * - String literals (both single and double quotes) 886 | * - CSS comments 887 | * - Escaped characters 888 | */ 889 | function extractThemeContent(css: string): string | null { 890 | const themeMatch = css.match(/@(?:layer\s+)?theme\s*{/s); 891 | 892 | if (!themeMatch?.index) return null; 893 | 894 | const startIndex = themeMatch.index + themeMatch[0].length; 895 | 896 | // Define token types we need to handle 897 | const tokens = { 898 | ESCAPE: { pattern: '\\', skip: 1 }, 899 | STRING: { pattern: /['"]/, handleUntil: (quote: string) => quote }, 900 | COMMENT: { pattern: '/*', handleUntil: '*/' }, 901 | OPEN_BRACE: { pattern: '{', count: 1 }, 902 | CLOSE_BRACE: { pattern: '}', count: -1 }, 903 | } as const; 904 | 905 | let braceCount = 1; 906 | let position = startIndex; 907 | 908 | while (position < css.length) { 909 | // Handle escaped characters 910 | if (css[position] === tokens.ESCAPE.pattern) { 911 | position += tokens.ESCAPE.skip + 1; 912 | continue; 913 | } 914 | 915 | // Handle string literals 916 | if (/['"]/.test(css[position])) { 917 | const quote = css[position]; 918 | position++; 919 | 920 | while (position < css.length) { 921 | if (css[position] === tokens.ESCAPE.pattern) { 922 | position += tokens.ESCAPE.skip + 1; 923 | } else if (css[position] === quote) { 924 | position++; 925 | break; 926 | } else { 927 | position++; 928 | } 929 | } 930 | 931 | continue; 932 | } 933 | 934 | // Handle comments 935 | if (css.slice(position, position + 2) === '/*') { 936 | position += 2; 937 | 938 | while (position < css.length) { 939 | if (css.slice(position, position + 2) === '*/') { 940 | position += 2; 941 | break; 942 | } 943 | 944 | position++; 945 | } 946 | 947 | continue; 948 | } 949 | 950 | // Handle braces 951 | if (css[position] === '{') braceCount++; 952 | if (css[position] === '}') braceCount--; 953 | 954 | if (braceCount === 0) { 955 | return css.substring(startIndex, position); 956 | } 957 | 958 | position++; 959 | } 960 | 961 | // If we get here, we have an unclosed block 962 | const blockType = themeMatch[0].trim(); 963 | throw new Error(`Unclosed ${blockType} block - missing closing brace`); 964 | } 965 | 966 | return { 967 | name: 'wordpress-theme-json', 968 | enforce: 'pre', 969 | 970 | async configResolved() { 971 | if (tailwindConfig) { 972 | resolvedTailwindConfig = await loadTailwindConfig( 973 | tailwindConfig 974 | ); 975 | } 976 | }, 977 | 978 | transform(code: string, id: string) { 979 | if (id.includes(cssFile)) { 980 | cssContent = code; 981 | } 982 | 983 | return null; 984 | }, 985 | 986 | async generateBundle() { 987 | try { 988 | const baseThemeJson = JSON.parse( 989 | fs.readFileSync(path.resolve(baseThemeJsonPath), 'utf8') 990 | ) as ThemeJson; 991 | 992 | // Extract theme content if CSS is available 993 | const themeContent = cssContent 994 | ? extractThemeContent(cssContent) 995 | : null; 996 | 997 | // If no @theme block and no Tailwind config, nothing to do 998 | if (!themeContent && !resolvedTailwindConfig) return; 999 | 1000 | /** 1001 | * Helper to extract CSS variables using a regex pattern 1002 | */ 1003 | const extractVariables = ( 1004 | regex: RegExp, 1005 | content: string | null 1006 | ) => { 1007 | if (!content) return []; 1008 | 1009 | const variables: Array<[string, string]> = []; 1010 | let match: RegExpExecArray | null; 1011 | 1012 | while ((match = regex.exec(content)) !== null) { 1013 | const [, name, value] = match; 1014 | 1015 | if (name && value) variables.push([name, value.trim()]); 1016 | } 1017 | 1018 | return variables; 1019 | }; 1020 | 1021 | const patterns = { 1022 | COLOR: /--color-([^:]+):\s*([^;}]+)[;}]?/g, 1023 | FONT_FAMILY: /--font-([^:]+):\s*([^;}]+)[;}]?/g, 1024 | FONT_SIZE: /--text-([^:]+):\s*([^;}]+)[;}]?/g, 1025 | } as const; 1026 | 1027 | // Process colors from either @theme block or Tailwind config 1028 | const colorEntries = !disableTailwindColors 1029 | ? [ 1030 | // Process @theme block colors if available 1031 | ...extractVariables(patterns.COLOR, themeContent) 1032 | .filter(([name]) => !name.endsWith('-*')) 1033 | .map(([name, value]) => { 1034 | const parts = name.split('-'); 1035 | const colorName = parts[0]; 1036 | const shade = 1037 | parts.length > 1 1038 | ? parts.slice(1).join(' ') 1039 | : undefined; 1040 | const capitalizedColor = 1041 | colorName.charAt(0).toUpperCase() + 1042 | colorName.slice(1); 1043 | const displayName = shade 1044 | ? shadeLabels && shade in shadeLabels 1045 | ? `${shadeLabels[shade]} ${capitalizedColor}` 1046 | : Number.isNaN(Number(shade)) 1047 | ? `${capitalizedColor} (${shade 1048 | .split(' ') 1049 | .map( 1050 | (word) => 1051 | word 1052 | .charAt(0) 1053 | .toUpperCase() + 1054 | word.slice(1) 1055 | ) 1056 | .join(' ')})` 1057 | : `${capitalizedColor} (${shade})` 1058 | : capitalizedColor; 1059 | 1060 | return { 1061 | name: displayName, 1062 | slug: name.toLowerCase(), 1063 | color: value, 1064 | }; 1065 | }), 1066 | // Process Tailwind config colors if available 1067 | ...(resolvedTailwindConfig?.theme?.colors 1068 | ? flattenColors( 1069 | resolvedTailwindConfig.theme.colors 1070 | ).map(([name, value]) => { 1071 | const parts = name.split('-'); 1072 | const colorName = parts[0]; 1073 | const shade = 1074 | parts.length > 1 1075 | ? parts.slice(1).join(' ') 1076 | : undefined; 1077 | const capitalizedColor = 1078 | colorName.charAt(0).toUpperCase() + 1079 | colorName.slice(1); 1080 | const displayName = shade 1081 | ? shadeLabels && shade in shadeLabels 1082 | ? `${shadeLabels[shade]} ${capitalizedColor}` 1083 | : Number.isNaN(Number(shade)) 1084 | ? `${capitalizedColor} (${shade 1085 | .split(' ') 1086 | .map( 1087 | (word) => 1088 | word 1089 | .charAt(0) 1090 | .toUpperCase() + 1091 | word.slice(1) 1092 | ) 1093 | .join(' ')})` 1094 | : `${capitalizedColor} (${shade})` 1095 | : capitalizedColor; 1096 | 1097 | return { 1098 | name: displayName, 1099 | slug: name.toLowerCase(), 1100 | color: value, 1101 | }; 1102 | }) 1103 | : []), 1104 | ] 1105 | : undefined; 1106 | 1107 | const invalidFontProps = [ 1108 | 'feature-settings', 1109 | 'variation-settings', 1110 | 'family', 1111 | 'size', 1112 | 'smoothing', 1113 | 'style', 1114 | 'weight', 1115 | 'stretch', 1116 | ]; 1117 | 1118 | // Process font families from either @theme block or Tailwind config 1119 | const fontFamilyEntries = !disableTailwindFonts 1120 | ? [ 1121 | // Process @theme block font families if available 1122 | ...extractVariables( 1123 | patterns.FONT_FAMILY, 1124 | themeContent 1125 | ) 1126 | .filter( 1127 | ([name]) => 1128 | !invalidFontProps.some((prop) => 1129 | name.includes(prop) 1130 | ) 1131 | ) 1132 | .map(([name, value]) => { 1133 | const displayName = 1134 | fontLabels && name in fontLabels 1135 | ? fontLabels[name] 1136 | : name; 1137 | return { 1138 | name: displayName, 1139 | slug: name.toLowerCase(), 1140 | fontFamily: value.replace(/['"]/g, ''), 1141 | }; 1142 | }), 1143 | // Process Tailwind config font families if available 1144 | ...(resolvedTailwindConfig?.theme?.fontFamily 1145 | ? processFontFamilies( 1146 | resolvedTailwindConfig.theme.fontFamily, 1147 | fontLabels 1148 | ) 1149 | : []), 1150 | ] 1151 | : undefined; 1152 | 1153 | // Process font sizes from either @theme block or Tailwind config 1154 | const fontSizeEntries = !disableTailwindFontSizes 1155 | ? [ 1156 | // Process @theme block font sizes if available 1157 | ...extractVariables(patterns.FONT_SIZE, themeContent) 1158 | .filter( 1159 | ([name]) => 1160 | !name.includes('line-height') && 1161 | !name.includes('letter-spacing') && 1162 | !name.includes('font-weight') && 1163 | !name.includes('shadow') 1164 | ) 1165 | .map(([name, value]) => { 1166 | const displayName = 1167 | fontSizeLabels && name in fontSizeLabels 1168 | ? fontSizeLabels[name] 1169 | : name; 1170 | return { 1171 | name: displayName, 1172 | slug: name.toLowerCase(), 1173 | size: value, 1174 | }; 1175 | }), 1176 | // Process Tailwind config font sizes if available 1177 | ...(resolvedTailwindConfig?.theme?.fontSize 1178 | ? processFontSizes( 1179 | resolvedTailwindConfig.theme.fontSize, 1180 | fontSizeLabels 1181 | ) 1182 | : []), 1183 | ] 1184 | : undefined; 1185 | 1186 | // Build theme.json 1187 | const themeJson: ThemeJson = { 1188 | __processed__: 'This file was generated using Vite', 1189 | ...baseThemeJson, 1190 | settings: { 1191 | ...baseThemeJson.settings, 1192 | color: disableTailwindColors 1193 | ? baseThemeJson.settings?.color 1194 | : { 1195 | ...baseThemeJson.settings?.color, 1196 | palette: [ 1197 | ...(baseThemeJson.settings?.color 1198 | ?.palette || []), 1199 | ...(colorEntries || []), 1200 | ].filter( 1201 | (entry, index, self) => 1202 | index === 1203 | self.findIndex( 1204 | (e) => e.slug === entry.slug 1205 | ) 1206 | ), 1207 | }, 1208 | typography: { 1209 | ...baseThemeJson.settings?.typography, 1210 | defaultFontSizes: 1211 | baseThemeJson.settings?.typography 1212 | ?.defaultFontSizes ?? false, 1213 | customFontSize: 1214 | baseThemeJson.settings?.typography 1215 | ?.customFontSize ?? false, 1216 | fontFamilies: disableTailwindFonts 1217 | ? baseThemeJson.settings?.typography 1218 | ?.fontFamilies 1219 | : [ 1220 | ...(baseThemeJson.settings?.typography 1221 | ?.fontFamilies || []), 1222 | ...(fontFamilyEntries || []), 1223 | ].filter( 1224 | (entry, index, self) => 1225 | index === 1226 | self.findIndex( 1227 | (e) => e.slug === entry.slug 1228 | ) 1229 | ), 1230 | fontSizes: disableTailwindFontSizes 1231 | ? baseThemeJson.settings?.typography?.fontSizes 1232 | : sortFontSizes( 1233 | [ 1234 | ...(baseThemeJson.settings?.typography 1235 | ?.fontSizes || []), 1236 | ...(fontSizeEntries || []), 1237 | ].filter( 1238 | (entry, index, self) => 1239 | index === 1240 | self.findIndex( 1241 | (e) => e.slug === entry.slug 1242 | ) 1243 | ) 1244 | ), 1245 | }, 1246 | }, 1247 | }; 1248 | 1249 | delete themeJson.__preprocessed__; 1250 | 1251 | this.emitFile({ 1252 | type: 'asset', 1253 | fileName: outputPath, 1254 | source: JSON.stringify(themeJson, null, 2), 1255 | }); 1256 | } catch (error) { 1257 | throw error instanceof Error ? error : new Error(String(error)); 1258 | } 1259 | }, 1260 | }; 1261 | } 1262 | --------------------------------------------------------------------------------