| 1 |
import { deepMerge, isObject } from '@shared/lib/utils'; |
| 2 |
import { ownedSettingPaths, ownedStylePaths } from '@shared/lib/vibe-globals'; |
| 3 |
|
| 4 |
const headings = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6']; |
| 5 |
|
| 6 |
const kebab = (property) => |
| 7 |
property.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`); |
| 8 |
|
| 9 |
// Core's selectors for theme.json elements; heading resolves per level. |
| 10 |
const elementSelectors = { |
| 11 |
body: 'body', |
| 12 |
button: '.wp-element-button, .wp-block-button__link', |
| 13 |
caption: '.wp-element-caption', |
| 14 |
cite: 'cite', |
| 15 |
link: 'a:where(:not(.wp-element-button))', |
| 16 |
}; |
| 17 |
|
| 18 |
const blockSelector = (block) => { |
| 19 |
const [namespace, name] = block.split('/'); |
| 20 |
return namespace === 'core' |
| 21 |
? `.wp-block-${name}` |
| 22 |
: `.wp-block-${namespace}-${name}`; |
| 23 |
}; |
| 24 |
|
| 25 |
// Property paths that don't kebab straight into their css property. |
| 26 |
const propertyAliases = { |
| 27 |
'color.background': 'background-color', |
| 28 |
'color.text': 'color', |
| 29 |
'color.gradient': 'background', |
| 30 |
'filter.duotone': 'filter', |
| 31 |
shadow: 'box-shadow', |
| 32 |
'border.radius.topLeft': 'border-top-left-radius', |
| 33 |
'border.radius.topRight': 'border-top-right-radius', |
| 34 |
'border.radius.bottomLeft': 'border-bottom-left-radius', |
| 35 |
'border.radius.bottomRight': 'border-bottom-right-radius', |
| 36 |
}; |
| 37 |
|
| 38 |
// typography.fontSize is font-size, but border.radius keeps its group. |
| 39 |
const grouplessRoots = new Set(['typography', 'spacing', 'dimensions']); |
| 40 |
|
| 41 |
const cssProperty = (path) => |
| 42 |
propertyAliases[path.join('.')] ?? |
| 43 |
(grouplessRoots.has(path[0]) ? path.slice(1) : path).map(kebab).join('-'); |
| 44 |
|
| 45 |
const valueAt = (source, path) => |
| 46 |
path.reduce( |
| 47 |
(value, key) => (isObject(value) ? value[key] : undefined), |
| 48 |
source, |
| 49 |
); |
| 50 |
|
| 51 |
// theme.json stores preset references as var:preset|color|primary. |
| 52 |
const cssValue = (value) => |
| 53 |
typeof value === 'string' && value.startsWith('var:') |
| 54 |
? `var(--wp--${value.slice(4).split('|').map(kebab).join('--')})` |
| 55 |
: value; |
| 56 |
|
| 57 |
// revert alone would drop the theme's own value with the outgoing vibe's. |
| 58 |
const resetValue = (value) => |
| 59 |
typeof value === 'string' || typeof value === 'number' |
| 60 |
? cssValue(value) |
| 61 |
: 'revert'; |
| 62 |
|
| 63 |
const setDeclarations = (rules, key, text) => { |
| 64 |
for (const declaration of text.split(';')) { |
| 65 |
const [name, ...value] = declaration.split(':'); |
| 66 |
const property = name.trim(); |
| 67 |
if (!/^-?[a-zA-Z][-a-zA-Z]*$/.test(property) || !value.length) continue; |
| 68 |
const declarations = rules.get(key) ?? new Map(); |
| 69 |
declarations.set(property, value.join(':').trim()); |
| 70 |
rules.set(key, declarations); |
| 71 |
} |
| 72 |
}; |
| 73 |
|
| 74 |
const cssRuleMap = (css) => { |
| 75 |
const rules = new Map(); |
| 76 |
if (typeof css !== 'string') return rules; |
| 77 |
|
| 78 |
let topLevel = ''; |
| 79 |
let lastIndex = 0; |
| 80 |
for (const match of css.matchAll(/([^{};]*)\{([^{}]*)\}/g)) { |
| 81 |
topLevel += css.slice(lastIndex, match.index); |
| 82 |
lastIndex = match.index + match[0].length; |
| 83 |
setDeclarations(rules, match[1].trim().replace(/\s+/g, ' '), match[2]); |
| 84 |
} |
| 85 |
setDeclarations(rules, '', topLevel + css.slice(lastIndex)); |
| 86 |
|
| 87 |
return rules; |
| 88 |
}; |
| 89 |
|
| 90 |
// Core emits block custom css at raw specificity; a fully :where-wrapped reset loses. |
| 91 |
const nestedSelector = (blockSel, nested) => |
| 92 |
nested |
| 93 |
.split(',') |
| 94 |
.map((part) => { |
| 95 |
const trimmed = part.trim().replace(/\s+/g, ' '); |
| 96 |
if (!trimmed) return `:root :where(${blockSel})`; |
| 97 |
return trimmed.includes('&') |
| 98 |
? `:root ${trimmed.replace(/&/g, `:where(${blockSel})`)}` |
| 99 |
: `:root :where(${blockSel}) ${trimmed}`; |
| 100 |
}) |
| 101 |
.join(', '); |
| 102 |
|
| 103 |
const addSlot = (slots, selector, property, resolve) => |
| 104 |
slots.set(`${selector}|${property}`, { selector, property, resolve }); |
| 105 |
|
| 106 |
const addCssSlots = (slots, blockSel, css, path) => { |
| 107 |
for (const [nested, declarations] of cssRuleMap(css)) { |
| 108 |
const selector = nested |
| 109 |
? nestedSelector(blockSel, nested) |
| 110 |
: `:root :where(${blockSel})`; |
| 111 |
for (const property of declarations.keys()) { |
| 112 |
addSlot(slots, selector, property, (theme) => |
| 113 |
cssRuleMap(valueAt(theme, path)).get(nested)?.get(property), |
| 114 |
); |
| 115 |
} |
| 116 |
} |
| 117 |
}; |
| 118 |
|
| 119 |
const collectSlots = (slots, vibe) => { |
| 120 |
for (const path of ownedStylePaths({ vibe })) { |
| 121 |
if (path[0] === 'elements') { |
| 122 |
const [, element, ...rest] = path; |
| 123 |
// Resolved per level so a theme's own h5 value survives the reset. |
| 124 |
for (const level of element === 'heading' ? headings : [element]) { |
| 125 |
if (headings.includes(level)) { |
| 126 |
addSlot( |
| 127 |
slots, |
| 128 |
`:root :where(${level})`, |
| 129 |
cssProperty(rest), |
| 130 |
(theme) => |
| 131 |
valueAt(theme, ['elements', level, ...rest]) ?? |
| 132 |
valueAt(theme, ['elements', 'heading', ...rest]), |
| 133 |
); |
| 134 |
continue; |
| 135 |
} |
| 136 |
addSlot( |
| 137 |
slots, |
| 138 |
`:root :where(${elementSelectors[element] ?? element})`, |
| 139 |
cssProperty(rest), |
| 140 |
(theme) => valueAt(theme, path), |
| 141 |
); |
| 142 |
} |
| 143 |
continue; |
| 144 |
} |
| 145 |
|
| 146 |
if (path[0] === 'blocks') { |
| 147 |
const [, block, ...rest] = path; |
| 148 |
const selector = blockSelector(block); |
| 149 |
if (rest[rest.length - 1] === 'css') { |
| 150 |
addCssSlots(slots, selector, valueAt(vibe?.styles, path), path); |
| 151 |
continue; |
| 152 |
} |
| 153 |
addSlot(slots, `:root :where(${selector})`, cssProperty(rest), (theme) => |
| 154 |
valueAt(theme, path), |
| 155 |
); |
| 156 |
continue; |
| 157 |
} |
| 158 |
|
| 159 |
addSlot(slots, ':root :where(body)', cssProperty(path), (theme) => |
| 160 |
valueAt(theme, path), |
| 161 |
); |
| 162 |
} |
| 163 |
}; |
| 164 |
|
| 165 |
const customVarPaths = (vibes) => |
| 166 |
ownedSettingPaths(vibes).filter((path) => path[0] === 'custom'); |
| 167 |
|
| 168 |
// Core names the vars via _wp_to_kebab_case, which drops the ':' from :hover. |
| 169 |
const wpKebab = (key) => |
| 170 |
kebab(key) |
| 171 |
.replace(/[^a-z0-9-]+/gi, '-') |
| 172 |
.replace(/-+/g, '-') |
| 173 |
.replace(/^-|-$/g, ''); |
| 174 |
|
| 175 |
// Layout sizes are server-compiled fluid clamps, so settings resets stop at custom vars. |
| 176 |
const customVarResetCss = ({ payloads, slug, themeSettings }) => { |
| 177 |
const incoming = new Set( |
| 178 |
customVarPaths({ [slug]: payloads?.[slug] }).map((path) => path.join('.')), |
| 179 |
); |
| 180 |
|
| 181 |
const declarations = customVarPaths(payloads ?? {}) |
| 182 |
.filter((path) => !incoming.has(path.join('.'))) |
| 183 |
.map( |
| 184 |
(path) => |
| 185 |
`--wp--${path.map(wpKebab).join('--')}:${resetValue( |
| 186 |
valueAt(themeSettings, path), |
| 187 |
)}`, |
| 188 |
); |
| 189 |
|
| 190 |
return declarations.length ? `:root{${declarations.join(';')};}` : ''; |
| 191 |
}; |
| 192 |
|
| 193 |
// The applied fonts variation is the no-vibe baseline where it declares a value. |
| 194 |
export const withAppliedVariation = (theme, variation) => { |
| 195 |
const base = isObject(theme) ? theme : {}; |
| 196 |
return isObject(variation) ? deepMerge(base, variation) : base; |
| 197 |
}; |
| 198 |
|
| 199 |
// Css only adds, so an incoming vibe's silence cannot undo the applied vibe's |
| 200 |
// styles. Which vibe is applied is unknown here, so every vibe's slots get cleared. |
| 201 |
export const buildVibeResetCss = ({ |
| 202 |
payloads, |
| 203 |
slug, |
| 204 |
themeStyles, |
| 205 |
themeSettings, |
| 206 |
}) => { |
| 207 |
const owned = new Map(); |
| 208 |
for (const vibe of Object.values(payloads ?? {})) collectSlots(owned, vibe); |
| 209 |
|
| 210 |
const incoming = new Map(); |
| 211 |
if (payloads?.[slug]) collectSlots(incoming, payloads[slug]); |
| 212 |
|
| 213 |
const rules = new Map(); |
| 214 |
for (const [key, { selector, property, resolve }] of owned) { |
| 215 |
if (incoming.has(key)) continue; |
| 216 |
const declarations = rules.get(selector) ?? []; |
| 217 |
declarations.push(`${property}:${resetValue(resolve(themeStyles))}`); |
| 218 |
rules.set(selector, declarations); |
| 219 |
} |
| 220 |
|
| 221 |
let css = ''; |
| 222 |
for (const [selector, declarations] of rules) { |
| 223 |
css += `${selector}{${declarations.join(';')};}`; |
| 224 |
} |
| 225 |
|
| 226 |
return css + customVarResetCss({ payloads, slug, themeSettings }); |
| 227 |
}; |
| 228 |
|