From 3c87d1f9becba7d6207797bb4e47c4bc18e77a54 Mon Sep 17 00:00:00 2001 From: Ben Word Date: Wed, 11 Mar 2026 19:49:02 -0500 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Add=20dev=20mode=20theme.json=20gen?= =?UTF-8?q?eration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 --- src/index.ts | 883 +++++++++++++++++++++++++++------------------- tests/dev.test.ts | 215 +++++++++++ 2 files changed, 743 insertions(+), 355 deletions(-) create mode 100644 tests/dev.test.ts diff --git a/src/index.ts b/src/index.ts index 0a6dd1d..c828c37 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ import { defaultRequestToExternal, defaultRequestToHandle, } from '@wordpress/dependency-extraction-webpack-plugin/lib/util'; -import type { Plugin as VitePlugin } from 'vite'; +import type { Plugin as VitePlugin, ViteDevServer, Logger } from 'vite'; import type { InputOptions } from 'rollup'; import fs from 'fs'; import path from 'path'; @@ -1090,11 +1090,456 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { throw new Error(`Unclosed ${blockType} block - missing closing brace`); } + /** + * In dev mode, Vite's CSS transform wraps CSS in a JS module string + * literal with escaped newlines/quotes. This reverses common escapes + * so our regex patterns can match CSS variable declarations. + */ + function unescapeJsString(code: string): string { + return code + .replace(/\\n/g, '\n') + .replace(/\\t/g, '\t') + .replace(/\\"/g, '"') + .replace(/\\'/g, "'") + .replace(/\\\\/g, '\\'); + } + + let isDev = false; + let resolvedOutDir = ''; + let resolvedCssFilePath = ''; + let cssEntryPath = ''; + let logger: Logger | undefined; + let debounceTimer: ReturnType | null = null; + let writeCounter = 0; + let serverClosed = false; + + /** CSS-wide keywords that represent resets, not actual values */ + const cssWideKeywords = [ + 'initial', + 'inherit', + 'unset', + 'revert', + 'revert-layer', + ]; + + const invalidFontProps = [ + 'feature-settings', + 'variation-settings', + 'family', + 'size', + 'smoothing', + 'style', + 'weight', + 'stretch', + ]; + + const patterns = { + COLOR: /(?:^|[;{}])\s*--color-([^:]+):\s*([^;}]+)/gm, + FONT_FAMILY: /(?:^|[;{}])\s*--font-([^:]+):\s*([^;}]+)/gm, + FONT_SIZE: /(?:^|[;{}])\s*--text-([^:]+):\s*([^;}]+)/gm, + BORDER_RADIUS: /(?:^|[;{}])\s*--radius-([^:]+):\s*([^;}]+)/gm, + } as const; + + /** + * Helper to extract CSS variables using a regex pattern. + * Filters out wildcard namespace resets (e.g. --font-*: initial) + * and CSS-wide keywords. + */ + const extractVariables = ( + regex: RegExp, + content: string | null + ) => { + if (!content) return []; + + const variables: Array<[string, string]> = []; + let match: RegExpExecArray | null; + + // Reset regex lastIndex since we reuse pattern objects + regex.lastIndex = 0; + + while ((match = regex.exec(content)) !== null) { + const [, name, value] = match; + + if ( + name && + value && + !name.includes('*') && + !cssWideKeywords.includes(value.trim()) + ) + variables.push([name, value.trim()]); + } + + return variables; + }; + + /** + * Generates the theme.json object from current CSS content and config. + * Used by both build (generateBundle) and dev (fs write) paths. + */ + function generateThemeJson(css: string | null): ThemeJson { + const baseThemeJson = JSON.parse( + fs.readFileSync(path.resolve(baseThemeJsonPath), 'utf8') + ) as ThemeJson; + + // Extract theme content if CSS is available + const themeContent = css + ? extractThemeContent(css) + : null; + + // Process colors from either @theme block or Tailwind config + const colorEntries = !disableTailwindColors + ? [ + // Process @theme block colors if available + ...extractVariables(patterns.COLOR, themeContent) + .map(([name, value]) => { + const parts = name.split('-'); + const colorName = parts[0]; + const shade = + parts.length > 1 + ? parts.slice(1).join(' ') + : undefined; + const capitalizedColor = + colorName.charAt(0).toUpperCase() + + colorName.slice(1); + const displayName = shade + ? shadeLabels && shade in shadeLabels + ? `${shadeLabels[shade]} ${capitalizedColor}` + : Number.isNaN(Number(shade)) + ? `${capitalizedColor} (${shade + .split(' ') + .map( + (word) => + word + .charAt(0) + .toUpperCase() + + word.slice(1) + ) + .join(' ')})` + : `${capitalizedColor} (${shade})` + : capitalizedColor; + + return { + name: displayName, + slug: name.toLowerCase(), + color: value, + }; + }), + // Process Tailwind config colors if available + ...(resolvedTailwindConfig?.theme?.colors + ? flattenColors( + resolvedTailwindConfig.theme.colors + ).map(([name, value]) => { + const parts = name.split('-'); + const colorName = parts[0]; + const shade = + parts.length > 1 + ? parts.slice(1).join(' ') + : undefined; + const capitalizedColor = + colorName.charAt(0).toUpperCase() + + colorName.slice(1); + const displayName = shade + ? shadeLabels && shade in shadeLabels + ? `${shadeLabels[shade]} ${capitalizedColor}` + : Number.isNaN(Number(shade)) + ? `${capitalizedColor} (${shade + .split(' ') + .map( + (word) => + word + .charAt(0) + .toUpperCase() + + word.slice(1) + ) + .join(' ')})` + : `${capitalizedColor} (${shade})` + : capitalizedColor; + + return { + name: displayName, + slug: name.toLowerCase(), + color: value, + }; + }) + : []), + ] + : undefined; + + // Process font families from either @theme block or Tailwind config + const fontFamilyEntries = !disableTailwindFonts + ? [ + // Process @theme block font families if available + ...extractVariables( + patterns.FONT_FAMILY, + themeContent + ) + .filter( + ([name]) => + !invalidFontProps.some((prop) => + name.includes(prop) + ) + ) + .map(([name, value]) => { + const displayName = + fontLabels && name in fontLabels + ? fontLabels[name] + : name; + return { + name: displayName, + slug: name.toLowerCase(), + fontFamily: value.replace(/['"]/g, ''), + }; + }), + // Process Tailwind config font families if available + ...(resolvedTailwindConfig?.theme?.fontFamily + ? processFontFamilies( + resolvedTailwindConfig.theme.fontFamily, + fontLabels + ) + : []), + ] + : undefined; + + // Process font sizes from either @theme block or Tailwind config + const fontSizeEntries = !disableTailwindFontSizes + ? [ + // Process @theme block font sizes if available + ...extractVariables(patterns.FONT_SIZE, themeContent) + .filter( + ([name]) => + !name.includes('line-height') && + !name.includes('letter-spacing') && + !name.includes('font-weight') && + !name.includes('shadow') + ) + .map(([name, value]) => { + const displayName = + fontSizeLabels && name in fontSizeLabels + ? fontSizeLabels[name] + : name; + return { + name: displayName, + slug: name.toLowerCase(), + size: value, + }; + }), + // Process Tailwind config font sizes if available + ...(resolvedTailwindConfig?.theme?.fontSize + ? processFontSizes( + resolvedTailwindConfig.theme.fontSize, + fontSizeLabels + ) + : []), + ] + : undefined; + + // Process border radius sizes from either @theme block or Tailwind config + const borderRadiusEntries = !disableTailwindBorderRadius + ? [ + // Process @theme block border radius sizes if available + ...extractVariables( + patterns.BORDER_RADIUS, + themeContent + ) + .filter( + ([, value]) => isStaticRadiusValue(value) + ) + .map(([name, value]) => { + const displayName = + borderRadiusLabels && + name in borderRadiusLabels + ? borderRadiusLabels[name] + : name; + return { + name: displayName, + slug: name.toLowerCase(), + size: value, + }; + }), + // Process Tailwind config border radius if available + ...(resolvedTailwindConfig?.theme?.borderRadius + ? processBorderRadiusSizes( + resolvedTailwindConfig.theme.borderRadius, + borderRadiusLabels + ) + : []), + ] + : undefined; + + // Build theme.json + const themeJson: ThemeJson = { + __processed__: 'This file was generated using Vite', + ...baseThemeJson, + settings: { + ...baseThemeJson.settings, + color: disableTailwindColors + ? baseThemeJson.settings?.color + : { + ...baseThemeJson.settings?.color, + palette: [ + ...(baseThemeJson.settings?.color + ?.palette || []), + ...(colorEntries || []), + ].filter( + (entry, index, self) => + index === + self.findIndex( + (e) => e.slug === entry.slug + ) + ), + }, + typography: { + ...baseThemeJson.settings?.typography, + defaultFontSizes: + baseThemeJson.settings?.typography + ?.defaultFontSizes ?? false, + customFontSize: + baseThemeJson.settings?.typography + ?.customFontSize ?? false, + fontFamilies: disableTailwindFonts + ? baseThemeJson.settings?.typography + ?.fontFamilies + : [ + ...(baseThemeJson.settings?.typography + ?.fontFamilies || []), + ...(fontFamilyEntries || []), + ].filter( + (entry, index, self) => + index === + self.findIndex( + (e) => e.slug === entry.slug + ) + ), + fontSizes: disableTailwindFontSizes + ? baseThemeJson.settings?.typography?.fontSizes + : sortFontSizes( + [ + ...(baseThemeJson.settings?.typography + ?.fontSizes || []), + ...(fontSizeEntries || []), + ].filter( + (entry, index, self) => + index === + self.findIndex( + (e) => e.slug === entry.slug + ) + ) + ), + }, + ...(() => { + if (disableTailwindBorderRadius) { + return baseThemeJson.settings?.border + ? { border: baseThemeJson.settings.border } + : {}; + } + const mergedRadiusSizes = sortBorderRadiusSizes( + [ + ...(baseThemeJson.settings?.border + ?.radiusSizes || []), + ...(borderRadiusEntries || []), + ].filter( + (entry, index, self) => + index === + self.findIndex( + (e) => e.slug === entry.slug + ) + ) + ); + // Only add radius settings if there are entries + if (mergedRadiusSizes.length === 0) { + return baseThemeJson.settings?.border + ? { border: baseThemeJson.settings.border } + : {}; + } + return { + border: { + ...baseThemeJson.settings?.border, + radius: + baseThemeJson.settings?.border + ?.radius ?? true, + radiusSizes: mergedRadiusSizes, + }, + }; + })(), + }, + }; + + delete themeJson.__preprocessed__; + + return themeJson; + } + + /** + * Writes theme.json to disk atomically (write to tmp then rename). + */ + async function writeThemeJsonToDisk(): Promise { + if (serverClosed) return; + try { + const themeJson = generateThemeJson(cssContent); + const outFile = path.resolve(resolvedOutDir, outputPath); + const tmpFile = `${outFile}.${process.pid}.${++writeCounter}.tmp`; + + await fs.promises.mkdir(path.dirname(outFile), { recursive: true }); + await fs.promises.writeFile(tmpFile, JSON.stringify(themeJson, null, 2)); + await fs.promises.rename(tmpFile, outFile); + + logger?.info(`[theme.json] Generated → ${outputPath}`, { timestamp: true }); + } catch (error) { + logger?.error( + `[theme.json] ${error instanceof Error ? error.message : String(error)}`, + { timestamp: true } + ); + } + } + + /** + * Trailing-edge debounced write — only the last call within the + * window fires, so rapid HMR transforms coalesce into one write. + */ + function debouncedWriteThemeJson(): void { + if (debounceTimer) clearTimeout(debounceTimer); + debounceTimer = setTimeout(() => { + debounceTimer = null; + writeThemeJsonToDisk(); + }, 150); + } + return { name: 'wordpress-theme-json', enforce: 'pre', - async configResolved() { + async configResolved(viteConfig) { + isDev = viteConfig?.command === 'serve'; + logger = viteConfig?.logger; + + // Resolve outDir against Vite root so relative paths + // (e.g. "public/build" from laravel-vite-plugin) work + // regardless of process cwd. + const root = viteConfig?.root ?? process.cwd(); + const rawOutDir = viteConfig?.build?.outDir ?? ''; + resolvedOutDir = path.isAbsolute(rawOutDir) + ? rawOutDir + : path.resolve(root, rawOutDir); + + // Resolve the CSS entry to a full absolute path for exact + // matching in the transform hook. We look through Vite's + // resolved inputs to find the entry ending with our cssFile. + if (viteConfig?.root) { + const input = viteConfig.build?.rollupOptions?.input; + const inputs = Array.isArray(input) + ? input + : typeof input === 'string' + ? [input] + : input + ? Object.values(input) + : []; + const match = inputs.find((i: string) => i.endsWith(cssFile)); + if (match) { + cssEntryPath = match; + resolvedCssFilePath = path.resolve(viteConfig.root, match); + } + } + if (tailwindConfig) { resolvedTailwindConfig = await loadTailwindConfig( tailwindConfig @@ -1102,9 +1547,87 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { } }, + configureServer(server: ViteDevServer) { + serverClosed = false; + + // Cancel pending debounce timer on server close + server.httpServer?.on('close', () => { + serverClosed = true; + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } + }); + + // On startup, write a base passthrough immediately, then + // kick off a transformRequest for the CSS file so Tailwind + // processes it through the pipeline. Our transform hook + // captures the result and the debounced write overwrites + // the passthrough with the full token set. + server.httpServer?.on('listening', async () => { + await writeThemeJsonToDisk(); + + if (cssEntryPath) { + try { + await server.transformRequest(cssEntryPath); + } catch { + // Non-fatal — the first browser request will + // trigger the transform instead + } + } + }); + + // Watch non-CSS files that affect theme.json output + const watchPaths = [path.resolve(baseThemeJsonPath)]; + if (tailwindConfig) watchPaths.push(path.resolve(tailwindConfig)); + + const resolvedTailwindConfigPath = tailwindConfig + ? path.resolve(tailwindConfig) + : null; + + for (const event of ['change', 'add', 'unlink'] as const) { + server.watcher.on(event, async (changedPath: string) => { + const resolved = path.resolve(changedPath); + if (!watchPaths.includes(resolved)) return; + + // Re-import tailwind config so changes take effect + if (resolvedTailwindConfigPath && resolved === resolvedTailwindConfigPath) { + try { + resolvedTailwindConfig = await loadTailwindConfig( + tailwindConfig! + ); + } catch (error) { + logger?.error( + `[theme.json] Failed to reload Tailwind config: ${ + error instanceof Error ? error.message : String(error) + }`, + { timestamp: true } + ); + return; + } + } + + debouncedWriteThemeJson(); + }); + } + }, + transform(code: string, id: string) { - if (id.includes(cssFile)) { - cssContent = code; + const cleanId = id.split('?')[0]; + const isCssFile = resolvedCssFilePath + ? path.resolve(cleanId) === resolvedCssFilePath + : cleanId.endsWith(cssFile); + + if (isCssFile) { + // In dev mode, Vite wraps CSS in a JS module for HMR injection. + // The CSS is embedded as a string literal with escaped newlines + // and quotes. Extract and unescape the raw CSS so our regex + // patterns can match variable declarations. + cssContent = isDev ? unescapeJsString(code) : code; + + if (isDev) { + debouncedWriteThemeJson(); + } } return null; @@ -1112,357 +1635,7 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { async generateBundle() { try { - const baseThemeJson = JSON.parse( - fs.readFileSync(path.resolve(baseThemeJsonPath), 'utf8') - ) as ThemeJson; - - // Extract theme content if CSS is available - const themeContent = cssContent - ? extractThemeContent(cssContent) - : null; - - // Even without Tailwind, always generate theme.json so - // the theme_file_path filter in Sage doesn't point to - // a non-existent file (passes through the base theme.json) - - /** CSS-wide keywords that represent resets, not actual values */ - const cssWideKeywords = [ - 'initial', - 'inherit', - 'unset', - 'revert', - 'revert-layer', - ]; - - /** - * Helper to extract CSS variables using a regex pattern. - * Filters out wildcard namespace resets (e.g. --font-*: initial) - * and CSS-wide keywords. - */ - const extractVariables = ( - regex: RegExp, - content: string | null - ) => { - if (!content) return []; - - const variables: Array<[string, string]> = []; - let match: RegExpExecArray | null; - - while ((match = regex.exec(content)) !== null) { - const [, name, value] = match; - - if ( - name && - value && - !name.includes('*') && - !cssWideKeywords.includes(value.trim()) - ) - variables.push([name, value.trim()]); - } - - return variables; - }; - - const patterns = { - COLOR: /(?:^|[;{}])\s*--color-([^:]+):\s*([^;}]+)/gm, - FONT_FAMILY: - /(?:^|[;{}])\s*--font-([^:]+):\s*([^;}]+)/gm, - FONT_SIZE: - /(?:^|[;{}])\s*--text-([^:]+):\s*([^;}]+)/gm, - BORDER_RADIUS: - /(?:^|[;{}])\s*--radius-([^:]+):\s*([^;}]+)/gm, - } as const; - - // Process colors from either @theme block or Tailwind config - const colorEntries = !disableTailwindColors - ? [ - // Process @theme block colors if available - ...extractVariables(patterns.COLOR, themeContent) - .map(([name, value]) => { - const parts = name.split('-'); - const colorName = parts[0]; - const shade = - parts.length > 1 - ? parts.slice(1).join(' ') - : undefined; - const capitalizedColor = - colorName.charAt(0).toUpperCase() + - colorName.slice(1); - const displayName = shade - ? shadeLabels && shade in shadeLabels - ? `${shadeLabels[shade]} ${capitalizedColor}` - : Number.isNaN(Number(shade)) - ? `${capitalizedColor} (${shade - .split(' ') - .map( - (word) => - word - .charAt(0) - .toUpperCase() + - word.slice(1) - ) - .join(' ')})` - : `${capitalizedColor} (${shade})` - : capitalizedColor; - - return { - name: displayName, - slug: name.toLowerCase(), - color: value, - }; - }), - // Process Tailwind config colors if available - ...(resolvedTailwindConfig?.theme?.colors - ? flattenColors( - resolvedTailwindConfig.theme.colors - ).map(([name, value]) => { - const parts = name.split('-'); - const colorName = parts[0]; - const shade = - parts.length > 1 - ? parts.slice(1).join(' ') - : undefined; - const capitalizedColor = - colorName.charAt(0).toUpperCase() + - colorName.slice(1); - const displayName = shade - ? shadeLabels && shade in shadeLabels - ? `${shadeLabels[shade]} ${capitalizedColor}` - : Number.isNaN(Number(shade)) - ? `${capitalizedColor} (${shade - .split(' ') - .map( - (word) => - word - .charAt(0) - .toUpperCase() + - word.slice(1) - ) - .join(' ')})` - : `${capitalizedColor} (${shade})` - : capitalizedColor; - - return { - name: displayName, - slug: name.toLowerCase(), - color: value, - }; - }) - : []), - ] - : undefined; - - const invalidFontProps = [ - 'feature-settings', - 'variation-settings', - 'family', - 'size', - 'smoothing', - 'style', - 'weight', - 'stretch', - ]; - - // Process font families from either @theme block or Tailwind config - const fontFamilyEntries = !disableTailwindFonts - ? [ - // Process @theme block font families if available - ...extractVariables( - patterns.FONT_FAMILY, - themeContent - ) - .filter( - ([name]) => - !invalidFontProps.some((prop) => - name.includes(prop) - ) - ) - .map(([name, value]) => { - const displayName = - fontLabels && name in fontLabels - ? fontLabels[name] - : name; - return { - name: displayName, - slug: name.toLowerCase(), - fontFamily: value.replace(/['"]/g, ''), - }; - }), - // Process Tailwind config font families if available - ...(resolvedTailwindConfig?.theme?.fontFamily - ? processFontFamilies( - resolvedTailwindConfig.theme.fontFamily, - fontLabels - ) - : []), - ] - : undefined; - - // Process font sizes from either @theme block or Tailwind config - const fontSizeEntries = !disableTailwindFontSizes - ? [ - // Process @theme block font sizes if available - ...extractVariables(patterns.FONT_SIZE, themeContent) - .filter( - ([name]) => - !name.includes('line-height') && - !name.includes('letter-spacing') && - !name.includes('font-weight') && - !name.includes('shadow') - ) - .map(([name, value]) => { - const displayName = - fontSizeLabels && name in fontSizeLabels - ? fontSizeLabels[name] - : name; - return { - name: displayName, - slug: name.toLowerCase(), - size: value, - }; - }), - // Process Tailwind config font sizes if available - ...(resolvedTailwindConfig?.theme?.fontSize - ? processFontSizes( - resolvedTailwindConfig.theme.fontSize, - fontSizeLabels - ) - : []), - ] - : undefined; - - // Process border radius sizes from either @theme block or Tailwind config - const borderRadiusEntries = !disableTailwindBorderRadius - ? [ - // Process @theme block border radius sizes if available - ...extractVariables( - patterns.BORDER_RADIUS, - themeContent - ) - .filter( - ([, value]) => isStaticRadiusValue(value) - ) - .map(([name, value]) => { - const displayName = - borderRadiusLabels && - name in borderRadiusLabels - ? borderRadiusLabels[name] - : name; - return { - name: displayName, - slug: name.toLowerCase(), - size: value, - }; - }), - // Process Tailwind config border radius if available - ...(resolvedTailwindConfig?.theme?.borderRadius - ? processBorderRadiusSizes( - resolvedTailwindConfig.theme.borderRadius, - borderRadiusLabels - ) - : []), - ] - : undefined; - - // Build theme.json - const themeJson: ThemeJson = { - __processed__: 'This file was generated using Vite', - ...baseThemeJson, - settings: { - ...baseThemeJson.settings, - color: disableTailwindColors - ? baseThemeJson.settings?.color - : { - ...baseThemeJson.settings?.color, - palette: [ - ...(baseThemeJson.settings?.color - ?.palette || []), - ...(colorEntries || []), - ].filter( - (entry, index, self) => - index === - self.findIndex( - (e) => e.slug === entry.slug - ) - ), - }, - typography: { - ...baseThemeJson.settings?.typography, - defaultFontSizes: - baseThemeJson.settings?.typography - ?.defaultFontSizes ?? false, - customFontSize: - baseThemeJson.settings?.typography - ?.customFontSize ?? false, - fontFamilies: disableTailwindFonts - ? baseThemeJson.settings?.typography - ?.fontFamilies - : [ - ...(baseThemeJson.settings?.typography - ?.fontFamilies || []), - ...(fontFamilyEntries || []), - ].filter( - (entry, index, self) => - index === - self.findIndex( - (e) => e.slug === entry.slug - ) - ), - fontSizes: disableTailwindFontSizes - ? baseThemeJson.settings?.typography?.fontSizes - : sortFontSizes( - [ - ...(baseThemeJson.settings?.typography - ?.fontSizes || []), - ...(fontSizeEntries || []), - ].filter( - (entry, index, self) => - index === - self.findIndex( - (e) => e.slug === entry.slug - ) - ) - ), - }, - ...(() => { - if (disableTailwindBorderRadius) { - return baseThemeJson.settings?.border - ? { border: baseThemeJson.settings.border } - : {}; - } - const mergedRadiusSizes = sortBorderRadiusSizes( - [ - ...(baseThemeJson.settings?.border - ?.radiusSizes || []), - ...(borderRadiusEntries || []), - ].filter( - (entry, index, self) => - index === - self.findIndex( - (e) => e.slug === entry.slug - ) - ) - ); - // Only add radius settings if there are entries - if (mergedRadiusSizes.length === 0) { - return baseThemeJson.settings?.border - ? { border: baseThemeJson.settings.border } - : {}; - } - return { - border: { - ...baseThemeJson.settings?.border, - radius: - baseThemeJson.settings?.border - ?.radius ?? true, - radiusSizes: mergedRadiusSizes, - }, - }; - })(), - }, - }; - - delete themeJson.__preprocessed__; + const themeJson = generateThemeJson(cssContent); this.emitFile({ type: 'asset', diff --git a/tests/dev.test.ts b/tests/dev.test.ts new file mode 100644 index 0000000..367efcb --- /dev/null +++ b/tests/dev.test.ts @@ -0,0 +1,215 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { describe, expect, it, afterAll, afterEach, beforeEach } from 'vitest'; +import { createServer, type ViteDevServer } from 'vite'; +import tailwindcss from '@tailwindcss/vite'; +import { wordpressThemeJson } from '../src/index.js'; +import fs from 'fs'; +import path from 'path'; + +const fixtureDir = path.resolve(__dirname, 'fixture'); +const outDir = path.join(fixtureDir, 'dist-dev'); +const themeJsonPath = path.join(outDir, 'theme.json'); + +/** + * Wait for theme.json to appear, contain a given marker, and stabilize + * (no further writes for 250ms). This ensures debounced writes from the + * startup transformRequest have completed before we assert. + */ +async function waitForThemeJson( + marker?: string, + timeout = 5000 +): Promise { + const start = Date.now(); + let lastContent = ''; + let stableSince = 0; + + while (Date.now() - start < timeout) { + if (fs.existsSync(themeJsonPath)) { + const content = fs.readFileSync(themeJsonPath, 'utf8'); + try { + const json = JSON.parse(content); + if (!marker || content.includes(marker)) { + // Wait for the file to stop changing + if (content === lastContent) { + if (Date.now() - stableSince >= 250) return json; + } else { + lastContent = content; + stableSince = Date.now(); + } + } + } catch { + // partial write — retry + } + } + await new Promise((r) => setTimeout(r, 50)); + } + throw new Error( + `theme.json did not stabilize${marker ? ` with marker "${marker}"` : ''} within ${timeout}ms` + ); +} + +async function createDevServer(pluginOptions = {}, baseThemeJson = 'theme.json') { + const server = await createServer({ + plugins: [ + tailwindcss(), + wordpressThemeJson({ + baseThemeJsonPath: path.join(fixtureDir, baseThemeJson), + outputPath: 'theme.json', + ...pluginOptions, + }), + ], + build: { + rollupOptions: { + input: path.join(fixtureDir, 'app.css'), + }, + outDir, + }, + server: { + // Use a random port to avoid conflicts + port: 0, + strictPort: false, + }, + logLevel: 'silent', + }); + + return server; +} + +describe('vite dev integration', () => { + let server: ViteDevServer | null = null; + + beforeEach(() => { + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + afterEach(async () => { + if (server) { + await server.close(); + server = null; + } + }); + + afterAll(() => { + fs.rmSync(outDir, { recursive: true, force: true }); + }); + + it('should generate theme.json on server start', async () => { + server = await createDevServer(); + await server.listen(); + + const themeJson = await waitForThemeJson('__processed__'); + + expect(themeJson.__processed__).toBe( + 'This file was generated using Vite' + ); + expect(themeJson.settings).toBeDefined(); + }); + + it('should generate valid JSON (atomic write)', async () => { + server = await createDevServer(); + await server.listen(); + + const themeJson = await waitForThemeJson('__processed__'); + + // Should be parseable — if atomic write failed we'd get a parse error + expect(themeJson.__processed__).toBe( + 'This file was generated using Vite' + ); + expect(typeof themeJson.settings).toBe('object'); + }); + + it('should preserve base theme.json settings', async () => { + server = await createDevServer(); + await server.listen(); + + const themeJson = await waitForThemeJson('__processed__'); + + // Base theme.json has custom: false and defaultPalette: false + expect(themeJson.settings.color.custom).toBe(false); + expect(themeJson.settings.color.defaultPalette).toBe(false); + }); + + it('should respect disableTailwindColors in dev mode', async () => { + server = await createDevServer({ disableTailwindColors: true }); + await server.listen(); + + const themeJson = await waitForThemeJson('__processed__'); + + // When colors are disabled, palette should come from base only + expect(themeJson.settings.color.custom).toBe(false); + expect(themeJson.settings.color.palette).toBeUndefined(); + }); + + it('should respect disableTailwindFonts in dev mode', async () => { + server = await createDevServer({ disableTailwindFonts: true }); + await server.listen(); + + const themeJson = await waitForThemeJson('__processed__'); + + expect(themeJson.settings.typography.fontFamilies).toBeUndefined(); + }); + + it('should respect disableTailwindFontSizes in dev mode', async () => { + server = await createDevServer({ disableTailwindFontSizes: true }); + await server.listen(); + + const themeJson = await waitForThemeJson('__processed__'); + + expect(themeJson.settings.typography.fontSizes).toBeUndefined(); + }); + + it('should respect disableTailwindBorderRadius in dev mode', async () => { + server = await createDevServer({ disableTailwindBorderRadius: true }); + await server.listen(); + + const themeJson = await waitForThemeJson('__processed__'); + + expect(themeJson.settings.border).toBeUndefined(); + }); + + it('should generate theme.json with CSS tokens after transform', async () => { + server = await createDevServer(); + await server.listen(); + + // Trigger a CSS transform by processing the module. + await server.transformRequest(path.join(fixtureDir, 'app.css')); + + // Wait for debounced write — look for a color slug in the output + const themeJson = await waitForThemeJson('red-50', 8000); + + expect(themeJson.__processed__).toBe( + 'This file was generated using Vite' + ); + + // Verify Tailwind color tokens were extracted from the dev CSS + const palette = themeJson.settings.color.palette; + expect(palette.length).toBeGreaterThan(0); + expect(palette.some((c: any) => c.slug.startsWith('red-'))).toBe(true); + }, 15000); + + it('should extract all token types from dev CSS', async () => { + server = await createDevServer(); + await server.listen(); + + await server.transformRequest(path.join(fixtureDir, 'app.css')); + + const themeJson = await waitForThemeJson('red-50', 8000); + + // Colors + const palette = themeJson.settings.color.palette; + expect(palette.length).toBeGreaterThan(0); + + // Font families + const fonts = themeJson.settings.typography.fontFamilies; + expect(fonts.length).toBeGreaterThan(0); + + // Font sizes + const sizes = themeJson.settings.typography.fontSizes; + expect(sizes.length).toBeGreaterThan(0); + + // Border radius + const radius = themeJson.settings.border?.radiusSizes; + expect(radius).toBeDefined(); + expect(radius.length).toBeGreaterThan(0); + }, 15000); +});