├── .editorconfig ├── .eslintrc.cjs ├── .gitignore ├── LICENSE.md ├── README.md ├── package.json ├── src ├── index.ts └── types │ └── wordpress-dependency-extraction-webpack-plugin.d.ts ├── tests └── index.test.ts └── tsconfig.json /.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 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | }, 6 | parser: '@typescript-eslint/parser', 7 | plugins: ['@typescript-eslint'], 8 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'], 9 | rules: { 10 | 'no-trailing-spaces': 'error', 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /npm-debug.log 4 | /package-lock.json 5 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | ### Editor HMR Support 42 | 43 | The plugin automatically enables CSS Hot Module Replacement (HMR) for the WordPress editor. 44 | 45 | > [!NOTE] 46 | > JavaScript HMR is not supported at this time. JS changes will trigger a full page reload. 47 | 48 | You can customize the HMR behavior in your Vite config: 49 | 50 | ```js 51 | // vite.config.js 52 | import { defineConfig } from 'vite'; 53 | import { wordpressPlugin } from '@roots/vite-plugin'; 54 | 55 | export default defineConfig({ 56 | plugins: [ 57 | wordpressPlugin({ 58 | hmr: { 59 | // Enable/disable HMR (default: true) 60 | enabled: true, 61 | 62 | // Pattern to match editor entry points (default: /editor/) 63 | editorPattern: /editor/, 64 | 65 | // Name of the editor iframe element (default: 'editor-canvas') 66 | iframeName: 'editor-canvas', 67 | }, 68 | }), 69 | ], 70 | }); 71 | ``` 72 | 73 | ### Theme.json Generation 74 | 75 | When using this plugin for theme development, you have the option of generating a `theme.json` file from your Tailwind CSS configuration. 76 | 77 | To enable this feature, add the `wordpressThemeJson` plugin to your Vite config: 78 | 79 | ```js 80 | // vite.config.js 81 | import { defineConfig } from 'vite'; 82 | import { wordpressThemeJson } from '@roots/vite-plugin'; 83 | 84 | export default defineConfig({ 85 | plugins: [ 86 | wordpressThemeJson({ 87 | // Optional: Configure shade labels 88 | shadeLabels: { 89 | 100: 'Lightest', 90 | 900: 'Darkest', 91 | }, 92 | 93 | // Optional: Configure font family labels 94 | fontLabels: { 95 | sans: 'Sans Serif', 96 | mono: 'Monospace', 97 | inter: 'Inter Font', 98 | }, 99 | 100 | // Optional: Configure font size labels 101 | fontSizeLabels: { 102 | sm: 'Small', 103 | base: 'Default', 104 | lg: 'Large', 105 | }, 106 | 107 | // Optional: Disable specific transformations 108 | disableTailwindColors: false, 109 | disableTailwindFonts: false, 110 | disableTailwindFontSizes: false, 111 | 112 | // Optional: Configure paths 113 | baseThemeJsonPath: './theme.json', 114 | outputPath: 'assets/theme.json', 115 | cssFile: 'app.css', 116 | 117 | // Optional: Legacy Tailwind v3 config path 118 | tailwindConfig: './tailwind.config.js', 119 | }), 120 | ], 121 | }); 122 | ``` 123 | 124 | 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. 125 | 126 | To generate the full default Tailwind color palette into your `theme.json`, you can use the `static` theme option when importing Tailwind: 127 | 128 | ```css 129 | @import 'tailwindcss' theme(static); 130 | ``` 131 | 132 | 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: 133 | 134 | ```css 135 | @theme static { 136 | --color-white: #fff; 137 | --color-purple: #3f3cbb; 138 | --color-midnight: #121063; 139 | --color-tahiti: #3ab7bf; 140 | --color-bermuda: #78dcca; 141 | } 142 | ``` 143 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@roots/vite-plugin", 3 | "version": "1.0.6", 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.18.0" 53 | }, 54 | "devDependencies": { 55 | "@types/node": "^18.11.9", 56 | "@typescript-eslint/eslint-plugin": "^5.21.0", 57 | "@typescript-eslint/parser": "^5.21.0", 58 | "esbuild": "0.16.10", 59 | "eslint": "^8.14.0", 60 | "tailwindcss": "^4.0.7", 61 | "typescript": "^4.6.4", 62 | "vite": "^6.0.0", 63 | "vitest": "^0.34.4" 64 | }, 65 | "peerDependencies": { 66 | "vite": "^5.0.0 || ^6.0.0" 67 | }, 68 | "engines": { 69 | "node": "^18.0.0 || ^20.0.0 || >=22.0.0" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | defaultRequestToExternal, 3 | defaultRequestToHandle, 4 | } from '@wordpress/dependency-extraction-webpack-plugin/lib/util.js'; 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 | * Configuration for the WordPress plugin 196 | */ 197 | interface WordPressPluginConfig { 198 | /** 199 | * File extensions to process for WordPress imports. 200 | * 201 | * @default ['.js', '.jsx', '.ts', '.tsx'] 202 | */ 203 | extensions?: SupportedExtension[]; 204 | 205 | /** 206 | * HMR configuration for the WordPress editor 207 | */ 208 | hmr?: { 209 | /** 210 | * Pattern to match editor entry points. 211 | * Can be a string (exact match) or RegExp. 212 | * 213 | * @default /editor/ 214 | */ 215 | editorPattern?: string | RegExp; 216 | 217 | /** 218 | * Pattern to match editor CSS files. 219 | * Can be a string (exact match) or RegExp. 220 | * 221 | * @default 'editor.css' 222 | */ 223 | cssPattern?: string | RegExp; 224 | 225 | /** 226 | * Whether to enable HMR for the WordPress editor. 227 | * 228 | * @default true 229 | */ 230 | enabled?: boolean; 231 | 232 | /** 233 | * Name of the editor iframe element. 234 | * 235 | * @default 'editor-canvas' 236 | */ 237 | iframeName?: string; 238 | }; 239 | } 240 | 241 | /** 242 | * Creates a Vite plugin that handles WordPress dependencies. 243 | * This plugin transforms @wordpress/* imports into global wp.* references, 244 | * generates a dependency manifest for WordPress enqueuing, and handles 245 | * external dependencies. 246 | * 247 | * @example 248 | * ```ts 249 | * // vite.config.ts 250 | * import { wordpressPlugin } from '@wordpress/vite-plugin' 251 | * 252 | * export default defineConfig({ 253 | * plugins: [ 254 | * wordpressPlugin() 255 | * ] 256 | * }) 257 | * ``` 258 | * 259 | * The plugin will: 260 | * 1. Transform imports like `import { useState } from '@wordpress/element'` 261 | * into `const useState = wp.element.useState` 262 | * 2. Track WordPress script dependencies (e.g. 'wp-element') 263 | * 3. Generate an editor.deps.json file listing all dependencies 264 | * 4. Mark all @wordpress/* packages as external dependencies 265 | * 5. Prevent WordPress core libraries from being bundled 266 | * 267 | * @returns A Vite plugin configured to handle WordPress dependencies 268 | */ 269 | export function wordpressPlugin( 270 | config: WordPressPluginConfig = {} 271 | ): VitePlugin { 272 | const extensions = config.extensions ?? SUPPORTED_EXTENSIONS; 273 | const dependencies = new Set(); 274 | 275 | // HMR configuration with defaults 276 | const hmrConfig = { 277 | enabled: true, 278 | editorPattern: /editor/, 279 | iframeName: 'editor-canvas', 280 | ...config.hmr, 281 | }; 282 | 283 | // HMR code to inject 284 | const hmrCode = ` 285 | if (import.meta.hot) { 286 | import.meta.hot.on('vite:beforeUpdate', ({ updates }) => { 287 | const editorIframe = document.querySelector('iframe[name="${hmrConfig.iframeName}"]'); 288 | const editor = editorIframe?.contentDocument; 289 | 290 | if (!editor) { 291 | return; 292 | } 293 | 294 | updates.forEach(({ path, type }) => { 295 | if (type !== 'css-update') { 296 | return; 297 | } 298 | 299 | const key = path.split('?')[0]; 300 | 301 | editor.querySelectorAll('link[rel="stylesheet"]').forEach(link => { 302 | if (!link.href.includes(key)) { 303 | return; 304 | } 305 | 306 | const updated = link.href.split('?')[0] + '?direct&t=' + Date.now(); 307 | 308 | link.href = updated; 309 | }); 310 | 311 | editor.querySelectorAll('style').forEach(style => { 312 | if (!style.textContent.includes(key)) { 313 | return; 314 | } 315 | 316 | const importRegex = new RegExp(\`(@import\\\\s*(?:url\\\\(['"]?|['"]))(.*?\${key}[^'"\\\\)]*?)(?:\\\\?[^'"\\\\)]*)?(['"]?\\\\))\`, 'g'); 317 | 318 | style.textContent = style.textContent.replace(importRegex, (_, prefix, importPath, suffix) => { 319 | const updated = importPath.split('?')[0]; 320 | 321 | return prefix + updated + '?direct&t=' + Date.now() + suffix; 322 | }); 323 | }); 324 | }); 325 | }); 326 | }`; 327 | 328 | /** 329 | * Extracts named imports from a WordPress import statement. 330 | * Handles both single and multiple imports with aliases. 331 | */ 332 | function extractNamedImports(imports: string): string[] { 333 | return ( 334 | imports 335 | .match(/{([^}]+)}/) 336 | ?.at(1) 337 | ?.split(',') 338 | .map((s) => s.trim()) 339 | .filter(Boolean) ?? [] 340 | ); 341 | } 342 | 343 | /** 344 | * Transforms WordPress named imports into global variable assignments. 345 | * Handles both direct imports and aliased imports. 346 | */ 347 | function handleNamedReplacement( 348 | namedImports: string[], 349 | external: string[] 350 | ): string { 351 | const externalPath = external.join('.'); 352 | 353 | return namedImports 354 | .map((importStr) => { 355 | const parts = importStr.split(' as ').map((s) => s.trim()); 356 | const name = parts[0]; 357 | const alias = parts[1] ?? name; 358 | 359 | return `const ${alias} = ${externalPath}.${name};`; 360 | }) 361 | .join('\n'); 362 | } 363 | 364 | return { 365 | name: 'wordpress-plugin', 366 | enforce: 'pre', 367 | 368 | options(opts: InputOptions) { 369 | return { 370 | ...opts, 371 | external: (id: string): boolean => 372 | typeof id === 'string' && id.startsWith('@wordpress/'), 373 | }; 374 | }, 375 | 376 | resolveId(id: string) { 377 | if (!id?.startsWith('@wordpress/')) return null; 378 | 379 | const [external, handle] = [ 380 | defaultRequestToExternal(id), 381 | defaultRequestToHandle(id), 382 | ]; 383 | 384 | if (!external || !handle) return null; 385 | 386 | dependencies.add(handle); 387 | return { id, external: true }; 388 | }, 389 | 390 | transform(code: string, id: string) { 391 | const cleanId = id.split('?')[0]; 392 | if (!extensions.some((ext) => cleanId.endsWith(ext))) return null; 393 | 394 | let transformedCode = code; 395 | 396 | // Handle all WordPress imports 397 | const importRegex = 398 | /^[\s\n]*import[\s\n]+(?:([^;'"]+?)[\s\n]+from[\s\n]+)?['"]@wordpress\/([^'"]+)['"][\s\n]*;?/gm; 399 | let match; 400 | 401 | while ((match = importRegex.exec(code)) !== null) { 402 | const [fullMatch, imports, pkg] = match; 403 | 404 | const external = defaultRequestToExternal(`@wordpress/${pkg}`); 405 | const handle = defaultRequestToHandle(`@wordpress/${pkg}`); 406 | 407 | if (!external || !handle) continue; 408 | 409 | // Add dependency 410 | dependencies.add(handle); 411 | 412 | // For side-effect only imports, just remove them 413 | if (!imports) { 414 | transformedCode = transformedCode.replace(fullMatch, ''); 415 | continue; 416 | } 417 | 418 | // Handle different import types 419 | let replacement; 420 | 421 | if (imports.includes('{')) { 422 | // Named imports 423 | replacement = handleNamedReplacement( 424 | extractNamedImports(imports), 425 | external 426 | ); 427 | } else if (imports.includes('*')) { 428 | // Namespace imports 429 | const namespaceAlias = 430 | imports.match(/\*\s+as\s+(\w+)/)?.[1]; 431 | 432 | if (namespaceAlias) { 433 | replacement = `const ${namespaceAlias} = ${external.join( 434 | '.' 435 | )};`; 436 | } 437 | } else { 438 | // Default imports 439 | const defaultImport = imports.match(/^(\w+)/)?.[1]; 440 | 441 | if (defaultImport) { 442 | replacement = `const ${defaultImport} = ${external.join( 443 | '.' 444 | )};`; 445 | } 446 | } 447 | 448 | if (replacement) { 449 | transformedCode = transformedCode.replace( 450 | fullMatch, 451 | replacement 452 | ); 453 | } 454 | } 455 | 456 | // Inject HMR code if this is the editor entry point 457 | if ( 458 | hmrConfig.enabled && 459 | !transformedCode.includes('vite:beforeUpdate') && 460 | ((typeof hmrConfig.editorPattern === 'string' && 461 | id.includes(hmrConfig.editorPattern)) || 462 | (hmrConfig.editorPattern instanceof RegExp && 463 | hmrConfig.editorPattern.test(id))) 464 | ) { 465 | transformedCode = `${transformedCode}\n${hmrCode}`; 466 | } 467 | 468 | return { 469 | code: transformedCode, 470 | map: null, 471 | }; 472 | }, 473 | 474 | generateBundle() { 475 | this.emitFile({ 476 | type: 'asset', 477 | name: 'editor.deps.json', 478 | originalFileName: 'editor.deps.json', 479 | source: JSON.stringify([...dependencies], null, 2), 480 | }); 481 | }, 482 | }; 483 | } 484 | 485 | /** 486 | * Configuration for the WordPress theme.json plugin 487 | */ 488 | interface ThemeJsonConfig extends ThemeJsonPluginOptions { 489 | /** 490 | * The Tailwind configuration object containing design tokens. 491 | * This is used as a source of truth for generating theme.json settings. 492 | * If not provided, only CSS variables from the @theme block will be processed. 493 | */ 494 | tailwindConfig?: string; 495 | 496 | /** 497 | * The path to the base theme.json file. 498 | * 499 | * @default './theme.json' 500 | */ 501 | baseThemeJsonPath?: string; 502 | 503 | /** 504 | * The path where the generated theme.json will be written. 505 | * 506 | * @default 'assets/theme.json' 507 | */ 508 | outputPath?: string; 509 | 510 | /** 511 | * The CSS file to process for theme variables. 512 | * 513 | * @default 'app.css' 514 | */ 515 | cssFile?: string; 516 | } 517 | 518 | interface TailwindTheme { 519 | colors?: Record; 520 | fontFamily?: Record; 521 | fontSize?: Record]>; 522 | extend?: { 523 | colors?: Record; 524 | fontFamily?: Record; 525 | fontSize?: Record]>; 526 | }; 527 | } 528 | 529 | interface TailwindConfig { 530 | theme?: TailwindTheme; 531 | } 532 | 533 | /** 534 | * Merges base theme with extended theme properties 535 | */ 536 | function mergeThemeWithExtend(theme: TailwindTheme): TailwindTheme { 537 | if (!theme.extend) return theme; 538 | 539 | return { 540 | ...theme, 541 | colors: { 542 | ...theme.colors, 543 | ...theme.extend.colors, 544 | }, 545 | fontFamily: { 546 | ...theme.fontFamily, 547 | ...theme.extend.fontFamily, 548 | }, 549 | fontSize: { 550 | ...theme.fontSize, 551 | ...theme.extend.fontSize, 552 | }, 553 | }; 554 | } 555 | 556 | /** 557 | * Flattens a nested color object into an array of [name, value] pairs 558 | */ 559 | function flattenColors( 560 | colors: Record 561 | ): Array<[string, string]> { 562 | const flattened: Array<[string, string]> = []; 563 | 564 | for (const [name, value] of Object.entries(colors)) { 565 | if (typeof value === 'string') { 566 | flattened.push([name, value]); 567 | } else if (typeof value === 'object' && value !== null) { 568 | // Handle nested color objects (e.g. { orange: { 500: '#...' } }) 569 | for (const [shade, shadeValue] of Object.entries(value)) { 570 | if (typeof shadeValue === 'string') { 571 | flattened.push([`${name}-${shade}`, shadeValue]); 572 | } 573 | } 574 | } 575 | } 576 | 577 | return flattened; 578 | } 579 | 580 | /** 581 | * Processes font families from Tailwind config into theme.json format 582 | */ 583 | function processFontFamilies( 584 | fonts: Record, 585 | fontLabels?: Record 586 | ): Array<{ name: string; slug: string; fontFamily: string }> { 587 | return Object.entries(fonts).map(([name, value]) => { 588 | const fontFamily = Array.isArray(value) ? value.join(', ') : value; 589 | const displayName = 590 | fontLabels && name in fontLabels ? fontLabels[name] : name; 591 | 592 | return { 593 | name: displayName, 594 | slug: name.toLowerCase(), 595 | fontFamily, 596 | }; 597 | }); 598 | } 599 | 600 | /** 601 | * Converts a CSS size value to a numeric value in rem units for comparison 602 | */ 603 | function convertToRem(size: string): number { 604 | // Remove any spaces and convert to lowercase 605 | size = size.trim().toLowerCase(); 606 | 607 | // Convert px to rem (assuming 16px = 1rem) 608 | if (size.endsWith('px')) { 609 | return parseFloat(size) / 16; 610 | } 611 | 612 | // Convert em to rem (they're equivalent) 613 | if (size.endsWith('em')) { 614 | return parseFloat(size); 615 | } 616 | 617 | // Already in rem 618 | if (size.endsWith('rem')) { 619 | return parseFloat(size); 620 | } 621 | 622 | // For other units or invalid values, return 0 623 | return 0; 624 | } 625 | 626 | /** 627 | * Sorts font sizes from smallest to largest 628 | */ 629 | function sortFontSizes(fontSizes: FontSize[]): FontSize[] { 630 | return [...fontSizes].sort((a, b) => { 631 | const sizeA = convertToRem(a.size); 632 | const sizeB = convertToRem(b.size); 633 | 634 | return sizeA - sizeB; 635 | }); 636 | } 637 | 638 | /** 639 | * Processes font sizes from Tailwind config into theme.json format 640 | */ 641 | function processFontSizes( 642 | sizes: Record]>, 643 | fontSizeLabels?: Record 644 | ): Array<{ name: string; slug: string; size: string }> { 645 | return Object.entries(sizes).map(([name, value]) => { 646 | // Handle both simple sizes and sizes with line height config 647 | const size = Array.isArray(value) ? value[0] : value; 648 | const displayName = 649 | fontSizeLabels && name in fontSizeLabels 650 | ? fontSizeLabels[name] 651 | : name; 652 | 653 | return { 654 | name: displayName, 655 | slug: name.toLowerCase(), 656 | size, 657 | }; 658 | }); 659 | } 660 | 661 | /** 662 | * Loads and resolves the Tailwind configuration from the provided path 663 | */ 664 | async function loadTailwindConfig(configPath: string): Promise { 665 | try { 666 | const absolutePath = path.resolve(configPath); 667 | const config = await import(absolutePath); 668 | const resolvedConfig = config.default || config; 669 | 670 | // Merge extended theme properties if they exist 671 | if (resolvedConfig.theme?.extend) { 672 | resolvedConfig.theme = mergeThemeWithExtend(resolvedConfig.theme); 673 | } 674 | 675 | return resolvedConfig; 676 | } catch (error) { 677 | throw new Error( 678 | `Failed to load Tailwind config from ${configPath}: ${ 679 | error instanceof Error ? error.message : String(error) 680 | }` 681 | ); 682 | } 683 | } 684 | 685 | /** 686 | * Creates a Vite plugin that generates a WordPress theme.json file from Tailwind CSS variables. 687 | * This allows theme.json settings to stay in sync with your Tailwind design tokens. 688 | * 689 | * @example 690 | * ```ts 691 | * // vite.config.ts 692 | * import { wordpressThemeJson } from '@wordpress/vite-plugin' 693 | * import tailwindConfig from './tailwind.config.js' 694 | * 695 | * export default defineConfig({ 696 | * plugins: [ 697 | * wordpressThemeJson({ 698 | * disableTailwindColors: false, 699 | * disableTailwindFonts: false, 700 | * disableTailwindFontSizes: false, 701 | * }), 702 | * ] 703 | * }) 704 | * ``` 705 | * 706 | * CSS variables in an @theme block will be transformed into theme.json: 707 | * ```css 708 | * @theme { 709 | * --color-primary: #000000; -> { name: "primary", color: "#000000" } 710 | * --color-red-500: #ef4444; -> { name: "red-500", color: "#ef4444" } 711 | * --font-inter: "Inter"; -> { name: "inter", fontFamily: "Inter" } 712 | * --text-lg: 1.125rem; -> { name: "lg", size: "1.125rem" } 713 | * } 714 | * ``` 715 | * 716 | * @param options - Configuration options for the theme.json generator 717 | * @returns A Vite plugin configured to generate theme.json from CSS variables 718 | */ 719 | export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { 720 | const { 721 | tailwindConfig, 722 | disableTailwindColors = false, 723 | disableTailwindFonts = false, 724 | disableTailwindFontSizes = false, 725 | baseThemeJsonPath = './theme.json', 726 | outputPath = 'assets/theme.json', 727 | cssFile = 'app.css', 728 | shadeLabels, 729 | fontLabels, 730 | fontSizeLabels, 731 | } = config; 732 | 733 | let cssContent: string | null = null; 734 | let resolvedTailwindConfig: TailwindConfig | undefined; 735 | 736 | if (tailwindConfig !== undefined && typeof tailwindConfig !== 'string') { 737 | throw new Error('tailwindConfig must be a string path or undefined'); 738 | } 739 | 740 | /** 741 | * Safely extracts CSS content between matched braces while handling: 742 | * - Nested braces within the block 743 | * - String literals (both single and double quotes) 744 | * - CSS comments 745 | * - Escaped characters 746 | */ 747 | function extractThemeContent(css: string): string | null { 748 | const themeMatch = css.match(/@(?:layer\s+)?theme\s*{/s); 749 | 750 | if (!themeMatch?.index) return null; 751 | 752 | const startIndex = themeMatch.index + themeMatch[0].length; 753 | 754 | // Define token types we need to handle 755 | const tokens = { 756 | ESCAPE: { pattern: '\\', skip: 1 }, 757 | STRING: { pattern: /['"]/, handleUntil: (quote: string) => quote }, 758 | COMMENT: { pattern: '/*', handleUntil: '*/' }, 759 | OPEN_BRACE: { pattern: '{', count: 1 }, 760 | CLOSE_BRACE: { pattern: '}', count: -1 }, 761 | } as const; 762 | 763 | let braceCount = 1; 764 | let position = startIndex; 765 | 766 | while (position < css.length) { 767 | // Handle escaped characters 768 | if (css[position] === tokens.ESCAPE.pattern) { 769 | position += tokens.ESCAPE.skip + 1; 770 | continue; 771 | } 772 | 773 | // Handle string literals 774 | if (/['"]/.test(css[position])) { 775 | const quote = css[position]; 776 | position++; 777 | 778 | while (position < css.length) { 779 | if (css[position] === tokens.ESCAPE.pattern) { 780 | position += tokens.ESCAPE.skip + 1; 781 | } else if (css[position] === quote) { 782 | position++; 783 | break; 784 | } else { 785 | position++; 786 | } 787 | } 788 | 789 | continue; 790 | } 791 | 792 | // Handle comments 793 | if (css.slice(position, position + 2) === '/*') { 794 | position += 2; 795 | 796 | while (position < css.length) { 797 | if (css.slice(position, position + 2) === '*/') { 798 | position += 2; 799 | break; 800 | } 801 | 802 | position++; 803 | } 804 | 805 | continue; 806 | } 807 | 808 | // Handle braces 809 | if (css[position] === '{') braceCount++; 810 | if (css[position] === '}') braceCount--; 811 | 812 | if (braceCount === 0) { 813 | return css.substring(startIndex, position); 814 | } 815 | 816 | position++; 817 | } 818 | 819 | // If we get here, we have an unclosed block 820 | const blockType = themeMatch[0].trim(); 821 | throw new Error(`Unclosed ${blockType} block - missing closing brace`); 822 | } 823 | 824 | return { 825 | name: 'wordpress-theme-json', 826 | enforce: 'pre', 827 | 828 | async configResolved() { 829 | if (tailwindConfig) { 830 | resolvedTailwindConfig = await loadTailwindConfig( 831 | tailwindConfig 832 | ); 833 | } 834 | }, 835 | 836 | transform(code: string, id: string) { 837 | if (id.includes(cssFile)) { 838 | cssContent = code; 839 | } 840 | 841 | return null; 842 | }, 843 | 844 | async generateBundle() { 845 | try { 846 | const baseThemeJson = JSON.parse( 847 | fs.readFileSync(path.resolve(baseThemeJsonPath), 'utf8') 848 | ) as ThemeJson; 849 | 850 | // Extract theme content if CSS is available 851 | const themeContent = cssContent 852 | ? extractThemeContent(cssContent) 853 | : null; 854 | 855 | // If no @theme block and no Tailwind config, nothing to do 856 | if (!themeContent && !resolvedTailwindConfig) return; 857 | 858 | /** 859 | * Helper to extract CSS variables using a regex pattern 860 | */ 861 | const extractVariables = ( 862 | regex: RegExp, 863 | content: string | null 864 | ) => { 865 | if (!content) return []; 866 | 867 | const variables: Array<[string, string]> = []; 868 | let match: RegExpExecArray | null; 869 | 870 | while ((match = regex.exec(content)) !== null) { 871 | const [, name, value] = match; 872 | 873 | if (name && value) variables.push([name, value.trim()]); 874 | } 875 | 876 | return variables; 877 | }; 878 | 879 | const patterns = { 880 | COLOR: /--color-([^:]+):\s*([^;}]+)[;}]?/g, 881 | FONT_FAMILY: /--font-([^:]+):\s*([^;}]+)[;}]?/g, 882 | FONT_SIZE: /--text-([^:]+):\s*([^;}]+)[;}]?/g, 883 | } as const; 884 | 885 | // Process colors from either @theme block or Tailwind config 886 | const colorEntries = !disableTailwindColors 887 | ? [ 888 | // Process @theme block colors if available 889 | ...extractVariables(patterns.COLOR, themeContent) 890 | .filter(([name]) => !name.endsWith('-*')) 891 | .map(([name, value]) => { 892 | const parts = name.split('-'); 893 | const colorName = parts[0]; 894 | const shade = 895 | parts.length > 1 896 | ? parts.slice(1).join(' ') 897 | : undefined; 898 | const capitalizedColor = 899 | colorName.charAt(0).toUpperCase() + 900 | colorName.slice(1); 901 | const displayName = shade 902 | ? shadeLabels && shade in shadeLabels 903 | ? `${shadeLabels[shade]} ${capitalizedColor}` 904 | : Number.isNaN(Number(shade)) 905 | ? `${capitalizedColor} (${shade 906 | .split(' ') 907 | .map( 908 | (word) => 909 | word 910 | .charAt(0) 911 | .toUpperCase() + 912 | word.slice(1) 913 | ) 914 | .join(' ')})` 915 | : `${capitalizedColor} (${shade})` 916 | : capitalizedColor; 917 | 918 | return { 919 | name: displayName, 920 | slug: name.toLowerCase(), 921 | color: value, 922 | }; 923 | }), 924 | // Process Tailwind config colors if available 925 | ...(resolvedTailwindConfig?.theme?.colors 926 | ? flattenColors( 927 | resolvedTailwindConfig.theme.colors 928 | ).map(([name, value]) => { 929 | const parts = name.split('-'); 930 | const colorName = parts[0]; 931 | const shade = 932 | parts.length > 1 933 | ? parts.slice(1).join(' ') 934 | : undefined; 935 | const capitalizedColor = 936 | colorName.charAt(0).toUpperCase() + 937 | colorName.slice(1); 938 | const displayName = shade 939 | ? shadeLabels && shade in shadeLabels 940 | ? `${shadeLabels[shade]} ${capitalizedColor}` 941 | : Number.isNaN(Number(shade)) 942 | ? `${capitalizedColor} (${shade 943 | .split(' ') 944 | .map( 945 | (word) => 946 | word 947 | .charAt(0) 948 | .toUpperCase() + 949 | word.slice(1) 950 | ) 951 | .join(' ')})` 952 | : `${capitalizedColor} (${shade})` 953 | : capitalizedColor; 954 | 955 | return { 956 | name: displayName, 957 | slug: name.toLowerCase(), 958 | color: value, 959 | }; 960 | }) 961 | : []), 962 | ] 963 | : undefined; 964 | 965 | const invalidFontProps = [ 966 | 'feature-settings', 967 | 'variation-settings', 968 | 'family', 969 | 'size', 970 | 'smoothing', 971 | 'style', 972 | 'weight', 973 | 'stretch', 974 | ]; 975 | 976 | // Process font families from either @theme block or Tailwind config 977 | const fontFamilyEntries = !disableTailwindFonts 978 | ? [ 979 | // Process @theme block font families if available 980 | ...extractVariables( 981 | patterns.FONT_FAMILY, 982 | themeContent 983 | ) 984 | .filter( 985 | ([name]) => 986 | !invalidFontProps.some((prop) => 987 | name.includes(prop) 988 | ) 989 | ) 990 | .map(([name, value]) => { 991 | const displayName = 992 | fontLabels && name in fontLabels 993 | ? fontLabels[name] 994 | : name; 995 | return { 996 | name: displayName, 997 | slug: name.toLowerCase(), 998 | fontFamily: value.replace(/['"]/g, ''), 999 | }; 1000 | }), 1001 | // Process Tailwind config font families if available 1002 | ...(resolvedTailwindConfig?.theme?.fontFamily 1003 | ? processFontFamilies( 1004 | resolvedTailwindConfig.theme.fontFamily, 1005 | fontLabels 1006 | ) 1007 | : []), 1008 | ] 1009 | : undefined; 1010 | 1011 | // Process font sizes from either @theme block or Tailwind config 1012 | const fontSizeEntries = !disableTailwindFontSizes 1013 | ? [ 1014 | // Process @theme block font sizes if available 1015 | ...extractVariables(patterns.FONT_SIZE, themeContent) 1016 | .filter( 1017 | ([name]) => 1018 | !name.includes('line-height') && 1019 | !name.includes('shadow') 1020 | ) 1021 | .map(([name, value]) => { 1022 | const displayName = 1023 | fontSizeLabels && name in fontSizeLabels 1024 | ? fontSizeLabels[name] 1025 | : name; 1026 | return { 1027 | name: displayName, 1028 | slug: name.toLowerCase(), 1029 | size: value, 1030 | }; 1031 | }), 1032 | // Process Tailwind config font sizes if available 1033 | ...(resolvedTailwindConfig?.theme?.fontSize 1034 | ? processFontSizes( 1035 | resolvedTailwindConfig.theme.fontSize, 1036 | fontSizeLabels 1037 | ) 1038 | : []), 1039 | ] 1040 | : undefined; 1041 | 1042 | // Build theme.json 1043 | const themeJson: ThemeJson = { 1044 | __processed__: 'This file was generated using Vite', 1045 | ...baseThemeJson, 1046 | settings: { 1047 | ...baseThemeJson.settings, 1048 | color: disableTailwindColors 1049 | ? baseThemeJson.settings?.color 1050 | : { 1051 | ...baseThemeJson.settings?.color, 1052 | palette: [ 1053 | ...(baseThemeJson.settings?.color 1054 | ?.palette || []), 1055 | ...(colorEntries || []), 1056 | ].filter( 1057 | (entry, index, self) => 1058 | index === 1059 | self.findIndex( 1060 | (e) => e.slug === entry.slug 1061 | ) 1062 | ), 1063 | }, 1064 | typography: { 1065 | ...baseThemeJson.settings?.typography, 1066 | defaultFontSizes: 1067 | baseThemeJson.settings?.typography 1068 | ?.defaultFontSizes ?? false, 1069 | customFontSize: 1070 | baseThemeJson.settings?.typography 1071 | ?.customFontSize ?? false, 1072 | fontFamilies: disableTailwindFonts 1073 | ? baseThemeJson.settings?.typography 1074 | ?.fontFamilies 1075 | : [ 1076 | ...(baseThemeJson.settings?.typography 1077 | ?.fontFamilies || []), 1078 | ...(fontFamilyEntries || []), 1079 | ].filter( 1080 | (entry, index, self) => 1081 | index === 1082 | self.findIndex( 1083 | (e) => e.slug === entry.slug 1084 | ) 1085 | ), 1086 | fontSizes: disableTailwindFontSizes 1087 | ? baseThemeJson.settings?.typography?.fontSizes 1088 | : sortFontSizes( 1089 | [ 1090 | ...(baseThemeJson.settings?.typography 1091 | ?.fontSizes || []), 1092 | ...(fontSizeEntries || []), 1093 | ].filter( 1094 | (entry, index, self) => 1095 | index === 1096 | self.findIndex( 1097 | (e) => e.slug === entry.slug 1098 | ) 1099 | ) 1100 | ), 1101 | }, 1102 | }, 1103 | }; 1104 | 1105 | delete themeJson.__preprocessed__; 1106 | 1107 | this.emitFile({ 1108 | type: 'asset', 1109 | fileName: outputPath, 1110 | source: JSON.stringify(themeJson, null, 2), 1111 | }); 1112 | } catch (error) { 1113 | throw error instanceof Error ? error : new Error(String(error)); 1114 | } 1115 | }, 1116 | }; 1117 | } 1118 | -------------------------------------------------------------------------------- /src/types/wordpress-dependency-extraction-webpack-plugin.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@wordpress/dependency-extraction-webpack-plugin/lib/util.js' { 2 | export function defaultRequestToExternal(request: string): string[] | null; 3 | export function defaultRequestToHandle(request: string): string | null; 4 | } 5 | -------------------------------------------------------------------------------- /tests/index.test.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable @typescript-eslint/no-explicit-any */ 2 | import { describe, expect, it, afterEach, beforeEach, vi } from 'vitest'; 3 | import { wordpressPlugin, wordpressThemeJson } from '../src/index.js'; 4 | import type { Plugin, TransformResult } from 'vite'; 5 | import type { InputOptions } from 'rollup'; 6 | import fs from 'fs'; 7 | import path from 'path'; 8 | 9 | vi.mock('fs'); 10 | vi.mock('path'); 11 | 12 | describe('wordpressPlugin', () => { 13 | let plugin: Plugin; 14 | 15 | beforeEach(() => { 16 | plugin = wordpressPlugin(); 17 | }); 18 | 19 | describe('import transformation', () => { 20 | it('should transform named imports', () => { 21 | const code = `import { useState, useEffect } from '@wordpress/element';`; 22 | const result = (plugin.transform as any)?.( 23 | code, 24 | 'test.tsx' 25 | ) as TransformResult; 26 | 27 | expect(result).toBeDefined(); 28 | expect(result?.code).toContain( 29 | 'const useState = wp.element.useState;' 30 | ); 31 | expect(result?.code).toContain( 32 | 'const useEffect = wp.element.useEffect;' 33 | ); 34 | }); 35 | 36 | it('should transform aliased named imports', () => { 37 | const code = `import { useState as useStateWP } from '@wordpress/element';`; 38 | const result = (plugin.transform as any)?.( 39 | code, 40 | 'test.tsx' 41 | ) as TransformResult; 42 | 43 | expect(result).toBeDefined(); 44 | expect(result?.code).toContain( 45 | 'const useStateWP = wp.element.useState;' 46 | ); 47 | }); 48 | 49 | it('should transform namespace imports', () => { 50 | const code = `import * as element from '@wordpress/element';`; 51 | const result = (plugin.transform as any)?.( 52 | code, 53 | 'test.tsx' 54 | ) as TransformResult; 55 | 56 | expect(result).toBeDefined(); 57 | expect(result?.code).toContain('const element = wp.element;'); 58 | }); 59 | 60 | it('should transform default imports', () => { 61 | const code = `import apiFetch from '@wordpress/api-fetch';`; 62 | const result = (plugin.transform as any)?.( 63 | code, 64 | 'test.tsx' 65 | ) as TransformResult; 66 | 67 | expect(result).toBeDefined(); 68 | expect(result?.code).toContain('const apiFetch = wp.apiFetch;'); 69 | }); 70 | 71 | it('should transform side-effect imports', () => { 72 | const code = `import '@wordpress/block-editor';`; 73 | const result = (plugin.transform as any)?.( 74 | code, 75 | 'test.tsx' 76 | ) as TransformResult; 77 | 78 | expect(result).toBeDefined(); 79 | expect(result?.code).toBe(''); 80 | }); 81 | 82 | it('should handle multiple imports', () => { 83 | const code = ` 84 | import { useState } from '@wordpress/element'; 85 | import apiFetch from '@wordpress/api-fetch'; 86 | import * as blocks from '@wordpress/blocks'; 87 | `.trim(); 88 | const result = (plugin.transform as any)?.( 89 | code, 90 | 'test.tsx' 91 | ) as TransformResult; 92 | 93 | expect(result).toBeDefined(); 94 | expect(result?.code).toContain( 95 | 'const useState = wp.element.useState;' 96 | ); 97 | expect(result?.code).toContain('const apiFetch = wp.apiFetch;'); 98 | expect(result?.code).toContain('const blocks = wp.blocks;'); 99 | }); 100 | 101 | it('should only transform files with supported extensions', () => { 102 | const code = `import { useState } from '@wordpress/element';`; 103 | const result = (plugin.transform as any)?.(code, 'test.md'); 104 | 105 | expect(result).toBeNull(); 106 | }); 107 | 108 | it('should preserve non-WordPress imports', () => { 109 | const code = ` 110 | import { useState } from '@wordpress/element'; 111 | import React from 'react'; 112 | import styles from './styles.css'; 113 | `.trim(); 114 | const result = (plugin.transform as any)?.( 115 | code, 116 | 'test.tsx' 117 | ) as TransformResult; 118 | 119 | expect(result).toBeDefined(); 120 | expect(result?.code).toContain( 121 | 'const useState = wp.element.useState;' 122 | ); 123 | expect(result?.code).toContain("import React from 'react';"); 124 | expect(result?.code).toContain( 125 | "import styles from './styles.css';" 126 | ); 127 | }); 128 | }); 129 | 130 | describe('dependency tracking', () => { 131 | it('should track WordPress dependencies and generate manifest', () => { 132 | const code = ` 133 | import { useState } from '@wordpress/element'; 134 | import apiFetch from '@wordpress/api-fetch'; 135 | `.trim(); 136 | 137 | // Transform to trigger dependency tracking 138 | (plugin.transform as any)?.(code, 'test.tsx'); 139 | 140 | // Mock emitFile to capture dependencies 141 | const emitFile = vi.fn(); 142 | if ( 143 | plugin.generateBundle && 144 | typeof plugin.generateBundle === 'function' 145 | ) { 146 | const context = { 147 | emitFile, 148 | meta: {}, 149 | /* eslint-disable @typescript-eslint/no-unused-vars */ 150 | warn: (_message: string) => { 151 | /* intentionally empty for tests */ 152 | }, 153 | error: (_message: string) => { 154 | /* intentionally empty for tests */ 155 | }, 156 | /* eslint-enable @typescript-eslint/no-unused-vars */ 157 | }; 158 | 159 | plugin.generateBundle.call( 160 | context as any, 161 | {} as any, 162 | {}, 163 | false 164 | ); 165 | } 166 | 167 | expect(emitFile).toHaveBeenCalledWith( 168 | expect.objectContaining({ 169 | name: 'editor.deps.json', 170 | originalFileName: 'editor.deps.json', 171 | type: 'asset', 172 | source: JSON.stringify( 173 | ['wp-element', 'wp-api-fetch'], 174 | null, 175 | 2 176 | ), 177 | }) 178 | ); 179 | }); 180 | }); 181 | 182 | describe('external handling', () => { 183 | it('should mark WordPress packages as external', () => { 184 | const result = (plugin.options as any)({ 185 | input: 'src/index.ts', 186 | }) as InputOptions; 187 | 188 | const external = result.external as (id: string) => boolean; 189 | 190 | expect(external('@wordpress/element')).toBe(true); 191 | expect(external('@wordpress/components')).toBe(true); 192 | expect(external('@wordpress/blocks')).toBe(true); 193 | }); 194 | 195 | it('should not mark non-WordPress packages as external', () => { 196 | const result = (plugin.options as any)({ 197 | input: 'src/index.ts', 198 | }) as InputOptions; 199 | 200 | const external = result.external as (id: string) => boolean; 201 | 202 | expect(external('react')).toBe(false); 203 | expect(external('@emotion/react')).toBe(false); 204 | expect(external('./local-file')).toBe(false); 205 | }); 206 | 207 | it('should handle non-string input IDs in external check', () => { 208 | const result = (plugin.options as any)({ 209 | input: 'src/index.ts', 210 | }) as InputOptions; 211 | const external = result.external as (id: unknown) => boolean; 212 | 213 | expect(external(null)).toBe(false); 214 | expect(external(undefined)).toBe(false); 215 | expect(external(123)).toBe(false); 216 | }); 217 | 218 | it('should preserve existing options while adding external handling', () => { 219 | const result = (plugin.options as any)({ 220 | input: 'src/index.ts', 221 | treeshake: true, 222 | preserveEntrySignatures: 'strict' as const, 223 | }) as InputOptions; 224 | 225 | expect(result).toEqual( 226 | expect.objectContaining({ 227 | input: 'src/index.ts', 228 | treeshake: true, 229 | preserveEntrySignatures: 'strict', 230 | external: expect.any(Function), 231 | }) 232 | ); 233 | }); 234 | }); 235 | }); 236 | 237 | describe('wordpressThemeJson', () => { 238 | const mockTailwindConfigPath = './tailwind.config.js'; 239 | const mockTailwindConfig = { 240 | theme: { 241 | colors: { 242 | primary: '#000000', 243 | 'red-500': '#ef4444', 244 | }, 245 | fontFamily: { 246 | inter: ['Inter', 'sans-serif'], 247 | sans: ['system-ui', 'sans-serif'], 248 | }, 249 | fontSize: { 250 | sm: '0.875rem', 251 | lg: '1.125rem', 252 | }, 253 | }, 254 | }; 255 | 256 | const mockBaseThemeJson = { 257 | settings: { 258 | color: { 259 | palette: [], 260 | }, 261 | typography: { 262 | fontFamilies: [], 263 | fontSizes: [], 264 | }, 265 | }, 266 | }; 267 | 268 | beforeEach(() => { 269 | vi.mocked(fs.readFileSync).mockImplementation( 270 | (path: fs.PathOrFileDescriptor) => { 271 | if ( 272 | typeof path === 'string' && 273 | path.includes('tailwind.config.js') 274 | ) { 275 | return `module.exports = ${JSON.stringify( 276 | mockTailwindConfig 277 | )}`; 278 | } 279 | return JSON.stringify(mockBaseThemeJson); 280 | } 281 | ); 282 | 283 | vi.mocked(path.resolve).mockImplementation((...paths: string[]) => 284 | paths.join('/') 285 | ); 286 | }); 287 | 288 | afterEach(() => { 289 | vi.clearAllMocks(); 290 | }); 291 | 292 | it('should process CSS variables from @theme block', () => { 293 | const plugin = wordpressThemeJson({ 294 | tailwindConfig: mockTailwindConfigPath, 295 | }); 296 | 297 | const cssContent = ` 298 | @theme { 299 | --color-primary: #000000; 300 | --color-red-500: #ef4444; 301 | --font-inter: "Inter"; 302 | --text-lg: 1.125rem; 303 | } 304 | `; 305 | 306 | (plugin.transform as any)(cssContent, 'app.css'); 307 | const emitFile = vi.fn(); 308 | (plugin.generateBundle as any).call({ emitFile }); 309 | 310 | expect(emitFile).toHaveBeenCalledWith( 311 | expect.objectContaining({ 312 | fileName: 'assets/theme.json', 313 | source: expect.stringContaining('"name": "Primary"'), 314 | }) 315 | ); 316 | }); 317 | 318 | it('should handle invalid tailwind config path', async () => { 319 | const plugin = wordpressThemeJson({ 320 | tailwindConfig: './nonexistent.config.js', 321 | }); 322 | 323 | await expect((plugin.configResolved as any)?.()).rejects.toThrow( 324 | /Failed to load Tailwind config/ 325 | ); 326 | }); 327 | 328 | it('should handle numeric color shades', () => { 329 | const plugin = wordpressThemeJson({ 330 | tailwindConfig: mockTailwindConfigPath, 331 | }); 332 | 333 | const cssContent = ` 334 | @theme { 335 | --color-red-500: #ef4444; 336 | --color-blue-100: #e0f2fe; 337 | --color-primary: #000000; 338 | --color-white: #ffffff; 339 | --color-black: #000000; 340 | } 341 | `; 342 | 343 | (plugin.transform as any)(cssContent, 'app.css'); 344 | const emitFile = vi.fn(); 345 | (plugin.generateBundle as any).call({ emitFile }); 346 | 347 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 348 | 349 | expect(themeJson.settings.color.palette).toContainEqual({ 350 | name: 'Red (500)', 351 | slug: 'red-500', 352 | color: '#ef4444', 353 | }); 354 | 355 | expect(themeJson.settings.color.palette).toContainEqual({ 356 | name: 'Blue (100)', 357 | slug: 'blue-100', 358 | color: '#e0f2fe', 359 | }); 360 | 361 | expect(themeJson.settings.color.palette).toContainEqual({ 362 | name: 'Primary', 363 | slug: 'primary', 364 | color: '#000000', 365 | }); 366 | 367 | expect(themeJson.settings.color.palette).toContainEqual({ 368 | name: 'White', 369 | slug: 'white', 370 | color: '#ffffff', 371 | }); 372 | 373 | expect(themeJson.settings.color.palette).toContainEqual({ 374 | name: 'Black', 375 | slug: 'black', 376 | color: '#000000', 377 | }); 378 | }); 379 | 380 | it('should respect disable flags', () => { 381 | const plugin = wordpressThemeJson({ 382 | tailwindConfig: mockTailwindConfigPath, 383 | disableTailwindColors: true, 384 | disableTailwindFonts: true, 385 | disableTailwindFontSizes: true, 386 | }); 387 | 388 | const cssContent = ` 389 | @theme { 390 | --color-primary: #000000; 391 | --font-inter: "Inter"; 392 | --text-lg: 1.125rem; 393 | } 394 | `; 395 | 396 | (plugin.transform as any)(cssContent, 'app.css'); 397 | const emitFile = vi.fn(); 398 | (plugin.generateBundle as any).call({ emitFile }); 399 | 400 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 401 | 402 | expect(themeJson.settings.color?.palette).toEqual([]); 403 | expect(themeJson.settings.typography.fontFamilies).toEqual([]); 404 | expect(themeJson.settings.typography.fontSizes).toEqual([]); 405 | }); 406 | 407 | it('should handle invalid font properties', () => { 408 | const plugin = wordpressThemeJson({ 409 | tailwindConfig: mockTailwindConfigPath, 410 | }); 411 | 412 | const cssContent = ` 413 | @theme { 414 | --font-feature-settings: "ss01"; 415 | --font-weight: 500; 416 | --font-inter: "Inter"; 417 | } 418 | `; 419 | 420 | (plugin.transform as any)(cssContent, 'app.css'); 421 | const emitFile = vi.fn(); 422 | (plugin.generateBundle as any).call({ emitFile }); 423 | 424 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 425 | const fontFamilies = themeJson.settings.typography.fontFamilies; 426 | 427 | expect(fontFamilies).toHaveLength(1); 428 | expect(fontFamilies[0]).toEqual({ 429 | name: 'inter', 430 | slug: 'inter', 431 | fontFamily: 'Inter', 432 | }); 433 | }); 434 | 435 | it('should handle missing @theme block', () => { 436 | const plugin = wordpressThemeJson({ 437 | tailwindConfig: mockTailwindConfigPath, 438 | }); 439 | 440 | const cssContent = ` 441 | .some-class { 442 | color: red; 443 | } 444 | `; 445 | 446 | (plugin.transform as any)(cssContent, 'app.css'); 447 | const emitFile = vi.fn(); 448 | (plugin.generateBundle as any).call({ emitFile }); 449 | 450 | expect(emitFile).not.toHaveBeenCalled(); 451 | }); 452 | 453 | it('should handle malformed @theme block', async () => { 454 | const plugin = wordpressThemeJson({ 455 | tailwindConfig: mockTailwindConfigPath, 456 | }); 457 | 458 | const cssContent = ` 459 | @theme { 460 | --color-primary: #000000; 461 | /* missing closing brace */ 462 | `; 463 | 464 | (plugin.transform as any)(cssContent, 'app.css'); 465 | const emitFile = vi.fn(); 466 | 467 | await expect( 468 | (plugin.generateBundle as any).call({ emitFile }) 469 | ).rejects.toThrow('Unclosed @theme { block - missing closing brace'); 470 | }); 471 | 472 | it('should handle shade labels', () => { 473 | const plugin = wordpressThemeJson({ 474 | tailwindConfig: mockTailwindConfigPath, 475 | shadeLabels: { 476 | '50': 'Lightest', 477 | '100': 'Lighter', 478 | '500': 'Default', 479 | '900': 'Darkest', 480 | }, 481 | }); 482 | 483 | const cssContent = ` 484 | @theme { 485 | --color-blue-50: #f0f9ff; 486 | --color-blue-100: #e0f2fe; 487 | --color-blue-500: #3b82f6; 488 | --color-blue-900: #1e3a8a; 489 | --color-primary: #000000; 490 | } 491 | `; 492 | 493 | (plugin.transform as any)(cssContent, 'app.css'); 494 | const emitFile = vi.fn(); 495 | (plugin.generateBundle as any).call({ emitFile }); 496 | 497 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 498 | 499 | expect(themeJson.settings.color.palette).toContainEqual({ 500 | name: 'Lightest Blue', 501 | slug: 'blue-50', 502 | color: '#f0f9ff', 503 | }); 504 | 505 | expect(themeJson.settings.color.palette).toContainEqual({ 506 | name: 'Lighter Blue', 507 | slug: 'blue-100', 508 | color: '#e0f2fe', 509 | }); 510 | 511 | expect(themeJson.settings.color.palette).toContainEqual({ 512 | name: 'Default Blue', 513 | slug: 'blue-500', 514 | color: '#3b82f6', 515 | }); 516 | 517 | expect(themeJson.settings.color.palette).toContainEqual({ 518 | name: 'Darkest Blue', 519 | slug: 'blue-900', 520 | color: '#1e3a8a', 521 | }); 522 | 523 | expect(themeJson.settings.color.palette).toContainEqual({ 524 | name: 'Primary', 525 | slug: 'primary', 526 | color: '#000000', 527 | }); 528 | }); 529 | 530 | it('should format shades without labels as Color (shade)', () => { 531 | const plugin = wordpressThemeJson({ 532 | tailwindConfig: mockTailwindConfigPath, 533 | // No shade labels configured 534 | }); 535 | 536 | const cssContent = ` 537 | @theme { 538 | --color-blue-50: #f0f9ff; 539 | --color-blue-100: #e0f2fe; 540 | --color-red-500: #ef4444; 541 | --color-gray-900: #111827; 542 | --color-primary: #000000; 543 | } 544 | `; 545 | 546 | (plugin.transform as any)(cssContent, 'app.css'); 547 | const emitFile = vi.fn(); 548 | (plugin.generateBundle as any).call({ emitFile }); 549 | 550 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 551 | 552 | expect(themeJson.settings.color.palette).toContainEqual({ 553 | name: 'Blue (50)', 554 | slug: 'blue-50', 555 | color: '#f0f9ff', 556 | }); 557 | 558 | expect(themeJson.settings.color.palette).toContainEqual({ 559 | name: 'Blue (100)', 560 | slug: 'blue-100', 561 | color: '#e0f2fe', 562 | }); 563 | 564 | expect(themeJson.settings.color.palette).toContainEqual({ 565 | name: 'Red (500)', 566 | slug: 'red-500', 567 | color: '#ef4444', 568 | }); 569 | 570 | expect(themeJson.settings.color.palette).toContainEqual({ 571 | name: 'Gray (900)', 572 | slug: 'gray-900', 573 | color: '#111827', 574 | }); 575 | 576 | expect(themeJson.settings.color.palette).toContainEqual({ 577 | name: 'Primary', 578 | slug: 'primary', 579 | color: '#000000', 580 | }); 581 | }); 582 | 583 | it('should handle multi-hyphen color names', () => { 584 | const plugin = wordpressThemeJson({ 585 | tailwindConfig: mockTailwindConfigPath, 586 | }); 587 | 588 | const cssContent = ` 589 | @theme { 590 | --color-fancy-test-example: #123456; 591 | --color-button-hover-state: #234567; 592 | --color-social-twitter-blue: #1DA1F2; 593 | --color-primary: #000000; 594 | } 595 | `; 596 | 597 | (plugin.transform as any)(cssContent, 'app.css'); 598 | const emitFile = vi.fn(); 599 | (plugin.generateBundle as any).call({ emitFile }); 600 | 601 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 602 | 603 | expect(themeJson.settings.color.palette).toContainEqual({ 604 | name: 'Fancy (Test Example)', 605 | slug: 'fancy-test-example', 606 | color: '#123456', 607 | }); 608 | 609 | expect(themeJson.settings.color.palette).toContainEqual({ 610 | name: 'Button (Hover State)', 611 | slug: 'button-hover-state', 612 | color: '#234567', 613 | }); 614 | 615 | expect(themeJson.settings.color.palette).toContainEqual({ 616 | name: 'Social (Twitter Blue)', 617 | slug: 'social-twitter-blue', 618 | color: '#1DA1F2', 619 | }); 620 | 621 | expect(themeJson.settings.color.palette).toContainEqual({ 622 | name: 'Primary', 623 | slug: 'primary', 624 | color: '#000000', 625 | }); 626 | }); 627 | 628 | it('should preserve existing theme.json settings', () => { 629 | const existingThemeJson = { 630 | settings: { 631 | color: { 632 | palette: [ 633 | { 634 | name: 'existing-color', 635 | slug: 'existing-color', 636 | color: '#cccccc', 637 | }, 638 | ], 639 | }, 640 | typography: { 641 | fontFamilies: [ 642 | { 643 | name: 'existing-font', 644 | slug: 'existing-font', 645 | fontFamily: 'Arial', 646 | }, 647 | ], 648 | fontSizes: [ 649 | { 650 | name: 'existing-size', 651 | slug: 'existing-size', 652 | size: '1rem', 653 | }, 654 | ], 655 | }, 656 | }, 657 | }; 658 | 659 | vi.mocked(fs.readFileSync).mockReturnValue( 660 | JSON.stringify(existingThemeJson) 661 | ); 662 | 663 | const plugin = wordpressThemeJson({ 664 | tailwindConfig: mockTailwindConfigPath, 665 | }); 666 | 667 | const cssContent = ` 668 | @theme { 669 | --color-primary: #000000; 670 | --font-inter: "Inter"; 671 | --text-lg: 1.125rem; 672 | } 673 | `; 674 | 675 | (plugin.transform as any)(cssContent, 'app.css'); 676 | 677 | const emitFile = vi.fn(); 678 | (plugin.generateBundle as any).call({ emitFile }); 679 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 680 | 681 | expect(themeJson.settings.color.palette).toContainEqual({ 682 | name: 'existing-color', 683 | slug: 'existing-color', 684 | color: '#cccccc', 685 | }); 686 | 687 | expect(themeJson.settings.typography.fontFamilies).toContainEqual({ 688 | name: 'existing-font', 689 | slug: 'existing-font', 690 | fontFamily: 'Arial', 691 | }); 692 | 693 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 694 | name: 'existing-size', 695 | slug: 'existing-size', 696 | size: '1rem', 697 | }); 698 | }); 699 | 700 | it('should handle font labels', () => { 701 | const plugin = wordpressThemeJson({ 702 | tailwindConfig: mockTailwindConfigPath, 703 | fontLabels: { 704 | inter: 'Inter Font', 705 | sans: 'System Sans', 706 | }, 707 | }); 708 | 709 | const cssContent = ` 710 | @theme { 711 | --font-inter: "Inter"; 712 | --font-sans: "system-ui"; 713 | } 714 | `; 715 | 716 | (plugin.transform as any)(cssContent, 'app.css'); 717 | const emitFile = vi.fn(); 718 | (plugin.generateBundle as any).call({ emitFile }); 719 | 720 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 721 | 722 | expect(themeJson.settings.typography.fontFamilies).toContainEqual({ 723 | name: 'Inter Font', 724 | slug: 'inter', 725 | fontFamily: 'Inter', 726 | }); 727 | 728 | expect(themeJson.settings.typography.fontFamilies).toContainEqual({ 729 | name: 'System Sans', 730 | slug: 'sans', 731 | fontFamily: 'system-ui', 732 | }); 733 | }); 734 | 735 | it('should handle font size labels', () => { 736 | const plugin = wordpressThemeJson({ 737 | tailwindConfig: mockTailwindConfigPath, 738 | fontSizeLabels: { 739 | sm: 'Small', 740 | lg: 'Large', 741 | '2xs': 'Extra Extra Small', 742 | }, 743 | }); 744 | 745 | const cssContent = ` 746 | @theme { 747 | --text-sm: 0.875rem; 748 | --text-lg: 1.125rem; 749 | --text-2xs: 0.625rem; 750 | } 751 | `; 752 | 753 | (plugin.transform as any)(cssContent, 'app.css'); 754 | const emitFile = vi.fn(); 755 | (plugin.generateBundle as any).call({ emitFile }); 756 | 757 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 758 | 759 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 760 | name: 'Small', 761 | slug: 'sm', 762 | size: '0.875rem', 763 | }); 764 | 765 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 766 | name: 'Large', 767 | slug: 'lg', 768 | size: '1.125rem', 769 | }); 770 | 771 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 772 | name: 'Extra Extra Small', 773 | slug: '2xs', 774 | size: '0.625rem', 775 | }); 776 | }); 777 | 778 | it('should handle missing font and font size labels', () => { 779 | const plugin = wordpressThemeJson({ 780 | tailwindConfig: mockTailwindConfigPath, 781 | }); 782 | 783 | const cssContent = ` 784 | @theme { 785 | --font-inter: "Inter"; 786 | --text-2xs: 0.625rem; 787 | } 788 | `; 789 | 790 | (plugin.transform as any)(cssContent, 'app.css'); 791 | const emitFile = vi.fn(); 792 | (plugin.generateBundle as any).call({ emitFile }); 793 | 794 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 795 | 796 | expect(themeJson.settings.typography.fontFamilies).toContainEqual({ 797 | name: 'inter', 798 | slug: 'inter', 799 | fontFamily: 'Inter', 800 | }); 801 | 802 | expect(themeJson.settings.typography.fontSizes).toContainEqual({ 803 | name: '2xs', 804 | slug: '2xs', 805 | size: '0.625rem', 806 | }); 807 | }); 808 | 809 | it('should sort font sizes from smallest to largest', () => { 810 | const plugin = wordpressThemeJson({ 811 | tailwindConfig: mockTailwindConfigPath, 812 | }); 813 | 814 | const cssContent = ` 815 | @theme { 816 | --text-4xl: 2.25rem; 817 | --text-sm: 0.875rem; 818 | --text-base: 1rem; 819 | --text-xs: 0.75rem; 820 | --text-2xl: 1.5rem; 821 | --text-lg: 1.125rem; 822 | } 823 | `; 824 | 825 | (plugin.transform as any)(cssContent, 'app.css'); 826 | const emitFile = vi.fn(); 827 | (plugin.generateBundle as any).call({ emitFile }); 828 | 829 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 830 | const fontSizes = themeJson.settings.typography.fontSizes; 831 | 832 | // Verify the order is correct 833 | expect(fontSizes.map((f: { size: string }) => f.size)).toEqual([ 834 | '0.75rem', // xs 835 | '0.875rem', // sm 836 | '1rem', // base 837 | '1.125rem', // lg 838 | '1.5rem', // 2xl 839 | '2.25rem', // 4xl 840 | ]); 841 | }); 842 | 843 | it('should handle sorting of mixed units', () => { 844 | const plugin = wordpressThemeJson({ 845 | tailwindConfig: mockTailwindConfigPath, 846 | }); 847 | 848 | const cssContent = ` 849 | @theme { 850 | --text-px: 16px; 851 | --text-em: 1em; 852 | --text-rem: 1rem; 853 | --text-small: 12px; 854 | --text-large: 1.5rem; 855 | } 856 | `; 857 | 858 | (plugin.transform as any)(cssContent, 'app.css'); 859 | const emitFile = vi.fn(); 860 | (plugin.generateBundle as any).call({ emitFile }); 861 | 862 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 863 | const fontSizes = themeJson.settings.typography.fontSizes; 864 | 865 | // Verify the order is correct (12px = 0.75rem, 16px = 1rem) 866 | expect(fontSizes.map((f: { size: string }) => f.size)).toEqual([ 867 | '12px', // 0.75rem 868 | '16px', // 1rem 869 | '1em', // 1rem 870 | '1rem', // 1rem 871 | '1.5rem', // 1.5rem 872 | ]); 873 | }); 874 | 875 | it('should not include text shadow variables as font sizes', () => { 876 | const plugin = wordpressThemeJson({ 877 | tailwindConfig: mockTailwindConfigPath, 878 | }); 879 | 880 | const cssContent = ` 881 | @theme { 882 | --text-shadow-xs: 0px 1px 1px #0003; 883 | --text-shadow-md: 0px 1px 2px #0000001a; 884 | --text-lg: 1.125rem; 885 | --text-base: 1rem; 886 | } 887 | `; 888 | 889 | (plugin.transform as any)(cssContent, 'app.css'); 890 | const emitFile = vi.fn(); 891 | (plugin.generateBundle as any).call({ emitFile }); 892 | 893 | const themeJson = JSON.parse(emitFile.mock.calls[0][0].source); 894 | const fontSizes = themeJson.settings.typography.fontSizes; 895 | 896 | expect(fontSizes).toContainEqual({ 897 | name: 'lg', 898 | slug: 'lg', 899 | size: '1.125rem', 900 | }); 901 | 902 | expect(fontSizes).toContainEqual({ 903 | name: 'base', 904 | slug: 'base', 905 | size: '1rem', 906 | }); 907 | 908 | expect( 909 | fontSizes.some((f: { slug: string }) => f.slug.includes('shadow')) 910 | ).toBe(false); 911 | }); 912 | }); 913 | -------------------------------------------------------------------------------- /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 | } --------------------------------------------------------------------------------