├── .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 |
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 | ### 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 | }
--------------------------------------------------------------------------------