♻️ Improve theme.json generation with more robust CSS parsing (#3223)

This commit is contained in:
Ben Word
2025-02-06 08:52:03 -05:00
committed by GitHub
parent 47a48480bd
commit 464e977d5a
2 changed files with 206 additions and 124 deletions

View File

@@ -37,6 +37,24 @@ jobs:
npm run build npm run build
cat public/build/manifest.json cat public/build/manifest.json
- name: Validate theme.json
run: |
THEME_JSON="public/build/assets/theme.json"
if [ ! -f "$THEME_JSON" ]; then
echo "❌ theme.json not found"
exit 1
fi
jq -e '
(.settings.color.palette | map(select(.name == "black")) | length > 0) and
(.settings.typography.fontFamilies | map(select(.name == "sans")) | length > 0) and
(.settings.typography.fontSizes | map(select(.name == "xl")) | length > 0)
' "$THEME_JSON" 2>&1 || {
echo "❌ Invalid theme.json structure or missing required values"
exit 1
}
php: php:
name: PHP ${{ matrix.php }} name: PHP ${{ matrix.php }}
runs-on: ubuntu-latest runs-on: ubuntu-latest

View File

@@ -163,12 +163,20 @@ export function wordpressRollupPlugin() {
} }
/** /**
* Generates a WordPress theme.json file by combining: * Vite plugin that generates a WordPress theme.json file based on Tailwind v4 CSS variables.
* - Base theme.json settings * This allows theme.json settings to stay in sync with your Tailwind design tokens.
* - Tailwind configuration (colors, fonts, font sizes)
* *
* The generated theme.json is emitted to public/build/assets/theme.json * CSS variables defined in an @theme block will be transformed into theme.json format:
* and provides WordPress with theme settings that match your Tailwind configuration. *
* @example
* ```css
* @theme {
* --color-primary: #000000; -> { name: "primary", color: "#000000" }
* --color-red-500: #ef4444; -> { name: "red-500", color: "#ef4444" }
* --font-inter: "Inter"; -> { name: "inter", fontFamily: "Inter" }
* --text-lg: 1.125rem; -> { name: "lg", size: "1.125rem" }
* }
* ```
* *
* @param {Object} options Plugin options * @param {Object} options Plugin options
* @param {Object} options.tailwindConfig - The resolved Tailwind configuration object * @param {Object} options.tailwindConfig - The resolved Tailwind configuration object
@@ -185,6 +193,74 @@ export function wordpressThemeJson({
}) { }) {
let cssContent = null let cssContent = null
/**
* Safely extracts content between matched braces, handling:
* - Nested braces
* - String literals (both single and double quotes)
* - CSS comments
* - Escaped characters
*/
function extractThemeContent(css) {
const themeMatch = css.match(/@(?:layer\s+)?theme\s*{/s)
if (!themeMatch) {
return null // No @theme block - that's fine
}
const startIndex = themeMatch.index + themeMatch[0].length
let braceCount = 1
for (let i = startIndex; i < css.length; i++) {
// Skip escaped characters
if (css[i] === '\\') {
i++
continue
}
// Skip string literals
if (css[i] === '"' || css[i] === "'") {
const quote = css[i]
i++
while (i < css.length) {
if (css[i] === '\\') {
i++
} else if (css[i] === quote) {
break
}
i++
}
if (i >= css.length) {
throw new Error('Unclosed string literal in CSS')
}
continue
}
// Skip CSS comments
if (css[i] === '/' && css[i + 1] === '*') {
i += 2
while (i < css.length) {
if (css[i] === '*' && css[i + 1] === '/') {
i++
break
}
i++
}
if (i >= css.length) {
throw new Error('Unclosed comment in CSS')
}
continue
}
if (css[i] === '{') braceCount++
if (css[i] === '}') braceCount--
if (braceCount === 0) {
return css.substring(startIndex, i)
}
}
throw new Error('Unclosed @theme block - missing closing brace')
}
return { return {
name: 'wordpress-theme-json', name: 'wordpress-theme-json',
enforce: 'post', enforce: 'post',
@@ -198,51 +274,30 @@ export function wordpressThemeJson({
async generateBundle() { async generateBundle() {
if (!cssContent) { if (!cssContent) {
return; return // No CSS file to process
} }
try {
const baseThemeJson = JSON.parse( const baseThemeJson = JSON.parse(
fs.readFileSync(path.resolve('./theme.json'), 'utf8') fs.readFileSync(path.resolve('./theme.json'), 'utf8')
) )
const themeContent = (() => { const themeContent = extractThemeContent(cssContent)
const match = cssContent.match(/@(?:layer\s+)?theme\s*{/s)
if (!match[0]) {
return null
}
const startIndex = match.index + match[0].length;
let braceCount = 1;
for (let i = startIndex; i < cssContent.length; i++) {
if (cssContent[i] === "{") braceCount++;
if (cssContent[i] === "}") braceCount--;
if (braceCount === 0) {
return cssContent.substring(startIndex, i );
}
}
return null
})()
if (!themeContent) { if (!themeContent) {
return; return // No @theme block to process
} }
if (!themeContent.trim().startsWith(':root')) { // Process any CSS variables in whatever format they exist
return;
}
const endIndex = themeContent.lastIndexOf('}');
const rootContent = themeContent.slice(themeContent.indexOf('{') + 1, endIndex === -1 ? undefined : endIndex);
const colorVariables = {} const colorVariables = {}
const colorVarRegex = /--color-([^:]+):\s*([^;}]+)[;}]?/g const colorVarRegex = /--color-([^:]+):\s*([^;}]+)[;}]?/g
let match let match
while ((match = colorVarRegex.exec(rootContent)) !== null) { while ((match = colorVarRegex.exec(themeContent)) !== null) {
const [, name, value] = match const [, name, value] = match
colorVariables[name] = value.trim() colorVariables[name] = value.trim()
} }
// Transform colors to theme.json format
const colors = [] const colors = []
Object.entries(colorVariables).forEach(([name, value]) => { Object.entries(colorVariables).forEach(([name, value]) => {
if (name.endsWith('-*')) return if (name.endsWith('-*')) return
@@ -271,11 +326,16 @@ export function wordpressThemeJson({
} }
}) })
// Process any font families
const fontFamilies = [] const fontFamilies = []
const fontVarRegex = /--font-([^:]+):\s*([^;}]+)[;}]?/g const fontVarRegex = /--font-([^:]+):\s*([^;}]+)[;}]?/g
while ((match = fontVarRegex.exec(rootContent)) !== null) { while ((match = fontVarRegex.exec(themeContent)) !== null) {
const [, name, value] = match const [, name, value] = match
if (!name.includes('-feature-settings') && !name.includes('-variation-settings')) { // Skip feature settings, variation settings, and any font-* properties
if (!name.includes('feature-settings') &&
!name.includes('variation-settings') &&
!['family', 'size', 'smoothing', 'style', 'weight', 'stretch']
.some(prop => name.includes(prop))) {
fontFamilies.push({ fontFamilies.push({
name: name, name: name,
slug: name.toLowerCase(), slug: name.toLowerCase(),
@@ -284,11 +344,13 @@ export function wordpressThemeJson({
} }
} }
// Process any font sizes
const fontSizes = [] const fontSizes = []
const fontSizeVarRegex = /--text-([^:]+):\s*([^;}]+)[;}]?/g const fontSizeVarRegex = /--text-([^:]+):\s*([^;}]+)[;}]?/g
while ((match = fontSizeVarRegex.exec(rootContent)) !== null) { while ((match = fontSizeVarRegex.exec(themeContent)) !== null) {
const [, name, value] = match const [, name, value] = match
if (!name.includes('--line-height')) { // Skip line-height entries
if (!name.includes('line-height')) {
fontSizes.push({ fontSizes.push({
name: name, name: name,
slug: name.toLowerCase(), slug: name.toLowerCase(),
@@ -297,6 +359,7 @@ export function wordpressThemeJson({
} }
} }
// Build theme.json with whatever variables were found
const themeJson = { const themeJson = {
__processed__: "This file was generated from Tailwind v4 CSS variables", __processed__: "This file was generated from Tailwind v4 CSS variables",
...baseThemeJson, ...baseThemeJson,
@@ -308,19 +371,17 @@ export function wordpressThemeJson({
palette: colors, palette: colors,
}, },
}), }),
typography: {
defaultFontSizes: false,
customFontSize: false,
...((!disableTailwindFonts && fontFamilies.length > 0) && { ...((!disableTailwindFonts && fontFamilies.length > 0) && {
typography: {
...baseThemeJson.settings?.typography,
fontFamilies, fontFamilies,
},
}), }),
...((!disableTailwindFontSizes && fontSizes.length > 0) && { ...(!disableTailwindFontSizes && fontSizes.length > 0 && {
typography: {
...baseThemeJson.settings?.typography,
fontSizes, fontSizes,
},
}), }),
}, },
},
} }
delete themeJson.__preprocessed__ delete themeJson.__preprocessed__
@@ -330,6 +391,9 @@ export function wordpressThemeJson({
fileName: 'assets/theme.json', fileName: 'assets/theme.json',
source: JSON.stringify(themeJson, null, 2) source: JSON.stringify(themeJson, null, 2)
}) })
} catch (error) {
this.error(error.message)
}
}, },
} }
} }