feat: ThemeOptionSpec – die 20 Theme-Optionen mit Prüfung, Normalisierung und Vererbung (W2–W4)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
a90cd7f5bd
commit
a9edd850c1
@@ -0,0 +1,282 @@
|
||||
<?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+)?$/', $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),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user