Files
sage/resources/js/build/wordpress.js

400 lines
12 KiB
JavaScript

import {
defaultRequestToExternal,
defaultRequestToHandle,
} from '@wordpress/dependency-extraction-webpack-plugin/lib/util'
import fs from 'fs'
import path from 'path'
/**
* Vite plugin that handles WordPress dependencies and generates a dependency manifest.
*
* This plugin:
* 1. Transforms @wordpress/* imports into global wp.* references
* 2. Tracks WordPress script dependencies
* 3. Generates an editor.deps.json file listing all WordPress dependencies
*
* @returns {import('vite').Plugin} Vite plugin
*/
export function wordpressPlugin() {
const dependencies = new Set()
// Helper functions for import handling
function extractNamedImports(imports) {
const match = imports.match(/{([^}]+)}/)
if (!match) return []
return match[1]
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '')
}
function handleNamedReplacement(namedImports, external) {
return namedImports
.map((imports) => {
const [name, alias = name] = imports
.split(' as ')
.map((script) => script.trim())
return `const ${alias} = ${external.join('.')}.${name};`
})
.join('\n')
}
function handleReplacements(imports, external) {
const importStr = Array.isArray(imports) ? imports[0] : imports
if (importStr.includes('{')) {
const namedImports = extractNamedImports(importStr)
return handleNamedReplacement(namedImports, external)
}
if (importStr.includes('* as')) {
const match = importStr.match(/\*\s+as\s+(\w+)/)
if (!match) return ''
const alias = match[1]
return `const ${alias} = ${external.join('.')};`
}
const name = importStr.trim()
return `const ${name} = ${external.join('.')};`
}
return {
name: 'wordpress-plugin',
enforce: 'pre',
config(config) {
return {
...config,
resolve: {
...config.resolve,
alias: {
...config.resolve?.alias,
}
},
}
},
resolveId(id) {
if (id.startsWith('@wordpress/')) {
const pkg = id.replace('@wordpress/', '')
const external = defaultRequestToExternal(id)
const handle = defaultRequestToHandle(id)
if (external && handle) {
dependencies.add(handle)
return {
id,
external: true,
}
}
}
},
transform(code, id) {
if ((!id.endsWith('.js')) && !id.endsWith('.jsx') && !id.endsWith('.ts') && !id.endsWith('.tsx')) return
const imports = [
...(code.match(/^import[\s\n]+(?:[^;]+?)[\s\n]+from[\s\n]+['"]@wordpress\/[^'"]+['"]/gm) || []),
...(code.match(/^import[\s\n]+['"]@wordpress\/[^'"]+['"]/gm) || []),
]
imports.forEach((statement) => {
const match =
statement
.replace(/[\s\n]+/g, ' ')
.match(/^import (.+) from ['"]@wordpress\/([^'"]+)['"]/) ||
statement.match(/^import ['"]@wordpress\/([^'"]+)['"]/);
if (!match) return
const [, imports, pkg] = match
if (!pkg) return
const external = defaultRequestToExternal(`@wordpress/${pkg}`)
const handle = defaultRequestToHandle(`@wordpress/${pkg}`)
if (external && handle) {
dependencies.add(handle)
const replacement = imports
? handleReplacements(imports, external)
: `const ${pkg.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())} = ${external.join('.')};`
code = code.replace(statement, replacement)
}
})
return { code, map: null }
},
generateBundle() {
this.emitFile({
type: 'asset',
name: 'editor.deps.json',
fileName: 'editor.deps.json',
source: JSON.stringify([...dependencies]),
})
},
}
}
/**
* Rollup plugin that configures external WordPress dependencies.
*
* This plugin:
* 1. Marks all @wordpress/* packages as external dependencies
* 2. Maps external @wordpress/* imports to wp.* global variables
*
* This prevents WordPress core libraries from being bundled and ensures
* they are loaded from WordPress's global scope instead.
*
* @returns {import('rollup').Plugin} Rollup plugin
*/
export function wordpressRollupPlugin() {
return {
name: 'wordpress-rollup-plugin',
options(opts) {
opts.external = (id) => id.startsWith('@wordpress/')
opts.output = opts.output || {}
opts.output.globals = (id) => {
if (id.startsWith('@wordpress/')) {
const packageName = id.replace('@wordpress/', '')
return `wp.${packageName.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())}`
}
}
return opts
},
}
}
/**
* Vite plugin that generates a WordPress theme.json file based on Tailwind v4 CSS variables.
* This allows theme.json settings to stay in sync with your Tailwind design tokens.
*
* CSS variables defined in an @theme block will be transformed into theme.json format:
*
* @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.tailwindConfig - The resolved Tailwind configuration object
* @param {boolean} [options.disableTailwindColors=false] - Disable including Tailwind colors in theme.json
* @param {boolean} [options.disableTailwindFonts=false] - Disable including Tailwind fonts in theme.json
* @param {boolean} [options.disableTailwindFontSizes=false] - Disable including Tailwind font sizes in theme.json
* @returns {import('vite').Plugin} Vite plugin
*/
export function wordpressThemeJson({
tailwindConfig,
disableTailwindColors = false,
disableTailwindFonts = false,
disableTailwindFontSizes = false,
}) {
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 {
name: 'wordpress-theme-json',
enforce: 'post',
transform(code, id) {
if (id.includes('app.css')) {
cssContent = code
}
return null
},
async generateBundle() {
if (!cssContent) {
return // No CSS file to process
}
try {
const baseThemeJson = JSON.parse(
fs.readFileSync(path.resolve('./theme.json'), 'utf8')
)
const themeContent = extractThemeContent(cssContent)
if (!themeContent) {
return // No @theme block to process
}
// Process any CSS variables in whatever format they exist
const colorVariables = {}
const colorVarRegex = /--color-([^:]+):\s*([^;}]+)[;}]?/g
let match
while ((match = colorVarRegex.exec(themeContent)) !== null) {
const [, name, value] = match
colorVariables[name] = value.trim()
}
// Transform colors to theme.json format
const colors = []
Object.entries(colorVariables).forEach(([name, value]) => {
if (name.endsWith('-*')) return
if (name.includes('-')) {
const [colorName, shade] = name.split('-')
if (shade && !isNaN(shade)) {
colors.push({
name: `${colorName}-${shade}`,
slug: `${colorName}-${shade}`.toLowerCase(),
color: value,
})
} else {
colors.push({
name: name,
slug: name.toLowerCase(),
color: value,
})
}
} else {
colors.push({
name: name,
slug: name.toLowerCase(),
color: value,
})
}
})
// Process any font families
const fontFamilies = []
const fontVarRegex = /--font-([^:]+):\s*([^;}]+)[;}]?/g
while ((match = fontVarRegex.exec(themeContent)) !== null) {
const [, name, value] = match
// 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({
name: name,
slug: name.toLowerCase(),
fontFamily: value.trim(),
})
}
}
// Process any font sizes
const fontSizes = []
const fontSizeVarRegex = /--text-([^:]+):\s*([^;}]+)[;}]?/g
while ((match = fontSizeVarRegex.exec(themeContent)) !== null) {
const [, name, value] = match
// Skip line-height entries
if (!name.includes('line-height')) {
fontSizes.push({
name: name,
slug: name.toLowerCase(),
size: value.trim(),
})
}
}
// Build theme.json with whatever variables were found
const themeJson = {
__processed__: "This file was generated from Tailwind v4 CSS variables",
...baseThemeJson,
settings: {
...baseThemeJson.settings,
...((!disableTailwindColors && colors.length > 0) && {
color: {
...baseThemeJson.settings?.color,
palette: colors,
},
}),
typography: {
defaultFontSizes: false,
customFontSize: false,
...((!disableTailwindFonts && fontFamilies.length > 0) && {
fontFamilies,
}),
...(!disableTailwindFontSizes && fontSizes.length > 0 && {
fontSizes,
}),
},
},
}
delete themeJson.__preprocessed__
this.emitFile({
type: 'asset',
fileName: 'assets/theme.json',
source: JSON.stringify(themeJson, null, 2)
})
} catch (error) {
this.error(error.message)
}
},
}
}