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

7384 lines
372 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.
```php
// 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
<?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
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
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
<?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
<?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
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
<?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
<?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
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
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
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
<?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:
```bash
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:
```bash
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)**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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**
```bash
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
<?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
<?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
<?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
<?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**
```bash
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;`):
```php
use LIT\MCP\Integrations\Divi\Design\FontCatalog;
```
2. In `ability()` den Parameter `bool $manage = true` ersetzen durch
```php
bool $manage = true,
?FontCatalog $fonts = null
```
und im Konstruktoraufruf nach `$usages ?? self::usages(self::unused()),` einfügen:
```php
$fonts ?? FontCheckTest::catalog(),
```
3. Am Ende der Klasse (vor der letzten `}`) anfügen:
```php
/** 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:
```php
* 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
```php
* 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.
*/
```
2. Konstruktor: nach `private GlobalUsages $usages,` einfügen
```php
private FontCatalog $fonts,
```
3. In `apply()` die Zeile `'value' => self::value($type, $item['value']),` ersetzen durch
```php
'value' => $this->value($type, $item['value']),
```
4. `value()` wird eine Instanzmethode mit der Listenprüfung – den Anfang der Methode ersetzen:
```php
/** @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).
5. `src/Integrations/Divi/Design/VariableValue.php`, Docblock der Klasse:
```php
* (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
```php
* (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.
```
6. `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
```php
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.
```bash
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
<?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
<?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.
```bash
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;`):
```php
use LIT\MCP\Integrations\Divi\Design\ThemeOptionBook;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsAdapter;
use LIT\MCP\Integrations\Divi\Design\ThemeOptionsRestorePlanner;
```
2. `testUnknownSnapshotAndThemeOptions()` ersetzen durch die zwei Tests:
```php
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']);
}
```
3. `allAreas()` bekommt den Theme-Options-Planer – die ganze Methode samt neuer Hilfe `optionStore()` ersetzen:
```php
/** 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“.)
4. Vor `testRightsFollowTheSnapshotArea()` den Restore-Test einfügen und den Rechte-Test ersetzen:
```php
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');
}
```
5. `testFeaturesFollowTheSnapshotArea()` ersetzen:
```php
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:
```php
* the plan. Areas without a planner (theme_options until Etappe 4d) are not restorable yet.
```
ersetzen durch
```php
* the plan. A snapshot of an area without a planner is refused as unsupported, never treated as a no-op.
```
2. In `description()` das Ende ab `Bereiche:` ersetzen:
```php
. '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).';
```
3. Der `unsupported`-Zweig in `execute()`:
```php
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.
```bash
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()`):
```php
// 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
<?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
<?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**
```diff
--- 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.
```bash
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):
```php
// 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
<?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
<?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
<?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
<?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.
```bash
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
<?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
<?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:
```php
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:
```php
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):
```diff
--- 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.
```bash
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:
```bash
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.
```bash
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
<?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
<?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.
```bash
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:
```php
'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):
```php
'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:
```php
'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):
```php
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`:
```php
private ThemeOptionsAdapter $themeOptionsAdapter;
private FontCatalog $fontCatalog;
private FontInstaller $fontInstaller;
```
Im Konstruktor nach `$this->variablesAdapter = new GlobalVariablesAdapter();`:
```php
$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):
```php
new ThemeOptionsRestorePlanner($this->designWriter, new ThemeOptionsAdapter())
```
nachher:
```php
new ThemeOptionsRestorePlanner($this->designWriter, $this->themeOptionsAdapter)
```
Vorher (Variablen-Tool, letztes Konstruktorargument vor `$random`):
```php
new FontCatalog()
```
nachher:
```php
$this->fontCatalog
```
Nach dem Eintrag `new DiviVariablesSetAbility( … ),` anfügen:
```php
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**
```bash
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`:
```bash
#!/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**
```bash
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.