├── .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 |
4 |
5 |
6 |
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 |
--------------------------------------------------------------------------------