['base', 'background', 'white', 'base-2', 'light', 'ast-global-color-4', 'theme-palette9', 'palette-color-8'], 'text' => ['contrast', 'text', 'foreground', 'black', 'dark', 'contrast-2', 'ast-global-color-3', 'theme-palette4', 'palette-color-3'], 'accent' => ['primary', 'accent', 'brand', 'accent-1', 'theme-1', 'link', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'], 'button_bg' => ['primary', 'accent', 'brand', 'contrast', 'accent-1', 'theme-1', 'ast-global-color-0', 'theme-palette1', 'palette-color-1'], ]; /** * Request-level cache for the normalised palette. * * @var array|null */ protected static $cachedPalette = null; /** * Request-level cache for the resolved roles. * * @var array|null */ protected static $cachedRoles = null; /** * Request-level cache for the vendor-declared property values. * * @var array|null */ protected static $cachedVendorValues = null; /** * Request-level cache for the roles the theme's own settings state. * * @var array|null */ protected static $cachedSettingsRoles = null; /** * @var array|null */ protected static $cachedRadii = null; /** * Whether the site switched the theme's settings reader off: a reader * stated colours and `fluent_cart/theme/settings_roles` returned none. * Set by settingsRoles(). * * @var bool */ protected static $settingsReaderOff = false; /** * Theme settings readers, keyed by the theme name passed to the * `fluent_cart/theme/settings_roles` filter. The first that applies wins. * * Only one theme runs on a site, so the order only matters when several * vendors' functions are loaded at once (the test stubs). The stricter * checks go first: Blocksy's and Kadence's each need a live instance from * the vendor's API, Divi's needs the `$shortname` global to name Divi, and * Bricks' needs its running `Bricks\Theme` singleton, and GeneratePress's * needs its dynamic-CSS printer hooked on `wp_enqueue_scripts`, none of * which a loaded-but-idle stub provides, while Astra's is a bare * `function_exists()` that stays true once its stub is loaded — so Astra * is asked last. Among the strict ones the order is arbitrary. * * @var array */ protected static $settingsReaders = [ 'blocksy' => BlocksySettingsReader::class, 'kadence' => KadenceSettingsReader::class, 'divi' => DiviSettingsReader::class, 'bricks' => BricksSettingsReader::class, 'generatepress' => GeneratePressSettingsReader::class, 'astra' => AstraSettingsReader::class, ]; /** * The roles a theme settings reader may state. * * @var array */ protected static $settingsRoleKeys = [ 'surface', 'text', 'accent', 'border', 'button_bg', 'button_text', 'button_hover_bg', 'button_hover_text', ]; /** * Drop the request-level caches. * * Reading theme.json and resolving eleven roles is repeated work within a * request, so both are cached. Anything that changes what those reads would * return — switching theme, editing the palette, a filter registered after * a first read — has to clear them or it keeps seeing the old palette. * * @return void */ public static function clearCache(): void { self::$cachedPalette = null; self::$cachedRoles = null; self::$cachedVendorValues = null; self::$cachedSettingsRoles = null; self::$cachedRadii = null; self::$settingsReaderOff = false; } /** * The active theme's colour palette, normalised to hex. * * @return array List of ['slug' => ..., 'name' => ..., 'color' => ...]. */ public static function palette(): array { if (self::$cachedPalette !== null) { return self::$cachedPalette; } if (!function_exists('wp_get_global_settings')) { self::$cachedPalette = []; return self::$cachedPalette; } $raw = wp_get_global_settings(['color', 'palette']); self::$cachedPalette = is_array($raw) ? self::normalizePalette($raw) : []; return self::$cachedPalette; } /** * Flatten whatever shape wp_get_global_settings() returned. * * Depending on the WordPress version this is either a flat list or one * list per origin. The `default` origin is WordPress's own twelve-colour * palette — black, white and the vivid-* set — which every site has * whether or not its theme declares anything. Inheriting from it would * not be inheriting from the theme, and it would let a theme that * publishes nothing report a palette it does not have, so only the * theme's own colours and the user's customisations of them are read. * * @param array $raw * @return array */ protected static function normalizePalette(array $raw): array { $groups = []; if (isset($raw['default']) || isset($raw['theme']) || isset($raw['custom'])) { // `custom` comes last so a colour edited in the site editor wins // over the theme's declared value for the same slug. foreach (['theme', 'custom'] as $origin) { $originColors = Arr::get($raw, $origin, []); if (is_array($originColors) && $originColors) { $groups[] = $originColors; } } } else { $groups[] = $raw; } $entries = []; foreach ($groups as $colors) { foreach ($colors as $color) { $slug = Arr::get($color, 'slug', ''); if (!$slug) { continue; } $entries[$slug] = [ 'slug' => (string)$slug, 'name' => (string)Arr::get($color, 'name', $slug), 'color' => (string)Arr::get($color, 'color', ''), ]; } } /** * Filter the theme palette offered for colour inheritance. * * @param array $entries List of ['slug', 'name', 'color']. */ $entries = apply_filters('fluent_cart/theme/palette', array_values($entries)); return self::usableEntries($entries); } /** * Keep only the entries that name a slug and a colour we can actually read. * * theme.json is not restricted to hex, and several popular themes publish * their palette as `var(--theme-colour-0)` references that cannot be * resolved server-side. Those are dropped rather than guessed: an * unresolvable value handed on as if it were a colour would be mixed into * the derived tones, and a muted text colour mixed from nothing collapses * to the surface it sits on — invisible text rather than an obvious error. * * Applied after the filter as well as before it, because a filter is just * another source of values and gets no more trust than theme.json does. * * @param mixed $entries * @return array */ protected static function usableEntries($entries): array { if (!is_array($entries)) { return []; } $usable = []; foreach ($entries as $entry) { if (!is_array($entry)) { continue; } $slug = Arr::get($entry, 'slug', ''); $raw = (string)Arr::get($entry, 'color', ''); $hex = ColorMath::hex($raw); // A literal colour is preferred because it can also be mixed and // measured. A reference cannot be resolved here, but the browser // resolves it perfectly well, so it is kept as something we can // write — and when it carries a hex fallback, that fallback is the // colour it can be measured as too. $value = $hex !== '' ? $hex : self::safeReference($raw); // A bare reference to a property the active vendor itself declares // is only unreadable to an outsider: the vendor prints it from its // own settings, and those are one function call away. Attaching // that value as the fallback turns the reference into the // measurable shape — the browser still follows the live property, // the fallback is only what it is measured as here. if ($hex === '' && $value !== '' && self::measurable($value) === '') { $property = substr($value, 4, -1); $vendorHex = (string)Arr::get(self::vendorValues(), $property, ''); if ($vendorHex !== '') { $value = 'var(' . $property . ', ' . $vendorHex . ')'; } } if (!$slug || $value === '') { continue; } $usable[(string)$slug] = [ 'slug' => (string)$slug, 'name' => (string)Arr::get($entry, 'name', $slug), 'color' => self::measurable($value), 'value' => $value, ]; } return array_values($usable); } /** * The current values of the properties the active vendor declares, keyed * by property name. * * Each mapping reads the same source the vendor prints the property from, * so customisations are included — the customiser saves into the very * option being read. Astra prints `--ast-global-color-N` from * `astra_get_option('global-color-palette')` (its * `generate_global_palette_style()`), and Kadence prints * `--global-paletteN` from `kadence()->palette_option('paletteN')` (its * styles component; see KadenceSettingsReader::paletteValues()). * Blocksy's editor palette already ships with hex fallbacks, but its Customizer settings point at the bare * `--theme-palette-color-N`, so its palette is read too, from * `blocksy_manager()->colors->get_color_palette()` (the list it prints * the properties from; see BlocksySettingsReader::paletteValues()). * Bricks prints `--bricks-color-{id}` from its colour palette (or the * property a palette colour's raw value names; see * BricksSettingsReader::paletteValues()). GeneratePress prints `--{slug}` * on :root from its Global Colors and publishes them to the editor * palette as bare `var(--{slug})` (see * GeneratePressSettingsReader::paletteValues()). * * The values are only ever used as the fallback half of `var(--x, #hex)`, * so a wrong or stale answer cannot repaint anything — the browser keeps * resolving the live property — it can only mis-measure, which is where * resolve() refusing an unmeasurable button still protects the store. * * @return array Property => hex. */ protected static function vendorValues(): array { if (self::$cachedVendorValues !== null) { return self::$cachedVendorValues; } $values = []; if (function_exists('astra_get_option')) { $palette = astra_get_option('global-color-palette'); $colors = is_array($palette) ? Arr::get($palette, 'palette', []) : []; foreach ((array)$colors as $index => $color) { $values['--ast-global-color-' . $index] = (string)$color; } } foreach (BlocksySettingsReader::paletteValues() as $property => $color) { $values[$property] = $color; } foreach (KadenceSettingsReader::paletteValues() as $property => $color) { $values[$property] = $color; } foreach (BricksSettingsReader::paletteValues() as $property => $color) { $values[$property] = $color; } foreach (GeneratePressSettingsReader::paletteValues() as $property => $color) { $values[$property] = $color; } /** * Filter the vendor-declared custom property values used to measure * bare palette references, keyed by property name (`--x` => `#hex`). * * @param array $values Property => colour. */ $values = apply_filters('fluent_cart/theme/vendor_values', $values); // A filter is just another source of values: only a real property // name paired with a real hex survives. Kadence's palette10 can be an // oklch() expression, which this drops too — an expression cannot be // measured, and it must never ride into a declaration as a fallback. $clean = []; if (is_array($values)) { foreach ($values as $property => $color) { $colorHex = ColorMath::hex((string)$color); if ($colorHex !== '' && preg_match('/^--[A-Za-z0-9_-]+$/', (string)$property)) { $clean[(string)$property] = $colorHex; } } } return self::$cachedVendorValues = $clean; } /** * Accept a bare custom-property reference, and nothing more. * * Themes built on the page-builder stack — Astra, Kadence, GeneratePress — * publish their palette as `var(--ast-global-color-0)` rather than as a * literal, because the real value lives in their own settings and is * emitted as a custom property at runtime. Refusing those makes theme * inheritance do nothing on a large share of real stores. * * Only the single-argument form is accepted: no fallback expression, no * nesting, no parentheses beyond the one pair. This string is written into * a declaration on every storefront page, so the grammar is kept narrow * enough that nothing else can ride along inside it. * * @param string $value * @return string The normalised reference, or '' when it is not one. */ protected static function safeReference($value): string { $value = trim((string)$value); if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*\)$/', $value, $matches)) { return 'var(' . $matches[1] . ')'; } // One fallback shape is allowed, and only one: a hex colour. Customify // publishes its whole palette as `var(--customify-primary, #0e7c7b)` — // the reference follows the customiser live, and the fallback is the // one kind of fallback that is itself checkable. `red`, expressions and // nested var() stay refused. if (preg_match('/^var\(\s*(--[A-Za-z0-9_-]+)\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) { return 'var(' . $matches[1] . ', ' . strtolower($matches[2]) . ')'; } return ''; } /** * The colour a value can actually be measured as. * * A hex is itself. A reference with a hex fallback is measured as the * fallback — the theme shipped it as the value to use when the property is * missing, which makes it the theme's own best answer for what the colour * is. A bare reference measures as nothing. * * @param string $value * @return string Hex, or ''. */ public static function measurable($value): string { $value = (string)$value; $hex = ColorMath::hex($value); if ($hex !== '') { return $hex; } if (preg_match('/^var\(\s*--[A-Za-z0-9_-]+\s*,\s*(#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6}))\s*\)$/', $value, $matches)) { return strtolower($matches[1]); } return ''; } /** * Normalise a colour a theme setting states into a value we can write. * * A hex is itself, lowercased. A custom-property reference stays a live * reference; a bare one gains the vendor's current value as its hex * fallback (see vendorValues()), so the browser still follows the * property while PHP measures the fallback. Anything else — rgba(), * named colours, expressions — is refused: the result is written into a * declaration on every storefront page and must pass * FrontendTheme::sanitizeDeclarationValue(). * * @param mixed $raw * @return string Hex, `var(--x)`, `var(--x, #hex)`, or ''. */ public static function settingValue($raw): string { if (!is_string($raw)) { return ''; } $raw = trim($raw); if (preg_match('/^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/', $raw)) { return ColorMath::hex($raw); } $reference = self::safeReference($raw); if ($reference === '' || self::measurable($reference) !== '') { return $reference; } $property = substr($reference, 4, -1); $vendorHex = (string)Arr::get(self::vendorValues(), $property, ''); return $vendorHex !== '' ? 'var(' . $property . ', ' . $vendorHex . ')' : $reference; } /** * The colours the active theme's own settings state, by role. * * Palette slots say which colours a theme offers; its settings say which * one the owner put where. A reader for the active theme (see * Readers\ThemeSettingsReader) reports what the owner set — Astra's, * Blocksy's, Kadence's, Divi's, Bricks' or GeneratePress's Accent, Body Text, Content Background, Borders * and Button colours — and those outrank the palette-slot guesses in anchors() and resolve(). * Only the site editor's own button (buttonGlobals()) outranks them. * * Roles: surface, text, accent, border, button_bg, button_text, * button_hover_bg, button_hover_text. A role the theme does not state is * absent, and is derived exactly as it would be without a reader. * * @return array Role => hex or reference. */ public static function settingsRoles(): array { if (self::$cachedSettingsRoles !== null) { return self::$cachedSettingsRoles; } $roles = []; $theme = ''; foreach (self::$settingsReaders as $name => $reader) { if ($reader::applies()) { $theme = $name; $roles = $reader::roles(); break; } } $stated = $roles; /** * Filter the colours the active theme's settings state, by role. * * Runs whether or not a built-in reader applied, so a theme FluentCart * does not read can be supplied here; return [] to turn the reader off * and fall back to palette slots. Values must be a hex or a * `var(--x)` / `var(--x, #hex)` reference — anything else is dropped, * and a bare reference to a vendor property gains its hex fallback. * * @param array $roles Role => colour. Keys: surface, text, accent, * border, button_bg, button_text, * button_hover_bg, button_hover_text. * @param array $context ['theme' => reader name ('blocksy', 'kadence', 'divi', 'bricks', 'generatepress', 'astra'), or '' when * no built-in reader applies]. */ $roles = apply_filters('fluent_cart/theme/settings_roles', $roles, [ 'theme' => $theme, ]); // A filter is just another source of values: only known roles with a // writable colour survive. $clean = []; if (is_array($roles)) { foreach (self::$settingsRoleKeys as $role) { $value = self::settingValue(Arr::get($roles, $role, '')); if ($value !== '') { $clean[$role] = $value; } } } // Returning [] for a reader that stated something is the documented // way to switch it off; radii() honours that for the same reader. self::$settingsReaderOff = !empty($stated) && is_array($roles) && $roles === []; return self::$cachedSettingsRoles = $clean; } /** * The border radii the active theme states, by role. * * theme.json speaks first, as it does for the button colours * (buttonGlobals()): the button element, else the Button block, and the * text-input element, else select, else the Search block — the owner's * site editor and picked style variation over the theme's own, see * radiusGlobals(). Then the first applying settings reader that * implements Readers\ThemeRadiusReader fills the roles the site editor * left unstated — unless the site switched that reader off by returning * [] from `fluent_cart/theme/settings_roles`, which turns off its radii * too. A role nothing states is absent, and FluentCart prints nothing * for it. * * Roles: card, btn, input (RadiusPalette::roles()). Every value is one * length in RadiusPalette::sanitizeLength()'s grammar; anything else is * dropped rather than guessed. * * @return array Role => length (`0`, `8px`, `0.5rem`, `1em`). */ public static function radii(): array { if (self::$cachedRadii !== null) { return self::$cachedRadii; } $radii = self::radiusGlobals(); $readerName = ''; // A reader the site switched off through `fluent_cart/theme/settings_roles` // states no radius either. The site editor is not a reader and still counts. self::settingsRoles(); $readers = self::$settingsReaderOff ? [] : self::$settingsReaders; foreach ($readers as $name => $reader) { if (!$reader::applies() || !is_subclass_of($reader, ThemeRadiusReader::class)) { continue; } $readerName = $name; foreach ((array)$reader::radii() as $role => $value) { if (!isset($radii[$role])) { $radii[$role] = $value; } } break; } /** * Filter the border radii the active theme states, by role. * * Runs whether or not a built-in reader applied, so a theme FluentCart * does not read can be supplied here; return [] to state none. Every * value is normalised again: only `card`, `btn` and `input` survive, * each as one length (`0`, or a number in px, rem or em). * * @param array $radii Role => length. Keys: card, btn, input. * @param array $context ['theme' => the active template's slug, * 'reader' => the reader that applied ('blocksy', 'kadence', * 'divi', 'bricks', 'astra'), or '' when none did]. */ $radii = apply_filters('fluent_cart/theme/radius_roles', $radii, [ 'theme' => get_template(), 'reader' => $readerName, ]); $clean = []; if (is_array($radii)) { foreach (array_keys(RadiusPalette::roles()) as $role) { $value = RadiusPalette::sanitizeLength(Arr::get($radii, $role, '')); if ($value !== '') { $clean[$role] = $value; } } } return self::$cachedRadii = $clean; } /** * The radii theme.json states, from the `theme` and `user` origins — the * owner's (the site editor, or a style variation they picked, both saved * in the user origin) over the theme's. Core's origin is left out, as in * buttonGlobals(). * * Within one origin each role reads a chain, the closest match first: * - btn: the button element (`styles.elements.button`, what a plain * `