From 6a1ebb78bbedb0fdaf72555c31f136dde0a373a3 Mon Sep 17 00:00:00 2001 From: eavonius Date: Sat, 19 Apr 2025 17:46:19 +0000 Subject: [PATCH 1/2] feat: generate theme.json during dev mode using in-memory CSS - Enables writing theme.json to disk when running Vite in dev mode - Extracts CSS from the in-memory transform hook instead of relying on output files - Writes to public/build/assets for compatibility with Sage - Still generates theme.json during build - Adds support for hashed CSS filenames when resolving in build --- package.json | 3 +- src/index.ts | 708 ++++++++++++++++++++++++++++++++------------------- 2 files changed, 448 insertions(+), 263 deletions(-) diff --git a/package.json b/package.json index 1228283..499f085 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "test": "vitest run" }, "dependencies": { - "@wordpress/dependency-extraction-webpack-plugin": "^6.18.0" + "@wordpress/dependency-extraction-webpack-plugin": "^6.18.0", + "fast-glob": "^3.3.3" }, "devDependencies": { "@types/node": "^18.11.9", diff --git a/src/index.ts b/src/index.ts index e313a88..8b6c94d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,9 +3,10 @@ import { defaultRequestToHandle, } from '@wordpress/dependency-extraction-webpack-plugin/lib/util.js'; import type { Plugin as VitePlugin } from 'vite'; -import type { InputOptions } from 'rollup'; +import type { InputOptions, OutputAsset } from 'rollup'; import fs from 'fs'; import path from 'path'; +import glob from 'fast-glob'; interface ThemeJsonPluginOptions { /** @@ -513,6 +514,11 @@ interface ThemeJsonConfig extends ThemeJsonPluginOptions { * @default 'app.css' */ cssFile?: string; + + /** + * Optional callback called after theme.json is written to disk. + */ + onGenerated?: (themeJson: ThemeJson) => void; } interface TailwindTheme { @@ -682,6 +688,47 @@ async function loadTailwindConfig(configPath: string): Promise { } } +/** + * Resolves the path to the output directory relative to public/build. + * + * @param outputPath The relative path to the output directory. + * @returns The absolute path to the output directory. + */ +function resolveOutputPath(outputPath: string): string { + return path.resolve('public/build', outputPath.replace(/^\.?\//, '')); +} + +/** + * Writes the generated theme.json to disk. + * + * @param themeJson The theme.json object to write. + * @param outputPath The path to write the theme.json file to. + * @param onGenerated A callback function to execute after the file is generated. + */ +async function writeThemeJsonToDisk( + themeJson: ThemeJson, + outputPath: string, + onGenerated?: (json: ThemeJson) => void +) { + const outFile = resolveOutputPath(outputPath); + const outDir = path.dirname(outFile); + + try { + await fs.promises.mkdir(outDir, { recursive: true }); + await fs.promises.writeFile( + outFile, + JSON.stringify(themeJson, null, 2), + 'utf8' + ); + } catch (err) { + console.error('[theme.json] Failed to write file:', err); + } + + if (typeof onGenerated === 'function') { + onGenerated(themeJson); + } +} + /** * Creates a Vite plugin that generates a WordPress theme.json file from Tailwind CSS variables. * This allows theme.json settings to stay in sync with your Tailwind design tokens. @@ -730,6 +777,8 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { fontSizeLabels, } = config; + let isDev = false; + let cssContent: string | null = null; let resolvedTailwindConfig: TailwindConfig | undefined; @@ -737,6 +786,17 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { throw new Error('tailwindConfig must be a string path or undefined'); } + /** + * Resolves the path to the CSS file, prefixing it with resources/css when running in dev mode. + * + * @param cssFile The path to the css file relative to resources/css. + * @returns The absolute path to the css file adjusted for whether running in dev or after building. + */ + function resolveCssPath(cssFile: string): string { + const baseDir = isDev ? 'resources/css' : 'public/build'; + return path.resolve(baseDir, cssFile.replace(/^\.?\//, '')); + } + /** * Safely extracts CSS content between matched braces while handling: * - Nested braces within the block @@ -821,11 +881,289 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { throw new Error(`Unclosed ${blockType} block - missing closing brace`); } + async function generateThemeJson(): Promise { + try { + const baseThemeJson = JSON.parse( + fs.readFileSync(path.resolve(baseThemeJsonPath), 'utf8') + ) as ThemeJson; + + // Extract theme content if CSS is available + let themeContent: string | null = null; + + if (cssContent) { + themeContent = extractThemeContent(cssContent); + } else { + const cssPath = resolveCssPath(cssFile); // Already handles `isDev` + console.log('[theme.json] Checking for CSS at:', cssPath); + if (fs.existsSync(cssPath)) { + const fileContent = fs.readFileSync(cssPath, 'utf8'); + themeContent = extractThemeContent(fileContent); + } + } + + // If no @theme block and no Tailwind config, nothing to do + if (!themeContent && !resolvedTailwindConfig) { + console.warn( + '[theme.json] No @theme block or Tailwind config found nothing to generate.' + ); + return; + } + + /** + * Helper to extract CSS variables using a regex pattern + */ + 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) variables.push([name, value.trim()]); + } + + return variables; + }; + + const patterns = { + COLOR: /--color-([^:]+):\s*([^;}]+)[;}]?/g, + FONT_FAMILY: /--font-([^:]+):\s*([^;}]+)[;}]?/g, + FONT_SIZE: /--text-([^:]+):\s*([^;}]+)[;}]?/g, + } as const; + + // Process colors from either @theme block or Tailwind config + const colorEntries = !disableTailwindColors + ? [ + // Process @theme block colors if available + ...extractVariables(patterns.COLOR, themeContent) + .filter(([name]) => !name.endsWith('-*')) + .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')) + .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; + + // 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 + ) + ) + ), + }, + }, + }; + + delete themeJson.__preprocessed__; + + return themeJson; + } catch (error) { + console.error('Failed to generate theme.json:', error); + return undefined; + } + } + return { name: 'wordpress-theme-json', enforce: 'pre', - async configResolved() { + async configResolved(viteConfig) { + isDev = viteConfig.command === 'serve'; + if (tailwindConfig) { resolvedTailwindConfig = await loadTailwindConfig( tailwindConfig @@ -833,281 +1171,127 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { } }, - transform(code: string, id: string) { - if (id.includes(cssFile)) { + transform(code, id) { + const normalizedCssFile = resolveCssPath(cssFile); + + const resolvedId = path.resolve(id); + + // Handle vite adding ?direct to the end of the file name + const resolvedIdWithoutQuery = resolvedId.split('?')[0]; + + if ( + resolvedIdWithoutQuery === normalizedCssFile || + resolvedIdWithoutQuery.endsWith(normalizedCssFile) + ) { cssContent = code; + + if (isDev) { + generateThemeJson().then((themeJson) => { + if (themeJson) { + writeThemeJsonToDisk( + themeJson, + outputPath, + config.onGenerated + ); + } + }); + } } return null; }, - async generateBundle() { - try { - const baseThemeJson = JSON.parse( - fs.readFileSync(path.resolve(baseThemeJsonPath), 'utf8') - ) as ThemeJson; + async generateBundle(_, bundle) { + const baseName = path.basename(cssFile, '.css'); + + const cssAsset = Object.values(bundle).find( + (asset): asset is OutputAsset => + asset.type === 'asset' && + asset.fileName.includes(baseName) && + asset.fileName.endsWith('.css') + ); + + if (cssAsset && typeof cssAsset.source === 'string') { + cssContent = cssAsset.source; + } else { + console.warn( + `[theme.json] Could not find in-memory CSS asset matching: ${baseName}-*.css` + ); + const fallbackPath = path.resolve('public/build', cssFile); + console.log('[theme.json] Checking for CSS at:', fallbackPath); - // Extract theme content if CSS is available - const themeContent = cssContent - ? extractThemeContent(cssContent) - : null; + try { + cssContent = await fs.promises.readFile( + fallbackPath, + 'utf8' + ); + } catch { + console.warn( + '[theme.json] No @theme block or Tailwind config found — nothing to generate.' + ); + throw new Error( + '[wordpress-theme-json] theme.json generation failed during build' + ); + } + } - // If no @theme block and no Tailwind config, nothing to do - if (!themeContent && !resolvedTailwindConfig) return; + const themeJson = await generateThemeJson(); - /** - * Helper to extract CSS variables using a regex pattern - */ - const extractVariables = ( - regex: RegExp, - content: string | null - ) => { - if (!content) return []; + if (!themeJson) { + throw new Error( + '[wordpress-theme-json] theme.json generation failed during build' + ); + } + + this.emitFile({ + type: 'asset', + fileName: outputPath, + source: JSON.stringify(themeJson, null, 2), + }); + }, - const variables: Array<[string, string]> = []; - let match: RegExpExecArray | null; + configureServer(server) { + // Resolve full paths to files explicitly configured + const cssPathToWatch = resolveCssPath(cssFile); + const watchedFiles = [ + path.resolve(baseThemeJsonPath), + tailwindConfig ? path.resolve(tailwindConfig) : null, + cssPathToWatch, + ].filter(Boolean) as string[]; + + // Also watch all .css files in the same folder as the main css file + const cssDir = path.dirname(path.resolve(cssFile)); + const cssFiles = glob.sync('**/*.css', { + cwd: cssDir, + absolute: true, + }); - while ((match = regex.exec(content)) !== null) { - const [, name, value] = match; + const allWatchedPaths = [...watchedFiles, ...cssFiles]; - if (name && value) variables.push([name, value.trim()]); - } + // Register paths with Vite's watcher + server.watcher.add(allWatchedPaths); - return variables; - }; - - const patterns = { - COLOR: /--color-([^:]+):\s*([^;}]+)[;}]?/g, - FONT_FAMILY: /--font-([^:]+):\s*([^;}]+)[;}]?/g, - FONT_SIZE: /--text-([^:]+):\s*([^;}]+)[;}]?/g, - } as const; - - // Process colors from either @theme block or Tailwind config - const colorEntries = !disableTailwindColors - ? [ - // Process @theme block colors if available - ...extractVariables(patterns.COLOR, themeContent) - .filter(([name]) => !name.endsWith('-*')) - .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')) - .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; - - // 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 - ) - ) - ), - }, - }, - }; + // Regenerate theme.json when the CSS file changes + const regenerate = async () => { + const themeJson = await generateThemeJson(); + if (!themeJson) return; - delete themeJson.__preprocessed__; + await writeThemeJsonToDisk( + themeJson, + outputPath, + config.onGenerated + ); + server.ws.send({ type: 'full-reload' }); + }; - this.emitFile({ - type: 'asset', - fileName: outputPath, - source: JSON.stringify(themeJson, null, 2), - }); - } catch (error) { - throw error instanceof Error ? error : new Error(String(error)); - } + server.watcher.on('change', async (changedPath) => { + if (allWatchedPaths.includes(path.resolve(changedPath))) { + console.log( + `[theme.json] Re-generating due to change: ${changedPath}` + ); + await regenerate(); + } + }); }, }; } From 251858def451f924fac68c7e40a49b10bcc58b4e Mon Sep 17 00:00:00 2001 From: eavonius Date: Sun, 20 Apr 2025 15:23:23 +0000 Subject: [PATCH 2/2] fix: Generate theme.json during vite dev at startup (don't wait for file to be changed). --- src/index.ts | 44 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 36 insertions(+), 8 deletions(-) diff --git a/src/index.ts b/src/index.ts index 8b6c94d..20ad3bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -883,9 +883,10 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { async function generateThemeJson(): Promise { try { - const baseThemeJson = JSON.parse( - fs.readFileSync(path.resolve(baseThemeJsonPath), 'utf8') - ) as ThemeJson; + const baseThemeJsonPathResolved = path.resolve(baseThemeJsonPath); + const baseThemeJsonExists = fs.existsSync( + baseThemeJsonPathResolved + ); // Extract theme content if CSS is available let themeContent: string | null = null; @@ -894,21 +895,29 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { themeContent = extractThemeContent(cssContent); } else { const cssPath = resolveCssPath(cssFile); // Already handles `isDev` - console.log('[theme.json] Checking for CSS at:', cssPath); if (fs.existsSync(cssPath)) { const fileContent = fs.readFileSync(cssPath, 'utf8'); themeContent = extractThemeContent(fileContent); } } - // If no @theme block and no Tailwind config, nothing to do - if (!themeContent && !resolvedTailwindConfig) { + if ( + !themeContent && + !resolvedTailwindConfig && + !baseThemeJsonExists + ) { console.warn( - '[theme.json] No @theme block or Tailwind config found nothing to generate.' + '[theme.json] No @theme block, Tailwind config, or base theme.json — skipping generation.' ); return; } + const baseThemeJson = baseThemeJsonExists + ? (JSON.parse( + fs.readFileSync(path.resolve(baseThemeJsonPath), 'utf8') + ) as ThemeJson) + : ({ settings: { typography: {} } } as ThemeJson); + /** * Helper to extract CSS variables using a regex pattern */ @@ -1218,7 +1227,6 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { `[theme.json] Could not find in-memory CSS asset matching: ${baseName}-*.css` ); const fallbackPath = path.resolve('public/build', cssFile); - console.log('[theme.json] Checking for CSS at:', fallbackPath); try { cssContent = await fs.promises.readFile( @@ -1284,6 +1292,26 @@ export function wordpressThemeJson(config: ThemeJsonConfig = {}): VitePlugin { server.ws.send({ type: 'full-reload' }); }; + server.httpServer?.once('listening', async () => { + const fallbackPath = resolveCssPath(cssFile); + + if (fs.existsSync(fallbackPath)) { + cssContent = await fs.promises.readFile( + fallbackPath, + 'utf8' + ); + const themeJson = await generateThemeJson(); + + if (themeJson) { + await writeThemeJsonToDisk( + themeJson, + outputPath, + config.onGenerated + ); + } + } + }); + server.watcher.on('change', async (changedPath) => { if (allWatchedPaths.includes(path.resolve(changedPath))) { console.log(