feat: Fähigkeiten theme-option-read/-write und font-upload; ThemeOptionsAdapter und FontCatalog (Manifest, Nachbau customizer-typography, Signatur-Referenzen) und DesignRights::themeOptions()/fonts()

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Jan-Niclas Loosen
2026-10-19 17:44:57 +02:00
co-authored by Claude Sonnet 5.5
parent 8379c55c89
commit a90cd7f5bd
14 changed files with 4765 additions and 26 deletions
+60 -9
View File
@@ -13,11 +13,15 @@ defined('ABSPATH') || exit;
/**
* Who may change the design system (spec §10): presets and colors need edit_theme_options and Divi's role right
* "Visual Builder verwenden" – what Divi's own preset sync asks since 5.15 (formats "Nachtrag 5.15.1"); variables
* also the role right "Variables Manager". Without unfiltered_html a write is refused while presets it saves along
* hold HTML/CSS that Divi would strip (final review I-2).
* also the role right "Variables Manager". Theme options need edit_theme_options and the role right
* "Theme-Anpassung" (what Divi's customizer asks), font uploads upload_files, edit_theme_options and the role right
* "Eigene Schriften verwalten" (Divi's font route plus the plugin's rule, plan 4d). Without unfiltered_html a write is
* refused while presets it saves along hold HTML/CSS that Divi would strip (final review I-2).
*/
final class DesignRights
{
private const ROLE_EDITOR = 'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.';
public const OTHER_MARKUP = 'Andere Presets enthalten HTML/CSS, das beim Speichern ohne das Recht unfiltered_html '
. 'verloren ginge; nichts wurde gespeichert.';
@@ -70,18 +74,52 @@ final class DesignRights
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt den Variablen-Manager nicht; ohne ihn speichert Divi keine '
. 'Variablen.',
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
self::ROLE_EDITOR
);
}
/**
* Theme options (writing and restoring): edit_theme_options and Divi's role right "Theme-Anpassung". $writer is
* not asked – Divi's customizer needs no Visual Builder; it keeps the signature of the other design rights.
*/
public static function themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options): ?WP_Error
{
$refusal = self::themeRight('Theme-Optionen ändern');
if ($refusal !== null || $options->canCustomize()) {
return $refusal;
}
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt die Theme-Anpassung nicht; ohne sie ändert LIT MCP keine '
. 'Theme-Optionen.',
self::ROLE_EDITOR
);
}
/** Font uploads: upload_files, edit_theme_options and Divi's role right "Eigene Schriften verwalten". */
public static function fonts(FontCatalog $fonts): ?WP_Error
{
if (! current_user_can('upload_files')) {
return Errors::forbidden('Schriften hochladen erfordert das Recht, Dateien hochzuladen (upload_files).');
}
$refusal = self::themeRight('Schriften hochladen');
if ($refusal !== null || $fonts->canManage()) {
return $refusal;
}
return Errors::forbidden(
'Die Divi-Rolle dieses Kontos erlaubt „Eigene Schriften verwalten“ nicht; ohne das Recht lädt LIT MCP '
. 'keine Schriften hoch.',
self::ROLE_EDITOR
);
}
/** edit_theme_options and Divi's role right "Visual Builder verwenden"; $what names the area in the message. */
private static function designer(DesignSystemWriter $writer, string $what): ?WP_Error
{
if (! current_user_can('edit_theme_options')) {
return Errors::forbidden(sprintf(
'%s ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).',
$what
));
$refusal = self::themeRight($what . ' ändern');
if ($refusal !== null) {
return $refusal;
}
if (! $writer->canUseVisualBuilder()) {
return Errors::forbidden(
@@ -89,10 +127,23 @@ final class DesignRights
'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine %s.',
$what
),
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
self::ROLE_EDITOR
);
}
return null;
}
/** edit_theme_options; $activity starts the message ("Presets ändern", "Schriften hochladen"). */
private static function themeRight(string $activity): ?WP_Error
{
if (current_user_can('edit_theme_options')) {
return null;
}
return Errors::forbidden(sprintf(
'%s erfordert das Recht, das Design der Website anzupassen (edit_theme_options).',
$activity
));
}
}
@@ -0,0 +1,95 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3) for Divi's font lists (plan 4d W5, W9): the families Divi's customizer sanitizer
* accepts (uploaded fonts + et_builder_get_fonts(), exact names), the uploaded ones, and every reserved name for an
* upload, plus Divi's role right "Eigene Schriften verwalten". et_builder_get_google_fonts() parses about 1.3 MB and
* may fetch from Google once a day, so names() is loaded once per instance – DiviIntegration builds one per request;
* uploaded() and reserved() are read fresh (an upload changes them). Not final so the tools can be tested with a mock.
*/
class FontCatalog implements DiviAdapter
{
/** @var list<string>|null */
private ?array $names = null;
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call('et_builder_get_fonts', 0),
new Call('et_builder_get_custom_fonts', 0),
new Call('et_builder_get_google_fonts', 0),
new Call('et_builder_get_websafe_fonts', 0),
new Call('et_pb_is_allowed', 1, [DiviFeature::FontUpload]),
],
// The options behind Divi's lists; read only through the functions above.
keys: ['et_uploaded_fonts', 'et_google_fonts_cache', 'et_google_api_settings'],
// Plan 4d W1: font variables are checked against the same list, so a break locks all three on purpose.
features: [DiviFeature::ThemeOptionWrite, DiviFeature::FontUpload, DiviFeature::VariableWrite]
);
}
/** @return list<string> Exact family names Divi's sanitizer accepts: uploaded, then web-safe and Google. */
public function names(): array
{
return $this->names ??= self::namesOf(et_builder_get_custom_fonts(), et_builder_get_fonts());
}
/** @return list<string> */
public function uploaded(): array
{
return self::namesOf(et_builder_get_custom_fonts());
}
/** @return list<string> Google, web-safe and uploaded names – an upload must not reuse one (W9). */
public function reserved(): array
{
return self::namesOf(
et_builder_get_google_fonts(),
et_builder_get_websafe_fonts(),
et_builder_get_custom_fonts()
);
}
public function has(string $name): bool
{
return in_array($name, $this->names(), true);
}
/** Divi's role right "Eigene Schriften verwalten" (custom_fonts_management); fail closed without Divi. */
public function canManage(): bool
{
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('custom_fonts_management');
}
/**
* @param mixed ...$lists Divi's lists (family => settings); anything else (a broken filter) is skipped.
* @return list<string>
*/
private static function namesOf(mixed ...$lists): array
{
$names = [];
foreach ($lists as $list) {
if (! is_array($list)) {
continue;
}
foreach (array_keys($list) as $name) {
$names[] = (string) $name;
}
}
return array_values(array_unique($names));
}
}
@@ -0,0 +1,187 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Design;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapter;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\DiviLocked;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Integrations\Divi\Release\Mirror;
use LIT\MCP\Integrations\Divi\Release\ReleaseGate;
use RuntimeException;
defined('ABSPATH') || exit;
/**
* Adapter "Designsystem" (spec §3) for the whitelisted theme options of Divi's customizer (plan 4d W2, W6): reads
* them raw from get_option('et_divi'), sends a value through the customizer's own sanitizer of its setting, and
* writes the way a customizer save ends – et_update_option()/et_delete_option() per key, then the static CSS and the
* builder caches (research A.2/A.3). Limits (W13): et_update_option() writes the whole et_divi array from Divi's
* request global, so a change another process made after this request loaded it is lost; design_rev and the lock of
* the pipeline narrow that window. remove_static_resources() does nothing without a logged-in user. Not final so the
* theme option area can be tested with a mock.
*/
class ThemeOptionsAdapter implements DiviAdapter
{
/** Divi's theme options (one array option). */
private const OPTIONS = 'et_divi';
/**
* The 20 whitelist keys (plan 4d W2, in this order) => the sanitize_callback of their setting in
* et_divi_customizer_theme_settings() (mirror customizer-typography). sanitize() calls it by this name; every
* Divi name here is a call of the manifest (ThemeOptionsAdapterTest), absint is WordPress core.
*/
public const SANITIZERS = [
'heading_font' => 'et_sanitize_font_choices',
'body_font' => 'et_sanitize_font_choices',
'primary_nav_font' => 'et_sanitize_font_choices',
'secondary_nav_font' => 'et_sanitize_font_choices',
'heading_font_weight' => 'et_sanitize_global_font_weight',
'body_font_weight' => 'et_sanitize_global_font_weight',
'body_font_size' => 'absint',
'tablet_body_font_size' => 'absint',
'phone_body_font_size' => 'absint',
'body_header_size' => 'absint',
'tablet_header_font_size' => 'absint',
'phone_header_font_size' => 'absint',
'body_font_height' => 'et_sanitize_float_number',
'body_header_height' => 'et_sanitize_float_number',
'body_header_spacing' => 'et_sanitize_int_number',
'body_header_style' => 'et_divi_global_font_style',
'content_width' => 'absint',
'gutter_width' => 'absint',
'section_padding' => 'absint',
'row_padding' => 'absint',
];
public static function manifest(): Manifest
{
return new Manifest(
Manifest::DESIGN,
calls: [
new Call('et_sanitize_font_choices', 1),
new Call('et_sanitize_global_font_weight', 1),
new Call('et_sanitize_float_number', 1),
new Call('et_sanitize_int_number', 1),
new Call('et_divi_global_font_style', 1),
new Call('et_update_option', 2),
new Call('et_delete_option', 1),
new Call('ET_Core_PageResource::remove_static_resources', 2),
new Call('et_pb_force_regenerate_templates', 0),
new Call('et_pb_is_allowed', 1),
],
keys: [self::OPTIONS],
mirrors: [
// shortcut: the fingerprint covers the whole customizer function (about 4,000 lines) and changes with
// every customizer edit; switch to a narrower excerpt when it fires too often.
new Mirror(
'customizer-typography',
'functions.php',
'et_divi_customizer_theme_settings',
null,
'Standards, Bereiche und Sanitizer der 20 Theme-Optionen (ThemeOptionSpec)',
[DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite]
),
],
features: [DiviFeature::ThemeOptionWrite]
);
}
/**
* The whitelist keys present in et_divi with their raw value, in the order of SANITIZERS; a missing key (Divi's
* default) is missing here too. A value of another type (array, null, true) is no customizer value and left out.
*
* @return array<string, int|float|string|false>
*/
public function read(): array
{
$options = get_option(self::OPTIONS);
if (! is_array($options)) {
return [];
}
$stored = [];
foreach (array_keys(self::SANITIZERS) as $key) {
$value = $options[$key] ?? null;
if (is_int($value) || is_float($value) || is_string($value) || $value === false) {
$stored[$key] = $value;
}
}
return $stored;
}
/**
* Divi's customizer sanitizer of $key applied to $value; false = Divi refuses it (Divi itself would store false).
* Non-scalars never reach Divi's sanitizers.
*
* @throws InvalidArgumentException When $key is not a whitelist key.
*/
public function sanitize(string $key, mixed $value): mixed
{
$sanitizer = self::SANITIZERS[$key] ?? throw new InvalidArgumentException(
'not a theme option of the whitelist: ' . $key
);
return is_scalar($value) ? $sanitizer($value) : false;
}
/**
* Writes like a customizer save: $set per key with et_update_option(), $unset (back to Divi's default) with
* et_delete_option(), then the static CSS and the builder caches; reads back from et_divi.
*
* @param array<string, int|float|string|false> $set Checked values (ThemeOptionSpec, sanitize()) or raw values
* of a snapshot.
* @param list<string> $unset
* @return array<string, int|float|string|false> read() after writing.
* @throws DiviLocked When theme-option-write is not released – before anything is written.
* @throws InvalidArgumentException When a key is not a whitelist key – before anything is written.
* @throws RuntimeException When the read-back differs from what was written.
*/
public function write(array $set, array $unset): array
{
ReleaseGate::assertFeature(DiviFeature::ThemeOptionWrite);
$unknown = array_diff([...array_keys($set), ...$unset], array_keys(self::SANITIZERS));
if ($unknown !== []) {
throw new InvalidArgumentException('not a theme option of the whitelist: ' . implode(', ', $unknown));
}
foreach ($set as $key => $value) {
et_update_option($key, $value);
}
foreach ($unset as $key) {
et_delete_option($key);
}
// What Divi runs on customize_save_after (research A.2): the customizer CSS lives in the static resources.
\ET_Core_PageResource::remove_static_resources('all', 'all');
et_pb_force_regenerate_templates();
$stored = $this->read();
$wrong = [];
foreach ($set as $key => $value) {
if (! array_key_exists($key, $stored) || $stored[$key] !== $value) {
$wrong[] = $key;
}
}
foreach ($unset as $key) {
if (array_key_exists($key, $stored)) {
$wrong[] = $key;
}
}
if ($wrong !== []) {
throw new RuntimeException('theme options not written: ' . implode(', ', $wrong));
}
return $stored;
}
/** Divi's role right "Theme-Anpassung" (theme_customizer); fail closed without Divi. */
public function canCustomize(): bool
{
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed('theme_customizer');
}
}
@@ -6,8 +6,10 @@ namespace LIT\MCP\Integrations\Divi\Release;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\GlobalColorsAdapter;
use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Pages\DiviPages;
@@ -31,6 +33,8 @@ final class DiviAdapters
DesignSystemWriter::class,
GlobalColorsAdapter::class,
GlobalVariablesAdapter::class,
ThemeOptionsAdapter::class,
FontCatalog::class,
DiviModules::class,
DynamicContentMap::class,
];
@@ -23,18 +23,21 @@ enum DiviFeature: string
case PresetWrite = 'preset-write';
case ColorWrite = 'color-write';
case VariableWrite = 'variable-write';
case ThemeOptionRead = 'theme-option-read';
case ThemeOptionWrite = 'theme-option-write';
case FontUpload = 'font-upload';
case LibraryRead = 'library-read';
case LibraryWrite = 'library-write';
case Roles = 'roles';
case ThemeBuilderRead = 'tb-read';
case ThemeBuilderWrite = 'tb-write';
/** Whether the feature changes data (the six *Write cases). */
/** Whether the feature changes data (the seven *Write cases and FontUpload). */
public function isWrite(): bool
{
return match ($this) {
self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::LibraryWrite,
self::ThemeBuilderWrite => true,
self::PageWrite, self::PresetWrite, self::ColorWrite, self::VariableWrite, self::ThemeOptionWrite,
self::FontUpload, self::LibraryWrite, self::ThemeBuilderWrite => true,
default => false,
};
}
@@ -51,6 +54,9 @@ enum DiviFeature: string
self::PresetWrite => 'Presets schreiben',
self::ColorWrite => 'Farben schreiben',
self::VariableWrite => 'Variablen schreiben',
self::ThemeOptionRead => 'Theme-Optionen lesen',
self::ThemeOptionWrite => 'Theme-Optionen schreiben',
self::FontUpload => 'Schriften hochladen',
self::LibraryRead => 'Bibliothek lesen',
self::LibraryWrite => 'Bibliothek schreiben',
self::Roles => 'Divi-Rollen',
@@ -1,4 +1,5 @@
{
"customizer-typography": "0264fa5a2ba604542e1e1ad0c0339f04c8f4f67297087a0f92954ee63d343fd0",
"library-submit-layout": "594b3843d4c42e1a91e5eda06f108c79583a0036e46c92dd749f4f541e362d02",
"preset-sync": "9586f8005b8065893c0350977a58ee9171bf715fff21ac0c84663cc512f45522",
"sig:ET\\Builder\\Framework\\UserRole\\UserRole::can_current_user_use_visual_builder": "76d199c13adc6600c8a456225a8eb9db8037064f20eca9db597c4e45ccecf8d5",
@@ -24,8 +25,19 @@
"sig:ET_Core_PageResource::remove_static_resources": "eaa39fd2c037c2d399578f71862da5c04b68d58a30657916482f95e94e5d8a96",
"sig:et_": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_enabled_for_post_type": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_builder_get_custom_fonts": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_get_fonts": "3ac3132305f3818c13df78416ae13e34c36af17a0ba9b66d652fe9babc6c051d",
"sig:et_builder_get_google_fonts": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_get_public_post_types": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_builder_get_websafe_fonts": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_delete_option": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_divi_global_font_style": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_pb_force_regenerate_templates": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_pb_is_allowed": "b7561d91c3e696079a598474517118c3e9809cbf1f08b1321babe7af11d15799",
"sig:et_sanitize_float_number": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_sanitize_font_choices": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_sanitize_global_font_weight": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_sanitize_int_number": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_theme_builder_clear_wp_cache": "3ac3132305f3818c13df78416ae13e34c36af17a0ba9b66d652fe9babc6c051d",
"sig:et_theme_builder_get_flat_template_settings_options": "ae7b0ec701e388b03d29d972195cb727fe1c8fd75dba4657ad71f1a963a32966",
"sig:et_theme_builder_get_template": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
@@ -33,6 +45,7 @@
"sig:et_theme_builder_get_theme_builder_template_ids": "b7561d91c3e696079a598474517118c3e9809cbf1f08b1321babe7af11d15799",
"sig:et_theme_builder_insert_layout": "a6aed8043b2c07ef1082488fde706816cf0146847f5bac8acca79fb3fe5275fc",
"sig:et_theme_builder_store_template": "6c84ec472739a2dbb87bcf21777c4dc977fd2233142eacfefe60eba462a4c219",
"sig:et_update_option": "b6b87f370cc0a0e01df5899074045bc8646b05855d4cd7103d9fc1ca0ac09799",
"tb-api-save": "7ba452f4d73c9b676281ca6ddcfddd669502965db868bf478ef7804937e5bfc4",
"tb-load-setting-options": "2ea0a6925a1da5522489a417104b384b2bd77a16b25f4122b9503873a4c8a24e",
"tb-store-template": "3bf4ac6028ed99ed8ac3523c659392a5fce7818ef8a80ea226e01f5404eb441d",