| 1 |
import { |
| 2 |
ownedPaletteSettingPaths, |
| 3 |
ownedPaletteStylePaths, |
| 4 |
} from '@shared/lib/palette-globals'; |
| 5 |
import { isObject } from '@shared/lib/utils'; |
| 6 |
|
| 7 |
const headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; |
| 8 |
|
| 9 |
const elementSelectors = { |
| 10 |
body: 'body', |
| 11 |
button: '.wp-element-button, .wp-block-button__link', |
| 12 |
caption: '.wp-element-caption', |
| 13 |
cite: 'cite', |
| 14 |
link: 'a:where(:not(.wp-element-button))', |
| 15 |
}; |
| 16 |
|
| 17 |
const cssProperties = { |
| 18 |
'color.text': 'color', |
| 19 |
'color.background': 'background-color', |
| 20 |
'color.gradient': 'background', |
| 21 |
'border.color': 'border-color', |
| 22 |
}; |
| 23 |
|
| 24 |
const kebab = (property) => |
| 25 |
property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); |
| 26 |
|
| 27 |
// Core names the vars via _wp_to_kebab_case, which drops the ':' from :hover. |
| 28 |
const wpKebab = (key) => |
| 29 |
kebab(key) |
| 30 |
.replace(/[^a-z0-9-]+/gi, '-') |
| 31 |
.replace(/-+/g, '-') |
| 32 |
.replace(/^-|-$/g, ''); |
| 33 |
|
| 34 |
const valueAt = (source, path) => |
| 35 |
path.reduce( |
| 36 |
(value, key) => (isObject(value) ? value[key] : undefined), |
| 37 |
source, |
| 38 |
); |
| 39 |
|
| 40 |
// theme.json stores preset references as var:preset|color|primary. |
| 41 |
const cssValue = (value) => |
| 42 |
typeof value === 'string' && value.startsWith('var:') |
| 43 |
? `var(--wp--${value.slice(4).split('|').map(kebab).join('--')})` |
| 44 |
: value; |
| 45 |
|
| 46 |
// revert alone would drop the theme's own value with the outgoing palette's. |
| 47 |
const resetValue = (value) => |
| 48 |
typeof value === 'string' || typeof value === 'number' |
| 49 |
? cssValue(value) |
| 50 |
: 'revert'; |
| 51 |
|
| 52 |
const blockSelector = (block) => { |
| 53 |
const [namespace, name] = block.split('/'); |
| 54 |
return namespace === 'core' |
| 55 |
? `.wp-block-${name}` |
| 56 |
: `.wp-block-${namespace}-${name}`; |
| 57 |
}; |
| 58 |
|
| 59 |
const presetGroups = { |
| 60 |
palette: { var: 'color', value: 'color' }, |
| 61 |
gradients: { var: 'gradient', value: 'gradient' }, |
| 62 |
}; |
| 63 |
|
| 64 |
// A palette lists its presets plainly; WP keys its own theme data by origin. |
| 65 |
const presetList = (settings, group) => { |
| 66 |
const presets = valueAt(settings, ['color', group]); |
| 67 |
return Array.isArray(presets) ? presets : (presets?.theme ?? []); |
| 68 |
}; |
| 69 |
|
| 70 |
const presetValue = (settings, group, slug, key) => |
| 71 |
presetList(settings, group).find((entry) => entry?.slug === slug)?.[key]; |
| 72 |
|
| 73 |
const presetSlugs = (palettes, group) => { |
| 74 |
const slugs = new Set(); |
| 75 |
|
| 76 |
for (const palette of Object.values(palettes ?? {})) { |
| 77 |
for (const entry of presetList(palette?.settings, group)) { |
| 78 |
if (entry?.slug) slugs.add(entry.slug); |
| 79 |
} |
| 80 |
} |
| 81 |
|
| 82 |
return [...slugs]; |
| 83 |
}; |
| 84 |
|
| 85 |
// Returning [] rather than null would hide an unmapped leaf from the guard. |
| 86 |
const settingSlots = (path, palettes) => { |
| 87 |
const [root, group] = path; |
| 88 |
|
| 89 |
// The svg filter carries the duotone, so there is no var to write. |
| 90 |
if (root === 'color' && group === 'duotone') return []; |
| 91 |
|
| 92 |
if (root === 'color' && presetGroups[group]) { |
| 93 |
const { var: name, value } = presetGroups[group]; |
| 94 |
return presetSlugs(palettes, group).map((slug) => ({ |
| 95 |
selector: ':root', |
| 96 |
property: `--wp--preset--${name}--${wpKebab(slug)}`, |
| 97 |
read: (settings) => presetValue(settings, group, slug, value), |
| 98 |
})); |
| 99 |
} |
| 100 |
|
| 101 |
if (root !== 'custom') return null; |
| 102 |
|
| 103 |
return [ |
| 104 |
{ |
| 105 |
selector: ':root', |
| 106 |
property: `--wp--${path.map(wpKebab).join('--')}`, |
| 107 |
read: (settings) => valueAt(settings, path), |
| 108 |
}, |
| 109 |
]; |
| 110 |
}; |
| 111 |
|
| 112 |
const styleSlots = (path) => { |
| 113 |
if (path[0] === 'elements') { |
| 114 |
const [, element, ...rest] = path; |
| 115 |
const pseudo = rest[0]?.startsWith(':') ? rest[0] : ''; |
| 116 |
const property = cssProperties[rest.slice(pseudo ? 1 : 0).join('.')]; |
| 117 |
if (!property) return null; |
| 118 |
|
| 119 |
if (element !== 'heading') { |
| 120 |
return [ |
| 121 |
{ |
| 122 |
selector: `:root :where(${elementSelectors[element] ?? element})${pseudo}`, |
| 123 |
property, |
| 124 |
read: (styles) => valueAt(styles, path), |
| 125 |
}, |
| 126 |
]; |
| 127 |
} |
| 128 |
|
| 129 |
// Resolved per level so a theme's own h5 colour survives the reset. |
| 130 |
return headings.map((level) => ({ |
| 131 |
selector: `:root :where(${level})${pseudo}`, |
| 132 |
property, |
| 133 |
read: (styles) => |
| 134 |
valueAt(styles, ['elements', level, ...rest]) ?? |
| 135 |
valueAt(styles, ['elements', 'heading', ...rest]), |
| 136 |
})); |
| 137 |
} |
| 138 |
|
| 139 |
if (path[0] === 'blocks') { |
| 140 |
const [, block, ...rest] = path; |
| 141 |
const property = cssProperties[rest.join('.')]; |
| 142 |
if (!property) return null; |
| 143 |
|
| 144 |
return [ |
| 145 |
{ |
| 146 |
selector: `:root :where(${blockSelector(block)})`, |
| 147 |
property, |
| 148 |
read: (styles) => valueAt(styles, path), |
| 149 |
}, |
| 150 |
]; |
| 151 |
} |
| 152 |
|
| 153 |
const property = cssProperties[path.join('.')]; |
| 154 |
if (!property) return null; |
| 155 |
|
| 156 |
return [ |
| 157 |
{ |
| 158 |
selector: ':root :where(body)', |
| 159 |
property, |
| 160 |
read: (styles) => valueAt(styles, path), |
| 161 |
}, |
| 162 |
]; |
| 163 |
}; |
| 164 |
|
| 165 |
const paletteSlots = (palettes) => { |
| 166 |
const slots = []; |
| 167 |
const covered = []; |
| 168 |
|
| 169 |
for (const path of ownedPaletteSettingPaths(palettes)) { |
| 170 |
const built = settingSlots(path, palettes); |
| 171 |
if (built === null) continue; |
| 172 |
covered.push(`settings.${path.join('.')}`); |
| 173 |
slots.push(...built.map((slot) => ({ ...slot, section: 'settings' }))); |
| 174 |
} |
| 175 |
|
| 176 |
for (const path of ownedPaletteStylePaths(palettes)) { |
| 177 |
const built = styleSlots(path); |
| 178 |
if (built === null) continue; |
| 179 |
covered.push(`styles.${path.join('.')}`); |
| 180 |
slots.push(...built.map((slot) => ({ ...slot, section: 'styles' }))); |
| 181 |
} |
| 182 |
|
| 183 |
return { slots, covered }; |
| 184 |
}; |
| 185 |
|
| 186 |
export const paletteResetScope = (palettes) => |
| 187 |
paletteSlots(palettes).covered.sort(); |
| 188 |
|
| 189 |
// An older backend wraps this list by origin. |
| 190 |
export const paletteDuotone = (palette) => { |
| 191 |
const duotone = palette?.settings?.color?.duotone; |
| 192 |
return Array.isArray(duotone) ? duotone : duotone?.theme; |
| 193 |
}; |
| 194 |
|
| 195 |
const cssFromSlots = ({ slots, incoming, themeStyles, themeSettings }) => { |
| 196 |
const rules = new Map(); |
| 197 |
|
| 198 |
for (const { selector, property, section, read } of slots) { |
| 199 |
const settings = section === 'settings'; |
| 200 |
const value = read(settings ? incoming?.settings : incoming?.styles); |
| 201 |
const declarations = rules.get(selector) ?? []; |
| 202 |
|
| 203 |
declarations.push( |
| 204 |
`${property}:${ |
| 205 |
value === undefined |
| 206 |
? resetValue(read(settings ? themeSettings : themeStyles)) |
| 207 |
: cssValue(value) |
| 208 |
}`, |
| 209 |
); |
| 210 |
rules.set(selector, declarations); |
| 211 |
} |
| 212 |
|
| 213 |
let css = ''; |
| 214 |
for (const [selector, declarations] of rules) { |
| 215 |
css += `${selector}{${declarations.join(';')};}`; |
| 216 |
} |
| 217 |
|
| 218 |
return css; |
| 219 |
}; |
| 220 |
|
| 221 |
// A palette carries no compiled css, unlike a variation or a vibe. |
| 222 |
export const buildPaletteCss = ({ |
| 223 |
payloads, |
| 224 |
slug, |
| 225 |
themeStyles, |
| 226 |
themeSettings, |
| 227 |
}) => |
| 228 |
cssFromSlots({ |
| 229 |
slots: paletteSlots(payloads).slots, |
| 230 |
incoming: payloads?.[slug], |
| 231 |
themeStyles, |
| 232 |
themeSettings, |
| 233 |
}); |
| 234 |
|
| 235 |
// One slot pass for the whole set; per-slug calls make it quadratic. |
| 236 |
export const buildPaletteCssMap = ({ |
| 237 |
payloads, |
| 238 |
themeStyles, |
| 239 |
themeSettings, |
| 240 |
}) => { |
| 241 |
const { slots } = paletteSlots(payloads); |
| 242 |
|
| 243 |
return Object.fromEntries( |
| 244 |
Object.entries(payloads ?? {}).map(([slug, incoming]) => [ |
| 245 |
slug, |
| 246 |
cssFromSlots({ slots, incoming, themeStyles, themeSettings }), |
| 247 |
]), |
| 248 |
); |
| 249 |
}; |
| 250 |
|