diff --git a/docs/superpowers/plans/2026-10-19-etappe-4d-theme-optionen-schriften.md b/docs/superpowers/plans/2026-10-19-etappe-4d-theme-optionen-schriften.md index 3615a54..6f6d52b 100644 --- a/docs/superpowers/plans/2026-10-19-etappe-4d-theme-optionen-schriften.md +++ b/docs/superpowers/plans/2026-10-19-etappe-4d-theme-optionen-schriften.md @@ -153,4 +153,7231 @@ public function __construct(ThemeOptionsAdapter $options); --- - +### 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 + '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-, indirect lit/): $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[]`, 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('/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(, 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` – 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: ')`. `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: ')`; 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` – Schlüssel von `et_builder_get_custom_fonts()` dann `et_builder_get_fonts()` (genau Divis Sanitizer-Liste), je Instanz einmal geladen; `uploaded(): list` und `reserved(): list` (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 +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 +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 + 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 + */ + 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 $set Checked values (ThemeOptionSpec, sanitize()) or raw values + * of a snapshot. + * @param list $unset + * @return array 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 +|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 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 */ + public function uploaded(): array + { + return self::namesOf(et_builder_get_custom_fonts()); + } + + /** @return list 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 + */ + 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 + 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 $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:": ""`); `?? 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 " +``` + +--- + + +### 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, 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 + */ + 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 */ + 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 */ + 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', '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 + '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, 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 $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 $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 $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 " +``` + +#### 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 + 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 + $data */ + private function __construct(private array $data) + { + } + + /** @param array $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 */ + 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 " +``` + +#### 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 +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 + + * 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> $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 " +``` + +#### 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 + $names + * @return list 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 " +``` + +#### 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 $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: {: Wert|null, …}, confirm?, design_rev?}`. `set` muss ein nicht leeres Objekt sein. Gesammelt mit Pfad `set.`: 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 + + */ + 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 $stored + * @param list, 1: list}> $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 +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 " +``` + +#### 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 +justReturn(true); + } + + private static function ability(): DiviThemeOptionsGetAbility + { + $stored = ThemeOptionsAreaTest::stored(); + $writes = []; + + return new DiviThemeOptionsGetAbility(ThemeOptionsAreaTest::adapter($stored, $writes)); + } + + /** + * @param array $result + * @return array> 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 + '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 $input + * @return list|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 " +``` + +#### 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 +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 + What the adapter stores (ThemeOptionsAreaTest::adapter()). */ + private array $stored = []; + + /** @var list, 1: list}> */ + 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 $input + * @return array{0: array, 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 +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 +. 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 $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 $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> $diff + * @return list + */ + 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 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 " +``` + +#### 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 " +``` + +--- + +### 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, )` 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 wiederherstellen – `. 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 + 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 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 +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 " +``` + +#### 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 $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 " +``` + + +### 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|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 +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 $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 + Host name => IPv4 addresses. */ + private Closure $resolve; + + /** @param (Closure(string): list)|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|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 Host name => IPv4 addresses. */ +- private Closure $resolve; ++ private RemoteFileFetcher $fetcher; + + /** @param (Closure(string): list)|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|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 " +``` + +--- + +### 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', , …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 + '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 + $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 $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 " +``` + +#### 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 + false, 'type' => false, 'proper_filename' => false]; + + /** @var list */ + 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 + 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 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 $files Extension => temp file path (deleted or moved by WordPress). + * @return list 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 ( Fingerabdrücke, Signaturen). …` mit `` = Zahl der Mirrors nach Task 2 + 1 und `` = 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 " +``` + +#### 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 + */ + private array $tempFiles = []; + + /** @var array URL => body the fake fetcher writes (null = download fails). */ + private array $bodies = []; + + /** @var list */ + private array $fetches = []; + + /** @var array 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 $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 */ + 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] = '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 + '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 $errors + * @return array 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 $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 $urls + * @return array|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 $urls + * @param list $temps Gets every temp file created (the caller deletes them). + * @return array|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 $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 " +``` + + +### 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/`), 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 " +``` + +(`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 : 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) : '';" +} + +# 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)" = "" ] || 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= 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 " +``` + +### 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= 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.`); 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 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. +