Files
WP-LIT-MCP/docs/superpowers/plans/2026-10-19-etappe-4d-theme-optionen-schriften.md

372 KiB
Raw Permalink Blame History

Etappe 4d – Designsystem: Theme-Optionen und Schriften – Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Der KI-Assistent liest und ändert die Design-Theme-Optionen von Divi (Schriften, Größen, Layout – lit/divi-theme-options-get, lit/divi-theme-options-update), lädt eigene Schriftdateien von einer URL hoch (lit/divi-fonts-upload), bekommt bei unbekannten Schriftnamen Vorschläge (auch bei Schrift-Variablen), und kann Snapshots des Bereichs theme_options wiederherstellen (lit/divi-design-restore) – mit Probelauf, confirm, Snapshot (Theme-Optionen) und Zurücklesen.

Architecture: Die 4a-Pipeline DesignChange bleibt unverändert; neu sind die DesignArea ThemeOptionsArea mit reinen Klassen (ThemeOptionSpec, ThemeOptionBook, ThemeOptionDiff, FontSuggest) und drei kleinen Divi-Adaptern (ThemeOptionsAdapter, FontCatalog, FontInstaller) mit eigenen Fähigkeiten (theme-option-read, theme-option-write, font-upload). Divis Customizer-Standards und -Bereiche stehen nur als Literale in et_divi_customizer_theme_settings(); sie werden als Tabelle (ThemeOptionSpec) nachgebaut und über einen Mirror mit Fingerabdruck abgesichert. Der Font-Upload ist KEIN Pipeline-Bereich (keine Snapshots, Spec A2): eigener Probelauf/confirm-Ablauf, Download über einen aus MediaImporter herausgelösten, geteilten RemoteFileFetcher, Installation über Divis FontsUtility::font_add() im Prozess (action: wp_handle_upload_rest, wie Divis eigener KI-Pfad).

Tech Stack: PHP ≥ 8.1, WordPress 7.x, Divi 5.15.1 (et_update_option/et_delete_option, Customizer-Sanitizer, et_builder_get_fonts/_google_fonts/_websafe_fonts/_custom_fonts, FontsUtility::font_add/font_remove, HooksRegistration::check_filetype_and_ext_font, et_pb_is_allowed – nur über die drei neuen Adapter), PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes.

Spec: docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md (§3, §4, §5, §8, §10, §11, §13, §14 Schritt 7), Entscheidungen docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md (G3, G6, G10, A1, A2, A8, A9 – verbindlich, soweit unten nicht durch Rulings W1–W14 geändert), Datenformate docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md §4 und der Nachtrag 4d in Task 11, Recherche 5.15.1: docs/superpowers/notes/2026-10-19-etappe-4d-divi-recherche.md (Task 11 legt sie ab; bis dahin die Kopie …/scratchpad/research-4d-divi.md des Planers – die Task-Autoren haben sie gelesen), Plan 4c (2026-10-19-etappe-4c-farben-variablen.md) als Vorbild; der Code ist maßgeblich, wo er von den Plänen abweicht.

Voraussetzung: Branch etappe-4 (= main), Stand 2307027 (4a, 4b, 4c fertig), 3121 Unit-Tests grün (composer test), composer lint sauber.

Teil von Etappe 4: 4a, 4b, 4c (fertig) → 4d (dieser Plan) → 4e Schleifen-Inhalte, Guides, Audit, Version.

Global Constraints

  • Dateien nur im Plugin-Verzeichnis /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP anlegen oder ändern. Integrationsskripte stellen im EXIT-Trap alles wieder her, was sie ändern (et_divi byte-genau über serialize/base64, hochgeladene Schriften inkl. Dateien über FontsUtility::font_remove, Seiten, Variablen).
  • PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9; Divi 5.15.1. Namespace LIT\MCP\ → src/, PSR-4, eine Klasse pro Datei, jede Datei mit defined('ABSPATH') || exit;, declare(strict_types=1);, höchstens 120 Zeichen je Zeile. Nach jedem Task composer test und composer lint sauber (composer test = php -d memory_limit=512M vendor/bin/phpunit).
  • Divi-Grenze (Divi-Absicherung §3): Divi-Aufrufe nur in Klassen aus DiviAdapters::CLASSES mit Manifest; jedes Divi-Symbol steht mit Fähigkeiten im Manifest; DiviBoundaryTest und DiviFeatureAssignmentTest bleiben grün (die angepasste Tabelle gehört in denselben Task wie die Manifest-Änderung); neue Signaturen: fingerprints.json über wp lit-mcp divi-check --update-reference in DDEV neu erzeugen (Befehl und Prüfung wie in Task 2 des Plans 4c). Alle übrigen neuen Klassen sind reines PHP ohne WordPress- und Divi-Aufrufe; Ausnahmen nennt der Task ausdrücklich.
  • Pipeline (Spec §4): Theme-Optionen laufen durch DesignChange::run() (Rechte → Eingabe prüfen, alle Fehler gesammelt als validation_failed mit Feldpfaden → Probelauf mit diff, design_rev, warnings → mit confirm: true Sperre, Snapshot theme_options, Schreiben, Zurücklesen + verify, Protokoll). Der Font-Upload nutzt die Pipeline nicht (kein Snapshot, A2), hat aber Probelauf + confirm, Rechte vorab, Rollback und Protokoll.
  • Werte: Jeder Wert einer Theme-Option wird vom Plugin geprüft (ThemeOptionSpec::check: Typ, Bereich, Auswahl) UND durch Divis eigenen Customizer-Sanitizer des Schlüssels geschickt (ThemeOptionsAdapter::sanitize); false oder ein vom Eingabewert abweichendes Ergebnis ist ein Validierungsfehler (Divi würde false stillschweigend speichern bzw. runden). Die fünf Hauptfarben, Button-Standards (all_buttons_*), Logo und SEO bleiben außen (G3, A8).
  • Rechte: Theme-Optionen schreiben und Restore: edit_theme_options und et_pb_is_allowed('theme_customizer') (Divis Customizer-Regel); Lesen (theme-options-get): wie lit/divi-design-system (edit_posts). Font-Upload: upload_files und edit_theme_options und et_pb_is_allowed('custom_fonts_management') (Divis eigene Regel CustomFontController::store_permission() plus die Plugin-Regel; Spec §10 nannte irrtümlich use_visual_builder).
  • Exposition: Neue Tools sind indirekt (über mcp-adapter-execute-ability), Gruppe divi, Annotations wie die Preset-Tools (write/destructive); Texte nennen indirekte Tools lit/<name> (ToolNamingTest); docs/guides/overview.md „Direkt sichtbar:“ bleibt unverändert; Guides folgen gesammelt mit 4e.
  • Sprache: Meldungen, Hinweise, Beschreibungen auf Deutsch mit echten Umlauten; neutral „KI-Assistent“, „MCP-Client“.
  • Tests: Abilities über LIT\MCP\Tests\Unit\TestCase (Brain Monkey + Mockery, assertToolError); vorbelegte Stubs (wp_json_encode, is_wp_error, absint, wp_slash, wp_unslash, sanitize_text_field) nie mit Functions\expect() prüfen. Reine Klassen erben von PHPUnit\Framework\TestCase. TDD: Test zuerst, RED-Lauf, Code, GREEN. Adapter-Tests ersetzen Divi-Klassen per Mockery::mock('alias:…') mit @runInSeparateProcess (Vorbild GlobalColorsAdapterTest). Integration: cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh (läuft nur mit deaktivierten Plugins postlos und asynclistry; vorher Zustand mit ddev exec wp plugin list notieren und danach wiederherstellen).
  • Kein clean-code-review-Skill je Task (die globale CLAUDE.md-Regel zieht sonst die Antwort des Implementierers an sich); der Gesamtreview folgt am Ende (Task 11).
  • Commits: git commit -m "<Nachricht>" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>" mit dem Namen des ausführenden Modells; nur die Dateien des Tasks stagen.

Entscheidungen für 4d (Rulings, verbindlich)

  • W1 Drei Fähigkeiten: DiviFeature::ThemeOptionRead (theme-option-read, „Theme-Optionen lesen“), ThemeOptionWrite (theme-option-write, „Theme-Optionen schreiben“, schreibend), FontUpload (font-upload, „Schriften hochladen“, schreibend). ThemeOptionsArea::feature() = ThemeOptionWrite. Der Mirror customizer-typography (Standards/Bereiche der 20 Settings) trägt theme-option-read und -write; die Schriftlisten-Aufrufe tragen theme-option-write, font-upload und variable-write (die Schrift-Variablen werden gegen dieselbe Liste geprüft; ein Bruch sperrt bewusst alle drei).
  • W2 Whitelist (Spec §8.1, G3): 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. Standards, Bereiche und Sanitizer-Namen je Schlüssel stehen in der Recherche (Tabelle A) und werden in ThemeOptionSpec als Literale nachgebaut; der Mirror auf et_divi_customizer_theme_settings() (functions.php) sichert sie. shortcut: der Mirror hasht die ganze Funktion und schlägt bei jeder Customizer-Änderung an; bei zu häufigem Anschlagen auf einen engeren Ausschnitt umstellen (im Code kommentieren).
  • W3 Unset statt 'none': Ein Schlüssel ist „nicht gesetzt“ = Divi-Standard. Der Eingabewert null setzt zurück (Divi: et_delete_option); 'none' und false werden abgelehnt (Divi speichert die Standardschrift als false, ein gespeichertes 'none' wäre in Divi 5 ein echter Schriftname). Tablet-/Phone-Größen erben, solange sie nicht gesetzt sind (effektiver Standard = der größere Wert); get nennt den effektiven Wert und default.
  • W4 Bereiche prüft das Plugin: Divis Sanitizer (absint, intval, floatval, Auswahl-Sanitizer → false) klemmen nicht; ThemeOptionSpec prüft min/max/step/choices der Customizer-Controls. body_header_style nimmt genau einen Wert (italic, uppercase, underline) oder null (Divis Sanitizer lehnt Kombinationen ab). Zahlen dürfen als Zahl oder Ziffern-String kommen; gespeichert wird der Typ, den Divis Sanitizer liefert (int/float/String).
  • W5 Schriftnamen: gültig ist der exakte Familienname (Groß-/Kleinschreibung) aus array_merge(et_builder_get_custom_fonts(), et_builder_get_fonts()) (so prüft Divis Sanitizer). Unbekannt → validation_failed mit bis zu 5 ähnlichen Namen (FontSuggest, ohne Beachtung der Groß-/Kleinschreibung: Präfix, Teilstring, levenshtein). Die Liste wird je Request einmal geladen (et_builder_get_google_fonts() lädt 1,3 MB und kann einmal je Tag einen HTTP-Abruf auslösen). Gilt für heading_font, body_font, primary_nav_font, secondary_nav_font und Variablen vom Typ fonts. Schriftnamen in Preset-/Modul-Attributen werden in 4d NICHT geprüft (Folgepunkt für 4e, in der Notiz festgehalten).
  • W6 Snapshot theme_options: raw = genau die vorhandenen Whitelist-Schlüssel aus get_option('et_divi') mit Rohwert und Typ (array<string, int|float|string|false>); ein fehlender Schlüssel fehlt im Array. ThemeOptionBook normalisiert (nur Whitelist, Schlüsselreihenfolge fest) und liefert rev(). Schreiben/Restore: Schlüssel im neuen Bestand → et_update_option, Whitelist-Schlüssel, die im neuen Bestand fehlen → et_delete_option; danach die Caches wie ein Customizer-Speichern (ET_Core_PageResource::remove_static_resources('all', 'all') und et_pb_force_regenerate_templates()). Zurücklesen über get_option('et_divi') (nicht über GlobalData::get_customizer_fonts(), das static cacht). Restore ist ein vollständiges Ersetzen der Whitelist (mayRemove = true). Lost-Update-Risiko von et_update_option (schreibt das ganze Array aus dem Request-Global) ist dokumentiert; design_rev und Sperre mindern es.
  • W7 Keine Fundstellen: Theme-Optionen haben keinen Seitenbezug; der Probelauf liefert kein affected und die Warnung „wirkt auf die gesamte Website“ (bei Schriften zusätzlich „wirkt auch auf den Builder“). DesignPlan::usages bleibt null.
  • W8 Font-Upload-Weg: FontInstaller::install() ruft FontsUtility::font_add($files, $name, $settings, ['action' => 'wp_handle_upload_rest']) mit Temp-Dateien (wp_tempnam, wie AiAgentImageUtility::install_to_custom_fonts()); jede Datei vorab durch HooksRegistration::check_filetype_and_ext_font(); nach font_add werden die gelieferten font_file-Schlüssel gegen die Eingabe verglichen (Divi meldet Teilerfolg als Erfolg) – Abweichung → FontsUtility::font_remove($name) und Fehler. Statt eines Nachbaus des et_uploaded_fonts-Eintrags (Spec §8.2) ein Mirror font-install auf AiAgentImageUtility::install_to_custom_fonts (Aufrufkonvention). Erlaubte Formate: woff2, woff, ttf, otf (kein eot); Gewichte all oder Liste aus 100…900 (Plugin prüft, Divi nicht); generic_family aus serif|sans-serif|monospace|cursive|fantasy (Standard sans-serif; Divi prüft nicht).
  • W9 Font-Namen und -Dateien: Name: 1–100 Zeichen, nur A–Z a–z 0–9, Leerzeichen, _, - (Divi entfernt andere Zeichen stillschweigend – das Plugin lehnt ab und nennt den bereinigten Namen), nicht in Googles, Web-Safe- oder bereits hochgeladenen Schriften (ohne Beachtung der Groß-/Kleinschreibung; strenger als Divi). Dateien: höchstens min(Limits::uploadBytes(), 5 MB) je Datei (A9), 1–4 Dateien (je Format höchstens eine), Magic Bytes (FontSignature: wOF2, wOFF, 00 01 00 00/true = ttf, OTTO = otf) müssen zur Endung der Eingabe passen; URL-Regeln (nur öffentliche Hosts, Weiterleitungen geprüft, Zeitlimit) wie lit/media-upload-from-url über den geteilten RemoteFileFetcher.
  • W10 Probelauf des Uploads: ohne confirm lädt das Tool die Dateien wirklich herunter (nur Temp-Dateien, danach gelöscht), prüft alles und antwortet mit Errors::confirmRequired (Name, Dateien mit Format/Größe/SHA-256, Gewichte, Familie, „wirkt auf …“-Hinweis). Mit confirm: true lädt es erneut, prüft erneut und installiert; das Protokoll nennt die SHA-256-Werte. Kein Snapshot; Rollback bei Fehler über font_remove.
  • W11 Restore: DiviDesignRestoreAbility bekommt ThemeOptionsRestorePlanner (area() theme_options); die Meldung „Theme-Optionen folgen mit Etappe 4d“ entfällt (ein unbekannter Bereich bleibt unsupported); Rechte und Fähigkeiten wie beim Schreiben.
  • W12 Schrift-Variablen: DiviVariablesSetAbility prüft Variablen vom Typ fonts zusätzlich gegen FontCatalog (W5); VariableValue bleibt rein (die Listenprüfung liegt in der Ability, VariableValue.php:12 „folgt mit 4d“ wird berichtigt).
  • W13 Divi-Beschränkungen als Grenzen dokumentieren: et_get_option-/et_update_option-Lost-Update, body_header_style-Einzelwert, fehlende Bereichsprüfung in Divi, font_add-Teilerfolg, remove_static_resources wirkungslos ohne angemeldeten Benutzer (der Prozess hat einen, Smokes laufen mit --user).
  • W14 Rechte-Texte: DesignRights::themeOptions(DesignSystemWriter, ThemeOptionsAdapter) und DesignRights::fonts(FontCatalog) entsprechen den Rechten oben; Fehlertexte nennen das fehlende Recht und die Divi-Rolle („Theme-Anpassung“/„Eigene Schriften verwalten“).

Schnittstellenvertrag (Namen und Signaturen, die spätere Tasks voraussetzen)

Alle Klassen unter src/Integrations/Divi/Design/ (Namespace LIT\MCP\Integrations\Divi\Design), Tests unter tests/Unit/Integrations/Divi/Design/ (Namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design), sofern nicht anders genannt. Task-Autoren dürfen Details verfeinern, aber nur mit Eintrag unter ## ABWEICHUNGEN am Ende ihres Abschnitts.

// Release/DiviFeature.php (Task 1)
case ThemeOptionRead = 'theme-option-read';    // label 'Theme-Optionen lesen'
case ThemeOptionWrite = 'theme-option-write';  // label 'Theme-Optionen schreiben', isWrite() = true
case FontUpload = 'font-upload';               // label 'Schriften hochladen',      isWrite() = true

// ThemeOptionsAdapter.php (Task 2) – class (nicht final) implements DiviAdapter, Manifest::DESIGN
public static function manifest(): Manifest;
/** @return array<string, int|float|string|false> vorhandene Whitelist-Schlüssel aus get_option('et_divi') */
public function read(): array;
/** Divis Customizer-Sanitizer des Schlüssels; false = Divi lehnt ab. @throws InvalidArgumentException unbekannter Schlüssel */
public function sanitize(string $key, mixed $value): mixed;
/** @param array<string, int|float|string|false> $set @param list<string> $unset @throws RuntimeException wenn das Zurücklesen nicht stimmt */
public function write(array $set, array $unset): array;   // et_update_option / et_delete_option, dann Caches, liefert read()
public function canCustomize(): bool;                      // et_pb_is_allowed('theme_customizer'), fail closed ohne Divi

// FontCatalog.php (Task 2) – class (nicht final) implements DiviAdapter
/** @return list<string> exakte Familiennamen: hochgeladene + Google + Web-Safe, einmal je Request */
public function names(): array;
/** @return list<string> */ public function uploaded(): array;
/** @return list<string> Google + Web-Safe + hochgeladen (Kollisionsprüfung beim Upload) */ public function reserved(): array;
public function has(string $name): bool;
public function canManage(): bool;                         // et_pb_is_allowed('custom_fonts_management')

// FontInstaller.php (Task 8) – class (nicht final) implements DiviAdapter
/** @return list<string> Formate, die Plugin UND Divi (mime_types_font) erlauben: woff2, woff, ttf, otf */
public function formats(): array;
public function accepts(string $path, string $filename): bool;            // HooksRegistration::check_filetype_and_ext_font
/** @param array<string, string> $files Endung => Temp-Dateipfad @return list<string> installierte Endungen @throws RuntimeException */
public function install(string $name, array $files, string $weights, string $family): array;
public function remove(string $name): void;

// DesignRights.php (Task 2) – zusätzlich
public static function themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options): ?WP_Error;
public static function fonts(FontCatalog $fonts): ?WP_Error;               // upload_files + edit_theme_options + custom_fonts_management

// ThemeOptionSpec.php (Task 3) – reines PHP
public const KEYS = [ /* die 20 Schlüssel in der Reihenfolge von W2 */ ];
/** @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} */
public static function definition(string $key): array;
public static function isKey(string $key): bool;
public static function check(string $key, mixed $value): ?string;          // null = gültig (Typ/Bereich/Auswahl, ohne Schriftliste), sonst deutsche Meldung
public static function normalize(string $key, mixed $value): int|float|string;   // "15" → 15, "1.7" → 1.7
public static function effective(string $key, array $stored): int|float|string|null;   // gespeicherter Wert, sonst vererbter/Standardwert (W3)

// ThemeOptionBook.php (Task 3) – reines PHP, unveränderlich
public static function fromDivi(array $raw): self;             // nur Whitelist, feste Reihenfolge
/** @return array<string, int|float|string|false> */ public function data(): array;
public function rev(): string;                                 // Rev::of wie PresetBook/ColorBook
public function has(string $key): bool;
public function get(string $key): int|float|string|false|null;
public function with(string $key, int|float|string|false $value): self;
public function without(string $key): self;

// ThemeOptionDiff.php (Task 3) – reines PHP
/** @return list<array{key: string, label: string, action: string, before: mixed, after: mixed}> action: added|changed|reset */
public static function between(ThemeOptionBook $before, ThemeOptionBook $after): array;
public static function summary(array $diff): string;           // 'Theme-Optionen: 1 gesetzt, 2 geändert, 1 zurückgesetzt.' / '… keine Änderung.'

// FontSuggest.php (Task 3) – reines PHP
/** @param list<string> $names @return list<string> höchstens $limit ähnliche Namen */
public static function similar(string $name, array $names, int $limit = 5): array;

// ThemeOptionsArea.php (Task 4) – implements DesignArea: name 'theme_options', label 'Theme-Optionen', feature ThemeOptionWrite
public function __construct(ThemeOptionsAdapter $options);
// DiviThemeOptionsGetAbility.php: __construct(ThemeOptionsAdapter, ?FontCatalog = null) – lit/divi-theme-options-get
// DiviThemeOptionsUpdateAbility.php: __construct(DesignSystemWriter, ThemeOptionsAdapter, FontCatalog, DesignChange) – lit/divi-theme-options-update

// RemoteFileFetcher.php (Task 7) – src/Integrations/Media/RemoteFileFetcher.php (Namespace LIT\MCP\Integrations\Media) – aus MediaImporter herausgelöst
// FontSignature.php (Task 8) – reines PHP: public static function detect(string $header): ?string;   // 'woff2'|'woff'|'ttf'|'otf'
// FontNameRules.php (Task 8) – reines PHP: public static function check(string $name, array $reserved): ?string;  // null = gültig
// DiviFontsUploadAbility.php (Task 8): __construct(FontCatalog, FontInstaller, RemoteFileFetcher, ChangeLog, …) – lit/divi-fonts-upload

// RestorePlanner (Task 6) – ThemeOptionsRestorePlanner implements RestorePlanner (area 'theme_options')

Dateistruktur

Neu (src/Integrations/Divi/Design/): ThemeOptionsAdapter, FontCatalog, FontInstaller, ThemeOptionSpec, ThemeOptionBook, ThemeOptionDiff, FontSuggest, ThemeOptionsArea, DiviThemeOptionsGetAbility, DiviThemeOptionsUpdateAbility, ThemeOptionsRestorePlanner, FontSignature, FontNameRules, DiviFontsUploadAbility; neu src/Integrations/Media/RemoteFileFetcher.php.

Geändert: Release/DiviFeature.php, Release/DiviAdapters.php, Release/fingerprints.json, Design/DesignRights.php, Design/DiviDesignRestoreAbility.php, Design/DiviVariablesSetAbility.php, Design/VariableValue.php (nur Kommentar), src/Integrations/Media/MediaImporter.php, DiviIntegration.php, Notizen/Spec (Task 11).

Neu (Tests): je Klasse eine Testdatei; tests/Integration/divi-theme-fonts-smoke.sh; ggf. eine kleine Schrift-Fixture unter tests/Fixtures/.

Geändert (Tests): DiviFeatureTest, DiviFeatureAssignmentTest, DiviToolFeaturesTest, DiviIntegrationTest, DiviDesignRestoreAbilityTest, DesignRightsTest, DiviVariablesSetAbilityTest, MediaImporterTest (und was grep -rn "PresetWrite\|preset-write\|VariableWrite\|variable-write" tests an Fähigkeitszählungen findet).

Task-Übersicht

# Task Ergebnis
1 Fähigkeiten ThemeOptionRead/ThemeOptionWrite/FontUpload Enum, Label, Tests (Commit gemeinsam mit Task 2)
2 ThemeOptionsAdapter, FontCatalog, Manifest, Fingerabdrücke, DesignRights Divi-Grenze für Lesen/Schreiben/Schriftlisten
3 ThemeOptionSpec, ThemeOptionBook, ThemeOptionDiff, FontSuggest reiner Bestand der Theme-Optionen
4 ThemeOptionsArea, lit/divi-theme-options-get, lit/divi-theme-options-update Theme-Optionen lesen/schreiben
5 Schriftprüfung der Schrift-Variablen lit/divi-variables-set prüft Typ fonts
6 ThemeOptionsRestorePlanner Restore für theme_options
7 RemoteFileFetcher aus MediaImporter herauslösen geteilter Download (Refactor, Verhalten gleich)
8 FontSignature, FontNameRules, FontInstaller, lit/divi-fonts-upload Schriften hochladen
9 Verdrahtung DiviIntegration, Tabellen der Fähigkeiten
10 Smoke gegen echtes Divi divi-theme-fonts-smoke.sh
11 Notizen, Recherche ablegen, Spec, Gesamtverifikation Formate-Nachtrag 4d, Spec §8/§10, Review, Memory

Task 1: Fähigkeiten ThemeOptionRead, ThemeOptionWrite und FontUpload (W1)

Drei neue Fähigkeiten, damit ein Bruch des Customizer-Nachbaus, der Schriftlisten oder des Font-Uploads nur die jeweils betroffenen Tools sperrt. Geprüft mit grep -rn "VariableWrite\|variable-write\|13 \|dreizehn\|thirteen" src tests docs und einem Lauf der ganzen Suite mit den drei neuen Fällen (Stand 2307027, Kopie): nur drei Tests brechen – zwei in DiviFeatureTest (zählen und listen die Fälle exakt) und DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement (jede Fähigkeit braucht eine Manifest-Anforderung; die kommen erst mit den Adaptern in Task 2). FeatureOverviewTest, DiviEnvironmentComponentTest, CompatibilityMatrixTest, ReleaseGateTest, DiviCheckCommandTest::testEveryFeatureGetsOneLine und alle übrigen rechnen mit count(DiviFeature::cases()) oder nennen nur einzelne Fähigkeiten und bleiben grün; DiviToolFeaturesTest nennt nur Tool-Tabellen (die neuen Tools folgen mit Task 9). Außerhalb der Unit-Tests zählt nur tests/Integration/divi-release-smoke.sh die Fähigkeiten (Liste der IDs, „thirteen“, = "13"); docs/guides nennt keine Zahl.

Wegen des Zuordnungstests hat Task 1 keinen eigenen Commit: seine Dateien gehen mit dem Commit von Task 2 (Step 14) ins Repo (wie Tasks 1/2 des Plans 4c). Bis dahin ist genau DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement rot.

Files:

  • Modify: src/Integrations/Divi/Release/DiviFeature.php (drei Fälle, isWrite(), label(), Docblock von isWrite())
  • Modify: tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php
  • Modify: tests/Integration/divi-release-smoke.sh (16 statt 13 Fähigkeiten; läuft erst nach Task 2 grün, weil vorher die Signatur-Referenzen der neuen Aufrufe fehlen)

Interfaces:

  • Consumes: DiviFeature (vorhanden).

  • Produces: DiviFeature::ThemeOptionRead = 'theme-option-read' („Theme-Optionen lesen“, isWrite() === false), DiviFeature::ThemeOptionWrite = 'theme-option-write' („Theme-Optionen schreiben“, isWrite() === true), DiviFeature::FontUpload = 'font-upload' („Schriften hochladen“, isWrite() === true). Reihenfolge der Fälle (bestimmt die Reihenfolge in Status-Tool, Admin-Reiter und divi-check): direkt nach VariableWrite, vor LibraryRead.

  • Step 1: DiviFeatureTest auf sechzehn Fähigkeiten umstellen

tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php vollständig ersetzen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Release;

use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use PHPUnit\Framework\TestCase;

final class DiviFeatureTest extends TestCase
{
    public function testTheSixteenFeaturesAndTheirValues(): void
    {
        self::assertSame(
            [
                'PageRead' => 'page-read',
                'PageWrite' => 'page-write',
                'Catalog' => 'catalog',
                'DynamicSources' => 'dynamic-sources',
                'DesignRead' => 'design-read',
                'PresetWrite' => 'preset-write',
                'ColorWrite' => 'color-write',
                'VariableWrite' => 'variable-write',
                'ThemeOptionRead' => 'theme-option-read',
                'ThemeOptionWrite' => 'theme-option-write',
                'FontUpload' => 'font-upload',
                'LibraryRead' => 'library-read',
                'LibraryWrite' => 'library-write',
                'Roles' => 'roles',
                'ThemeBuilderRead' => 'tb-read',
                'ThemeBuilderWrite' => 'tb-write',
            ],
            array_column(array_map(
                static fn (DiviFeature $feature): array => [$feature->name, $feature->value],
                DiviFeature::cases()
            ), 1, 0)
        );
    }

    public function testOnlyTheEightWriteFeaturesWrite(): void
    {
        $writing = array_values(array_map(
            static fn (DiviFeature $feature): string => $feature->value,
            array_filter(DiviFeature::cases(), static fn (DiviFeature $feature): bool => $feature->isWrite())
        ));

        self::assertSame(
            [
                'page-write', 'preset-write', 'color-write', 'variable-write', 'theme-option-write', 'font-upload',
                'library-write', 'tb-write',
            ],
            $writing
        );
    }

    public function testEveryFeatureHasAUniqueGermanLabel(): void
    {
        $labels = array_map(static fn (DiviFeature $feature): string => $feature->label(), DiviFeature::cases());

        self::assertCount(count(DiviFeature::cases()), array_unique($labels));
        self::assertSame('Seiten schreiben', DiviFeature::PageWrite->label());
        self::assertSame('Theme Builder lesen', DiviFeature::ThemeBuilderRead->label());
        self::assertSame('Farben schreiben', DiviFeature::ColorWrite->label());
        self::assertSame('Variablen schreiben', DiviFeature::VariableWrite->label());
        self::assertSame('Theme-Optionen lesen', DiviFeature::ThemeOptionRead->label());
        self::assertSame('Theme-Optionen schreiben', DiviFeature::ThemeOptionWrite->label());
        self::assertSame('Schriften hochladen', DiviFeature::FontUpload->label());
        foreach ($labels as $label) {
            self::assertNotSame('', trim($label));
        }
    }

    public function testValuesResolveBackToTheFeature(): void
    {
        self::assertSame(DiviFeature::ThemeBuilderWrite, DiviFeature::from('tb-write'));
        self::assertSame(DiviFeature::ThemeOptionWrite, DiviFeature::from('theme-option-write'));
        self::assertNull(DiviFeature::tryFrom('page'));
    }
}
  • Step 2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviFeatureTest Expected: FAIL – Tests: 4, Assertions: 8, Errors: 2, Failures: 2:

  • testEveryFeatureHasAUniqueGermanLabel: Error: Undefined constant LIT\MCP\Integrations\Divi\Release\DiviFeature::ThemeOptionRead

  • testValuesResolveBackToTheFeature: Error: Undefined constant …DiviFeature::ThemeOptionWrite

  • testTheSixteenFeaturesAndTheirValues: Array-Vergleich, es fehlen 'ThemeOptionRead' => 'theme-option-read', 'ThemeOptionWrite' => 'theme-option-write', 'FontUpload' => 'font-upload'

  • testOnlyTheEightWriteFeaturesWrite: es fehlen theme-option-write und font-upload

  • Step 3: Fälle, isWrite() und label() ergänzen

diff --git a/src/Integrations/Divi/Release/DiviFeature.php b/src/Integrations/Divi/Release/DiviFeature.php
index 2880001..4ed32b3 100644
--- a/src/Integrations/Divi/Release/DiviFeature.php
+++ b/src/Integrations/Divi/Release/DiviFeature.php
@@ -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',
  • Step 4: GREEN

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviFeatureTest Expected: OK (4 tests, 29 assertions).

Run: composer test Expected: Tests: 3121, …, Failures: 1 – genau DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement mit Fähigkeiten ohne Anforderung in einem Manifest und + 0 => 'theme-option-read', + 1 => 'theme-option-write', + 2 => 'font-upload'. Jeder andere Fehlschlag ist ein Befund (ein Test zählt Fähigkeiten doch fest) und wird hier mit angepasst.

  • Step 5: Integrations-Smoke auf sechzehn Fähigkeiten umstellen
diff --git a/tests/Integration/divi-release-smoke.sh b/tests/Integration/divi-release-smoke.sh
index 35dd803..084ec5f 100755
--- a/tests/Integration/divi-release-smoke.sh
+++ b/tests/Integration/divi-release-smoke.sh
@@ -27,8 +27,8 @@ trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
 
 check_pages() { wp post list --post_type=page --post_status=any --meta_key=_lit_mcp_check_page --format=count; }
 
-# The ids of the thirteen features (plan decision D1, plan 4c V2), sorted.
-FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","variable-write"]'
+# The ids of the sixteen features (plan decision D1, plan 4c V2, plan 4d W1), sorted.
+FEATURE_IDS='["catalog","color-write","design-read","dynamic-sources","font-upload","library-read","library-write","page-read","page-write","preset-write","roles","tb-read","tb-write","theme-option-read","theme-option-write","variable-write"]'
 
 # Adds a problem to one feature in the cached state, as if its mirror check had failed. Needs the cache entry of
 # ReleaseResolver (format 2: ["format" => 2, "key" => stateKey, "problems" => list of Problem::toArray()]); the key
@@ -68,7 +68,7 @@ echo "$STATUS" | jq -e --argjson ids "$FEATURE_IDS" '
   .components[] | select(.id == "divi") | .features
   | ([.[].id] | sort) == $ids
     and all(.state == "released" and .source == "code" and .problems == [])' >/dev/null \
-  || fail "all thirteen features must be released from code without problems: $STATUS"
+  || fail "all sixteen features must be released from code without problems: $STATUS"
 echo "$STATUS" | jq -e '
   .components[] | select(.id == "divi") | .features
   | (.[] | select(.id == "page-write") | .tools | any(. == "lit-divi-insert"))
@@ -76,7 +76,7 @@ echo "$STATUS" | jq -e '
     and (.[] | select(.id == "preset-write") | .tools | any(startswith("lit/divi-presets-")))
     and (.[] | select(.id == "page-read") | .label == "Seiten lesen")' >/dev/null \
   || fail "features must name their tools (direct lit-<name>, indirect lit/<name>): $STATUS"
-pass "lit-site-status: thirteen features released (code), each with its tools"
+pass "lit-site-status: sixteen features released (code), each with its tools"
 
 # 2. Admin check through the REST route (application password instead of the cookie nonce).
 # X-Forwarded-Proto: https is required here: without it the request to http://localhost with the
@@ -212,8 +212,8 @@ simulate_problem page-write "Smoke: Cache vergiftet"
 CLI=$(wp --user="$ADMIN_LOGIN" lit-mcp divi-check 2>&1) || fail "wp lit-mcp divi-check must succeed (exit 0): $CLI"
 echo "$CLI" | grep -q '^Fähigkeiten:' || fail "divi-check must print the matrix: $CLI"
 echo "$CLI" | grep -q '✓ Seiten schreiben – freigegeben (code)' || fail "divi-check must show page-write released: $CLI"
-[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "13" ] || fail "divi-check must show thirteen released features: $CLI"
+[ "$(echo "$CLI" | grep -c ' – freigegeben (')" = "16" ] || fail "divi-check must show sixteen released features: $CLI"
 [ "$(check_pages)" = "0" ] || fail "the CLI check must delete its page"
-pass "wp lit-mcp divi-check prints thirteen released features, recomputes the state and leaves no page"
+pass "wp lit-mcp divi-check prints sixteen released features, recomputes the state and leaves no page"
 
 finish

Run: bash -n tests/Integration/divi-release-smoke.sh && ! grep -n thirteen tests/Integration/divi-release-smoke.sh Expected: keine Ausgabe, Exit 0 (Syntax gültig, kein „thirteen“ mehr). Die Liste ist sortiert (jq 'sort == .' → true). Der Smoke selbst läuft in Task 2, Step 13.

  • Step 6: Lint, kein Commit

Run: composer lint Expected: sauber.

Nicht committen: die drei Dateien gehen mit dem Commit von Task 2 (Step 14) ins Repo, weil composer test erst mit den Adaptern grün ist.


Task 2: Adapter ThemeOptionsAdapter und FontCatalog, Manifest, Fingerabdrücke, DesignRights (W1, W2, W5, W6, W13, W14)

Zwei kleine Adapter im Bereich „Designsystem“ – die einzigen Klassen, die Divis Theme-Optionen schreiben bzw. Divis Schriftlisten lesen – mit eigenem Manifest. Geteilte Divi-Aufrufe stehen wie in Plan 4c in jedem Manifest, das sie nutzt (ET_Core_PageResource::remove_static_resources zusätzlich zu DiviPages/DiviThemeBuilder, et_pb_is_allowed zusätzlich zu DiviRoles/GlobalVariablesAdapter); DiviAdapters::calls() führt jedes Symbol nur einmal (für die Signatur egal), featuresOf() gilt je Manifest.

Geprüft an Divi 5.15.1 (themes/Divi): die sanitize_callback aller 20 Settings in functions.php (et_divi[<key>], je Setting gelesen – stimmt mit der Recherche, Tabelle A, überein); et_sanitize_float_number($number) (includes/functions/sanitization.php:7), et_sanitize_int_number($number) (:16), et_sanitize_font_choices($choosen) (:59, array_merge(et_builder_get_custom_fonts(), et_builder_get_fonts()), isset → case-sensitiv), et_sanitize_global_font_weight($choosen) (:345), et_divi_global_font_style($choosen) (:358) – alle 1 Parameter, untypisiert; et_update_option($option_name, $new_value, …) (epanel/custom_functions.php:276, 2 Pflicht-/6 Parameter, schreibt über das Request-Global $et_theme_options), et_delete_option($option_name) (:320), ET_Core_PageResource::remove_static_resources($post_id, $owner = 'core', …) (core/components/PageResource.php:1398, 1 Pflicht-/7 Parameter, wirkungslos ohne edit_posts), et_pb_force_regenerate_templates() (includes/builder/core.php:3811, auch auf customize_save_after), et_pb_is_allowed($capabilities, $role = '') (:1136), et_builder_get_fonts($settings = array()) (:4730, ohne Google Fonts nur Web-Safe), et_builder_get_websafe_fonts() (:4783), et_builder_get_custom_fonts() (:4822, (array) gefiltert), et_builder_get_google_fonts() (:4911, [] bei abgeschalteten Google Fonts, ruft et_builder_google_fonts_sync()). Rollen-Editor: theme_customizer „Theme Customizer“ (includes/builder/functions.php:9523), custom_fonts_management „Upload/Remove Fonts“ (:9595).

Mirror auf functions.php (Theme-Wurzel, Funktion ohne Namespace): wird ohne Codeänderung unterstützt. Mirror::$file ist relativ zum Theme-Verzeichnis (DiviReference::current($themeDir) → $themeDir . '/' . $file), class: null sucht die Funktion außerhalb jedes Klassenrumpfs; SourceFingerprint::fromSource() zählt if (! function_exists(…)) :-Blöcke nicht als Klassenrumpf. In der Kopie nachgerechnet: SourceFingerprint::of('<Divi>/functions.php', 'et_divi_customizer_theme_settings') → Zeilen 1371–5332, 103 803 Byte Quelltext, Hash 0264fa5a2ba604542e1e1ad0c0339f04c8f4f67297087a0f92954ee63d343fd0. Die Referenzquelle tests/Fixtures/divi/reference/customizer-typography.txt wird damit ~104 KB groß (W2-shortcut:, im Manifest kommentiert).

Sanitizer-Tabelle und Divi-Grenze: ThemeOptionsAdapter::SANITIZERS (öffentliche Konstante, Schlüssel = die 20 Whitelist-Schlüssel in W2-Reihenfolge, Wert = Name des sanitize_callback) ist die eine Tabelle; sanitize() ruft den Namen dynamisch auf ($sanitizer($value), wie WordPress selbst sanitize_callback ruft). Ehrlich bleibt das, weil DiviTouches auch String-Literale 'et_…' als Divi-Namen meldet: ein neuer Divi-Sanitizer in der Tabelle ohne Manifest-Eintrag lässt DiviBoundaryTest::testAdaptersDeclareExactlyTheDiviNamesTheyUse scheitern; absint (WordPress) ist kein Divi-Name. Die Gegenrichtung („im Manifest, aber nicht benutzt“) prüft DiviBoundaryTest nur schwach (die Literale im Manifest selbst zählen als Benutzung) – darum pinnt ThemeOptionsAdapterTest::testTheSanitizerTableCoversTheWhitelistWithDivisCallsOfTheManifest die Tabelle: genau die 20 W2-Schlüssel, genau fünf Divi-Sanitizer, jeder als Call(<name>, 1) im Manifest. ThemeOptionSpec (Task 3) soll seine KEYS gegen array_keys(ThemeOptionsAdapter::SANITIZERS) testen (siehe ## ABWEICHUNGEN, B1).

Unit-Tests ohne Divi: Alle Divi-Symbole der beiden Adapter sind Funktionen; die Tests ersetzen sie mit Brain Monkey (Functions\when()/Functions\expect()) und laufen dann mit @runInSeparateProcess (Brain Monkey definiert eine fehlende Funktion für den Rest des Prozesses; DiviRolesTest, DiviIntegrationTest, GlobalVariablesAdapterTest::testWithoutDiviNobodyManagesVariables und die neuen testWithoutDivi…-Tests verlassen sich auf ihr Fehlen). Ein Mockery::mock('alias:…') wie in GlobalColorsAdapterTest ist nicht nötig – es gibt keine Divi-Klasse außer ET_Core_PageResource, und die ist der aufzeichnende Stub tests/Stubs/EtCorePageResource.php ($removed, vom Bootstrap geladen). get_option wird wie in GlobalColorsAdapterTest ohne eigenen Prozess per Functions\when() ersetzt. Beide Adapter sind nicht final, damit DesignRightsTest und die Tasks 4–8 sie mit Mockery ersetzen können.

Files:

  • Create: src/Integrations/Divi/Design/ThemeOptionsAdapter.php
  • Create: src/Integrations/Divi/Design/FontCatalog.php
  • Create: tests/Unit/Integrations/Divi/Design/ThemeOptionsAdapterTest.php
  • Create: tests/Unit/Integrations/Divi/Design/FontCatalogTest.php
  • Modify: src/Integrations/Divi/Release/DiviAdapters.php (beide Klassen nach GlobalVariablesAdapter::class)
  • Modify: tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php (gepinnte Tabelle, „twelve adapters“)
  • Modify: tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php (Stand-in für den neuen Nachbau, sieben statt sechs Fingerabdrücke)
  • Modify: src/Integrations/Divi/Release/fingerprints.json (generiert: ein Nachbau, zwölf sig:-Schlüssel)
  • Create: tests/Fixtures/divi/reference/customizer-typography.txt (generiert)
  • Modify: src/Integrations/Divi/Design/DesignRights.php (themeOptions(), fonts(), private themeRight(), Konstante ROLE_EDITOR)
  • Modify: tests/Unit/Integrations/Divi/Design/DesignRightsTest.php

Interfaces:

  • Consumes: Manifest, Call, Mirror, DiviAdapter, DiviAdapters::CLASSES, ReleaseGate::assertFeature(), DiviLocked, Errors::forbidden(), DesignSystemWriter (alle vorhanden), DiviFeature::ThemeOptionRead/ThemeOptionWrite/FontUpload (Task 1); Tests: LIT\MCP\Tests\Unit\TestCase (assertToolError, Stub absint), ReleaseMatrices::uniform(ReleaseState, string), Brain Monkey Functions\when()/expect(), Stub \ET_Core_PageResource::$removed.

  • Produces:

    • class ThemeOptionsAdapter implements DiviAdapter – manifest(): Bereich Manifest::DESIGN; Aufrufe et_sanitize_font_choices (1), et_sanitize_global_font_weight (1), et_sanitize_float_number (1), et_sanitize_int_number (1), et_divi_global_font_style (1), et_update_option (2), et_delete_option (1), ET_Core_PageResource::remove_static_resources (2), et_pb_force_regenerate_templates (0), et_pb_is_allowed (1); Schlüssel et_divi; Mirror customizer-typography (functions.php, et_divi_customizer_theme_settings, class: null, Fähigkeiten [ThemeOptionRead, ThemeOptionWrite]); Vorgabe [ThemeOptionWrite]. public const SANITIZERS (siehe oben). read(): array<string, int|float|string|false> – die vorhandenen Whitelist-Schlüssel aus get_option('et_divi') in SANITIZERS-Reihenfolge, Werte anderen Typs (Array, null, true) fehlen. sanitize(string $key, mixed $value): mixed – Divis Sanitizer des Schlüssels, Nicht-Skalare → false ohne Divi-Aufruf, unbekannter Schlüssel → InvalidArgumentException('not a theme option of the whitelist: <key>'). write(array $set, array $unset): array – ReleaseGate::assertFeature(ThemeOptionWrite), fremde Schlüssel → InvalidArgumentException (nichts geschrieben), dann et_update_option je $set-Schlüssel, et_delete_option je $unset-Schlüssel, \ET_Core_PageResource::remove_static_resources('all', 'all'), et_pb_force_regenerate_templates(), Zurücklesen über read(); weicht ein Wert ab (!==) oder ist ein $unset-Schlüssel noch da → RuntimeException('theme options not written: <keys>'); sonst liefert es read(). canCustomize(): bool = function_exists('et_pb_is_allowed') && et_pb_is_allowed('theme_customizer').
    • class FontCatalog implements DiviAdapter – manifest(): Aufrufe et_builder_get_fonts (0), et_builder_get_custom_fonts (0), et_builder_get_google_fonts (0), et_builder_get_websafe_fonts (0) mit der Vorgabe [ThemeOptionWrite, FontUpload, VariableWrite] (W1), et_pb_is_allowed (1) nur [FontUpload]; Schlüssel et_uploaded_fonts, et_google_fonts_cache, et_google_api_settings (Dokumentation: die Optionen hinter Divis Listen, gelesen nur über Divis Funktionen). names(): list<string> – Schlüssel von et_builder_get_custom_fonts() dann et_builder_get_fonts() (genau Divis Sanitizer-Liste), je Instanz einmal geladen; uploaded(): list<string> und reserved(): list<string> (Google, Web-Safe, hochgeladen) je Aufruf frisch; has(string $name): bool exakt (in_array(…, true)); canManage(): bool = et_pb_is_allowed('custom_fonts_management'), ohne Divi false. Nicht-Array-Listen (kaputter Filter) zählen als leer; numerische Familiennamen bleiben Strings.
    • DesignRights::themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options): ?WP_Error – edit_theme_options, dann canCustomize(); $writer wird nicht gefragt (siehe ## ABWEICHUNGEN, B2). Meldungen: „Theme-Optionen ändern erfordert das Recht, das Design der Website anzupassen (edit_theme_options).“ / „Die Divi-Rolle dieses Kontos erlaubt die Theme-Anpassung nicht; ohne sie ändert LIT MCP keine Theme-Optionen.“ (Hinweis Rollen-Editor).
    • DesignRights::fonts(FontCatalog $fonts): ?WP_Error – upload_files („Schriften hochladen erfordert das Recht, Dateien hochzuladen (upload_files).“), edit_theme_options („Schriften hochladen erfordert das Recht, …“), canManage() („Die Divi-Rolle dieses Kontos erlaubt „Eigene Schriften verwalten“ nicht; ohne das Recht lädt LIT MCP keine Schriften hoch.“, Hinweis Rollen-Editor).
    • presets(), colors(), variables() verhalten sich unverändert (gleiche Texte; designer() nutzt jetzt themeRight()).
  • Step 1: Failing Test ThemeOptionsAdapterTest schreiben

Datei tests/Unit/Integrations/Divi/Design/ThemeOptionsAdapterTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Brain\Monkey\Functions;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
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\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use RuntimeException;

/**
 * Divi's sanitizers, et_update_option(), et_delete_option(), et_pb_force_regenerate_templates() and
 * et_pb_is_allowed() are faked with Brain Monkey; those tests run in their own process (Brain Monkey defines a missing
 * function for the rest of the process, and other tests rely on Divi's functions being absent). ET_Core_PageResource
 * is the recording stub of tests/Stubs.
 */
final class ThemeOptionsAdapterTest extends TestCase
{
    /** Plan 4d W2, in this order. */
    private 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',
    ];

    public function testIsARegisteredDesignAdapterForThemeOptions(): void
    {
        $manifest = ThemeOptionsAdapter::manifest();

        self::assertContains(ThemeOptionsAdapter::class, DiviAdapters::CLASSES);
        self::assertSame(Manifest::DESIGN, $manifest->area);
        self::assertSame(
            [
                'et_sanitize_font_choices()', 'et_sanitize_global_font_weight()', 'et_sanitize_float_number()',
                'et_sanitize_int_number()', 'et_divi_global_font_style()', 'et_update_option()', 'et_delete_option()',
                'ET_Core_PageResource::remove_static_resources()', 'et_pb_force_regenerate_templates()',
                'et_pb_is_allowed()',
            ],
            array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
        );
        self::assertSame(
            [1, 1, 1, 1, 1, 2, 1, 2, 0, 1],
            array_map(static fn (Call $call): ?int => $call->args, $manifest->calls)
        );
        self::assertSame(['et_divi'], $manifest->keys);
        self::assertSame([DiviFeature::ThemeOptionWrite], $manifest->features);

        self::assertCount(1, $manifest->mirrors);
        $mirror = $manifest->mirrors[0];
        self::assertSame('customizer-typography', $mirror->id);
        self::assertSame('functions.php', $mirror->file);
        self::assertSame('et_divi_customizer_theme_settings', $mirror->function);
        self::assertNull($mirror->class);
        self::assertSame(
            [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite],
            $manifest->featuresOf($mirror)
        );
    }

    public function testTheSanitizerTableCoversTheWhitelistWithDivisCallsOfTheManifest(): void
    {
        self::assertSame(self::KEYS, array_keys(ThemeOptionsAdapter::SANITIZERS));

        $divi = array_values(array_unique(array_diff(ThemeOptionsAdapter::SANITIZERS, ['absint'])));
        sort($divi);
        self::assertSame(
            [
                'et_divi_global_font_style', 'et_sanitize_float_number', 'et_sanitize_font_choices',
                'et_sanitize_global_font_weight', 'et_sanitize_int_number',
            ],
            $divi
        );
        $calls = array_map(
            static fn (Call $call): string => $call->symbol . '/' . $call->args,
            ThemeOptionsAdapter::manifest()->calls
        );
        foreach ($divi as $sanitizer) {
            self::assertContains($sanitizer . '/1', $calls, 'every Divi sanitizer is a call of the manifest');
        }
    }

    public function testSanitizeRefusesAKeyOutsideTheWhitelist(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('not a theme option of the whitelist: accent_color');

        (new ThemeOptionsAdapter())->sanitize('accent_color', '#ff0000');
    }

    public function testSizesGoThroughWordPressAbsintAndNonScalarsAreRefused(): void
    {
        $adapter = new ThemeOptionsAdapter();

        self::assertSame(15, $adapter->sanitize('body_font_size', '15.7'), 'absint rounds, the caller compares');
        self::assertSame(1200, $adapter->sanitize('content_width', 1200));
        self::assertFalse($adapter->sanitize('row_padding', ['2']));
    }

    /** @runInSeparateProcess */
    public function testSanitizeCallsDivisCustomizerSanitizerOfTheKey(): void
    {
        Functions\expect('et_sanitize_font_choices')->once()->with('Roboto')->andReturn('Roboto');
        Functions\expect('et_sanitize_global_font_weight')->once()->with('600')->andReturn('600');
        Functions\expect('et_sanitize_float_number')->once()->with('1.8')->andReturn(1.8);
        Functions\expect('et_sanitize_int_number')->once()->with('-1')->andReturn(-1);
        Functions\expect('et_divi_global_font_style')->once()->with('italic|uppercase')->andReturn(false);
        $adapter = new ThemeOptionsAdapter();

        self::assertSame('Roboto', $adapter->sanitize('secondary_nav_font', 'Roboto'));
        self::assertSame('600', $adapter->sanitize('body_font_weight', '600'));
        self::assertSame(1.8, $adapter->sanitize('body_header_height', '1.8'));
        self::assertSame(-1, $adapter->sanitize('body_header_spacing', '-1'));
        self::assertFalse($adapter->sanitize('body_header_style', 'italic|uppercase'), 'Divi refuses combinations');
    }

    public function testReadReturnsThePresentWhitelistKeysWithTheirStoredTypes(): void
    {
        Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi'
            ? [
                'row_padding' => null,
                'accent_color' => '#ff0000',
                'body_font' => 'Roboto',
                'body_font_height' => 1.8,
                'gutter_width' => ['3'],
                'heading_font' => false,
                'body_font_size' => 15,
            ]
            : false);

        self::assertSame(
            ['heading_font' => false, 'body_font' => 'Roboto', 'body_font_size' => 15, 'body_font_height' => 1.8],
            (new ThemeOptionsAdapter())->read(),
            'whitelist only, in W2 order; values of another type are not theme option values'
        );
    }

    public function testReadWithoutThemeOptionsIsEmpty(): void
    {
        Functions\when('get_option')->justReturn(false);

        self::assertSame([], (new ThemeOptionsAdapter())->read());
    }

    public function testWritesNeedAReleasedDivi(): void
    {
        ReleaseGate::useResolver(
            static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
        );

        $this->expectException(DiviLocked::class);

        (new ThemeOptionsAdapter())->write(['body_font_size' => 16], []);
    }

    public function testWriteRefusesAKeyOutsideTheWhitelistBeforeWriting(): void
    {
        // Unit tests have no et_update_option(): reaching it would be an Error, not this exception.
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('not a theme option of the whitelist: accent_color, divi_logo');

        (new ThemeOptionsAdapter())->write(['body_font_size' => 16, 'accent_color' => '#f00'], ['divi_logo']);
    }

    /** @runInSeparateProcess */
    public function testWriteUpdatesAndDeletesPerKeyClearsTheCachesAndReadsBack(): void
    {
        $stored = ['body_font_size' => 14, 'heading_font' => 'Roboto', 'accent_color' => '#ff0000'];
        $log = [];
        Functions\when('get_option')->alias(static function (string $name) use (&$stored): mixed {
            return $name === 'et_divi' ? $stored : false;
        });
        Functions\when('et_update_option')->alias(static function (string $key, mixed $value) use (
            &$stored,
            &$log
        ): void {
            $stored[$key] = $value;
            $log[] = 'update ' . $key;
        });
        Functions\when('et_delete_option')->alias(static function (string $key) use (&$stored, &$log): void {
            unset($stored[$key]);
            $log[] = 'delete ' . $key;
        });
        Functions\when('et_pb_force_regenerate_templates')->alias(static function () use (&$log): void {
            $log[] = 'regenerate templates after ' . count(\ET_Core_PageResource::$removed) . ' static CSS clear';
        });
        \ET_Core_PageResource::$removed = [];

        $result = (new ThemeOptionsAdapter())->write(
            ['body_font_size' => 16, 'body_font_height' => 1.8],
            ['heading_font']
        );

        self::assertSame(['body_font_size' => 16, 'body_font_height' => 1.8], $result);
        self::assertSame(
            [
                'update body_font_size', 'update body_font_height', 'delete heading_font',
                'regenerate templates after 1 static CSS clear',
            ],
            $log
        );
        self::assertSame([['all', 'all', false]], \ET_Core_PageResource::$removed, 'like a customizer save');
        self::assertSame('#ff0000', $stored['accent_color'], 'other theme options stay');
    }

    /** @runInSeparateProcess */
    public function testWriteThrowsWhenTheReadBackDiffers(): void
    {
        $stored = ['body_font_size' => 14, 'heading_font' => 'Roboto'];
        Functions\when('get_option')->alias(static fn (string $name): mixed => $name === 'et_divi' ? $stored : false);
        // Divi silently saves nothing (e.g. a filter or another process).
        Functions\when('et_update_option')->justReturn(null);
        Functions\when('et_delete_option')->justReturn(null);
        Functions\when('et_pb_force_regenerate_templates')->justReturn(null);

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('theme options not written: body_font_size, heading_font');

        (new ThemeOptionsAdapter())->write(['body_font_size' => 16], ['heading_font']);
    }

    public function testWithoutDiviNobodyCustomizesTheTheme(): void
    {
        // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
        self::assertFalse((new ThemeOptionsAdapter())->canCustomize());
    }

    /** @runInSeparateProcess */
    public function testCanCustomizeAsksTheThemeCustomizerRight(): void
    {
        Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'theme_customizer');

        self::assertTrue((new ThemeOptionsAdapter())->canCustomize());
    }
}
  • Step 2: Failing Test FontCatalogTest schreiben

Datei tests/Unit/Integrations/Divi/Design/FontCatalogTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\Manifest;
use LIT\MCP\Tests\Unit\TestCase;

/**
 * Divi's font lists and et_pb_is_allowed() are faked with Brain Monkey; those tests run in their own process (Brain
 * Monkey defines a missing function for the rest of the process, and other tests rely on Divi's functions being
 * absent).
 */
final class FontCatalogTest extends TestCase
{
    public function testIsARegisteredDesignAdapterForTheFontLists(): void
    {
        $manifest = FontCatalog::manifest();

        self::assertContains(FontCatalog::class, DiviAdapters::CLASSES);
        self::assertSame(Manifest::DESIGN, $manifest->area);
        self::assertSame(
            [
                'et_builder_get_fonts()', 'et_builder_get_custom_fonts()', 'et_builder_get_google_fonts()',
                'et_builder_get_websafe_fonts()', 'et_pb_is_allowed()',
            ],
            array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
        );
        self::assertSame([0, 0, 0, 0, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls));
        self::assertSame(['et_uploaded_fonts', 'et_google_fonts_cache', 'et_google_api_settings'], $manifest->keys);
        // Plan 4d W1: a broken font list locks every tool that checks font names against it.
        self::assertSame(
            [DiviFeature::ThemeOptionWrite, DiviFeature::FontUpload, DiviFeature::VariableWrite],
            $manifest->features
        );
        self::assertSame([DiviFeature::FontUpload], $manifest->featuresOf($manifest->calls[4]));
    }

    /** @runInSeparateProcess */
    public function testNamesAreTheExactFamiliesOfDivisSanitizerLoadedOnce(): void
    {
        Functions\expect('et_builder_get_custom_fonts')->once()->andReturn(['Hausschrift' => [], '1942' => []]);
        Functions\expect('et_builder_get_fonts')->once()
            ->andReturn(['Arial' => [], 'Open Sans' => [], 'Roboto' => []]);
        $catalog = new FontCatalog();

        self::assertSame(['Hausschrift', '1942', 'Arial', 'Open Sans', 'Roboto'], $catalog->names());
        self::assertTrue($catalog->has('Roboto'));
        self::assertTrue($catalog->has('1942'), 'a numeric family name stays a string');
        self::assertFalse($catalog->has('roboto'), 'exact, like Divi');
        self::assertFalse($catalog->has('none'), 'Divi stores the default font as false, never as a name');
        self::assertSame($catalog->names(), $catalog->names());
    }

    /** @runInSeparateProcess */
    public function testUploadedAndReservedAreReadFreshEachTime(): void
    {
        Functions\expect('et_builder_get_custom_fonts')->twice()
            ->andReturn(['Hausschrift' => []], ['Hausschrift' => [], 'Neu' => []]);
        Functions\when('et_builder_get_google_fonts')->justReturn(['Roboto' => []]);
        Functions\when('et_builder_get_websafe_fonts')->justReturn(['Arial' => [], 'Roboto' => []]);
        $catalog = new FontCatalog();

        self::assertSame(['Hausschrift'], $catalog->uploaded());
        self::assertSame(['Roboto', 'Arial', 'Hausschrift', 'Neu'], $catalog->reserved(), 'after an upload');
    }

    /** @runInSeparateProcess */
    public function testABrokenListCountsAsEmpty(): void
    {
        // A filter of et_builder_google_fonts that returns no array (Divi passes it on unchecked).
        Functions\when('et_builder_get_google_fonts')->justReturn(null);
        Functions\when('et_builder_get_websafe_fonts')->justReturn(['Arial' => []]);
        Functions\when('et_builder_get_custom_fonts')->justReturn([]);

        self::assertSame(['Arial'], (new FontCatalog())->reserved());
    }

    public function testWithoutDiviNobodyManagesFonts(): void
    {
        // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
        self::assertFalse((new FontCatalog())->canManage());
    }

    /** @runInSeparateProcess */
    public function testCanManageAsksTheCustomFontsRight(): void
    {
        Functions\when('et_pb_is_allowed')->alias(static fn (string $cap): bool => $cap === 'custom_fonts_management');

        self::assertTrue((new FontCatalog())->canManage());
    }
}
  • Step 3: Gepinnte Tabelle in DiviFeatureAssignmentTest erweitern
diff --git a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php
index 5033b0f..1b32ee5 100644
--- a/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php
+++ b/tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php
@@ -6,8 +6,10 @@ namespace LIT\MCP\Tests\Unit\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;
@@ -82,6 +84,26 @@ final class DiviFeatureAssignmentTest extends TestCase
             'GlobalData::set_global_variables()' => ['variable-write'],
             'et_pb_is_allowed()' => ['variable-write'],
         ],
+        ThemeOptionsAdapter::class => [
+            'et_sanitize_font_choices()' => ['theme-option-write'],
+            'et_sanitize_global_font_weight()' => ['theme-option-write'],
+            'et_sanitize_float_number()' => ['theme-option-write'],
+            'et_sanitize_int_number()' => ['theme-option-write'],
+            'et_divi_global_font_style()' => ['theme-option-write'],
+            'et_update_option()' => ['theme-option-write'],
+            'et_delete_option()' => ['theme-option-write'],
+            'ET_Core_PageResource::remove_static_resources()' => ['theme-option-write'],
+            'et_pb_force_regenerate_templates()' => ['theme-option-write'],
+            'et_pb_is_allowed()' => ['theme-option-write'],
+            'Nachbau et_divi_customizer_theme_settings()' => ['theme-option-read', 'theme-option-write'],
+        ],
+        FontCatalog::class => [
+            'et_builder_get_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'],
+            'et_builder_get_custom_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'],
+            'et_builder_get_google_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'],
+            'et_builder_get_websafe_fonts()' => ['font-upload', 'theme-option-write', 'variable-write'],
+            'et_pb_is_allowed()' => ['font-upload'],
+        ],
         DiviModules::class => [
             'ModuleRegistration::get_all_core_modules_metadata()' => ['catalog'],
             'ModuleRegistration::get_default_attrs()' => ['catalog'],
@@ -143,7 +165,7 @@ final class DiviFeatureAssignmentTest extends TestCase
         }
 
         self::assertSame(self::EXPECTED, $actual);
-        self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all ten adapters, in report order');
+        self::assertSame(array_keys(self::EXPECTED), DiviAdapters::CLASSES, 'all twelve adapters, in report order');
     }
 
     public function testReadingAdaptersCarryNoWriteFeature(): void
  • Step 4: RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'ThemeOptionsAdapterTest|FontCatalogTest|DiviFeatureAssignmentTest' Expected: FAIL – Tests: 24, Assertions: 25, Errors: 15, Failures: 6: fünfzehn Mal Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter" not found bzw. …FontCatalog" not found, vier Mal Failed asserting that exception of type "Error" matches expected exception … (gleiche Ursache), dazu DiviFeatureAssignmentTest::testEveryFeatureIsServedByAtLeastOneRequirement (aus Task 1) und ::testTheAssignmentIsThePinnedTable.

  • Step 5: ThemeOptionsAdapter implementieren

Datei src/Integrations/Divi/Design/ThemeOptionsAdapter.php anlegen:

<?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');
    }
}
  • Step 6: FontCatalog implementieren

Datei src/Integrations/Divi/Design/FontCatalog.php anlegen (die Hilfsfunktion heißt namesOf, nicht families…: PHPCompatibility meldet Funktionsnamen mit dem Präfix fam als entfernte PHP-Erweiterung fam):

<?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));
    }
}
  • Step 7: Adapter in DiviAdapters::CLASSES aufnehmen
diff --git a/src/Integrations/Divi/Release/DiviAdapters.php b/src/Integrations/Divi/Release/DiviAdapters.php
index 5e09753..293e76f 100644
--- a/src/Integrations/Divi/Release/DiviAdapters.php
+++ b/src/Integrations/Divi/Release/DiviAdapters.php
@@ -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,
     ];
  • Step 8: GREEN (ohne Fingerabdrücke)

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'ThemeOptionsAdapterTest|FontCatalogTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviFeatureTest' Expected: OK (31 tests, 171 assertions) (u. a. DiviBoundaryTest::testAdaptersDeclareExactlyTheDiviNamesTheyUse: die Adapter nutzen genau die Namen ihres Manifests – die Sanitizer-Strings der Tabelle, et_divi, die Funktionsaufrufe, ET_Core_PageResource; 'theme_customizer', 'custom_fonts_management', 'functions.php' und 'absint' sind für DiviTouches keine Divi-Namen).

Run: composer test Expected: Tests: 3140, …, Failures: 5 – vier in DiviCheckCommandTest (testUpdateReferenceWritesNothingWhenASourceIsUnwritable, …WhenASignatureIsUnreadable, …WritesFingerprintsSignaturesAndSources, …KeepsItsOwnExitCodeAndStillPrintsTheMatrix: …/Divi/functions.php is not readable – der Temp-Theme-Ordner hat noch keinen Stand-in für den neuen Nachbau, Step 9) und DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource (in fingerprints.json fehlen customizer-typography und zwölf sig:-Schlüssel, Step 12).

  • Step 9: DiviCheckCommandTest um den siebten Nachbau ergänzen
diff --git a/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php b/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php
index 3098480..6c914bd 100644
--- a/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php
+++ b/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php
@@ -228,14 +228,14 @@ final class DiviCheckCommandTest extends TestCase
         self::assertSame(0, $code);
         self::assertSame(
             [
-                'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save',
-                'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
+                'customizer-typography', 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed',
+                'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
             ],
             array_keys(DiviReference::expected($this->expectedFile))
         );
         self::assertSame(str_repeat('b', 64), DiviReference::expected($this->expectedFile)['sig:A::b']);
         self::assertContains(
-            'Referenz aktualisiert (6 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.',
+            'Referenz aktualisiert (7 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.',
             $this->lines
         );
         self::assertStringStartsWith(
@@ -244,10 +244,11 @@ final class DiviCheckCommandTest extends TestCase
         );
     }
 
-    /** Minimal stand-ins for the six mirrored Divi functions at their real paths. */
+    /** Minimal stand-ins for the seven mirrored Divi functions at their real paths. */
     private function writeDivi(): void
     {
         $files = [
+            'functions.php' => "function et_divi_customizer_theme_settings( \$wp_customize ) {\n}",
             'includes/builder/shortcode-core.php' => "function et_pb_submit_layout( \$args ) {\n\treturn 1;\n}",
             'includes/builder/frontend-builder/theme-builder/api.php' => "function et_theme_builder_api_save() {\n}",
             'includes/builder/frontend-builder/theme-builder/theme-builder.php' =>

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviCheckCommandTest Expected: OK (17 tests, 50 assertions) (vorher Tests: 17, Assertions: 42, Failures: 4).

  • Step 10: Failing Tests für DesignRights::themeOptions()/fonts() schreiben
diff --git a/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php b/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php
index 0714c59..7857e7b 100644
--- a/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php
+++ b/tests/Unit/Integrations/Divi/Design/DesignRightsTest.php
@@ -7,7 +7,9 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;
 use Brain\Monkey\Functions;
 use LIT\MCP\Integrations\Divi\Design\DesignRights;
 use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
+use LIT\MCP\Integrations\Divi\Design\FontCatalog;
 use LIT\MCP\Integrations\Divi\Design\GlobalVariablesAdapter;
+use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
 use LIT\MCP\Tests\Unit\TestCase;
 use Mockery;
 
@@ -73,4 +75,46 @@ final class DesignRightsTest extends TestCase
         $refusal = self::assertToolError(DesignRights::variables($writer, $variables), 'forbidden');
         self::assertStringContainsString('Variablen ändern erfordert', $refusal['message']);
     }
+
+    public function testThemeOptionsNeedEditThemeOptionsAndTheThemeCustomizerRight(): void
+    {
+        $writer = Mockery::mock(DesignSystemWriter::class);
+        // Divi's customizer does not need the Visual Builder (plan 4d, rights).
+        $writer->expects('canUseVisualBuilder')->never();
+        $options = Mockery::mock(ThemeOptionsAdapter::class);
+        $options->allows('canCustomize')->andReturn(true, false);
+        Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
+
+        self::assertNull(DesignRights::themeOptions($writer, $options));
+        $refusal = self::assertToolError(DesignRights::themeOptions($writer, $options), 'forbidden');
+        self::assertStringContainsString('Theme-Anpassung', $refusal['message']);
+        self::assertStringContainsString('keine Theme-Optionen', $refusal['message']);
+        self::assertStringContainsString('Rollen-Editor', $refusal['hint']);
+
+        Functions\when('current_user_can')->justReturn(false);
+        $refusal = self::assertToolError(DesignRights::themeOptions($writer, $options), 'forbidden');
+        self::assertStringContainsString('Theme-Optionen ändern erfordert', $refusal['message']);
+        self::assertStringContainsString('edit_theme_options', $refusal['message']);
+    }
+
+    public function testFontsNeedUploadFilesEditThemeOptionsAndTheFontsRight(): void
+    {
+        $fonts = Mockery::mock(FontCatalog::class);
+        $fonts->allows('canManage')->andReturn(true, false);
+        Functions\when('current_user_can')->justReturn(true);
+
+        self::assertNull(DesignRights::fonts($fonts));
+        $refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden');
+        self::assertStringContainsString('Eigene Schriften verwalten', $refusal['message']);
+        self::assertStringContainsString('Rollen-Editor', $refusal['hint']);
+
+        Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'edit_theme_options');
+        $refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden');
+        self::assertStringContainsString('upload_files', $refusal['message']);
+
+        Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'upload_files');
+        $refusal = self::assertToolError(DesignRights::fonts($fonts), 'forbidden');
+        self::assertStringContainsString('Schriften hochladen erfordert', $refusal['message']);
+        self::assertStringContainsString('edit_theme_options', $refusal['message']);
+    }
 }

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DesignRightsTest Expected: FAIL – Tests: 6, Assertions: 33, Errors: 2: Error: Call to undefined method LIT\MCP\Integrations\Divi\Design\DesignRights::themeOptions() und …::fonts().

  • Step 11: DesignRights erweitern

src/Integrations/Divi/Design/DesignRights.php vollständig ersetzen (unfilteredHtml(), markup(), presets(), colors(), variables() mit unveränderten Texten; die drei gleichen Rollen-Editor-Hinweise stehen jetzt in ROLE_EDITOR, die edit_theme_options-Prüfung in themeRight()):

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use Closure;
use LIT\MCP\Abilities\Errors;
use WP_Error;

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". 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.';

    /** @return Closure(): bool Whether the current user may save unfiltered HTML. */
    public static function unfilteredHtml(): Closure
    {
        return static fn (): bool => current_user_can('unfiltered_html');
    }

    /**
     * Refuses the write when the user lacks unfiltered_html and $markupIds (PresetBook::markupOutside()) is not empty.
     *
     * @param Closure(): bool $unfiltered
     * @param list<string>    $markupIds
     */
    public static function markup(
        Closure $unfiltered,
        array $markupIds,
        string $message = self::OTHER_MARKUP
    ): ?WP_Error {
        if ($markupIds === [] || $unfiltered()) {
            return null;
        }

        return Errors::forbidden(
            $message,
            'Einen Administrator (mit dem Recht unfiltered_html) die Änderung vornehmen lassen.'
        );
    }

    public static function presets(DesignSystemWriter $writer): ?WP_Error
    {
        return self::designer($writer, 'Presets');
    }

    /** Colors: the same rights as presets (spec §10 – stricter than Divi, which asks edit_theme_options only). */
    public static function colors(DesignSystemWriter $writer): ?WP_Error
    {
        return self::designer($writer, 'Farben');
    }

    /** Variables: also Divi's role right "Variables Manager" – without it Divi silently saves nothing (plan 4c V7). */
    public static function variables(DesignSystemWriter $writer, GlobalVariablesAdapter $variables): ?WP_Error
    {
        $refusal = self::designer($writer, 'Variablen');
        if ($refusal !== null || $variables->canManage()) {
            return $refusal;
        }

        return Errors::forbidden(
            'Die Divi-Rolle dieses Kontos erlaubt den Variablen-Manager nicht; ohne ihn speichert Divi keine '
                . 'Variablen.',
            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
    {
        $refusal = self::themeRight($what . ' ändern');
        if ($refusal !== null) {
            return $refusal;
        }
        if (! $writer->canUseVisualBuilder()) {
            return Errors::forbidden(
                sprintf(
                    'Die Divi-Rolle dieses Kontos erlaubt den Visual Builder nicht; ohne ihn ändert LIT MCP keine %s.',
                    $what
                ),
                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
        ));
    }
}

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DesignRightsTest Expected: OK (6 tests, 61 assertions).

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'DesignRightsTest|DiviPresets|DiviDesign|DiviColors|DiviVariables' Expected: OK (144 tests, 1233 assertions) (die Preset-, Farb- und Variablen-Tools sehen dieselben Meldungen wie vorher).

  • Step 12: Signatur-Referenzen und Nachbau-Fingerabdruck in DDEV erzeugen

wp lit-mcp divi-check --update-reference (src/Integrations/Divi/Cli/DiviCheckCommand.php::updateReference()) verlangt einen Administrator, schreibt nur, wenn außer „Fingerabdrücke nachgebauter Divi-Funktionen“ alle Prüfschritte grün sind (Manifeste: die zwölf neuen Aufrufe existieren mit passender Argumentzahl – geprüft über getNumberOfRequiredParameters() <= args <= getNumberOfParameters(); die fehlende Signatur-Referenz und der neue Nachbau sind nur reference-missing-Probleme), und nur in einer Entwicklungskopie mit tests/ (DiviReference::sourceDir()). Es schreibt zuerst die Referenzquellen aller Nachbauten (die sechs alten unverändert, neu customizer-typography.txt), zuletzt fingerprints.json (Nachbauten + sig: jedes Aufrufs aus DiviAdapters::calls()).

Run:

cd /home/janniclas/Projekte/WordPress
ddev start
ddev exec wp plugin list --status=active --field=name
ddev exec bash -c 'wp --user="$(wp user list --role=administrator --field=user_login | head -1)" lit-mcp divi-check --update-reference'

Expected: die Prüfschritte mit ✓ (Fingerabdrücke nachgebauter Divi-Funktionen darf ✗ sein), dann Referenz aktualisiert (7 Fingerabdrücke, 44 Signaturen). Smoke-Skripte laufen lassen, dann committen. und die Fähigkeiten-Matrix; Exit-Code 0. Bricht WP-CLI mit Monolog-/Deprecation-Ausgaben ab, vorher ddev exec wp plugin deactivate postlos asynclistry und danach wieder ddev exec wp plugin activate postlos asynclistry (nur, wenn die Liste oben sie als aktiv zeigte).

Ergebnis prüfen:

cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git diff -U0 src/Integrations/Divi/Release/fingerprints.json | grep '^[-+] '
git status --short tests/Fixtures/divi/reference
jq -r 'keys[]' src/Integrations/Divi/Release/fingerprints.json | grep -c '^sig:'
jq -r '."customizer-typography"' src/Integrations/Divi/Release/fingerprints.json

Expected: genau dreizehn +-Zeilen und keine --Zeile – "customizer-typography": "0264fa5a2ba604542e1e1ad0c0339f04c8f4f67297087a0f92954ee63d343fd0" und die sig:-Schlüssel et_builder_get_custom_fonts, et_builder_get_fonts, et_builder_get_google_fonts, et_builder_get_websafe_fonts, et_delete_option, et_divi_global_font_style, et_pb_force_regenerate_templates, et_sanitize_float_number, et_sanitize_font_choices, et_sanitize_global_font_weight, et_sanitize_int_number, et_update_option (je "sig:<name>": "<sha256>"); ?? tests/Fixtures/divi/reference/customizer-typography.txt als einzige Änderung unter reference (beginnt mit function et_divi_customizer_theme_settings( $wp_customize ) {, ~104 KB); 44; der Hash oben (in der Kopie mit SourceFingerprint über das installierte functions.php nachgerechnet). Ändert sich ein anderer Schlüssel oder weicht der Hash ab, nicht committen, sondern prüfen (installiertes Divi ≠ 5.15.1 oder eine Divi-Signatur hat sich geändert).

  • Step 13: Gesamtlauf, Lint, Freigabe-Smoke

Run: composer test && composer lint Expected: OK (3142 tests, …) (in der Kopie mit nachgestellten Referenzen: OK (3142 tests, 19539 assertions)), Lint sauber.

Run: cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-release-smoke.sh Expected: ALL CHECKS PASSED – u. a. ok - manifests and fingerprints match the installed Divi (keine reference-missing-Probleme für die neuen Aufrufe und den Nachbau) und ok - lit-site-status: sixteen features released (code), each with its tools. Läuft nur mit deaktivierten Plugins postlos und asynclistry (vorher ddev exec wp plugin list notieren, danach den Zustand wiederherstellen).

  • Step 14: Commit (Tasks 1 und 2)
cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Release/DiviFeature.php tests/Unit/Integrations/Divi/Release/DiviFeatureTest.php \
  tests/Integration/divi-release-smoke.sh \
  src/Integrations/Divi/Design/ThemeOptionsAdapter.php src/Integrations/Divi/Design/FontCatalog.php \
  tests/Unit/Integrations/Divi/Design/ThemeOptionsAdapterTest.php tests/Unit/Integrations/Divi/Design/FontCatalogTest.php \
  src/Integrations/Divi/Release/DiviAdapters.php tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php \
  tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php \
  src/Integrations/Divi/Release/fingerprints.json tests/Fixtures/divi/reference/customizer-typography.txt \
  src/Integrations/Divi/Design/DesignRights.php tests/Unit/Integrations/Divi/Design/DesignRightsTest.php
git commit -m "feat: Fähigkeiten theme-option-read/-write und font-upload; ThemeOptionsAdapter und FontCatalog (Manifest, Nachbau customizer-typography, Signatur-Referenzen) und DesignRights::themeOptions()/fonts()" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 3: Reine Klassen ThemeOptionSpec, ThemeOptionBook, ThemeOptionDiff, FontSuggest

Der reine Bestand der Theme-Optionen (W2, W3, W4, W6, W5): die Tabelle der 20 Customizer-Einstellungen mit Prüfung, Normalisierung und Vererbung (ThemeOptionSpec), der unveränderliche Bestand mit rev() (ThemeOptionBook), der Diff mit Zusammenfassung (ThemeOptionDiff) und die Namensvorschläge für unbekannte Schriften (FontSuggest). Kein WordPress, kein Divi: die Klassen brauchen weder Adapter noch Manifest und sind unabhängig von Task 1 und 2 (Task 4 und 5 setzen sie voraus). Vier Teilblöcke, je eine Klasse und ein Commit; 3c baut auf 3b auf (ThemeOptionDiffTest nutzt ThemeOptionBookTest::stored()), 3b auf 3a. Die Werte der Tabelle sind gegen Divis et_divi_customizer_theme_settings() (functions.php 1903–2561) nachgeprüft: Standards, min/max/step und Sanitizer stimmen mit Tabelle A der Recherche überein (z. B. body_header_spacing −2…10, body_font_height 0,8…3 in 0,1-Schritten, content_width 960…1920).

Files:

  • Create: src/Integrations/Divi/Design/ThemeOptionSpec.php, src/Integrations/Divi/Design/ThemeOptionBook.php, src/Integrations/Divi/Design/ThemeOptionDiff.php, src/Integrations/Divi/Design/FontSuggest.php
  • Create: tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php, tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php, tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php, tests/Unit/Integrations/Divi/Design/FontSuggestTest.php

Interfaces:

  • Consumes: LIT\MCP\Abilities\Rev::of() (vorhanden).
  • Produces (Namen und Signaturen wie im Schnittstellenvertrag; Verfeinerungen siehe ## ABWEICHUNGEN):
    • ThemeOptionSpec::KEYS (20 Schlüssel, Reihenfolge W2), definition(string): array, isKey(string): bool, check(string, mixed): ?string, normalize(string, mixed): int|float|string, effective(string, array): int|float|string|null. definition() und check()/normalize()/effective() werfen InvalidArgumentException bei einem Schlüssel außerhalb der Whitelist.
    • ThemeOptionBook::fromDivi(array), data(), rev(), has(), get(), with(), without(); with() wirft InvalidArgumentException bei unbekanntem Schlüssel.
    • ThemeOptionDiff::between(ThemeOptionBook, ThemeOptionBook), summary(array).
    • FontSuggest::similar(string, list<string>, int = 5).
  • Ausgangslage vor dem Task: composer test = 3121 Tests grün (Stand 2307027).

Einordnung der Prüfregeln (für Task 4 und 5): check() meldet je Schlüssel genau eine deutsche Meldung mit dem Label als Präfix („Fließtext-Schriftgröße: …“), Task 4 setzt davor den Feldpfad. null ist „zurücksetzen“ und wird vom Aufrufer vor check() behandelt; check(…, null) ist trotzdem definiert und lehnt ab. Schriftnamen: nur Form (1–100 Zeichen, keine Steuerzeichen, kein <>"' und Backtick, nicht none); ob die Schrift existiert, prüft FontCatalog (Task 4) mit FontSuggest. Zahlen kommen als Zahl oder als Ziffern-String ("15", "1.7", "-1"), Exponent, Einheit und Leerzeichen sind keine Zahl.

Task 3a: ThemeOptionSpec

Die Tabelle steht als Literal in der Klasse (DEFINITIONS), die geteilten Bereiche als Konstanten (BODY_SIZE, HEADER_SIZE, LINE_HEIGHT, WEIGHT, FONT), damit keine Zeile über 120 Zeichen läuft. Der Mirror customizer-typography (Task 2) hasht Divis Funktion; wer die Tabelle ändert, ändert sie nur nach einem Divi-Update und mit neuem Fingerabdruck.

  • Step 3a.1: Failing Test ThemeOptionSpecTest schreiben

Datei tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
use PHPUnit\Framework\TestCase;

final class ThemeOptionSpecTest extends TestCase
{
    public function testTheTwentyKeysKeepTheOrderOfRulingW2(): void
    {
        self::assertSame([
            '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',
        ], ThemeOptionSpec::KEYS);
    }

    /** @return array<string, array{string, string, int|float|string|null, int|float|null, int|float|null}> */
    public static function definitions(): array
    {
        return [
            'heading_font' => ['heading_font', 'font', null, null, null],
            'body_font' => ['body_font', 'font', null, null, null],
            'primary_nav_font' => ['primary_nav_font', 'font', null, null, null],
            'secondary_nav_font' => ['secondary_nav_font', 'font', null, null, null],
            'heading_font_weight' => ['heading_font_weight', 'weight', '500', null, null],
            'body_font_weight' => ['body_font_weight', 'weight', '500', null, null],
            'body_font_size' => ['body_font_size', 'int', 14, 10, 32],
            'tablet_body_font_size' => ['tablet_body_font_size', 'int', 14, 10, 32],
            'phone_body_font_size' => ['phone_body_font_size', 'int', 14, 10, 32],
            'body_header_size' => ['body_header_size', 'int', 30, 22, 72],
            'tablet_header_font_size' => ['tablet_header_font_size', 'int', 30, 22, 72],
            'phone_header_font_size' => ['phone_header_font_size', 'int', 30, 22, 72],
            'body_font_height' => ['body_font_height', 'float', 1.7, 0.8, 3],
            'body_header_height' => ['body_header_height', 'float', 1.0, 0.8, 3],
            'body_header_spacing' => ['body_header_spacing', 'int', 0, -2, 10],
            'body_header_style' => ['body_header_style', 'style', null, null, null],
            'content_width' => ['content_width', 'int', 1080, 960, 1920],
            'gutter_width' => ['gutter_width', 'int', 3, 1, 4],
            'section_padding' => ['section_padding', 'int', 4, 0, 10],
            'row_padding' => ['row_padding', 'int', 2, 0, 10],
        ];
    }

    /** @dataProvider definitions */
    public function testEveryKeyHasItsDefinition(
        string $key,
        string $type,
        mixed $default,
        mixed $min,
        mixed $max
    ): void
    {
        $definition = ThemeOptionSpec::definition($key);

        self::assertTrue(ThemeOptionSpec::isKey($key));
        self::assertSame($type, $definition['type']);
        self::assertSame($default, $definition['default']);
        self::assertNotSame('', $definition['label']);
        self::assertSame($min, $definition['min'] ?? null);
        self::assertSame($max, $definition['max'] ?? null);
        self::assertSame(in_array($type, ['int', 'float'], true), isset($definition['step']));
        self::assertSame(in_array($type, ['weight', 'style'], true), isset($definition['choices']));
    }

    public function testInheritanceAndChoicesAreDefined(): void
    {
        self::assertSame('body_font_size', ThemeOptionSpec::definition('tablet_body_font_size')['inherits']);
        self::assertSame('tablet_body_font_size', ThemeOptionSpec::definition('phone_body_font_size')['inherits']);
        self::assertSame('body_header_size', ThemeOptionSpec::definition('tablet_header_font_size')['inherits']);
        self::assertSame('tablet_header_font_size', ThemeOptionSpec::definition('phone_header_font_size')['inherits']);
        self::assertArrayNotHasKey('inherits', ThemeOptionSpec::definition('body_font_size'));
        self::assertSame(
            ['100', '200', '300', '400', '500', '600', '700', '800', '900'],
            ThemeOptionSpec::definition('body_font_weight')['choices']
        );
        self::assertSame(
            ['italic', 'uppercase', 'underline'],
            ThemeOptionSpec::definition('body_header_style')['choices']
        );
        self::assertSame(0.1, ThemeOptionSpec::definition('body_font_height')['step']);
    }

    public function testAnUnknownKeyIsNoKeyAndHasNoDefinition(): void
    {
        self::assertFalse(ThemeOptionSpec::isKey('accent_color'));
        $this->expectException(InvalidArgumentException::class);
        ThemeOptionSpec::definition('accent_color');
    }

    /** @return array<string, array{string, mixed}> */
    public static function validValues(): array
    {
        return [
            'int lower bound' => ['body_font_size', 10],
            'int upper bound' => ['body_font_size', 32],
            'int as digits' => ['body_font_size', '15'],
            'int as whole float' => ['body_font_size', 15.0],
            'negative int bound' => ['body_header_spacing', -2],
            'negative digits' => ['body_header_spacing', '-1'],
            'width bounds' => ['content_width', 1920],
            'zero padding' => ['section_padding', 0],
            'float lower bound' => ['body_font_height', 0.8],
            'float upper bound as int' => ['body_font_height', 3],
            'float on a step' => ['body_font_height', 1.7],
            'float as digits' => ['body_header_height', '1.2'],
            'float from int' => ['body_header_height', 1],
            'font' => ['heading_font', 'Open Sans'],
            'font with umlaut and padding' => ['body_font', "  Fjalla Önö \t"],
            'font of 100 characters' => ['primary_nav_font', str_repeat('a', 100)],
            'weight as text' => ['heading_font_weight', '700'],
            'weight as int' => ['body_font_weight', 400],
            'style' => ['body_header_style', 'uppercase'],
        ];
    }

    /** @dataProvider validValues */
    public function testCheckAcceptsValidValues(string $key, mixed $value): void
    {
        self::assertNull(ThemeOptionSpec::check($key, $value));
    }

    /** @return array<string, array{string, mixed, string}> */
    public static function invalidValues(): array
    {
        return [
            'int below' => ['body_font_size', 9, 'außerhalb von 10 bis 32'],
            'int above' => ['body_font_size', 33, 'außerhalb von 10 bis 32'],
            'int digits above' => ['content_width', '1921', 'außerhalb von 960 bis 1920'],
            'negative below' => ['body_header_spacing', -3, 'außerhalb von -2 bis 10'],
            'float with a fraction for an int key' => ['body_font_size', 15.5, 'ganze Zahl'],
            'digits with a fraction for an int key' => ['row_padding', '2.5', 'ganze Zahl'],
            'non-numeric text' => ['body_font_size', 'groß', 'erwartet eine Zahl, erhalten "groß"'],
            'unit suffix' => ['body_font_size', '15px', 'erwartet eine Zahl'],
            'exponent' => ['body_font_size', '1e1', 'erwartet eine Zahl'],
            'spaces around digits' => ['body_font_size', ' 15', 'erwartet eine Zahl'],
            'empty text' => ['gutter_width', '', 'erwartet eine Zahl'],
            'true' => ['body_font_size', true, 'erhalten true'],
            'false' => ['body_font_size', false, 'erhalten false'],
            'null' => ['body_font_size', null, 'erhalten null'],
            'array' => ['body_font_size', [14], 'erhalten einen array'],
            'infinity' => ['body_font_height', INF, 'erwartet eine Zahl'],
            'not a number' => ['body_font_height', NAN, 'erwartet eine Zahl'],
            'float below' => ['body_font_height', 0.7, 'außerhalb von 0,8 bis 3'],
            'float above' => ['body_header_height', 3.1, 'außerhalb von 0,8 bis 3'],
            'float off the step' => ['body_font_height', 1.75, 'Schrittweite 0,1 (ab 0,8)'],
            'float digits off the step' => ['body_header_height', '1.05', 'Schrittweite'],
            'font as number' => ['heading_font', 12, 'erwartet einen Schriftnamen'],
            'font as false' => ['heading_font', false, 'erwartet einen Schriftnamen'],
            'font as null' => ['heading_font', null, 'erhalten null'],
            'empty font' => ['body_font', '', '1 bis 100 Zeichen'],
            'blank font' => ['body_font', "  \t ", '1 bis 100 Zeichen'],
            'font of 101 characters' => ['body_font', str_repeat('a', 101), '1 bis 100 Zeichen'],
            'font with a control character' => ['body_font', "Open\nSans", 'unzulässige Zeichen'],
            'font with angle brackets' => ['body_font', 'Open<Sans>', 'unzulässige Zeichen'],
            'font with a quote' => ['primary_nav_font', 'Open "Sans"', 'unzulässige Zeichen'],
            'font with an apostrophe' => ['secondary_nav_font', "Open'Sans", 'unzulässige Zeichen'],
            'font with invalid utf-8' => ['body_font', "Open\xC3(Sans", 'unzulässige Zeichen'],
            'font none' => ['heading_font', 'None', 'null setzt auf Divis Standardschrift zurück'],
            'weight off the list' => ['body_font_weight', '550', 'erlaubt ist genau ein Wert aus 100, 200'],
            'weight as float' => ['body_font_weight', 500.0, 'erhalten 500'],
            'weight word' => ['heading_font_weight', 'bold', 'erhalten "bold"'],
            'weight as null' => ['heading_font_weight', null, 'erhalten null'],
            'style combination' => ['body_header_style', 'italic|uppercase', 'genau ein Wert'],
            'style empty' => ['body_header_style', '', 'genau ein Wert'],
            'style bold' => ['body_header_style', 'bold', 'genau ein Wert'],
            'style case' => ['body_header_style', 'Italic', 'genau ein Wert'],
            'style as list' => ['body_header_style', ['italic'], 'erhalten einen array'],
        ];
    }

    /** @dataProvider invalidValues */
    public function testCheckRejectsInvalidValuesWithAGermanMessage(string $key, mixed $value, string $needle): void
    {
        $message = ThemeOptionSpec::check($key, $value);

        self::assertIsString($message);
        self::assertStringContainsString($needle, $message);
        self::assertStringStartsWith(ThemeOptionSpec::definition($key)['label'] . ':', $message);
    }

    public function testCheckOfAnUnknownKeyThrows(): void
    {
        $this->expectException(InvalidArgumentException::class);
        ThemeOptionSpec::check('logo', 'x');
    }

    public function testNormalizeCastsToTheTypeOfTheKey(): void
    {
        self::assertSame(15, ThemeOptionSpec::normalize('body_font_size', '15'));
        self::assertSame(15, ThemeOptionSpec::normalize('body_font_size', 15.0));
        self::assertSame(-1, ThemeOptionSpec::normalize('body_header_spacing', '-1'));
        self::assertSame(1.7, ThemeOptionSpec::normalize('body_font_height', '1.7'));
        self::assertSame(1.0, ThemeOptionSpec::normalize('body_header_height', 1));
        self::assertSame('700', ThemeOptionSpec::normalize('heading_font_weight', 700));
        self::assertSame('400', ThemeOptionSpec::normalize('body_font_weight', '400'));
        self::assertSame('Open Sans', ThemeOptionSpec::normalize('heading_font', "  Open Sans\t"));
        self::assertSame('italic', ThemeOptionSpec::normalize('body_header_style', 'italic'));
    }

    public function testEffectiveFallsBackToTheDivisDefaultsWhenNothingIsStored(): void
    {
        self::assertSame(14, ThemeOptionSpec::effective('body_font_size', []));
        self::assertSame(1.7, ThemeOptionSpec::effective('body_font_height', []));
        self::assertSame('500', ThemeOptionSpec::effective('heading_font_weight', []));
        self::assertSame(1080, ThemeOptionSpec::effective('content_width', []));
        self::assertNull(ThemeOptionSpec::effective('heading_font', []));
        self::assertNull(ThemeOptionSpec::effective('body_header_style', []));
        self::assertSame(14, ThemeOptionSpec::effective('phone_body_font_size', []));
        self::assertSame(30, ThemeOptionSpec::effective('phone_header_font_size', []));
    }

    public function testEffectiveInheritsTheNextLargerSizeWhileUnset(): void
    {
        $stored = ['body_font_size' => 18, 'body_header_size' => 40];

        self::assertSame(18, ThemeOptionSpec::effective('tablet_body_font_size', $stored));
        self::assertSame(18, ThemeOptionSpec::effective('phone_body_font_size', $stored));
        self::assertSame(40, ThemeOptionSpec::effective('tablet_header_font_size', $stored));
        self::assertSame(40, ThemeOptionSpec::effective('phone_header_font_size', $stored));
    }

    public function testAStoredOverrideBreaksTheChainAtItsLevelOnly(): void
    {
        $stored = ['body_font_size' => 18, 'tablet_body_font_size' => 16, 'tablet_header_font_size' => 28];

        self::assertSame(18, ThemeOptionSpec::effective('body_font_size', $stored));
        self::assertSame(16, ThemeOptionSpec::effective('tablet_body_font_size', $stored));
        self::assertSame(16, ThemeOptionSpec::effective('phone_body_font_size', $stored));
        self::assertSame(28, ThemeOptionSpec::effective('phone_header_font_size', $stored));
        self::assertSame(30, ThemeOptionSpec::effective('body_header_size', $stored));
        $stored['phone_body_font_size'] = '12';
        self::assertSame(12, ThemeOptionSpec::effective('phone_body_font_size', $stored));
    }

    public function testEffectiveTreatsFalseAndGarbageAsUnsetAndCastsStoredDigits(): void
    {
        self::assertNull(ThemeOptionSpec::effective('heading_font', ['heading_font' => false]));
        self::assertNull(ThemeOptionSpec::effective('heading_font', ['heading_font' => 'none']));
        self::assertSame('Lato', ThemeOptionSpec::effective('heading_font', ['heading_font' => 'Lato']));
        self::assertSame(14, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => 'groß']));
        self::assertSame(15, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => '15']));
        self::assertSame(1.2, ThemeOptionSpec::effective('body_font_height', ['body_font_height' => '1.2']));
        self::assertSame('300', ThemeOptionSpec::effective('body_font_weight', ['body_font_weight' => 300]));
        self::assertSame(40, ThemeOptionSpec::effective('body_font_size', ['body_font_size' => 40]), 'no range check');
        self::assertSame(18, ThemeOptionSpec::effective('tablet_body_font_size', [
            'body_font_size' => 18, 'tablet_body_font_size' => false,
        ]));
    }

    public function testEffectiveOfAnUnknownKeyThrows(): void
    {
        $this->expectException(InvalidArgumentException::class);
        ThemeOptionSpec::effective('nope', []);
    }
}
  • Step 3a.2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php Expected: FAIL – Tests: 91, Assertions: 2, Errors: 89, Failures: 2 (alle 91 Fälle rot; die zwei Failures sind die expectException-Tests, die statt der erwarteten Ausnahme einen Error bekommen); jeder Fehler ist Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec" not found.

  • Step 3a.3: ThemeOptionSpec implementieren

Datei src/Integrations/Divi/Design/ThemeOptionSpec.php anlegen:

<?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),
        };
    }
}
  • Step 3a.4: GREEN und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php && composer lint Expected: OK (91 tests, 354 assertions); phpcs ohne Meldung.

  • Step 3a.5: Commit
git add src/Integrations/Divi/Design/ThemeOptionSpec.php tests/Unit/Integrations/Divi/Design/ThemeOptionSpecTest.php
git commit -m "feat: ThemeOptionSpec – die 20 Theme-Optionen mit Prüfung, Normalisierung und Vererbung (W2–W4)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 3b: ThemeOptionBook

fromDivi() nimmt ausdrücklich auch das ganze et_divi-Array (Defensive: nur Whitelist-Schlüssel, feste Reihenfolge, null und Werte anderer Typen als int|float|string|false fallen weg). rev() hasht kanonisches JSON mit JSON_PRESERVE_ZERO_FRACTION, damit 1 und 1.0 verschieden sind – genau wie "1" und 1 (der Diff zählt Typwechsel).

  • Step 3b.1: Failing Test ThemeOptionBookTest schreiben

Datei tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use PHPUnit\Framework\TestCase;

final class ThemeOptionBookTest extends TestCase
{
    /** @return array<string, mixed> The shape of get_option('et_divi') with the options of other areas around. */
    public static function stored(): array
    {
        return [
            'accent_color' => '#2ea3f2',
            'body_font_size' => 16,
            'heading_font' => false,
            'logo' => 'https://example.test/logo.png',
            'body_font_height' => 1.7,
            'body_header_style' => 'uppercase',
            'body_font' => 'Lato',
        ];
    }

    public function testOnlyWhitelistKeysStayInTheFixedOrderWithTheirRawTypes(): void
    {
        $book = ThemeOptionBook::fromDivi(self::stored());

        self::assertSame([
            'heading_font' => false,
            'body_font' => 'Lato',
            'body_font_size' => 16,
            'body_font_height' => 1.7,
            'body_header_style' => 'uppercase',
        ], $book->data());
        self::assertSame(['heading_font', 'body_font', 'body_font_size', 'body_font_height', 'body_header_style'],
            array_keys($book->data()));
    }

    public function testNullAndValuesOfOtherTypesAreDropped(): void
    {
        $book = ThemeOptionBook::fromDivi([
            'heading_font' => null, 'body_font' => ['Lato'], 'content_width' => true, 'row_padding' => 0,
            'gutter_width' => '3', 'section_padding' => 0.0, 'body_header_spacing' => new \stdClass(),
        ]);

        self::assertSame(['gutter_width' => '3', 'section_padding' => 0.0, 'row_padding' => 0], $book->data());
        self::assertSame([], ThemeOptionBook::fromDivi([])->data());
    }

    public function testHasAndGetDistinguishAFalseValueFromAMissingKey(): void
    {
        $book = ThemeOptionBook::fromDivi(self::stored());

        self::assertTrue($book->has('heading_font'));
        self::assertFalse($book->get('heading_font'));
        self::assertFalse($book->has('primary_nav_font'));
        self::assertNull($book->get('primary_nav_font'));
        self::assertFalse($book->has('logo'));
        self::assertNull($book->get('logo'));
        self::assertSame(16, $book->get('body_font_size'));
    }

    public function testRevIgnoresTheOrderAndOtherKeysButNotValuesOrTheirTypes(): void
    {
        $book = ThemeOptionBook::fromDivi(self::stored());
        $shuffled = ThemeOptionBook::fromDivi(array_reverse(self::stored(), true) + ['phone_body_font_size' => null]);

        self::assertMatchesRegularExpression('/^[0-9a-f]{12}$/', $book->rev());
        self::assertSame($book->rev(), $shuffled->rev());
        self::assertSame($book->rev(), ThemeOptionBook::fromDivi($book->data())->rev());
        self::assertNotSame($book->rev(), $book->with('body_font_size', 17)->rev());
        self::assertNotSame($book->rev(), $book->with('body_font_size', '16')->rev(), '16 versus "16"');
        self::assertNotSame($book->rev(), $book->with('heading_font', 'Lato')->rev(), 'false versus a font');
        self::assertNotSame($book->rev(), $book->without('body_font_size')->rev());
        self::assertNotSame(
            ThemeOptionBook::fromDivi(['body_header_height' => 1])->rev(),
            ThemeOptionBook::fromDivi(['body_header_height' => 1.0])->rev(),
            '1 versus 1.0'
        );
        self::assertNotSame($book->rev(), ThemeOptionBook::fromDivi([])->rev());
    }

    public function testWithAddsOrReplacesInPlaceOfTheFixedOrderAndLeavesTheOriginalAlone(): void
    {
        $book = ThemeOptionBook::fromDivi(self::stored());
        $changed = $book->with('primary_nav_font', 'Roboto')->with('body_font_size', 18);

        self::assertSame(['heading_font', 'body_font', 'primary_nav_font', 'body_font_size', 'body_font_height',
            'body_header_style'], array_keys($changed->data()));
        self::assertSame(18, $changed->get('body_font_size'));
        self::assertSame(16, $book->get('body_font_size'));
        self::assertFalse($book->has('primary_nav_font'));
        self::assertFalse($book->with('body_font', false)->get('body_font'));
    }

    public function testWithRejectsAKeyOutsideTheWhitelist(): void
    {
        $this->expectException(InvalidArgumentException::class);
        ThemeOptionBook::fromDivi([])->with('accent_color', '#fff');
    }

    public function testWithoutRemovesAKeyAndIgnoresAMissingOne(): void
    {
        $book = ThemeOptionBook::fromDivi(self::stored());
        $without = $book->without('heading_font');

        self::assertFalse($without->has('heading_font'));
        self::assertTrue($book->has('heading_font'));
        self::assertSame($book->rev(), $book->without('row_padding')->rev());
        self::assertSame($book->data(), $book->without('logo')->data());
    }
}
  • Step 3b.2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php Expected: FAIL – Tests: 7, Assertions: 1, Errors: 6, Failures: 1; jeder Fehler ist Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionBook" not found.

  • Step 3b.3: ThemeOptionBook implementieren

Datei src/Integrations/Divi/Design/ThemeOptionBook.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use InvalidArgumentException;
use LIT\MCP\Abilities\Rev;

defined('ABSPATH') || exit;

/**
 * The managed theme options as stored in get_option('et_divi') (ruling W6): only the whitelist keys that are
 * set, in the fixed order of ThemeOptionSpec::KEYS, with the raw value and type (int, float, string or false –
 * Divi stores its default font as false). A key that is not set is missing. Takes a snapshot or the whole et_divi
 * array alike. Immutable – with() and without() return a new book. rev() is the design_rev of the area
 * "theme_options". Pure PHP.
 */
final class ThemeOptionBook
{
    /** 1.0 and 1 are different stored values, so the hash must tell them apart (zero fraction kept). */
    private const JSON_FLAGS = JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION;

    /** @param array<string, int|float|string|false> $data */
    private function __construct(private array $data)
    {
    }

    /** @param array<array-key, mixed> $raw Other keys, null and values of other types are dropped. */
    public static function fromDivi(array $raw): self
    {
        $data = [];
        foreach (ThemeOptionSpec::KEYS as $key) {
            $value = $raw[$key] ?? null;
            if ($value === false || is_int($value) || is_float($value) || is_string($value)) {
                $data[$key] = $value;
            }
        }

        return new self($data);
    }

    /** @return array<string, int|float|string|false> */
    public function data(): array
    {
        return $this->data;
    }

    /** Hash of the canonical JSON; the key order is fixed, and 1, 1.0 and "1" differ. */
    public function rev(): string
    {
        // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure PHP (spec §4.4).
        $json = (string) json_encode($this->data, self::JSON_FLAGS);

        return Rev::of('theme_options' . "\0" . $json);
    }

    public function has(string $key): bool
    {
        return array_key_exists($key, $this->data);
    }

    public function get(string $key): int|float|string|false|null
    {
        return $this->data[$key] ?? null;
    }

    /** @throws InvalidArgumentException For a key outside the whitelist. */
    public function with(string $key, int|float|string|false $value): self
    {
        ThemeOptionSpec::definition($key);

        return self::fromDivi([...$this->data, $key => $value]);
    }

    public function without(string $key): self
    {
        return self::fromDivi(array_diff_key($this->data, [$key => true]));
    }
}
  • Step 3b.4: GREEN und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php && composer lint Expected: OK (7 tests, 30 assertions); phpcs ohne Meldung.

  • Step 3b.5: Commit
git add src/Integrations/Divi/Design/ThemeOptionBook.php tests/Unit/Integrations/Divi/Design/ThemeOptionBookTest.php
git commit -m "feat: ThemeOptionBook – unveränderlicher Bestand der Theme-Optionen mit rev() (W6)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 3c: ThemeOptionDiff

Aktionen added (Schlüssel vorher nicht gesetzt), changed (strikter Vergleich, also auch false ↔ Schriftname und 1 ↔ "1"), reset (Schlüssel danach nicht mehr gesetzt). before/after tragen die Rohwerte, null wo der Schlüssel fehlt.

  • Step 3c.1: Failing Test ThemeOptionDiffTest schreiben

Datei tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff;
use PHPUnit\Framework\TestCase;

final class ThemeOptionDiffTest extends TestCase
{
    public function testNoChangeIsAnEmptyDiffEvenWhenTheOrderOfTheInputDiffers(): void
    {
        $book = ThemeOptionBook::fromDivi(ThemeOptionBookTest::stored());
        $same = ThemeOptionBook::fromDivi(array_reverse(ThemeOptionBookTest::stored(), true));

        self::assertSame([], ThemeOptionDiff::between($book, $same));
        self::assertSame([], ThemeOptionDiff::between(ThemeOptionBook::fromDivi([]), ThemeOptionBook::fromDivi([])));
    }

    public function testAddedChangedAndResetInTheOrderOfTheKeys(): void
    {
        $before = ThemeOptionBook::fromDivi(ThemeOptionBookTest::stored());
        $after = $before
            ->with('content_width', 1200)
            ->with('body_font_size', 18)
            ->with('body_font', 'Roboto')
            ->without('body_header_style');

        self::assertSame([
            ['key' => 'body_font', 'label' => 'Fließtext-Schrift', 'action' => 'changed',
                'before' => 'Lato', 'after' => 'Roboto'],
            ['key' => 'body_font_size', 'label' => 'Fließtext-Schriftgröße', 'action' => 'changed',
                'before' => 16, 'after' => 18],
            ['key' => 'body_header_style', 'label' => 'Überschrift-Schriftstil', 'action' => 'reset',
                'before' => 'uppercase', 'after' => null],
            ['key' => 'content_width', 'label' => 'Inhaltsbreite', 'action' => 'added',
                'before' => null, 'after' => 1200],
        ], ThemeOptionDiff::between($before, $after));
    }

    public function testFalseIsAValueOfItsOwn(): void
    {
        $unset = ThemeOptionBook::fromDivi([]);
        $default = ThemeOptionBook::fromDivi(['heading_font' => false]);
        $font = ThemeOptionBook::fromDivi(['heading_font' => 'Lato']);

        self::assertSame(
            [['key' => 'heading_font', 'label' => 'Überschrift-Schrift', 'action' => 'added',
                'before' => null, 'after' => false]],
            ThemeOptionDiff::between($unset, $default)
        );
        self::assertSame(
            [['key' => 'heading_font', 'label' => 'Überschrift-Schrift', 'action' => 'changed',
                'before' => false, 'after' => 'Lato']],
            ThemeOptionDiff::between($default, $font)
        );
        self::assertSame(
            [['key' => 'heading_font', 'label' => 'Überschrift-Schrift', 'action' => 'reset',
                'before' => false, 'after' => null]],
            ThemeOptionDiff::between($default, $unset)
        );
    }

    public function testATypeChangeCountsAsAChange(): void
    {
        $int = ThemeOptionBook::fromDivi(['gutter_width' => 1, 'body_header_height' => 1]);
        $text = ThemeOptionBook::fromDivi(['gutter_width' => '1', 'body_header_height' => 1.0]);

        self::assertSame(
            [
                ['key' => 'body_header_height', 'label' => 'Überschrift-Zeilenhöhe', 'action' => 'changed',
                    'before' => 1, 'after' => 1.0],
                ['key' => 'gutter_width', 'label' => 'Spaltenabstand', 'action' => 'changed',
                    'before' => 1, 'after' => '1'],
            ],
            ThemeOptionDiff::between($int, $text)
        );
    }

    public function testSummaryCountsPerAction(): void
    {
        $before = ThemeOptionBook::fromDivi(ThemeOptionBookTest::stored());
        $after = $before->with('content_width', 1200)->with('body_font_size', 18)->with('body_font', 'Roboto')
            ->without('body_header_style');

        self::assertSame(
            'Theme-Optionen: 1 gesetzt, 2 geändert, 1 zurückgesetzt.',
            ThemeOptionDiff::summary(ThemeOptionDiff::between($before, $after))
        );
        self::assertSame(
            'Theme-Optionen: 1 gesetzt.',
            ThemeOptionDiff::summary(ThemeOptionDiff::between(
                ThemeOptionBook::fromDivi([]),
                ThemeOptionBook::fromDivi(['content_width' => 1200])
            ))
        );
        self::assertSame('Theme-Optionen: keine Änderung.', ThemeOptionDiff::summary([]));
    }
}
  • Step 3c.2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php Expected: FAIL – Tests: 5, Assertions: 0, Errors: 5; jeder Fehler ist Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff" not found.

  • Step 3c.3: ThemeOptionDiff implementieren

Datei src/Integrations/Divi/Design/ThemeOptionDiff.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

defined('ABSPATH') || exit;

/**
 * What a theme-options change does (spec §4 step 4, "diff"): per key added (not set before), changed or reset
 * (removed afterwards – Divi falls back to its default). Values are compared strictly, so false (Divi's default
 * font) and a type change (1 versus "1") count. before/after carry the raw values, null where the key is not set.
 * In the order of ThemeOptionSpec::KEYS. Pure PHP.
 */
final class ThemeOptionDiff
{
    /**
     * @return list<array{key: string, label: string, action: string, before: mixed, after: mixed}>
     *     action: added|changed|reset
     */
    public static function between(ThemeOptionBook $before, ThemeOptionBook $after): array
    {
        $rows = [];
        foreach (ThemeOptionSpec::KEYS as $key) {
            $action = match (true) {
                ! $before->has($key) && $after->has($key) => 'added',
                $before->has($key) && ! $after->has($key) => 'reset',
                $before->get($key) !== $after->get($key) => 'changed',
                default => null,
            };
            if ($action !== null) {
                $rows[] = [
                    'key' => $key,
                    'label' => ThemeOptionSpec::definition($key)['label'],
                    'action' => $action,
                    'before' => $before->get($key),
                    'after' => $after->get($key),
                ];
            }
        }

        return $rows;
    }

    /**
     * One German sentence for the dry run and the change log (spec §4 step 6).
     *
     * @param list<array<string, mixed>> $diff
     */
    public static function summary(array $diff): string
    {
        $count = array_count_values(array_map(static fn (array $row): string => (string) $row['action'], $diff));
        $parts = [];
        foreach (['added' => 'gesetzt', 'changed' => 'geändert', 'reset' => 'zurückgesetzt'] as $action => $word) {
            if (isset($count[$action])) {
                $parts[] = $count[$action] . ' ' . $word;
            }
        }

        return 'Theme-Optionen: ' . ($parts === [] ? 'keine Änderung' : implode(', ', $parts)) . '.';
    }
}
  • Step 3c.4: GREEN und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php && composer lint Expected: OK (5 tests, 10 assertions); phpcs ohne Meldung.

  • Step 3c.5: Commit
git add src/Integrations/Divi/Design/ThemeOptionDiff.php tests/Unit/Integrations/Divi/Design/ThemeOptionDiffTest.php
git commit -m "feat: ThemeOptionDiff – Diff und Zusammenfassung der Theme-Optionen" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 3d: FontSuggest

Reihenfolge der Vorschläge: gleicher Name ohne Beachtung der Schreibweise, Präfix, Teilstring, dann levenshtein ≤ max(3, Länge/3); innerhalb eines Rangs der nähere, dann der kürzere, dann der alphabetisch erste Name – unabhängig von der Reihenfolge der Eingabeliste.

  • Step 3d.1: Failing Test FontSuggestTest schreiben

Datei tests/Unit/Integrations/Divi/Design/FontSuggestTest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use LIT\MCP\Integrations\Divi\Design\FontSuggest;
use PHPUnit\Framework\TestCase;

final class FontSuggestTest extends TestCase
{
    private const NAMES = [
        'Roboto', 'Roboto Condensed', 'Roboto Slab', 'Open Sans', 'Open Sans Condensed', 'Lato', 'Lora', 'Arial',
        'Source Sans 3', 'Playfair Display', 'Helvetica', 'Georgia',
    ];

    public function testTheSameNameInAnotherCaseComesFirst(): void
    {
        self::assertSame(
            ['Open Sans', 'Open Sans Condensed'],
            FontSuggest::similar('open sans', self::NAMES, 2)
        );
        self::assertSame('Lato', FontSuggest::similar('LATO', self::NAMES)[0]);
    }

    public function testRanksPrefixBeforeSubstringBeforeASmallDistance(): void
    {
        $names = ['Big Roboto', 'Rubik', 'Roboto Slab', 'Roboot', 'Robot'];

        self::assertSame(
            ['Roboto Slab', 'Big Roboto', 'Robot', 'Roboot'],
            FontSuggest::similar('roboto', $names)
        );
    }

    public function testATypoIsFoundByDistanceButUnrelatedNamesAreNot(): void
    {
        self::assertSame(['Open Sans'], FontSuggest::similar('Opne Sans', self::NAMES));
        self::assertSame(['Playfair Display'], FontSuggest::similar('Playfair Displey', self::NAMES));
        self::assertSame([], FontSuggest::similar('Comic Sans', ['Arial', 'Georgia', 'Helvetica']));
    }

    public function testTheLimitCutsTheListAndTheOrderIsStable(): void
    {
        $first = FontSuggest::similar('Roboto', self::NAMES, 2);

        self::assertSame(['Roboto', 'Roboto Slab'], $first);
        self::assertSame($first, FontSuggest::similar('Roboto', array_reverse(self::NAMES), 2));
        self::assertSame(['Roboto', 'Roboto Slab', 'Roboto Condensed'], FontSuggest::similar('Roboto', self::NAMES, 5));
        self::assertCount(5, FontSuggest::similar('o', self::NAMES));
    }

    public function testTiesAreBrokenByLengthThenAlphabet(): void
    {
        self::assertSame(['Aab', 'Aac', 'Aaaa'], FontSuggest::similar('aa', ['Aaaa', 'Aac', 'Aab']));
    }

    public function testEdgeCases(): void
    {
        self::assertSame([], FontSuggest::similar('', self::NAMES));
        self::assertSame([], FontSuggest::similar('   ', self::NAMES));
        self::assertSame([], FontSuggest::similar('Lato', self::NAMES, 0));
        self::assertSame([], FontSuggest::similar('Lato', []));
        self::assertSame(['Lato'], FontSuggest::similar(' lato ', ['Lato', 'Lato']), 'trimmed, duplicates once');
        self::assertSame(['Fjalla Önö'], FontSuggest::similar('FJALLA ÖNÖ', ['Fjalla Önö']));
    }
}
  • Step 3d.2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/FontSuggestTest.php Expected: FAIL – Tests: 6, Assertions: 0, Errors: 6; jeder Fehler ist Error: Class "LIT\MCP\Integrations\Divi\Design\FontSuggest" not found.

  • Step 3d.3: FontSuggest implementieren

Datei src/Integrations/Divi/Design/FontSuggest.php anlegen:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

defined('ABSPATH') || exit;

/**
 * Names that resemble an unknown font name, for the hint of a validation error (ruling W5). Case-insensitive;
 * ranked: the same name in another case, a name that starts with it, one that contains it, then a small
 * Levenshtein distance (at most max(3, length / 3)). Within a rank the closer, then the shorter, then the
 * alphabetically first name wins, so the order is deterministic. Pure PHP.
 */
final class FontSuggest
{
    /**
     * @param list<string> $names
     * @return list<string> At most $limit names.
     */
    public static function similar(string $name, array $names, int $limit = 5): array
    {
        $needle = mb_strtolower(trim($name));
        if ($needle === '' || $limit < 1) {
            return [];
        }
        $maxDistance = max(3, intdiv(mb_strlen($needle), 3));
        $ranked = [];
        foreach (array_unique($names) as $candidate) {
            $lower = mb_strtolower($candidate);
            $distance = levenshtein($needle, $lower);
            $rank = match (true) {
                $lower === $needle => 0,
                str_starts_with($lower, $needle) => 1,
                str_contains($lower, $needle) => 2,
                $distance <= $maxDistance => 3,
                default => null,
            };
            if ($rank !== null) {
                $ranked[] = [$rank, $distance, mb_strlen($candidate), $candidate];
            }
        }
        usort($ranked, static fn (array $a, array $b): int => $a[0] <=> $b[0] ?: $a[1] <=> $b[1] ?: $a[2] <=> $b[2]
            ?: strcmp($a[3], $b[3]));

        return array_slice(array_column($ranked, 3), 0, $limit);
    }
}
  • Step 3d.4: GREEN und Lint

Run: php -d memory_limit=512M vendor/bin/phpunit tests/Unit/Integrations/Divi/Design/FontSuggestTest.php && composer lint Expected: OK (6 tests, 17 assertions); phpcs ohne Meldung.

  • Step 3d.5: Commit
git add src/Integrations/Divi/Design/FontSuggest.php tests/Unit/Integrations/Divi/Design/FontSuggestTest.php
git commit -m "feat: FontSuggest – ähnliche Schriftnamen für unbekannte Schriften (W5)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 3e: Gesamtlauf

  • Step 3e.1: Suite und Lint

Run: composer test && composer lint Expected: OK (3230 tests, 19717 assertions) (3121 + 109 neue: 91 + 7 + 5 + 6); composer lint ohne Meldung. Task 3 hat keinen weiteren Commit; git status ist sauber.

Task 4: ThemeOptionsArea, lit/divi-theme-options-get, lit/divi-theme-options-update (W2–W7, W14)

Files:

  • Create: src/Integrations/Divi/Design/ThemeOptionsArea.php, DiviThemeOptionsGetAbility.php, FontCheck.php, DiviThemeOptionsUpdateAbility.php
  • Test (neu): tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php, DiviThemeOptionsGetAbilityTest.php, FontCheckTest.php, DiviThemeOptionsUpdateAbilityTest.php (alle über LIT\MCP\Tests\Unit\TestCase, weil sie Mockery-Fakes der Adapter nutzen)
  • Keine Verdrahtung in DiviIntegration (Task 9).

Interfaces:

  • Consumes (Schnittstellenvertrag, Tasks 1–3): DiviFeature::ThemeOptionRead/ThemeOptionWrite; ThemeOptionsAdapter::read(), ::sanitize(string, mixed): mixed, ::write(array $set, list<string> $unset): array (liefert das Zurückgelesene), ::canCustomize(); FontCatalog::names(), ::has(); DesignRights::themeOptions(DesignSystemWriter, ThemeOptionsAdapter): ?WP_Error; ThemeOptionSpec::KEYS, ::definition(), ::isKey(), ::check(), ::normalize(), ::effective(); ThemeOptionBook::fromDivi(), data(), rev(), has(), get(), with(), without(); ThemeOptionDiff::between(), ::summary(); FontSuggest::similar(). Bestehend: DesignArea, DesignChange::run()/inputProperties()/outputSchema(), DesignPlan, ItemKeys::refuseUnknown(), Errors, Annotations.
  • Produces:
    • final class ThemeOptionsArea implements DesignArea – __construct(ThemeOptionsAdapter $options), Name theme_options, Label Theme-Optionen, Fähigkeit ThemeOptionWrite; zusätzlich public const SITE_WIDE (W7-Warnung, auch für Task 6) und public static function same(mixed $a, mixed $b): bool (gleich oder dieselbe Zahl in anderem Typ; auch vom Update genutzt).
    • final class DiviThemeOptionsGetAbility implements Ability, RequiresDiviFeatures – __construct(ThemeOptionsAdapter $options) (ohne FontCatalog, siehe ABWEICHUNGEN), lit/divi-theme-options-get, Fähigkeiten [ThemeOptionRead].
    • final class FontCheck – public static function problem(FontCatalog $fonts, string $name): ?string (null = Divi kennt die Schrift exakt; sonst deutsche Meldung mit bis zu fünf Vorschlägen oder dem Hinweis auf lit/divi-fonts-upload). Auch von Task 5 genutzt.
    • final class DiviThemeOptionsUpdateAbility implements Ability, RequiresDiviFeatures – __construct(DesignSystemWriter $writer, ThemeOptionsAdapter $options, FontCatalog $fonts, DesignChange $change) (wie Vertrag), lit/divi-theme-options-update, Fähigkeiten [ThemeOptionRead, ThemeOptionWrite].
    • Testhilfen für spätere Tests: ThemeOptionsAreaTest::stored() (body_font „Open Sans“, body_font_size 16, body_font_height 1.8), ThemeOptionsAreaTest::adapter(array &$stored, array &$writes, ?Closure $sanitize = null, bool $customize = true) (zustandsbehafteter Fake: write() setzt/löscht wie et_update_option/et_delete_option und liefert das Zurückgelesene), FontCheckTest::catalog() (FontCatalog-Fake über Lato, Open Sans, Roboto, Roboto Mono, Roboto Slab, has() exakt).

Regeln dieses Tasks (alle durch Tests belegt):

  • ThemeOptionsArea::write() ersetzt immer die ganze Whitelist: Schlüssel des Bestands → $set, Whitelist-Schlüssel, die fehlen → $unset (W6); $mayRemove ist bedeutungslos, weil ein fehlender Schlüssel „zurück auf Divi-Standard“ heißt (W3). restore() = write($raw, true). Ergebnis ist das, was ThemeOptionsAdapter::write() zurückliest (kein zweiter Lesezugriff).
  • verify() verlangt dieselben Schlüssel und dieselben Werte; toleriert wird nur eine Zahl in anderem Typ (15, "15", 15.0) – die Werte des Plans sind schon die Ausgabe von Divis Sanitizer, also ist jede andere Abweichung ein Fehler (→ Rollback). false ≠ '', Schriftnamen exakt. differences() nennt die tolerierten Typwechsel als Warnung („Divi hat die Theme-Option „Textgröße“ als „16“ statt 16 gespeichert.“).
  • theme-options-get liefert je Schlüssel (alle oder keys, immer in der Reihenfolge von ThemeOptionSpec::KEYS) key, label, type, value (gespeicherter Rohwert, null = nicht gesetzt), effective (ThemeOptionSpec::effective auf dem Bestand), default (effective auf dem Bestand ohne diesen Schlüssel – bei Tablet/Smartphone also die geerbte Größe, W3), is_default (nicht gespeichert) und die Felder min, max, step, choices, inherits der Definition, soweit vorhanden; dazu design_rev (ThemeOptionBook::rev(), dieselbe wie die des Probelaufs) und notes (Update-Tool, null, Vererbung, Hauptfarben → lit/divi-colors-set). Die fünf Hauptfarben selbst liest das Tool nicht (kein Leser im Vertrag; der Hinweis genügt). Recht: edit_posts in permission() und execute(), vor jedem Lesen.
  • theme-options-update: Eingabe {set: {<key>: Wert|null, …}, confirm?, design_rev?}. set muss ein nicht leeres Objekt sein. Gesammelt mit Pfad set.<key>: unbekannte Schlüssel (ItemKeys, „unbekanntes Feld“; bei delete mit Hinweis auf null), false und 'none' (Hinweis auf null, W3), ThemeOptionSpec::check(), bei den vier Schriftschlüsseln FontCheck (vor Divis Sanitizer, der nur false liefern würde), dann ThemeOptionsAdapter::sanitize() auf den normalisierten Wert: kein Skalar/false → „Divi lehnt den Wert ab“, abweichend (außer Zahl in anderem Typ) → „Divi würde den Wert verändert speichern (…)“. Gespeichert wird die Ausgabe des Sanitizers (Typ von Divi, W4). null → without() (Divi: et_delete_option).
  • Ein nicht gesetzter Tablet-/Smartphone-Schlüssel, dessen neuer Wert der geerbten Größe gleicht, bleibt ungesetzt (kein Diff); weil die Schlüssel in der Reihenfolge von KEYS (Desktop vor Tablet vor Smartphone) angewendet werden, zählt dabei die Desktop-Größe desselben Aufrufs.
  • Plan: ThemeOptionDiff::between/summary, usages = null (kein affected-Inhalt, DesignChange setzt affected: []), mayRemove = false; Warnungen nur bei Änderungen: immer ThemeOptionsArea::SITE_WIDE, bei einer Schrift zusätzlich der Builder-Hinweis (W7).
  • Fähigkeiten des Updates [ThemeOptionRead, ThemeOptionWrite]: das Tool liest et_divi und die Customizer-Tabelle (ThemeOptionRead), ruft Sanitizer, Schriftliste und Schreiben (ThemeOptionWrite, W1). DesignRead braucht es nicht – es liest weder Presets noch das Designsystem (anders als lit/divi-colors-set, dessen Warnungen Presets lesen).

Task 4a: ThemeOptionsArea – der Bereich theme_options für die Pipeline

  • Step 4a.1: Failing Test ThemeOptionsAreaTest schreiben

Datei tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php. Der Adapter ist ein zustandsbehafteter Fake (Vorbild ColorAreaTest::adapter()).

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Closure;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class ThemeOptionsAreaTest extends TestCase
{
    /**
     * ThemeOptionsAdapter::read(): body font, text size and line height set; everything else at Divi's default.
     *
     * @return array<string, int|float|string|false>
     */
    public static function stored(): array
    {
        return ['body_font' => 'Open Sans', 'body_font_size' => 16, 'body_font_height' => 1.8];
    }

    /**
     * A ThemeOptionsAdapter over $stored: write() sets and deletes like et_update_option/et_delete_option, notes each
     * call in $writes and returns the read-back; sanitize() runs $sanitize (default: Divi keeps the value).
     *
     * @param array<string, int|float|string|false>                                   $stored
     * @param list<array{0: array<string, int|float|string|false>, 1: list<string>}> $writes
     * @param (Closure(string, mixed): mixed)|null                                    $sanitize
     */
    public static function adapter(
        array &$stored,
        array &$writes,
        ?Closure $sanitize = null,
        bool $customize = true
    ): ThemeOptionsAdapter {
        $options = Mockery::mock(ThemeOptionsAdapter::class);
        $options->allows('canCustomize')->andReturn($customize);
        $options->allows('read')->andReturnUsing(static function () use (&$stored): array {
            return $stored;
        });
        $options->allows('sanitize')
            ->andReturnUsing($sanitize ?? static fn (string $key, mixed $value): mixed => $value);
        $options->allows('write')->andReturnUsing(
            static function (array $set, array $unset) use (&$stored, &$writes): array {
                $writes[] = [$set, $unset];
                $stored = array_diff_key(array_merge($stored, $set), array_flip($unset));

                return $stored;
            }
        );

        return $options;
    }

    private function area(array &$stored, array &$writes): ThemeOptionsArea
    {
        return new ThemeOptionsArea(self::adapter($stored, $writes));
    }

    public function testAreaReadsThroughTheAdapter(): void
    {
        $stored = self::stored();
        $writes = [];
        $area = $this->area($stored, $writes);

        self::assertSame(['theme_options', 'Theme-Optionen'], [$area->name(), $area->label()]);
        self::assertSame(DiviFeature::ThemeOptionWrite, $area->feature());
        self::assertSame(ThemeOptionBook::fromDivi($stored)->data(), $area->raw());
        self::assertSame(ThemeOptionBook::fromDivi($stored)->rev(), $area->rev($stored));
    }

    /** Ruling W6: keys of the planned inventory are set, whitelist keys missing from it are deleted. */
    public function testWriteSetsThePlannedKeysAndDeletesTheMissingOnes(): void
    {
        $stored = self::stored();
        $writes = [];
        $planned = ['heading_font' => 'Roboto', 'body_font' => 'Open Sans', 'body_font_size' => 18];

        $saved = $this->area($stored, $writes)->write($planned, false);

        self::assertCount(1, $writes);
        self::assertSame(ThemeOptionBook::fromDivi($planned)->data(), $writes[0][0]);
        self::assertSame(array_values(array_diff(ThemeOptionSpec::KEYS, array_keys($planned))), $writes[0][1]);
        self::assertContains('body_font_height', $writes[0][1]);
        self::assertSame(ThemeOptionBook::fromDivi($planned)->data(), $saved);
        self::assertArrayNotHasKey('body_font_height', $stored);
    }

    /** Ruling W6: a restore (and a rollback) replaces the whole whitelist with the snapshot. */
    public function testRestoreReplacesTheWholeWhitelistWithTheSnapshot(): void
    {
        $stored = ['body_font_size' => 20, 'tablet_body_font_size' => 18, 'gutter_width' => 2];
        $writes = [];
        $snapshot = ThemeOptionBook::fromDivi(self::stored())->data();

        $saved = $this->area($stored, $writes)->restore($snapshot);

        self::assertSame($snapshot, $saved);
        self::assertSame($snapshot, ThemeOptionBook::fromDivi($stored)->data());
        self::assertContains('tablet_body_font_size', $writes[0][1]);
        self::assertContains('gutter_width', $writes[0][1]);
    }

    /** verify() tolerates only a number stored as another type (15 / "15" / 15.0), nothing else. */
    public function testVerifyInsistsOnTheSameKeysAndValues(): void
    {
        $stored = self::stored();
        $writes = [];
        $area = $this->area($stored, $writes);
        $expected = self::stored();

        self::assertTrue($area->verify($expected, $expected));
        self::assertTrue($area->verify($expected, ['body_font_size' => '16', 'body_font_height' => '1.8'] + $expected));
        self::assertFalse($area->verify($expected, ['body_font_size' => 15] + $expected), 'changed value');
        self::assertFalse($area->verify($expected, ['body_font' => 'open sans'] + $expected), 'fonts are exact');
        $missing = $expected;
        unset($missing['body_font_height']);
        self::assertFalse($area->verify($expected, $missing), 'missing key');
        self::assertFalse($area->verify($expected, $expected + ['gutter_width' => 3]), 'added key');
        self::assertFalse($area->verify(['heading_font' => false], ['heading_font' => '']), 'false is not ""');
    }

    public function testDifferencesNameTheValuesDiviStoredAsAnotherType(): void
    {
        $stored = self::stored();
        $writes = [];
        $area = $this->area($stored, $writes);
        $expected = self::stored();

        self::assertSame([], $area->differences($expected, $expected));
        self::assertSame(
            [sprintf(
                'Divi hat die Theme-Option „%s“ als „16“ statt 16 gespeichert.',
                ThemeOptionSpec::definition('body_font_size')['label']
            )],
            $area->differences($expected, ['body_font_size' => '16'] + $expected)
        );
    }
}
  • Step 4a.2: RED-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter ThemeOptionsAreaTest Expected: Tests: 5, Assertions: 0, Errors: 5. mit Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea" not found.

  • Step 4a.3: ThemeOptionsArea implementieren

Datei src/Integrations/Divi/Design/ThemeOptionsArea.php. Reines PHP (nur der Adapter spricht mit WordPress/Divi).

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use LIT\MCP\Integrations\Divi\Release\DiviFeature;

defined('ABSPATH') || exit;

/**
 * The area "theme_options" (spec §8.1, rulings W2, W3, W6): the 20 whitelisted Divi theme options as et_divi stores
 * them (ThemeOptionBook); a key missing from the inventory is unset, i.e. at Divi's default. Every write replaces the
 * whole whitelist through ThemeOptionsAdapter::write() – keys of the inventory are set, whitelist keys missing from it
 * are deleted – so a change, a restore and a rollback all leave exactly the given inventory. verify() insists on the
 * same keys with the same values and only tolerates a number stored as another type (15, "15", 15.0);
 * differences() names those.
 */
final class ThemeOptionsArea implements DesignArea
{
    /** Ruling W7: theme options have no page reference; every plan warns about their reach instead. */
    public const SITE_WIDE = 'Theme-Optionen wirken auf die gesamte Website.';

    public function __construct(private ThemeOptionsAdapter $options)
    {
    }

    public function name(): string
    {
        return 'theme_options';
    }

    public function label(): string
    {
        return 'Theme-Optionen';
    }

    public function feature(): DiviFeature
    {
        return DiviFeature::ThemeOptionWrite;
    }

    public function raw(): array
    {
        return ThemeOptionBook::fromDivi($this->options->read())->data();
    }

    public function rev(array $raw): string
    {
        return ThemeOptionBook::fromDivi($raw)->rev();
    }

    /** $mayRemove has no effect: a key missing from the planned inventory is a reset to Divi's default (W3). */
    public function write(array $raw, bool $mayRemove): array
    {
        $planned = ThemeOptionBook::fromDivi($raw)->data();
        $saved = $this->options->write(
            $planned,
            array_values(array_diff(ThemeOptionSpec::KEYS, array_keys($planned)))
        );

        return ThemeOptionBook::fromDivi($saved)->data();
    }

    public function restore(array $raw): array
    {
        return $this->write($raw, true);
    }

    public function verify(array $expected, array $actual): bool
    {
        $expected = ThemeOptionBook::fromDivi($expected)->data();
        $actual = ThemeOptionBook::fromDivi($actual)->data();
        if (array_keys($expected) !== array_keys($actual)) {
            return false;
        }
        foreach ($expected as $key => $value) {
            if (! self::same($value, $actual[$key])) {
                return false;
            }
        }

        return true;
    }

    public function differences(array $expected, array $actual): array
    {
        $actual = ThemeOptionBook::fromDivi($actual)->data();
        $warnings = [];
        foreach (ThemeOptionBook::fromDivi($expected)->data() as $key => $value) {
            if (array_key_exists($key, $actual) && $actual[$key] !== $value) {
                $warnings[] = sprintf(
                    'Divi hat die Theme-Option „%s“ als %s statt %s gespeichert.',
                    ThemeOptionSpec::definition($key)['label'],
                    self::shown($actual[$key]),
                    self::shown($value)
                );
            }
        }

        return $warnings;
    }

    /** Whether Divi keeps $b as $a: equal, or the same number of another type (absint, floatval, a digit string). */
    public static function same(mixed $a, mixed $b): bool
    {
        return $a === $b || (is_numeric($a) && is_numeric($b) && (float) $a === (float) $b);
    }

    private static function shown(int|float|string|false $value): string
    {
        return match (true) {
            is_string($value) => '„' . $value . '“',
            $value === false => 'false',
            default => (string) $value,
        };
    }
}
  • Step 4a.4: GREEN-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter ThemeOptionsAreaTest Expected: OK (5 tests, 23 assertions).

  • Step 4a.5: Lint

Run: composer lint Expected: keine Meldung.

  • Step 4a.6: Commit
git add src/Integrations/Divi/Design/ThemeOptionsArea.php tests/Unit/Integrations/Divi/Design/ThemeOptionsAreaTest.php
git commit -m "feat: ThemeOptionsArea – Bereich theme_options ersetzt die Whitelist, prüft das Zurücklesen" \
    -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 4b: lit/divi-theme-options-get – Theme-Optionen lesen

  • Step 4b.1: Failing Test DiviThemeOptionsGetAbilityTest schreiben

Datei tests/Unit/Integrations/Divi/Design/DiviThemeOptionsGetAbilityTest.php.

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsGetAbility;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class DiviThemeOptionsGetAbilityTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Functions\when('current_user_can')->justReturn(true);
    }

    private static function ability(): DiviThemeOptionsGetAbility
    {
        $stored = ThemeOptionsAreaTest::stored();
        $writes = [];

        return new DiviThemeOptionsGetAbility(ThemeOptionsAreaTest::adapter($stored, $writes));
    }

    /**
     * @param array<string, mixed> $result
     * @return array<string, array<string, mixed>> key => row
     */
    private static function byKey(array $result): array
    {
        return array_column($result['options'], null, 'key');
    }

    public function testSchemaAndMeta(): void
    {
        $ability = self::ability();
        $schema = $ability->inputSchema();

        self::assertSame('lit/divi-theme-options-get', $ability->name());
        self::assertFalse($ability->isDirect());
        self::assertSame(Annotations::read(), $ability->annotations());
        self::assertSame([DiviFeature::ThemeOptionRead], $ability->diviFeatures([]));
        self::assertSame(ThemeOptionSpec::KEYS, $schema['properties']['keys']['items']['enum']);
        self::assertFalse($schema['additionalProperties']);
        self::assertSame(['options', 'design_rev', 'notes'], $ability->outputSchema()['required']);
        self::assertStringContainsString('lit/divi-theme-options-update', $ability->description());
    }

    /** Reading needs what lit/divi-design-system needs (edit_posts) – checked before Divi is asked. */
    public function testNeedsEditPosts(): void
    {
        Functions\when('current_user_can')->justReturn(false);
        $options = Mockery::mock(ThemeOptionsAdapter::class);
        $options->shouldNotReceive('read');
        $ability = new DiviThemeOptionsGetAbility($options);

        self::assertToolError($ability->permission([]), 'forbidden');
        self::assertToolError($ability->execute([]), 'forbidden');
    }

    public function testListsEveryOptionWithValueEffectiveValueAndDefault(): void
    {
        $result = self::ability()->execute([]);

        self::assertSame(ThemeOptionSpec::KEYS, array_column($result['options'], 'key'));
        self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $result['design_rev']);
        $rows = self::byKey($result);
        $size = ThemeOptionSpec::definition('body_font_size');
        $expected = ['key' => 'body_font_size', 'label' => $size['label'], 'type' => 'int', 'value' => 16,
            'effective' => 16, 'default' => ThemeOptionSpec::effective('body_font_size', []), 'is_default' => false,
            'min' => $size['min'], 'max' => $size['max'], 'step' => $size['step']];
        $actual = $rows['body_font_size'];
        ksort($expected);
        ksort($actual);
        self::assertSame($expected, $actual);
        self::assertSame('Open Sans', $rows['body_font']['value']);
    }

    /** Ruling W3: an unset tablet size inherits the desktop size – as effective value and as its default. */
    public function testUnsetSizesInheritTheLargerSize(): void
    {
        $tablet = self::byKey(self::ability()->execute([]))['tablet_body_font_size'];

        self::assertNull($tablet['value']);
        self::assertTrue($tablet['is_default']);
        self::assertSame(16, $tablet['effective']);
        self::assertSame(16, $tablet['default']);
        self::assertSame('body_font_size', $tablet['inherits']);
    }

    public function testChoicesAreListed(): void
    {
        $rows = self::byKey(self::ability()->execute([]));

        self::assertSame(
            ThemeOptionSpec::definition('body_header_style')['choices'],
            $rows['body_header_style']['choices']
        );
        self::assertArrayNotHasKey('choices', $rows['body_font']);
    }

    public function testKeysNarrowTheAnswerInTheFixedOrder(): void
    {
        $result = self::ability()->execute(['keys' => ['row_padding', 'body_font']]);

        self::assertSame(['body_font', 'row_padding'], array_column($result['options'], 'key'));
    }

    public function testUnknownKeysAreRefused(): void
    {
        $error = self::ability()->execute(['keys' => ['accent_color', 'body_font', 5]]);

        self::assertToolError($error, 'validation_failed');
        self::assertSame(['keys[0]', 'keys[2]'], array_keys(self::errorFields($error)));
        self::assertToolError(self::ability()->execute(['keys' => 'body_font']), 'validation_failed');
    }

    /** G3, A8: the main colors are not theme options here; the notes point to lit/divi-colors-set. */
    public function testNotesPointToTheColorTool(): void
    {
        $notes = implode(' ', self::ability()->execute([])['notes']);

        self::assertStringContainsString('lit/divi-colors-set', $notes);
        self::assertStringContainsString('null', $notes);
    }
}
  • Step 4b.2: RED-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviThemeOptionsGetAbilityTest Expected: Tests: 8, Assertions: 0, Errors: 8. mit Error: Class "LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsGetAbility" not found.

  • Step 4b.3: DiviThemeOptionsGetAbility implementieren

Datei src/Integrations/Divi/Design/DiviThemeOptionsGetAbility.php.

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;

defined('ABSPATH') || exit;

/**
 * lit/divi-theme-options-get (spec §8.1, rulings W2, W3): the 20 whitelisted Divi theme options – per key the stored
 * value (null = unset), the value in effect (an unset tablet/phone size inherits the larger size), Divi's default,
 * type, range or choices – and the design_rev of the area. Reading needs what lit/divi-design-system needs.
 */
final class DiviThemeOptionsGetAbility implements Ability, RequiresDiviFeatures
{
    /** Fields of ThemeOptionSpec::definition() passed through as they are. */
    private const RULES = ['min', 'max', 'step', 'choices', 'inherits'];

    private const NOTES = [
        'Ändern mit lit/divi-theme-options-update: set {Schlüssel: Wert}; null setzt auf den Divi-Standard zurück.',
        'Ohne eigenen Wert erben Tablet- und Smartphone-Größen die nächstgrößere Größe (inherits).',
        'Die fünf Hauptfarben ändert lit/divi-colors-set; Buttons, Logo und SEO bleiben im Divi-Customizer.',
    ];

    public function __construct(private ThemeOptionsAdapter $options)
    {
    }

    public function name(): string
    {
        return 'lit/divi-theme-options-get';
    }

    public function label(): string
    {
        return 'Divi-Theme-Optionen lesen';
    }

    public function description(): string
    {
        return 'Liest Divis Theme-Optionen für Schriften, Schriftgrößen und Layout: je Schlüssel den gespeicherten '
            . 'Wert (null = nicht gesetzt), den wirksamen Wert, den Divi-Standard, Typ und Bereich oder Auswahl, dazu '
            . 'die design_rev. keys grenzt auf einzelne Schlüssel ein. Ändern mit lit/divi-theme-options-update.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'keys' => [
                    'type' => 'array',
                    'items' => ['type' => 'string', 'enum' => ThemeOptionSpec::KEYS],
                    'description' => 'Nur diese Theme-Optionen liefern (Standard: alle).',
                ],
            ],
            'additionalProperties' => false,
        ];
    }

    public function outputSchema(): ?array
    {
        return [
            'type' => 'object',
            'properties' => [
                'options' => [
                    'type' => 'array',
                    'items' => [
                        'type' => 'object',
                        'properties' => [
                            'key' => ['type' => 'string'],
                            'label' => ['type' => 'string'],
                            'type' => ['type' => 'string'],
                            'is_default' => ['type' => 'boolean'],
                        ],
                        'required' => ['key', 'label', 'type', 'value', 'effective', 'default', 'is_default'],
                    ],
                ],
                'design_rev' => ['type' => 'string'],
                'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
            ],
            'required' => ['options', 'design_rev', 'notes'],
        ];
    }

    public function annotations(): array
    {
        return Annotations::read();
    }

    public function isDirect(): bool
    {
        return false;
    }

    /** Reads et_divi and the Customizer table (ThemeOptionsAdapter, ThemeOptionSpec mirror). */
    public function diviFeatures(array $input): array
    {
        return [DiviFeature::ThemeOptionRead];
    }

    public function permission(array $input): bool|WP_Error
    {
        return self::rights() ?? true;
    }

    public function execute(array $input): mixed
    {
        $refusal = self::rights();
        if ($refusal !== null) {
            return $refusal;
        }
        $keys = self::keys($input);
        if ($keys instanceof WP_Error) {
            return $keys;
        }

        $book = ThemeOptionBook::fromDivi($this->options->read());
        $rows = [];
        foreach ($keys as $key) {
            $definition = ThemeOptionSpec::definition($key);
            $rows[] = [
                'key' => $key,
                'label' => $definition['label'],
                'type' => $definition['type'],
                'value' => $book->get($key),
                'effective' => ThemeOptionSpec::effective($key, $book->data()),
                'default' => ThemeOptionSpec::effective($key, $book->without($key)->data()),
                'is_default' => ! $book->has($key),
            ] + array_intersect_key($definition, array_flip(self::RULES));
        }

        return ['options' => $rows, 'design_rev' => $book->rev(), 'notes' => self::NOTES];
    }

    /** Like lit/divi-design-system: whoever edits content may read the design. */
    private static function rights(): ?WP_Error
    {
        return current_user_can('edit_posts') ? null : Errors::forbidden('Keine Berechtigung, Inhalte zu bearbeiten.');
    }

    /**
     * @param array<string, mixed> $input
     * @return list<string>|WP_Error The requested keys in ThemeOptionSpec::KEYS order (all without "keys").
     */
    private static function keys(array $input): array|WP_Error
    {
        $keys = $input['keys'] ?? ThemeOptionSpec::KEYS;
        if (! is_array($keys) || ! array_is_list($keys)) {
            return Errors::invalidInput('keys muss eine Liste sein.', ['keys' => 'Liste von Theme-Optionen']);
        }
        $errors = [];
        foreach ($keys as $index => $key) {
            if (! is_string($key) || ! ThemeOptionSpec::isKey($key)) {
                $errors[sprintf('keys[%d]', $index)] = 'unbekannte Theme-Option; erlaubt sind die Schlüssel aus '
                    . 'lit/divi-theme-options-get ohne keys';
            }
        }
        if ($errors !== []) {
            return Errors::invalidInput('Unbekannte Theme-Optionen.', $errors);
        }

        return array_values(array_intersect(ThemeOptionSpec::KEYS, $keys));
    }
}
  • Step 4b.4: GREEN-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviThemeOptionsGetAbilityTest Expected: OK (8 tests, 38 assertions).

  • Step 4b.5: Lint

Run: composer lint Expected: keine Meldung.

  • Step 4b.6: Commit
git add src/Integrations/Divi/Design/DiviThemeOptionsGetAbility.php \
    tests/Unit/Integrations/Divi/Design/DiviThemeOptionsGetAbilityTest.php
git commit -m "feat: lit/divi-theme-options-get liest Wert, wirksamen Wert, Standard und Bereich je Theme-Option" \
    -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 4c: FontCheck und lit/divi-theme-options-update – Theme-Optionen ändern

  • Step 4c.1: Failing Tests FontCheckTest und DiviThemeOptionsUpdateAbilityTest schreiben

Datei tests/Unit/Integrations/Divi/Design/FontCheckTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\FontCheck;
use LIT\MCP\Integrations\Divi\Design\FontSuggest;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;

final class FontCheckTest extends TestCase
{
    /** The names FontCatalog::names() delivers in these tests. */
    public const NAMES = ['Lato', 'Open Sans', 'Roboto', 'Roboto Mono', 'Roboto Slab'];

    /** A FontCatalog over NAMES: has() is exact, like Divi's sanitizer (ruling W5). */
    public static function catalog(): FontCatalog&MockInterface
    {
        $fonts = Mockery::mock(FontCatalog::class);
        $fonts->allows('names')->andReturn(self::NAMES);
        $fonts->allows('has')->andReturnUsing(static fn (string $name): bool => in_array($name, self::NAMES, true));

        return $fonts;
    }

    public function testAKnownFontIsFine(): void
    {
        $fonts = self::catalog();
        $fonts->shouldNotReceive('names');

        self::assertNull(FontCheck::problem($fonts, 'Roboto Mono'));
    }

    /** Ruling W5: the name is exact – "roboto" is unknown, but gets "Roboto" as suggestion. */
    public function testAnUnknownFontGetsSimilarNames(): void
    {
        self::assertSame(
            sprintf(
                'unbekannte Schrift „roboto“; ähnlich: %s',
                implode(', ', FontSuggest::similar('roboto', self::NAMES))
            ),
            FontCheck::problem(self::catalog(), 'roboto')
        );
        self::assertStringContainsString('Roboto', (string) FontCheck::problem(self::catalog(), 'roboto'));
    }

    public function testWithoutSimilarNamesTheUploadToolIsNamed(): void
    {
        self::assertSame(
            'unbekannte Schrift „Xyzzy Grotesk“; eigene Schriften lädt lit/divi-fonts-upload hoch',
            FontCheck::problem(self::catalog(), 'Xyzzy Grotesk')
        );
    }
}

Datei tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php. Jeder Test erreicht den Pfad, den er nennt: der Sanitizer-Fake protokolliert, welche Schlüssel Divi erreichen; der Rollback-Test lässt das erste write() scheitern und prüft, dass das zweite den Snapshot schreibt.

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Brain\Monkey\Functions;
use Closure;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\DesignChange;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsUpdateAbility;
use LIT\MCP\Integrations\Divi\Design\SnapshotStore;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;

final class DiviThemeOptionsUpdateAbilityTest extends TestCase
{
    /** @var array<string, int|float|string|false> What the adapter stores (ThemeOptionsAreaTest::adapter()). */
    private array $stored = [];

    /** @var list<array{0: array<string, int|float|string|false>, 1: list<string>}> */
    private array $writes = [];

    protected function setUp(): void
    {
        parent::setUp();
        // DesignLock (final review I-5): the lock is always free here.
        Functions\when('add_option')->justReturn(true);
        Functions\when('delete_option')->justReturn(true);
        Functions\when('current_user_can')->justReturn(true);
        $this->stored = ThemeOptionsAreaTest::stored();
        $this->writes = [];
    }

    /** @param (Closure(string, mixed): mixed)|null $sanitize Divi's sanitizer (default: keeps the value). */
    private function ability(
        ?SnapshotStore $snapshots = null,
        ?Closure $sanitize = null,
        bool $customize = true,
        ?ThemeOptionsAdapter $options = null
    ): DiviThemeOptionsUpdateAbility {
        $writer = Mockery::mock(DesignSystemWriter::class);
        $writer->allows('canUseVisualBuilder')->andReturn(true);

        return new DiviThemeOptionsUpdateAbility(
            $writer,
            $options ?? ThemeOptionsAreaTest::adapter($this->stored, $this->writes, $sanitize, $customize),
            FontCheckTest::catalog(),
            new DesignChange($snapshots ?? Mockery::mock(SnapshotStore::class), new FakeChangeLog(3))
        );
    }

    /** A SnapshotStore that expects one snapshot of the stored options and the log ID 3 attached to it. */
    private static function snapshots(): SnapshotStore
    {
        $snapshots = Mockery::mock(SnapshotStore::class);
        $snapshots->expects('save')
            ->with(
                'theme_options',
                ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(),
                'lit/divi-theme-options-update'
            )
            ->andReturn('theme_options-1');
        $snapshots->expects('attachLog')->with('theme_options', 'theme_options-1', 3);

        return $snapshots;
    }

    /**
     * Dry run, then confirm with its design_rev.
     *
     * @param array<string, mixed> $input
     * @return array{0: array<string, mixed>, 1: mixed}
     */
    private function confirmed(DiviThemeOptionsUpdateAbility $ability, array $input): array
    {
        $dry = self::assertToolError($ability->execute($input), 'confirm_required');

        return [$dry, $ability->execute($input + ['confirm' => true, 'design_rev' => $dry['design_rev']])];
    }

    public function testSchemaAndMeta(): void
    {
        $ability = $this->ability();
        $properties = $ability->inputSchema()['properties'];

        self::assertSame('lit/divi-theme-options-update', $ability->name());
        self::assertFalse($ability->isDirect());
        self::assertSame(Annotations::destructive(false), $ability->annotations());
        self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $ability->diviFeatures([]));
        self::assertSame(ThemeOptionSpec::KEYS, array_keys($properties['set']['properties']));
        self::assertFalse($properties['set']['additionalProperties']);
        self::assertSame(10, $properties['set']['properties']['body_font_size']['minimum']);
        self::assertSame(32, $properties['set']['properties']['body_font_size']['maximum']);
        self::assertContains('null', $properties['set']['properties']['heading_font']['type']);
        self::assertArrayHasKey('design_rev', $properties);
        self::assertSame(['diff', 'design_rev'], array_slice($ability->outputSchema()['required'], -2));
        self::assertStringContainsString('confirm: true mit design_rev', $ability->description());
        self::assertStringContainsString('lit/divi-colors-set', $ability->description());
    }

    /** Spec §10, W14: edit_theme_options and Divi's role right "Theme-Anpassung", before anything is read. */
    public function testNeedsEditThemeOptionsAndTheCustomizerRight(): void
    {
        $input = ['set' => ['body_font_size' => 18]];

        self::assertToolError($this->ability(customize: false)->execute($input), 'forbidden');
        self::assertToolError($this->ability(customize: false)->permission($input), 'forbidden');

        Functions\when('current_user_can')->justReturn(false);
        self::assertToolError($this->ability()->execute($input), 'forbidden');
        self::assertToolError($this->ability()->permission($input), 'forbidden');
        self::assertSame([], $this->writes);
    }

    public function testSetMustBeAnObjectWithAtLeastOneKey(): void
    {
        foreach ([[], ['set' => []], ['set' => ['body_font']], ['set' => 'body_font']] as $input) {
            self::assertToolError($this->ability()->execute($input), 'validation_failed');
        }
    }

    public function testDryRunThenConfirmWritesTheOptions(): void
    {
        $input = ['set' => ['heading_font' => 'Roboto', 'body_font_size' => '18']];

        [$dry, $result] = $this->confirmed($this->ability(self::snapshots()), $input);

        self::assertSame(
            [['heading_font', 'added'], ['body_font_size', 'changed']],
            array_map(static fn (array $row): array => [$row['key'], $row['action']], $dry['diff'])
        );
        self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->rev(), $dry['design_rev']);
        self::assertStringContainsString(ThemeOptionDiff::summary($dry['diff']), $dry['message']);
        self::assertSame([], $dry['affected'], 'theme options have no page reference (W7)');
        self::assertSame(ThemeOptionsArea::SITE_WIDE, $dry['warnings'][0]);
        self::assertStringContainsString('Builder', $dry['warnings'][1]);

        self::assertSame('theme_options-1', $result['snapshot_id']);
        self::assertSame(18, $this->stored['body_font_size'], 'stored as the number Divi keeps');
        self::assertSame('Roboto', $this->stored['heading_font']);
        self::assertSame(ThemeOptionBook::fromDivi($this->stored)->rev(), $result['design_rev']);
    }

    /** Only fonts reach the builder (W7). */
    public function testSizesOnlyWarnAboutTheWholeWebsite(): void
    {
        $dry = self::assertToolError(
            $this->ability()->execute(['set' => ['content_width' => 1200]]),
            'confirm_required'
        );

        self::assertSame([ThemeOptionsArea::SITE_WIDE], $dry['warnings']);
    }

    /** Ruling W3: null resets to Divi's default – the key is deleted, not stored as "none" or false. */
    public function testNullResetsToTheDiviDefault(): void
    {
        [$dry] = $this->confirmed($this->ability(self::snapshots()), ['set' => ['body_font_height' => null]]);

        self::assertSame(['reset'], array_column($dry['diff'], 'action'));
        self::assertContains('body_font_height', $this->writes[0][1]);
        self::assertArrayNotHasKey('body_font_height', $this->stored);
    }

    public function testNoneAndFalseAreRefusedWithAHint(): void
    {
        $error = $this->ability()->execute(['set' => ['heading_font' => false, 'body_font' => 'none']]);

        self::assertToolError($error, 'validation_failed');
        $fields = self::errorFields($error);
        self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields));
        self::assertStringContainsString('null', $fields['set.body_font']);
    }

    public function testAllProblemsAreCollectedAndUnknownKeysRefused(): void
    {
        $error = $this->ability()->execute(['set' => [
            'gutter_width' => 'breit',
            'accent_color' => '#ffffff',
            'delete' => true,
            'body_font_size' => 99,
            'body_header_style' => 'bold',
        ]]);

        self::assertToolError($error, 'validation_failed');
        $fields = self::errorFields($error);
        self::assertSame(
            ['set.accent_color', 'set.delete', 'set.body_font_size', 'set.body_header_style', 'set.gutter_width'],
            array_keys($fields)
        );
        self::assertSame('unbekanntes Feld', $fields['set.accent_color']);
        self::assertStringContainsString('null', $fields['set.delete']);
        self::assertSame([], $this->writes);
    }

    /** Ruling W5: the exact name of a Divi font; unknown names get suggestions before Divi's sanitizer is asked. */
    public function testUnknownFontsGetSuggestions(): void
    {
        $asked = [];
        $sanitize = static function (string $key, mixed $value) use (&$asked): mixed {
            $asked[] = $key;

            return $value;
        };
        $error = $this->ability(sanitize: $sanitize)->execute(['set' => [
            'heading_font' => 'robotto',
            'body_font' => 'Xyzzy Grotesk',
            'primary_nav_font' => 'Lato',
        ]]);

        self::assertToolError($error, 'validation_failed');
        $fields = self::errorFields($error);
        self::assertSame(['set.heading_font', 'set.body_font'], array_keys($fields));
        self::assertStringContainsString('Roboto', $fields['set.heading_font']);
        self::assertStringContainsString('lit/divi-fonts-upload', $fields['set.body_font']);
        self::assertSame(['primary_nav_font'], $asked, 'only the known font goes to Divi');
    }

    /** Divi's sanitizer answers false or changes the value – Divi would store false or round (Global Constraints). */
    public function testValuesDiviRejectsOrChangesAreErrors(): void
    {
        $sanitize = static fn (string $key, mixed $value): mixed => match ($key) {
            'body_header_style' => false,
            'content_width' => 1000,
            default => $value,
        };
        $error = $this->ability(sanitize: $sanitize)->execute(['set' => [
            'body_header_style' => 'italic',
            'content_width' => 1200,
            'row_padding' => 3,
        ]]);

        self::assertToolError($error, 'validation_failed');
        self::assertSame(['set.body_header_style', 'set.content_width'], array_keys(self::errorFields($error)));
    }

    /** Ruling W4: the value is stored with the type Divi's sanitizer returns. */
    public function testTheSanitizedTypeIsStored(): void
    {
        $sanitize = static fn (string $key, mixed $value): mixed
            => $key === 'body_font_size' ? (string) $value : $value;

        $this->confirmed($this->ability(self::snapshots(), $sanitize), ['set' => ['body_font_size' => 20]]);

        self::assertSame('20', $this->stored['body_font_size']);
    }

    /** Ruling W3: a tablet/phone size equal to the size it inherits stays unset and keeps following it. */
    public function testATabletSizeEqualToTheInheritedSizeIsNoChange(): void
    {
        $same = $this->ability()->execute(['set' => ['tablet_body_font_size' => 16]]);
        self::assertSame([], $same['diff']);
        self::assertStringStartsWith('Keine Änderung', $same['summary']);

        $dry = self::assertToolError(
            $this->ability()->execute(['set' => ['tablet_body_font_size' => 20, 'body_font_size' => 20]]),
            'confirm_required'
        );
        self::assertSame(['body_font_size'], array_column($dry['diff'], 'key'), 'inherits the new desktop size');

        $own = self::assertToolError(
            $this->ability()->execute(['set' => ['tablet_body_font_size' => 15]]),
            'confirm_required'
        );
        self::assertSame([['tablet_body_font_size', 'added']], array_map(
            static fn (array $row): array => [$row['key'], $row['action']],
            $own['diff']
        ));
        self::assertSame([], $this->writes);
    }

    public function testUnchangedValuesAreNoChange(): void
    {
        $result = $this->ability()->execute(['set' => ['body_font_size' => 16, 'gutter_width' => null]]);

        self::assertSame([], $result['diff']);
        self::assertSame([], $this->writes);
    }

    public function testConfirmNeedsTheDesignRevOfTheDryRun(): void
    {
        $input = ['set' => ['body_font_size' => 18], 'confirm' => true];

        self::assertToolError($this->ability()->execute($input), 'validation_failed');
        self::assertToolError($this->ability()->execute($input + ['design_rev' => 'veraltet']), 'rev_mismatch');
        self::assertSame([], $this->writes);
    }

    public function testAFailedWriteIsRolledBackToTheSnapshot(): void
    {
        $calls = [];
        $options = Mockery::mock(ThemeOptionsAdapter::class);
        $options->allows('canCustomize')->andReturn(true);
        $options->allows('read')->andReturn(ThemeOptionsAreaTest::stored());
        $options->allows('sanitize')->andReturnUsing(static fn (string $key, mixed $value): mixed => $value);
        $options->allows('write')->andReturnUsing(static function (array $set, array $unset) use (&$calls): array {
            $calls[] = $set;
            if (count($calls) === 1) {
                throw new RuntimeException('Zurücklesen stimmt nicht');
            }

            return $set;
        });
        $snapshots = Mockery::mock(SnapshotStore::class);
        $snapshots->expects('save')->andReturn('theme_options-1');

        try {
            $this->confirmed($this->ability($snapshots, options: $options), ['set' => ['body_font_size' => 18]]);
            self::fail('expected WriteFailed');
        } catch (WriteFailed $e) {
            self::assertTrue($e->rolledBack);
            self::assertSame('theme_options-1', $e->details['snapshot_id']);
        }
        self::assertSame(ThemeOptionBook::fromDivi(ThemeOptionsAreaTest::stored())->data(), $calls[1]);
    }
}
  • Step 4c.2: RED-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'FontCheckTest|DiviThemeOptionsUpdateAbilityTest' Expected: Tests: 18, Assertions: 0, Errors: 18. mit Error: Class "LIT\MCP\Integrations\Divi\Design\FontCheck" not found bzw. … DiviThemeOptionsUpdateAbility" not found.

  • Step 4c.3: FontCheck implementieren

Datei src/Integrations/Divi/Design/FontCheck.php. Ruft nur die Adapter-Methoden; keine WordPress-/Divi-Aufrufe.

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

defined('ABSPATH') || exit;

/**
 * Ruling W5: a font name is valid when Divi knows it exactly (FontCatalog); otherwise the message names up to five
 * similar fonts (FontSuggest) or the upload tool. Shared by the theme options and the font variables (W12).
 */
final class FontCheck
{
    /** @return string|null Null when $name is a font Divi knows, else a German message. */
    public static function problem(FontCatalog $fonts, string $name): ?string
    {
        if ($fonts->has($name)) {
            return null;
        }
        $similar = FontSuggest::similar($name, $fonts->names());

        return sprintf('unbekannte Schrift „%s“', $name) . ($similar === []
            ? '; eigene Schriften lädt lit/divi-fonts-upload hoch'
            : '; ähnlich: ' . implode(', ', $similar));
    }
}
  • Step 4c.4: DiviThemeOptionsUpdateAbility implementieren

Datei src/Integrations/Divi/Design/DiviThemeOptionsUpdateAbility.php. Das Eingabeschema entsteht aus ThemeOptionSpec (20 Schlüssel, Typen inkl. null, minimum/maximum der Zahlen, Auswahl in der Beschreibung, additionalProperties: false).

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use WP_Error;

defined('ABSPATH') || exit;

/**
 * lit/divi-theme-options-update (spec §8.1, rulings W2–W5, W7): sets or resets the whitelisted Divi theme options
 * through the change pipeline (ThemeOptionsArea). set maps keys to values; null resets a key to Divi's default
 * (et_delete_option), "none" and false are refused. Every value passes ThemeOptionSpec::check() (type, range, choice),
 * fonts must be known to Divi exactly (FontCheck, with suggestions), and Divi's own sanitizer must keep the value –
 * false or a changed value is an error, since Divi would store false or round silently; the value is stored with the
 * sanitizer's type. A tablet/phone size equal to the size it inherits stays unset. All problems are collected with
 * the path set.<key>. Theme options have no page reference: the dry run warns about the whole website instead.
 */
final class DiviThemeOptionsUpdateAbility implements Ability, RequiresDiviFeatures
{
    private const RESET_HINT = 'zum Zurücksetzen den Wert null angeben';

    private const BUILDER = 'Geänderte Schriften wirken auch im Divi-Builder (Standardschrift der Module).';

    public function __construct(
        private DesignSystemWriter $writer,
        private ThemeOptionsAdapter $options,
        private FontCatalog $fonts,
        private DesignChange $change
    ) {
    }

    public function name(): string
    {
        return 'lit/divi-theme-options-update';
    }

    public function label(): string
    {
        return 'Divi-Theme-Optionen ändern';
    }

    public function description(): string
    {
        return 'Ändert Divis Theme-Optionen für Schriften, Schriftgrößen und Layout. set: {Schlüssel: Wert}; null '
            . 'setzt auf den Divi-Standard zurück. Schlüssel, aktuelle Werte und Bereiche liefert '
            . 'lit/divi-theme-options-get; Schriften mit dem exakten Namen einer Divi-Schrift (unbekannte Namen '
            . 'bekommen Vorschläge). Die Hauptfarben ändert lit/divi-colors-set. Wirkt auf die gesamte Website: zuerst '
            . 'Probelauf (diff, Warnungen, design_rev), nach Rückfrage beim Nutzer confirm: true mit design_rev. '
            . 'Vorher wird ein Snapshot angelegt (lit/divi-design-restore).';
    }

    public function inputSchema(): array
    {
        $keys = [];
        foreach (ThemeOptionSpec::KEYS as $key) {
            $keys[$key] = self::keySchema($key);
        }

        return [
            'type' => 'object',
            'properties' => [
                'set' => [
                    'type' => 'object',
                    'minProperties' => 1,
                    'properties' => $keys,
                    'additionalProperties' => false,
                    'description' => 'Theme-Option => neuer Wert; null = Divi-Standard.',
                ],
            ] + DesignChange::inputProperties(),
            'required' => ['set'],
            'additionalProperties' => false,
        ];
    }

    public function outputSchema(): ?array
    {
        return DesignChange::outputSchema();
    }

    public function annotations(): array
    {
        return Annotations::destructive(false);
    }

    public function isDirect(): bool
    {
        return false;
    }

    /** Reads et_divi and the Customizer table (ThemeOptionRead); sanitizer, font list and writing: ThemeOptionWrite. */
    public function diviFeatures(array $input): array
    {
        return [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite];
    }

    public function permission(array $input): bool|WP_Error
    {
        return DesignRights::themeOptions($this->writer, $this->options) ?? true;
    }

    public function execute(array $input): mixed
    {
        $refusal = DesignRights::themeOptions($this->writer, $this->options);
        if ($refusal !== null) {
            return $refusal;
        }
        $set = $input['set'] ?? null;
        if (! is_array($set) || $set === [] || array_is_list($set)) {
            return Errors::invalidInput(
                'set fehlt oder ist leer; nichts wurde gespeichert.',
                ['set' => 'Objekt {Theme-Option: Wert}, Schlüssel aus lit/divi-theme-options-get']
            );
        }

        return $this->change->run(
            $this->name(),
            new ThemeOptionsArea($this->options),
            $input,
            fn (array $raw): DesignPlan|WP_Error => $this->plan(ThemeOptionBook::fromDivi($raw), $set)
        );
    }

    /** @param array<array-key, mixed> $set */
    private function plan(ThemeOptionBook $before, array $set): DesignPlan|WP_Error
    {
        $errors = [];
        ItemKeys::refuseUnknown($set, ThemeOptionSpec::KEYS, 'set', $errors, self::RESET_HINT);
        $book = $before;
        // In the fixed key order, so a tablet size is compared with the desktop size of this call.
        foreach (ThemeOptionSpec::KEYS as $key) {
            if (! array_key_exists($key, $set)) {
                continue;
            }
            if ($set[$key] === null) {
                $book = $book->without($key);
                continue;
            }
            $value = $this->value($key, $set[$key], $errors);
            if ($value !== null && ! self::inherited($book, $key, $value)) {
                $book = $book->with($key, $value);
            }
        }
        if ($errors !== []) {
            return Errors::invalidInput('Ungültige Theme-Optionen; nichts wurde gespeichert.', $errors);
        }

        $diff = ThemeOptionDiff::between($before, $book);

        return new DesignPlan($book->data(), $diff, ThemeOptionDiff::summary($diff), null, self::warnings($diff));
    }

    /**
     * The value Divi stores for $key, or null after noting the problem in $errors.
     *
     * @param array<string, string> $errors
     */
    private function value(string $key, mixed $value, array &$errors): int|float|string|null
    {
        $field = 'set.' . $key;
        if ($value === false || $value === 'none') {
            $errors[$field] = 'für den Divi-Standard null angeben, nicht false oder "none"';

            return null;
        }
        $problem = ThemeOptionSpec::check($key, $value);
        if ($problem === null && ThemeOptionSpec::definition($key)['type'] === 'font') {
            // Before Divi's sanitizer: it would only answer false, without suggestions.
            $problem = FontCheck::problem($this->fonts, (string) $value);
        }
        if ($problem !== null) {
            $errors[$field] = $problem;

            return null;
        }

        $normalized = ThemeOptionSpec::normalize($key, $value);
        $sanitized = $this->options->sanitize($key, $normalized);
        if (! is_int($sanitized) && ! is_float($sanitized) && ! is_string($sanitized)) {
            $errors[$field] = 'Divi lehnt den Wert ab';

            return null;
        }
        if (! ThemeOptionsArea::same($sanitized, $normalized)) {
            $errors[$field] = sprintf('Divi würde den Wert verändert speichern (%s)', $sanitized);

            return null;
        }

        return $sanitized;
    }

    /** Ruling W3: an unset tablet/phone size given the size it inherits stays unset – it keeps following that size. */
    private static function inherited(ThemeOptionBook $book, string $key, int|float|string $value): bool
    {
        return isset(ThemeOptionSpec::definition($key)['inherits']) && ! $book->has($key)
            && ThemeOptionsArea::same(ThemeOptionSpec::effective($key, $book->data()), $value);
    }

    /**
     * Ruling W7: no affected pages, but the reach of the change.
     *
     * @param list<array<string, mixed>> $diff
     * @return list<string>
     */
    private static function warnings(array $diff): array
    {
        if ($diff === []) {
            return [];
        }
        foreach ($diff as $row) {
            if (ThemeOptionSpec::definition((string) $row['key'])['type'] === 'font') {
                return [ThemeOptionsArea::SITE_WIDE, self::BUILDER];
            }
        }

        return [ThemeOptionsArea::SITE_WIDE];
    }

    /** @return array<string, mixed> The schema of one key: its types, the range of numbers, a description. */
    private static function keySchema(string $key): array
    {
        $definition = ThemeOptionSpec::definition($key);
        $schema = ['type' => match ($definition['type']) {
            'int' => ['integer', 'string', 'null'],
            'float' => ['number', 'string', 'null'],
            'weight' => ['string', 'integer', 'null'],
            default => ['string', 'null'],
        }];
        if (isset($definition['min'], $definition['max'])) {
            $schema['minimum'] = $definition['min'];
            $schema['maximum'] = $definition['max'];
        }
        $schema['description'] = $definition['label'] . (isset($definition['choices'])
            ? ': ' . implode(', ', $definition['choices'])
            : '');

        return $schema;
    }
}
  • Step 4c.5: GREEN-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'FontCheckTest|DiviThemeOptionsUpdateAbilityTest' Expected: OK (18 tests, 149 assertions).

  • Step 4c.6: Lint

Run: composer lint Expected: keine Meldung.

  • Step 4c.7: Commit
git add src/Integrations/Divi/Design/FontCheck.php src/Integrations/Divi/Design/DiviThemeOptionsUpdateAbility.php \
    tests/Unit/Integrations/Divi/Design/FontCheckTest.php \
    tests/Unit/Integrations/Divi/Design/DiviThemeOptionsUpdateAbilityTest.php
git commit -m "feat: lit/divi-theme-options-update ändert Theme-Optionen über DesignChange (Prüfung, Divi-Sanitizer, Schriftvorschläge)" \
    -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 4 – Abschluss

  • Step 4.8: Gesamtlauf

Run: composer test && composer lint Expected: alle Unit-Tests grün – Stand nach Task 3 + 31 neue Tests (Probelauf des Planers mit Platzhaltern für Tasks 1–3: 3121 → 3152), Lint ohne Meldung. Schlägt DiviBoundaryTest oder CoreIndependenceTest an, steht in den neuen Klassen ein Divi-/WordPress-Aufruf, der in den Adapter gehört – entfernen, nicht den Test anpassen. Die neuen Tools sind noch nicht registriert (Task 9); DiviToolFeaturesTest ändert sich deshalb hier nicht.


Task 5: Schriftprüfung der Schrift-Variablen in lit/divi-variables-set (W5, W12)

Files:

  • Modify: src/Integrations/Divi/Design/DiviVariablesSetAbility.php (Konstruktor, value(), Docblock), src/Integrations/Divi/Design/VariableValue.php (nur Docblock, Zeile 10–12), src/Integrations/Divi/DiviIntegration.php (nur das Konstruktor-Argument, damit die Suite grün bleibt)
  • Test: tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php (Hilfe ability() + vier neue Tests)

Interfaces:

  • Consumes: FontCatalog (Task 2), FontCheck::problem() (Task 4c), FontCheckTest::catalog() (Task 4c).
  • Produces: neue Konstruktor-Signatur DiviVariablesSetAbility::__construct(DesignSystemWriter $writer, GlobalVariablesAdapter $variables, DesignChange $change, GlobalUsages $usages, FontCatalog $fonts, ?Closure $random = null) – FontCatalog steht vor dem optionalen $random (nur Tests übergeben es). Task 9 ersetzt das hier eingesetzte new FontCatalog() durch die gemeinsame Instanz der Integration (eine Schriftliste je Request für Variablen, Theme-Optionen und Upload).
  • diviFeatures() bleibt [DesignRead, VariableWrite]: die Schriftlisten-Aufrufe tragen laut W1 auch variable-write; ein Bruch der Liste sperrt das Tool bewusst.

Regeln: Nur Variablen vom Typ fonts und nur, wenn VariableValue::check() den Wert schon angenommen hat, gehen an FontCheck (exakter Name aus FontCatalog, sonst Fehler items[i].value mit Vorschlägen). Andere Typen und ein formal ungültiger Schriftname laden die Liste nicht (sie kostet 1,3 MB, W5). VariableValue bleibt rein.

  • Step 5.1: Tests anpassen und ergänzen

In tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php:

  1. Import ergänzen (nach use LIT\MCP\Integrations\Divi\Design\DiviVariablesSetAbility;):
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
  1. In ability() den Parameter bool $manage = true ersetzen durch
        bool $manage = true,
        ?FontCatalog $fonts = null

und im Konstruktoraufruf nach $usages ?? self::usages(self::unused()), einfügen:

            $fonts ?? FontCheckTest::catalog(),
  1. Am Ende der Klasse (vor der letzten }) anfügen:
    /** Ruling W12: a font variable names a font Divi knows exactly; unknown names get suggestions. */
    public function testFontVariablesNeedAFontDiviKnows(): void
    {
        $error = $this->ability()
            ->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'robotto']]]);

        self::assertToolError($error, 'validation_failed');
        $fields = self::errorFields($error);
        self::assertSame(['items[0].value'], array_keys($fields));
        self::assertStringStartsWith('unbekannte Schrift „robotto“; ähnlich: ', $fields['items[0].value']);
        self::assertStringContainsString('Roboto', $fields['items[0].value']);
        self::assertSame([], $this->saves);
    }

    public function testAFontVariableWithAKnownFontIsPlanned(): void
    {
        $dry = self::assertToolError(
            $this->ability()->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'Roboto Slab']]]),
            'confirm_required'
        );

        self::assertSame(['added'], array_column($dry['diff'], 'action'));
    }

    /** The font list is loaded only for font variables (it costs 1.3 MB, ruling W5). */
    public function testOtherTypesDoNotLoadTheFontList(): void
    {
        $fonts = Mockery::mock(FontCatalog::class);
        $fonts->shouldNotReceive('has', 'names');

        self::assertToolError(
            $this->ability(fonts: $fonts)->execute(['items' => [
                ['type' => 'strings', 'label' => 'Gruß', 'value' => 'Hallo'],
                ['id' => 'Eckenradius', 'value' => '12px'],
            ]]),
            'confirm_required'
        );
    }

    /** VariableValue checks first: a malformed name never reaches the font list. */
    public function testAMalformedFontNameFailsBeforeTheFontList(): void
    {
        $fonts = Mockery::mock(FontCatalog::class);
        $fonts->shouldNotReceive('has', 'names');

        $error = $this->ability(fonts: $fonts)
            ->execute(['items' => [['type' => 'fonts', 'label' => 'Marke', 'value' => 'Roboto; x']]]);

        self::assertToolError($error, 'validation_failed');
        self::assertStringStartsWith('Schrift:', self::errorFields($error)['items[0].value']);
    }
  • Step 5.2: RED-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviVariablesSetAbilityTest Expected: Tests: 21, Assertions: 17, Errors: 21. mit TypeError: …DiviVariablesSetAbility::__construct(): Argument #5 ($random) must be of type ?Closure, Mockery_…_FontCatalog given.

  • Step 5.3: DiviVariablesSetAbility erweitern

In src/Integrations/Divi/Design/DiviVariablesSetAbility.php:

  1. Docblock der Klasse – den Satz zur Wertprüfung ersetzen:
 * builder. Values are checked per type (VariableValue); an active variable needs one. The dry run lists the posts that
 * reference a changed, archived or activated variable (ReferenceUsageQuery) and warns about presets that use it.
 */

durch

 * builder. Values are checked per type (VariableValue), font variables also against Divi's font list (FontCheck, ruling
 * W12); an active variable needs one. The dry run lists the posts that reference a changed, archived or activated
 * variable (ReferenceUsageQuery) and warns about presets that use it.
 */
  1. Konstruktor: nach private GlobalUsages $usages, einfügen
        private FontCatalog $fonts,
  1. In apply() die Zeile 'value' => self::value($type, $item['value']), ersetzen durch
                    'value' => $this->value($type, $item['value']),
  1. value() wird eine Instanzmethode mit der Listenprüfung – den Anfang der Methode ersetzen:
    /** @throws InvalidArgumentException */
    private function value(string $type, mixed $value): string
    {
        if (is_int($value)) {
            $value = (string) $value; // an attachment ID for images
        }
        if (! is_string($value)) {
            throw new InvalidArgumentException('Wert als Text erwartet');
        }
        $problem = VariableValue::check($type, $value);
        if ($problem === null && $type === 'fonts') {
            $problem = FontCheck::problem($this->fonts, $value);
        }

(der Rest – if ($problem !== null) { throw … } und return $value; – bleibt).

  1. src/Integrations/Divi/Design/VariableValue.php, Docblock der Klasse:
 * (new in Divi 5.15) is passed through by VariableBook, but the tools never create or change it. The check of fonts
 * against Divi's font list follows with 4d. Pure PHP.

ersetzen durch

 * (new in Divi 5.15) is passed through by VariableBook, but the tools never create or change it. Font names are only
 * checked for their form here; DiviVariablesSetAbility checks them against Divi's font list (FontCheck, ruling W12).
 * Pure PHP.
  1. src/Integrations/Divi/DiviIntegration.php (minimal, damit DiviIntegrationTest grün bleibt; die gemeinsame Instanz folgt mit Task 9): Import use LIT\MCP\Integrations\Divi\Design\FontCatalog; (alphabetisch nach DiviVariablesSetAbility) und im Aufruf
            new DiviVariablesSetAbility(
                $this->designWriter,
                $this->variablesAdapter,
                $this->designChange(),
                $this->globalUsages(),
                new FontCatalog()
            ),
  • Step 5.4: GREEN-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviVariablesSetAbilityTest|DiviIntegrationTest' Expected: OK (36 tests, 344 assertions) (21 + 15).

  • Step 5.5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint Expected: grün, Stand nach Task 4 + 4 Tests (Probe: 3156), Lint ohne Meldung.

git add src/Integrations/Divi/Design/DiviVariablesSetAbility.php src/Integrations/Divi/Design/VariableValue.php \
    src/Integrations/Divi/DiviIntegration.php tests/Unit/Integrations/Divi/Design/DiviVariablesSetAbilityTest.php
git commit -m "feat: lit/divi-variables-set prüft Schrift-Variablen gegen Divis Schriftliste (mit Vorschlägen)" \
    -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 6: ThemeOptionsRestorePlanner – Restore für theme_options (W6, W11)

Files:

  • Create: src/Integrations/Divi/Design/ThemeOptionsRestorePlanner.php
  • Modify: src/Integrations/Divi/Design/DiviDesignRestoreAbility.php (Docblock, Beschreibung, Text des unsupported-Zweigs)
  • Test: tests/Unit/Integrations/Divi/Design/ThemeOptionsRestorePlannerTest.php (neu), DiviDesignRestoreAbilityTest.php (angepasst)

Interfaces:

  • Consumes: ThemeOptionsArea, ThemeOptionsArea::SITE_WIDE (Task 4a), ThemeOptionsAreaTest::adapter(), DesignRights::themeOptions(), ThemeOptionBook, ThemeOptionDiff, ThemeOptionSpec::definition() (Tasks 2/3).
  • Produces: final class ThemeOptionsRestorePlanner implements RestorePlanner – __construct(DesignSystemWriter $writer, ThemeOptionsAdapter $options); area() = theme_options, designArea() = new ThemeOptionsArea($options), rights() = DesignRights::themeOptions($writer, $options), features() = [ThemeOptionRead, ThemeOptionWrite] (wie das Update-Tool). Testhilfen ThemeOptionsRestorePlannerTest::SNAPSHOT_ID, ::current(), ::snapshot().
  • DiviDesignRestoreAbility::__construct(DesignChange, SnapshotStore, RestorePlanner ...$planners) bleibt; Task 9 hängt in DiviIntegration::abilities() an die Planerliste new ThemeOptionsRestorePlanner($this->designWriter, <ThemeOptionsAdapter der Integration>) an (wie 4c Task 9 die Farb-/Variablen-Planer). Ohne diese Zeile bleibt theme_options unsupported – die Suite braucht sie nicht.

Regeln: Der Snapshot ersetzt die ganze Whitelist (mayRemove = true); fehlt ein Schlüssel im Snapshot, ist das eine reset-Zeile und eine Warnung, die die Labels nennt. Zusammenfassung Snapshot <id> wiederherstellen – <ThemeOptionDiff::summary>. Bei Änderungen zusätzlich SITE_WIDE; ohne Änderung keine Warnung. Der unsupported-Zweig der Restore-Ability bleibt für Bereiche ohne Planer, ohne den Verweis auf Etappe 4d.

Task 6a: ThemeOptionsRestorePlanner

  • Step 6a.1: Failing Test ThemeOptionsRestorePlannerTest schreiben

Datei tests/Unit/Integrations/Divi/Design/ThemeOptionsRestorePlannerTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\DesignPlan;
use LIT\MCP\Integrations\Divi\Design\DesignSystemWriter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionDiff;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsArea;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionSpec;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class ThemeOptionsRestorePlannerTest extends TestCase
{
    public const SNAPSHOT_ID = 'theme_options-20261016T100000Z-aaaaaa';

    /** @return array<string, int|float|string|false> The options now: larger text, a gutter set after the snapshot. */
    public static function current(): array
    {
        return ['body_font_size' => 18, 'gutter_width' => 2];
    }

    /** @return array<string, int|float|string|false> The snapshot: body font and text size 16, no gutter. */
    public static function snapshot(): array
    {
        return ['body_font' => 'Open Sans', 'body_font_size' => 16];
    }

    private static function planner(bool $customize = true): ThemeOptionsRestorePlanner
    {
        $stored = [];
        $writes = [];
        $writer = Mockery::mock(DesignSystemWriter::class);
        $writer->allows('canUseVisualBuilder')->andReturn(true);

        return new ThemeOptionsRestorePlanner(
            $writer,
            ThemeOptionsAreaTest::adapter($stored, $writes, null, $customize)
        );
    }

    public function testAreaFeaturesAndDesignArea(): void
    {
        $planner = self::planner();

        self::assertSame('theme_options', $planner->area());
        self::assertSame([DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite], $planner->features());
        self::assertInstanceOf(ThemeOptionsArea::class, $planner->designArea());
    }

    /** Ruling W11: the rights of lit/divi-theme-options-update. */
    public function testNeedsTheThemeOptionRights(): void
    {
        Functions\when('current_user_can')->justReturn(true);
        self::assertNull(self::planner()->rights());
        self::assertToolError(self::planner(false)->rights(), 'forbidden');

        Functions\when('current_user_can')->justReturn(false);
        self::assertToolError(self::planner()->rights(), 'forbidden');
    }

    /** Ruling W6: the snapshot replaces the whole whitelist – keys it lacks return to Divi's default. */
    public function testTheSnapshotReplacesEveryOptionAndNamesTheResetOnes(): void
    {
        $plan = self::planner()->plan(self::current(), self::snapshot(), self::SNAPSHOT_ID);

        self::assertInstanceOf(DesignPlan::class, $plan);
        self::assertTrue($plan->mayRemove);
        self::assertNull($plan->usages);
        self::assertSame(ThemeOptionBook::fromDivi(self::snapshot())->data(), $plan->raw);
        self::assertSame(
            [['body_font', 'added'], ['body_font_size', 'changed'], ['gutter_width', 'reset']],
            array_map(static fn (array $row): array => [$row['key'], $row['action']], $plan->diff)
        );
        self::assertSame(
            'Snapshot ' . self::SNAPSHOT_ID . ' wiederherstellen – ' . ThemeOptionDiff::summary($plan->diff),
            $plan->summary
        );
        self::assertSame([
            ThemeOptionsArea::SITE_WIDE,
            sprintf(
                'Theme-Optionen, die im Snapshot nicht gesetzt sind, kehren zum Divi-Standard zurück („%s“).',
                ThemeOptionSpec::definition('gutter_width')['label']
            ),
        ], $plan->warnings);
    }

    public function testTheSameOptionsAreNoChange(): void
    {
        $plan = self::planner()->plan(self::snapshot(), self::snapshot(), self::SNAPSHOT_ID);

        self::assertInstanceOf(DesignPlan::class, $plan);
        self::assertTrue($plan->isEmpty());
        self::assertSame([], $plan->warnings);
    }
}
  • Step 6a.2: RED-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter ThemeOptionsRestorePlannerTest Expected: Tests: 4, Assertions: 0, Errors: 4. mit Error: Class "LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner" not found.

  • Step 6a.3: ThemeOptionsRestorePlanner implementieren

Datei src/Integrations/Divi/Design/ThemeOptionsRestorePlanner.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use WP_Error;

defined('ABSPATH') || exit;

/**
 * Restores a snapshot of the theme options (rulings W6, W11): the snapshot replaces the whole whitelist – keys it
 * lacks are deleted and return to Divi's default (ThemeOptionDiff "reset" rows, named in a warning). The rights and
 * Divi features are those of lit/divi-theme-options-update.
 */
final class ThemeOptionsRestorePlanner implements RestorePlanner
{
    private const RESET = 'Theme-Optionen, die im Snapshot nicht gesetzt sind, kehren zum Divi-Standard zurück (%s).';

    public function __construct(private DesignSystemWriter $writer, private ThemeOptionsAdapter $options)
    {
    }

    public function area(): string
    {
        return 'theme_options';
    }

    public function designArea(): DesignArea
    {
        return new ThemeOptionsArea($this->options);
    }

    public function rights(): ?WP_Error
    {
        return DesignRights::themeOptions($this->writer, $this->options);
    }

    /** Like lit/divi-theme-options-update: reads et_divi (ThemeOptionRead), writes it (ThemeOptionWrite). */
    public function features(): array
    {
        return [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite];
    }

    public function plan(array $current, array $snapshot, string $snapshotId): DesignPlan|WP_Error
    {
        $after = ThemeOptionBook::fromDivi($snapshot);
        $diff = ThemeOptionDiff::between(ThemeOptionBook::fromDivi($current), $after);
        $reset = [];
        foreach ($diff as $row) {
            if ($row['action'] === 'reset') {
                $reset[] = '„' . $row['label'] . '“';
            }
        }
        $warnings = $diff === [] ? [] : [ThemeOptionsArea::SITE_WIDE];
        if ($reset !== []) {
            $warnings[] = sprintf(self::RESET, implode(', ', $reset));
        }

        return new DesignPlan(
            $after->data(),
            $diff,
            sprintf('Snapshot %s wiederherstellen – %s', $snapshotId, ThemeOptionDiff::summary($diff)),
            null,
            $warnings,
            true
        );
    }
}
  • Step 6a.4: GREEN-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter ThemeOptionsRestorePlannerTest Expected: OK (4 tests, 20 assertions).

  • Step 6a.5: Lint und Commit

Run: composer lint – Expected: keine Meldung.

git add src/Integrations/Divi/Design/ThemeOptionsRestorePlanner.php \
    tests/Unit/Integrations/Divi/Design/ThemeOptionsRestorePlannerTest.php
git commit -m "feat: ThemeOptionsRestorePlanner – Snapshot theme_options ersetzt die Whitelist, nennt zurückgesetzte Optionen" \
    -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 6b: lit/divi-design-restore kennt theme_options

  • Step 6b.1: DiviDesignRestoreAbilityTest anpassen

In tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php:

  1. Importe ergänzen (nach use LIT\MCP\Integrations\Divi\Design\SnapshotStore;):
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner;
  1. testUnknownSnapshotAndThemeOptions() ersetzen durch die zwei Tests:
    public function testUnknownSnapshots(): void
    {
        $snapshots = Mockery::mock(SnapshotStore::class);
        $snapshots->allows('get')->andReturn(null);
        $ability = self::allAreas($snapshots, self::colorStore([]), self::variableStore([]));

        self::assertToolError($ability->execute(['snapshot_id' => 'presets-20261016T100000Z-abcdef']), 'not_found');
        self::assertToolError($ability->execute(['snapshot_id' => 'colors-20261016T100000Z-abcdef']), 'not_found');
        self::assertToolError($ability->execute(['snapshot_id' => 'variables-20261016T100000Z-abcdef']), 'not_found');
        self::assertToolError(
            $ability->execute(['snapshot_id' => 'theme_options-20261016T100000Z-abcdef']),
            'not_found'
        );
        self::assertToolError($ability->execute(['snapshot_id' => 'kaputt']), 'validation_failed');
    }

    /** A snapshot area whose planner is not wired stays unsupported (W11) – never a silent no-op. */
    public function testAnAreaWithoutAPlannerIsUnsupported(): void
    {
        Functions\when('current_user_can')->justReturn(true);
        $writer = Mockery::mock(DesignSystemWriter::class);
        $writer->allows('canUseVisualBuilder')->andReturn(true);
        $snapshots = Mockery::mock(SnapshotStore::class);
        $snapshots->shouldNotReceive('get');
        $ability = new DiviDesignRestoreAbility(
            new DesignChange($snapshots, FakeChangeLog::forbidden()),
            $snapshots,
            new PresetRestorePlanner($writer)
        );

        $error = self::assertToolError(
            $ability->execute(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]),
            'unsupported'
        );
        self::assertStringNotContainsString('4d', $error['hint']);
    }
  1. allAreas() bekommt den Theme-Options-Planer – die ganze Methode samt neuer Hilfe optionStore() ersetzen:
    /** The ability with the planners of all four areas; the user has every right unless a store says otherwise. */
    private static function allAreas(
        SnapshotStore $snapshots,
        GlobalColorsAdapter $colors,
        GlobalVariablesAdapter $variables,
        ?FakeChangeLog $log = null,
        ?ThemeOptionsAdapter $options = null
    ): DiviDesignRestoreAbility {
        Functions\when('current_user_can')->justReturn(true);
        $writer = Mockery::mock(DesignSystemWriter::class);
        $writer->allows('canUseVisualBuilder')->andReturn(true);
        $writer->allows('presets')->andReturn(PresetBookTest::stored());
        $writer->shouldNotReceive('savePresets');
        $noOptions = [];

        return new DiviDesignRestoreAbility(
            new DesignChange($snapshots, $log ?? FakeChangeLog::forbidden()),
            $snapshots,
            new PresetRestorePlanner($writer),
            new ColorRestorePlanner($writer, $colors),
            new VariableRestorePlanner($writer, $variables),
            new ThemeOptionsRestorePlanner($writer, $options ?? self::optionStore($noOptions))
        );
    }

    /**
     * A theme options adapter that reads what it last wrote, starting with $stored (ThemeOptionsAreaTest::adapter()).
     *
     * @param array<string, int|float|string|false> $stored
     */
    private static function optionStore(array &$stored, bool $customize = true): ThemeOptionsAdapter
    {
        $writes = [];

        return ThemeOptionsAreaTest::adapter($stored, $writes, null, $customize);
    }

(Der Docblock nennt jetzt „all four areas“.)

  1. Vor testRightsFollowTheSnapshotArea() den Restore-Test einfügen und den Rechte-Test ersetzen:
    public function testRestoresThemeOptionsAndResetsTheKeysTheSnapshotLacks(): void
    {
        $id = ThemeOptionsRestorePlannerTest::SNAPSHOT_ID;
        $stored = ThemeOptionsRestorePlannerTest::current();
        $snapshots = Mockery::mock(SnapshotStore::class);
        $snapshots->allows('get')->with('theme_options', $id)
            ->andReturn(self::snapshotIn('theme_options', $id, ThemeOptionsRestorePlannerTest::snapshot()));
        $snapshots->expects('save')
            ->with(
                'theme_options',
                ThemeOptionBook::fromDivi(ThemeOptionsRestorePlannerTest::current())->data(),
                'lit/divi-design-restore'
            )
            ->andReturn('theme_options-5');
        $snapshots->expects('attachLog');
        $ability = self::allAreas(
            $snapshots,
            self::colorStore([]),
            self::variableStore([]),
            new FakeChangeLog(8),
            self::optionStore($stored)
        );

        $dry = self::assertToolError($ability->execute(['snapshot_id' => $id]), 'confirm_required');
        self::assertSame(['added', 'changed', 'reset'], array_column($dry['diff'], 'action'));
        self::assertStringContainsString('Divi-Standard', $dry['warnings'][1]);

        $result = $ability->execute(['snapshot_id' => $id, 'confirm' => true, 'design_rev' => $dry['design_rev']]);
        self::assertSame('theme_options-5', $result['snapshot_id']);
        self::assertSame(
            ThemeOptionBook::fromDivi(ThemeOptionsRestorePlannerTest::snapshot())->data(),
            ThemeOptionBook::fromDivi($stored)->data()
        );
    }

    /** Spec §10, V10: the rights are those of the snapshot's area. */
    public function testRightsFollowTheSnapshotArea(): void
    {
        $snapshots = Mockery::mock(SnapshotStore::class);
        $snapshots->shouldNotReceive('get');
        $options = [];
        $ability = self::allAreas(
            $snapshots,
            self::colorStore([]),
            self::variableStore([], false),
            null,
            self::optionStore($options, false)
        );

        self::assertToolError(
            $ability->permission(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]),
            'forbidden'
        );
        self::assertToolError(
            $ability->execute(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID]),
            'forbidden'
        );
        self::assertToolError(
            $ability->permission(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID]),
            'forbidden'
        );
        self::assertTrue($ability->permission(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID]));
        self::assertTrue($ability->permission(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa']));
        self::assertToolError($ability->permission(['snapshot_id' => 'kaputt']), 'forbidden', 'fail closed');
    }
  1. testFeaturesFollowTheSnapshotArea() ersetzen:
    public function testFeaturesFollowTheSnapshotArea(): void
    {
        $ability = self::allAreas(Mockery::mock(SnapshotStore::class), self::colorStore([]), self::variableStore([]));

        self::assertSame(
            [DiviFeature::DesignRead, DiviFeature::ColorWrite],
            $ability->diviFeatures(['snapshot_id' => ColorRestorePlannerTest::SNAPSHOT_ID])
        );
        self::assertSame(
            [DiviFeature::DesignRead, DiviFeature::VariableWrite],
            $ability->diviFeatures(['snapshot_id' => VariableRestorePlannerTest::SNAPSHOT_ID])
        );
        self::assertSame(
            [DiviFeature::DesignRead, DiviFeature::PresetWrite],
            $ability->diviFeatures(['snapshot_id' => 'presets-20261016T100000Z-aaaaaa'])
        );
        self::assertSame(
            [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite],
            $ability->diviFeatures(['snapshot_id' => ThemeOptionsRestorePlannerTest::SNAPSHOT_ID])
        );
        self::assertSame(
            [DiviFeature::DesignRead, DiviFeature::PresetWrite, DiviFeature::ColorWrite, DiviFeature::VariableWrite,
                DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite],
            $ability->diviFeatures([])
        );
    }
  • Step 6b.2: RED-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviDesignRestoreAbilityTest Expected: Tests: 10, Assertions: 116, Failures: 1. – nur testAnAreaWithoutAPlannerIsUnsupported mit Failed asserting that 'Theme-Optionen folgen mit Etappe 4d.' does not contain "4d". Der Restore über den neuen Planer ist schon grün: die Ability ist planer-generisch (4c), sie braucht nur die neuen Texte.

  • Step 6b.3: DiviDesignRestoreAbility anpassen

In src/Integrations/Divi/Design/DiviDesignRestoreAbility.php:

  1. Docblock der Klasse, letzter Satz:
 * the plan. Areas without a planner (theme_options until Etappe 4d) are not restorable yet.

ersetzen durch

 * the plan. A snapshot of an area without a planner is refused as unsupported, never treated as a no-op.
  1. In description() das Ende ab Bereiche: ersetzen:
            . 'beim Nutzer confirm: true; vorher wird der aktuelle Stand selbst gesichert. Bereiche: presets, colors '
            . '(Farben, die es im Snapshot nicht gibt, entfallen), variables (Variablen, die erst nach dem Snapshot '
            . 'entstanden, bleiben archiviert erhalten) und theme_options (Theme-Optionen, die im Snapshot nicht '
            . 'gesetzt sind, kehren zum Divi-Standard zurück).';
  1. Der unsupported-Zweig in execute():
            return Errors::unsupported(
                'Snapshots dieses Bereichs lassen sich hier nicht wiederherstellen.',
                'Den Stand des Snapshots im Divi-Builder bzw. -Customizer von Hand herstellen.'
            );
  • Step 6b.4: GREEN-Lauf

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'DiviDesignRestoreAbilityTest|ThemeOptionsRestorePlannerTest' Expected: OK (14 tests, 137 assertions).

  • Step 6b.5: Gesamtlauf, Lint, Commit

Run: composer test && composer lint Expected: grün, Stand nach Task 5 + 6 Tests (Probe: 3162), Lint ohne Meldung.

git add src/Integrations/Divi/Design/DiviDesignRestoreAbility.php \
    tests/Unit/Integrations/Divi/Design/DiviDesignRestoreAbilityTest.php
git commit -m "feat: lit/divi-design-restore stellt theme_options wieder her; Hinweis auf Etappe 4d entfällt" \
    -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 7: RemoteFileFetcher aus MediaImporter herauslösen (Refactor, Verhalten gleich)

Der URL-Teil von MediaImporter (Schema-, Core- und eigene Bereichsprüfung, IPv4-Pin, Weiterleitungs-Hook, Streamen in eine Temp-Datei mit Abschneiden bei Limit + 1 Byte, Zeitlimit 30 s, Protokoll ohne Zugangsdaten/Query, keine Fehlerursache für den Client – Security-Audit M3) wandert unverändert in die neue Klasse RemoteFileFetcher, damit lit/divi-fonts-upload (Task 8) dieselbe Härtung nutzt. Medienbezogen bleiben in MediaImporter: die Fehlermeldungen an den Client (validation_failed mit url), die Content-Length-Prüfung gegen Limits::uploadBytes(), Typprüfung, Sideload, Alt-Text. Der Konstruktor MediaImporter(Limits, ?Closure $resolve) bleibt gleich – MediaIntegration und alle Tests bleiben unverändert. Sicherheitsnetz: MediaImporterTest (deckt die Wächter über fromUrl() ab), MediaUploadAbilitiesTest, PublicAddressTest – sie werden nicht geändert. RemoteFileFetcherTest prüft nur, was ein zweiter Aufrufer braucht (Limit als Parameter, null statt Fehlermeldung, Protokoll-Kontext, Hooks nur während des Requests).

Files:

  • Create: src/Integrations/Media/RemoteFileFetcher.php
  • Modify: src/Integrations/Media/MediaImporter.php
  • Test: tests/Unit/Integrations/Media/RemoteFileFetcherTest.php (neu); MediaImporterTest.php, MediaUploadAbilitiesTest.php, PublicAddressTest.php unverändert

Interfaces:

  • Consumes: PublicAddress::hostIsPublic()/dnsResolver(), Log::failure(), WordPress wp_parse_url, wp_http_validate_url, wp_safe_remote_get, wp_remote_retrieve_response_code, add_action/remove_action (Hooks http_api_curl, requests-requests.before_redirect).

  • Produces (Task 8 nutzt, Task 9 verdrahtet mit new RemoteFileFetcher()):

    // src/Integrations/Media/RemoteFileFetcher.php – class (nicht final, für Fakes)
    public function __construct(?Closure $resolve = null);                 // DNS-Lookup; null = PublicAddress::dnsResolver()
    public function allows(string $url): bool;                             // http(s) + wp_http_validate_url + nur öffentliche Adressen
    /** @return array<string, mixed>|null Antwort (Header, Status); null = Download fehlgeschlagen (Grund nur im Log) */
    public function fetch(string $url, string $tmp, int $maxBytes, string $context): ?array;  // limit_response_size = $maxBytes + 1
    
  • Unverändert: MediaImporter::__construct(Limits $limits, ?Closure $resolve = null), fromUrl(), fromBase64(). Keine Verdrahtungsänderung nötig.

  • Step 1: Sicherheitsnetz grün

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'MediaImporterTest|MediaUploadAbilitiesTest|PublicAddressTest' Expected: OK (71 tests, 246 assertions)

  • Step 2: Failing Test für RemoteFileFetcher schreiben

tests/Unit/Integrations/Media/RemoteFileFetcherTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Media;

use Brain\Monkey\Actions;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Error;

/**
 * The URL guard and the download shared by the media import and the font upload. MediaImporterTest keeps covering
 * the same guard through fromUrl(); this test pins the parts a second caller relies on: the size limit as
 * parameter, null instead of an error message, the log context.
 */
final class RemoteFileFetcherTest extends TestCase
{
    private string $tmp = '';

    /** error_log() is a PHP internal and cannot be stubbed; it is redirected to this file. */
    private string $errorLog = '';
    private string|false $previousErrorLog = false;

    protected function setUp(): void
    {
        parent::setUp();
        $this->tmp = (string) tempnam(sys_get_temp_dir(), 'lit-fetch');
        $this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
        $this->previousErrorLog = ini_set('error_log', $this->errorLog);
        Functions\when('wp_parse_url')->alias('parse_url');
        Functions\when('wp_http_validate_url')->returnArg();
        Functions\when('wp_remote_retrieve_response_code')->alias(static fn (array $r): int => $r['response']['code']);
    }

    protected function tearDown(): void
    {
        ini_set('error_log', (string) $this->previousErrorLog);
        foreach ([$this->tmp, $this->errorLog] as $file) {
            if (is_file($file)) {
                unlink($file);
            }
        }
        parent::tearDown();
    }

    /** @param list<string> $addresses What every host name resolves to (no DNS in unit tests). */
    private function fetcher(array $addresses = ['93.184.216.34']): RemoteFileFetcher
    {
        return new RemoteFileFetcher(static fn (string $host): array => $addresses);
    }

    public function testOnlyPublicHttpUrlsAreAllowed(): void
    {
        self::assertTrue($this->fetcher()->allows('https://fonts.example.com/inter.woff2'));
        self::assertTrue($this->fetcher()->allows('HTTP://fonts.example.com/inter.woff2'));
        self::assertFalse($this->fetcher()->allows('ftp://fonts.example.com/inter.woff2'));
        self::assertFalse($this->fetcher()->allows('file:///etc/passwd'));
        self::assertFalse($this->fetcher()->allows('http://169.254.169.254/latest/meta-data/'));
        self::assertFalse($this->fetcher(['10.0.0.5'])->allows('https://intern.example.com/x.woff2'));
        self::assertFalse($this->fetcher([])->allows('https://nirgends.example.com/x.woff2'));

        Functions\when('wp_http_validate_url')->justReturn(false);
        self::assertFalse($this->fetcher()->allows('https://fonts.example.com/inter.woff2'));
    }

    public function testTheBodyIsStreamedIntoTheTempFileAndCutOffOneByteAboveTheLimit(): void
    {
        Functions\expect('wp_safe_remote_get')->once()->with(
            'https://fonts.example.com/inter.woff2',
            Mockery::on(function (array $args): bool {
                file_put_contents($args['filename'], 'wOF2DATA');

                return $args['filename'] === $this->tmp && $args['stream'] === true && $args['timeout'] === 30
                    && $args['limit_response_size'] === 2048 + 1;
            })
        )->andReturn(['response' => ['code' => 200], 'headers' => ['content-type' => 'font/woff2']]);

        $response = $this->fetcher()->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'font upload');

        self::assertSame(['code' => 200], $response['response'] ?? null);
        self::assertSame('wOF2DATA', file_get_contents($this->tmp));
    }

    public function testAFailedRequestIsNullAndOnlyTheLogNamesTheReasonAndTheContext(): void
    {
        Functions\when('wp_safe_remote_get')->justReturn(new WP_Error('http_request_failed', 'Connection refused'));

        $response = $this->fetcher()->fetch(
            'https://nutzer:geheim@fonts.example.com:8443/inter.woff2?token=abc',
            $this->tmp,
            2048,
            'font upload'
        );

        $log = (string) file_get_contents($this->errorLog);
        self::assertNull($response);
        self::assertStringContainsString('font upload from https://fonts.example.com:8443/inter.woff2', $log);
        self::assertStringContainsString('Connection refused', $log);
        self::assertStringNotContainsString('geheim', $log);
        self::assertStringNotContainsString('token=abc', $log);
    }

    public function testAnHttpErrorStatusIsNull(): void
    {
        Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 404], 'headers' => []]);

        self::assertNull($this->fetcher()->fetch('https://fonts.example.com/fehlt.woff2', $this->tmp, 2048, 'x'));
        self::assertStringContainsString('HTTP status 404', (string) file_get_contents($this->errorLog));
    }

    public function testTheIpv4AndRedirectHooksLiveOnlyDuringTheRequest(): void
    {
        $listener = null;
        Actions\expectAdded('http_api_curl')->once();
        Actions\expectAdded('requests-requests.before_redirect')->once()
            ->whenHappen(static function (callable $callback) use (&$listener): void {
                $listener = $callback;
            });
        Functions\when('wp_safe_remote_get')->justReturn(['response' => ['code' => 200], 'headers' => []]);

        $this->fetcher()->fetch('https://fonts.example.com/inter.woff2', $this->tmp, 2048, 'x');

        self::assertFalse(has_action('http_api_curl'));
        self::assertFalse(has_action('requests-requests.before_redirect'));
        self::assertIsCallable($listener);
        $listener('https://cdn.example.com/inter.woff2'); // public: no exception
        $this->expectException(\WpOrg\Requests\Exception::class);
        $listener('http://127.0.0.1/inter.woff2');
    }
}
  • Step 3: RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter RemoteFileFetcherTest Expected: Tests: 5, Assertions: 0, Errors: 5. – jeweils Error: Class "LIT\MCP\Integrations\Media\RemoteFileFetcher" not found.

  • Step 4: RemoteFileFetcher anlegen (Code aus MediaImporter::download(), isPublicUrl(), loggableUrl(), downloadFailed() – unverändert bis auf $maxBytes, $context und null)

src/Integrations/Media/RemoteFileFetcher.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Media;

use Closure;
use LIT\MCP\Log;
use WP_Error;
use WpOrg\Requests\Exception as RequestsException;

defined('ABSPATH') || exit;

/**
 * Downloads a file from a public http(s) URL into a temp file (spec §6.3, §10) – shared by the media import and
 * the font upload. Only http(s), checked with wp_http_validate_url() and the plugin's own range check
 * (PublicAddress, independent of the core version) before any request and on every redirect, fetched with
 * wp_safe_remote_get() over IPv4 only (core re-validates redirects too), streamed into the temp file and cut off
 * one byte above $maxBytes. A failure reaches the caller only as null (no refused/timeout/status oracle, security
 * audit M3); the reason goes to the log. Not final so abilities can be tested with a fake.
 */
class RemoteFileFetcher
{
    private const TIMEOUT = 30;
    private const REDIRECT_HOOK = 'requests-requests.before_redirect';

    /** @var Closure(string): list<string> Host name => IPv4 addresses. */
    private Closure $resolve;

    /** @param (Closure(string): list<string>)|null $resolve DNS lookup; null = the real one. */
    public function __construct(?Closure $resolve = null)
    {
        $this->resolve = $resolve ?? PublicAddress::dnsResolver();
    }

    /** Whether $url may be fetched at all: http(s), accepted by core, host with public addresses only. */
    public function allows(string $url): bool
    {
        $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));

        return in_array($scheme, ['http', 'https'], true) && wp_http_validate_url($url) && $this->isPublicUrl($url);
    }

    /**
     * Streams $url into $tmp; the caller checks allows() first and the size of $tmp afterwards (a body above
     * $maxBytes is cut off at $maxBytes + 1 bytes).
     *
     * @param string $context Names the download in the failure log, e.g. "media import".
     * @return array<string, mixed>|null The response (headers, status), null when the download failed.
     */
    public function fetch(string $url, string $tmp, int $maxBytes, string $context): ?array
    {
        // IPv4 only, for this request only: wp_http_validate_url() checks the IPv4 address
        // (gethostbyname), so an AAAA record must not lead cURL to an unchecked IPv6 target.
        $ipv4 = static function ($handle): void {
            if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) {
                // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- http_api_curl hands us the handle.
                curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
            }
        };
        // Every redirect target gets the plugin's own range check too; Requests turns the
        // exception into a failed request (WP_Http returns a WP_Error).
        $redirect = function ($location): void {
            if (! $this->isPublicUrl((string) $location)) {
                throw new RequestsException('Redirect to a non-public address refused', 'lit_mcp_redirect');
            }
        };
        add_action('http_api_curl', $ipv4);
        add_action(self::REDIRECT_HOOK, $redirect);
        try {
            $response = wp_safe_remote_get($url, [
                'timeout' => self::TIMEOUT,
                'stream' => true,
                'filename' => $tmp,
                'limit_response_size' => $maxBytes + 1,
            ]);
        } finally {
            remove_action('http_api_curl', $ipv4);
            remove_action(self::REDIRECT_HOOK, $redirect);
        }

        if ($response instanceof WP_Error) {
            return $this->failed($context, $url, $response->get_error_message());
        }

        $status = (int) wp_remote_retrieve_response_code($response);
        if ($status !== 200) {
            return $this->failed($context, $url, sprintf('HTTP status %d', $status));
        }

        return $response;
    }

    /** Host (name or IP literal) of $url resolves to public addresses only (PublicAddress). */
    private function isPublicUrl(string $url): bool
    {
        return PublicAddress::hostIsPublic((string) wp_parse_url($url, PHP_URL_HOST), $this->resolve);
    }

    /** The reason goes to the log only (security audit M3). */
    private function failed(string $context, string $url, string $reason): ?array
    {
        Log::failure($context . ' from ' . self::loggableUrl($url), $reason);

        return null;
    }

    /** Scheme, host, port and path of $url – no credentials, query string or fragment in the log. */
    private static function loggableUrl(string $url): string
    {
        $parts = wp_parse_url($url);
        if (! is_array($parts)) {
            return '(ungültige URL)';
        }

        $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';

        return sprintf('%s://%s%s%s', $parts['scheme'] ?? '', $parts['host'] ?? '', $port, $parts['path'] ?? '');
    }
}
  • Step 5: GREEN

Run: php -d memory_limit=512M vendor/bin/phpunit --filter RemoteFileFetcherTest Expected: OK (5 tests, 24 assertions)

  • Step 6: MediaImporter auf RemoteFileFetcher umstellen
--- a/src/Integrations/Media/MediaImporter.php
+++ b/src/Integrations/Media/MediaImporter.php
@@ -11,18 +11,15 @@ use LIT\MCP\Limits;
 use LIT\MCP\Log;
 use Throwable;
 use WP_Error;
-use WpOrg\Requests\Exception as RequestsException;
 
 defined('ABSPATH') || exit;
 
 /**
  * Imports files into the media library (spec §6.3, §10).
  *
- * - URL: only http(s), checked with wp_http_validate_url() and the plugin's own range check
- *   (PublicAddress, independent of the core version) before any request and on every redirect,
- *   fetched with wp_safe_remote_get() (core re-validates redirects too), streamed into a temp file
- *   and cut off one byte above the upload limit. Network failures reach the client only as one
- *   generic message (no refused/timeout/status oracle, security audit M3); details go to the log.
+ * - URL: downloaded by RemoteFileFetcher (public http(s) only, redirects checked, streamed into a
+ *   temp file and cut off one byte above the upload limit). Network failures reach the client only
+ *   as one generic message (no refused/timeout/status oracle, security audit M3); details go to the log.
  * - Base64: decoded strictly; the size is checked against Limits::inlineBytes() (upload limit,
  *   at most 10 MB – the whole file sits in memory) before anything is decoded or written.
  * - Type: whitelist (filter lit_mcp_upload_mimes) intersected with what WordPress allows the
@@ -48,28 +45,20 @@ class MediaImporter
         'mp3' => 'audio/mpeg',
     ];
 
-    private const TIMEOUT = 30;
     private const ALT_KEY = '_wp_attachment_image_alt';
-    private const REDIRECT_HOOK = 'requests-requests.before_redirect';
 
-    /** @var Closure(string): list<string> Host name => IPv4 addresses. */
-    private Closure $resolve;
+    private RemoteFileFetcher $fetcher;
 
     /** @param (Closure(string): list<string>)|null $resolve DNS lookup; null = the real one. */
     public function __construct(private Limits $limits, ?Closure $resolve = null)
     {
-        $this->resolve = $resolve ?? PublicAddress::dnsResolver();
+        $this->fetcher = new RemoteFileFetcher($resolve);
     }
 
     /** @param array{title?: string, alt?: string, post_id?: int} $meta */
     public function fromUrl(string $url, ?string $filename, array $meta): int|WP_Error
     {
-        $scheme = strtolower((string) wp_parse_url($url, PHP_URL_SCHEME));
-        if (
-            ! in_array($scheme, ['http', 'https'], true)
-            || ! wp_http_validate_url($url)
-            || ! $this->isPublicUrl($url)
-        ) {
+        if (! $this->fetcher->allows($url)) {
             return Errors::invalidInput(
                 'Die URL ist nicht erlaubt.',
                 ['url' => 'nur öffentlich erreichbare http(s)-Adressen'],
@@ -134,42 +123,9 @@ class MediaImporter
     /** @return array<string, mixed>|WP_Error The response (body streamed into $tmp). */
     private function download(string $url, string $tmp): array|WP_Error
     {
-        // IPv4 only, for this request only: wp_http_validate_url() checks the IPv4 address
-        // (gethostbyname), so an AAAA record must not lead cURL to an unchecked IPv6 target.
-        $ipv4 = static function ($handle): void {
-            if (defined('CURLOPT_IPRESOLVE') && defined('CURL_IPRESOLVE_V4')) {
-                // phpcs:ignore WordPress.WP.AlternativeFunctions.curl_curl_setopt -- http_api_curl hands us the handle.
-                curl_setopt($handle, CURLOPT_IPRESOLVE, CURL_IPRESOLVE_V4);
-            }
-        };
-        // Every redirect target gets the plugin's own range check too; Requests turns the
-        // exception into a failed request (WP_Http returns a WP_Error).
-        $redirect = function ($location): void {
-            if (! $this->isPublicUrl((string) $location)) {
-                throw new RequestsException('Redirect to a non-public address refused', 'lit_mcp_redirect');
-            }
-        };
-        add_action('http_api_curl', $ipv4);
-        add_action(self::REDIRECT_HOOK, $redirect);
-        try {
-            $response = wp_safe_remote_get($url, [
-                'timeout' => self::TIMEOUT,
-                'stream' => true,
-                'filename' => $tmp,
-                'limit_response_size' => $this->limits->uploadBytes() + 1,
-            ]);
-        } finally {
-            remove_action('http_api_curl', $ipv4);
-            remove_action(self::REDIRECT_HOOK, $redirect);
-        }
-
-        if ($response instanceof WP_Error) {
-            return $this->downloadFailed($url, $response->get_error_message());
-        }
-
-        $status = (int) wp_remote_retrieve_response_code($response);
-        if ($status !== 200) {
-            return $this->downloadFailed($url, sprintf('HTTP status %d', $status));
+        $response = $this->fetcher->fetch($url, $tmp, $this->limits->uploadBytes(), 'media import');
+        if ($response === null) {
+            return $this->downloadFailed();
         }
 
         $length = (string) wp_remote_retrieve_header($response, 'content-length');
@@ -361,30 +317,9 @@ class MediaImporter
         }
     }
 
-    /** Host (name or IP literal) of $url resolves to public addresses only (PublicAddress). */
-    private function isPublicUrl(string $url): bool
+    /** One message for every network failure (security audit M3); RemoteFileFetcher logged the reason. */
+    private function downloadFailed(): WP_Error
     {
-        return PublicAddress::hostIsPublic((string) wp_parse_url($url, PHP_URL_HOST), $this->resolve);
-    }
-
-    /** Scheme, host, port and path of $url – no credentials, query string or fragment in the log. */
-    private static function loggableUrl(string $url): string
-    {
-        $parts = wp_parse_url($url);
-        if (! is_array($parts)) {
-            return '(ungültige URL)';
-        }
-
-        $port = isset($parts['port']) ? ':' . (int) $parts['port'] : '';
-
-        return sprintf('%s://%s%s%s', $parts['scheme'] ?? '', $parts['host'] ?? '', $port, $parts['path'] ?? '');
-    }
-
-    /** One message for every network failure (security audit M3); the reason is logged only. */
-    private function downloadFailed(string $url, string $reason): WP_Error
-    {
-        Log::failure('media import from ' . self::loggableUrl($url), $reason);
-
         return Errors::invalidInput(
             'Die Datei konnte nicht geladen werden.',
             ['url' => 'nicht abrufbar'],

(Log bleibt importiert – der Sideload-Fehler protokolliert weiter selbst; PublicAddress wird in MediaImporter nicht mehr gebraucht, es steht im selben Namespace und hatte kein use.)

  • Step 7: Sicherheitsnetz weiter grün, Tests unverändert

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'MediaImporterTest|MediaUploadAbilitiesTest|PublicAddressTest|RemoteFileFetcherTest' Expected: OK (76 tests, 270 assertions) (71 + 5).

Run: git diff --stat tests/Unit/Integrations/Media/MediaImporterTest.php tests/Unit/Integrations/Media/MediaUploadAbilitiesTest.php src/Integrations/Media/MediaIntegration.php Expected: keine Ausgabe.

  • Step 8: Gesamtlauf, Lint, Commit

Run: composer test && composer lint Expected: OK (Basis + 5 Tests), Lint sauber.

cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Media/RemoteFileFetcher.php src/Integrations/Media/MediaImporter.php \
  tests/Unit/Integrations/Media/RemoteFileFetcherTest.php
git commit -m "refactor: RemoteFileFetcher aus MediaImporter herausgelöst (URL-Prüfung, Weiterleitungen, Download)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 8: FontSignature, FontNameRules, FontInstaller, lit/divi-fonts-upload

Schriften hochladen nach W8–W10, in drei Blöcken mit je eigenem Commit: 8a reine Regeln (Magic Bytes, Namen), 8b Divi-Adapter FontInstaller mit Manifest, Mirror font-install und Fingerabdrücken, 8c die Ability. Ablauf der Ability: Rechte (DesignRights::fonts()) in permission() und execute() → alle Eingabefehler gesammelt (validation_failed mit Feldpfaden, auch unbekannte Schlüssel oben und unter files) → Name belegt: conflict → Download jeder Datei über RemoteFileFetcher (je höchstens min(Limits::uploadBytes(), 5 MB)), Größe, FontSignature passend zum Schlüssel, FontInstaller::accepts() → ohne confirm confirm_required mit Name, Dateien (format, bytes, sha256), Gewichten, Familie und Hinweis „ganze Website“; mit confirm: true erneuter Download + Prüfung, FontInstaller::install(), Teilerfolg → FontInstaller::remove() (WriteFailed::rolledBack, sonst partial + recordPartial), Erfolg → ChangeLog::record('lit/divi-fonts-upload', 'divi_font', <Name>, …SHA-256…). Temp-Dateien werden auf jedem Weg gelöscht (try/finally). Neue Fehlercodes (Spec §11): font_fetch_failed (400, mindestens eine Datei nicht abrufbar – ohne Ursache, M3) und font_invalid (400, Größe/Inhalt/Divi-Prüfung), beide mit fields; conflict (409) wie in DesignChange.

Voraussetzung aus Task 1–2 (Schnittstellenvertrag): DiviFeature::FontUpload (font-upload, schreibend), FontCatalog::reserved()/canManage(), DesignRights::fonts(FontCatalog): ?WP_Error. Die Tests ersetzen FontCatalog per Mockery und hängen nur an reserved()/canManage(); die Rechte-Tests verlangen nur forbidden (Texte gehören Task 2).

Files:

  • Create: src/Integrations/Divi/Design/FontSignature.php, FontNameRules.php, FontInstaller.php, DiviFontsUploadAbility.php; tests/Fixtures/divi/reference/font-install.txt (generiert)
  • Modify: src/Integrations/Divi/Release/DiviAdapters.php, src/Integrations/Divi/Release/fingerprints.json (generiert)
  • Test: tests/Unit/Integrations/Divi/Design/FontSignatureTest.php, FontNameRulesTest.php, FontInstallerTest.php, DiviFontsUploadAbilityTest.php (neu); tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php, tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php (geändert)

Interfaces:

  • Consumes: DiviFeature::FontUpload, FontCatalog (reserved(), canManage()), DesignRights::fonts() (Task 1–2); RemoteFileFetcher (Task 7); Limits::uploadBytes(), ChangeLog::record()/recordPartial(), Rollback::attempt(), WriteFailed::rolledBack()/partial(), Errors::make()/invalidInput()/confirmRequired()/writeFailed(), ToolResult, Annotations::write(false), AdminIncludes::load() (lädt wp-admin/includes/file.php für wp_tempnam und wp_handle_upload), ReleaseGate::assertFeature().
  • Divi (nur FontInstaller): FontsUtility::mime_types_font (0), HooksRegistration::check_filetype_and_ext_font (3), FontsUtility::font_add (4), FontsUtility::font_remove (1), Konstante FontsUtility::FONT_NAME_MAX_LENGTH, Mirror font-install (AiAgentImageUtility::install_to_custom_fonts); alle mit font-upload. WordPress (kein Manifest): sanitize_file_name, is_wp_error, wp_tempnam, wp_delete_file, wp_handle_upload (in font_add).
  • Produces (Task 9 verdrahtet):
    // FontSignature.php – reines PHP
    public const HEADER_BYTES = 4;
    public static function detect(string $header): ?string;                    // 'woff2'|'woff'|'ttf'|'otf'|null
    // FontNameRules.php – reines PHP
    public const MAX_LENGTH = 100;
    public static function check(string $name, array $reserved, int $maxLength = self::MAX_LENGTH): ?string;
    public static function taken(string $name, array $reserved): ?string;      // vorhandener Name (ohne Groß-/Kleinschreibung) oder null
    // FontInstaller.php – class (nicht final) implements DiviAdapter, Manifest::DESIGN, features [FontUpload]
    public const FORMATS = ['woff2', 'woff', 'ttf', 'otf'];
    public function __construct();                                             // keine Abhängigkeiten
    public function formats(): array;  public function nameMaxLength(): int;
    public function accepts(string $path, string $filename): bool;
    public function install(string $name, array $files, string $weights, string $family): array;   // gespeicherte Endungen; @throws DiviLocked, RuntimeException (dann ist nichts gespeichert)
    public function remove(string $name): void;                                // @throws RuntimeException
    // DiviFontsUploadAbility.php – final, implements Ability, RequiresDiviFeatures; lit/divi-fonts-upload
    public function __construct(FontCatalog $catalog, FontInstaller $installer, RemoteFileFetcher $fetcher, ChangeLog $log, Limits $limits);
    public const MAX_FILE_BYTES = 5 * 1024 * 1024;  public const OBJECT_TYPE = 'divi_font';
    // diviFeatures() = [FontUpload]; isDirect() = false; annotations() = Annotations::write(false); Gruppe divi (Task 9)
    
    Verdrahtung in Task 9: new DiviFontsUploadAbility($fontCatalog, new FontInstaller(), new RemoteFileFetcher(), $this->log, new Limits()) (Limits liest die Option bei jedem Aufruf; DiviIntegration hat bisher keine Instanz). Tabellenzeile für DiviToolFeaturesTest: 'lit/divi-fonts-upload' => ['font-upload'], Schreib-Tool.
  • Keine Konstruktoränderung an bestehenden Klassen → keine Verdrahtungsänderung in diesem Task.

8a – FontSignature und FontNameRules

  • Step 1: Failing Tests schreiben

tests/Unit/Integrations/Divi/Design/FontSignatureTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use LIT\MCP\Integrations\Divi\Design\FontSignature;
use PHPUnit\Framework\TestCase;

final class FontSignatureTest extends TestCase
{
    public function testTheFourFormatsAreRecognizedByTheirFirstFourBytes(): void
    {
        self::assertSame('woff2', FontSignature::detect("wOF2\x00\x01\x00\x00\x00\x00\x12\x34"));
        self::assertSame('woff', FontSignature::detect("wOFF\x00\x01\x00\x00"));
        self::assertSame('ttf', FontSignature::detect("\x00\x01\x00\x00\x00\x0c\x00\x80"));
        self::assertSame('ttf', FontSignature::detect('true' . "\x00\x0c"), 'Apple TrueType');
        self::assertSame('otf', FontSignature::detect("OTTO\x00\x0a\x00\x80"));
    }

    public function testEverythingElseIsNoFont(): void
    {
        self::assertNull(FontSignature::detect(''));
        self::assertNull(FontSignature::detect('wOF'), 'too short');
        self::assertNull(FontSignature::detect('wof2'), 'case matters');
        self::assertNull(FontSignature::detect('ttcf'), 'font collections are not accepted');
        self::assertNull(FontSignature::detect("\x89PNG\r\n\x1a\n"));
        self::assertNull(FontSignature::detect('<?php evil();'));
        self::assertNull(FontSignature::detect("\x00wOF2"), 'the magic must start at byte 0');
    }
}

tests/Unit/Integrations/Divi/Design/FontNameRulesTest.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use LIT\MCP\Integrations\Divi\Design\FontNameRules;
use PHPUnit\Framework\TestCase;

final class FontNameRulesTest extends TestCase
{
    private const RESERVED = ['Open Sans', 'Roboto', 'Arial', 'Times New Roman', 'Hausschrift'];

    public function testLettersDigitsSpacesUnderscoresAndHyphensAreAllowed(): void
    {
        self::assertNull(FontNameRules::check('Inter Display_2-Bold', self::RESERVED));
        self::assertNull(FontNameRules::check('X', self::RESERVED));
    }

    public function testOtherCharactersAreRefusedWithTheNameDiviWouldStore(): void
    {
        $problem = (string) FontNameRules::check('Grotesk (Pro)!', self::RESERVED);
        self::assertStringContainsString('nur Buchstaben', $problem);
        self::assertStringContainsString('„Grotesk Pro“', $problem);

        self::assertStringContainsString('„Mller“', (string) FontNameRules::check('Müller', self::RESERVED));
        self::assertStringContainsString('nur Buchstaben', (string) FontNameRules::check('a.b', self::RESERVED));
        self::assertStringContainsString('nur Buchstaben', (string) FontNameRules::check("Tab\tName", self::RESERVED));
        self::assertStringContainsString('nichts übrig', (string) FontNameRules::check('«»', self::RESERVED));
    }

    public function testEmptyAndPaddedNamesAreRefused(): void
    {
        self::assertStringContainsString('1–100 Zeichen', (string) FontNameRules::check('', self::RESERVED));
        self::assertStringContainsString('1–100 Zeichen', (string) FontNameRules::check('   ', self::RESERVED));
        self::assertStringContainsString('Anfang oder Ende', (string) FontNameRules::check(' Inter', self::RESERVED));
        self::assertStringContainsString('Anfang oder Ende', (string) FontNameRules::check('Inter ', self::RESERVED));
    }

    public function testTheLengthLimitIsOneHundredCharactersOrDivisBound(): void
    {
        self::assertNull(FontNameRules::check(str_repeat('a', 100), self::RESERVED));
        self::assertStringContainsString(
            'höchstens 100 Zeichen (101 angegeben)',
            (string) FontNameRules::check(str_repeat('a', 101), self::RESERVED)
        );
        self::assertStringContainsString(
            'höchstens 50 Zeichen',
            (string) FontNameRules::check(str_repeat('a', 51), self::RESERVED, 50)
        );
    }

    public function testNamesOfGoogleWebSafeAndUploadedFontsAreTakenWhateverTheCase(): void
    {
        self::assertSame('Open Sans', FontNameRules::taken('open sans', self::RESERVED));
        self::assertSame('Arial', FontNameRules::taken('ARIAL', self::RESERVED), 'web-safe fonts too (Divi skips them)');
        self::assertSame('Hausschrift', FontNameRules::taken('Hausschrift', self::RESERVED));
        self::assertNull(FontNameRules::taken('Open Sans Pro', self::RESERVED));
        self::assertNull(FontNameRules::taken('Roboto', []));

        $problem = (string) FontNameRules::check('times new roman', self::RESERVED);
        self::assertStringContainsString('„Times New Roman“ gibt es schon', $problem);
    }
}
  • Step 2: RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'FontSignatureTest|FontNameRulesTest' Expected: Tests: 7, Assertions: 0, Errors: 7. (Class "…\FontSignature" not found bzw. "…\FontNameRules" not found).

  • Step 3: FontSignature anlegen

src/Integrations/Divi/Design/FontSignature.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

defined('ABSPATH') || exit;

/**
 * The font format of a file by its magic bytes (ruling W9): wOF2 = woff2, wOFF = woff, 00 01 00 00 or "true" =
 * ttf, OTTO = otf. Divi's own check (finfo) may be skipped for users with unfiltered_upload, so the font upload
 * checks the content itself. Font collections (ttcf) and eot are no font here. Pure PHP.
 */
final class FontSignature
{
    /** Bytes detect() needs from the start of the file. */
    public const HEADER_BYTES = 4;

    private const MAGIC = [
        'wOF2' => 'woff2',
        'wOFF' => 'woff',
        "\x00\x01\x00\x00" => 'ttf',
        'true' => 'ttf',
        'OTTO' => 'otf',
    ];

    /** @return 'woff2'|'woff'|'ttf'|'otf'|null */
    public static function detect(string $header): ?string
    {
        return self::MAGIC[substr($header, 0, self::HEADER_BYTES)] ?? null;
    }
}
  • Step 4: FontNameRules anlegen

src/Integrations/Divi/Design/FontNameRules.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

defined('ABSPATH') || exit;

/**
 * Name rules of an uploaded font (ruling W9), stricter than FontsUtility::font_add(): Divi silently strips every
 * character but A–Z, a–z, 0–9, whitespace, _ and -; the plugin refuses them and names what Divi would store.
 * Divi compares names with Google's and uploaded fonts case-sensitively and skips the web-safe ones; the plugin
 * refuses any of them whatever the case, so a font never shadows another one in Divi's font lists. Pure PHP.
 */
final class FontNameRules
{
    /** = FontsUtility::FONT_NAME_MAX_LENGTH in Divi 5.15.1 (FontInstaller::nameMaxLength() reads Divi's). */
    public const MAX_LENGTH = 100;

    private const ALLOWED = '/^[A-Za-z0-9 _-]+$/D';

    /** What FontsUtility::font_add() removes from a name. */
    private const DIVI_STRIPS = '/[^A-Za-z0-9\s\_-]/';

    /**
     * @param list<string> $reserved Names of Google, web-safe and uploaded fonts (FontCatalog::reserved()).
     * @return string|null null = valid, else the problem (German).
     */
    public static function check(string $name, array $reserved, int $maxLength = self::MAX_LENGTH): ?string
    {
        if (trim($name) === '') {
            return sprintf('Name fehlt (1–%d Zeichen)', $maxLength);
        }

        if (preg_match(self::ALLOWED, $name) !== 1) {
            $stored = trim((string) preg_replace(self::DIVI_STRIPS, '', $name));

            return 'nur Buchstaben A–Z/a–z, Ziffern, Leerzeichen, _ und - erlaubt ('
                . ($stored === '' ? 'Divi ließe nichts übrig)' : sprintf('Divi würde „%s“ speichern)', $stored));
        }

        if ($name !== trim($name)) {
            return 'ohne Leerzeichen am Anfang oder Ende';
        }

        if (strlen($name) > $maxLength) {
            return sprintf('höchstens %d Zeichen (%d angegeben)', $maxLength, strlen($name));
        }

        $taken = self::taken($name, $reserved);

        return $taken === null ? null : sprintf(
            '„%s“ gibt es schon als Google-, Web-Safe- oder hochgeladene Schrift (ohne Beachtung der '
                . 'Groß-/Kleinschreibung)',
            $taken
        );
    }

    /**
     * @param list<string> $reserved
     * @return string|null The existing name $name collides with (case-insensitive), null when it is free.
     */
    public static function taken(string $name, array $reserved): ?string
    {
        $lower = strtolower($name);
        foreach ($reserved as $existing) {
            if (strtolower($existing) === $lower) {
                return $existing;
            }
        }

        return null;
    }
}
  • Step 5: GREEN, Lint, Commit

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'FontSignatureTest|FontNameRulesTest' Expected: OK (7 tests, 33 assertions)

Run: composer lint Expected: sauber.

cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Design/FontSignature.php src/Integrations/Divi/Design/FontNameRules.php \
  tests/Unit/Integrations/Divi/Design/FontSignatureTest.php tests/Unit/Integrations/Divi/Design/FontNameRulesTest.php
git commit -m "feat: FontSignature (Magic Bytes) und FontNameRules (Namensregeln eigener Schriften)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

8b – FontInstaller (Divi-Adapter, Manifest, Mirror font-install, Fingerabdrücke)

  • Step 6: Failing Test schreiben

tests/Unit/Integrations/Divi/Design/FontInstallerTest.php (Vorbild GlobalColorsAdapterTest; die Divi-Klassen per Mockery::mock('alias:…') in eigenem Prozess, die Konstante über Mockery::getConfiguration()->setConstantsMap()):

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Design\FontInstaller;
use LIT\MCP\Integrations\Divi\Release\Call;
use LIT\MCP\Integrations\Divi\Release\ConstantRequirement;
use LIT\MCP\Integrations\Divi\Release\DiviAdapters;
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\ReleaseGate;
use LIT\MCP\Integrations\Divi\Release\ReleaseState;
use LIT\MCP\Integrations\Divi\Release\ReleaseStatus;
use LIT\MCP\Tests\Support\ReleaseMatrices;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use RuntimeException;
use WP_Error;

/**
 * Divi's FontsUtility and HooksRegistration are replaced by Mockery aliases; the tests that need them run in their
 * own process (an alias defines the class for the rest of the process).
 */
final class FontInstallerTest extends TestCase
{
    private const FONTS = 'ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility';
    private const HOOKS = 'ET\\Builder\\VisualBuilder\\Hooks\\HooksRegistration';
    private const UNCHECKED = ['ext' => false, 'type' => false, 'proper_filename' => false];

    /** @var list<string> */
    private array $files = [];

    protected function setUp(): void
    {
        parent::setUp();
        Functions\when('sanitize_file_name')->alias(static fn (string $name): string => str_replace(' ', '-', $name));
    }

    protected function tearDown(): void
    {
        foreach ($this->files as $file) {
            if (is_file($file)) {
                unlink($file);
            }
        }
        parent::tearDown();
    }

    private function tempFile(string $bytes): string
    {
        $file = (string) tempnam(sys_get_temp_dir(), 'lit-font');
        file_put_contents($file, $bytes);
        $this->files[] = $file;

        return $file;
    }

    public function testIsARegisteredDesignAdapterForFontUpload(): void
    {
        $manifest = FontInstaller::manifest();

        self::assertContains(FontInstaller::class, DiviAdapters::CLASSES);
        self::assertSame(Manifest::DESIGN, $manifest->area);
        self::assertSame(
            ['FontsUtility::mime_types_font()', 'HooksRegistration::check_filetype_and_ext_font()',
             'FontsUtility::font_add()', 'FontsUtility::font_remove()'],
            array_map(static fn (Call $call): string => $call->label(), $manifest->calls)
        );
        self::assertSame([0, 3, 4, 1], array_map(static fn (Call $call): ?int => $call->args, $manifest->calls));
        self::assertSame(
            [self::FONTS . '::FONT_NAME_MAX_LENGTH'],
            array_map(static fn (ConstantRequirement $constant): string => $constant->name, $manifest->constants)
        );
        self::assertCount(1, $manifest->mirrors);
        self::assertSame('font-install', $manifest->mirrors[0]->id);
        self::assertSame(
            'includes/builder-5/server/VisualBuilder/AiAgent/AiAgentImageUtility.php',
            $manifest->mirrors[0]->file
        );
        self::assertSame('AiAgentImageUtility::install_to_custom_fonts()', $manifest->mirrors[0]->label());
        self::assertSame([DiviFeature::FontUpload], $manifest->features);
    }

    public function testInstallNeedsAReleasedDivi(): void
    {
        ReleaseGate::useResolver(
            static fn (): ReleaseStatus => ReleaseMatrices::uniform(ReleaseState::Untested, '5.16.0')
        );

        $this->expectException(DiviLocked::class);

        (new FontInstaller())->install('Inter', ['woff2' => '/tmp/x'], 'all', 'sans-serif');
    }

    /** @runInSeparateProcess */
    public function testFormatsAreThePluginsFourAsFarAsDiviKnowsThem(): void
    {
        Mockery::mock('alias:' . self::FONTS)->allows('mime_types_font')->andReturn([
            'otf' => ['font/otf'],
            'ttf' => ['font/ttf'],
            'woff' => ['font/woff'],
            'eot' => ['application/vnd.ms-fontobject'],
        ]);

        self::assertSame(['woff', 'ttf', 'otf'], (new FontInstaller())->formats(), 'no woff2 in Divi, never eot');
    }

    /** @runInSeparateProcess */
    public function testTheNameLimitIsDivis(): void
    {
        Mockery::getConfiguration()->setConstantsMap([self::FONTS => ['FONT_NAME_MAX_LENGTH' => 80]]);
        Mockery::mock('alias:' . self::FONTS);

        self::assertSame(80, (new FontInstaller())->nameMaxLength());
    }

    /** @runInSeparateProcess */
    public function testAcceptsAsksDivisContentCheckAndWantsTheGivenExtensionBack(): void
    {
        $file = $this->tempFile('wOF2DATA');
        $hooks = Mockery::mock('alias:' . self::HOOKS);
        $hooks->expects('check_filetype_and_ext_font')->with(self::UNCHECKED, $file, 'Inter.woff2')
            ->andReturn(['ext' => 'woff2', 'type' => 'font/woff2', 'proper_filename' => 'Inter.woff2']);
        $hooks->expects('check_filetype_and_ext_font')->with(self::UNCHECKED, $file, 'Inter.ttf')
            ->andReturn(['ext' => 'woff2', 'type' => 'font/woff2', 'proper_filename' => 'Inter.ttf']);
        $hooks->expects('check_filetype_and_ext_font')->with(self::UNCHECKED, $file, 'Inter.otf')
            ->andReturn(self::UNCHECKED);

        $installer = new FontInstaller();

        self::assertTrue($installer->accepts($file, 'Inter.woff2'));
        self::assertFalse($installer->accepts($file, 'Inter.ttf'), 'Divi reports another extension');
        self::assertFalse($installer->accepts($file, 'Inter.otf'), 'Divi refuses the content');
    }

    /** @runInSeparateProcess */
    public function testInstallHandsTheTempFilesToFontAddLikeDivisAiAgent(): void
    {
        $woff2 = $this->tempFile('wOF2DATA');
        $ttf = $this->tempFile("\x00\x01\x00\x00TTF");
        $hooks = Mockery::mock('alias:' . self::HOOKS);
        $hooks->allows('check_filetype_and_ext_font')->andReturnUsing(
            static fn (array $unchecked, string $file, string $name): array => [
                'ext' => pathinfo($name, PATHINFO_EXTENSION),
                'type' => 'font/' . pathinfo($name, PATHINFO_EXTENSION),
                'proper_filename' => $name,
            ]
        );
        Mockery::mock('alias:' . self::FONTS)->expects('font_add')->with(
            [
                'woff2' => ['name' => 'Inter-Display.woff2', 'type' => 'font/woff2', 'tmp_name' => $woff2,
                    'error' => 0, 'size' => 8],
                'ttf' => ['name' => 'Inter-Display.ttf', 'type' => 'font/ttf', 'tmp_name' => $ttf, 'error' => 0,
                    'size' => 7],
            ],
            'Inter Display',
            ['font_weights' => '400,700', 'generic_family' => 'serif'],
            ['action' => 'wp_handle_upload_rest']
        )->andReturn([
            'uploaded_font' => 'Inter Display',
            'updated_fonts' => [
                'Andere' => ['font_file' => ['otf' => '/up/et-fonts/Andere.otf']],
                'Inter Display' => ['font_file' => [
                    'woff2' => '/up/et-fonts/Inter-Display.woff2',
                    'ttf' => '/up/et-fonts/Inter-Display.ttf',
                ]],
            ],
        ]);

        $installed = (new FontInstaller())->install(
            'Inter Display',
            ['woff2' => $woff2, 'ttf' => $ttf],
            '400,700',
            'serif'
        );

        self::assertSame(['woff2', 'ttf'], $installed);
    }

    /** @runInSeparateProcess */
    public function testAPartialSuccessListsOnlyTheFormatsDiviStored(): void
    {
        $woff2 = $this->tempFile('wOF2DATA');
        $ttf = $this->tempFile("\x00\x01\x00\x00TTF");
        Mockery::mock('alias:' . self::HOOKS)->allows('check_filetype_and_ext_font')->andReturnUsing(
            static fn (array $unchecked, string $file, string $name): array => [
                'ext' => pathinfo($name, PATHINFO_EXTENSION),
                'type' => 'font/x',
                'proper_filename' => $name,
            ]
        );
        Mockery::mock('alias:' . self::FONTS)->allows('font_add')->andReturn([
            'uploaded_font' => 'Inter',
            'updated_fonts' => ['Inter' => ['font_file' => ['woff2' => '/up/et-fonts/Inter.woff2']]],
        ]);

        self::assertSame(
            ['woff2'],
            (new FontInstaller())->install('Inter', ['woff2' => $woff2, 'ttf' => $ttf], 'all', 'sans-serif')
        );
    }

    /** @runInSeparateProcess */
    public function testAFileDivisTypeCheckRefusesStopsTheInstallBeforeFontAdd(): void
    {
        $woff2 = $this->tempFile('wOF2DATA');
        Mockery::mock('alias:' . self::HOOKS)->allows('check_filetype_and_ext_font')->andReturn(self::UNCHECKED);
        Mockery::mock('alias:' . self::FONTS)->expects('font_add')->never();

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage("font file woff2 refused by Divi's type check");

        (new FontInstaller())->install('Inter', ['woff2' => $woff2], 'all', 'sans-serif');
    }

    /** @runInSeparateProcess */
    public function testARefusedFontAddIsARuntimeException(): void
    {
        $woff2 = $this->tempFile('wOF2DATA');
        Mockery::mock('alias:' . self::HOOKS)->allows('check_filetype_and_ext_font')
            ->andReturn(['ext' => 'woff2', 'type' => 'font/woff2', 'proper_filename' => 'Inter.woff2']);
        Mockery::mock('alias:' . self::FONTS)->allows('font_add')
            ->andReturn(new WP_Error('upload_error_handler', 'Sorry, you are not allowed to upload this file type.'));

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('font_add refused: Sorry, you are not allowed to upload this file type.');

        (new FontInstaller())->install('Inter', ['woff2' => $woff2], 'all', 'sans-serif');
    }

    /** @runInSeparateProcess */
    public function testRemoveDeletesTheFontAndReportsARefusal(): void
    {
        $fonts = Mockery::mock('alias:' . self::FONTS);
        $fonts->expects('font_remove')->with('Inter')->andReturn(['updated_fonts' => []]);
        $fonts->expects('font_remove')->with('Fehlt')->andReturn(new WP_Error('font_not_exist', 'Font Does not Exist.'));

        (new FontInstaller())->remove('Inter');

        $this->expectException(RuntimeException::class);
        $this->expectExceptionMessage('font_remove refused: Font Does not Exist.');
        (new FontInstaller())->remove('Fehlt');
    }
}
  • Step 7: RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter FontInstallerTest Expected: Tests: 10, Assertions: 3, Errors: 7, Failures: 3. – sieben Error: Class "LIT\MCP\Integrations\Divi\Design\FontInstaller" not found, drei Failed asserting that exception of type "Error" matches expected exception … (die expectException-Tests).

  • Step 8: FontInstaller anlegen

src/Integrations/Divi/Design/FontInstaller.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use ET\Builder\VisualBuilder\Fonts\FontsUtility;
use ET\Builder\VisualBuilder\Hooks\HooksRegistration;
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 uploaded fonts (ruling W8): installs server-side temp files through
 * FontsUtility::font_add() in-process with the upload action wp_handle_upload_rest – the way Divi's own AI agent
 * does it (AiAgentImageUtility::install_to_custom_fonts(), mirrored as calling convention) – after checking every
 * file with Divi's content check (check_filetype_and_ext_font). font_add() reports a partial success as success;
 * install() therefore returns the formats Divi really stored, the caller compares and removes the font again.
 * Not final so the upload tool can be tested with a fake.
 */
class FontInstaller implements DiviAdapter
{
    /** Formats the plugin allows (ruling W8, no eot), in the order the tool lists them. */
    public const FORMATS = ['woff2', 'woff', 'ttf', 'otf'];

    private const UNCHECKED = ['ext' => false, 'type' => false, 'proper_filename' => false];

    public static function manifest(): Manifest
    {
        return new Manifest(
            Manifest::DESIGN,
            calls: [
                new Call(FontsUtility::class . '::mime_types_font', 0),
                new Call(HooksRegistration::class . '::check_filetype_and_ext_font', 3),
                new Call(FontsUtility::class . '::font_add', 4),
                new Call(FontsUtility::class . '::font_remove', 1),
            ],
            constants: [FontsUtility::class . '::FONT_NAME_MAX_LENGTH'],
            mirrors: [
                new Mirror(
                    'font-install',
                    'includes/builder-5/server/VisualBuilder/AiAgent/AiAgentImageUtility.php',
                    'install_to_custom_fonts',
                    'AiAgentImageUtility',
                    'Aufrufkonvention von font_add() mit serverseitiger Temp-Datei (check_filetype_and_ext_font '
                        . 'vorab, wp_tempnam, action wp_handle_upload_rest)'
                ),
            ],
            features: [DiviFeature::FontUpload]
        );
    }

    /** @return list<string> FORMATS that Divi's font MIME list knows too. */
    public function formats(): array
    {
        return array_values(array_intersect(self::FORMATS, array_keys(FontsUtility::mime_types_font())));
    }

    /** Divi's bound for a font name (FontNameRules::check() takes it). */
    public function nameMaxLength(): int
    {
        return (int) FontsUtility::FONT_NAME_MAX_LENGTH;
    }

    /** Whether Divi's content check (finfo) takes $path as a font of $filename's extension. */
    public function accepts(string $path, string $filename): bool
    {
        return $this->mimeOf($path, $filename) !== null;
    }

    /**
     * @param array<string, string> $files Extension => temp file path (deleted or moved by WordPress).
     * @return list<string> The extensions Divi stored – fewer than $files on a partial success.
     * @throws DiviLocked When font-upload is not released – before anything is written.
     * @throws RuntimeException When Divi refuses a file or the whole font; nothing is stored then.
     */
    public function install(string $name, array $files, string $weights, string $family): array
    {
        ReleaseGate::assertFeature(DiviFeature::FontUpload);
        $uploads = [];
        foreach ($files as $extension => $path) {
            $filename = sanitize_file_name($name . '.' . $extension);
            $mime = $this->mimeOf($path, $filename);
            if ($mime === null) {
                throw new RuntimeException(sprintf("font file %s refused by Divi's type check", $extension));
            }
            clearstatcache(true, $path);
            $uploads[$extension] = [
                'name' => $filename,
                'type' => $mime,
                'tmp_name' => $path,
                'error' => 0,
                'size' => (int) filesize($path),
            ];
        }

        $result = FontsUtility::font_add(
            $uploads,
            $name,
            ['font_weights' => $weights, 'generic_family' => $family],
            ['action' => 'wp_handle_upload_rest']
        );
        if (is_wp_error($result)) {
            throw new RuntimeException('font_add refused: ' . $result->get_error_message());
        }

        $stored = is_array($result) ? ($result['updated_fonts'][$name]['font_file'] ?? null) : null;

        return is_array($stored) ? array_map('strval', array_keys($stored)) : [];
    }

    /** @throws RuntimeException When Divi does not remove the font (e.g. it does not exist). */
    public function remove(string $name): void
    {
        $result = FontsUtility::font_remove($name);
        if (is_wp_error($result)) {
            throw new RuntimeException('font_remove refused: ' . $result->get_error_message());
        }
    }

    /** The MIME type Divi detected, or null when it refuses the content or names another extension. */
    private function mimeOf(string $path, string $filename): ?string
    {
        $checked = HooksRegistration::check_filetype_and_ext_font(self::UNCHECKED, $path, $filename);
        $extension = strtolower((string) ($checked['ext'] ?? ''));
        $type = (string) ($checked['type'] ?? '');

        return $type !== '' && $extension !== '' && $extension === strtolower(pathinfo($filename, PATHINFO_EXTENSION))
            ? $type
            : null;
    }
}
  • Step 9: Adapter in DiviAdapters::CLASSES aufnehmen

In src/Integrations/Divi/Release/DiviAdapters.php den Import use LIT\MCP\Integrations\Divi\Design\FontInstaller; (alphabetisch bei den Design-Importen) ergänzen und FontInstaller::class, in CLASSES direkt hinter FontCatalog::class (Task 2) einfügen – Schriftliste und Upload stehen so im Bericht nebeneinander:

        FontCatalog::class,
        FontInstaller::class,
  • Step 10: GREEN Adapter, erwartete rote Tabellen

Run: php -d memory_limit=512M vendor/bin/phpunit --filter FontInstallerTest Expected: OK (10 tests, 31 assertions)

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'FontInstallerTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviReferenceTest|DiviCheckCommandTest' Expected: Tests: 43, …, Failures: 6. – genau DiviFeatureAssignmentTest::testTheAssignmentIsThePinnedTable (neue Zeile fehlt, Step 11), DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource (Fingerabdrücke fehlen, Step 13) und vier DiviCheckCommandTest::testUpdateReference… (der Wegwerf-Theme-Ordner des Tests kennt den neuen Mirror nicht, Step 12). DiviBoundaryTest ist grün (der Adapter nutzt genau ET\Builder\VisualBuilder\Fonts\FontsUtility und …\Hooks\HooksRegistration; wp_handle_upload_rest und der Klassen-Konstantenname sind für DiviTouches keine Divi-Namen).

  • Step 11: Zuordnungstabelle anpassen

In tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php den Import use LIT\MCP\Integrations\Divi\Design\FontInstaller; ergänzen und in EXPECTED direkt hinter dem Block von FontCatalog::class (gleiche Reihenfolge wie CLASSES) einfügen:

        FontInstaller::class => [
            'FontsUtility::mime_types_font()' => ['font-upload'],
            'HooksRegistration::check_filetype_and_ext_font()' => ['font-upload'],
            'FontsUtility::font_add()' => ['font-upload'],
            'FontsUtility::font_remove()' => ['font-upload'],
            'Konstante ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility::FONT_NAME_MAX_LENGTH' => ['font-upload'],
            'Nachbau AiAgentImageUtility::install_to_custom_fonts()' => ['font-upload'],
        ],

Den Text 'all ten adapters, in report order' in testTheAssignmentIsThePinnedTable auf die neue Zahl bringen (nach Task 2 und diesem Task: 'all thirteen adapters, in report order').

  • Step 12: DiviCheckCommandTest kennt den neuen Mirror

writeDivi() legt für jeden Mirror eine Minimal-Quelle unter seinem echten Pfad an; --update-reference liest alle Mirrors. In tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php (auf dem Stand nach Task 2 – falls Task 2 dort schon einen Mirror ergänzt hat, zählen beide):

--- a/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php
+++ b/tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php
@@ -228,14 +228,14 @@ final class DiviCheckCommandTest extends TestCase
         self::assertSame(0, $code);
         self::assertSame(
             [
-                'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed', 'tb-api-save',
-                'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
+                'font-install', 'library-submit-layout', 'preset-sync', 'sig:A::b', 'sig:et_pb_is_allowed',
+                'tb-api-save', 'tb-load-setting-options', 'tb-store-template', 'vb-sync-flags',
             ],
             array_keys(DiviReference::expected($this->expectedFile))
         );
         self::assertSame(str_repeat('b', 64), DiviReference::expected($this->expectedFile)['sig:A::b']);
         self::assertContains(
-            'Referenz aktualisiert (6 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.',
+            'Referenz aktualisiert (7 Fingerabdrücke, 2 Signaturen). Smoke-Skripte laufen lassen, dann committen.',
             $this->lines
         );
         self::assertStringStartsWith(
@@ -244,7 +244,7 @@ final class DiviCheckCommandTest extends TestCase
         );
     }
 
-    /** Minimal stand-ins for the six mirrored Divi functions at their real paths. */
+    /** Minimal stand-ins for the seven mirrored Divi functions at their real paths. */
     private function writeDivi(): void
     {
         $files = [
@@ -257,6 +257,8 @@ final class DiviCheckCommandTest extends TestCase
                 "class SyncToServerController {\n\tpublic static function update( \$request ) {\n\t}\n}",
             'includes/builder-5/server/Packages/GlobalData/GlobalPresetController.php' =>
                 "class GlobalPresetController {\n\tpublic static function sync( \$request ) {\n\t}\n}",
+            'includes/builder-5/server/VisualBuilder/AiAgent/AiAgentImageUtility.php' =>
+                "class AiAgentImageUtility {\n\tpublic static function install_to_custom_fonts( \$a, \$b ) {\n\t}\n}",
         ];
         foreach ($files as $path => $code) {
             $file = $this->themeDir . '/' . $path;

(Ausgangsstand ohne weitere Mirrors von Task 2: sechs → sieben; jeder weitere Mirror aus Task 2 erhöht beide Zahlen um eins, 'font-install' steht alphabetisch vorn.)

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'FontInstallerTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviReferenceTest|DiviCheckCommandTest' Expected: genau ein Fehlschlag, DiviReferenceTest::testEveryMirrorAndEveryCallHasAnExpectedFingerprintAndEveryMirrorAReferenceSource.

  • Step 13: Fingerabdrücke in DDEV erzeugen

Befehl und Bedingungen wie Task 2 des Plans 4c, Step 11 (wp lit-mcp divi-check --update-reference verlangt einen Administrator, schreibt nur, wenn außer „Fingerabdrücke“ alle Prüfschritte grün sind, nur in der Entwicklungskopie; schreibt die Referenzquellen aller Mirrors und fingerprints.json). Plugins postlos und asynclistry vorher deaktivieren, Zustand vorher mit ddev exec wp plugin list notieren und danach wiederherstellen.

cd /home/janniclas/Projekte/WordPress
ddev start
ddev exec wp plugin list
ddev wp plugin deactivate postlos asynclistry
ddev exec bash -c 'wp --user="$(wp user list --role=administrator --field=user_login | head -1)" lit-mcp divi-check --update-reference'
ddev wp plugin activate postlos asynclistry

Expected: Prüfschritte mit ✓ („Fingerabdrücke nachgebauter Divi-Funktionen“ darf ✗ sein), dann Referenz aktualisiert (<F> Fingerabdrücke, <S> Signaturen). … mit <F> = Zahl der Mirrors nach Task 2 + 1 und <S> = Zahl der sig:-Schlüssel nach Task 2 + 4; Exit-Code 0.

Ergebnis prüfen:

cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git diff -U0 src/Integrations/Divi/Release/fingerprints.json | grep '^[-+] '
git status --short tests/Fixtures/divi/reference

Expected: genau diese fünf +-Zeilen (Divi 5.15.1; im Arbeitsstand des Planers mit Divis Quellen nachgerechnet) und keine --Zeile; unter tests/Fixtures/divi/reference nur ?? tests/Fixtures/divi/reference/font-install.txt (148 Zeilen, beginnt mit function install_to_custom_fonts( string $chat_id, string $filename, string $font_name = '' ) {):

+    "font-install": "a6b7c0f26437f9ea63186138e130f3c226c0cc017d424807e1030b44c165ea88",
+    "sig:ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility::font_add": "028c32cd79e1f33dceebf23c4c08d39c7b8062da01fb3006318ce21597d55103",
+    "sig:ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility::font_remove": "ab573a0776e9ba79e9606d3df9b1d2adb77e00e6eaf4120710a7649bcaf68ba0",
+    "sig:ET\\Builder\\VisualBuilder\\Fonts\\FontsUtility::mime_types_font": "dfbf01f6750e3fa6c309f1200effcfda539d75c3404732cd5df939a8bff399ae",
+    "sig:ET\\Builder\\VisualBuilder\\Hooks\\HooksRegistration::check_filetype_and_ext_font": "d98fd02bab7ba06af60ac0a99fbc717f916dd126d19c8f2e69779ab83d99902f",

Ändert sich ein anderer Schlüssel oder eine andere Referenzquelle, nicht committen, sondern prüfen (installiertes Divi ≠ 5.15.1 oder Task 2 hat seine Referenz nicht committet).

  • Step 14: GREEN, Gesamtlauf, Lint, Commit

Run: php -d memory_limit=512M vendor/bin/phpunit --filter 'FontInstallerTest|DiviFeatureAssignmentTest|DiviBoundaryTest|DiviReferenceTest|DiviCheckCommandTest' Expected: OK (43 tests, 224 assertions) (Zahlen auf dem Stand ohne Task-2-Erweiterungen dieser Tests; mit ihnen entsprechend mehr, aber kein Fehlschlag).

Run: composer test && composer lint Expected: OK, Lint sauber.

cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Design/FontInstaller.php tests/Unit/Integrations/Divi/Design/FontInstallerTest.php \
  src/Integrations/Divi/Release/DiviAdapters.php tests/Unit/Integrations/Divi/Release/DiviFeatureAssignmentTest.php \
  tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php \
  src/Integrations/Divi/Release/fingerprints.json tests/Fixtures/divi/reference/font-install.txt
git commit -m "feat: FontInstaller – Schrift-Upload über FontsUtility::font_add im Prozess (Manifest, Mirror font-install, Signatur-Referenzen)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

8c – lit/divi-fonts-upload

  • Step 15: Failing Test schreiben

tests/Unit/Integrations/Divi/Design/DiviFontsUploadAbilityTest.php – FontCatalog und FontInstaller per Mockery, der Download über einen Fake von RemoteFileFetcher (schreibt den Inhalt je URL in die Temp-Datei, die die Ability anlegt), wp_tempnam legt echte Temp-Dateien an (die Tests prüfen, dass sie danach weg sind), echte FontSignature/FontNameRules, FakeChangeLog:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Unit\Integrations\Divi\Design;

use Brain\Monkey\Functions;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\Integrations\Divi\Design\DiviFontsUploadAbility;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\FontInstaller;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
use LIT\MCP\Limits;
use LIT\MCP\Tests\Support\FakeChangeLog;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use Mockery\MockInterface;
use RuntimeException;

final class DiviFontsUploadAbilityTest extends TestCase
{
    private const WOFF2 = "wOF2\x00\x01\x00\x00woff2-body";
    private const TTF = "\x00\x01\x00\x00\x00\x0cttf-body";
    private const WOFF2_URL = 'https://fonts.example.com/inter.woff2';
    private const TTF_URL = 'https://fonts.example.com/inter.ttf';

    /** @var list<string> */
    private array $tempFiles = [];

    /** @var array<string, string|null> URL => body the fake fetcher writes (null = download fails). */
    private array $bodies = [];

    /** @var list<array{url: string, tmp: string, max: int, context: string}> */
    private array $fetches = [];

    /** @var array<string, string> Path => content of the temp files when install() ran. */
    private array $installedFrom = [];

    private string $errorLog = '';
    private string|false $previousErrorLog = false;

    protected function setUp(): void
    {
        parent::setUp();
        $this->errorLog = (string) tempnam(sys_get_temp_dir(), 'lit-mcp-log');
        $this->previousErrorLog = ini_set('error_log', $this->errorLog);
        $this->bodies = [self::WOFF2_URL => self::WOFF2, self::TTF_URL => self::TTF];
        Functions\when('current_user_can')->justReturn(true);
        Functions\when('get_option')->justReturn(['upload_mb' => 1]);
        Functions\when('wp_tempnam')->alias(function (): string {
            $file = (string) tempnam(sys_get_temp_dir(), 'lit-font');
            $this->tempFiles[] = $file;

            return $file;
        });
        Functions\when('wp_delete_file')->alias(static function (string $file): void {
            if (is_file($file)) {
                unlink($file);
            }
        });
    }

    protected function tearDown(): void
    {
        ini_set('error_log', (string) $this->previousErrorLog);
        foreach ([...$this->tempFiles, $this->errorLog] as $file) {
            if (is_file($file)) {
                unlink($file);
            }
        }
        parent::tearDown();
    }

    private function fetcher(): RemoteFileFetcher
    {
        $test = $this;

        return new class ($test) extends RemoteFileFetcher {
            public function __construct(private DiviFontsUploadAbilityTest $test)
            {
                parent::__construct(static fn (string $host): array => ['93.184.216.34']);
            }

            public function allows(string $url): bool
            {
                return str_starts_with($url, 'https://') && ! str_contains($url, 'intern');
            }

            public function fetch(string $url, string $tmp, int $maxBytes, string $context): ?array
            {
                return $this->test->fakeFetch($url, $tmp, $maxBytes, $context);
            }
        };
    }

    /** The fake fetcher's download: writes the body of $url into $tmp. */
    public function fakeFetch(string $url, string $tmp, int $maxBytes, string $context): ?array
    {
        $this->fetches[] = ['url' => $url, 'tmp' => $tmp, 'max' => $maxBytes, 'context' => $context];
        $body = $this->bodies[$url] ?? null;
        if ($body === null) {
            return null;
        }
        file_put_contents($tmp, substr($body, 0, $maxBytes + 1));

        return ['response' => ['code' => 200], 'headers' => []];
    }

    /** @param list<string> $reserved */
    private static function catalog(array $reserved = ['Open Sans', 'Arial'], bool $canManage = true): FontCatalog
    {
        $catalog = Mockery::mock(FontCatalog::class);
        $catalog->allows('reserved')->andReturn($reserved);
        $catalog->allows('canManage')->andReturn($canManage);

        return $catalog;
    }

    private static function installer(bool $accepts = true): FontInstaller&MockInterface
    {
        $installer = Mockery::mock(FontInstaller::class);
        $installer->allows('formats')->andReturn(['woff2', 'woff', 'ttf', 'otf']);
        $installer->allows('nameMaxLength')->andReturn(100);
        $installer->allows('accepts')->andReturn($accepts);

        return $installer;
    }

    private function ability(
        ?FontInstaller $installer = null,
        ?FakeChangeLog $log = null,
        ?FontCatalog $catalog = null
    ): DiviFontsUploadAbility {
        return new DiviFontsUploadAbility(
            $catalog ?? self::catalog(),
            $installer ?? self::installer(),
            $this->fetcher(),
            $log ?? FakeChangeLog::forbidden(),
            new Limits()
        );
    }

    /** @return array<string, mixed> */
    private static function input(array $overrides = []): array
    {
        return array_merge([
            'name' => 'Inter Display',
            'files' => ['woff2' => self::WOFF2_URL, 'ttf' => self::TTF_URL],
            'weights' => [700, 400],
        ], $overrides);
    }

    private function assertTempFilesRemoved(): void
    {
        self::assertNotSame([], $this->tempFiles, 'the files were downloaded');
        foreach ($this->tempFiles as $file) {
            self::assertFileDoesNotExist($file);
        }
    }

    public function testIsAnIndirectAdditiveDiviToolForFontUpload(): void
    {
        $ability = $this->ability();

        self::assertSame('lit/divi-fonts-upload', $ability->name());
        self::assertFalse($ability->isDirect());
        self::assertSame(['readonly' => false, 'destructive' => false, 'idempotent' => false], $ability->annotations());
        self::assertSame([DiviFeature::FontUpload], $ability->diviFeatures([]));
        self::assertSame(['name', 'files', 'weights'], $ability->inputSchema()['required']);
        self::assertFalse($ability->inputSchema()['properties']['files']['additionalProperties']);
    }

    public function testPermissionAndExecuteAskTheFontRights(): void
    {
        $ability = $this->ability(catalog: self::catalog(canManage: false));

        self::assertToolError($ability->permission(self::input()), 'forbidden');
        self::assertToolError($ability->execute(self::input()), 'forbidden');

        Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'upload_files');
        self::assertToolError($this->ability()->permission(self::input()), 'forbidden');
        self::assertToolError($this->ability()->execute(self::input()), 'forbidden');
        self::assertSame([], $this->fetches, 'nothing is downloaded without the rights');
    }

    public function testEveryInputProblemIsReportedAtOnceWithItsPath(): void
    {
        $result = $this->ability()->execute([
            'name' => 'Inter (Pro)',
            'files' => ['woff2' => 'http://intern.example.com/a.woff2', 'eot' => self::TTF_URL, 'ttf' => 5],
            'weights' => [400, 450, 400],
            'generic_family' => 'serifs',
            'confirm' => 'yes',
            'style' => 'italic',
        ]);

        $fields = self::errorFields($result);
        self::assertToolError($result, 'validation_failed');
        self::assertStringContainsString('„Inter Pro“', $fields['name']);
        self::assertStringContainsString('öffentlich', $fields['files.woff2']);
        self::assertStringContainsString('erlaubt: woff2, woff, ttf, otf', $fields['files.eot']);
        self::assertSame('URL als Text erwartet', $fields['files.ttf']);
        self::assertStringContainsString('100, 200, 300, 400, 500, 600, 700, 800, 900', $fields['weights']);
        self::assertStringContainsString('sans-serif', $fields['generic_family']);
        self::assertSame('true oder false', $fields['confirm']);
        self::assertSame('unbekanntes Feld', $fields['style']);
        self::assertSame([], $this->fetches);
    }

    public function testFilesAndWeightsNeedTheirShape(): void
    {
        $fields = self::errorFields($this->ability()->execute(self::input(['files' => [], 'weights' => []])));
        self::assertSame('1 bis 4 Formate als Objekt {Format: URL}', $fields['files']);
        self::assertStringContainsString('"all" oder Liste', $fields['weights']);

        $fields = self::errorFields($this->ability()->execute(self::input([
            'files' => [self::WOFF2_URL],
            'weights' => 'bold',
        ])));
        self::assertSame('1 bis 4 Formate als Objekt {Format: URL}', $fields['files']);
        self::assertStringContainsString('"all" oder Liste', $fields['weights']);

        $fields = self::errorFields($this->ability()->execute(['files' => ['woff2' => self::WOFF2_URL]]));
        self::assertStringContainsString('Name fehlt', $fields['name']);
        self::assertStringContainsString('"all" oder Liste', $fields['weights']);
    }

    public function testAFormatDiviDoesNotKnowIsRefused(): void
    {
        $installer = Mockery::mock(FontInstaller::class);
        $installer->allows('formats')->andReturn(['ttf', 'otf']);
        $installer->allows('nameMaxLength')->andReturn(100);

        $fields = self::errorFields($this->ability($installer)->execute(self::input()));

        self::assertStringContainsString('unterstützt Divi auf dieser Website nicht', $fields['files.woff2']);
        self::assertArrayNotHasKey('files.ttf', $fields);
    }

    public function testANameThatIsTakenWhateverTheCaseIsAConflict(): void
    {
        $error = self::assertToolError(
            $this->ability(catalog: self::catalog(['Open Sans', 'Inter Display']))->execute(
                self::input(['name' => 'inter display'])
            ),
            'conflict'
        );

        self::assertStringContainsString('„Inter Display“', $error['message']);
        self::assertSame([], $this->fetches);
    }

    public function testTheDryRunDownloadsAndChecksEveryFileAndAsksForConfirmation(): void
    {
        $installer = self::installer();
        $installer->expects('install')->never();

        $error = self::assertToolError($this->ability($installer)->execute(self::input()), 'confirm_required');

        self::assertStringContainsString('„Inter Display“', $error['message']);
        self::assertStringContainsString('ganzen Website', $error['message']);
        self::assertStringContainsString('Nichts wurde gespeichert (Probelauf).', $error['message']);
        self::assertSame('Inter Display', $error['font']);
        self::assertSame([
            ['format' => 'woff2', 'bytes' => strlen(self::WOFF2), 'sha256' => hash('sha256', self::WOFF2)],
            ['format' => 'ttf', 'bytes' => strlen(self::TTF), 'sha256' => hash('sha256', self::TTF)],
        ], $error['files']);
        self::assertSame('400,700', $error['weights']);
        self::assertSame('sans-serif', $error['generic_family']);
        self::assertSame([self::WOFF2_URL, self::TTF_URL], array_column($this->fetches, 'url'));
        self::assertSame(['font upload', 'font upload'], array_column($this->fetches, 'context'));
        self::assertSame([1048576, 1048576], array_column($this->fetches, 'max'), 'upload limit 1 MB');
        $this->assertTempFilesRemoved();
    }

    public function testTheSizeCapIsFiveMegabytesEvenWithAHigherUploadLimit(): void
    {
        Functions\when('get_option')->justReturn(['upload_mb' => 32]);

        $this->ability()->execute(self::input());

        self::assertSame([5242880, 5242880], array_column($this->fetches, 'max'));
    }

    public function testAFileAboveTheCapIsInvalid(): void
    {
        $this->bodies[self::TTF_URL] = self::TTF . str_repeat('x', 1048576);

        $result = $this->ability()->execute(self::input());

        self::assertToolError($result, 'font_invalid');
        self::assertSame('größer als 1 MB', self::errorFields($result)['files.ttf']);
        self::assertArrayNotHasKey('files.woff2', self::errorFields($result));
        $this->assertTempFilesRemoved();
    }

    public function testTheContentMustBeTheFormatOfItsKey(): void
    {
        $this->bodies[self::WOFF2_URL] = self::TTF;
        $this->bodies[self::TTF_URL] = '<?php evil();';

        $result = $this->ability()->execute(self::input());

        $fields = self::errorFields($result);
        self::assertToolError($result, 'font_invalid');
        self::assertSame('Inhalt ist eine ttf-Datei, keine woff2-Datei', $fields['files.woff2']);
        self::assertSame('Inhalt ist keine Schriftdatei', $fields['files.ttf']);
        $this->assertTempFilesRemoved();
    }

    public function testAnEmptyFileIsInvalid(): void
    {
        $this->bodies[self::TTF_URL] = '';

        $result = $this->ability()->execute(self::input());

        self::assertToolError($result, 'font_invalid');
        self::assertSame('leere Datei', self::errorFields($result)['files.ttf']);
    }

    public function testAFileDivisContentCheckRefusesIsInvalid(): void
    {
        $result = $this->ability(self::installer(accepts: false))->execute(self::input());

        self::assertToolError($result, 'font_invalid');
        self::assertSame('von Divi nicht als Schriftdatei erkannt', self::errorFields($result)['files.woff2']);
        $this->assertTempFilesRemoved();
    }

    public function testAFailedDownloadIsFontFetchFailedWithoutAnOracle(): void
    {
        $this->bodies[self::TTF_URL] = null;
        $this->bodies[self::WOFF2_URL] = self::TTF;

        $result = $this->ability()->execute(self::input());

        $error = self::assertToolError($result, 'font_fetch_failed');
        self::assertSame(
            ['files.woff2' => 'Inhalt ist eine ttf-Datei, keine woff2-Datei', 'files.ttf' => 'nicht abrufbar'],
            self::errorFields($result)
        );
        self::assertStringContainsString('nicht geladen', $error['message']);
        $this->assertTempFilesRemoved();
    }

    public function testConfirmInstallsFromTheCheckedTempFilesAndLogsTheChecksums(): void
    {
        $installer = self::installer();
        $installer->expects('install')->once()
            ->with('Inter Display', Mockery::type('array'), '400,700', 'serif')
            ->andReturnUsing(function (string $name, array $files): array {
                $this->installedFrom = array_map('file_get_contents', $files);

                return array_keys($files);
            });
        $log = new FakeChangeLog(7);

        $result = $this->ability($installer, $log)->execute(
            self::input(['generic_family' => 'serif', 'confirm' => true])
        );

        self::assertSame(['woff2' => self::WOFF2, 'ttf' => self::TTF], $this->installedFrom);
        self::assertTrue($result['ok']);
        self::assertSame('Inter Display', $result['font']);
        self::assertSame(['woff2', 'ttf'], array_column($result['files'], 'format'));
        self::assertSame(7, $result['log_id']);
        self::assertSame([['id' => 'Inter Display', 'type' => 'divi_font', 'action' => 'created',
            'label' => 'Inter Display']], $result['changed']);
        self::assertCount(1, $log->records);
        self::assertSame('lit/divi-fonts-upload', $log->records[0]['ability']);
        self::assertSame('divi_font', $log->records[0]['object_type']);
        self::assertSame('Inter Display', $log->records[0]['object_id']);
        self::assertStringContainsString('woff2 ' . hash('sha256', self::WOFF2), $log->records[0]['summary']);
        self::assertStringContainsString('ttf ' . hash('sha256', self::TTF), $log->records[0]['summary']);
        self::assertSame(2, count($this->fetches), 'downloaded and checked again for the install');
        $this->assertTempFilesRemoved();
    }

    public function testWeightsAllArePassedOn(): void
    {
        $installer = self::installer();
        $installer->expects('install')->once()->with('Inter Display', Mockery::type('array'), 'all', 'sans-serif')
            ->andReturn(['woff2', 'ttf']);

        $result = $this->ability($installer, new FakeChangeLog())->execute(
            self::input(['weights' => 'all', 'confirm' => true])
        );

        self::assertSame('all', $result['weights']);
    }

    public function testAPartialInstallIsRemovedAgain(): void
    {
        $installer = self::installer();
        $installer->allows('install')->andReturn(['woff2']);
        $installer->expects('remove')->once()->with('Inter Display');

        try {
            $this->ability($installer)->execute(self::input(['confirm' => true]));
            self::fail('WriteFailed expected');
        } catch (WriteFailed $e) {
            self::assertTrue($e->rolledBack);
            $error = self::assertToolError($e->toError(), 'write_failed');
            self::assertStringContainsString('ttf', $error['message']);
        }
        $this->assertTempFilesRemoved();
    }

    public function testAPartialInstallThatCannotBeRemovedIsAPartialWrite(): void
    {
        $installer = self::installer();
        $installer->allows('install')->andReturn(['woff2']);
        $installer->allows('remove')->andThrow(new RuntimeException('font_remove refused: Font Does not Exist.'));
        $log = FakeChangeLog::partialsOnly();

        try {
            $this->ability($installer, $log)->execute(self::input(['confirm' => true]));
            self::fail('WriteFailed expected');
        } catch (WriteFailed $e) {
            self::assertFalse($e->rolledBack);
            $error = self::assertToolError($e->toError(), 'partial_write');
            self::assertStringContainsString('„Inter Display“', $error['message']);
        }
        self::assertCount(1, $log->partials);
        self::assertSame('divi_font', $log->partials[0]['object_type']);
    }

    public function testARefusedInstallIsAWriteFailureThatLeavesNothing(): void
    {
        $installer = self::installer();
        $installer->allows('install')->andThrow(new RuntimeException('font_add refused: kaputt'));
        $installer->expects('remove')->never();

        try {
            $this->ability($installer)->execute(self::input(['confirm' => true]));
            self::fail('WriteFailed expected');
        } catch (WriteFailed $e) {
            self::assertTrue($e->rolledBack);
            self::assertStringNotContainsString('kaputt', Errors::clientMessage($e->toError()));
        }
        $this->assertTempFilesRemoved();
    }
}
  • Step 16: RED

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviFontsUploadAbilityTest Expected: Tests: 18, Assertions: 1, Errors: 18. – jeweils Error: Class "LIT\MCP\Integrations\Divi\Design\DiviFontsUploadAbility" not found.

  • Step 17: Ability anlegen

src/Integrations/Divi/Design/DiviFontsUploadAbility.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Design;

use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Abilities\Rollback;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\Abilities\WriteFailed;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
use LIT\MCP\Integrations\Media\AdminIncludes;
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
use LIT\MCP\Limits;
use RuntimeException;
use WP_Error;

defined('ABSPATH') || exit;

/**
 * lit/divi-fonts-upload (spec §8.2, rulings W8–W10): adds a custom font to Divi's uploaded fonts from public URLs,
 * one file per format. No pipeline and no snapshot (A2), but its own dry run: without confirm the files are really
 * downloaded (RemoteFileFetcher, at most 5 MB and the upload limit each), checked by magic bytes against their
 * format key (FontSignature) and by Divi's content check, the temp files deleted again, and the answer is
 * confirm_required with format, size and SHA-256 of every file. confirm: true downloads and checks again and
 * installs (FontInstaller); Divi's partial success is undone with font_remove. Every path deletes the temp files.
 */
final class DiviFontsUploadAbility implements Ability, RequiresDiviFeatures
{
    /** Ruling A9: at most 5 MB per font file, whatever the upload limit allows. */
    public const MAX_FILE_BYTES = 5 * 1024 * 1024;

    public const OBJECT_TYPE = 'divi_font';

    public const FAMILIES = ['serif', 'sans-serif', 'monospace', 'cursive', 'fantasy'];

    public const WEIGHTS = [100, 200, 300, 400, 500, 600, 700, 800, 900];

    private const KEYS = ['name', 'files', 'weights', 'generic_family', 'confirm'];

    private const DEFAULT_FAMILY = 'sans-serif';

    private const REASON = 'Divi hat die Schrift nicht vollständig übernommen.';

    private const FILE_RULE = '1 bis 4 Formate als Objekt {Format: URL}';

    public function __construct(
        private FontCatalog $catalog,
        private FontInstaller $installer,
        private RemoteFileFetcher $fetcher,
        private ChangeLog $log,
        private Limits $limits
    ) {
    }

    public function name(): string
    {
        return 'lit/divi-fonts-upload';
    }

    public function label(): string
    {
        return 'Eigene Schrift in Divi hochladen';
    }

    public function description(): string
    {
        return 'Lädt eine eigene Schrift von öffentlichen URLs in Divis hochgeladene Schriften: files mit je einer '
            . 'Datei pro Format (woff2, woff, ttf, otf; höchstens 5 MB je Datei). name: 1–100 Zeichen aus Buchstaben, '
            . 'Ziffern, Leerzeichen, _ und -, kein Name einer Google-, Web-Safe- oder schon hochgeladenen Schrift. '
            . 'weights: "all" oder Liste aus 100 … 900; generic_family: serif, sans-serif (Standard), monospace, '
            . 'cursive oder fantasy. Wirkt auf die ganze Website: zuerst Probelauf (lädt und prüft die Dateien, nennt '
            . 'Format, Größe und SHA-256), nach Rückfrage beim Nutzer confirm: true. Kein Snapshot; entfernen lässt '
            . 'sich die Schrift im Divi-Builder.';
    }

    public function inputSchema(): array
    {
        $url = ['type' => 'string', 'description' => 'Öffentliche http(s)-URL der Datei.'];

        return [
            'type' => 'object',
            'properties' => [
                'name' => ['type' => 'string', 'description' => 'Name der Schrift (Familienname in Divi).'],
                'files' => [
                    'type' => 'object',
                    'properties' => array_fill_keys(FontInstaller::FORMATS, $url),
                    'additionalProperties' => false,
                ],
                'weights' => [
                    'type' => ['string', 'array'],
                    'items' => ['type' => 'integer'],
                    'description' => '"all" oder Liste der Schriftstärken aus 100, 200, … 900.',
                ],
                'generic_family' => ['type' => 'string', 'enum' => self::FAMILIES],
                'confirm' => ['type' => 'boolean'],
            ],
            'required' => ['name', 'files', 'weights'],
            'additionalProperties' => false,
        ];
    }

    public function outputSchema(): ?array
    {
        return ToolResult::schema([
            'font' => ['type' => 'string'],
            'files' => [
                'type' => 'array',
                'items' => [
                    'type' => 'object',
                    'properties' => [
                        'format' => ['type' => 'string'],
                        'bytes' => ['type' => 'integer'],
                        'sha256' => ['type' => 'string'],
                    ],
                ],
            ],
            'weights' => ['type' => 'string'],
            'generic_family' => ['type' => 'string'],
        ], ['font', 'files', 'weights', 'generic_family']);
    }

    public function annotations(): array
    {
        return Annotations::write(false);
    }

    public function isDirect(): bool
    {
        return false;
    }

    /** FontInstaller and the font lists of FontCatalog (name collision) both serve font-upload (ruling W1). */
    public function diviFeatures(array $input): array
    {
        return [DiviFeature::FontUpload];
    }

    public function permission(array $input): bool|WP_Error
    {
        return DesignRights::fonts($this->catalog) ?? true;
    }

    /** @throws WriteFailed */
    public function execute(array $input): mixed
    {
        $refusal = DesignRights::fonts($this->catalog);
        if ($refusal !== null) {
            return $refusal;
        }

        $errors = [];
        foreach (array_diff(array_map('strval', array_keys($input)), self::KEYS) as $key) {
            $errors[$key] = 'unbekanntes Feld';
        }
        $name = is_string($input['name'] ?? null) ? $input['name'] : '';
        $taken = FontNameRules::taken($name, $this->catalog->reserved());
        $nameProblem = $taken === null
            ? FontNameRules::check($name, [], $this->installer->nameMaxLength())
            : null;
        if ($nameProblem !== null) {
            $errors['name'] = $nameProblem;
        }
        $urls = $this->urls($input['files'] ?? null, $errors);
        $weights = self::weights($input['weights'] ?? null, $errors);
        $family = $input['generic_family'] ?? self::DEFAULT_FAMILY;
        if (! in_array($family, self::FAMILIES, true)) {
            $errors['generic_family'] = 'eine von: ' . implode(', ', self::FAMILIES);
        }
        $confirm = $input['confirm'] ?? false;
        if (! is_bool($confirm)) {
            $errors['confirm'] = 'true oder false';
        }
        if ($errors !== []) {
            return Errors::invalidInput('Ungültige Angaben zur Schrift; nichts wurde gespeichert.', $errors);
        }

        if ($taken !== null) {
            return Errors::make(
                'conflict',
                sprintf('Eine Schrift „%s“ gibt es schon; nichts wurde gespeichert.', $taken),
                'Einen anderen Namen wählen (Groß-/Kleinschreibung zählt nicht) oder die vorhandene Schrift verwenden.',
                409
            );
        }

        return $this->upload($name, $urls, $weights, (string) $family, $confirm === true);
    }

    /**
     * @param array<string, mixed> $errors
     * @return array<string, string> Format => URL, in the order of the input.
     */
    private function urls(mixed $files, array &$errors): array
    {
        if (! is_array($files) || $files === [] || array_is_list($files) || count($files) > 4) {
            $errors['files'] = self::FILE_RULE;

            return [];
        }

        $known = $this->installer->formats();
        $urls = [];
        foreach ($files as $format => $url) {
            $field = 'files.' . $format;
            if (! in_array($format, FontInstaller::FORMATS, true)) {
                $errors[$field] = 'unbekanntes Format; erlaubt: ' . implode(', ', FontInstaller::FORMATS);
            } elseif (! in_array($format, $known, true)) {
                $errors[$field] = 'dieses Format unterstützt Divi auf dieser Website nicht';
            } elseif (! is_string($url) || $url === '') {
                $errors[$field] = 'URL als Text erwartet';
            } elseif (! $this->fetcher->allows($url)) {
                $errors[$field] = 'nur öffentlich erreichbare http(s)-Adressen';
            } else {
                $urls[$format] = $url;
            }
        }

        return $urls;
    }

    /**
     * @param array<string, mixed> $errors
     * @return string "all" or the sorted list as Divi stores it ("400,700").
     */
    private static function weights(mixed $weights, array &$errors): string
    {
        if ($weights === 'all') {
            return 'all';
        }

        if (
            ! is_array($weights) || $weights === [] || ! array_is_list($weights)
            || array_filter($weights, 'is_int') !== $weights
            || array_diff($weights, self::WEIGHTS) !== [] || count(array_unique($weights)) !== count($weights)
        ) {
            $errors['weights'] = '"all" oder Liste verschiedener Schriftstärken aus '
                . implode(', ', self::WEIGHTS);

            return '';
        }

        sort($weights);

        return implode(',', $weights);
    }

    /**
     * Downloads and checks every file into temp files – for the dry run and again for the install – and deletes
     * them on every path.
     *
     * @param array<string, string> $urls
     * @return array<string, mixed>|WP_Error
     * @throws WriteFailed
     */
    private function upload(string $name, array $urls, string $weights, string $family, bool $confirm): array|WP_Error
    {
        AdminIncludes::load();
        $temps = [];
        try {
            $files = $this->download($name, $urls, $temps);
            if ($files instanceof WP_Error) {
                return $files;
            }

            $report = array_values(array_map(
                static fn (array $file): array => array_diff_key($file, ['path' => true]),
                $files
            ));
            if (! $confirm) {
                return Errors::confirmRequired(
                    sprintf(
                        'Schrift „%s“ hochladen (%s): Sie steht danach auf der ganzen Website in Divi zur Auswahl '
                            . '(Builder, Theme-Optionen, Schrift-Variablen). Nichts wurde gespeichert (Probelauf).',
                        $name,
                        implode(', ', array_keys($files))
                    ),
                    ['font' => $name, 'files' => $report, 'weights' => $weights, 'generic_family' => $family]
                );
            }

            $paths = array_map(static fn (array $file): string => $file['path'], $files);
            $this->install($name, $paths, $weights, $family);
        } finally {
            foreach ($temps as $tmp) {
                if (file_exists($tmp)) {
                    wp_delete_file($tmp);
                }
            }
        }

        $summary = sprintf('Schrift „%s“ hochgeladen (%s).', $name, implode(', ', array_map(
            static fn (array $file): string => $file['format'] . ' ' . $file['sha256'],
            $report
        )));
        $logId = $this->log->record($this->name(), self::OBJECT_TYPE, $name, $summary);

        return ToolResult::ok(sprintf(
            'Schrift „%s“ hochgeladen; sie steht jetzt in Divi zur Auswahl (z. B. für lit/divi-theme-options-update).',
            $name
        ))
            ->changed($name, self::OBJECT_TYPE, 'created', $name)
            ->logId($logId)
            ->with('font', $name)
            ->with('files', $report)
            ->with('weights', $weights)
            ->with('generic_family', $family)
            ->toArray();
    }

    /**
     * @param array<string, string> $urls
     * @param list<string>          $temps Gets every temp file created (the caller deletes them).
     * @return array<string, array{format: string, bytes: int, sha256: string, path: string}>|WP_Error
     */
    private function download(string $name, array $urls, array &$temps): array|WP_Error
    {
        $max = min($this->limits->uploadBytes(), self::MAX_FILE_BYTES);
        $files = [];
        $problems = [];
        $unreachable = false;
        foreach ($urls as $format => $url) {
            $tmp = (string) wp_tempnam('lit-mcp-font');
            if ($tmp === '') {
                return Errors::writeFailed('Die temporäre Datei konnte nicht angelegt werden.');
            }
            $temps[] = $tmp;
            if ($this->fetcher->fetch($url, $tmp, $max, 'font upload') === null) {
                $problems['files.' . $format] = 'nicht abrufbar';
                $unreachable = true;
                continue;
            }

            $problem = $this->problemOf($tmp, $format, $name . '.' . $format, $max);
            if ($problem !== null) {
                $problems['files.' . $format] = $problem;
                continue;
            }
            $files[$format] = [
                'format' => $format,
                'bytes' => (int) filesize($tmp),
                'sha256' => (string) hash_file('sha256', $tmp),
                'path' => $tmp,
            ];
        }

        if ($unreachable) {
            return Errors::make(
                'font_fetch_failed',
                'Mindestens eine Schriftdatei konnte nicht geladen werden; nichts wurde gespeichert.',
                'URLs prüfen; nur öffentlich erreichbare Dateien lassen sich laden.',
                400,
                ['fields' => $problems]
            );
        }

        return $problems === [] ? $files : Errors::make(
            'font_invalid',
            'Mindestens eine Datei ist keine gültige Schriftdatei ihres Formats; nichts wurde gespeichert.',
            sprintf(
                'Je Format die passende Datei angeben (woff2, woff, ttf oder otf, höchstens %d MB je Datei).',
                intdiv($max, 1024 * 1024)
            ),
            400,
            ['fields' => $problems]
        );
    }

    /** The problem of a downloaded file (size, magic bytes, Divi's content check), null when it is fine. */
    private function problemOf(string $tmp, string $format, string $filename, int $max): ?string
    {
        clearstatcache(true, $tmp);
        $bytes = (int) filesize($tmp);
        if ($bytes === 0) {
            return 'leere Datei';
        }
        if ($bytes > $max) {
            return sprintf('größer als %d MB', intdiv($max, 1024 * 1024));
        }

        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- local temp file.
        $header = (string) file_get_contents($tmp, false, null, 0, FontSignature::HEADER_BYTES);
        $detected = FontSignature::detect($header);
        if ($detected === null) {
            return 'Inhalt ist keine Schriftdatei';
        }
        if ($detected !== $format) {
            return sprintf('Inhalt ist eine %s-Datei, keine %s-Datei', $detected, $format);
        }

        return $this->installer->accepts($tmp, $filename) ? null : 'von Divi nicht als Schriftdatei erkannt';
    }

    /**
     * @param array<string, string> $paths Format => checked temp file.
     * @throws WriteFailed When Divi refuses the font or stores only some files (removed again if possible).
     */
    private function install(string $name, array $paths, string $weights, string $family): void
    {
        try {
            $installed = $this->installer->install($name, $paths, $weights, $family);
        } catch (RuntimeException $e) {
            // FontInstaller stores nothing when it throws.
            throw WriteFailed::rolledBack(self::REASON, $e, 'font upload: ' . $e->getMessage());
        }

        $missing = array_values(array_diff(array_keys($paths), $installed));
        if ($missing === []) {
            return;
        }

        $detail = sprintf(
            'font upload: Divi stored %s of %s',
            implode(',', $installed),
            implode(',', array_keys($paths))
        );
        $failures = Rollback::attempt(fn () => $this->installer->remove($name));
        if ($failures === []) {
            throw WriteFailed::rolledBack(
                self::REASON . sprintf(' Divi hat %s nicht gespeichert.', implode(', ', $missing)),
                detail: $detail
            );
        }

        $this->log->recordPartial(
            $this->name(),
            self::OBJECT_TYPE,
            $name,
            sprintf('Schrift „%s“ nur teilweise hochgeladen, nicht entfernt (%s)', $name, $failures[0])
        );
        throw WriteFailed::partial(
            self::REASON,
            detail: $detail . '; remove failed: ' . $failures[0],
            state: sprintf('Die Schrift „%s“ ist ohne %s in Divi gespeichert.', $name, implode(', ', $missing)),
            hint: 'Den Aufruf nicht wiederholen; die Schrift im Divi-Builder (Schriften verwalten) prüfen und ggf. '
                . 'löschen und dem Nutzer Bescheid geben.'
        );
    }
}
  • Step 18: GREEN

Run: php -d memory_limit=512M vendor/bin/phpunit --filter DiviFontsUploadAbilityTest Expected: OK (18 tests, 178 assertions)

  • Step 19: Gesamtlauf, Lint, Commit

Die Ability ist noch nicht verdrahtet (Task 9); DiviIntegrationTest/DiviToolFeaturesTest bleiben unverändert grün.

Run: composer test && composer lint Expected: OK – Basis von Task 7 + 35 Tests (8a: 7, 8b: 10, 8c: 18); im Prüflauf des Planers mit Platzhaltern für Task 1–2 auf dem Stand 2307027: OK (3161 tests, …) = 3121 + 40 (Tasks 7 und 8); Lint sauber.

cd /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP
git add src/Integrations/Divi/Design/DiviFontsUploadAbility.php \
  tests/Unit/Integrations/Divi/Design/DiviFontsUploadAbilityTest.php
git commit -m "feat: lit/divi-fonts-upload – eigene Schriften von URLs hochladen (Probelauf mit SHA-256, confirm, Rücknahme bei Teilerfolg)" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 9: Verdrahtung in DiviIntegration, Tabellen der Fähigkeiten, Protokoll-Label

Files:

  • Modify: src/Integrations/Divi/DiviIntegration.php
  • Modify: src/Admin/LogObjects.php (Label für den Objekttyp divi_font, falls dort je Typ ein Label steht – erst lesen)
  • Modify: tests/Unit/Integrations/Divi/DiviIntegrationTest.php (Namensliste)
  • Modify: tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php (Tabelle EXPECTED)
  • Modify (nur falls ein Test den Objekttyp prüft): tests/Unit/Admin/LogObjectsTest.php

Interfaces:

  • Consumes (so, wie die Tasks 2–8 sie tatsächlich gebaut haben – vor dem Verdrahten die echten Konstruktoren lesen): ThemeOptionsAdapter(), FontCatalog(), FontInstaller(), RemoteFileFetcher(), Limits() ohne Argumente; DiviThemeOptionsGetAbility(ThemeOptionsAdapter); DiviThemeOptionsUpdateAbility(DesignSystemWriter, ThemeOptionsAdapter, FontCatalog, DesignChange); DiviFontsUploadAbility(FontCatalog, FontInstaller, RemoteFileFetcher, ChangeLog, Limits); DiviVariablesSetAbility(…, FontCatalog $fonts, ?Closure $random = null); ThemeOptionsRestorePlanner(DesignSystemWriter, ThemeOptionsAdapter).

  • Produces: die drei Tools in DiviIntegration::abilities(), eine Instanz je Adapter pro Integration (Eigenschaften $themeOptionsAdapter, $fontCatalog, $fontInstaller); die in Task 5 und Task 6 inline eingeführten new FontCatalog()/new ThemeOptionsAdapter() werden durch die geteilten Eigenschaften ersetzt (FontCatalog hält die große Schriftliste je Instanz – Task 2 –, darum nur eine pro Request).

  • Step 1: Tests zuerst – Namensliste und Fähigkeitentabelle

tests/Unit/Integrations/Divi/DiviIntegrationTest.php, in EXPECTED nach 'lit/divi-variables-set', ergänzen:

        'lit/divi-theme-options-get',
        'lit/divi-theme-options-update',
        'lit/divi-fonts-upload',

tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php, in EXPECTED unter // Design system (divi) nach 'lit/divi-variables-set' => … ergänzen – die Zeilen müssen genau die diviFeatures() der Abilities abbilden (zuerst DiviThemeOptionsGetAbility::diviFeatures(), DiviThemeOptionsUpdateAbility::diviFeatures() und DiviFontsUploadAbility::diviFeatures() lesen; die Erwartung unten ist die aus den Plänen der Tasks 4 und 8, die Tabelle ist der Vertrag und folgt dem Code, nicht umgekehrt):

        'lit/divi-theme-options-get' => ['theme-option-read'],
        'lit/divi-theme-options-update' => ['theme-option-read', 'theme-option-write'],
        'lit/divi-fonts-upload' => ['font-upload'],

Und in der zweiten Tabelle der Datei (jedem schreibenden Tool seine Schreib-Fähigkeit, Zeile mit 'lit/divi-colors-set' => 'color-write', …) ergänzen:

            'lit/divi-theme-options-update' => 'theme-option-write', 'lit/divi-fonts-upload' => 'font-upload',

Run: php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviIntegrationTest|DiviToolFeaturesTest/' Expected: FAIL – DiviIntegrationTest (Namensliste ohne die drei Tools) und DiviToolFeaturesTest (Tabelle nennt Tools, die es nicht gibt).

  • Step 2: Imports, Eigenschaften, Konstruktor

src/Integrations/Divi/DiviIntegration.php – Imports (zu den vorhandenen Design\-Imports; FontCatalog, ThemeOptionsAdapter und ThemeOptionsRestorePlanner stehen nach Task 5/6 teilweise schon dort):

use LIT\MCP\Integrations\Divi\Design\DiviFontsUploadAbility;
use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsGetAbility;
use LIT\MCP\Integrations\Divi\Design\DiviThemeOptionsUpdateAbility;
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
use LIT\MCP\Integrations\Divi\Design\FontInstaller;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner;
use LIT\MCP\Integrations\Media\RemoteFileFetcher;
use LIT\MCP\Limits;

Eigenschaften neben $colorsAdapter/$variablesAdapter:

    private ThemeOptionsAdapter $themeOptionsAdapter;

    private FontCatalog $fontCatalog;

    private FontInstaller $fontInstaller;

Im Konstruktor nach $this->variablesAdapter = new GlobalVariablesAdapter();:

        $this->themeOptionsAdapter = new ThemeOptionsAdapter();
        $this->fontCatalog = new FontCatalog();
        $this->fontInstaller = new FontInstaller();
  • Step 3: Tools registrieren, geteilte Adapter verwenden

In abilities(): die inline-new FontCatalog() des Variablen-Tools (Task 5) und new ThemeOptionsAdapter() des Restore-Planers (Task 6) durch $this->fontCatalog bzw. $this->themeOptionsAdapter ersetzen. Vorher (Restore, Planerliste):

                new ThemeOptionsRestorePlanner($this->designWriter, new ThemeOptionsAdapter())

nachher:

                new ThemeOptionsRestorePlanner($this->designWriter, $this->themeOptionsAdapter)

Vorher (Variablen-Tool, letztes Konstruktorargument vor $random):

                new FontCatalog()

nachher:

                $this->fontCatalog

Nach dem Eintrag new DiviVariablesSetAbility( … ), anfügen:

            new DiviThemeOptionsGetAbility($this->themeOptionsAdapter),
            new DiviThemeOptionsUpdateAbility(
                $this->designWriter,
                $this->themeOptionsAdapter,
                $this->fontCatalog,
                $this->designChange()
            ),
            new DiviFontsUploadAbility(
                $this->fontCatalog,
                $this->fontInstaller,
                new RemoteFileFetcher(),
                $this->log,
                new Limits()
            ),

(Die Argumente des DiviFontsUploadAbility-Konstruktors folgen der von Task 8 tatsächlich gebauten Signatur; weicht sie ab, gilt der Code.)

  • Step 4: Protokoll-Label

Run: grep -n "divi_design\|divi_font\|divi_" src/Admin/LogObjects.php

Steht dort je Objekttyp ein Label (z. B. divi_design → „Designsystem“), den Typ divi_font mit dem Label „Divi-Schrift“ ergänzen und im zugehörigen Test (tests/Unit/Admin/LogObjectsTest.php, falls vorhanden) eine Zeile; gibt es keine Typ-Liste, entfällt der Schritt (im Bericht vermerken).

  • Step 5: GREEN, Gesamtlauf, Lint

Run: php -d memory_limit=512M vendor/bin/phpunit --filter '/DiviIntegrationTest|DiviToolFeaturesTest|IntegrationRegistryTest|ToolNamingTest/' Expected: PASS.

Run: composer test && composer lint Expected: alle Tests grün, phpcs ohne Befund. Schlägt ein Test fehl, der Tool-Namen oder Beschreibungen prüft (ToolNamingTest, Exposure-/Guide-Tests), zuerst lesen, was er verlangt (indirekte Tools heißen in Texten lit/<name>), und den Text der Ability anpassen, nicht den Test.

Run: grep -rn "Etappe 4d\|folgen mit Etappe\|mit 4d" src docs/guides | grep -v "docs/superpowers" Expected: keine Treffer, die behaupten, Theme-Optionen/Schriften/Restore von theme_options fehlten. Treffer in Guides, die nur auf spätere Guides (4e) verweisen, bleiben.

  • Step 6: Commit
git add src/Integrations/Divi/DiviIntegration.php src/Admin/LogObjects.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php \
  tests/Unit/Integrations/Divi/Release/DiviToolFeaturesTest.php
git commit -m "feat: Theme-Optionen und Schriften-Upload in der Divi-Integration; je ein geteilter Adapter" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

(git add nur die tatsächlich geänderten Dateien; LogObjects/LogObjectsTest nur, wenn Step 4 sie geändert hat.)

Task 10: Smoke gegen echtes Divi (Theme-Optionen, Schriften-Upload, Restore)

Files:

  • Create: tests/Integration/divi-theme-fonts-smoke.sh

Interfaces:

  • Consumes: die Tools lit/divi-theme-options-get, lit/divi-theme-options-update, lit/divi-fonts-upload, lit/divi-design-snapshots, lit/divi-design-restore über den OAuth-Endpunkt und die Hilfen aus tests/Integration/lib.sh (connect, ability, error_code, pass, fail, finish, guard_exit, cleanup_app_passwords_for).
  • Produces: ein Skript, das 4d gegen Divi 5.15.1 prüft und alles, was es ändert (et_divi byte-genau, et_uploaded_fonts samt Dateien), im EXIT-Trap zurücksetzt.

Das Skript wird ausgeführt und – wie in Plan 4c – anhand der echten Antworten korrigiert. Die Feldnamen der Antworten (.__error | fromjson, .fields, .snapshots[]) kommen aus dem Stand der Tools nach Task 9; stimmt eine Annahme nicht, wird das Skript berichtigt (nicht die Prüfung gelockert). Die Prüfungen selbst sind verbindlich.

  • Step 1: Das Skript anlegen

Datei tests/Integration/divi-theme-fonts-smoke.sh:

#!/usr/bin/env bash
# End-to-end checks of Etappe 4d (Divi theme options: read, change, unknown font with suggestions, reset to default,
# snapshot + restore; custom font upload from a public URL) through the OAuth-protected endpoint against the real
# Divi. Runs INSIDE the DDEV web container:
#   cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-theme-fonts-smoke.sh
# Needs network access from the container (fetches one small woff2 from fonts.gstatic.com). The EXIT trap puts the
# options et_divi and et_uploaded_fonts back byte-exact (serialized, base64 through the environment), removes the
# uploaded font with Divi's own FontsUtility::font_remove (files included) and fails the script when a restore fails.
set -euo pipefail
cd /var/www/html
# shellcheck source=lib.sh
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh

ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
RUN="$(date +%s)-$$"
FONT_NAME="LIT Smoke $RUN"
FONT_URL="https://fonts.gstatic.com/s/roboto/v30/KFOmCnqEu92Fr1Mu4mxK.woff2"
capture() { wp eval "echo base64_encode(serialize(get_option('$1')));"; } # $1 = option name
ORIGINAL_THEME=$(capture et_divi)
ORIGINAL_FONTS=$(capture et_uploaded_fonts)
[ -n "$ORIGINAL_THEME" ] || { echo "FAIL: could not capture et_divi"; exit 1; }
[ -n "$ORIGINAL_FONTS" ] || { echo "FAIL: could not capture et_uploaded_fonts"; exit 1; }
curl -fsS -m 20 -o /dev/null "$FONT_URL" || { echo "FAIL: $FONT_URL is not reachable from the container"; exit 1; }

restore() { # $1 = option name, $2 = name of the environment variable that holds the capture
  local LIT_CAPTURE="$2"; export LIT_CAPTURE
  LIT_OPTION="$1" wp eval '$n = getenv("LIT_OPTION"); $v = unserialize(base64_decode(getenv(getenv("LIT_CAPTURE")))); update_option($n, $v); exit(serialize(get_option($n)) === serialize($v) ? 0 : 1);' >/dev/null 2>&1 \
    || { echo "FAIL: $1 not restored"; return 1; }
}
cleanup() {
  local failed=0
  # Remove the font through Divi (deletes the files), then put the option back exactly as it was.
  LIT_FONT="$FONT_NAME" wp eval 'ET\Builder\VisualBuilder\Fonts\FontsUtility::font_remove(getenv("LIT_FONT"));' >/dev/null 2>&1 || true
  restore et_uploaded_fonts LIT_FONTS || failed=1
  restore et_divi LIT_THEME || failed=1
  wp eval 'ET_Core_PageResource::remove_static_resources("all", "all", true);' >/dev/null 2>&1 || true
  cleanup_app_passwords_for "$ADMIN_LOGIN"
  return "$failed"
}
export LIT_THEME="$ORIGINAL_THEME" LIT_FONTS="$ORIGINAL_FONTS"
trap 'rc=$?; cleanup || rc=1; guard_exit "$rc" || exit $?' EXIT

connect "$ADMIN_LOGIN"

# confirm <tool> <input JSON>: dry run must answer confirm_required; then the confirmed call; prints the result.
confirm() {
  local dry rev
  dry=$(ability "$1" "$2")
  [ "$(error_code "$dry")" = "confirm_required" ] || fail "$1 dry run: $dry"
  rev=$(echo "$dry" | jq -r '.__error | fromjson | .design_rev')
  ability "$1" "$(echo "$2" | jq -c --arg r "$rev" '. + {confirm: true, design_rev: $r}')"
}
theme_option() { # $1 = key -> the stored value ('' when the key is not set)
  wp eval "\$o = get_option('et_divi'); echo array_key_exists('$1', \$o) ? var_export(\$o['$1'], true) : '<unset>';"
}

# 1. Read: the tool lists the options with effective values and a design_rev.
GET=$(ability lit/divi-theme-options-get '{}')
echo "$GET" | jq -e '.design_rev | type == "string"' >/dev/null || fail "theme-options-get without design_rev: $GET"
echo "$GET" | jq -e '[.. | objects | select(has("effective"))] | length >= 20' >/dev/null \
  || fail "theme-options-get does not list the 20 options with effective values: $GET"
pass "theme options listed"

# 2. Change two options (snapshot is taken), stale design_rev refused, values stored with Divi's types.
UPDATE=$(jq -cn '{set: {body_font_size: 18, content_width: "1200"}}')
DONE=$(confirm lit/divi-theme-options-update "$UPDATE")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "theme options update: $DONE"
THEME_SNAPSHOT=$(echo "$DONE" | jq -r '.snapshot_id')
[ "$(theme_option body_font_size)" = "18" ] || fail "body_font_size not stored"
[ "$(theme_option content_width)" = "1200" ] || fail "content_width not stored as an integer"
STALE=$(ability lit/divi-theme-options-update "$(echo "$UPDATE" | jq -c '. + {confirm: true, design_rev: "veraltet"}')")
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale design_rev: $STALE"
pass "theme options changed ($THEME_SNAPSHOT), stale design_rev refused"

# 3. Validation: unknown font with suggestions, range, 'none', unknown key – all collected, nothing written.
BAD=$(ability lit/divi-theme-options-update '{"set": {"heading_font": "Robotto", "body_font_size": 99, "body_font": "none", "colour": 1}}')
[ "$(error_code "$BAD")" = "validation_failed" ] || fail "invalid input accepted: $BAD"
echo "$BAD" | grep -q 'Roboto' || fail "no font suggestion in: $BAD"
[ "$(theme_option body_font_size)" = "18" ] || fail "invalid call changed body_font_size"
pass "unknown font, range, none and unknown key refused with suggestions"

# 4. A known Google font is accepted; null resets an option to Divi's default (key removed).
DONE=$(confirm lit/divi-theme-options-update '{"set": {"heading_font": "Roboto", "content_width": null}}')
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "font + reset: $DONE"
[ "$(theme_option heading_font)" = "'Roboto'" ] || fail "heading_font not stored"
[ "$(theme_option content_width)" = "<unset>" ] || fail "content_width not removed by null"
pass "Google font stored, null resets to Divi's default"

# 5. Restore the snapshot of step 2: body_font_size and content_width are back as before step 2; heading_font too.
LIST=$(ability lit/divi-design-snapshots '{"area": "theme_options"}')
echo "$LIST" | jq -e --arg s "$THEME_SNAPSHOT" 'any(.snapshots[]; .id == $s)' >/dev/null || fail "snapshot not listed: $LIST"
DONE=$(confirm lit/divi-design-restore "$(jq -cn --arg s "$THEME_SNAPSHOT" '{snapshot_id: $s}')")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "restore: $DONE"
# The snapshot of step 2 was taken before any change of this run: the three keys must equal their original state
# (present with the same typed value, or absent) – compared against the captured et_divi, not against assumptions.
for KEY in body_font_size content_width heading_font; do
  LIT_KEY="$KEY" wp eval '$o = unserialize(base64_decode(getenv("LIT_THEME"))); $n = get_option("et_divi"); $k = getenv("LIT_KEY"); exit(array_key_exists($k, $o) === array_key_exists($k, $n) && ($o[$k] ?? null) === ($n[$k] ?? null) ? 0 : 1);' \
    || fail "$KEY not restored to its state before the first change"
done
pass "theme_options snapshot restored (body_font_size, content_width, heading_font as before)"

# 6. Font upload: Probelauf names the file (format, bytes, sha256) and installs nothing.
UPLOAD=$(jq -cn --arg n "$FONT_NAME" --arg u "$FONT_URL" '{name: $n, files: {woff2: $u}, weights: "all"}')
DRY=$(ability lit/divi-fonts-upload "$UPLOAD")
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "font dry run: $DRY"
echo "$DRY" | grep -q 'woff2' || fail "dry run does not name the format: $DRY"
LIT_FONT="$FONT_NAME" wp eval 'exit(isset(get_option("et_uploaded_fonts", [])[getenv("LIT_FONT")]) ? 1 : 0);' \
  || fail "dry run installed the font"
pass "font dry run installs nothing"

# 7. Confirmed upload: the font is in et_uploaded_fonts, the file exists, the name is now reserved.
DONE=$(ability lit/divi-fonts-upload "$(echo "$UPLOAD" | jq -c '. + {confirm: true}')")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "font upload: $DONE"
LIT_FONT="$FONT_NAME" wp eval '$f = get_option("et_uploaded_fonts", [])[getenv("LIT_FONT")] ?? null; exit($f && isset($f["font_url"]["woff2"]) ? 0 : 1);' \
  || fail "font not stored in et_uploaded_fonts"
AGAIN=$(ability lit/divi-fonts-upload "$(echo "$UPLOAD" | jq -c '. + {confirm: true}')")
[ "$(error_code "$AGAIN")" != "" ] || fail "a second upload of the same name was accepted: $AGAIN"
pass "font installed through Divi, same name refused"

# 8. The uploaded font is a valid theme font now; Arial (web-safe) is refused as a new font name.
DONE=$(confirm lit/divi-theme-options-update "$(jq -cn --arg n "$FONT_NAME" '{set: {body_font: $n}}')")
[ "$(echo "$DONE" | jq -r '.ok')" = "true" ] || fail "uploaded font as body_font: $DONE"
RESERVED=$(ability lit/divi-fonts-upload "$(jq -cn --arg u "$FONT_URL" '{name: "Arial", files: {woff2: $u}, weights: "all"}')")
[ "$(error_code "$RESERVED")" = "validation_failed" ] || fail "web-safe name accepted: $RESERVED"
pass "uploaded font usable as theme font, web-safe name refused"

# 9. A file whose content does not match its format key is refused (a PNG offered as woff2).
WRONG=$(ability lit/divi-fonts-upload "$(jq -cn --arg n "$FONT_NAME Falsch" '{name: $n, files: {woff2: "https://s.w.org/images/wmark.png"}, weights: "all"}')")
[ "$(error_code "$WRONG")" = "font_invalid" ] || fail "wrong content accepted: $WRONG"
pass "content/format mismatch refused (font_invalid)"

finish
  • Step 2: Syntax prüfen

Run: bash -n tests/Integration/divi-theme-fonts-smoke.sh && (command -v shellcheck >/dev/null && shellcheck -x tests/Integration/divi-theme-fonts-smoke.sh || true) Expected: keine Syntaxfehler; Warnungen zu $-Escapes in wp eval-Strings sind erwartet.

  • Step 3: In DDEV ausführen und korrigieren

Vorbereitung: cd /home/janniclas/Projekte/WordPress && ddev exec wp plugin list notieren, dann ddev exec wp plugin deactivate postlos asynclistry (nur falls aktiv); nach den Läufen den notierten Zustand wiederherstellen.

Run: cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-theme-fonts-smoke.sh Expected: ALL CHECKS PASSED. Schlägt eine Prüfung fehl, die Antwort der Ability lesen (fail druckt sie): Ein Skriptfehler (Feldname, Quoting, jq-Pfad) wird im Skript berichtigt – Prüfung bleibt; ein Produktfehler (das Tool tut gegen echtes Divi etwas Falsches) wird NICHT im Skript versteckt, sondern mit Diagnose (Antwort, erwartet/ist, Verdacht mit Datei:Zeile) berichtet (DONE_WITH_CONCERNS). Schritt 5 vergleicht die drei Schlüssel nach dem Restore mit dem erfassten Original (LIT_THEME); der Snapshot aus Schritt 2 entstand vor jeder Änderung des Laufs, deshalb müssen alle drei Schlüssel exakt (vorhanden mit gleichem getypten Wert oder fehlend) dem Original entsprechen.

Danach prüfen, dass nichts übrig ist: et_divi und et_uploaded_fonts byte-identisch zum Zustand vor dem Lauf (ddev exec wp eval 'echo md5(serialize(get_option("et_divi")));' vor und nach dem Lauf), keine Schrift „LIT Smoke …“, keine Dateien LIT-Smoke* oder *Roboto* neu in wp-content/uploads/et-fonts/.

Run zusätzlich: divi-release-smoke.sh (erwartet jetzt 16 Fähigkeiten), divi-colors-variables-smoke.sh, divi-presets-smoke.sh, divi-presets-assign-smoke.sh – alle ALL CHECKS PASSED; und ddev exec wp --user=<Admin> lit-mcp divi-check mit Exit-Code 0 (alle Fähigkeiten nutzbar; die neuen Fingerabdrücke aus Task 2 und 8 sind schon committet).

  • Step 4: Commit
git add tests/Integration/divi-theme-fonts-smoke.sh
git commit -m "test: Smoke für Theme-Optionen und Schriften-Upload gegen Divi 5.15.1" -m "Co-Authored-By: Claude <Modellname> <noreply@anthropic.com>"

Task 11: Recherche ablegen, Notizen, Spec, Gesamtverifikation und Abschluss

Files:

  • Create: docs/superpowers/notes/2026-10-19-etappe-4d-divi-recherche.md (die Divi-5.15.1-Recherche; Kopie der Planer-Notiz, redaktionell auf die Entscheidungen W1–W14 abgestimmt)
  • Modify: docs/superpowers/notes/2026-09-28-etappe-4-divi-formate.md (Nachtrag „Etappe 4d“ nach dem Nachtrag 4c)
  • Modify: docs/superpowers/specs/2026-10-16-etappe-4-designsystem-design.md (§8.1, §8.2, §10)
  • Modify: docs/superpowers/notes/2026-09-28-etappe-4-entscheidungen-entwurf.md (Abschnitt „Etappe 4d“ mit den Rulings und den offenen Punkten)
  • Modify: docs/superpowers/notes/2026-10-18-divi-faehigkeiten.md (Abschnitt „Offen“: Theme-Optionen-Fähigkeiten erledigt)
  • Modify: Memory etappe-4-stand (außerhalb des Repos, /home/janniclas/.claude/projects/-home-janniclas-Projekte-WordPress-wp-content-plugins-WP-LIT-MCP/memory/etappe-4-stand.md)

Interfaces:

  • Consumes: alles aus den Tasks 1–10.

  • Produces: grüne Suite, grüne Smokes, Gesamtreview ohne offene wichtige Befunde, nachgeführte Spec und Notizen, aktualisierter Stand.

  • Step 1: Recherche und Nachträge schreiben

  1. Die Recherche (…/scratchpad/research-4d-divi.md, falls die Datei fehlt: aus Plan und Code neu fassen) als docs/superpowers/notes/2026-10-19-etappe-4d-divi-recherche.md ablegen.
  2. Formate-Nachtrag „Divi 5.15.1 – Theme-Optionen und Schriften (Etappe 4d, 2026-10-19)“ in 2026-09-28-etappe-4-divi-formate.md nach dem Nachtrag 4c: die zwölf Befunde der Recherche (Abschnitt 0) in einer Liste, je ein Satz, mit Verweis auf die Recherche für Zeilen.
  3. Spec: §8.2 – der Nachbau des et_uploaded_fonts-Eintrags („eigener Sideload, … Nachbau mit Fingerabdruck“) ersetzen durch „Installation über FontsUtility::font_add() im Prozess (action: wp_handle_upload_rest, wie Divis KI-Pfad), Mirror font-install“; Formate woff2, woff, ttf, otf; §8.1 – body_header_style nimmt einen Wert, null setzt auf den Divi-Standard zurück; §10 – Zeile „Farben, Presets schreiben, Restore, Fonts-Upload“ trennen: Fonts-Upload = upload_files + edit_theme_options + Divi-Recht custom_fonts_management; Theme-Optionen = edit_theme_options + theme_customizer.
  4. Entscheidungsnotiz: Abschnitt „Etappe 4d (Plan 2026-10-19-etappe-4d-theme-optionen-schriften.md)“ mit W1–W14 in je einem Satz und der Liste der offenen Folgepunkte: Schriftprüfung in Preset-/Modul-Attributen (nicht Teil von 4d), lit/divi-fonts-list/Schriftsuche (Vorschläge nur in Fehlern), Entfernen hochgeladener Schriften (A2), eot.
  5. 2026-10-18-divi-faehigkeiten.md „Offen“: den Punkt zu DesignArea::feature() für Theme-Optionen als erledigt kennzeichnen (Fähigkeiten theme-option-read/-write, font-upload).
  • Step 2: Suite, Lint, Divi-Prüfung

Run: composer test && composer lint Expected: alle Tests grün (3121 + die neuen), phpcs ohne Befund.

Run: cd /home/janniclas/Projekte/WordPress && ddev exec wp --user=<Admin> lit-mcp divi-check Expected: Exit-Code 0; die drei neuen Fähigkeiten „Theme-Optionen lesen“, „Theme-Optionen schreiben“, „Schriften hochladen“ erscheinen als nutzbar.

  • Step 3: Gesamtreview (zwei unabhängige Opus-Reviews parallel)

Dispatch (zwei Subagents, Modell Opus; je mit dem Paket scripts/review-package PLAN BASE HEAD über den ganzen Plan und den Rulings W1–W14):

  1. Fachreview: Korrektheit gegen Plan und Spec, Sicherheit (SSRF-Pfad über RemoteFileFetcher, Dateityp/Magic Bytes, Größenlimit, Temp-Dateien immer gelöscht, Rechte in permission() UND execute(), Restore mit unlesbarer Snapshot-ID, unset statt 'none', Teilerfolg von font_add, et_update_option-Lost-Update, Cache-Aufrufe ohne angemeldeten Benutzer), Divi-Grenze und Mirror-Fingerabdrücke, Fähigkeitszuordnung je Tool.
  2. anthropic-skills:clean-code-review (Quick) über die neuen und geänderten Klassen; bekannte Folgepunkte aus 4a–4c nur nennen, wenn 4d sie verschlimmert.

Wichtige und größere Befunde in EINER Fix-Welle (Sonnet) beheben, danach EIN scoped Re-Review (Sonnet); Kleinigkeiten in die Ledger-Notiz.

  • Step 4: Memory nachführen

etappe-4-stand.md und MEMORY.md aktualisieren: 4d fertig (HEAD, Testzahl, Datum), nächster Schritt Plan 4e (Schleifen-Inhalte G12, Guides divi-design-system/divi-shortform/workflows, Security-Audit, Version); aus 4d offene Punkte anhängen.

  • Step 5: Abschluss

git status sauber; nichts pushen oder mergen ohne Auftrag. Dem Nutzer berichten: Testzahl, Smoke-Ergebnisse, Befunde und Rulings, und fragen, ob gemergt/gepusht werden soll.


Anhang: Abweichungen der Task-Autoren vom Schnittstellenvertrag (geprüft, im Plan berücksichtigt)

Die Autoren haben ihre Tasks in Kopien des Repos mit Stand-ins für fremde Tasks ausgeführt. Verbindlich bleibt der Taskinhalt; bei Zählungen (Tests, Adapter, Fingerabdrücke) gilt beim Umsetzen der echte Stand.

Tasks 1-2

  • B1 (Ergänzung des Vertrags): ThemeOptionsAdapter bekommt zusätzlich public const SANITIZERS (Whitelist-Schlüssel in W2-Reihenfolge => Name des Divi-/WordPress-Sanitizers). Grund: Task 2 kommt vor ThemeOptionSpec (Task 3), read()/write() brauchen die Whitelist, und die Zuordnung Schlüssel → Sanitizer ist Divi-Wissen, das an die Divi-Grenze gehört (der Vertrag gibt ThemeOptionSpec::definition() kein Sanitizer-Feld). Damit stehen die 20 Schlüssel zweimal (SANITIZERS, ThemeOptionSpec::KEYS); Task 3 sollte in ThemeOptionSpecTest self::assertSame(ThemeOptionSpec::KEYS, array_keys(ThemeOptionsAdapter::SANITIZERS)) aufnehmen (die Konstante zu lesen ruft kein Divi; ThemeOptionSpec selbst bleibt rein).
  • B2 (Signatur beibehalten, Parameter ungenutzt): DesignRights::themeOptions(DesignSystemWriter $writer, ThemeOptionsAdapter $options) behält die Vertrags-Signatur, fragt $writer aber nicht: die Rechte für Theme-Optionen sind laut Global Constraints edit_theme_options + et_pb_is_allowed('theme_customizer'), kein Visual Builder (Divis Customizer fragt ihn nicht). Der Test pinnt das (canUseVisualBuilder never()). Den Parameter zu streichen hätte die Aufrufe der parallel geplanten Tasks 4/6 gebrochen; Task 11 (Gesamtreview) kann ihn samt Aufrufern entfernen.
  • B3 (zusätzliche Testdatei): tests/Unit/Integrations/Divi/Cli/DiviCheckCommandTest.php ist nicht in der Dateistruktur des Plans genannt, muss aber mit dem neuen Nachbau angepasst werden (Temp-Theme-Stand-in für functions.php, „7 Fingerabdrücke“) – Step 9.
  • B4 (Tests ohne Mockery-Alias): Die Adapter-Tests brauchen kein Mockery::mock('alias:…'): alle Divi-Symbole sind Funktionen (Brain Monkey in eigenem Prozess) bzw. der vorhandene Stub ET_Core_PageResource. Die Global Constraint „Adapter-Tests ersetzen Divi-Klassen per Alias“ greift erst bei FontInstaller (Task 8, FontsUtility/HooksRegistration).
  • B5 (Grenzen für Task 11/Notiz): FontCatalog::reserved() enthält Google-Schriften nur, wenn Google Fonts eingeschaltet sind (et_builder_get_google_fonts() liefert sonst [], Divis font_add prüft ebenso) – ein Upload namens „Roboto“ ist bei abgeschalteten Google Fonts möglich. names() ist je Instanz gecacht; eine im selben Request hochgeladene Schrift ist darin erst mit einer neuen Instanz sichtbar (Upload und Schriftprüfung laufen in getrennten Requests).

Tasks 3

  • check() lehnt none (ohne Beachtung der Schreibweise) bei Schrift-Schlüsseln ab. Der Auftrag nennt es nicht, W3 verlangt aber „'none' und false werden abgelehnt“; check() ist die einzige Stelle ohne Divi-Aufruf, die das kann. Meldung: „… none ist kein Schriftname; null setzt auf Divis Standardschrift zurück.“ Außerdem wird ein Schriftname vor der Prüfung getrimmt (" Lato " ist gültig), normalize() liefert den getrimmten Namen.
  • definition() enthält für tablet_*/phone_* den Standard 14 bzw. 30 (was Divis CSS-Leser bei gar nichts Gespeichertem annehmen) plus inherits; der Standard des Customizers ist dynamisch (et_get_option('body_font_size', '14')). effective() nutzt inherits zuerst, der feste Standard greift nur am Ende der Kette. body_header_style und die vier Schriften haben Standard null (Divi: '' bzw. false/'none'), heading_font_weight/body_font_weight '500' als String, body_header_height 1.0 (float).
  • Schrift-Stärke (weight) akzeptiert in check() auch eine Ganzzahl (500), normalize() liefert immer den String ('500'), weil Divi so speichert. Ein Float (500.0) wird abgelehnt.
  • effective() wertet einen gespeicherten Wert nicht gegen die Bereiche (Divi klemmt auch nicht; ein alter Wert 40 für body_font_size bleibt 40) und behandelt false, null, 'none', leere Namen und Nicht-Zahlen als „nicht gesetzt“; gespeicherte Ziffern-Strings werden in den Typ des Schlüssels gewandelt. Task 4 darf deshalb effective() direkt auf das Ergebnis von ThemeOptionsAdapter::read() anwenden.
  • ThemeOptionBook::with() wirft InvalidArgumentException bei einem Schlüssel außerhalb der Whitelist (Task 4 prüft vorher mit isKey()); fromDivi() ignoriert sie dagegen stillschweigend (defensiv, Auftrag). with($key, false) ist erlaubt (Divis Standardschrift im Snapshot).
  • FontSuggest ist bytebasiert beim Abstand (levenshtein kennt kein UTF-8): bei Umlauten zählt ein Zeichen als zwei Abstandseinheiten; die Rangfolge Präfix/Teilstring nutzt mb_* und ist unberührt. Die Eingabeliste wird dedupliziert, der Name getrimmt; leerer Name oder $limit < 1 ergeben [].
  • Keine Konflikte mit Divi gefunden: alle geprüften Bereiche, Schritte und Standards (Zeilen 1903–2561 von functions.php) stimmen mit Tabelle A überein. Risiko: Bereiche und Standards sind Literale – ändert Divi sie, schlägt nur der Mirror customizer-typography (Task 2) an, nicht dieser Test.

Tasks 4-6

  1. DiviThemeOptionsGetAbility::__construct(ThemeOptionsAdapter $options) – ohne ?FontCatalog (Vertrag: (ThemeOptionsAdapter, ?FontCatalog = null)). Das Lesetool braucht die Schriftliste nicht. Sie kostet 1,3 MB, und ihre Aufrufe tragen laut W1 theme-option-write. Mit ihr bräuchte das Lesetool also eine Schreibfähigkeit. Task 9 verdrahtet mit einem Argument.
  2. Neue Klasse FontCheck (nicht in der Dateistruktur): Sie hält die gemeinsame Meldung „unbekannte Schrift … ähnlich: …“ für Theme-Optionen (Task 4) und Schrift-Variablen (Task 5), damit die Meldung nicht doppelt gepflegt wird. Sie ist rein bis auf die übergebenen FontCatalog-Methoden.
  3. ThemeOptionsArea hat zusätzlich public const SITE_WIDE und public static same(). Beide sind gemeinsame Bausteine für das Update-Tool und den Restore-Planer.
  4. Reihenfolge der Prüfung bei den vier Schriftschlüsseln: erst ThemeOptionSpec::check, dann FontCatalog mit Vorschlägen, dann Divis Sanitizer. Der Auftrag nannte den Sanitizer vor der Schriftliste. et_sanitize_font_choices liefert bei unbekannten Namen aber nur false, und die Vorschläge kämen dann nie.
  5. 'none'/false lehnt das Update-Tool selbst ab, vor ThemeOptionSpec::check, mit dem Hinweis auf null. Lehnt auch check() (Task 3) sie ab, gewinnt die Meldung des Tools. Das ist kein Konflikt.
  6. Annahmen an Task 3, auf die sich die Tests stützen:
    • ThemeOptionBook::fromDivi() und ThemeOptionDiff::between() liefern Schlüssel bzw. Zeilen in der Reihenfolge von ThemeOptionSpec::KEYS. Die Diff-Reihenfolge legt der Vertrag nicht fest, die Tests vergleichen sie aber.
    • definition()['type'] ist bei den Größen 'int', und min/max von body_font_size sind 10/32 (int).
    • check() lehnt 'bold' (Stil), 99 (Textgröße) und 'breit' (Spaltenabstand) ab und nimmt '18' an.
    • effective() vererbt W3-gemäß: Ist tablet_body_font_size nicht gesetzt, gilt der wirksame body_font_size. Weicht Task 3 ab, gehören die Fixtures angepasst, nicht der Code.
  7. Annahmen an Task 2:
    • DesignRights::themeOptions() fragt current_user_can('edit_theme_options') und ThemeOptionsAdapter::canCustomize(). Die Test-Fakes erlauben zusätzlich DesignSystemWriter::canUseVisualBuilder() (→ true). Nutzt Task 2 weitere Methoden, braucht der Fake ein allows().
    • ThemeOptionsAdapter::write() liefert das Zurückgelesene. Seine eigene strenge Prüfung (RuntimeException) bleibt. ThemeOptionsArea::verify() ist die zweite Prüfung, auch für Restore und Rollback.
    • Für die minimale Änderung in DiviIntegration (Task 5) nehme ich an, dass FontCatalog sich ohne Argumente bauen lässt. Hat sein Konstruktor Parameter, gelten dort die Argumente aus Task 2.
  8. ThemeOptionsRestorePlanner::__construct(DesignSystemWriter, ThemeOptionsAdapter). Diese Signatur legt der Vertrag nicht fest. Sie folgt dem Muster der Farb-/Variablen-Planer, und Task 9 verdrahtet sie.
  9. Hauptfarben in theme-options-get: Es gibt nur einen Hinweis in notes, keine Werte. Das Tool braucht dafür also keinen neuen Leser im Adapter.

Tasks 7-8

  • D1 RemoteFileFetcher-Schnittstelle: allows(string): bool und fetch(…): ?array (null = fehlgeschlagen, Grund nur im Log) statt einer Methode mit WP_Error. Grund: die Meldungen an den Client unterscheiden sich (Medien validation_failed mit url wie bisher, Schriften validation_failed/font_fetch_failed mit files.<Format>); die Content-Length-Vorabprüfung bleibt medienbezogen in MediaImporter (Schriften prüfen die Dateigröße nach dem Abschneiden bei Limit + 1). MediaImporter behält seinen Konstruktor und erzeugt den Fetcher selbst – keine Verdrahtungsänderung, MediaImporterTest unverändert.
  • D2 FontNameRules::check() hat einen optionalen dritten Parameter int $maxLength = 100; die Ability übergibt FontInstaller::nameMaxLength() (= FontsUtility::FONT_NAME_MAX_LENGTH), so wird die Divi-Konstante wirklich gelesen und nicht nur im Manifest genannt. Zusätzlich FontNameRules::taken() (öffentlich), damit die Ability einen belegten Namen als conflict (409) statt als Validierungsfehler meldet; check() mit gefülltem $reserved meldet die Kollision weiterhin als Text (Vertrag).
  • D3 FontInstaller zusätzlich: public const FORMATS und nameMaxLength(). formats() schneidet FORMATS mit FontsUtility::mime_types_font() (W8), nicht mit et_pb_get_supported_font_formats() (Standard nur ttf/otf) – woff/woff2 sind nach W8 gewollt; das Manifest nennt diese Funktion deshalb nicht.
  • D4 Teilerfolg: install() entfernt bei Teilerfolg nicht selbst, sondern liefert die gespeicherten Endungen (Vertrag @return list<string> installierte Endungen); die Ability vergleicht und ruft remove() über Rollback::attempt() (W8: „Abweichung → font_remove und Fehler“). Wirft install(), ist nichts gespeichert (Divi speichert erst nach mindestens einer erfolgreichen Datei, Vorprüfung aller Dateien vor font_add) – dann kein remove().
  • D5 DiviCheckCommandTest ist betroffen (nicht im Dateiplan genannt): der neue Mirror braucht eine Minimal-Quelle im Wegwerf-Theme, die Zahl der Fingerabdrücke steigt. Fügt Task 2 den Mirror customizer-typography hinzu, ändert Task 2 dieselbe Stelle; Step 12 rechnet relativ dazu.
  • D6 Fehlercodes: font_fetch_failed und font_invalid mit Status 400 und fields; hat ein Aufruf beide Arten, gilt font_fetch_failed mit allen Feldern. Größenüberschreitung ist font_invalid („größer als N MB“).
  • D7 Protokoll-Objekttyp divi_font: src/Admin/LogObjects.php kennt ihn nicht und zeigt ihn als Code (divi_font Inter Display); ein Label kann Task 11 ergänzen.
  • Prüflauf: im Arbeitsstand des Planers mit Platzhaltern für DiviFeature::FontUpload (inkl. angepasstem DiviFeatureTest), FontCatalog (ohne Divi) und DesignRights::fonts(); fingerprints.json und font-install.txt dort aus den Divi-5.15.1-Quellen mit DiviReference/SourceFingerprint erzeugt (nicht über DDEV). Die RED/GREEN-Zahlen oben stammen aus diesen Läufen.