Files
WP-LIT-MCP/src/Integrations/Divi/Design/ThemeOptionSpec.php
T

283 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
defined('ABSPATH') || exit;
/**
* The 20 theme options the plugin manages (ruling W2) with what Divi's customizer says about them
* (et_divi_customizer_theme_settings(), Divi 5.15.1): type, default, range, choices, inheritance. Divi's own
* sanitizers do not clamp (ruling W4), so check() is where min/max/step/choices are enforced. The font list is
* not checked here (FontCatalog does). Pure PHP – a copy of the customizer literals, guarded by the mirror
* "customizer-typography".
*/
final class ThemeOptionSpec
{
/** The whitelist in the order of ruling W2; books, diffs and the tool output use it. */
public const KEYS = [
'heading_font', 'body_font', 'primary_nav_font', 'secondary_nav_font',
'heading_font_weight', 'body_font_weight',
'body_font_size', 'tablet_body_font_size', 'phone_body_font_size',
'body_header_size', 'tablet_header_font_size', 'phone_header_font_size',
'body_font_height', 'body_header_height', 'body_header_spacing', 'body_header_style',
'content_width', 'gutter_width', 'section_padding', 'row_padding',
];
private const MAX_FONT_LENGTH = 100;
private const WEIGHTS = ['100', '200', '300', '400', '500', '600', '700', '800', '900'];
private const STYLES = ['italic', 'uppercase', 'underline'];
private const BODY_SIZE = ['type' => 'int', 'default' => 14, 'min' => 10, 'max' => 32, 'step' => 1];
private const HEADER_SIZE = ['type' => 'int', 'default' => 30, 'min' => 22, 'max' => 72, 'step' => 1];
private const LINE_HEIGHT = ['type' => 'float', 'min' => 0.8, 'max' => 3, 'step' => 0.1];
private const WEIGHT = ['type' => 'weight', 'default' => '500', 'choices' => self::WEIGHTS];
private const FONT = ['type' => 'font', 'default' => null];
/**
* Tablet/phone sizes inherit the next larger one while unset (ruling W3); their default is what Divi's CSS
* readers assume (14 / 30) when nothing is stored at all.
*/
private const DEFINITIONS = [
'heading_font' => ['label' => 'Überschrift-Schrift', ...self::FONT],
'body_font' => ['label' => 'Fließtext-Schrift', ...self::FONT],
'primary_nav_font' => ['label' => 'Hauptmenü-Schrift', ...self::FONT],
'secondary_nav_font' => ['label' => 'Nebenmenü-Schrift', ...self::FONT],
'heading_font_weight' => ['label' => 'Überschrift-Schriftstärke', ...self::WEIGHT],
'body_font_weight' => ['label' => 'Fließtext-Schriftstärke', ...self::WEIGHT],
'body_font_size' => ['label' => 'Fließtext-Schriftgröße', ...self::BODY_SIZE],
'tablet_body_font_size' => [
'label' => 'Fließtext-Schriftgröße (Tablet)', 'inherits' => 'body_font_size', ...self::BODY_SIZE,
],
'phone_body_font_size' => [
'label' => 'Fließtext-Schriftgröße (Smartphone)', 'inherits' => 'tablet_body_font_size',
...self::BODY_SIZE,
],
'body_header_size' => ['label' => 'Überschrift-Schriftgröße', ...self::HEADER_SIZE],
'tablet_header_font_size' => [
'label' => 'Überschrift-Schriftgröße (Tablet)', 'inherits' => 'body_header_size', ...self::HEADER_SIZE,
],
'phone_header_font_size' => [
'label' => 'Überschrift-Schriftgröße (Smartphone)', 'inherits' => 'tablet_header_font_size',
...self::HEADER_SIZE,
],
'body_font_height' => ['label' => 'Fließtext-Zeilenhöhe', 'default' => 1.7, ...self::LINE_HEIGHT],
'body_header_height' => ['label' => 'Überschrift-Zeilenhöhe', 'default' => 1.0, ...self::LINE_HEIGHT],
'body_header_spacing' => [
'type' => 'int', 'label' => 'Überschrift-Buchstabenabstand', 'default' => 0,
'min' => -2, 'max' => 10, 'step' => 1,
],
'body_header_style' => [
'type' => 'style', 'label' => 'Überschrift-Schriftstil', 'default' => null, 'choices' => self::STYLES,
],
'content_width' => [
'type' => 'int', 'label' => 'Inhaltsbreite', 'default' => 1080, 'min' => 960, 'max' => 1920, 'step' => 1,
],
'gutter_width' => [
'type' => 'int', 'label' => 'Spaltenabstand', 'default' => 3, 'min' => 1, 'max' => 4, 'step' => 1,
],
'section_padding' => [
'type' => 'int', 'label' => 'Abschnitt-Innenabstand', 'default' => 4, 'min' => 0, 'max' => 10, 'step' => 1,
],
'row_padding' => [
'type' => 'int', 'label' => 'Zeile-Innenabstand', 'default' => 2, 'min' => 0, 'max' => 10, 'step' => 1,
],
];
/**
* @return array{type: 'int'|'float'|'font'|'weight'|'style', label: string, default: int|float|string|null,
* min?: int|float, max?: int|float, step?: int|float, choices?: list<string>, inherits?: string}
* @throws InvalidArgumentException For a key outside the whitelist.
*/
public static function definition(string $key): array
{
return self::DEFINITIONS[$key] ?? throw new InvalidArgumentException(
sprintf('Unbekannte Theme-Option "%s".', $key)
);
}
public static function isKey(string $key): bool
{
return isset(self::DEFINITIONS[$key]);
}
/**
* Type, range, step and choices of $value; null = valid, else a German message. null as a value is "unset" –
* the caller handles it before check() – and counts as invalid here. "none" and false are no font (ruling W3).
*/
public static function check(string $key, mixed $value): ?string
{
$definition = self::definition($key);
$label = $definition['label'];
return match ($definition['type']) {
'int', 'float' => self::checkNumber($label, $definition, $value),
'font' => self::checkFont($label, $value),
default => self::checkChoice($label, $definition['choices'] ?? [], $value, $definition['type']),
};
}
/**
* The value in the type Divi stores: int, float, weight as string, font and style as trimmed string.
* Only meaningful for a value check() accepted.
*/
public static function normalize(string $key, mixed $value): int|float|string
{
return match (self::definition($key)['type']) {
'int' => (int) self::number($value),
'float' => (float) self::number($value),
'weight' => (string) (int) self::number($value),
default => trim((string) (is_scalar($value) ? $value : '')),
};
}
/**
* What Divi uses for $key: the stored value, else (while unset) the effective value of the key it inherits from
* (ruling W3), else Divi's default; null when there is neither (a font or style left at Divi's default).
* A stored value that is false, null or no usable value counts as unset.
*
* @param array<string, mixed> $stored
*/
public static function effective(string $key, array $stored): int|float|string|null
{
$definition = self::definition($key);
$value = self::usable($definition['type'], $stored[$key] ?? null);
if ($value !== null) {
return $value;
}
return isset($definition['inherits'])
? self::effective($definition['inherits'], $stored)
: $definition['default'];
}
/** @param array<string, mixed> $definition */
private static function checkNumber(string $label, array $definition, mixed $value): ?string
{
$number = self::number($value);
if ($number === null || ! is_finite((float) $number)) {
return sprintf('%s: erwartet eine Zahl, erhalten %s.', $label, self::describe($value));
}
if ($definition['type'] === 'int' && floor((float) $number) !== (float) $number) {
return sprintf('%s: erwartet eine ganze Zahl, erhalten %s.', $label, self::describe($value));
}
$min = $definition['min'];
$max = $definition['max'];
if ($number < $min || $number > $max) {
return sprintf(
'%s: %s liegt außerhalb von %s bis %s.',
$label,
self::decimal($number),
self::decimal($min),
self::decimal($max)
);
}
$steps = ($number - $min) / $definition['step'];
if (abs($steps - round($steps)) > 1e-6) {
return sprintf(
'%s: %s passt nicht zur Schrittweite %s (ab %s).',
$label,
self::decimal($number),
self::decimal($definition['step']),
self::decimal($min)
);
}
return null;
}
private static function checkFont(string $label, mixed $value): ?string
{
if (! is_string($value)) {
return sprintf('%s: erwartet einen Schriftnamen (Text), erhalten %s.', $label, self::describe($value));
}
$name = trim($value);
if ($name === '' || mb_strlen($name) > self::MAX_FONT_LENGTH) {
return sprintf('%s: der Schriftname muss 1 bis %d Zeichen lang sein.', $label, self::MAX_FONT_LENGTH);
}
if (preg_match('/[\x00-\x1f\x7f<>"\'`]/u', $name) === 1 || ! mb_check_encoding($name, 'UTF-8')) {
return sprintf('%s: der Schriftname enthält unzulässige Zeichen.', $label);
}
if (strtolower($name) === 'none') {
return sprintf('%s: "none" ist kein Schriftname; null setzt auf Divis Standardschrift zurück.', $label);
}
return null;
}
/** @param list<string> $choices */
private static function checkChoice(string $label, array $choices, mixed $value, string $type): ?string
{
$text = match (true) {
is_string($value) => $value,
is_int($value) && $type === 'weight' => (string) $value,
default => null,
};
if ($text === null || ! in_array($text, $choices, true)) {
return sprintf(
'%s: erlaubt ist genau ein Wert aus %s, erhalten %s.',
$label,
implode(', ', $choices),
self::describe($value)
);
}
return null;
}
/** The number in $value (int, float or a plain decimal string), else null. */
private static function number(mixed $value): int|float|null
{
return match (true) {
is_int($value), is_float($value) => $value,
is_string($value) && preg_match('/^-?\d+(\.\d+)?$/D', $value) === 1 => $value + 0,
default => null,
};
}
/** A stored value in the type of $type, or null when it is not usable (false, empty, garbage). */
private static function usable(string $type, mixed $value): int|float|string|null
{
if ($type === 'font' || $type === 'style') {
return is_string($value) && trim($value) !== '' && strtolower(trim($value)) !== 'none'
? trim($value)
: null;
}
$number = self::number($value);
if ($number === null) {
return null;
}
return match ($type) {
'int' => (int) $number,
'float' => (float) $number,
default => (string) (int) $number,
};
}
private static function decimal(int|float $number): string
{
return str_replace('.', ',', (string) $number);
}
private static function describe(mixed $value): string
{
return match (true) {
is_string($value) => '"' . mb_substr($value, 0, 40) . '"',
is_bool($value) => $value ? 'true' : 'false',
$value === null => 'null',
is_int($value), is_float($value) => var_export($value, true),
default => 'einen ' . get_debug_type($value),
};
}
}