Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
8828 lines
391 KiB
Markdown
8828 lines
391 KiB
Markdown
# Etappe 3b – Theme Builder, ähnliche Abschnitte, globale Module umwandeln – Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Der KI-Assistent baut Theme-Builder-Vorlagen für Inhaltstypen: Vorlagen, Bedingungen und Layouts lesen (`lit/tb-list`), Vorlagen mit Kopf-, Inhalts- und Fußbereich anlegen – immer **deaktiviert** (`lit/tb-create-template`) –, Bedingungen setzen (`lit/tb-assign`), ausdrücklich aktivieren oder deaktivieren (`lit/tb-activate`), die Layouts mit den vorhandenen Divi-Werkzeugen bearbeiten (Body-Layouts mit Dynamic Content, auch Pods-Feldern); dazu ähnliche Abschnitte finden (`lit/library-find-similar`), Knoten in globale Module umwandeln und inhaltsgleiche Vorkommen ersetzen (`lit/library-make-global`), die aus 3a verschobenen Punkte P13 (Bibliotheks-IDs vollständig ausblenden) und P10 (gleiche Zielregeln für rohe `post_link_url_*`), Guides, Readme, Version 0.5.0, Smoke-Skript und Abnahme.
|
||
|
||
**Architecture:** Divis Theme Builder liegt hinter dem Adapter `DiviThemeBuilder` (Lesen über Divis Funktionen; Anlegen von Layouts und Vorlagen über `et_theme_builder_insert_layout`/`et_theme_builder_store_template` mit eigener Rechteprüfung davor und einer `wp_die`-Sperre währenddessen; Zuordnung zum Live-TB-Post, Bedingungen und Aktiv-Schalter als genaue Nachbildung von Divis Meta-Schreibweise). `ThemeBuilderAccess` bündelt die Rechte je Objekt (`edit_others_posts` + Divi-Rolle `theme_builder` + `edit_post`) und die Gates der neuen Tool-Gruppe „Divi Theme Builder“; `DiviPageLoader` nimmt darüber TB-Layouts an, sodass alle Divi-Werkzeuge sie bearbeiten. `ThemeBuilderEdits` ergänzt `DiviWriter::commit()` für Layouts, die Besucher sehen (Probelauf + `confirm`). Jeder Weg nach `post_content` bleibt `Composer`/`TreeEditor` → `DiviSaver`; übernommene gespeicherte Knoten prüft `StoredNodeGuard`. Reine Fingerabdrücke (`Fingerprint`) tragen `lit/library-find-similar`, die Schreibwarnung „ähnlicher Abschnitt“ und `lit/library-make-global`.
|
||
|
||
**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, `wp_insert_post`/`wp_update_post`, Revisionen, Post-Meta, kses), Divi 5.2.1 (`et_theme_builder_*`, `et_pb_is_allowed`, `DynamicContentOptions`, `ModuleRegistration`, `ET_Core_PageResource`), Pods 3.x (nur für Fixtures, Smoke und Abnahme), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes, headless Chrome (DevTools-Protokoll) für die Builder-Fixtures.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2 Prinzip 5, §4.3, §4.5, §5.1, §5.5, §5.6, §5.7 Theme-Builder- und Library-Zeilen, §9, §10, §12 Etappe 3) und `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` (Fakten F14–F32, Empfehlungen E1–E17, Schnittstellen-Einordnung Abschnitt 5, **Antworten des Benutzers „5. Antworten des Benutzers“ verbindlich – insbesondere F1: TB-Vorlagen werden deaktiviert angelegt und nur über ein eigenes Tool aktiviert**, Abschnitt 6 „Umsetzung 3a“), dazu `2026-09-27-etappe-3a-rulings.md` (P1–P25; offen für 3b: P13, P10), `2026-09-27-etappe-3a-security-audit.md` (L1–L6, I1–I3), `2026-09-26-etappe-2b-rulings.md` (S1–S52, v. a. S7, S37, S39), `2026-09-26-etappe-2-entscheidungen.md` (D7, D10) und die Fixtures `tests/Fixtures/divi/stage3/` mit `README.md` (Befunde 1–9).
|
||
|
||
**Voraussetzung:** Branch `etappe-3`, Stand e049959 (Etappe 3a vollständig, 1901 Tests grün, sechs Smokes grün).
|
||
|
||
**Pods:** Pods ist in DDEV deaktiviert. **Pods muss aktiv sein** für Task 1 (Fixtures), Task 17 Schritte 3–5 (Smoke-Abschnitt „Dynamic Content“ und Gesamtlauf) und die Pods-Punkte der manuellen Abnahme. Alle Unit-Tests laufen ohne Pods. Der Controller fragt den Benutzer vor Task 1, ob Pods aktiviert werden darf (`ddev wp plugin activate pods`), und nach Task 17, ob es wieder deaktiviert werden soll.
|
||
|
||
## Entscheidungen dieses Plans
|
||
|
||
1. **Divis `wp_die`-Wege (F17, F18):** Layouts und neue Vorlagen entstehen über Divis eigene Funktionen `et_theme_builder_insert_layout()` und `et_theme_builder_store_template()` (Stufe c, Spec §5.7 nennt sie). Sie rufen `wp_die()` ohne `edit_others_posts` – das Plugin prüft das Recht vorher selbst (`ThemeBuilderAccess::canCreate()` und nochmals im Adapter) und ersetzt während des Aufrufs jeden `wp_die`-Handler durch einen, der eine Ausnahme wirft (`DiviThemeBuilder::guarded()`; Verteidigung in der Tiefe). Nachgebildet werden nur die Schreibwege, die im REST-Kontext fehlen oder zu viel ändern würden: die Zuordnung zum Live-TB-Post (`et_theme_builder_api_save`, `api.php:223-243`, nur admin-ajax), Bedingungen (`theme-builder.php:1565-1583`) und der Aktiv-Schalter – `store_template` würde bei einer bestehenden Vorlage zusätzlich Standard-Kennzeichen, Titel und Onboarding-Meta neu schreiben. Nach dem Anlegen liest das Plugin die Vorlage zurück; weicht sie ab (Divi verwirft Layout-IDs stillschweigend), wird alles zurückgenommen.
|
||
2. **Divis TB-Editor löscht unsere Vorlagen nicht:** (a) Solange ein TB-Entwurf existiert (`auto-draft`, ungespeicherte Sitzung im TB-Editor), lehnen alle schreibenden TB-Tools ab (E7). (b) Neue Vorlagen hängen sofort an der Live-Liste `_et_template` – ein danach geöffneter TB-Editor kennt und behält sie; ihre Layouts sind referenziert und werden nie als ungenutzt markiert. (c) Das Plugin ruft nie `et_theme_builder_trash_draft_and_unused_posts()` auf und fasst den Entwurfs-TB-Post nie an. (d) Restfall „TB-Editor war vor dem Anlegen schon offen und speichert danach“: jede Anlege-Antwort warnt; `lit/tb-list` zeigt solche Vorlagen unter `detached` mit Markierungsdatum; `lit/tb-assign` mit `reattach: true` ordnet sie ausdrücklich wieder zu – immer deaktiviert, Markierungen „ungenutzt“ entfernt. Task 1 prüft den Restfall am echten TB-Editor (Befund 10); bestätigt er sich nicht, bleibt die Vorsorge trotzdem (harmlos).
|
||
3. **Keine Aufteilung von 3b in zwei Pläne.** 17 Tasks wie 3a; die Library-Grundlagen aus 3a sind stabil, TB- und Library-Teil berühren getrennte Dateien. Nach Task 11 (Theme Builder vollständig) ist ein **Zwischenhalt**: Controller-Review des TB-Teils und – falls der Benutzer es wünscht – vorgezogene TB-Abnahme; Tasks 12–15 (Konsolidierung) hängen nicht davon ab.
|
||
4. **Exposition:** neue Gruppe **„Divi Theme Builder“** (`divi-theme-builder`) mit `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate` – nicht die Gruppe „Divi“, damit ein Betreiber den Theme Builder getrennt abschalten oder auf „nur lesen“ stellen kann; wie bei „Divi-Bibliothek“ (P9) sperrt die Gruppe auch Schreibzugriffe der Divi-Werkzeuge auf TB-Layouts, und „deaktiviert“ lässt TB-Layout-IDs in den Divi-Werkzeugen wie fehlende IDs erscheinen. `lit/library-find-similar` und `lit/library-make-global` gehören zur Gruppe „Divi-Bibliothek“. Alle sechs neuen Tools sind **indirekt**; es bleiben 22 direkte Tools.
|
||
5. **Sichtbare Website nur mit ausdrücklicher Freigabe:** Neue Vorlagen sind deaktiviert (F1). Aktivieren/Deaktivieren nur mit `lit/tb-activate` (Probelauf, dann `confirm: true`). Jede andere Änderung, die Besucher sofort sähen – Bedingungen einer aktiven Vorlage (`lit/tb-assign`), Inhalt eines Layouts, das eine aktive Vorlage zeigt (alle Divi-Werkzeuge) –, ist zuerst ein Probelauf und braucht `confirm: true` (wie globale Module, Antwort F2). Offener Punkt für den Benutzer: statt `confirm` strikt ablehnen (siehe Zusammenfassung).
|
||
|
||
## Global Constraints
|
||
|
||
- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte und Fixture-Erzeugung schreiben nur in die DDEV-Datenbank und räumen temporäre Seiten, Personen, Vorlagen, Layouts, Benutzer, Rollen, die TB-Zuordnung und die Optionen `lit_mcp_allowed_roles`/`lit_mcp_exposure` wieder auf. `[Fixture]`/`[Fixture 3b]`-Inhalte werden nach ihrer Erzeugung nur gelesen.
|
||
- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.2.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber.
|
||
- `src/Integrations/Divi/BlockTree/*` und `src/Integrations/Divi/Compose/*` bleiben reines PHP ohne WordPress-Aufrufe. WordPress/Divi nur in `Divi/Pages/`, `Divi/Library/`, `Divi/ThemeBuilder/`, `Divi/Design/`, `Divi/Schema/` und `DiviCompatibility`.
|
||
- **Eine Engstelle nach `post_content` (D7, S39, E17):** Jeder Schreibweg in `post_content` – Seiten, Bibliothekseinträge und TB-Layouts – geht über `Composer`/`AttrWriter`/`ShortformWriter`/`ValueCleaner` (Client-Werte) bzw. `TreeEditor`/`Container::replaceChild()` (Struktur) und wird ausschließlich von `DiviSaver::save()`/`saveNew()` gespeichert (kses-Fenster nur dort). Kein `post_content` über `et_theme_builder_insert_layout`, `wp_insert_post` oder Divis Builder-Routen. Abilities rufen `setAttrs()`/`insertChild()` nie mit Client-Werten auf.
|
||
- **StoredNodeGuard für übertragene Inhalte (E6, P3, P15–P19):** Gespeicherte Knoten, die den Post wechseln – Seite → Bibliothek (`lit/library-make-global`), Bibliothek → Seite (Instanz-Schnappschuss beim Ersetzen), Seite/Bibliothek ↔ TB-Layout (`lit/divi-node-copy`, `lit-library-insert`) – laufen durch `StoredNodeGuard` der Ziel-Komposition; für Benutzer ohne `unfiltered_html` wird abgelehnt statt bereinigt.
|
||
- **Rechte je Objekt (Spec §4.5, E3 – nie lockerer als Divi):** Theme Builder lesen und schreiben = `edit_others_posts` + Divi-Rolle `theme_builder` (über `DiviRoles`), dazu `edit_post` auf jede berührte Vorlage bzw. jedes Layout; Anlegen zusätzlich `publish_posts` der Typen `et_template`, `et_header_layout`, `et_body_layout`, `et_footer_layout`. Ohne `theme_builder`/`edit_others_posts` oder mit deaktivierter Gruppe sind TB-IDs für alle Werkzeuge `not_found` wie fehlende IDs (P12-Muster). Bibliotheksrechte bleiben wie 3a (`divi_library`, `save_library`, `add_library`, `edit_global_library`, `edit_post`, `publish_posts`). Bedingungen mit Objekt-IDs verlangen Leserechte auf das Objekt (`read_post`, vorhandener Begriff, `list_users`).
|
||
- **Sperren, Alles-oder-nichts, Byte-Identität:** Divi-Schreibungen verlangen `tree_rev` (P16), TB-Vorlagen-Schreibungen `template_rev` (aus `lit/tb-list`), Ersetzen mehrerer Seiten `tree_rev` je Seite. Abweichung → `rev_mismatch`, nichts geschrieben. Erst vollständig validieren, dann schreiben; beim Anlegen einer Vorlage werden bei jedem Fehler alle neu angelegten Layouts, die Vorlage und die Zuordnung wieder entfernt; `lit/library-make-global` löscht den neuen Bibliothekseintrag, wenn die Seite nicht gespeichert werden kann. Unberührte Bytes bleiben identisch (Spec §1.3 Nr. 3, Ausnahme S7). Mehrere Seiten beim Ersetzen: alle vorab geprüft, jede Seite mit eigener Revision; ein Fehler mittendrin nennt die schon geänderten Seiten (D10).
|
||
- **Sichtbare Website nie ohne ausdrückliche Freigabe:** neue Vorlagen `_et_enabled = 0`; nur `lit/tb-activate` schaltet `_et_enabled`; jede andere Änderung an etwas, das Besucher sehen (aktive Vorlage, Layout einer aktiven Vorlage), erst Probelauf, dann `confirm: true`. Die Standardvorlage (`_et_default = 1`) ändert kein Tool. Nie der TB-Entwurf, nie `et_theme_builder_trash_draft_and_unused_posts()`.
|
||
- **Exposition:** Gruppe „Divi Theme Builder“ (`divi-theme-builder`): `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate`; Gruppe „Divi-Bibliothek“ zusätzlich `lit/library-find-similar`, `lit/library-make-global`. Alle indirekt, weiterhin 22 direkte Tools. „nur lesen“/deaktiviert der TB-Gruppe sperrt auch Schreibzugriffe der Divi-Werkzeuge auf TB-Layouts (`ExposurePolicy::groupWritable('divi-theme-builder')`); deaktiviert blendet TB-Layouts aus (`groupEnabled`). `lit/library-make-global` folgt zusätzlich `lit/divi-insert` (Audit M2). Texte nennen direkte Tools `lit-<name>`, indirekte `lit/<name>` (ToolNamingTest); `topic "<x>"` nur für vorhandene `docs/guides/<x>.md`.
|
||
- **Sprache und Wortwahl:** Oberfläche, Fehlermeldungen, Hinweise, Guides, Readme und Abnahme auf Deutsch mit echten Umlauten (ä, ö, ü, ß). Neutral formulieren: „KI-Assistent“, „MCP-Client“, „Verbindung“ – keine Chatbot-Marken in UI, Guides, Readme oder Tool-Texten.
|
||
- Tests: Unit-Tests der Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen. Reine BlockTree-/Compose-Tests erben von `PHPUnit\Framework\TestCase`. Einzelne Tests: `vendor/bin/phpunit --filter <Name>` (die `memory_limit` setzt `phpunit.xml.dist`). Integrationstests: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh`.
|
||
- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "<Nachricht>" -m "Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"` (im Folgenden „Commit wie in den Global Constraints“).
|
||
|
||
## Review Focus
|
||
|
||
1. **Die Website ändert sich ohne ausdrückliche Freigabe:** ein Layout, das eine aktive Vorlage zeigt (auch die globale Kopfzeile der Standardvorlage), wird mit `lit-divi-update`/`lit-divi-delete` geändert, oder die Bedingungen einer aktiven Vorlage werden ersetzt, oder eine neu angelegte Vorlage wäre aktiv. Erwartet: neue Vorlagen `enabled=false`; alles andere zuerst `confirm_required` mit den betroffenen Vorlagen, nichts gespeichert → Tests `ThemeBuilderEditsTest::testLiveLayoutNeedsConfirmAndNamesTheTemplates`, `DiviWriterThemeBuilderTest::testDeleteInALiveLayoutAsksOnceForBoth` (Task 5), `TbCreateTemplateAbilityTest::testNewTemplateIsDisabledAndAttached` (Task 8), `TbAssignAbilityTest::testActiveTemplateNeedsConfirm` (Task 9), `TbActivateAbilityTest::testActivationIsAlwaysADryRunFirst` (Task 10).
|
||
2. **Theme-Builder-Editor und Entwurf:** eine ungespeicherte TB-Sitzung existiert, oder ein zuvor geöffneter TB-Editor hat die Vorlagenliste überschrieben. Erwartet: Anlegen/Zuordnen/Aktivieren abgelehnt, solange ein Entwurf existiert; entfernte Vorlagen erscheinen unter `detached` mit Markierungsdatum und lassen sich nur ausdrücklich (`reattach`) und deaktiviert wieder zuordnen → Tests `TbCreateTemplateAbilityTest::testRefusesWhileTheThemeBuilderHasADraft` (Task 8), `TbListAbilityTest::testReportsDetachedAndMarkedTemplates` (Task 7), `TbAssignAbilityTest::testReattachKeepsTheTemplateDisabledAndUnmarksItsLayouts` (Task 9).
|
||
3. **Rechte und Bedingungen:** Redakteur mit `edit_others_posts`, aber ohne Divi-Rolle „Theme Builder“; Gruppe „nur lesen“/deaktiviert; Bedingung mit fremder privater Seiten-ID, erfundener ID-Form oder Steuerzeichen. Erwartet: TB-IDs `not_found`, Schreiben `forbidden`, unzulässige Bedingungen `validation_failed`, keine Meta geschrieben → Tests `ThemeBuilderAccessTest` (Task 4), `DiviPageLoaderTest::testThemeBuilderLayoutsNeedTheThemeBuilderRole` (Task 5), `TemplateConditionsTest::testRefusesUnknownUnreadableAndMalformedConditions` (Task 6).
|
||
4. **Fehlschlag mitten im Anlegen:** Layout angelegt, dann scheitert `store_template`, Divi verwirft eine Layout-ID, das Anhängen an die Live-Liste scheitert, oder Divi ruft `wp_die()`. Erwartet: alle neuen Posts gelöscht, Zuordnung entfernt, `write_failed`; scheitert das Aufräumen, ehrliches `partial_write` mit den IDs → Tests `TemplateBuildTest::testRollsBackEveryCreatedPostInReverse`, `TemplateBuildTest::testAFailedDeleteIsAPartialWrite`, `TbCreateTemplateAbilityTest::testFailedTemplateStoreDeletesTheLayouts`, `TbCreateTemplateAbilityTest::testDiviDroppingALayoutIsRolledBack` (Task 8), `DiviThemeBuilderTest::testWithoutEditOthersPostsDiviIsNeverCalled`, `::testDiviIsOnlyCalledWithTheRightAndWithoutWpDie` (Task 4).
|
||
5. **Globale Module aus Seiten erzeugen und ersetzen:** Redakteur ohne `unfiltered_html` macht einen Knoten mit Code-Modul/eigenem CSS global; Ziele zum Ersetzen sind nicht inhaltsgleich, veraltet (`tree_rev`), nicht bearbeitbar oder ein Bibliothekseintrag; die Seite scheitert nach dem Anlegen des Eintrags. Erwartet: Ablehnung ohne Schreiben bzw. neuer Eintrag wieder gelöscht → Tests `LibraryMakeGlobalAbilityTest::testEditorCannotMakeACodeModuleGlobal`, `::testFailedPageSaveDeletesTheNewEntry` (Task 14), `::testTargetsMustBeIdenticalEditableAndCurrent` (Task 15).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
wp-lit-mcp.php, src/Plugin.php (geändert) Version 0.5.0; Gates und Verdrahtung Theme Builder
|
||
readme.txt (geändert) Theme Builder, find-similar, make-global, Changelog 0.5.0
|
||
docs/guides/
|
||
theme-builder.md (neu) Thema „theme-builder“ inkl. Ablauf „custom-post-type-vorlage“
|
||
overview.md, divi-library.md, divi-placeholders.md, workflows.md (geändert)
|
||
docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (geändert) §4.3, §4.5, §5.5, §5.6, §5.7, §9, §12
|
||
docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (geändert) Abschnitt 7 „Umsetzung 3b“
|
||
docs/superpowers/notes/2026-09-27-etappe-3b-abnahme.md (neu) manuelle Abnahme
|
||
src/
|
||
Guides/InstructionsBuilder.php (geändert) Theme-Builder-Regel
|
||
Integrations/WordPress/WordPressIntegration.php (geändert) P13-Gate an den Render-Auszug
|
||
Integrations/WordPress/Render/PageRenderSummaryAbility.php (geändert) P13
|
||
Integrations/Divi/
|
||
DiviIntegration.php (geändert) TB-Zugang, themeBuilder(), libraryIdsVisible()
|
||
DiviCompatibility.php (geändert) TB-Prüfungen
|
||
BlockTree/Outline.php (geändert) P13: globale IDs ausblendbar
|
||
BlockTree/Fingerprint.php (neu) Struktur- und Inhalts-Fingerabdruck
|
||
Compose/PostIdParser.php (neu) P10: eine ID-Regel
|
||
Compose/ValueSanitizer.php (geändert) linkTargetType()
|
||
Compose/DynamicContentRules.php, VariableRef.php, Placeholders.php (geändert) P10, nameOf()
|
||
Compose/StructureRules.php (geändert) TB-Bereiche (appearance.themeBuilderLayouts)
|
||
Schema/ModuleCatalog.php (geändert) themeBuilderAreas()
|
||
Pages/
|
||
WpValueSanitizer.php (geändert) linkTargetType()
|
||
DiviPage.php, DiviPageLoader.php (geändert) TB-Layouts, libraryIdsVisible()
|
||
SaveScope.php (geändert) ThemeBuilderLayout
|
||
DiviComposition.php (geändert) forPost(…, $area)
|
||
DiviWriter.php, DiviWriteAbility.php (geändert) TB-Layouts, ähnliche Abschnitte, P13
|
||
DiviOutlineAbility.php, DiviNodeGetAbility.php (geändert) P13
|
||
DiviContentComposer.php (geändert) nutzt LayoutBuilder
|
||
LayoutBuilder.php (neu) Abschnitte → leeres Dokument
|
||
DynamicFields.php (neu) textartige Felder je Inhaltstyp (aus DiviDynamicSourcesAbility)
|
||
DiviDynamicSourcesAbility.php (geändert) nutzt DynamicFields
|
||
Library/
|
||
LibraryAccess.php (geändert) revealsIds()
|
||
NewLibraryEntry.php (neu) gemeinsamer Anlegeweg (library-save, make-global)
|
||
LibrarySaveAbility.php (geändert) nutzt NewLibraryEntry
|
||
SimilarSections.php (neu) Warnung §5.5
|
||
LibraryFindSimilarAbility.php (neu) lit/library-find-similar
|
||
LibraryMakeGlobalAbility.php (neu) lit/library-make-global
|
||
LibraryIntegration.php (geändert) zwei neue Tools
|
||
ThemeBuilder/
|
||
DiviThemeBuilder.php (neu) Adapter
|
||
Template.php (neu) Vorlage als Wert
|
||
ThemeBuilderAccess.php (neu) Rechte, Gates, Entwurfssperre
|
||
TemplateConditions.php (neu) Bedingungen prüfen und benennen
|
||
ThemeBuilderEdits.php (neu) sichtbare Layouts: Probelauf/confirm, Feldwarnungen
|
||
TemplateBuild.php (neu) Anlegen alles oder nichts
|
||
ThemeBuilderIntegration.php (neu) Gruppe „Divi Theme Builder“
|
||
TbListAbility.php, TbCreateTemplateAbility.php, TbAssignAbility.php, TbActivateAbility.php (neu)
|
||
tests/
|
||
Support/ThemeBuilderFixtures.php (neu) Posts, Vorlagen, Store, Zugang
|
||
Support/ComposeFixture.php, DiviWriterFixture.php (geändert)
|
||
Fixtures/divi/metadata-sample.json (neu erzeugt) mit divi/post-content und appearance
|
||
Fixtures/divi/stage3b/ (neu) Builder-Fixtures mit Pods, README.md
|
||
Integration/divi-export-stage3-fixtures.php (geändert) Ordner/Präfix als Argument, Pods-Personen
|
||
Integration/divi-metadata-fixture.php (geändert) post-content, appearance
|
||
Integration/pods-fixture.php (geändert) Aktion „persons“
|
||
Integration/tb-smoke.sh (neu) Ende-zu-Ende 3b
|
||
Unit/… (neu/geändert je Task)
|
||
```
|
||
|
||
---
|
||
### Task 1: Echte Theme-Builder-Fixtures mit Pods (braucht Pods)
|
||
|
||
Die 3a-Fixtures (`stage3/`) haben einen Body mit Dynamic Content, aber ohne Pods und ohne Inhaltstyp-Bedingung. Für 3b fehlen: eine Vorlage für einen Pods-Inhaltstyp, ein Body-Layout, dessen Pods-Felder **im echten Dynamic-Content-Dialog** gewählt wurden (so sehen wir, welche Form Divi schreibt: `custom_meta_<key>` oder `post_meta_key` mit `meta_key`), die Nutzung der globalen Kopfzeile der Standardvorlage und zwei Befunde zum TB-Editor. Erzeugt wie in 3a: Divis eigene Speicherwege in einem headless Chrome (DevTools-Protokoll, als Administrator angemeldet); kein Plugin-Code schreibt.
|
||
|
||
**Files:**
|
||
- Modify: `tests/Integration/pods-fixture.php` (Aktion `persons`)
|
||
- Modify: `tests/Integration/divi-export-stage3-fixtures.php` (Ordner und Titelpräfix als Argumente, Post-Typ `lit_person`)
|
||
- Create: `tests/Fixtures/divi/stage3b/README.md` und die exportierten `*.json`/`*.html`
|
||
- Test: `tests/Unit/Integrations/Divi/BlockTree/Stage3bFixturesTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Divi 5.2.1 Theme Builder (`wp-admin/admin.php?page=et_theme_builder`), Visual Builder, Pods-Pod `lit_person` (Felder `role` Text, `level` Auswahl, `mentor` Beziehung).
|
||
- Produces: `tests/Fixtures/divi/stage3b/` mit den Rollen `tb-template--person`, `tb-body--person-body`, `lit_person--erika-muster`, `lit_person--max-beispiel`, `manifest.json`, `theme-builder.json`, `render--person-erika.txt`; README-Befunde 10 (alter TB-Editor überschreibt die Liste?), 11 (wann entsteht der TB-Entwurf?), 12 (Form der Pods-Felder). Admin-Labels im Body: „Person Titel“ (Überschrift, Beitragstitel), „Person Funktion“ (Text, Pods `role`), „Person Stufe“ (Text, Pods `level`), „Person Mentor“ (Text, Pods `mentor`), „Person Beitragsinhalt“ (`divi/post-content`).
|
||
|
||
- [ ] **Step 1: Pods-Freigabe einholen und prüfen**
|
||
|
||
Der Controller fragt den Benutzer: „Für die Theme-Builder-Fixtures muss Pods in DDEV aktiv sein. Darf ich es mit `ddev wp plugin activate pods` aktivieren?“ Erst nach Zustimmung:
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev wp plugin activate pods && ddev wp plugin is-active pods && echo aktiv`
|
||
Expected: `aktiv`
|
||
|
||
- [ ] **Step 2: Den Fixture-Test schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/BlockTree/Stage3bFixturesTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
/** The Etappe 3b builder fixtures (tests/Fixtures/divi/stage3b, README): real Divi data with Pods fields. */
|
||
final class Stage3bFixturesTest extends TestCase
|
||
{
|
||
/** @return array<string, array{string}> */
|
||
public static function files(): array
|
||
{
|
||
$files = glob(dirname(__DIR__, 4) . '/Fixtures/divi/stage3b/*.html') ?: [];
|
||
$cases = [];
|
||
foreach ($files as $file) {
|
||
$cases[basename($file)] = ['stage3b/' . basename($file)];
|
||
}
|
||
|
||
return $cases;
|
||
}
|
||
|
||
public function testTheFixturesExist(): void
|
||
{
|
||
self::assertArrayHasKey('tb-body--person-body.html', self::files());
|
||
self::assertFileExists(dirname(__DIR__, 4) . '/Fixtures/divi/stage3b/tb-template--person.json');
|
||
}
|
||
|
||
/** @dataProvider files */
|
||
public function testParsesAndSerializesByteIdentically(string $name): void
|
||
{
|
||
$content = DiviFixtures::markup($name);
|
||
|
||
self::assertSame($content, Serializer::document((new Parser())->parse($content)));
|
||
}
|
||
|
||
/** Finding 12: the Pods field "role" as Divi's dialog writes it (custom_meta_role or post_meta_key + meta_key). */
|
||
public function testBodyLayoutShowsThePodsFieldRole(): void
|
||
{
|
||
$body = DiviFixtures::markup('stage3b/tb-body--person-body.html');
|
||
|
||
self::assertTrue(
|
||
str_contains($body, 'custom_meta_role') || str_contains($body, 'meta_key":"role"'),
|
||
'the body layout references the Pods field role'
|
||
);
|
||
self::assertStringContainsString('wp:divi/post-content', $body);
|
||
}
|
||
|
||
public function testTheTemplateIsDisabledAndMeantForPersons(): void
|
||
{
|
||
$template = json_decode(DiviFixtures::markup('stage3b/tb-template--person.json'), true);
|
||
|
||
self::assertSame(['0'], $template['meta']['_et_enabled']);
|
||
self::assertSame(['singular:post_type:lit_person:all'], $template['meta']['_et_use_on']);
|
||
self::assertSame(['1061'], $template['meta']['_et_header_layout_id'], 'global header of the default template');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Test läuft rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter Stage3bFixturesTest`
|
||
Expected: FAIL – `testTheFixturesExist` („Failed asserting that an array has the key 'tb-body--person-body.html'“).
|
||
|
||
- [ ] **Step 4: Pods-Fixture um Personen erweitern**
|
||
|
||
In `tests/Integration/pods-fixture.php` die Kopfzeile der Nutzung ergänzen:
|
||
|
||
```php
|
||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php persons
|
||
```
|
||
|
||
und direkt nach dem `delete`-Zweig (vor `if (! $api->load_pod(['name' => 'lit_person'])) {`) einfügen:
|
||
|
||
```php
|
||
if ($action === 'persons') {
|
||
if (! $api->load_pod(['name' => 'lit_person'])) {
|
||
fwrite(STDERR, "Pod lit_person missing – run the create action first\n");
|
||
exit(1);
|
||
}
|
||
$erika = (int) pods('lit_person')->add([
|
||
'post_title' => '[Fixture 3b] Erika Muster',
|
||
'post_status' => 'publish',
|
||
'role' => 'Geschäftsführung',
|
||
'level' => 'senior',
|
||
]);
|
||
$max = (int) pods('lit_person')->add([
|
||
'post_title' => '[Fixture 3b] Max Beispiel',
|
||
'post_status' => 'publish',
|
||
'role' => 'Entwicklung',
|
||
'level' => 'junior',
|
||
'mentor' => $erika,
|
||
]);
|
||
echo "persons {$erika} {$max}\n";
|
||
return;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Export für beliebige Ordner und Präfixe**
|
||
|
||
In `tests/Integration/divi-export-stage3-fixtures.php`:
|
||
|
||
1. Kopfkommentar, Nutzung ergänzen:
|
||
```php
|
||
* wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php stage3b "[Fixture 3b]"
|
||
* Arguments: target folder below tests/Fixtures/divi (default stage3) and title prefix (default "[Fixture]").
|
||
```
|
||
2. Die beiden ersten Variablen ersetzen:
|
||
```php
|
||
$lit_stage = preg_match('/^[a-z0-9]+$/D', (string) ($args[0] ?? '')) === 1 ? (string) $args[0] : 'stage3';
|
||
$lit_prefix = (string) ($args[1] ?? '[Fixture]') . ' ';
|
||
$lit_dir = dirname(__DIR__) . '/Fixtures/divi/' . $lit_stage;
|
||
```
|
||
3. In `$lit_role` die Präfix-Entfernung allgemein machen und Personen benennen:
|
||
```php
|
||
$slug = sanitize_title(preg_replace('/^\[Fixture[^\]]*\]\s*/', '', $post->post_title));
|
||
```
|
||
und im Präfix-Array ergänzen: `'lit_person' => 'lit_person',`.
|
||
4. `$lit_types` um `'lit_person'` erweitern.
|
||
5. Die Fehlermeldung ohne Treffer:
|
||
```php
|
||
WP_CLI::error(sprintf(
|
||
'No "%s" posts found – create them first (see tests/Fixtures/divi/%s/README.md).',
|
||
$lit_prefix,
|
||
$lit_stage
|
||
));
|
||
```
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php && git -C wp-content/plugins/WP-LIT-MCP status --short tests/Fixtures/divi/stage3`
|
||
Expected: `Success: 12 posts exported …` und **keine** Änderung unter `stage3/` (der Standardaufruf erzeugt byte-gleich dieselben Dateien). Zeigt `git status` doch Änderungen, sie mit `git checkout -- tests/Fixtures/divi/stage3` zurücknehmen, die Abweichung (Datei, Feld) im README von `stage3b/` als Befund festhalten und dem Controller melden – die 3a-Fixtures bleiben unverändert.
|
||
|
||
- [ ] **Step 6: Pods-Daten anlegen**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create
|
||
ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php persons
|
||
ddev wp eval 'echo et_builder_enabled_for_post_type("lit_person") ? "divi an" : "divi aus";'
|
||
```
|
||
Expected: `created`, `persons <ID1> <ID2>`; IDs im README notieren. Zeigt die letzte Zeile „divi aus“: in Divi → Theme-Optionen → Builder → „Beitragstyp-Integration“ den Builder für „Personen“ einschalten (UI im headless Chrome) und erneut prüfen.
|
||
|
||
- [ ] **Step 7: Vorlage und Body im echten Theme Builder bauen**
|
||
|
||
Im headless Chrome, angemeldet als Administrator, genau wie 3a Abschnitt 3 (README `stage3/`), mit den Nonces aus `et_theme_builder_bundle.config.nonces`:
|
||
|
||
1. TB-Editor öffnen (`wp-admin/admin.php?page=et_theme_builder`). Über die Oberfläche „Neue Vorlage hinzufügen“ → Bedingung „Personen → Alle Personen“ (`singular:post_type:lit_person:all`). Kopfzeile: „Globale Kopfzeile verwenden“ (zeigt auf 1061); Inhalt: „Benutzerdefinierten Inhalt erstellen“; Fußzeile: nichts. Vorlage mit dem Schalter **deaktivieren**, Titel „[Fixture 3b] Person“, „Änderungen speichern“.
|
||
2. Den neuen Body im Visual Builder öffnen (Link aus `et_theme_builder_api_get_layout_url`, ohne `app_window=1`). Einen Abschnitt mit einer Zeile (1 Spalte) anlegen und **über die echten Einstellungsdialoge** (nicht `setContent`) einfügen:
|
||
- Überschrift, Admin-Label „Person Titel“, Text über „Dynamischen Inhalt verwenden“ → „Beitragstitel“;
|
||
- Text „Person Funktion“ → Dynamic Content, in der Liste das Pods-Feld `role` wählen (steht es nicht in der Liste: „Benutzerdefiniertes Feld“ → Schlüssel `role` eintragen und das im README vermerken);
|
||
- Text „Person Stufe“ → Pods-Feld `level`;
|
||
- Text „Person Mentor“ → Pods-Feld `mentor`;
|
||
- Modul „Beitragsinhalt“, Admin-Label „Person Beitragsinhalt“.
|
||
Titel über „Seiteneinstellungen → Titel“: „[Fixture 3b] Person Body“; speichern („Veröffentlichen“).
|
||
3. Befund 11: vor dem Speichern in Schritt 1 und nach einer ersten ungespeicherten Änderung jeweils `ddev wp post list --post_type=et_theme_builder --post_status=auto-draft --format=ids` ausführen und festhalten, ab wann ein TB-Entwurf existiert.
|
||
|
||
- [ ] **Step 8: Darstellung prüfen (Vorlage kurz aktiv)**
|
||
|
||
1. Im TB-Editor „[Fixture 3b] Person“ aktivieren und speichern.
|
||
2. `curl -s -H "Host: wordpress.ddev.site" "http://localhost/?p=<ID von Erika>"` im Web-Container (`ddev exec`), die Texte der vier Module aus dem HTML ziehen und als `tests/Fixtures/divi/stage3b/render--person-erika.txt` speichern (je Zeile „Admin-Label: sichtbarer Text“; für den Mentor erwarten wir eine Beitrags-ID oder nichts – F26).
|
||
3. Im TB-Editor die Vorlage wieder **deaktivieren** und speichern. Prüfen: `ddev wp post meta get <Vorlagen-ID> _et_enabled` → `0`.
|
||
|
||
- [ ] **Step 9: Befund 10 – überschreibt ein alter TB-Editor die Liste?**
|
||
|
||
1. Tab A: TB-Editor öffnen und geladen lassen.
|
||
2. Im Terminal eine zusätzliche, deaktivierte Vorlage „[Fixture 3b] Stale-Test“ genau wie `DiviThemeBuilder::attach()` anhängen:
|
||
```bash
|
||
ddev wp eval '
|
||
$tb = et_theme_builder_get_theme_builder_post_id(true, false);
|
||
$id = wp_insert_post(["post_type" => "et_template", "post_status" => "publish", "post_title" => "[Fixture 3b] Stale-Test"]);
|
||
update_post_meta($id, "_et_enabled", "0");
|
||
$existing = get_post_meta($tb, "_et_template", false);
|
||
update_option("et_tb_templates_backup_" . $tb, $existing);
|
||
add_post_meta($tb, "_et_template", $id);
|
||
delete_option("et_tb_templates_backup_" . $tb);
|
||
echo $id;'
|
||
```
|
||
3. In Tab A eine harmlose Änderung machen (Titel einer Fixture-Vorlage ändern und zurück) und speichern.
|
||
4. Festhalten: `ddev wp post meta get <TB-ID> _et_template --format=json` (enthält Stale-Test noch?) und `ddev wp post meta get <Stale-ID> _et_theme_builder_marked_as_unused`. Danach `ddev wp post delete <Stale-ID> --force` und prüfen, dass `_et_template` des TB-Posts die ID nicht mehr enthält (sonst mit `delete_post_meta` entfernen).
|
||
|
||
- [ ] **Step 10: Exportieren und README schreiben**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php stage3b "[Fixture 3b]"`
|
||
Expected: `Success: 4 posts exported to …/stage3b` (Vorlage, Body, zwei Personen; die Standardvorlage 1065 liegt bereits in `stage3/`).
|
||
|
||
`tests/Fixtures/divi/stage3b/README.md` im Aufbau von `stage3/README.md`: Dateien und Rollen (Tabelle mit IDs), Entstehung (Schritte 6–9 mit den Divi-Dateistellen wie dort), „Was echt ist und was nachgebildet wurde“ (nachgebildet ist nur die Stale-Test-Zuordnung aus Schritt 9), **Befund 10** (Liste nach dem Speichern eines alten Editors: bleibt/entfernt, Markierung ja/nein), **Befund 11** (TB-Entwurf entsteht bei … / gar nicht), **Befund 12** (Form des Pods-Felds `role` im Body: Name und `settings`, Ausgabe von `level` – Wert oder Beschriftung – und `mentor` laut `render--person-erika.txt`), Wirkung auf die Live-Site (Vorlage deaktiviert, Stale-Test gelöscht), Neu exportieren, Aufräumen (Befehle mit den IDs).
|
||
|
||
- [ ] **Step 11: Test läuft grün**
|
||
|
||
Run: `vendor/bin/phpunit --filter Stage3bFixturesTest`
|
||
Expected: PASS (5 Tests + je `.html` ein Roundtrip).
|
||
|
||
- [ ] **Step 12: Commit**
|
||
|
||
```bash
|
||
git add tests/Integration/pods-fixture.php tests/Integration/divi-export-stage3-fixtures.php tests/Fixtures/divi/stage3b tests/Unit/Integrations/Divi/BlockTree/Stage3bFixturesTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit `test: Theme Builder fixtures with Pods fields from the real builder (stage3b)`.
|
||
|
||
---
|
||
|
||
### Task 2: P10 – gleiche Zielregeln für rohe `post_link_url_*`
|
||
|
||
Ruling P10: `{{link:<ID>}}` prüft das Ziel über `LinkTargets` (existiert, nicht Papierkorb/`auto-draft`/`inherit`, `read_post`, Typ); die Vollform `post_link_url_<typ>` mit `settings.post_id` prüfte bisher nur `read_post`. Künftig gilt für beide dieselbe Regel, und die ID wird an einer Stelle geparst.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Compose/PostIdParser.php`
|
||
- Modify: `src/Integrations/Divi/Compose/ValueSanitizer.php`, `src/Integrations/Divi/Compose/DynamicContentRules.php` (`cleanSetting`, `selectPostSetting`), `src/Integrations/Divi/Compose/VariableRef.php` (Aufruf in `contentSettings`), `src/Integrations/Divi/Compose/Placeholders.php` (`link`), `src/Integrations/Divi/Pages/WpValueSanitizer.php`
|
||
- Modify: `tests/Support/ComposeFixture.php`, `tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php` (innerer Sanitizer)
|
||
- Test: `tests/Unit/Integrations/Divi/Compose/PostIdParserTest.php`, `tests/Unit/Integrations/Divi/Compose/VariableRefTest.php`, `tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `WpLinkTargets::postTypeOf(int): ?string` (3a).
|
||
- Produces: `PostIdParser::parse(mixed $value): ?int`; `ValueSanitizer::linkTargetType(int $id): ?string`; `DynamicContentRules::cleanSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer, string $option = ''): mixed`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`tests/Unit/Integrations/Divi/Compose/PostIdParserTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use LIT\MCP\Integrations\Divi\Compose\PostIdParser;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class PostIdParserTest extends TestCase
|
||
{
|
||
public function testAcceptsPositiveIdsAsIntOrDigits(): void
|
||
{
|
||
self::assertSame(839, PostIdParser::parse(839));
|
||
self::assertSame(839, PostIdParser::parse('839'));
|
||
}
|
||
|
||
/** Ruling P10: one rule for {{link:}} and post_link_url_* – no zero, sign, padding, slug or overflow. */
|
||
public function testRefusesEverythingElse(): void
|
||
{
|
||
foreach ([0, -3, '0', '-3', '0839', '8 39', ' 839', 'impressum', '99999999999999999999', 1.5, null, [], true] as $bad) {
|
||
self::assertNull(PostIdParser::parse($bad), var_export($bad, true));
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
In `tests/Unit/Integrations/Divi/Compose/VariableRefTest.php` (nach `testMapFieldTypesAreRoutedStrictly`):
|
||
|
||
```php
|
||
/** Ruling P10: a full-form internal link follows the {{link:}} target rules, its type included. */
|
||
public function testFullFormInternalLinksFollowTheLinkTargetRules(): void
|
||
{
|
||
$ref = self::ref([
|
||
'post_link_url_page' => ['post_id' => 'select_post'],
|
||
'post_link_url_post' => ['post_id' => 'select_post'],
|
||
]);
|
||
$sanitizer = ComposeFixture::sanitizer();
|
||
|
||
self::assertSame(
|
||
self::content('post_link_url_post', '{"post_id":"840"}'),
|
||
$ref->canonical(self::content('post_link_url_post', '{"post_id":"840"}'), $sanitizer, true)
|
||
);
|
||
$refused = [
|
||
'a post is no page' => self::content('post_link_url_page', '{"post_id":"840"}'),
|
||
'no link target' => self::content('post_link_url_page', '{"post_id":"666"}'),
|
||
'overflowing id' => self::content('post_link_url_page', '{"post_id":"99999999999999999999"}'),
|
||
'padded id' => self::content('post_link_url_page', '{"post_id":"0840"}'),
|
||
];
|
||
foreach ($refused as $case => $value) {
|
||
try {
|
||
$ref->canonical($value, $sanitizer, true);
|
||
self::fail($case . ' was accepted');
|
||
} catch (InvalidArgumentException $e) {
|
||
self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $case);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
(`use InvalidArgumentException;` steht bereits in der Datei; sonst ergänzen.)
|
||
|
||
In `tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php`:
|
||
|
||
```php
|
||
/** Ruling P10: the full form uses the {{link:}} targets – attachments, trash and unreadable entries are none. */
|
||
public function testLinkTargetTypeFollowsTheLinkTargets(): void
|
||
{
|
||
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) {
|
||
12 => new WP_Post(['ID' => 12, 'post_type' => 'page', 'post_status' => 'draft']),
|
||
13 => new WP_Post(['ID' => 13, 'post_type' => 'attachment', 'post_status' => 'inherit']),
|
||
14 => new WP_Post(['ID' => 14, 'post_type' => 'page', 'post_status' => 'trash']),
|
||
16 => new WP_Post(['ID' => 16, 'post_type' => 'page', 'post_status' => 'private']),
|
||
default => null,
|
||
});
|
||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 16);
|
||
$sanitizer = new WpValueSanitizer();
|
||
|
||
self::assertSame('page', $sanitizer->linkTargetType(12));
|
||
self::assertNull($sanitizer->linkTargetType(13));
|
||
self::assertNull($sanitizer->linkTargetType(14));
|
||
self::assertNull($sanitizer->linkTargetType(15));
|
||
self::assertNull($sanitizer->linkTargetType(16));
|
||
}
|
||
```
|
||
|
||
(`use WP_Post;` und `use Brain\Monkey\Functions;` ergänzen, falls nicht vorhanden.)
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'PostIdParserTest|VariableRefTest|WpValueSanitizerTest'`
|
||
Expected: FAIL – „Class "LIT\MCP\Integrations\Divi\Compose\PostIdParser" not found“ und „Call to undefined method … linkTargetType()“.
|
||
|
||
- [ ] **Step 3: PostIdParser**
|
||
|
||
`src/Integrations/Divi/Compose/PostIdParser.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The one rule for post IDs a client or a stored reference names (ruling P10): {{link:<ID>}},
|
||
* post_link_url_* settings.post_id and Theme Builder conditions. A positive int, or its plain
|
||
* decimal digits without sign, padding or spaces; nothing beyond PHP_INT_MAX (FILTER_VALIDATE_INT
|
||
* would otherwise saturate to another entry). Pure PHP.
|
||
*/
|
||
final class PostIdParser
|
||
{
|
||
public static function parse(mixed $value): ?int
|
||
{
|
||
if (is_int($value)) {
|
||
return $value > 0 ? $value : null;
|
||
}
|
||
|
||
if (! is_string($value) || preg_match('/^[1-9][0-9]{0,18}$/D', $value) !== 1) {
|
||
return null;
|
||
}
|
||
|
||
$id = filter_var($value, FILTER_VALIDATE_INT);
|
||
|
||
return is_int($id) ? $id : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Sanitizer-Schnittstelle und WordPress-Umsetzung**
|
||
|
||
In `src/Integrations/Divi/Compose/ValueSanitizer.php` nach `canReadPost()`:
|
||
|
||
```php
|
||
/**
|
||
* Ruling P10: the post type of entry $id when it is a link target – exists, lives (no trash,
|
||
* auto-draft or revision/attachment status), readable – like {{link:}} (LinkTargets); else null.
|
||
*/
|
||
public function linkTargetType(int $id): ?string;
|
||
```
|
||
|
||
In `src/Integrations/Divi/Pages/WpValueSanitizer.php` nach `canReadPost()`:
|
||
|
||
```php
|
||
public function linkTargetType(int $id): ?string
|
||
{
|
||
return (new WpLinkTargets())->postTypeOf($id);
|
||
}
|
||
```
|
||
|
||
In `tests/Support/ComposeFixture.php` im anonymen Sanitizer nach `canReadPost()`:
|
||
|
||
```php
|
||
/** Ruling P10: 840 is a post, 666 no link target, every other ID a page. */
|
||
public function linkTargetType(int $id): ?string
|
||
{
|
||
return match ($id) {
|
||
666 => null,
|
||
840 => 'post',
|
||
default => 'page',
|
||
};
|
||
}
|
||
```
|
||
|
||
In `tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php` im inneren Sanitizer nach `canReadPost()`:
|
||
|
||
```php
|
||
public function linkTargetType(int $id): ?string
|
||
{
|
||
return $this->inner->linkTargetType($id);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: select_post nach Link-Regel prüfen**
|
||
|
||
In `src/Integrations/Divi/Compose/DynamicContentRules.php`:
|
||
|
||
1. Konstante ergänzen (bei den anderen Konstanten):
|
||
```php
|
||
/** Divi's internal link options: post_link_url_<post type> (F27). */
|
||
public const LINK_PREFIX = 'post_link_url_';
|
||
```
|
||
2. Signatur und Aufruf in `cleanSetting()`:
|
||
```php
|
||
public static function cleanSetting(
|
||
string $key,
|
||
mixed $value,
|
||
string $fieldType,
|
||
ValueSanitizer $sanitizer,
|
||
string $option = ''
|
||
): mixed {
|
||
```
|
||
und im `match`:
|
||
```php
|
||
'select_post' => self::selectPostSetting($key, $value, $sanitizer, $option),
|
||
```
|
||
3. `selectPostSetting()` vollständig ersetzen:
|
||
```php
|
||
/**
|
||
* Ruling S37/P10: a numeric post id (PostIdParser). In an internal link post_link_url_<type> the
|
||
* id must name a link target of exactly that type (LinkTargets rules, like {{link:}}); in any
|
||
* other option a post the user may read. Returned as sent (int or digit string).
|
||
*
|
||
* @throws InvalidArgumentException
|
||
*/
|
||
private static function selectPostSetting(
|
||
string $key,
|
||
mixed $value,
|
||
ValueSanitizer $sanitizer,
|
||
string $option
|
||
): mixed {
|
||
$id = PostIdParser::parse($value);
|
||
$linkType = str_starts_with($option, self::LINK_PREFIX) ? substr($option, strlen(self::LINK_PREFIX)) : null;
|
||
$valid = $id !== null && ($linkType === null
|
||
? $sanitizer->canReadPost($id)
|
||
: $sanitizer->linkTargetType($id) === $linkType);
|
||
if (! $valid) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet%s.',
|
||
$key,
|
||
$linkType === null ? '' : sprintf(' (Typ „%s“, nicht im Papierkorb, kein Anhang)', $linkType)
|
||
));
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
```
|
||
|
||
In `src/Integrations/Divi/Compose/VariableRef.php` in `contentSettings()` die Zeile mit `cleanSetting` ersetzen:
|
||
|
||
```php
|
||
$clean[$key] = DynamicContentRules::cleanSetting($key, $value, $fields[$key], $sanitizer, $name);
|
||
```
|
||
|
||
In `src/Integrations/Divi/Compose/Placeholders.php` in `link()` die zwei Zeilen zur ID ersetzen:
|
||
|
||
```php
|
||
$id = PostIdParser::parse($name);
|
||
if ($id === null) {
|
||
```
|
||
|
||
(der folgende `throw` bleibt; `is_int($id)` entfällt).
|
||
|
||
- [ ] **Step 6: Tests laufen grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'PostIdParserTest|VariableRefTest|WpValueSanitizerTest|PlaceholdersTest|StoredNodeGuardTest|ShortformWriterTest' && composer test && composer lint`
|
||
Expected: PASS; Gesamtlauf grün (1901 + neue Tests), Lint ohne Befund.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Compose/PostIdParser.php src/Integrations/Divi/Compose/ValueSanitizer.php src/Integrations/Divi/Compose/DynamicContentRules.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Compose/Placeholders.php src/Integrations/Divi/Pages/WpValueSanitizer.php tests/Support/ComposeFixture.php tests/Unit/Integrations/Divi/Compose tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit `fix: full-form internal links follow the {{link:}} target rules (ruling P10)`.
|
||
|
||
---
|
||
|
||
### Task 3: P13 – Bibliotheks-IDs vollständig ausblenden
|
||
|
||
Ruling P13: Ohne Bibliothekszugang (Gruppe „Divi-Bibliothek“ deaktiviert oder kein Divi-Recht `divi_library`) nennen `lit-divi-outline`, `lit-divi-node-get`, die Outline jeder Divi-Schreibantwort und `lit-page-render-summary` keine Bibliotheks-IDs mehr. Globale Instanzen bleiben als „nur lesbar, globales Modul“ erkennbar.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`revealsIds()`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php` (`libraryIdsVisible()`)
|
||
- Modify: `src/Integrations/Divi/BlockTree/Outline.php` (Konstruktor `$globalIds`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviOutlineAbility.php`, `DiviNodeGetAbility.php`, `DiviWriter.php` (Outline der Antwort)
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php` (`libraryIdsVisible()`), `src/Integrations/WordPress/WordPressIntegration.php`, `src/Integrations/WordPress/Render/PageRenderSummaryAbility.php`, `src/Plugin.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`, `tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LibraryAccess::canList()`, das `$visible`-Gate (P9).
|
||
- Produces: `LibraryAccess::revealsIds(): bool`; `DiviPageLoader::libraryIdsVisible(): bool`; `Outline::__construct(array $presetNames = [], bool $globalIds = true)`; `DiviIntegration::libraryIdsVisible(): bool`; `PageRenderSummaryAbility::__construct(ContentSchema $schema, PageRenderer $renderer, ?Closure $globalIdsVisible = null)`; `WordPressIntegration::__construct(ContentSchema, ChangeLog, ?ContentComposer $divi = null, ?Closure $globalIdsVisible = null)`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`LibraryAccessTest` ergänzen:
|
||
|
||
```php
|
||
/** Ruling P13: library IDs are named only while the library is visible and the user has its right. */
|
||
public function testRevealsIdsOnlyWithTheVisibleLibraryAndItsRight(): void
|
||
{
|
||
LibraryFixtures::given([]);
|
||
|
||
self::assertTrue(LibraryFixtures::access()->revealsIds());
|
||
self::assertFalse(LibraryFixtures::access(['divi_library'])->revealsIds());
|
||
$hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false);
|
||
self::assertFalse($hidden->revealsIds());
|
||
LibraryFixtures::given([], ['edit_posts']);
|
||
self::assertFalse(LibraryFixtures::access()->revealsIds());
|
||
}
|
||
```
|
||
|
||
`OutlineTest` ergänzen:
|
||
|
||
```php
|
||
/** Ruling P13: without library access the outline says "global", but not which entry. */
|
||
public function testGlobalIdsCanBeHidden(): void
|
||
{
|
||
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||
$outline = new Outline([], false);
|
||
$rows = $outline->rows(NodeIndex::build((new Parser())->parse($content)));
|
||
|
||
$byId = array_column($rows, null, 'id');
|
||
self::assertNull($byId['g0']['global_id']);
|
||
self::assertSame('section', $byId['g0']['type']);
|
||
self::assertStringNotContainsString('1058', $outline->text($rows));
|
||
self::assertStringContainsString('g0 section "Global Abschnitt" (nur lesbar)', $outline->text($rows));
|
||
}
|
||
```
|
||
|
||
`DiviReadAbilitiesTest`: den bestehenden Test `testNodeGetNamesTheLibraryEntryOfAGlobalModule` auf den Bibliothekszugang umstellen und einen ausgeblendeten Fall ergänzen:
|
||
|
||
```php
|
||
public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void
|
||
{
|
||
DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||
$ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $this->design());
|
||
|
||
$global = $ability->execute(['post_id' => 7, 'id' => 'g0']);
|
||
$plain = $ability->execute(['post_id' => 7, 'id' => 's0']);
|
||
|
||
self::assertSame('1058', $global['global']['id']);
|
||
self::assertSame('divi/section', $global['global']['block_name']);
|
||
self::assertStringContainsString('post_id 1058', $global['global']['hint']);
|
||
self::assertArrayHasKey('globalModule', $global['attrs']);
|
||
self::assertFalse($global['editable']);
|
||
self::assertNull($plain['global']);
|
||
}
|
||
|
||
/** Ruling P13: without the library right node-get and outline name no library ID. */
|
||
public function testWithoutLibraryAccessNoLibraryIdIsNamed(): void
|
||
{
|
||
DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||
$loader = LibraryFixtures::loader(LibraryFixtures::access(['divi_library']));
|
||
|
||
$global = (new DiviNodeGetAbility($loader, $this->design()))->execute(['post_id' => 7, 'id' => 'g0']);
|
||
$outline = (new DiviOutlineAbility($loader, $this->design()))->execute(['post_id' => 7]);
|
||
|
||
self::assertNull($global['global']['id']);
|
||
self::assertStringContainsString('Divi-Bibliothek', $global['global']['hint']);
|
||
self::assertArrayNotHasKey('globalModule', $global['attrs']);
|
||
self::assertStringNotContainsString('1058', (string) json_encode($global));
|
||
self::assertStringNotContainsString('1058', (string) json_encode($outline));
|
||
self::assertStringNotContainsString('1059', (string) json_encode($outline));
|
||
}
|
||
```
|
||
|
||
`PageRenderSummaryAbilityTest` ergänzen:
|
||
|
||
```php
|
||
/** Ruling P13: without library access the note counts global modules instead of naming them. */
|
||
public function testWithoutLibraryAccessGlobalModulesAreOnlyCounted(): void
|
||
{
|
||
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]);
|
||
Functions\when('get_post')->justReturn($post);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
|
||
$renderer = Mockery::mock(PageRenderer::class);
|
||
$renderer->allows('render')->andReturn('<p>Text</p>');
|
||
$ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer, static fn (): bool => false);
|
||
|
||
$notes = $ability->execute(['post_id' => 7])['notes'];
|
||
|
||
$joined = implode("\n", $notes);
|
||
self::assertStringContainsString('2 globale Module sind im Auszug nicht enthalten', $joined);
|
||
self::assertStringNotContainsString('1058', $joined);
|
||
self::assertStringNotContainsString('1059', $joined);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|OutlineTest|DiviReadAbilitiesTest|PageRenderSummaryAbilityTest'`
|
||
Expected: FAIL – „Call to undefined method … revealsIds()“, `global_id` ist `'1058'` statt `null`, `PageRenderSummaryAbility` nennt die IDs.
|
||
|
||
- [ ] **Step 3: Zugang und Loader**
|
||
|
||
`LibraryAccess` nach `canList()`:
|
||
|
||
```php
|
||
/**
|
||
* Ruling P13: whether library IDs may be named to this connection at all – the library group
|
||
* is not disabled (P9) and the user has the library right (canList). Otherwise outlines,
|
||
* node-get and the render summary say "global module" without the entry's ID.
|
||
*/
|
||
public function revealsIds(): bool
|
||
{
|
||
return ($this->visible === null || ($this->visible)()) && $this->canList() === null;
|
||
}
|
||
```
|
||
|
||
`DiviPageLoader` nach `library()`:
|
||
|
||
```php
|
||
/** Ruling P13: whether the Divi tools may name library IDs (no library access = never). */
|
||
public function libraryIdsVisible(): bool
|
||
{
|
||
return $this->library?->revealsIds() ?? false;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Outline, Outline-Tool, Node-Get, Schreibantwort**
|
||
|
||
`Outline`: Konstruktor und `row()`:
|
||
|
||
```php
|
||
/**
|
||
* @param array<string, string> $presetNames Preset ID => name.
|
||
* @param bool $globalIds Ruling P13: false leaves out the library IDs of global instances.
|
||
*/
|
||
public function __construct(private array $presetNames = [], private bool $globalIds = true)
|
||
{
|
||
}
|
||
```
|
||
|
||
und in `row()` den letzten Eintrag:
|
||
|
||
```php
|
||
'global_id' => $this->globalIds ? ($global['id'] ?? null) : null,
|
||
```
|
||
|
||
`DiviOutlineAbility::execute()`: `new Outline($this->design->read()->presetNames())` ersetzen durch
|
||
|
||
```php
|
||
$outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());
|
||
```
|
||
|
||
`DiviWriter::outline()`: ebenso
|
||
|
||
```php
|
||
$outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());
|
||
```
|
||
|
||
`DiviNodeGetAbility::execute()`: den Block ab `$global = NodeSummary::globalRef($block);` bis vor `return [` ersetzen:
|
||
|
||
```php
|
||
$attrs = AttrMap::flatten($block->attrs());
|
||
$global = NodeSummary::globalRef($block);
|
||
if ($global !== null && $this->loader->libraryIdsVisible()) {
|
||
$global['hint'] = sprintf(
|
||
'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); '
|
||
. 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.',
|
||
$global['id'],
|
||
$global['id']
|
||
);
|
||
} elseif ($global !== null) {
|
||
// Ruling P13: which entry is shown stays unknown without library access.
|
||
$global = [
|
||
'id' => null,
|
||
'block_name' => $global['block_name'],
|
||
'hint' => 'Globales Modul aus der Divi-Bibliothek – nur lesbar; dieses Konto hat keinen Zugang zur '
|
||
. 'Divi-Bibliothek.',
|
||
];
|
||
unset($attrs['globalModule'], $attrs['globalParent']);
|
||
}
|
||
```
|
||
|
||
und im Rückgabe-Array `'attrs' => JsonMap::of($attrs),`.
|
||
|
||
- [ ] **Step 5: Render-Auszug und Verdrahtung**
|
||
|
||
`PageRenderSummaryAbility`:
|
||
|
||
```php
|
||
/**
|
||
* @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether global module IDs may be named
|
||
* (library access of this connection); null = always.
|
||
*/
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private PageRenderer $renderer,
|
||
private ?Closure $globalIdsVisible = null
|
||
) {
|
||
}
|
||
```
|
||
|
||
In `execute()` die `foreach`-Schleife über `globalModuleIds()` ersetzen:
|
||
|
||
```php
|
||
$globalIds = self::globalModuleIds((string) $post->post_content);
|
||
if ($globalIds !== [] && $this->globalIdsVisible !== null && ! ($this->globalIdsVisible)()) {
|
||
$notes[] = sprintf(
|
||
'%d %s im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi globale Module '
|
||
. 'nicht; die Divi-Bibliothek ist für dieses Konto nicht zugänglich).',
|
||
count($globalIds),
|
||
count($globalIds) === 1 ? 'globales Modul ist' : 'globale Module sind'
|
||
);
|
||
} else {
|
||
foreach (array_slice($globalIds, 0, self::GLOBAL_NOTES) as $id) {
|
||
$notes[] = sprintf(
|
||
'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi '
|
||
. 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.',
|
||
$id,
|
||
$id
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
(`use Closure;` ergänzen.)
|
||
|
||
`WordPressIntegration`: Konstruktor um `private ?Closure $globalIdsVisible = null` erweitern (`use Closure;`) und in `abilities()`:
|
||
|
||
```php
|
||
new PageRenderSummaryAbility($this->schema, new PageRenderer(), $this->globalIdsVisible),
|
||
```
|
||
|
||
`DiviIntegration` ergänzen:
|
||
|
||
```php
|
||
/** Ruling P13: whether this connection may see library IDs (library group visible, library right). */
|
||
public function libraryIdsVisible(): bool
|
||
{
|
||
return $this->access->revealsIds();
|
||
}
|
||
```
|
||
|
||
`Plugin`: `new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),` ersetzen durch
|
||
|
||
```php
|
||
new WordPressIntegration(
|
||
$schema,
|
||
$changeLog,
|
||
$divi->contentComposer($diviWritable),
|
||
$divi->libraryIdsVisible(...)
|
||
),
|
||
```
|
||
|
||
- [ ] **Step 6: Weitere Stellen mit Bibliotheks-IDs prüfen**
|
||
|
||
Run: `grep -rn "globalModule\|global_id\|\[global " src --include=*.php | grep -v "BlockTree/\|Compose/\|Library/"`
|
||
Expected: nur die in diesem Task geänderten Stellen und `DiviTextSearch::GLOBAL_REASON` (ohne ID). Nennt eine weitere Stelle eine Bibliotheks-ID gegenüber Benutzern ohne Bibliothekszugang, dort `libraryIdsVisible()` ebenso anwenden.
|
||
|
||
- [ ] **Step 7: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|OutlineTest|DiviReadAbilitiesTest|PageRenderSummaryAbilityTest|DiviWriterTest' && composer test && composer lint`
|
||
Expected: PASS. Schlägt ein bestehender Test fehl, weil er mit `new DiviPageLoader(DiviFixtures::schema())` (ohne Bibliothekszugang) eine `global_id` oder `[global …]` erwartet, dort `LibraryFixtures::loader()` verwenden – ohne Bibliothekszugang ist das Ausblenden jetzt gewollt.
|
||
|
||
- [ ] **Step 8: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library/LibraryAccess.php src/Integrations/Divi/Pages src/Integrations/Divi/BlockTree/Outline.php src/Integrations/Divi/DiviIntegration.php src/Integrations/WordPress src/Plugin.php tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `fix: library IDs stay hidden without library access (ruling P13)`.
|
||
|
||
---
|
||
### Task 4: Theme-Builder-Adapter, Vorlage als Wert, Rechte je Objekt
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php`, `Template.php`, `ThemeBuilderAccess.php`
|
||
- Modify: `src/Integrations/Divi/DiviCompatibility.php`
|
||
- Create: `tests/Support/ThemeBuilderFixtures.php`
|
||
- Test: `tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php`, `TemplateTest.php`, `ThemeBuilderAccessTest.php`; `tests/Unit/Integrations/Divi/DiviCompatibilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DiviRoles::allows()/refusal()`, `PostGuard::id()/notFound()`, `Errors`, `Rev::of()`, `LibraryFixtures::roles()/given()`.
|
||
- Produces:
|
||
- `DiviThemeBuilder` (nicht final): Konstanten `TEMPLATE = 'et_template'`, `LAYOUT_TYPES = ['header' => 'et_header_layout', 'body' => 'et_body_layout', 'footer' => 'et_footer_layout']`, `TEMPLATE_LIST`, `USE_ON`, `EXCLUDE_FROM`, `ENABLED`, `UNUSED`, `BACKUP_OPTION`, `FUNCTIONS`; `static areaOf(string $postType): ?string`; `missing(): list<string>`; `liveId(bool $create = false): int`; `draftId(): int`; `templateIds(int $themeBuilderId): list<int>`; `template(int $id, bool $attached): ?Template`; `liveTemplates(): list<Template>`; `detachedIds(int $limit): list<int>`; `insertLayout(string $area, string $title): int`; `storeTemplate(int $themeBuilderId, array $template): int`; `attach(int $themeBuilderId, int $templateId): void`; `detach(int $themeBuilderId, int $templateId): void`; `setConditions(int $templateId, array $useOn, array $excludeFrom): void`; `setEnabled(int $templateId, bool $enabled): void`; `unmarkUnused(int $postId): void`; `flatConditions(): array<string, array{label: string, type: ?string, value: ?string}>`; `clearCaches(): void`. Schreibende Methoden werfen `RuntimeException`.
|
||
- `Template` (final, readonly Felder `id`, `title`, `status`, `default`, `enabled`, `layouts` (`array<'header'|'body'|'footer', array{id: int, enabled: bool}>`), `useOn`, `excludeFrom`, `attached`, `markedUnused`); `AREAS`; `static fromDivi(array $divi, string $status, bool $attached, ?string $markedUnused): self`; `isLive(): bool`; `showsLayout(string $area, int $layoutId): bool`; `rev(): string`; `postTypes(): list<string>`; `toArray(): array`; `static overlaps(array $live, int $exceptId, array $useOn): list<array{id: int, title: string, conditions: list<string>}>`.
|
||
- `ThemeBuilderAccess` (nicht final): `GROUP = 'divi-theme-builder'`; Konstruktor `(DiviThemeBuilder $store, DiviRoles $roles, ?Closure $writable = null, ?Closure $visible = null)`; `store()`, `roles()`, `canUse(): ?WP_Error`, `visible(): bool`, `writableGate(): ?WP_Error`, `layout(mixed $postId, bool $forWrite): WP_Post|WP_Error|null`, `template(mixed $templateId, bool $forWrite): WP_Post|WP_Error`, `canCreate(): ?WP_Error`, `draftRefusal(): ?WP_Error`.
|
||
- `ThemeBuilderFixtures` (Tests): `THEME_BUILDER = 1064`, `DEFAULT_TEMPLATE = 1065`, `TEMPLATE = 1066`, `HEADER = 1061`, `BODY = 1062`, `FOOTER = 1063`; `layout(int $id, array $fields = []): WP_Post`; `templatePost(int $id = self::TEMPLATE, array $fields = []): WP_Post`; `template(int $id = self::TEMPLATE, array $overrides = []): Template`; `store(array $templates = [], int $draftId = 0, array $detached = [], array $byIdOnly = []): DiviThemeBuilder`; `access(array $deniedRoles = [], bool $writable = true, ?DiviThemeBuilder $store = null, bool $visible = true): ThemeBuilderAccess`; `conditions(): array`.
|
||
|
||
- [ ] **Step 1: Test-Fixtures**
|
||
|
||
`tests/Support/ThemeBuilderFixtures.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Support;
|
||
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* The Theme Builder of the tests: the real builder posts of tests/Fixtures/divi/stage3 (live TB post 1064,
|
||
* default template 1065, template 1066 with header 1061, body 1062, footer 1063 – both disabled), a store
|
||
* answering from Template values and the access built from them. Write methods of the store are real
|
||
* unless a test mocks them.
|
||
*/
|
||
final class ThemeBuilderFixtures
|
||
{
|
||
public const THEME_BUILDER = 1064;
|
||
public const DEFAULT_TEMPLATE = 1065;
|
||
public const TEMPLATE = 1066;
|
||
public const HEADER = 1061;
|
||
public const BODY = 1062;
|
||
public const FOOTER = 1063;
|
||
|
||
/** @var array<int, array{string, string}> Layout ID => [stage3 fixture file, post type]. */
|
||
public const LAYOUTS = [
|
||
1061 => ['tb-header--globaler-header.html', 'et_header_layout'],
|
||
1062 => ['tb-body--body-mit-dynamic-content.html', 'et_body_layout'],
|
||
1063 => ['tb-footer--footer.html', 'et_footer_layout'],
|
||
];
|
||
|
||
/** @param array<string, mixed> $fields */
|
||
public static function layout(int $id, array $fields = []): WP_Post
|
||
{
|
||
return new WP_Post(array_merge([
|
||
'ID' => $id,
|
||
'post_type' => self::LAYOUTS[$id][1],
|
||
'post_status' => 'publish',
|
||
'post_title' => '[Fixture] ' . $id,
|
||
'post_content' => DiviFixtures::markup('stage3/' . self::LAYOUTS[$id][0]),
|
||
], $fields));
|
||
}
|
||
|
||
/** @param array<string, mixed> $fields */
|
||
public static function templatePost(int $id = self::TEMPLATE, array $fields = []): WP_Post
|
||
{
|
||
return new WP_Post(array_merge([
|
||
'ID' => $id,
|
||
'post_type' => DiviThemeBuilder::TEMPLATE,
|
||
'post_status' => 'publish',
|
||
'post_title' => $id === self::DEFAULT_TEMPLATE ? 'Standard-Website-Template' : '[Fixture] Vorlage Globale Instanzen',
|
||
'post_content' => '',
|
||
], $fields));
|
||
}
|
||
|
||
/**
|
||
* The template as Divi reads it (et_theme_builder_get_template); $overrides replace keys of that
|
||
* array and may set status, attached and marked_unused.
|
||
*
|
||
* @param array<string, mixed> $overrides
|
||
*/
|
||
public static function template(int $id = self::TEMPLATE, array $overrides = []): Template
|
||
{
|
||
$base = $id === self::DEFAULT_TEMPLATE
|
||
? [
|
||
'id' => $id,
|
||
'title' => 'Standard-Website-Template',
|
||
'default' => true,
|
||
'enabled' => false,
|
||
'layouts' => [
|
||
'header' => ['id' => self::HEADER, 'enabled' => true],
|
||
'body' => ['id' => 0, 'enabled' => true],
|
||
'footer' => ['id' => 0, 'enabled' => true],
|
||
],
|
||
'use_on' => [],
|
||
'exclude_from' => [],
|
||
]
|
||
: [
|
||
'id' => $id,
|
||
'title' => '[Fixture] Vorlage Globale Instanzen',
|
||
'default' => false,
|
||
'enabled' => false,
|
||
'layouts' => [
|
||
'header' => ['id' => self::HEADER, 'enabled' => true],
|
||
'body' => ['id' => self::BODY, 'enabled' => true],
|
||
'footer' => ['id' => self::FOOTER, 'enabled' => true],
|
||
],
|
||
'use_on' => ['singular:post_type:page:id:1053'],
|
||
'exclude_from' => [],
|
||
];
|
||
|
||
return Template::fromDivi(
|
||
array_merge($base, array_intersect_key($overrides, $base)),
|
||
(string) ($overrides['status'] ?? 'publish'),
|
||
(bool) ($overrides['attached'] ?? true),
|
||
isset($overrides['marked_unused']) ? (string) $overrides['marked_unused'] : null
|
||
);
|
||
}
|
||
|
||
/**
|
||
* A store whose live list is $templates, with a TB draft when $draftId > 0, $detached as templates no
|
||
* Theme Builder post lists and $byIdOnly as templates template() finds but no list names (e.g. the one a
|
||
* test lets storeTemplate() create).
|
||
*
|
||
* @param list<Template> $templates
|
||
* @param list<Template> $detached
|
||
* @param list<Template> $byIdOnly
|
||
*/
|
||
public static function store(
|
||
array $templates = [],
|
||
int $draftId = 0,
|
||
array $detached = [],
|
||
array $byIdOnly = []
|
||
): DiviThemeBuilder {
|
||
$byId = [];
|
||
foreach (array_merge($templates, $detached, $byIdOnly) as $template) {
|
||
$byId[$template->id] = $template;
|
||
}
|
||
$store = Mockery::mock(DiviThemeBuilder::class)->makePartial();
|
||
$store->allows('liveId')->andReturn(self::THEME_BUILDER);
|
||
$store->allows('draftId')->andReturn($draftId);
|
||
$store->allows('liveTemplates')->andReturn($templates);
|
||
$store->allows('templateIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $templates));
|
||
$store->allows('template')->andReturnUsing(static fn (int $id): ?Template => $byId[$id] ?? null);
|
||
$store->allows('detachedIds')->andReturn(array_map(static fn (Template $t): int => $t->id, $detached));
|
||
$store->allows('flatConditions')->andReturn(self::conditions());
|
||
|
||
return $store;
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles Divi role options the user lacks. */
|
||
public static function access(
|
||
array $deniedRoles = [],
|
||
bool $writable = true,
|
||
?DiviThemeBuilder $store = null,
|
||
bool $visible = true
|
||
): ThemeBuilderAccess {
|
||
return new ThemeBuilderAccess(
|
||
$store ?? self::store(),
|
||
LibraryFixtures::roles($deniedRoles),
|
||
static fn (): bool => $writable,
|
||
static fn (): bool => $visible
|
||
);
|
||
}
|
||
|
||
/**
|
||
* An excerpt of et_theme_builder_get_flat_template_settings_options() as the adapter returns it
|
||
* (live DDEV dump 2026-09-27 plus the Pods type lit_person).
|
||
*
|
||
* @return array<string, array{label: string, type: ?string, value: ?string}>
|
||
*/
|
||
public static function conditions(): array
|
||
{
|
||
return [
|
||
'singular:post_type:page:all' => ['label' => 'Alle Seiten', 'type' => null, 'value' => null],
|
||
'homepage' => ['label' => 'Startseite', 'type' => null, 'value' => null],
|
||
'singular:post_type:page:id:' => ['label' => 'Bestimmte Seiten', 'type' => 'post_type', 'value' => 'page'],
|
||
'singular:post_type:page:children:id:' => [
|
||
'label' => 'Unterseiten bestimmter Seiten', 'type' => 'post_type', 'value' => 'page',
|
||
],
|
||
'singular:post_type:lit_person:all' => ['label' => 'Alle Personen', 'type' => null, 'value' => null],
|
||
'singular:post_type:lit_person:id:' => [
|
||
'label' => 'Bestimmte Personen', 'type' => 'post_type', 'value' => 'lit_person',
|
||
],
|
||
'archive:taxonomy:category:term:id:' => [
|
||
'label' => 'Bestimmte Kategorien', 'type' => 'taxonomy', 'value' => 'category',
|
||
],
|
||
'archive:user:id:' => ['label' => 'Bestimmte Autoren', 'type' => 'user', 'value' => null],
|
||
'archive:user:role:' => ['label' => 'Autoren mit Rolle', 'type' => 'user_role', 'value' => null],
|
||
'search' => ['label' => 'Suchergebnisse', 'type' => null, 'value' => null],
|
||
'404' => ['label' => '404-Seite', 'type' => null, 'value' => null],
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Failing tests**
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use RuntimeException;
|
||
|
||
final class DiviThemeBuilderTest extends TestCase
|
||
{
|
||
public function testAreaOfTheLayoutTypes(): void
|
||
{
|
||
self::assertSame('header', DiviThemeBuilder::areaOf('et_header_layout'));
|
||
self::assertSame('body', DiviThemeBuilder::areaOf('et_body_layout'));
|
||
self::assertSame('footer', DiviThemeBuilder::areaOf('et_footer_layout'));
|
||
self::assertNull(DiviThemeBuilder::areaOf('et_template'));
|
||
self::assertNull(DiviThemeBuilder::areaOf('page'));
|
||
}
|
||
|
||
/** F17/F18: Divi wp_die()s without edit_others_posts – the adapter never gets there. */
|
||
public function testWithoutEditOthersPostsDiviIsNeverCalled(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(false);
|
||
Functions\expect('et_theme_builder_insert_layout')->never();
|
||
Functions\expect('et_theme_builder_store_template')->never();
|
||
|
||
foreach ([
|
||
static fn () => (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'),
|
||
static fn () => (new DiviThemeBuilder())->storeTemplate(1064, [
|
||
'title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => [],
|
||
]),
|
||
] as $call) {
|
||
try {
|
||
$call();
|
||
self::fail('Divi was called without edit_others_posts');
|
||
} catch (RuntimeException $e) {
|
||
self::assertStringContainsString('edit_others_posts', $e->getMessage());
|
||
}
|
||
}
|
||
}
|
||
|
||
/** While Divi runs, every wp_die handler throws instead (defense in depth); afterwards WordPress' own again. */
|
||
public function testDiviIsOnlyCalledWithTheRightAndWithoutWpDie(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$seen = [];
|
||
Functions\when('et_theme_builder_insert_layout')->alias(static function (array $options) use (&$seen): int {
|
||
$seen = ['options' => $options, 'guarded' => has_filter('wp_die_json_handler') !== false];
|
||
|
||
return 2101;
|
||
});
|
||
|
||
self::assertSame(2101, (new DiviThemeBuilder())->insertLayout('body', 'Person – Inhalt'));
|
||
self::assertSame(['post_type' => 'et_body_layout', 'post_title' => 'Person – Inhalt'], $seen['options']);
|
||
self::assertTrue($seen['guarded'], 'wp_die throws while Divi runs');
|
||
self::assertFalse(has_filter('wp_die_json_handler'), 'and only then');
|
||
}
|
||
|
||
public function testStoreTemplateIsAlwaysDisabledAndNeverDefault(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$call = [];
|
||
Functions\when('et_theme_builder_store_template')->alias(
|
||
static function (int $tb, array $template, bool $allowDefault) use (&$call): int {
|
||
$call = [$tb, $template, $allowDefault];
|
||
|
||
return 2100;
|
||
}
|
||
);
|
||
|
||
$id = (new DiviThemeBuilder())->storeTemplate(1064, [
|
||
'title' => 'Person',
|
||
'layouts' => [
|
||
'header' => ['id' => 1061, 'enabled' => true],
|
||
'body' => ['id' => 2101, 'enabled' => true],
|
||
'footer' => ['id' => 0, 'enabled' => false],
|
||
],
|
||
'use_on' => ['singular:post_type:lit_person:all'],
|
||
'exclude_from' => [],
|
||
]);
|
||
|
||
self::assertSame(2100, $id);
|
||
self::assertSame(1064, $call[0]);
|
||
self::assertSame('0', $call[1]['enabled']);
|
||
self::assertSame('0', $call[1]['default']);
|
||
self::assertSame('0', $call[1]['autogenerated_title']);
|
||
self::assertFalse($call[2], 'allow_default');
|
||
self::assertSame(['id' => 0, 'enabled' => false], $call[1]['layouts']['footer']);
|
||
}
|
||
|
||
/** Mirrors et_theme_builder_api_save(): backup option, append, remove the backup (api.php:223-243). */
|
||
public function testAttachAppendsLikeDivisSave(): void
|
||
{
|
||
Functions\when('get_post_meta')->justReturn(['1065', '1066']);
|
||
Functions\expect('update_option')->once()->with('et_tb_templates_backup_1064', ['1065', '1066']);
|
||
Functions\expect('add_post_meta')->once()->with(1064, '_et_template', 2100)->andReturn(501);
|
||
Functions\expect('delete_option')->once()->with('et_tb_templates_backup_1064');
|
||
|
||
(new DiviThemeBuilder())->attach(1064, 2100);
|
||
}
|
||
|
||
public function testSetConditionsReplacesBothListsWithoutDuplicates(): void
|
||
{
|
||
$calls = [];
|
||
Functions\when('delete_post_meta')->alias(static function (int $id, string $key) use (&$calls): bool {
|
||
$calls[] = ['delete', $key];
|
||
|
||
return true;
|
||
});
|
||
Functions\when('add_post_meta')->alias(static function (int $id, string $key, string $value) use (&$calls): int {
|
||
$calls[] = ['add', $key, $value];
|
||
|
||
return 1;
|
||
});
|
||
|
||
(new DiviThemeBuilder())->setConditions(
|
||
2100,
|
||
['singular:post_type:lit_person:all', 'singular:post_type:lit_person:all'],
|
||
['singular:post_type:lit_person:id:12']
|
||
);
|
||
|
||
self::assertSame([
|
||
['delete', '_et_use_on'],
|
||
['add', '_et_use_on', 'singular:post_type:lit_person:all'],
|
||
['delete', '_et_exclude_from'],
|
||
['add', '_et_exclude_from', 'singular:post_type:lit_person:id:12'],
|
||
], $calls);
|
||
}
|
||
|
||
public function testFlatConditionsKeepNumericIdsAsStrings(): void
|
||
{
|
||
Functions\when('wp_strip_all_tags')->returnArg();
|
||
Functions\when('et_theme_builder_get_flat_template_settings_options')->justReturn([
|
||
'404' => ['id' => '404', 'label' => '404-Seite'],
|
||
'singular:post_type:page:id:' => [
|
||
'label' => 'Bestimmte Seiten',
|
||
'options' => ['type' => 'post_type', 'value' => 'page'],
|
||
],
|
||
]);
|
||
|
||
$flat = (new DiviThemeBuilder())->flatConditions();
|
||
|
||
self::assertSame(['label' => '404-Seite', 'type' => null, 'value' => null], $flat['404']);
|
||
self::assertSame('post_type', $flat['singular:post_type:page:id:']['type']);
|
||
self::assertSame('page', $flat['singular:post_type:page:id:']['value']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/TemplateTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class TemplateTest extends TestCase
|
||
{
|
||
public function testReadsDivisTemplateArray(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template();
|
||
|
||
self::assertSame(1066, $template->id);
|
||
self::assertFalse($template->enabled);
|
||
self::assertSame(['id' => 1062, 'enabled' => true], $template->layouts['body']);
|
||
self::assertSame(['singular:post_type:page:id:1053'], $template->useOn);
|
||
self::assertSame(['page'], $template->postTypes());
|
||
self::assertFalse($template->isLive());
|
||
}
|
||
|
||
public function testLiveMeansAttachedEnabledAndPublished(): void
|
||
{
|
||
self::assertTrue(ThemeBuilderFixtures::template(1066, ['enabled' => true])->isLive());
|
||
self::assertFalse(ThemeBuilderFixtures::template(1066, ['enabled' => true, 'attached' => false])->isLive());
|
||
self::assertFalse(ThemeBuilderFixtures::template(1066, ['enabled' => true, 'status' => 'trash'])->isLive());
|
||
self::assertTrue(ThemeBuilderFixtures::template(1066, ['enabled' => true])->showsLayout('body', 1062));
|
||
$hiddenBody = ThemeBuilderFixtures::template(1066, [
|
||
'enabled' => true,
|
||
'layouts' => [
|
||
'header' => ['id' => 1061, 'enabled' => true],
|
||
'body' => ['id' => 1062, 'enabled' => false],
|
||
'footer' => ['id' => 1063, 'enabled' => true],
|
||
],
|
||
]);
|
||
self::assertFalse($hiddenBody->showsLayout('body', 1062));
|
||
}
|
||
|
||
public function testRevChangesWithEveryField(): void
|
||
{
|
||
$rev = ThemeBuilderFixtures::template()->rev();
|
||
|
||
self::assertSame($rev, ThemeBuilderFixtures::template()->rev());
|
||
self::assertNotSame($rev, ThemeBuilderFixtures::template(1066, ['enabled' => true])->rev());
|
||
self::assertNotSame($rev, ThemeBuilderFixtures::template(1066, ['use_on' => ['homepage']])->rev());
|
||
self::assertNotSame($rev, ThemeBuilderFixtures::template(1066, ['attached' => false])->rev());
|
||
}
|
||
|
||
public function testOverlapsNameOtherLiveTemplatesWithSharedConditions(): void
|
||
{
|
||
$other = ThemeBuilderFixtures::template(1070, [
|
||
'enabled' => true,
|
||
'title' => 'Seiten',
|
||
'use_on' => ['singular:post_type:page:all', 'homepage'],
|
||
]);
|
||
$disabled = ThemeBuilderFixtures::template(1071, ['use_on' => ['homepage']]);
|
||
|
||
self::assertSame(
|
||
[['id' => 1070, 'title' => 'Seiten', 'conditions' => ['homepage']]],
|
||
Template::overlaps([$other, $disabled], 1066, ['homepage', 'search'])
|
||
);
|
||
self::assertSame([], Template::overlaps([$other], 1070, ['homepage']));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
|
||
final class ThemeBuilderAccessTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('get_post_type_object')->justReturn(null);
|
||
}
|
||
|
||
/** Spec §4.5 + F12/F23: edit_others_posts and Divi's role option "Theme Builder". */
|
||
public function testUsingTheThemeBuilderNeedsEditOthersPostsAndTheDiviRole(): void
|
||
{
|
||
LibraryFixtures::given([]);
|
||
self::assertNull(ThemeBuilderFixtures::access()->canUse());
|
||
self::assertToolError(ThemeBuilderFixtures::access(['theme_builder'])->canUse(), 'forbidden');
|
||
self::assertFalse(ThemeBuilderFixtures::access(['theme_builder'])->visible());
|
||
self::assertFalse(ThemeBuilderFixtures::access([], true, null, false)->visible());
|
||
|
||
LibraryFixtures::given([], ['edit_others_posts']);
|
||
self::assertToolError(ThemeBuilderFixtures::access()->canUse(), 'forbidden');
|
||
}
|
||
|
||
/** P12 pattern: without access a layout ID answers exactly like a missing one. */
|
||
public function testLayoutsAreHiddenLikeMissingIdsWithoutAccess(): void
|
||
{
|
||
$body = ThemeBuilderFixtures::layout(1062);
|
||
LibraryFixtures::given([$body, LibraryFixtures::page('')]);
|
||
|
||
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->layout(1062, false));
|
||
self::assertNull(ThemeBuilderFixtures::access()->layout(7, false), 'a page is no layout');
|
||
$missing = ThemeBuilderFixtures::access(['theme_builder'])->layout(1062, false);
|
||
$hidden = ThemeBuilderFixtures::access([], true, null, false)->layout(1062, false);
|
||
self::assertToolError($missing, 'not_found');
|
||
self::assertSame(self::clientError($missing), self::clientError(\LIT\MCP\Integrations\WordPress\PostGuard::notFound(1062)));
|
||
self::assertToolError($hidden, 'not_found');
|
||
|
||
LibraryFixtures::given([$body], ['edit_post']);
|
||
self::assertToolError(ThemeBuilderFixtures::access()->layout(1062, false), 'forbidden');
|
||
}
|
||
|
||
public function testWritingALayoutNeedsTheGroupAndAPublishedLayout(): void
|
||
{
|
||
LibraryFixtures::given([ThemeBuilderFixtures::layout(1062), ThemeBuilderFixtures::layout(1063, ['post_status' => 'trash'])]);
|
||
|
||
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->layout(1062, true));
|
||
self::assertToolError(ThemeBuilderFixtures::access([], false)->layout(1062, true), 'forbidden');
|
||
self::assertToolError(ThemeBuilderFixtures::access()->layout(1063, true), 'validation_failed');
|
||
}
|
||
|
||
public function testTemplatesAreTheOnlyTemplateIds(): void
|
||
{
|
||
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(), ThemeBuilderFixtures::layout(1062)]);
|
||
|
||
self::assertInstanceOf(WP_Post::class, ThemeBuilderFixtures::access()->template(1066, true));
|
||
self::assertToolError(ThemeBuilderFixtures::access()->template(1062, false), 'not_found');
|
||
self::assertToolError(ThemeBuilderFixtures::access()->template(999, false), 'not_found');
|
||
self::assertToolError(ThemeBuilderFixtures::access()->template('x', false), 'validation_failed');
|
||
self::assertToolError(ThemeBuilderFixtures::access(['theme_builder'])->template(1066, false), 'not_found');
|
||
self::assertToolError(ThemeBuilderFixtures::access([], false)->template(1066, true), 'forbidden');
|
||
}
|
||
|
||
public function testCreatingNeedsPublishRightsAndTheGroup(): void
|
||
{
|
||
LibraryFixtures::given([]);
|
||
self::assertNull(ThemeBuilderFixtures::access()->canCreate());
|
||
self::assertToolError(ThemeBuilderFixtures::access([], false)->canCreate(), 'forbidden');
|
||
|
||
LibraryFixtures::given([], ['publish_posts']);
|
||
self::assertToolError(ThemeBuilderFixtures::access()->canCreate(), 'forbidden');
|
||
}
|
||
|
||
/** E7: an unsaved Theme Builder session would later replace the template list. */
|
||
public function testAThemeBuilderDraftBlocksWrites(): void
|
||
{
|
||
self::assertNull(ThemeBuilderFixtures::access()->draftRefusal());
|
||
$blocked = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([], 999))->draftRefusal();
|
||
self::assertToolError($blocked, 'unsupported');
|
||
self::assertStringContainsString('Entwurf', self::clientError($blocked)['message']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviCompatibilityTest` ergänzen:
|
||
|
||
```php
|
||
public function testChecksTheThemeBuilderInternals(): void
|
||
{
|
||
$labels = array_keys(DiviCompatibility::defaults());
|
||
|
||
self::assertContains('Theme Builder: Funktionen et_theme_builder_* und et_()', $labels);
|
||
self::assertContains('Theme Builder: Post-Typen et_template und Layouts', $labels);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DiviThemeBuilderTest|TemplateTest|ThemeBuilderAccessTest|DiviCompatibilityTest'`
|
||
Expected: FAIL – „Class "LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder" not found“.
|
||
|
||
- [ ] **Step 4: Template**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/Template.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Abilities\Rev;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* A Theme Builder template as Divi reads it (et_theme_builder_get_template(), theme-builder.php:471-521;
|
||
* fixtures stage3/stage3b), plus whether the live Theme Builder post lists it (attached) and Divi's
|
||
* "unused" mark. rev() is template_rev, the lock of the Theme Builder tools. No WordPress calls.
|
||
*/
|
||
final class Template
|
||
{
|
||
public const AREAS = ['header', 'body', 'footer'];
|
||
|
||
/**
|
||
* @param array<string, array{id: int, enabled: bool}> $layouts
|
||
* @param list<string> $useOn
|
||
* @param list<string> $excludeFrom
|
||
*/
|
||
public function __construct(
|
||
public readonly int $id,
|
||
public readonly string $title,
|
||
public readonly string $status,
|
||
public readonly bool $default,
|
||
public readonly bool $enabled,
|
||
public readonly array $layouts,
|
||
public readonly array $useOn,
|
||
public readonly array $excludeFrom,
|
||
public readonly bool $attached,
|
||
public readonly ?string $markedUnused
|
||
) {
|
||
}
|
||
|
||
/** @param array<string, mixed> $divi et_theme_builder_get_template() */
|
||
public static function fromDivi(array $divi, string $status, bool $attached, ?string $markedUnused): self
|
||
{
|
||
$layouts = [];
|
||
foreach (self::AREAS as $area) {
|
||
$layout = is_array($divi['layouts'][$area] ?? null) ? $divi['layouts'][$area] : [];
|
||
$layouts[$area] = ['id' => (int) ($layout['id'] ?? 0), 'enabled' => (bool) ($layout['enabled'] ?? true)];
|
||
}
|
||
|
||
return new self(
|
||
(int) ($divi['id'] ?? 0),
|
||
(string) ($divi['title'] ?? ''),
|
||
$status,
|
||
(bool) ($divi['default'] ?? false),
|
||
(bool) ($divi['enabled'] ?? false),
|
||
$layouts,
|
||
self::strings($divi['use_on'] ?? []),
|
||
self::strings($divi['exclude_from'] ?? []),
|
||
$attached,
|
||
$markedUnused
|
||
);
|
||
}
|
||
|
||
/** Whether visitors can see it: listed by the live Theme Builder post, enabled and published. */
|
||
public function isLive(): bool
|
||
{
|
||
return $this->attached && $this->enabled && $this->status === 'publish';
|
||
}
|
||
|
||
/** Whether visitors see layout $layoutId as its $area through this template. */
|
||
public function showsLayout(string $area, int $layoutId): bool
|
||
{
|
||
return $this->isLive()
|
||
&& ($this->layouts[$area]['id'] ?? 0) === $layoutId
|
||
&& ($this->layouts[$area]['enabled'] ?? false);
|
||
}
|
||
|
||
/** template_rev: changes with everything the Theme Builder tools read or write. */
|
||
public function rev(): string
|
||
{
|
||
$state = [$this->id, $this->title, $this->enabled, $this->layouts];
|
||
|
||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode -- pure value object, no WordPress.
|
||
return Rev::of((string) json_encode(
|
||
array_merge($state, [$this->useOn, $this->excludeFrom, $this->attached]),
|
||
JSON_UNESCAPED_UNICODE
|
||
));
|
||
}
|
||
|
||
/** @return list<string> Post types of its singular conditions (singular:post_type:<type>:…). */
|
||
public function postTypes(): array
|
||
{
|
||
$types = [];
|
||
foreach ($this->useOn as $condition) {
|
||
$parts = explode(':', $condition);
|
||
if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') {
|
||
$types[] = $parts[2];
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($types));
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
public function toArray(): array
|
||
{
|
||
return [
|
||
'id' => $this->id,
|
||
'title' => $this->title,
|
||
'default' => $this->default,
|
||
'enabled' => $this->enabled,
|
||
'live' => $this->isLive(),
|
||
'attached' => $this->attached,
|
||
'marked_unused' => $this->markedUnused,
|
||
'template_rev' => $this->rev(),
|
||
'layouts' => $this->layouts,
|
||
'use_on' => $this->useOn,
|
||
'exclude_from' => $this->excludeFrom,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Live templates other than $exceptId that share a condition of $useOn (Divi then picks one of them).
|
||
*
|
||
* @param list<Template> $live
|
||
* @param list<string> $useOn
|
||
* @return list<array{id: int, title: string, conditions: list<string>}>
|
||
*/
|
||
public static function overlaps(array $live, int $exceptId, array $useOn): array
|
||
{
|
||
$overlaps = [];
|
||
foreach ($live as $other) {
|
||
if ($other->id === $exceptId || ! $other->isLive()) {
|
||
continue;
|
||
}
|
||
$shared = array_values(array_intersect($other->useOn, $useOn));
|
||
if ($shared !== []) {
|
||
$overlaps[] = ['id' => $other->id, 'title' => $other->title, 'conditions' => $shared];
|
||
}
|
||
}
|
||
|
||
return $overlaps;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private static function strings(mixed $list): array
|
||
{
|
||
return array_values(array_map('strval', array_filter(is_array($list) ? $list : [], 'is_scalar')));
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Adapter**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use RuntimeException;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter for Divi's Theme Builder (E7, E15; decisions note F14–F23; fixtures stage3/stage3b).
|
||
* Reading uses Divi's own functions (theme-builder.php:378-521). Writing:
|
||
* - layouts and new templates through et_theme_builder_insert_layout() and et_theme_builder_store_template()
|
||
* (theme-builder.php:1463-1667). Both wp_die() without edit_others_posts: the right is checked first,
|
||
* and while they run every wp_die handler throws instead (guarded(), defense in depth);
|
||
* - the live Theme Builder post's template list, conditions and the enabled flag are mirrored from
|
||
* et_theme_builder_api_save() (api.php:223-243; admin-ajax only, not loaded in REST requests, F20) and
|
||
* store_template (theme-builder.php:1545-1583) – store_template on an existing template would also
|
||
* rewrite its default flag, title and onboarding meta.
|
||
* Never touched: the Theme Builder draft (auto-draft) and et_theme_builder_trash_draft_and_unused_posts().
|
||
* Not final so abilities can be tested with a mock.
|
||
*/
|
||
class DiviThemeBuilder
|
||
{
|
||
public const TEMPLATE = 'et_template';
|
||
|
||
/** Area => layout post type (theme-builder.php:7-23). */
|
||
public const LAYOUT_TYPES = [
|
||
'header' => 'et_header_layout',
|
||
'body' => 'et_body_layout',
|
||
'footer' => 'et_footer_layout',
|
||
];
|
||
|
||
public const TEMPLATE_LIST = '_et_template';
|
||
public const USE_ON = '_et_use_on';
|
||
public const EXCLUDE_FROM = '_et_exclude_from';
|
||
public const ENABLED = '_et_enabled';
|
||
public const UNUSED = '_et_theme_builder_marked_as_unused';
|
||
public const BACKUP_OPTION = 'et_tb_templates_backup_';
|
||
|
||
/** Divi functions this adapter calls; DiviCompatibility reports missing ones. */
|
||
public const FUNCTIONS = [
|
||
'et_theme_builder_get_theme_builder_post_id',
|
||
'et_theme_builder_get_theme_builder_template_ids',
|
||
'et_theme_builder_get_template',
|
||
'et_theme_builder_insert_layout',
|
||
'et_theme_builder_store_template',
|
||
'et_theme_builder_get_flat_template_settings_options',
|
||
'et_theme_builder_clear_wp_cache',
|
||
'et_',
|
||
];
|
||
|
||
private const DIE_FILTERS = [
|
||
'wp_die_handler', 'wp_die_ajax_handler', 'wp_die_json_handler', 'wp_die_jsonp_handler',
|
||
'wp_die_xml_handler', 'wp_die_xmlrpc_handler',
|
||
];
|
||
|
||
/** "header", "body" or "footer" for a layout post type, else null. */
|
||
public static function areaOf(string $postType): ?string
|
||
{
|
||
$area = array_search($postType, self::LAYOUT_TYPES, true);
|
||
|
||
return is_string($area) ? $area : null;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function missing(): array
|
||
{
|
||
return array_values(array_filter(self::FUNCTIONS, static fn (string $name): bool => ! function_exists($name)));
|
||
}
|
||
|
||
/** The live Theme Builder post (publish, no library TB); $create makes it like Divi's first save. 0 = none. */
|
||
public function liveId(bool $create = false): int
|
||
{
|
||
return function_exists('et_theme_builder_get_theme_builder_post_id')
|
||
? (int) et_theme_builder_get_theme_builder_post_id(true, $create)
|
||
: 0;
|
||
}
|
||
|
||
/** The Theme Builder draft (auto-draft) of an unsaved Theme Builder session; 0 = none (F15). */
|
||
public function draftId(): int
|
||
{
|
||
return function_exists('et_theme_builder_get_theme_builder_post_id')
|
||
? (int) et_theme_builder_get_theme_builder_post_id(false, false)
|
||
: 0;
|
||
}
|
||
|
||
/** @return list<int> Templates listed by Theme Builder post $themeBuilderId, in Divi's order (F15). */
|
||
public function templateIds(int $themeBuilderId): array
|
||
{
|
||
if ($themeBuilderId <= 0 || ! function_exists('et_theme_builder_get_theme_builder_template_ids')) {
|
||
return [];
|
||
}
|
||
|
||
return array_values(array_map(
|
||
'intval',
|
||
(array) et_theme_builder_get_theme_builder_template_ids(true, $themeBuilderId)
|
||
));
|
||
}
|
||
|
||
/** Template $id as Divi reads it, null when it is no template. */
|
||
public function template(int $id, bool $attached): ?Template
|
||
{
|
||
if (! function_exists('et_theme_builder_get_template')) {
|
||
return null;
|
||
}
|
||
|
||
$divi = et_theme_builder_get_template($id);
|
||
if (! is_array($divi) || $divi === []) {
|
||
return null;
|
||
}
|
||
|
||
$marked = (string) get_post_meta($id, self::UNUSED, true);
|
||
|
||
return Template::fromDivi($divi, (string) get_post_status($id), $attached, $marked === '' ? null : $marked);
|
||
}
|
||
|
||
/** @return list<Template> The templates of the live Theme Builder post. */
|
||
public function liveTemplates(): array
|
||
{
|
||
$templates = [];
|
||
foreach ($this->templateIds($this->liveId()) as $id) {
|
||
$template = $this->template($id, true);
|
||
if ($template !== null) {
|
||
$templates[] = $template;
|
||
}
|
||
}
|
||
|
||
return $templates;
|
||
}
|
||
|
||
/**
|
||
* Published templates that no Theme Builder post lists (e.g. removed by a save in a Theme Builder
|
||
* that was opened before they were created; Divi marks and later trashes them, F19).
|
||
*
|
||
* @return list<int>
|
||
*/
|
||
public function detachedIds(int $limit): array
|
||
{
|
||
global $wpdb;
|
||
// phpcs:ignore WordPress.DB.DirectDatabaseQuery -- every _et_template row, live, draft and library TB posts.
|
||
$listed = array_map('intval', (array) $wpdb->get_col($wpdb->prepare(
|
||
"SELECT meta_value FROM {$wpdb->postmeta} WHERE meta_key = %s",
|
||
self::TEMPLATE_LIST
|
||
)));
|
||
$ids = get_posts([
|
||
'post_type' => self::TEMPLATE,
|
||
'post_status' => 'publish',
|
||
'posts_per_page' => $limit,
|
||
'orderby' => 'ID',
|
||
'order' => 'ASC',
|
||
'fields' => 'ids',
|
||
'no_found_rows' => true,
|
||
'post__not_in' => $listed === [] ? [0] : $listed,
|
||
]);
|
||
|
||
return array_values(array_map('intval', is_array($ids) ? $ids : []));
|
||
}
|
||
|
||
/** @throws RuntimeException */
|
||
public function insertLayout(string $area, string $title): int
|
||
{
|
||
$this->assertCanWrite();
|
||
$type = self::LAYOUT_TYPES[$area] ?? throw new RuntimeException('unknown Theme Builder area ' . $area);
|
||
$id = $this->guarded(
|
||
static fn (): mixed => et_theme_builder_insert_layout(['post_type' => $type, 'post_title' => $title])
|
||
);
|
||
if (! is_int($id) || $id <= 0) {
|
||
$reason = $id instanceof WP_Error ? $id->get_error_message() : 'no id';
|
||
throw new RuntimeException('et_theme_builder_insert_layout refused: ' . $reason);
|
||
}
|
||
|
||
return $id;
|
||
}
|
||
|
||
/**
|
||
* A new template through Divi's store_template (allow_default false) – always disabled and never the
|
||
* default. Divi drops layout IDs that are no published layout of the area; callers read it back.
|
||
*
|
||
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
|
||
* exclude_from: list<string>} $template
|
||
* @throws RuntimeException
|
||
*/
|
||
public function storeTemplate(int $themeBuilderId, array $template): int
|
||
{
|
||
$this->assertCanWrite();
|
||
$payload = [
|
||
'title' => $template['title'],
|
||
'autogenerated_title' => '0',
|
||
'default' => '0',
|
||
'enabled' => '0',
|
||
'layouts' => $template['layouts'],
|
||
'use_on' => $template['use_on'],
|
||
'exclude_from' => $template['exclude_from'],
|
||
];
|
||
$id = $this->guarded(static fn (): mixed => et_theme_builder_store_template($themeBuilderId, $payload, false));
|
||
if (! is_int($id) || $id <= 0) {
|
||
throw new RuntimeException('et_theme_builder_store_template stored no template');
|
||
}
|
||
|
||
return $id;
|
||
}
|
||
|
||
/**
|
||
* Appends $templateId to the list of Theme Builder post $themeBuilderId in et_theme_builder_api_save()'s
|
||
* order: the current list as backup option (readers use it meanwhile), the new row, backup removed.
|
||
*
|
||
* @throws RuntimeException
|
||
*/
|
||
public function attach(int $themeBuilderId, int $templateId): void
|
||
{
|
||
$existing = get_post_meta($themeBuilderId, self::TEMPLATE_LIST, false);
|
||
if (is_array($existing) && $existing !== []) {
|
||
update_option(self::BACKUP_OPTION . $themeBuilderId, $existing);
|
||
}
|
||
$added = add_post_meta($themeBuilderId, self::TEMPLATE_LIST, $templateId);
|
||
delete_option(self::BACKUP_OPTION . $themeBuilderId);
|
||
if ($added === false) {
|
||
throw new RuntimeException(
|
||
sprintf('template %d not added to Theme Builder %d', $templateId, $themeBuilderId)
|
||
);
|
||
}
|
||
}
|
||
|
||
public function detach(int $themeBuilderId, int $templateId): void
|
||
{
|
||
delete_post_meta($themeBuilderId, self::TEMPLATE_LIST, (string) $templateId);
|
||
}
|
||
|
||
/**
|
||
* Replaces both condition lists like store_template (theme-builder.php:1565-1583): delete, then one
|
||
* row per unique condition.
|
||
*
|
||
* @param list<string> $useOn
|
||
* @param list<string> $excludeFrom
|
||
* @throws RuntimeException
|
||
*/
|
||
public function setConditions(int $templateId, array $useOn, array $excludeFrom): void
|
||
{
|
||
foreach ([self::USE_ON => $useOn, self::EXCLUDE_FROM => $excludeFrom] as $key => $conditions) {
|
||
delete_post_meta($templateId, $key);
|
||
foreach (array_values(array_unique($conditions)) as $condition) {
|
||
if (add_post_meta($templateId, $key, $condition) === false) {
|
||
throw new RuntimeException(sprintf('%s of template %d not stored', $key, $templateId));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/** @throws RuntimeException */
|
||
public function setEnabled(int $templateId, bool $enabled): void
|
||
{
|
||
$value = $enabled ? '1' : '0';
|
||
update_post_meta($templateId, self::ENABLED, $value);
|
||
if ((string) get_post_meta($templateId, self::ENABLED, true) !== $value) {
|
||
throw new RuntimeException(sprintf('_et_enabled of template %d not stored', $templateId));
|
||
}
|
||
}
|
||
|
||
/** Removes Divi's "unused" mark (F19), as Divi's own save does for every listed post. */
|
||
public function unmarkUnused(int $postId): void
|
||
{
|
||
delete_post_meta($postId, self::UNUSED);
|
||
}
|
||
|
||
/**
|
||
* Divi's condition list (et_theme_builder_get_flat_template_settings_options()): ID => label and, for
|
||
* IDs that end in ":" and take an object ID or slug, the object type and its value (e.g. post_type/page).
|
||
*
|
||
* @return array<string, array{label: string, type: ?string, value: ?string}>
|
||
*/
|
||
public function flatConditions(): array
|
||
{
|
||
if (! function_exists('et_theme_builder_get_flat_template_settings_options')) {
|
||
return [];
|
||
}
|
||
|
||
$flat = [];
|
||
foreach ((array) et_theme_builder_get_flat_template_settings_options() as $id => $setting) {
|
||
if (! is_array($setting)) {
|
||
continue;
|
||
}
|
||
$options = is_array($setting['options'] ?? null) ? $setting['options'] : [];
|
||
$flat[(string) $id] = [
|
||
'label' => wp_strip_all_tags((string) ($setting['label'] ?? $id)),
|
||
'type' => is_string($options['type'] ?? null) ? $options['type'] : null,
|
||
'value' => is_string($options['value'] ?? null) ? $options['value'] : null,
|
||
];
|
||
}
|
||
|
||
return $flat;
|
||
}
|
||
|
||
/** Divi's Theme Builder cache and every static CSS file (spec §5.6 step 5; api.php:249-254). */
|
||
public function clearCaches(): void
|
||
{
|
||
if (function_exists('et_theme_builder_clear_wp_cache')) {
|
||
et_theme_builder_clear_wp_cache('all');
|
||
}
|
||
if (class_exists('ET_Core_PageResource')) {
|
||
\ET_Core_PageResource::remove_static_resources('all', 'all', true);
|
||
}
|
||
}
|
||
|
||
/** @throws RuntimeException */
|
||
private function assertCanWrite(): void
|
||
{
|
||
if (! current_user_can('edit_others_posts')) {
|
||
throw new RuntimeException('edit_others_posts missing – Divi would call wp_die()');
|
||
}
|
||
}
|
||
|
||
/** Runs $call with every wp_die handler replaced by one that throws (F17/F18). */
|
||
private function guarded(callable $call): mixed
|
||
{
|
||
$handler = static function (): callable {
|
||
return static function (mixed $message = ''): never {
|
||
throw new RuntimeException(
|
||
'Divi called wp_die(): ' . (is_string($message) ? $message : 'without message')
|
||
);
|
||
};
|
||
};
|
||
foreach (self::DIE_FILTERS as $filter) {
|
||
add_filter($filter, $handler, PHP_INT_MAX);
|
||
}
|
||
|
||
try {
|
||
return $call();
|
||
} finally {
|
||
foreach (self::DIE_FILTERS as $filter) {
|
||
remove_filter($filter, $handler, PHP_INT_MAX);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 6: Zugang**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Rights on Theme Builder objects (spec §4.5, E3 – never laxer than Divi): the Theme Builder at all needs
|
||
* edit_others_posts (Divi's own TB requirement, F19/F23) and the Divi role option theme_builder (off for
|
||
* every role but administrator by default, F12); one layout or template additionally edit_post on it.
|
||
* Without those, or with the group "Divi Theme Builder" disabled, every TB ID answers like a missing ID
|
||
* (P12 pattern). The group's "nur lesen" stops writes (E9). Not final so abilities can be tested with a mock.
|
||
*/
|
||
class ThemeBuilderAccess
|
||
{
|
||
/** Tool group "Divi Theme Builder" (Tools tab). */
|
||
public const GROUP = 'divi-theme-builder';
|
||
|
||
/**
|
||
* @param (Closure(): bool)|null $writable Group writable (neither "nur lesen" nor disabled); null = always.
|
||
* @param (Closure(): bool)|null $visible Group not disabled; null = always.
|
||
*/
|
||
public function __construct(
|
||
private DiviThemeBuilder $store,
|
||
private DiviRoles $roles,
|
||
private ?Closure $writable = null,
|
||
private ?Closure $visible = null
|
||
) {
|
||
}
|
||
|
||
public function store(): DiviThemeBuilder
|
||
{
|
||
return $this->store;
|
||
}
|
||
|
||
public function roles(): DiviRoles
|
||
{
|
||
return $this->roles;
|
||
}
|
||
|
||
public function canUse(): ?WP_Error
|
||
{
|
||
if (! current_user_can('edit_others_posts')) {
|
||
return Errors::forbidden('Keine Berechtigung für den Divi Theme Builder (edit_others_posts).');
|
||
}
|
||
|
||
return $this->roles->refusal('theme_builder');
|
||
}
|
||
|
||
/** Whether Theme Builder objects exist for this connection: group not disabled and canUse(). */
|
||
public function visible(): bool
|
||
{
|
||
return ($this->visible === null || ($this->visible)()) && $this->canUse() === null;
|
||
}
|
||
|
||
public function writableGate(): ?WP_Error
|
||
{
|
||
if ($this->writable === null || ($this->writable)()) {
|
||
return null;
|
||
}
|
||
|
||
return Errors::forbidden(
|
||
'Schreiben im Divi Theme Builder ist für diese Verbindung abgeschaltet (Gruppe „Divi Theme Builder“ auf '
|
||
. '„nur lesen“ oder deaktiviert).',
|
||
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Layout $postId for the Divi tools (E1): null when it is no Theme Builder layout (the caller goes on);
|
||
* like a missing ID without visible(); forbidden without edit_post; for writing also the group gate and
|
||
* a published layout (Divi's TB layouts are always published).
|
||
*/
|
||
public function layout(mixed $postId, bool $forWrite): WP_Post|WP_Error|null
|
||
{
|
||
$id = PostGuard::id($postId);
|
||
$post = $id === null ? null : get_post($id);
|
||
if (! $post instanceof WP_Post || DiviThemeBuilder::areaOf((string) $post->post_type) === null) {
|
||
return null;
|
||
}
|
||
|
||
$id = (int) $post->ID;
|
||
if (! $this->visible()) {
|
||
return PostGuard::notFound($id);
|
||
}
|
||
|
||
if (! current_user_can('edit_post', $id)) {
|
||
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Layout %d zu bearbeiten.', $id));
|
||
}
|
||
|
||
if (! $forWrite) {
|
||
return $post;
|
||
}
|
||
|
||
$gate = $this->writableGate();
|
||
if ($gate !== null) {
|
||
return $gate;
|
||
}
|
||
|
||
if ((string) $post->post_status !== 'publish') {
|
||
return Errors::invalidInput(
|
||
sprintf(
|
||
'Theme-Builder-Layout %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
|
||
$id,
|
||
(string) $post->post_status
|
||
),
|
||
['post_id' => 'nur aktive Theme-Builder-Layouts']
|
||
);
|
||
}
|
||
|
||
return $post;
|
||
}
|
||
|
||
/** Template $templateId for the Theme Builder tools; any other ID, or a hidden Theme Builder, is not found. */
|
||
public function template(mixed $templateId, bool $forWrite): WP_Post|WP_Error
|
||
{
|
||
$id = PostGuard::id($templateId);
|
||
if ($id === null) {
|
||
return Errors::invalidInput(
|
||
'template_id fehlt oder ist keine ID.',
|
||
['template_id' => 'ID aus lit/tb-list']
|
||
);
|
||
}
|
||
|
||
$post = get_post($id);
|
||
if (
|
||
! $post instanceof WP_Post
|
||
|| $post->post_type !== DiviThemeBuilder::TEMPLATE
|
||
|| (string) $post->post_status !== 'publish'
|
||
|| ! $this->visible()
|
||
) {
|
||
return Errors::notFound(
|
||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
||
'Vorlagen mit lit/tb-list auflisten.'
|
||
);
|
||
}
|
||
|
||
if (! current_user_can('edit_post', $id)) {
|
||
return Errors::forbidden(sprintf('Keine Berechtigung, Theme-Builder-Vorlage %d zu bearbeiten.', $id));
|
||
}
|
||
|
||
return $forWrite ? ($this->writableGate() ?? $post) : $post;
|
||
}
|
||
|
||
/**
|
||
* Creating a template with layouts: canUse(), the group gate and publish_posts of the template and
|
||
* layout types – Divi publishes them at once (F17, F18).
|
||
*/
|
||
public function canCreate(): ?WP_Error
|
||
{
|
||
$refusal = $this->canUse() ?? $this->writableGate();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
foreach (array_merge([DiviThemeBuilder::TEMPLATE], array_values(DiviThemeBuilder::LAYOUT_TYPES)) as $type) {
|
||
$object = get_post_type_object($type);
|
||
$cap = is_object($object) && isset($object->cap->publish_posts)
|
||
? (string) $object->cap->publish_posts
|
||
: 'publish_posts';
|
||
if (! current_user_can($cap)) {
|
||
return Errors::forbidden(
|
||
sprintf('Keine Berechtigung, Inhalte des Theme Builders zu veröffentlichen (%s).', $cap)
|
||
);
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** E7: while the Theme Builder has unsaved changes (draft), its later save would replace the template list. */
|
||
public function draftRefusal(): ?WP_Error
|
||
{
|
||
if ($this->store->draftId() <= 0) {
|
||
return null;
|
||
}
|
||
|
||
return Errors::unsupported(
|
||
'Der Divi Theme Builder hat ungespeicherte Änderungen (Entwurf); solange ändert LIT MCP keine Vorlagen – '
|
||
. 'ein späteres Speichern dort würde die Zuordnung ersetzen. Nichts wurde gespeichert.',
|
||
'Im Theme Builder (Divi → Theme Builder) speichern oder die Änderungen verwerfen, dann erneut aufrufen.'
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: Kompatibilitätsprüfung**
|
||
|
||
In `DiviCompatibility::defaults()` am Ende des Arrays (`use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;`):
|
||
|
||
```php
|
||
'Theme Builder: Funktionen et_theme_builder_* und et_()' =>
|
||
static fn (): bool => (new DiviThemeBuilder())->missing() === [],
|
||
'Theme Builder: Post-Typen et_template und Layouts' => static fn (): bool => array_filter(
|
||
array_merge([DiviThemeBuilder::TEMPLATE], array_values(DiviThemeBuilder::LAYOUT_TYPES)),
|
||
static fn (string $type): bool => ! post_type_exists($type)
|
||
) === [],
|
||
```
|
||
|
||
- [ ] **Step 8: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DiviThemeBuilderTest|TemplateTest|ThemeBuilderAccessTest|DiviCompatibilityTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 9: Live-Probe der Kompatibilität**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev wp eval 'var_export((new LIT\MCP\Integrations\Divi\DiviCompatibility())->problems());'`
|
||
Expected: `array ( )`
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/ThemeBuilder src/Integrations/Divi/DiviCompatibility.php tests/Support/ThemeBuilderFixtures.php tests/Unit/Integrations/Divi/ThemeBuilder tests/Unit/Integrations/Divi/DiviCompatibilityTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: Theme Builder adapter, template value and per-object access`.
|
||
|
||
---
|
||
|
||
### Task 5: TB-Layouts in den Divi-Werkzeugen
|
||
|
||
Alle Divi-Werkzeuge (`lit-divi-outline`, `-node-get`, `-insert`, `-update`, `-move`, `-delete`, `lit/divi-duplicate-node`, `lit/divi-node-copy`, `lit-library-insert`) nehmen TB-Layout-IDs an (E1, Spec §5.1). Layouts, die eine aktive Vorlage zeigt, ändern die Website sofort – daher Probelauf + `confirm`. Module mit `appearance.themeBuilderLayouts` (z. B. `divi/post-content`, nur `body`) sind in anderen TB-Bereichen nicht einfügbar (F22, Divis Render-Regel `ModuleRegistration.php:509-527`).
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Pages/DiviPage.php`, `DiviPageLoader.php`, `SaveScope.php`, `DiviSaver.php` (Doc), `DiviComposition.php`, `DiviWriter.php`, `DiviWriteAbility.php`
|
||
- Modify: `src/Integrations/Divi/Compose/StructureRules.php`, `src/Integrations/Divi/Schema/ModuleCatalog.php`
|
||
- Create: `src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php`
|
||
- Modify: `tests/Integration/divi-metadata-fixture.php`, `tests/Fixtures/divi/metadata-sample.json` (neu erzeugt)
|
||
- Modify: `tests/Support/DiviWriterFixture.php`, `tests/Support/ThemeBuilderFixtures.php` (`loader()`)
|
||
- Test: `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `SaveScopeTest.php` (neu), `DiviWriterThemeBuilderTest.php` (neu); `tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderEditsTest.php` (neu); `tests/Unit/Integrations/Divi/Compose/StructureRulesTest.php`; `tests/Unit/Integrations/Divi/Schema/ModuleCatalogTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 4 (`ThemeBuilderAccess::layout()`, `DiviThemeBuilder::areaOf()/liveTemplates()`, `Template::showsLayout()`).
|
||
- Produces:
|
||
- `DiviPage::__construct(WP_Post $post, Document $document, NodeIndex $index, bool $libraryItem = false, ?string $themeBuilderArea = null)`; `isThemeBuilderLayout(): bool`; `themeBuilderArea(): ?string`.
|
||
- `DiviPageLoader::__construct(ContentSchema $schema, Parser $parser = new Parser(), ?LibraryAccess $library = null, ?ThemeBuilderAccess $themeBuilder = null)`.
|
||
- `SaveScope::ThemeBuilderLayout` (setzt Divi-Kennzeichen wie der Builder, Befund 4; leert CSS aller Seiten und den TB-Cache).
|
||
- `DiviComposition::forPost(int $postId, ?string $themeBuilderArea = null): static`.
|
||
- `StructureRules::__construct(ModuleCatalog $catalog, ?string $themeBuilderArea = null)`; `ModuleCatalog::themeBuilderAreas(string $name): ?array`.
|
||
- `ThemeBuilderEdits::__construct(ThemeBuilderAccess $access)`; `showing(DiviPage $page): list<Template>`; `prepare(DiviPage $page, array $input): array{scope: SaveScope, details: array}|WP_Error`; `notice(DiviPage $page, WP_Error $error): WP_Error`.
|
||
- `DiviWriter::__construct(DiviPageLoader, DiviSaver, ChangeLog, DesignSystemReader, DiviComposition, ?GlobalEdits $globals = null, ?ThemeBuilderEdits $themeBuilder = null)`; Schreibantworten auf TB-Layouts tragen `templates` (Liste `{id, title, use_on}` der aktiven Vorlagen, die das Layout zeigen).
|
||
- `DiviIntegration::__construct(..., ?Closure $libraryInsertable = null, ?Closure $themeBuilderWritable = null, ?Closure $themeBuilderVisible = null)`.
|
||
- `DiviWriterFixture::writer(array &$saved, array &$failures = [], bool $unfiltered = false, ?DiviPageLoader $loader = null, ?GlobalEdits $globals = null, ?ThemeBuilderEdits $themeBuilder = null)`; `ThemeBuilderFixtures::loader(?ThemeBuilderAccess $access = null): DiviPageLoader`.
|
||
|
||
- [ ] **Step 1: Katalog-Fixture um `divi/post-content` erweitern**
|
||
|
||
In `tests/Integration/divi-metadata-fixture.php`: `'post-content'` an `$wanted` anhängen und im `array_filter([...])` nach `'childModuleName' => …` ergänzen:
|
||
|
||
```php
|
||
'appearance' => $module['appearance'] ?? null,
|
||
```
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-metadata-fixture.php && cd wp-content/plugins/WP-LIT-MCP && git diff --stat tests/Fixtures/divi/metadata-sample.json && grep -c themeBuilderLayouts tests/Fixtures/divi/metadata-sample.json`
|
||
Expected: `Success: metadata-sample.json: 16 modules …`; nur Zusätze in der Datei; `1`.
|
||
|
||
- [ ] **Step 2: Failing tests**
|
||
|
||
`ModuleCatalogTest` ergänzen:
|
||
|
||
```php
|
||
public function testThemeBuilderAreasFromDivisAppearance(): void
|
||
{
|
||
$catalog = DiviFixtures::catalog();
|
||
|
||
self::assertSame(['body'], $catalog->themeBuilderAreas('divi/post-content'));
|
||
self::assertNull($catalog->themeBuilderAreas('divi/text'));
|
||
}
|
||
```
|
||
|
||
`StructureRulesTest` ergänzen:
|
||
|
||
```php
|
||
/** F22: Divi renders divi/post-content only in body layouts (appearance.themeBuilderLayouts). */
|
||
public function testModulesLimitedToThemeBuilderAreas(): void
|
||
{
|
||
$catalog = DiviFixtures::catalog();
|
||
$column = Block::create('divi/column');
|
||
|
||
self::assertNull((new StructureRules($catalog, 'body'))->violation($column, 'divi/post-content'));
|
||
self::assertSame(
|
||
'divi/post-content zeigt Divi im Bereich „header“ nicht (nur body)',
|
||
(new StructureRules($catalog, 'header'))->violation($column, 'divi/post-content')
|
||
);
|
||
self::assertNull((new StructureRules($catalog))->violation($column, 'divi/post-content'), 'pages: Divi decides');
|
||
self::assertNull((new StructureRules($catalog, 'footer'))->violation($column, 'divi/text'));
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Pages/SaveScopeTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class SaveScopeTest extends TestCase
|
||
{
|
||
/** Finding 4: TB layouts carry Divi's flags after a builder save; a layout change shows site-wide (§5.6). */
|
||
public function testThemeBuilderLayoutsGetFlagsAndClearEveryPage(): void
|
||
{
|
||
self::assertTrue(SaveScope::ThemeBuilderLayout->setsFlags());
|
||
self::assertTrue(SaveScope::ThemeBuilderLayout->clearsSiteCss());
|
||
self::assertTrue(SaveScope::Page->setsFlags());
|
||
self::assertFalse(SaveScope::Page->clearsSiteCss());
|
||
self::assertFalse(SaveScope::LibraryItem->setsFlags());
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviPageLoaderTest` ergänzen (`use LIT\MCP\Integrations\Divi\Pages\DiviPage;`, `use LIT\MCP\Tests\Support\ThemeBuilderFixtures;`):
|
||
|
||
```php
|
||
public function testThemeBuilderLayoutsNeedTheThemeBuilderRole(): void
|
||
{
|
||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||
LibraryFixtures::given([$body]);
|
||
|
||
$page = ThemeBuilderFixtures::loader()->load(1062, true);
|
||
self::assertInstanceOf(DiviPage::class, $page);
|
||
self::assertTrue($page->isThemeBuilderLayout());
|
||
self::assertFalse($page->isLibraryItem());
|
||
self::assertSame('body', $page->themeBuilderArea());
|
||
self::assertNull(ThemeBuilderFixtures::loader()->writable($page), 'no Divi-enabled check for TB layouts');
|
||
|
||
self::assertToolError(
|
||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access(['theme_builder']))->load(1062, false),
|
||
'not_found'
|
||
);
|
||
self::assertToolError(
|
||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access([], true, null, false))->load(1062, false),
|
||
'not_found'
|
||
);
|
||
self::assertToolError(
|
||
ThemeBuilderFixtures::loader(ThemeBuilderFixtures::access([], false))->load(1062, true),
|
||
'forbidden'
|
||
);
|
||
// Without a Theme Builder access the layout is no managed content (PostGuard).
|
||
self::assertToolError(LibraryFixtures::loader()->load(1062, false), 'not_found');
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderEditsTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class ThemeBuilderEditsTest extends TestCase
|
||
{
|
||
private static function page(int $id, string $area): DiviPage
|
||
{
|
||
$post = ThemeBuilderFixtures::layout($id);
|
||
$document = (new Parser())->parse((string) $post->post_content);
|
||
|
||
return new DiviPage($post, $document, NodeIndex::build($document), false, $area);
|
||
}
|
||
|
||
/** @param list<Template> $templates */
|
||
private static function edits(array $templates): ThemeBuilderEdits
|
||
{
|
||
return new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates)));
|
||
}
|
||
|
||
public function testLiveLayoutNeedsConfirmAndNamesTheTemplates(): void
|
||
{
|
||
$edits = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true])]);
|
||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||
|
||
$dry = $edits->prepare($page, []);
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertStringContainsString('sichtbar', self::clientError($dry)['message']);
|
||
self::assertSame(
|
||
[['id' => 1066, 'title' => '[Fixture] Vorlage Globale Instanzen', 'use_on' => ['singular:post_type:page:id:1053']]],
|
||
$dry->get_error_data()['templates']
|
||
);
|
||
|
||
$confirmed = $edits->prepare($page, ['confirm' => true]);
|
||
self::assertSame(SaveScope::ThemeBuilderLayout, $confirmed['scope']);
|
||
self::assertCount(1, $confirmed['details']['templates']);
|
||
}
|
||
|
||
/** The global header: the default template shows it once it is enabled. */
|
||
public function testTheDefaultTemplatesHeaderIsLiveWhenItIsEnabled(): void
|
||
{
|
||
$page = self::page(ThemeBuilderFixtures::HEADER, 'header');
|
||
|
||
self::assertSame([], self::edits([ThemeBuilderFixtures::template(1065)])->showing($page));
|
||
self::assertCount(1, self::edits([ThemeBuilderFixtures::template(1065, ['enabled' => true])])->showing($page));
|
||
}
|
||
|
||
public function testLayoutsOfDisabledOrDetachedTemplatesSaveAtOnce(): void
|
||
{
|
||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||
$prepared = self::edits([
|
||
ThemeBuilderFixtures::template(1066),
|
||
ThemeBuilderFixtures::template(1067, ['enabled' => true, 'attached' => false]),
|
||
])->prepare($page, []);
|
||
|
||
self::assertSame(SaveScope::ThemeBuilderLayout, $prepared['scope']);
|
||
self::assertSame(['templates' => []], $prepared['details']);
|
||
}
|
||
|
||
public function testNoticeExtendsAToolsOwnConfirm(): void
|
||
{
|
||
$page = self::page(ThemeBuilderFixtures::BODY, 'body');
|
||
$own = Errors::confirmRequired('3 Knoten löschen? Nichts wurde gelöscht.', ['ids' => ['s0']]);
|
||
|
||
$merged = self::edits([ThemeBuilderFixtures::template(1066, ['enabled' => true])])->notice($page, $own);
|
||
|
||
self::assertToolError($merged, 'confirm_required');
|
||
self::assertStringContainsString('3 Knoten löschen?', self::clientError($merged)['message']);
|
||
self::assertStringContainsString('sichtbar', self::clientError($merged)['message']);
|
||
self::assertSame(['s0'], $merged->get_error_data()['ids']);
|
||
self::assertCount(1, $merged->get_error_data()['templates']);
|
||
self::assertSame($own, self::edits([])->notice($page, $own));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Pages/DiviWriterThemeBuilderTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
|
||
/** The Divi write tools on Theme Builder layouts (E1, Task 5). */
|
||
final class DiviWriterThemeBuilderTest extends TestCase
|
||
{
|
||
/** @var list<string> */
|
||
private array $saved = [];
|
||
|
||
private WP_Post $body;
|
||
|
||
private WP_Post $header;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY);
|
||
$this->header = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::HEADER);
|
||
LibraryFixtures::given([$this->body, $this->header]);
|
||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||
}
|
||
|
||
/** @param list<Template> $templates */
|
||
private function writer(array $templates): DiviWriter
|
||
{
|
||
$access = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store($templates));
|
||
$failures = [];
|
||
|
||
return DiviWriterFixture::writer(
|
||
$this->saved,
|
||
$failures,
|
||
false,
|
||
ThemeBuilderFixtures::loader($access),
|
||
null,
|
||
new ThemeBuilderEdits($access)
|
||
);
|
||
}
|
||
|
||
/** @param array<string, mixed> $extra @return array<string, mixed> */
|
||
private function insert(WP_Post $post, array $extra): array
|
||
{
|
||
return array_merge([
|
||
'post_id' => (int) $post->ID,
|
||
'tree_rev' => Rev::of((string) $post->post_content),
|
||
'parent' => 's0.r0.c0',
|
||
'nodes' => [['type' => 'text', 'content' => 'Zusatz']],
|
||
], $extra);
|
||
}
|
||
|
||
public function testInsertIntoALiveLayoutIsADryRunFirst(): void
|
||
{
|
||
$ability = new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])]));
|
||
|
||
$dry = $ability->execute($this->insert($this->body, []));
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertSame([], $this->saved);
|
||
|
||
$result = $ability->execute($this->insert($this->body, ['confirm' => true]));
|
||
self::assertTrue($result['ok']);
|
||
self::assertCount(1, $this->saved);
|
||
self::assertSame(1066, $result['templates'][0]['id']);
|
||
}
|
||
|
||
public function testLayoutOfADisabledTemplateSavesAtOnce(): void
|
||
{
|
||
$result = (new DiviInsertAbility($this->writer([ThemeBuilderFixtures::template()])))
|
||
->execute($this->insert($this->body, []));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame([], $result['templates']);
|
||
self::assertStringContainsString('Zusatz', $this->saved[0]);
|
||
}
|
||
|
||
public function testDeleteInALiveLayoutAsksOnceForBoth(): void
|
||
{
|
||
$ability = new DiviDeleteAbility($this->writer([ThemeBuilderFixtures::template(1066, ['enabled' => true])]));
|
||
$input = ['post_id' => 1062, 'tree_rev' => Rev::of((string) $this->body->post_content), 'ids' => ['s0.r0.c0.m1']];
|
||
|
||
$dry = $ability->execute($input);
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertArrayHasKey('templates', $dry->get_error_data());
|
||
|
||
$result = $ability->execute($input + ['confirm' => true]);
|
||
self::assertTrue($result['ok']);
|
||
self::assertCount(1, $this->saved);
|
||
}
|
||
|
||
public function testPostContentOnlyInTheBody(): void
|
||
{
|
||
$ability = new DiviInsertAbility($this->writer([]));
|
||
$postContent = ['nodes' => [['type' => 'post-content']]];
|
||
|
||
self::assertToolError($ability->execute($this->insert($this->header, $postContent)), 'validation_failed');
|
||
self::assertTrue($ability->execute($this->insert($this->body, $postContent))['ok']);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 3: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'ModuleCatalogTest|StructureRulesTest|SaveScopeTest|DiviPageLoaderTest|ThemeBuilderEditsTest|DiviWriterThemeBuilderTest'`
|
||
Expected: FAIL – „Call to undefined method … themeBuilderAreas()“, „Undefined constant … ThemeBuilderLayout“, „Class … ThemeBuilderEdits not found“.
|
||
|
||
- [ ] **Step 4: Katalog und Strukturregel**
|
||
|
||
`ModuleCatalog` nach `childrenOf()`:
|
||
|
||
```php
|
||
/**
|
||
* Theme Builder areas Divi renders the module in (appearance.themeBuilderLayouts, e.g. divi/post-content:
|
||
* ["body"]; ModuleRegistration.php:509-527); null = no limit.
|
||
*
|
||
* @return list<string>|null
|
||
*/
|
||
public function themeBuilderAreas(string $name): ?array
|
||
{
|
||
$areas = $this->modules[$name]['appearance']['themeBuilderLayouts'] ?? null;
|
||
if (! is_array($areas)) {
|
||
return null;
|
||
}
|
||
|
||
$areas = array_values(array_filter($areas, 'is_string'));
|
||
|
||
return $areas === [] ? null : $areas;
|
||
}
|
||
```
|
||
|
||
`StructureRules`: Konstruktor und Prüfung (Klassen-Doc um „F22: in Theme-Builder-Layouts nur Module, die Divi dort rendert“ ergänzen):
|
||
|
||
```php
|
||
/** @param ?string $themeBuilderArea "header", "body" or "footer" when the entry is a Theme Builder layout. */
|
||
public function __construct(private ModuleCatalog $catalog, private ?string $themeBuilderArea = null)
|
||
{
|
||
}
|
||
```
|
||
|
||
In `violation()` direkt nach der `has()`-Prüfung:
|
||
|
||
```php
|
||
$area = $this->areaViolation($child);
|
||
if ($area !== null) {
|
||
return $area;
|
||
}
|
||
```
|
||
|
||
und als private Methode:
|
||
|
||
```php
|
||
/** F22: in a Theme Builder layout, a module Divi renders only in other areas (Divi's own render rule). */
|
||
private function areaViolation(string $child): ?string
|
||
{
|
||
$areas = $this->catalog->themeBuilderAreas($child);
|
||
if ($areas === null || $this->themeBuilderArea === null || in_array($this->themeBuilderArea, $areas, true)) {
|
||
return null;
|
||
}
|
||
|
||
return sprintf(
|
||
'%s zeigt Divi im Bereich „%s“ nicht (nur %s)',
|
||
$child,
|
||
$this->themeBuilderArea,
|
||
implode(', ', $areas)
|
||
);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Seite, Loader, Speicherbereich, Komposition**
|
||
|
||
`DiviPage`: Konstruktor und neue Methoden (`use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;` nicht nötig):
|
||
|
||
```php
|
||
public function __construct(
|
||
public readonly WP_Post $post,
|
||
public readonly Document $document,
|
||
public readonly NodeIndex $index,
|
||
private bool $libraryItem = false,
|
||
private ?string $themeBuilderArea = null
|
||
) {
|
||
}
|
||
|
||
/** Whether the entry is a Theme Builder layout (header, body or footer), loaded through ThemeBuilderAccess. */
|
||
public function isThemeBuilderLayout(): bool
|
||
{
|
||
return $this->themeBuilderArea !== null;
|
||
}
|
||
|
||
/** "header", "body" or "footer" for a Theme Builder layout, else null. */
|
||
public function themeBuilderArea(): ?string
|
||
{
|
||
return $this->themeBuilderArea;
|
||
}
|
||
```
|
||
|
||
`editUrl()`:
|
||
|
||
```php
|
||
public function editUrl(): string
|
||
{
|
||
if ($this->themeBuilderArea !== null) {
|
||
// Divi opens TB layouts from the Theme Builder screen (the builder URL needs its nonce).
|
||
return (string) admin_url('admin.php?page=et_theme_builder');
|
||
}
|
||
|
||
// A library entry has no public permalink (public: false); Divi's library list opens it here.
|
||
return $this->libraryItem
|
||
? (string) get_edit_post_link($this->post, 'raw')
|
||
: self::builderUrl($this->post);
|
||
}
|
||
```
|
||
|
||
`DiviPageLoader`: Konstruktor um `private ?ThemeBuilderAccess $themeBuilder = null` erweitern (`use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;`, `use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;`), in `load()` den Anfang bis `$content = …` ersetzen:
|
||
|
||
```php
|
||
// Library entries (E1) and Theme Builder layouts (Etappe 3b) through their access, everything else PostGuard.
|
||
$libraryItem = $this->library?->item($postId, $forWrite);
|
||
$layout = $libraryItem === null ? $this->themeBuilder?->layout($postId, $forWrite) : null;
|
||
$post = $libraryItem ?? $layout ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
|
||
if ($post instanceof WP_Error) {
|
||
return $post;
|
||
}
|
||
```
|
||
|
||
und die Rückgabe am Ende:
|
||
|
||
```php
|
||
$area = $layout instanceof WP_Post ? DiviThemeBuilder::areaOf((string) $layout->post_type) : null;
|
||
|
||
return new DiviPage($post, $document, NodeIndex::build($document), $libraryItem instanceof WP_Post, $area);
|
||
```
|
||
|
||
In `writable()` die erste Bedingung:
|
||
|
||
```php
|
||
$builderPost = $page->isLibraryItem() || $page->isThemeBuilderLayout();
|
||
if (! $builderPost && ! $this->schema->diviEnabled((string) $post->post_type)) {
|
||
```
|
||
|
||
`SaveScope`:
|
||
|
||
```php
|
||
/** A Theme Builder layout: Divi flags like the builder (finding 4); shown site-wide – all CSS, TB cache. */
|
||
case ThemeBuilderLayout;
|
||
|
||
public function setsFlags(): bool
|
||
{
|
||
return $this === self::Page || $this === self::ThemeBuilderLayout;
|
||
}
|
||
|
||
public function clearsSiteCss(): bool
|
||
{
|
||
return $this === self::GlobalItem || $this === self::ThemeBuilderLayout;
|
||
}
|
||
```
|
||
|
||
`DiviSaver`: Klassen-Doc „SaveScope: …“ um „a Theme Builder layout gets the Divi flags and clears like a global entry“ ergänzen (kein Code).
|
||
|
||
`DiviComposition`: Feld `private ?string $themeBuilderArea = null;`, `forPost()`:
|
||
|
||
```php
|
||
/** A fresh composition for entry $postId (own VariableRef with its live map; TB area for StructureRules). */
|
||
public function forPost(int $postId, ?string $themeBuilderArea = null): static
|
||
{
|
||
$bound = clone $this;
|
||
$bound->postId = $postId;
|
||
$bound->themeBuilderArea = $themeBuilderArea;
|
||
$bound->references = null;
|
||
$bound->placeholders = null;
|
||
$bound->cleaner = null;
|
||
$bound->attrWriter = null;
|
||
$bound->composer = null;
|
||
|
||
return $bound;
|
||
}
|
||
```
|
||
|
||
und `rules()`:
|
||
|
||
```php
|
||
return new StructureRules($this->modules->catalog(), $this->themeBuilderArea);
|
||
```
|
||
|
||
- [ ] **Step 6: ThemeBuilderEdits**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The extra rule of a Divi write to a Theme Builder layout (Etappe 3b, constraint "sichtbare Website nur mit
|
||
* ausdrücklicher Freigabe"): Divi reads TB layouts live (F22), so a layout that an enabled template of the live
|
||
* Theme Builder shows changes the website at once – without confirm: true nothing is saved and the answer lists
|
||
* those templates (dry run of the fully validated change, like global modules, answer F2). Layouts of disabled or
|
||
* detached templates save at once. The save scope clears every page's CSS and the Theme Builder cache.
|
||
*/
|
||
final class ThemeBuilderEdits
|
||
{
|
||
public function __construct(private ThemeBuilderAccess $access)
|
||
{
|
||
}
|
||
|
||
/** @return list<Template> Live templates that show layout $page in its area. */
|
||
public function showing(DiviPage $page): array
|
||
{
|
||
$area = (string) $page->themeBuilderArea();
|
||
$id = (int) $page->post->ID;
|
||
|
||
return array_values(array_filter(
|
||
$this->access->store()->liveTemplates(),
|
||
static fn (Template $template): bool => $template->showsLayout($area, $id)
|
||
));
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array{scope: SaveScope, details: array<string, mixed>}|WP_Error
|
||
*/
|
||
public function prepare(DiviPage $page, array $input): array|WP_Error
|
||
{
|
||
$templates = $this->showing($page);
|
||
$details = ['templates' => self::listing($templates)];
|
||
if ($templates !== [] && ($input['confirm'] ?? false) !== true) {
|
||
return Errors::confirmRequired(
|
||
sprintf(
|
||
'Theme-Builder-Layout %d ist auf der Website sichtbar (%s, siehe templates); die Änderung wirkt '
|
||
. 'sofort. Nichts wurde gespeichert (Probelauf).',
|
||
(int) $page->post->ID,
|
||
self::count($templates)
|
||
),
|
||
$details
|
||
);
|
||
}
|
||
|
||
return ['scope' => SaveScope::ThemeBuilderLayout, 'details' => $details];
|
||
}
|
||
|
||
/** A tool's own confirm_required (lit-divi-delete) on a visible layout, extended by the templates. */
|
||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||
{
|
||
$templates = Errors::codeOf($error) === 'confirm_required' ? $this->showing($page) : [];
|
||
if ($templates === []) {
|
||
return $error;
|
||
}
|
||
|
||
$data = $error->get_error_data();
|
||
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
|
||
|
||
return Errors::confirmRequired(
|
||
Errors::clientMessage($error)
|
||
. sprintf(' Das Layout ist auf der Website sichtbar (%s, siehe templates).', self::count($templates)),
|
||
array_merge($details, ['templates' => self::listing($templates)])
|
||
);
|
||
}
|
||
|
||
/** @param list<Template> $templates */
|
||
private static function count(array $templates): string
|
||
{
|
||
return sprintf('%d aktive %s', count($templates), count($templates) === 1 ? 'Vorlage' : 'Vorlagen');
|
||
}
|
||
|
||
/**
|
||
* @param list<Template> $templates
|
||
* @return list<array{id: int, title: string, use_on: list<string>}>
|
||
*/
|
||
private static function listing(array $templates): array
|
||
{
|
||
return array_map(
|
||
static fn (Template $template): array => [
|
||
'id' => $template->id,
|
||
'title' => $template->title,
|
||
'use_on' => $template->useOn,
|
||
],
|
||
$templates
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 7: DiviWriter und Schreibschema**
|
||
|
||
`DiviWriter`: Konstruktor um `private ?ThemeBuilderEdits $themeBuilder = null` ergänzen (nach `$globals`; `use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;`). `composition()`:
|
||
|
||
```php
|
||
return $this->composition->forPost((int) $page->post->ID, $page->themeBuilderArea());
|
||
```
|
||
|
||
In `commit()` als erste Zeilen:
|
||
|
||
```php
|
||
if ($page->isThemeBuilderLayout()) {
|
||
return $this->commitLayout($ability, $page, $change, $input);
|
||
}
|
||
```
|
||
|
||
neue Methode:
|
||
|
||
```php
|
||
/**
|
||
* save() for a Theme Builder layout: a layout visitors see needs confirm (ThemeBuilderEdits); the result
|
||
* names the templates that show it.
|
||
*
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed
|
||
*/
|
||
private function commitLayout(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
|
||
{
|
||
if ($this->themeBuilder === null) {
|
||
return Errors::unsupported(
|
||
'Theme-Builder-Layouts lassen sich über diese Verbindung nicht ändern.',
|
||
'Das Layout im Divi Theme Builder bearbeiten.'
|
||
);
|
||
}
|
||
|
||
if (Serializer::document($page->document) === (string) $page->post->post_content) {
|
||
return $this->save($ability, $page, $change, $input, SaveScope::ThemeBuilderLayout);
|
||
}
|
||
|
||
$prepared = $this->themeBuilder->prepare($page, $input);
|
||
if ($prepared instanceof WP_Error) {
|
||
return $prepared;
|
||
}
|
||
|
||
return array_merge($this->save($ability, $page, $change, $input, $prepared['scope']), $prepared['details']);
|
||
}
|
||
```
|
||
|
||
`notice()`:
|
||
|
||
```php
|
||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||
{
|
||
if ($page->isThemeBuilderLayout()) {
|
||
return $this->themeBuilder?->notice($page, $error) ?? $error;
|
||
}
|
||
|
||
return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error;
|
||
}
|
||
```
|
||
|
||
`DiviWriteAbility::outputSchema()` um `'templates' => ['type' => 'array', 'items' => ['type' => 'object']],` ergänzen (Kommentar: „Theme Builder layouts: the enabled templates that show the layout“).
|
||
|
||
- [ ] **Step 8: Verdrahtung und Test-Hilfen**
|
||
|
||
`DiviIntegration` (`use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;`, `…\ThemeBuilderAccess;`, `…\ThemeBuilderEdits;`): Feld `private ThemeBuilderAccess $themeBuilderAccess;`, Konstruktor-Parameter nach `$libraryInsertable`:
|
||
|
||
```php
|
||
?Closure $themeBuilderWritable = null,
|
||
?Closure $themeBuilderVisible = null
|
||
```
|
||
|
||
(Doc-Zeilen: „Tools-tab gates of the group "Divi Theme Builder" for Theme Builder layouts in the Divi tools“), Rumpf:
|
||
|
||
```php
|
||
$roles = new DiviRoles();
|
||
$this->access = new LibraryAccess(new DiviLibraryStore(), $roles, $libraryWritable, $libraryVisible);
|
||
$this->themeBuilderAccess = new ThemeBuilderAccess(
|
||
new DiviThemeBuilder(),
|
||
$roles,
|
||
$themeBuilderWritable,
|
||
$themeBuilderVisible
|
||
);
|
||
```
|
||
|
||
`loader()`: `new DiviPageLoader($this->schema, new Parser(), $this->access, $this->themeBuilderAccess)`; `writer()`: nach `new GlobalEdits(…)` als weiteres Argument `new ThemeBuilderEdits($this->themeBuilderAccess)`.
|
||
|
||
`Plugin` (`use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderAccess;`) vor `$divi = new DiviIntegration(`:
|
||
|
||
```php
|
||
// Etappe 3b: the group "Divi Theme Builder" gates TB layouts in the Divi tools like the library group (E9).
|
||
$themeBuilderVisible = static function () use (&$policy): bool {
|
||
return $policy instanceof ExposurePolicy && $policy->groupEnabled(ThemeBuilderAccess::GROUP);
|
||
};
|
||
$themeBuilderWritable = static function () use (&$policy): bool {
|
||
return $policy instanceof ExposurePolicy && $policy->groupWritable(ThemeBuilderAccess::GROUP);
|
||
};
|
||
```
|
||
|
||
und im Konstruktoraufruf ergänzen: `themeBuilderWritable: $themeBuilderWritable, themeBuilderVisible: $themeBuilderVisible`.
|
||
|
||
`DiviWriterFixture::writer()`: Parameter `?ThemeBuilderEdits $themeBuilder = null` anhängen und an `new DiviWriter(…, $globals, $themeBuilder)` übergeben.
|
||
|
||
`ThemeBuilderFixtures` ergänzen (`use LIT\MCP\Integrations\Divi\BlockTree\Parser;`, `use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;`):
|
||
|
||
```php
|
||
/** A loader with the library access of LibraryFixtures and $access (default: full TB rights, empty live list). */
|
||
public static function loader(?ThemeBuilderAccess $access = null): DiviPageLoader
|
||
{
|
||
return new DiviPageLoader(DiviFixtures::schema(), new Parser(), LibraryFixtures::access(), $access ?? self::access());
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 9: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'ModuleCatalogTest|StructureRulesTest|SaveScopeTest|DiviPageLoaderTest|ThemeBuilderEditsTest|DiviWriterThemeBuilderTest|DiviIntegrationTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 10: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi src/Plugin.php tests/Integration/divi-metadata-fixture.php tests/Fixtures/divi/metadata-sample.json tests/Support tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: Divi tools edit Theme Builder layouts; visible layouts need confirm`.
|
||
|
||
---
|
||
### Task 6: Bedingungen prüfen und benennen
|
||
|
||
Bedingungen kommen nur aus Divis Liste (E7, F21): exakte IDs (`singular:post_type:person:all`, `homepage`, `404` …) oder IDs mit Objekt, deren Divi-Eintrag auf `:` endet (`singular:post_type:page:id:` + Beitrags-ID). Das Objekt muss existieren und für den Benutzer lesbar sein; Benutzer-Bedingungen (`archive:user:id:`) nur mit `list_users` (strenger als Divi, offener Punkt).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/ThemeBuilder/TemplateConditions.php`
|
||
- Test: `tests/Unit/Integrations/Divi/ThemeBuilder/TemplateConditionsTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DiviThemeBuilder::flatConditions()`, `PostIdParser::parse()` (Task 2).
|
||
- Produces: `TemplateConditions::__construct(DiviThemeBuilder $store)`; `MAX = 50`; `check(mixed $conditions, string $field): array{ids: list<string>, errors: array<string, string>}` (Fehlerschlüssel `<field>` bzw. `<field>.<index>`; `null` = keine Bedingungen); `label(string $id): string`; `available(): list<array{id: string, label: string, needs: ?string}>`.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/TemplateConditionsTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
final class TemplateConditionsTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) {
|
||
1053 => new WP_Post(['ID' => 1053, 'post_type' => 'page', 'post_status' => 'draft']),
|
||
2201 => new WP_Post(['ID' => 2201, 'post_type' => 'lit_person', 'post_status' => 'publish']),
|
||
2202 => new WP_Post(['ID' => 2202, 'post_type' => 'page', 'post_status' => 'private']),
|
||
2203 => new WP_Post(['ID' => 2203, 'post_type' => 'page', 'post_status' => 'trash']),
|
||
default => null,
|
||
});
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap, mixed ...$args): bool => $cap !== 'list_users'
|
||
&& ! ($cap === 'read_post' && ($args[0] ?? 0) === 2202)
|
||
);
|
||
Functions\when('get_term')->alias(
|
||
static fn (int $id, string $taxonomy): ?WP_Term => $id === 5 && $taxonomy === 'category'
|
||
? new WP_Term(['term_id' => 5, 'slug' => 'news', 'name' => 'News'])
|
||
: null
|
||
);
|
||
Functions\when('get_userdata')->justReturn(false);
|
||
Functions\when('wp_roles')->justReturn(new class {
|
||
public function is_role(string $role): bool
|
||
{
|
||
return $role === 'editor';
|
||
}
|
||
});
|
||
}
|
||
|
||
private static function conditions(): TemplateConditions
|
||
{
|
||
return new TemplateConditions(ThemeBuilderFixtures::store());
|
||
}
|
||
|
||
public function testAcceptsDivisConditionsAndReadableObjects(): void
|
||
{
|
||
$checked = self::conditions()->check([
|
||
'singular:post_type:lit_person:all',
|
||
'homepage',
|
||
'404',
|
||
'singular:post_type:page:id:1053',
|
||
'singular:post_type:lit_person:id:2201',
|
||
'archive:taxonomy:category:term:id:5',
|
||
'archive:user:role:editor',
|
||
'homepage',
|
||
], 'use_on');
|
||
|
||
self::assertSame([], $checked['errors']);
|
||
self::assertSame([
|
||
'singular:post_type:lit_person:all',
|
||
'homepage',
|
||
'404',
|
||
'singular:post_type:page:id:1053',
|
||
'singular:post_type:lit_person:id:2201',
|
||
'archive:taxonomy:category:term:id:5',
|
||
'archive:user:role:editor',
|
||
], $checked['ids']);
|
||
}
|
||
|
||
public function testRefusesUnknownUnreadableAndMalformedConditions(): void
|
||
{
|
||
$checked = self::conditions()->check([
|
||
'singular:post_type:page:all:x',
|
||
'singular:post_type:page:id:2202',
|
||
'singular:post_type:page:id:2203',
|
||
'singular:post_type:page:id:2201',
|
||
'singular:post_type:page:id:0123',
|
||
"homepage\n",
|
||
'archive:user:id:1',
|
||
'archive:taxonomy:category:term:id:6',
|
||
'archive:user:role:hacker',
|
||
'singular:post_type:page:id:',
|
||
42,
|
||
], 'use_on');
|
||
|
||
self::assertSame([], $checked['ids']);
|
||
self::assertSame(
|
||
array_map(static fn (int $i): string => 'use_on.' . $i, range(0, 10)),
|
||
array_keys($checked['errors'])
|
||
);
|
||
self::assertStringContainsString('unbekannte Bedingung', $checked['errors']['use_on.0']);
|
||
// Private and trashed look the same – no oracle over other users' entries.
|
||
self::assertSame(
|
||
$checked['errors']['use_on.1'],
|
||
str_replace('2203', '2202', $checked['errors']['use_on.2'])
|
||
);
|
||
self::assertStringContainsString('list_users', $checked['errors']['use_on.6']);
|
||
}
|
||
|
||
public function testListShapeAndSize(): void
|
||
{
|
||
self::assertSame(['ids' => [], 'errors' => []], self::conditions()->check(null, 'use_on'));
|
||
self::assertArrayHasKey('use_on', self::conditions()->check('homepage', 'use_on')['errors']);
|
||
self::assertArrayHasKey(
|
||
'exclude_from',
|
||
self::conditions()->check(array_fill(0, TemplateConditions::MAX + 1, 'homepage'), 'exclude_from')['errors']
|
||
);
|
||
}
|
||
|
||
public function testLabelsAndTheCatalog(): void
|
||
{
|
||
self::assertSame('Alle Personen', self::conditions()->label('singular:post_type:lit_person:all'));
|
||
self::assertSame('Bestimmte Seiten: 1053', self::conditions()->label('singular:post_type:page:id:1053'));
|
||
self::assertSame('Unterseiten bestimmter Seiten: 7', self::conditions()->label('singular:post_type:page:children:id:7'));
|
||
self::assertSame('unbekannt:x', self::conditions()->label('unbekannt:x'));
|
||
|
||
$available = array_column(self::conditions()->available(), null, 'id');
|
||
self::assertNull($available['homepage']['needs']);
|
||
self::assertSame('post_type:page', $available['singular:post_type:page:id:']['needs']);
|
||
self::assertSame('404-Seite', $available['404']['label']);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test läuft rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter TemplateConditionsTest`
|
||
Expected: FAIL – „Class … TemplateConditions not found“.
|
||
|
||
- [ ] **Step 3: Umsetzung**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/TemplateConditions.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Integrations\Divi\Compose\PostIdParser;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Theme Builder conditions (_et_use_on/_et_exclude_from, F21, E7): only IDs from Divi's own list
|
||
* (et_theme_builder_get_flat_template_settings_options()) – exactly, or for an entry ending in ":" that entry
|
||
* plus an object: a post of that type the user may read (not trashed, no auto-draft/attachment status), an
|
||
* existing term of that taxonomy, an existing role, a user only with list_users (stricter than Divi). Refusals
|
||
* for missing and unreadable objects read the same. Nothing is written here.
|
||
*/
|
||
final class TemplateConditions
|
||
{
|
||
public const MAX = 50;
|
||
|
||
private const SEPARATOR = ':';
|
||
|
||
private const DEAD = ['trash', 'auto-draft', 'inherit'];
|
||
|
||
/** @var array<string, array{label: string, type: ?string, value: ?string}>|null */
|
||
private ?array $flat = null;
|
||
|
||
public function __construct(private DiviThemeBuilder $store)
|
||
{
|
||
}
|
||
|
||
/** @return array{ids: list<string>, errors: array<string, string>} */
|
||
public function check(mixed $conditions, string $field): array
|
||
{
|
||
if ($conditions === null) {
|
||
return ['ids' => [], 'errors' => []];
|
||
}
|
||
|
||
if (! is_array($conditions) || ! array_is_list($conditions)) {
|
||
return ['ids' => [], 'errors' => [$field => 'Liste von Bedingungs-IDs erwartet']];
|
||
}
|
||
|
||
if (count($conditions) > self::MAX) {
|
||
return ['ids' => [], 'errors' => [$field => sprintf('höchstens %d Bedingungen', self::MAX)]];
|
||
}
|
||
|
||
$ids = [];
|
||
$errors = [];
|
||
foreach ($conditions as $index => $condition) {
|
||
$problem = is_string($condition) ? $this->problem($condition) : 'Bedingungs-ID als Text erwartet';
|
||
if ($problem === null) {
|
||
$ids[] = (string) $condition;
|
||
} else {
|
||
$errors[$field . '.' . $index] = $problem;
|
||
}
|
||
}
|
||
|
||
return ['ids' => array_values(array_unique($ids)), 'errors' => $errors];
|
||
}
|
||
|
||
/** Divi's label, for an ID with object "<label>: <object>"; unknown IDs as they are. */
|
||
public function label(string $id): string
|
||
{
|
||
$flat = $this->flat();
|
||
if (isset($flat[$id]) && ! str_ends_with($id, self::SEPARATOR)) {
|
||
return $flat[$id]['label'];
|
||
}
|
||
|
||
$parent = $this->parentOf($id);
|
||
|
||
return $parent === null ? $id : sprintf('%s: %s', $flat[$parent]['label'], substr($id, strlen($parent)));
|
||
}
|
||
|
||
/**
|
||
* Divi's conditions for lit/tb-list: "needs" names what an ID ending in ":" takes, e.g. "post_type:page".
|
||
*
|
||
* @return list<array{id: string, label: string, needs: ?string}>
|
||
*/
|
||
public function available(): array
|
||
{
|
||
$list = [];
|
||
foreach ($this->flat() as $id => $setting) {
|
||
$id = (string) $id;
|
||
$list[] = [
|
||
'id' => $id,
|
||
'label' => $setting['label'],
|
||
'needs' => str_ends_with($id, self::SEPARATOR)
|
||
? trim(($setting['type'] ?? '') . self::SEPARATOR . ($setting['value'] ?? ''), self::SEPARATOR)
|
||
: null,
|
||
];
|
||
}
|
||
|
||
return $list;
|
||
}
|
||
|
||
private function problem(string $id): ?string
|
||
{
|
||
if (preg_match('/^[a-z0-9_:\-]{1,200}$/D', $id) !== 1) {
|
||
return 'ungültige Bedingungs-ID (erlaubt: a–z, 0–9, „_“, „-“ und „:“)';
|
||
}
|
||
|
||
$flat = $this->flat();
|
||
if (isset($flat[$id]) && ! str_ends_with($id, self::SEPARATOR)) {
|
||
return null;
|
||
}
|
||
|
||
$parent = $this->parentOf($id);
|
||
if ($parent === null) {
|
||
return 'unbekannte Bedingung (verfügbare: lit/tb-list mit conditions: true)';
|
||
}
|
||
|
||
return $this->objectProblem($flat[$parent], substr($id, strlen($parent)));
|
||
}
|
||
|
||
/** The longest Divi ID ending in ":" that $id continues, or null. */
|
||
private function parentOf(string $id): ?string
|
||
{
|
||
$best = null;
|
||
foreach (array_keys($this->flat()) as $key) {
|
||
$key = (string) $key;
|
||
if (
|
||
str_ends_with($key, self::SEPARATOR)
|
||
&& strlen($id) > strlen($key)
|
||
&& str_starts_with($id, $key)
|
||
&& ($best === null || strlen($key) > strlen($best))
|
||
) {
|
||
$best = $key;
|
||
}
|
||
}
|
||
|
||
return $best;
|
||
}
|
||
|
||
/** @param array{label: string, type: ?string, value: ?string} $setting */
|
||
private function objectProblem(array $setting, string $object): ?string
|
||
{
|
||
$value = (string) $setting['value'];
|
||
$id = PostIdParser::parse($object);
|
||
|
||
if ($setting['type'] === 'post_type') {
|
||
return $this->postProblem($value, $id, $object);
|
||
}
|
||
if ($setting['type'] === 'taxonomy') {
|
||
$valid = $id !== null && get_term($id, $value) instanceof WP_Term;
|
||
|
||
return $valid ? null : sprintf('Begriff %s gibt es in „%s“ nicht', $object, $value);
|
||
}
|
||
if ($setting['type'] === 'user') {
|
||
$valid = $id !== null && current_user_can('list_users') && get_userdata($id) !== false;
|
||
|
||
return $valid ? null : 'Autoren-Bedingungen nur mit dem Recht list_users und einer vorhandenen Benutzer-ID';
|
||
}
|
||
if ($setting['type'] === 'user_role') {
|
||
return wp_roles()->is_role($object) ? null : sprintf('Rolle „%s“ gibt es nicht', $object);
|
||
}
|
||
|
||
return 'diese Bedingung nur im Divi Theme Builder setzen';
|
||
}
|
||
|
||
private function postProblem(string $postType, ?int $id, string $object): ?string
|
||
{
|
||
$post = $id === null ? null : get_post($id);
|
||
$valid = $post instanceof WP_Post
|
||
&& (string) $post->post_type === $postType
|
||
&& ! in_array((string) $post->post_status, self::DEAD, true)
|
||
&& current_user_can('read_post', $id);
|
||
|
||
return $valid
|
||
? null
|
||
: sprintf('Eintrag %s vom Typ „%s“ gibt es nicht oder er ist nicht lesbar', $object, $postType);
|
||
}
|
||
|
||
/** @return array<string, array{label: string, type: ?string, value: ?string}> */
|
||
private function flat(): array
|
||
{
|
||
return $this->flat ??= $this->store->flatConditions();
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Test grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter TemplateConditionsTest && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/ThemeBuilder/TemplateConditions.php tests/Unit/Integrations/Divi/ThemeBuilder/TemplateConditionsTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: Theme Builder conditions only from Divi's list, objects readable`.
|
||
|
||
---
|
||
|
||
### Task 7: `lit/tb-list` und die Gruppe „Divi Theme Builder“
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/ThemeBuilder/TbListAbility.php`, `src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php` (`themeBuilder()`), `src/Plugin.php` (Registry)
|
||
- Test: `tests/Unit/Integrations/Divi/ThemeBuilder/TbListAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Tasks 4 und 6.
|
||
- Produces: `TbListAbility::__construct(ThemeBuilderAccess $access, TemplateConditions $conditions)`, `DETACHED_LIMIT = 50`; Ausgabe `{theme_builder_id, draft_pending, templates, detached, conditions?, notes}` – jede Vorlage `Template::toArray()` plus `use_on_labels`, `exclude_from_labels`, je Bereich `layouts.<area>.title` und `layouts.<area>.shared_with` (IDs anderer Vorlagen mit demselben Layout). `ThemeBuilderIntegration::__construct(ThemeBuilderAccess $access, DiviComposition $composition, DiviSaver $saver, ChangeLog $log)`, `id() === 'divi-theme-builder'`, `label() === 'Divi Theme Builder'`. `DiviIntegration::themeBuilder(): ThemeBuilderIntegration`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/TbListAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbListAbility;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class TbListAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
LibraryFixtures::given([]);
|
||
Functions\when('get_the_title')->alias(static fn (int $id): string => '[Fixture] Layout ' . $id);
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
private static function ability(DiviThemeBuilder $store, array $deniedRoles = []): TbListAbility
|
||
{
|
||
return new TbListAbility(
|
||
ThemeBuilderFixtures::access($deniedRoles, true, $store),
|
||
new TemplateConditions($store)
|
||
);
|
||
}
|
||
|
||
public function testListsTemplatesWithLayoutsAndLabels(): void
|
||
{
|
||
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)]);
|
||
|
||
$result = self::ability($store)->execute([]);
|
||
|
||
self::assertSame(1064, $result['theme_builder_id']);
|
||
self::assertFalse($result['draft_pending']);
|
||
self::assertSame([1065, 1066], array_column($result['templates'], 'id'));
|
||
$template = $result['templates'][1];
|
||
self::assertFalse($template['enabled']);
|
||
self::assertFalse($template['live']);
|
||
self::assertSame(ThemeBuilderFixtures::template(1066)->rev(), $template['template_rev']);
|
||
self::assertSame(['Bestimmte Seiten: 1053'], $template['use_on_labels']);
|
||
self::assertSame('[Fixture] Layout 1062', $template['layouts']['body']['title']);
|
||
self::assertSame([1065], $template['layouts']['header']['shared_with'], 'the global header');
|
||
self::assertSame([], $result['detached']);
|
||
self::assertArrayNotHasKey('conditions', $result);
|
||
}
|
||
|
||
public function testReportsDetachedAndMarkedTemplates(): void
|
||
{
|
||
$store = ThemeBuilderFixtures::store(
|
||
[ThemeBuilderFixtures::template(1065)],
|
||
0,
|
||
[ThemeBuilderFixtures::template(1066, ['attached' => false, 'marked_unused' => '2026-09-27 10:00:00'])]
|
||
);
|
||
|
||
$result = self::ability($store)->execute([]);
|
||
|
||
self::assertSame([1066], array_column($result['detached'], 'id'));
|
||
self::assertSame('2026-09-27 10:00:00', $result['detached'][0]['marked_unused']);
|
||
self::assertFalse($result['detached'][0]['attached']);
|
||
self::assertStringContainsString('reattach', implode(' ', $result['notes']));
|
||
}
|
||
|
||
public function testOneTemplateTheDraftAndTheConditionCatalog(): void
|
||
{
|
||
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)], 999);
|
||
|
||
$result = self::ability($store)->execute(['template_id' => 1066, 'conditions' => true]);
|
||
|
||
self::assertSame([1066], array_column($result['templates'], 'id'));
|
||
self::assertTrue($result['draft_pending']);
|
||
self::assertStringContainsString('Entwurf', implode(' ', $result['notes']));
|
||
self::assertContains('singular:post_type:lit_person:all', array_column($result['conditions'], 'id'));
|
||
self::assertToolError(self::ability($store)->execute(['template_id' => 4711]), 'not_found');
|
||
}
|
||
|
||
public function testNeedsTheThemeBuilderRole(): void
|
||
{
|
||
$ability = self::ability(ThemeBuilderFixtures::store(), ['theme_builder']);
|
||
|
||
self::assertToolError($ability->permission([]), 'forbidden');
|
||
self::assertToolError($ability->execute([]), 'forbidden');
|
||
self::assertTrue($ability->annotations()['readonly']);
|
||
self::assertFalse($ability->isDirect());
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegrationTest` ergänzen (`use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderIntegration;` nicht nötig):
|
||
|
||
```php
|
||
public function testProvidesTheThemeBuilderGroup(): void
|
||
{
|
||
Functions\when('__')->returnArg();
|
||
$group = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->themeBuilder();
|
||
|
||
self::assertSame('divi-theme-builder', $group->id());
|
||
self::assertSame('Divi Theme Builder', $group->label());
|
||
self::assertFalse($group->isAvailable(), 'no Divi classes in unit tests');
|
||
self::assertSame(
|
||
['lit/tb-list'],
|
||
array_map(static fn (Ability $ability): string => $ability->name(), $group->abilities())
|
||
);
|
||
foreach ($group->abilities() as $ability) {
|
||
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
|
||
self::assertNotNull($ability->outputSchema(), $ability->name());
|
||
self::assertFalse($ability->isDirect(), $ability->name());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'TbListAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL – „Class … TbListAbility not found“ / „Call to undefined method … themeBuilder()“.
|
||
|
||
- [ ] **Step 3: Tool**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/TbListAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/tb-list (spec §5.7): the templates of the live Theme Builder with conditions (Divi's labels), layouts
|
||
* (title, shared with other templates – the "global" header/footer is the default template's layout shared by
|
||
* ID), template_rev for the writing TB tools; templates a later Theme Builder save removed ("detached", with
|
||
* Divi's unused mark – Divi trashes marked posts 7 days later, F19); optionally Divi's condition catalog.
|
||
* Needs edit_others_posts and Divi's role option theme_builder (ThemeBuilderAccess); lists only templates the
|
||
* user may edit_post.
|
||
*/
|
||
final class TbListAbility implements Ability
|
||
{
|
||
public const DETACHED_LIMIT = 50;
|
||
|
||
public function __construct(private ThemeBuilderAccess $access, private TemplateConditions $conditions)
|
||
{
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/tb-list';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Theme-Builder-Vorlagen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Listet die Vorlagen des Divi Theme Builders: aktiv oder deaktiviert, Bedingungen mit Beschriftung, '
|
||
. 'Layouts für Kopfzeile, Inhalt und Fußzeile (Layout-IDs lassen sich mit den Divi-Werkzeugen bearbeiten, '
|
||
. 'z. B. lit-divi-outline mit post_id) und template_rev für lit/tb-assign und lit/tb-activate. '
|
||
. 'template_id: nur diese Vorlage. conditions: true liefert alle Bedingungen, die Divi anbietet; IDs, die '
|
||
. 'auf „:“ enden, brauchen ein Objekt (z. B. "singular:post_type:page:id:42"). detached: Vorlagen, die ein '
|
||
. 'späteres Speichern im Theme Builder entfernt hat.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'template_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'conditions' => ['type' => 'boolean', 'description' => 'Divis Bedingungen mitliefern.'],
|
||
],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
$objects = ['type' => 'array', 'items' => ['type' => 'object']];
|
||
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'theme_builder_id' => ['type' => 'integer'],
|
||
'draft_pending' => ['type' => 'boolean'],
|
||
'templates' => $objects,
|
||
'detached' => $objects,
|
||
'conditions' => $objects,
|
||
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
],
|
||
'required' => ['theme_builder_id', 'draft_pending', 'templates', 'detached', 'notes'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::read();
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return $this->access->canUse() ?? true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->access->canUse();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$store = $this->access->store();
|
||
$all = $store->liveTemplates();
|
||
$detached = [];
|
||
foreach ($store->detachedIds(self::DETACHED_LIMIT) as $id) {
|
||
$template = $store->template($id, false);
|
||
if ($template !== null) {
|
||
$detached[] = $template;
|
||
}
|
||
}
|
||
$editable = static fn (Template $template): bool => current_user_can('edit_post', $template->id);
|
||
$live = array_values(array_filter($all, $editable));
|
||
$detached = array_values(array_filter($detached, $editable));
|
||
|
||
if (isset($input['template_id'])) {
|
||
$id = PostGuard::id($input['template_id']);
|
||
$only = static fn (array $list): array => array_values(
|
||
array_filter($list, static fn (Template $template): bool => $template->id === $id)
|
||
);
|
||
$live = $only($live);
|
||
$detached = $only($detached);
|
||
if ($live === [] && $detached === []) {
|
||
return Errors::notFound(
|
||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', (int) $id),
|
||
'Vorlagen mit lit/tb-list ohne template_id auflisten.'
|
||
);
|
||
}
|
||
}
|
||
|
||
$draft = $store->draftId() > 0;
|
||
$notes = [];
|
||
if ($draft) {
|
||
$notes[] = 'Der Theme Builder hat ungespeicherte Änderungen (Entwurf); bis sie dort gespeichert oder '
|
||
. 'verworfen sind, ändert LIT MCP keine Vorlagen.';
|
||
}
|
||
if ($detached !== []) {
|
||
$notes[] = 'detached: Diese Vorlagen hat ein späteres Speichern im Theme Builder entfernt; Divi löscht als '
|
||
. 'ungenutzt markierte Vorlagen und Layouts 7 Tage nach der Markierung. Wieder zuordnen (bleibt '
|
||
. 'deaktiviert): lit/tb-assign mit reattach: true.';
|
||
}
|
||
|
||
$shared = array_merge($all, $detached);
|
||
$result = [
|
||
'theme_builder_id' => $store->liveId(),
|
||
'draft_pending' => $draft,
|
||
'templates' => array_map(fn (Template $t): array => $this->describe($t, $shared), $live),
|
||
'detached' => array_map(fn (Template $t): array => $this->describe($t, $shared), $detached),
|
||
'notes' => $notes,
|
||
];
|
||
if (($input['conditions'] ?? false) === true) {
|
||
$result['conditions'] = $this->conditions->available();
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
|
||
/**
|
||
* @param list<Template> $all
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function describe(Template $template, array $all): array
|
||
{
|
||
$row = $template->toArray();
|
||
foreach (Template::AREAS as $area) {
|
||
$id = $template->layouts[$area]['id'];
|
||
$others = array_filter(
|
||
$all,
|
||
static fn (Template $other): bool => $id > 0 && $other->id !== $template->id
|
||
&& $other->layouts[$area]['id'] === $id
|
||
);
|
||
$row['layouts'][$area]['title'] = $id > 0 ? (string) get_the_title($id) : '';
|
||
$row['layouts'][$area]['shared_with'] = array_values(array_map(
|
||
static fn (Template $other): int => $other->id,
|
||
$others
|
||
));
|
||
}
|
||
$row['use_on_labels'] = array_map($this->conditions->label(...), $template->useOn);
|
||
$row['exclude_from_labels'] = array_map($this->conditions->label(...), $template->excludeFrom);
|
||
|
||
return $row;
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Gruppe und Registrierung**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use LIT\MCP\Integrations\Integration;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The group "Divi Theme Builder" (Etappe 3b): lit/tb-list, lit/tb-create-template, lit/tb-assign and
|
||
* lit/tb-activate, all indirect. Built by DiviIntegration::themeBuilder() so it shares the Theme Builder access
|
||
* (rights, Tools-tab gates – its id ThemeBuilderAccess::GROUP is what the gates of the Divi tools ask).
|
||
* Available with the Divi 5 builder, like the Divi group; missing Divi internals show in lit-site-status.
|
||
*/
|
||
final class ThemeBuilderIntegration implements Integration
|
||
{
|
||
public function __construct(
|
||
private ThemeBuilderAccess $access,
|
||
private DiviComposition $composition,
|
||
private DiviSaver $saver,
|
||
private ChangeLog $log
|
||
) {
|
||
}
|
||
|
||
public function id(): string
|
||
{
|
||
return ThemeBuilderAccess::GROUP;
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return __('Divi Theme Builder', 'wp-lit-mcp');
|
||
}
|
||
|
||
public function isAvailable(): bool
|
||
{
|
||
return DiviModules::isAvailable();
|
||
}
|
||
|
||
public function abilities(): array
|
||
{
|
||
$conditions = new TemplateConditions($this->access->store());
|
||
|
||
return [
|
||
new TbListAbility($this->access, $conditions),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegration` (`use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderIntegration;`):
|
||
|
||
```php
|
||
/** The group "Divi Theme Builder" (lit/tb-*), sharing this integration's Theme Builder access (Etappe 3b). */
|
||
public function themeBuilder(): ThemeBuilderIntegration
|
||
{
|
||
return new ThemeBuilderIntegration(
|
||
$this->themeBuilderAccess,
|
||
new DiviComposition($this->modules, $this->design),
|
||
new DiviSaver(),
|
||
$this->log
|
||
);
|
||
}
|
||
```
|
||
|
||
`Plugin`: in `new IntegrationRegistry(…)` nach `$divi->library()` die Zeile `$divi->themeBuilder()` ergänzen.
|
||
|
||
- [ ] **Step 5: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'TbListAbilityTest|DiviIntegrationTest|ToolNamingTest|AbilityRegistrarTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/ThemeBuilder src/Integrations/Divi/DiviIntegration.php src/Plugin.php tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: lit/tb-list and the tool group "Divi Theme Builder"`.
|
||
|
||
---
|
||
### Task 8: `lit/tb-create-template` – deaktiviert, alles oder nichts
|
||
|
||
Legt eine Vorlage mit Kopf-, Inhalts- und Fußbereich an (E7, Antwort F1): je Bereich neu (optional mit Knoten in Kurz-/Vollform – dieselbe Engstelle wie `lit-divi-insert`), die globale Kopf-/Fußzeile der Standardvorlage mitbenutzen, nichts Eigenes oder ausgeblendet; Bedingungen optional. Die Vorlage ist **immer deaktiviert**. Divis Funktionen legen Layouts und Vorlage an, der Inhalt geht über `DiviSaver::saveNew()`; danach wird die Vorlage zurückgelesen und an die Live-Liste gehängt. Jeder Fehler nimmt alles zurück.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Pages/LayoutBuilder.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DiviContentComposer.php` (nutzt `LayoutBuilder`)
|
||
- Create: `src/Integrations/Divi/ThemeBuilder/TemplateBuild.php`, `src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php`
|
||
- Modify: `src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php`
|
||
- Test: `tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php`, `TbCreateTemplateAbilityTest.php`; `tests/Unit/Integrations/Divi/Pages/DiviContentComposerTest.php` (unverändert grün); `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Tasks 4–7; `DiviComposition::forPost(int, ?string)`, `DiviSaver::saveNew(int, string, SaveScope)`, `Warnings::collect()`, `FollowUp::run()`, `Rollback::attempt()`, `WriteFailed`.
|
||
- Produces:
|
||
- `LayoutBuilder::build(DiviComposition $composition, array $sections, string $field): array{document: Document, blocks: list<Block>}|WP_Error`.
|
||
- `TemplateBuild::__construct(DiviThemeBuilder $store, ChangeLog $log, string $ability)`; `REASON`; `layout(string $area, string $title): int`; `template(int $themeBuilderId, array $template): int`; `attach(int $themeBuilderId, int $templateId): void`; `created(): list<int>`; `refuse(WP_Error $error): WP_Error`; `rollBack(Throwable $cause): never`.
|
||
- `TbCreateTemplateAbility::__construct(ThemeBuilderAccess $access, TemplateConditions $conditions, DiviComposition $composition, DiviSaver $saver, ChangeLog $log)`; `SOURCES = ['new', 'global', 'default', 'hidden']`; `STALE_EDITOR`; Ausgabe `ToolResult` + `template_id`, `template_rev`, `enabled` (immer `false`), `layouts` (`area => {id, enabled, source}`).
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/TemplateBuildTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateBuild;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use RuntimeException;
|
||
use WP_Post;
|
||
|
||
final class TemplateBuildTest extends TestCase
|
||
{
|
||
public function testRollsBackEveryCreatedPostInReverse(): void
|
||
{
|
||
$store = ThemeBuilderFixtures::store();
|
||
$store->shouldReceive('insertLayout')->andReturn(2101, 2102);
|
||
$store->shouldReceive('storeTemplate')->andReturn(2100);
|
||
$store->shouldReceive('attach')->once()->with(1064, 2100);
|
||
$store->shouldReceive('detach')->once()->with(1064, 2100);
|
||
$deleted = [];
|
||
Functions\when('wp_delete_post')->alias(static function (int $id, bool $force) use (&$deleted): WP_Post {
|
||
$deleted[] = [$id, $force];
|
||
|
||
return new WP_Post(['ID' => $id]);
|
||
});
|
||
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
|
||
$build->layout('header', 'Kopf');
|
||
$build->layout('body', 'Inhalt');
|
||
$build->template(1064, ['title' => 'Person', 'layouts' => [], 'use_on' => [], 'exclude_from' => []]);
|
||
$build->attach(1064, 2100);
|
||
|
||
try {
|
||
$build->rollBack(new RuntimeException('Divi dropped a layout'));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertTrue($e->rolledBack);
|
||
}
|
||
self::assertSame([[2100, true], [2102, true], [2101, true]], $deleted);
|
||
}
|
||
|
||
public function testAFailedDeleteIsAPartialWrite(): void
|
||
{
|
||
$store = ThemeBuilderFixtures::store();
|
||
$store->shouldReceive('insertLayout')->andReturn(2101);
|
||
Functions\when('wp_delete_post')->justReturn(false);
|
||
$log = FakeChangeLog::partialsOnly();
|
||
$build = new TemplateBuild($store, $log, 'lit/tb-create-template');
|
||
$build->layout('body', 'Inhalt');
|
||
|
||
try {
|
||
$build->rollBack(new RuntimeException('store failed'));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertFalse($e->rolledBack);
|
||
self::assertSame([2101], $e->details['created_ids']);
|
||
}
|
||
self::assertCount(1, $log->summaries());
|
||
}
|
||
|
||
public function testARefusalAfterTheInsertRemovesTheNewPostsAndReturnsTheRefusal(): void
|
||
{
|
||
$store = ThemeBuilderFixtures::store();
|
||
$store->shouldReceive('insertLayout')->andReturn(2101);
|
||
Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101]));
|
||
$build = new TemplateBuild($store, FakeChangeLog::forbidden(), 'lit/tb-create-template');
|
||
$build->layout('body', 'Inhalt');
|
||
$refusal = Errors::invalidInput('Divi-Inhalt ist ungültig; nichts wurde angelegt.', ['body.nodes' => 'x']);
|
||
|
||
self::assertSame($refusal, $build->refuse($refusal));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbCreateTemplateAbility;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
use WP_Post;
|
||
|
||
final class TbCreateTemplateAbilityTest extends TestCase
|
||
{
|
||
/** @var list<array{int, string, SaveScope}> */
|
||
private array $savedNew = [];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
LibraryFixtures::given([]);
|
||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||
}
|
||
|
||
/** The template 2100 as Divi would store it for input(). */
|
||
private static function stored(array $overrides = []): Template
|
||
{
|
||
return ThemeBuilderFixtures::template(2100, array_merge([
|
||
'title' => 'Person',
|
||
'layouts' => [
|
||
'header' => ['id' => 1061, 'enabled' => true],
|
||
'body' => ['id' => 2101, 'enabled' => true],
|
||
'footer' => ['id' => 0, 'enabled' => false],
|
||
],
|
||
'use_on' => ['singular:post_type:lit_person:all'],
|
||
'attached' => false,
|
||
], $overrides));
|
||
}
|
||
|
||
private static function store(int $draftId = 0, ?Template $stored = null): DiviThemeBuilder
|
||
{
|
||
return ThemeBuilderFixtures::store(
|
||
[ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)],
|
||
$draftId,
|
||
[],
|
||
[$stored ?? self::stored()]
|
||
);
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
private function ability(
|
||
DiviThemeBuilder $store,
|
||
?FakeChangeLog $log = null,
|
||
array $deniedRoles = []
|
||
): TbCreateTemplateAbility {
|
||
$saver = Mockery::mock(DiviSaver::class);
|
||
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope): array {
|
||
$this->savedNew[] = [$id, $content, $scope];
|
||
|
||
return [];
|
||
});
|
||
|
||
return new TbCreateTemplateAbility(
|
||
ThemeBuilderFixtures::access($deniedRoles, true, $store),
|
||
new TemplateConditions($store),
|
||
DiviWriterFixture::composition(DiviWriterFixture::designReader()),
|
||
$saver,
|
||
$log ?? DiviWriterFixture::log()
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private static function input(array $extra = []): array
|
||
{
|
||
return array_merge([
|
||
'title' => 'Person',
|
||
'header' => ['source' => 'global'],
|
||
'body' => [
|
||
'source' => 'new',
|
||
'nodes' => [[
|
||
'type' => 'section',
|
||
'label' => 'Person',
|
||
'modules' => [
|
||
['type' => 'heading', 'text' => '{{post:title}}'],
|
||
['type' => 'text', 'content' => '{{field:role}}'],
|
||
],
|
||
]],
|
||
],
|
||
'footer' => ['source' => 'hidden'],
|
||
'use_on' => ['singular:post_type:lit_person:all'],
|
||
], $extra);
|
||
}
|
||
|
||
private static function expectCreation(DiviThemeBuilder $store): void
|
||
{
|
||
$store->shouldReceive('insertLayout')->once()->with('body', 'Person – Inhalt')->andReturn(2101);
|
||
$store->shouldReceive('storeTemplate')->once()->with(1064, [
|
||
'title' => 'Person',
|
||
'layouts' => [
|
||
'header' => ['id' => 1061, 'enabled' => true],
|
||
'body' => ['id' => 2101, 'enabled' => true],
|
||
'footer' => ['id' => 0, 'enabled' => false],
|
||
],
|
||
'use_on' => ['singular:post_type:lit_person:all'],
|
||
'exclude_from' => [],
|
||
])->andReturn(2100);
|
||
}
|
||
|
||
public function testNewTemplateIsDisabledAndAttached(): void
|
||
{
|
||
$store = self::store();
|
||
self::expectCreation($store);
|
||
$store->shouldReceive('attach')->once()->with(1064, 2100);
|
||
$store->shouldReceive('clearCaches')->once();
|
||
$store->shouldReceive('setEnabled')->never();
|
||
|
||
$result = $this->ability($store)->execute(self::input());
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(2100, $result['template_id']);
|
||
self::assertFalse($result['enabled']);
|
||
self::assertSame(['id' => 2101, 'enabled' => true, 'source' => 'new'], $result['layouts']['body']);
|
||
self::assertSame(['id' => 1061, 'enabled' => true, 'source' => 'global'], $result['layouts']['header']);
|
||
self::assertCount(1, $this->savedNew);
|
||
[$id, $content, $scope] = $this->savedNew[0];
|
||
self::assertSame(2101, $id);
|
||
self::assertSame(SaveScope::ThemeBuilderLayout, $scope);
|
||
self::assertStringStartsWith('<!-- wp:divi/placeholder -->', $content);
|
||
self::assertStringContainsString('custom_meta_role', $content);
|
||
self::assertContains(TbCreateTemplateAbility::STALE_EDITOR, $result['warnings']);
|
||
self::assertStringContainsString('deaktiviert', $result['summary']);
|
||
}
|
||
|
||
/** E7: an unsaved Theme Builder session would later replace the template list. */
|
||
public function testRefusesWhileTheThemeBuilderHasADraft(): void
|
||
{
|
||
$store = self::store(999);
|
||
$store->shouldReceive('insertLayout')->never();
|
||
$store->shouldReceive('storeTemplate')->never();
|
||
|
||
self::assertToolError($this->ability($store)->execute(self::input()), 'unsupported');
|
||
}
|
||
|
||
public function testInvalidInputCreatesNothing(): void
|
||
{
|
||
$store = self::store();
|
||
$store->shouldReceive('insertLayout')->never();
|
||
|
||
$result = $this->ability($store)->execute(self::input([
|
||
'title' => '<b></b>',
|
||
'body' => ['source' => 'global'],
|
||
'footer' => ['source' => 'new', 'nodes' => [['type' => 'section', 'modules' => [['type' => 'post-content']]]]],
|
||
'use_on' => ['singular:post_type:lit_person:all', 'erfunden'],
|
||
]));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
$fields = self::errorFields($result);
|
||
self::assertArrayHasKey('title', $fields);
|
||
self::assertArrayHasKey('body.source', $fields);
|
||
self::assertArrayHasKey('use_on.1', $fields);
|
||
self::assertNotSame([], array_filter(array_keys($fields), static fn (string $key): bool => str_starts_with($key, 'footer.nodes')));
|
||
}
|
||
|
||
public function testFailedTemplateStoreDeletesTheLayouts(): void
|
||
{
|
||
$store = self::store();
|
||
$store->shouldReceive('insertLayout')->once()->andReturn(2101);
|
||
$store->shouldReceive('storeTemplate')->once()->andThrow(new RuntimeException('Divi called wp_die(): '));
|
||
$store->shouldReceive('attach')->never();
|
||
Functions\expect('wp_delete_post')->once()->with(2101, true)->andReturn(new WP_Post(['ID' => 2101]));
|
||
|
||
$this->expectException(WriteFailed::class);
|
||
$this->ability($store)->execute(self::input());
|
||
}
|
||
|
||
/** Divi drops layout IDs that are no published layout of the area (theme-builder.php:1486-1496). */
|
||
public function testDiviDroppingALayoutIsRolledBack(): void
|
||
{
|
||
$dropped = self::stored(['layouts' => [
|
||
'header' => ['id' => 1061, 'enabled' => true],
|
||
'body' => ['id' => 0, 'enabled' => true],
|
||
'footer' => ['id' => 0, 'enabled' => false],
|
||
]]);
|
||
$store = self::store(0, $dropped);
|
||
self::expectCreation($store);
|
||
$store->shouldReceive('attach')->never();
|
||
$deleted = [];
|
||
Functions\when('wp_delete_post')->alias(static function (int $id) use (&$deleted): WP_Post {
|
||
$deleted[] = $id;
|
||
|
||
return new WP_Post(['ID' => $id]);
|
||
});
|
||
|
||
try {
|
||
$this->ability($store)->execute(self::input());
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertTrue($e->rolledBack);
|
||
}
|
||
self::assertSame([2100, 2101], $deleted);
|
||
}
|
||
|
||
public function testNeedsTheThemeBuilderRole(): void
|
||
{
|
||
$store = self::store();
|
||
$store->shouldReceive('insertLayout')->never();
|
||
$ability = $this->ability($store, null, ['theme_builder']);
|
||
|
||
self::assertToolError($ability->permission(self::input()), 'forbidden');
|
||
self::assertToolError($ability->execute(self::input()), 'forbidden');
|
||
self::assertFalse($ability->isDirect());
|
||
self::assertFalse($ability->annotations()['destructive']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegrationTest::testProvidesTheThemeBuilderGroup`: erwartete Namen `['lit/tb-list', 'lit/tb-create-template']`.
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'TemplateBuildTest|TbCreateTemplateAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL – „Class … TemplateBuild not found“.
|
||
|
||
- [ ] **Step 3: LayoutBuilder (aus DiviContentComposer herausgelöst)**
|
||
|
||
`src/Integrations/Divi/Pages/LayoutBuilder.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Sections in short/full form composed into an empty document (lit/content-create, lit/tb-create-template): the
|
||
* blocks come only from Composer (the single choke point, D7) and enter the document only through
|
||
* TreeEditor::insert(), which wraps the first section in divi/placeholder (ruling S9). All or nothing.
|
||
*/
|
||
final class LayoutBuilder
|
||
{
|
||
/**
|
||
* @param list<mixed> $sections
|
||
* @param string $field Input path the errors are reported under (e.g. "divi", "body.nodes").
|
||
* @return array{document: Document, blocks: list<Block>}|WP_Error
|
||
*/
|
||
public static function build(DiviComposition $composition, array $sections, string $field): array|WP_Error
|
||
{
|
||
if ($sections === []) {
|
||
return Errors::invalidInput('Kein Divi-Inhalt angegeben; nichts wurde angelegt.', [
|
||
$field => 'mindestens ein Abschnitt',
|
||
]);
|
||
}
|
||
|
||
$composed = $composition->composer()->compose(array_values($sections), null, $field);
|
||
if ($composed['errors'] !== []) {
|
||
return DiviErrors::composed($composed, 'Divi-Inhalt ist ungültig; nichts wurde angelegt.');
|
||
}
|
||
|
||
$document = new Document();
|
||
$violation = $composition->editor()->insert(
|
||
['container' => $document, 'parent' => null, 'index' => PHP_INT_MAX],
|
||
$composed['blocks']
|
||
);
|
||
if ($violation !== null) {
|
||
return Errors::invalidInput('Divi-Inhalt passt nicht auf die Seite; nichts wurde angelegt.', [
|
||
$field => $violation,
|
||
]);
|
||
}
|
||
|
||
return ['document' => $document, 'blocks' => $composed['blocks']];
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviContentComposer` die private Methode `layout()` löschen und beide Aufrufe `$this->layout($composition…, $sections)` durch `LayoutBuilder::build(<dieselbe Komposition>, $sections, 'divi')` ersetzen (in `check()` mit `$this->composition->forPost(0)`, in `write()` mit `$composition`). Nicht mehr benutzte `use`-Zeilen (`Block`, `Document`) entfernen.
|
||
|
||
Run: `vendor/bin/phpunit --filter DiviContentComposerTest`
|
||
Expected: PASS (unverändert).
|
||
|
||
- [ ] **Step 4: TemplateBuild**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/TemplateBuild.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Abilities\Rollback;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The transaction of lit/tb-create-template (all or nothing): records every post Divi created (layouts, the
|
||
* template) and the assignment to the Theme Builder post; refuse() and rollBack() remove all of it again,
|
||
* newest first. A cleanup that fails is an honest partial_write naming the IDs (logged).
|
||
*/
|
||
final class TemplateBuild
|
||
{
|
||
public const REASON = 'Die Theme-Builder-Vorlage konnte nicht angelegt werden.';
|
||
|
||
/** @var list<int> */
|
||
private array $created = [];
|
||
|
||
/** @var array{0: int, 1: int}|null Theme Builder post, template. */
|
||
private ?array $attached = null;
|
||
|
||
public function __construct(private DiviThemeBuilder $store, private ChangeLog $log, private string $ability)
|
||
{
|
||
}
|
||
|
||
/** @throws RuntimeException */
|
||
public function layout(string $area, string $title): int
|
||
{
|
||
$id = $this->store->insertLayout($area, $title);
|
||
$this->created[] = $id;
|
||
|
||
return $id;
|
||
}
|
||
|
||
/**
|
||
* @param array{title: string, layouts: array<string, array{id: int, enabled: bool}>, use_on: list<string>,
|
||
* exclude_from: list<string>} $template
|
||
* @throws RuntimeException
|
||
*/
|
||
public function template(int $themeBuilderId, array $template): int
|
||
{
|
||
$id = $this->store->storeTemplate($themeBuilderId, $template);
|
||
$this->created[] = $id;
|
||
|
||
return $id;
|
||
}
|
||
|
||
/** @throws RuntimeException */
|
||
public function attach(int $themeBuilderId, int $templateId): void
|
||
{
|
||
$this->attached = [$themeBuilderId, $templateId];
|
||
$this->store->attach($themeBuilderId, $templateId);
|
||
}
|
||
|
||
/** @return list<int> */
|
||
public function created(): array
|
||
{
|
||
return $this->created;
|
||
}
|
||
|
||
/**
|
||
* A refusal after posts exist (a layout the new post's own dynamic content map refuses): everything created
|
||
* is removed, then the refusal is returned.
|
||
*
|
||
* @throws WriteFailed When the cleanup fails.
|
||
*/
|
||
public function refuse(WP_Error $refusal): WP_Error
|
||
{
|
||
$failures = $this->undo();
|
||
if ($failures !== []) {
|
||
throw $this->partial(new RuntimeException($failures[0]), '');
|
||
}
|
||
|
||
return $refusal;
|
||
}
|
||
|
||
/** @throws WriteFailed Always. */
|
||
public function rollBack(Throwable $cause): never
|
||
{
|
||
$failures = $this->undo();
|
||
if ($failures !== []) {
|
||
throw $this->partial($cause, '; rollback: ' . $failures[0]);
|
||
}
|
||
|
||
throw WriteFailed::rolledBack(
|
||
self::REASON,
|
||
$cause,
|
||
'Theme Builder template not created: ' . $cause->getMessage()
|
||
);
|
||
}
|
||
|
||
/** @return list<string> */
|
||
private function undo(): array
|
||
{
|
||
$steps = [];
|
||
if ($this->attached !== null) {
|
||
[$themeBuilderId, $templateId] = $this->attached;
|
||
$steps[] = fn () => $this->store->detach($themeBuilderId, $templateId);
|
||
}
|
||
foreach (array_reverse($this->created) as $id) {
|
||
$steps[] = static function () use ($id): void {
|
||
if (! wp_delete_post($id, true)) {
|
||
throw new RuntimeException(sprintf('post %d not deleted', $id));
|
||
}
|
||
};
|
||
}
|
||
|
||
return Rollback::attempt(...$steps);
|
||
}
|
||
|
||
private function partial(Throwable $cause, string $detail): WriteFailed
|
||
{
|
||
$ids = implode(', ', $this->created);
|
||
$this->log->recordPartial(
|
||
$this->ability,
|
||
DiviThemeBuilder::TEMPLATE,
|
||
$ids,
|
||
sprintf('Theme Builder: Anlegen nicht vollständig zurückgenommen (Beiträge %s)', $ids)
|
||
);
|
||
|
||
return WriteFailed::partial(
|
||
self::REASON,
|
||
$cause,
|
||
'Theme Builder rollback incomplete' . $detail,
|
||
hint: sprintf('Im Divi Theme Builder prüfen und die Beiträge %s bei Bedarf löschen.', $ids),
|
||
details: ['created_ids' => $this->created]
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Tool**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\FollowUp;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\LayoutBuilder;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/tb-create-template (spec §5.7, E7, answer F1): a Theme Builder template, ALWAYS disabled, with per area a
|
||
* new layout (optionally with nodes – Composer/TreeEditor, stored by DiviSaver::saveNew, D7), the default
|
||
* template's layout ("global" header/footer), nothing of its own ("default") or the area hidden. Conditions only
|
||
* from TemplateConditions. Everything is validated first; Divi then creates layouts and template
|
||
* (DiviThemeBuilder), the template is read back and appended to the live list; any failure removes all of it
|
||
* (TemplateBuild). Refused while the Theme Builder has an unsaved draft (E7).
|
||
*/
|
||
final class TbCreateTemplateAbility implements Ability
|
||
{
|
||
public const SOURCES = ['new', 'global', 'default', 'hidden'];
|
||
|
||
public const STALE_EDITOR = 'Ist der Divi Theme Builder gerade in einem anderen Fenster geöffnet, dort vor dem '
|
||
. 'nächsten Speichern neu laden – sonst entfernt Divi die neue Vorlage wieder aus der Liste (lit/tb-list zeigt '
|
||
. 'sie dann unter detached).';
|
||
|
||
private const AREA_LABELS = ['header' => 'Kopfzeile', 'body' => 'Inhalt', 'footer' => 'Fußzeile'];
|
||
|
||
public function __construct(
|
||
private ThemeBuilderAccess $access,
|
||
private TemplateConditions $conditions,
|
||
private DiviComposition $composition,
|
||
private DiviSaver $saver,
|
||
private ChangeLog $log
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/tb-create-template';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Theme-Builder-Vorlage anlegen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Legt eine Vorlage im Divi Theme Builder an – immer deaktiviert, also ohne Wirkung auf die Website. Je '
|
||
. 'Bereich (header, body, footer) source "new" (neues Layout, optional mit nodes: Abschnitte in Kurz- oder '
|
||
. 'Vollform wie bei lit-divi-insert, z. B. {{post:title}} oder {{field:<Feld>}}), "global" (Kopf-/Fußzeile '
|
||
. 'der Standardvorlage mitbenutzen), "default" (nichts Eigenes) oder "hidden" (Bereich ausblenden). '
|
||
. 'Bedingungen optional (use_on, exclude_from; IDs aus lit/tb-list mit conditions: true). Aktivieren '
|
||
. 'nur mit lit/tb-activate.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
$area = [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'source' => ['type' => 'string', 'enum' => self::SOURCES],
|
||
'title' => ['type' => 'string', 'maxLength' => 200, 'description' => 'Titel des neuen Layouts.'],
|
||
'nodes' => [
|
||
'type' => 'array',
|
||
'maxItems' => 20,
|
||
'items' => ['type' => 'object'],
|
||
'description' => 'Nur mit source "new": Abschnitte wie bei lit-divi-insert.',
|
||
],
|
||
],
|
||
'required' => ['source'],
|
||
'additionalProperties' => false,
|
||
];
|
||
$conditions = ['type' => 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']];
|
||
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||
'header' => $area,
|
||
'body' => $area,
|
||
'footer' => $area,
|
||
'use_on' => $conditions,
|
||
'exclude_from' => $conditions,
|
||
],
|
||
'required' => ['title'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema([
|
||
'template_id' => ['type' => 'integer'],
|
||
'template_rev' => ['type' => 'string'],
|
||
'enabled' => ['type' => 'boolean'],
|
||
'layouts' => ['type' => 'object'],
|
||
], ['template_id', 'template_rev', 'enabled']);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::write(false);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return $this->access->canCreate() ?? $this->access->draftRefusal() ?? true;
|
||
}
|
||
|
||
/** @throws WriteFailed After the first post exists (everything was rolled back first, if possible). */
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->access->canCreate() ?? $this->access->draftRefusal();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$errors = [];
|
||
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
|
||
if ($title === '') {
|
||
$errors['title'] = 'nicht leer';
|
||
}
|
||
$plan = $this->plan($input, $title, $errors);
|
||
$useOn = $this->conditions->check($input['use_on'] ?? null, 'use_on');
|
||
$excludeFrom = $this->conditions->check($input['exclude_from'] ?? null, 'exclude_from');
|
||
$errors = array_merge($errors, $useOn['errors'], $excludeFrom['errors']);
|
||
foreach ($plan as $area => $spec) {
|
||
if ($spec['nodes'] !== []) {
|
||
$layout = LayoutBuilder::build($this->composition->forPost(0, $area), $spec['nodes'], $area . '.nodes');
|
||
if ($layout instanceof WP_Error) {
|
||
$errors = array_merge($errors, self::fields($layout, $area));
|
||
}
|
||
}
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
return Errors::invalidInput('Die Vorlage ist ungültig; nichts wurde angelegt.', $errors);
|
||
}
|
||
|
||
return $this->create($title, $plan, $useOn['ids'], $excludeFrom['ids']);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @param array<string, string> $errors
|
||
* @return array<string, array{source: string, title: string, nodes: list<mixed>, id: int}>
|
||
*/
|
||
private function plan(array $input, string $title, array &$errors): array
|
||
{
|
||
$default = null;
|
||
$plan = [];
|
||
foreach (Template::AREAS as $area) {
|
||
$spec = is_array($input[$area] ?? null) ? $input[$area] : ['source' => 'default'];
|
||
$source = $spec['source'] ?? null;
|
||
if (! in_array($source, self::SOURCES, true)) {
|
||
$errors[$area . '.source'] = 'new, global, default oder hidden';
|
||
continue;
|
||
}
|
||
|
||
$nodes = is_array($spec['nodes'] ?? null) ? array_values($spec['nodes']) : [];
|
||
if ($nodes !== [] && $source !== 'new') {
|
||
$errors[$area . '.nodes'] = 'nur mit source "new"';
|
||
}
|
||
|
||
$id = 0;
|
||
if ($source === 'global') {
|
||
if ($area === 'body') {
|
||
$errors['body.source'] = 'für den Inhalt gibt es kein globales Layout';
|
||
continue;
|
||
}
|
||
$default ??= $this->defaultTemplate();
|
||
$id = $default?->layouts[$area]['id'] ?? 0;
|
||
if ($id <= 0) {
|
||
$errors[$area . '.source'] = sprintf(
|
||
'die Standardvorlage hat keine eigene %s',
|
||
self::AREA_LABELS[$area]
|
||
);
|
||
continue;
|
||
}
|
||
}
|
||
|
||
$layoutTitle = sanitize_text_field(is_string($spec['title'] ?? null) ? $spec['title'] : '');
|
||
$plan[$area] = [
|
||
'source' => (string) $source,
|
||
'title' => $layoutTitle !== '' ? $layoutTitle : sprintf('%s – %s', $title, self::AREA_LABELS[$area]),
|
||
'nodes' => $nodes,
|
||
'id' => $id,
|
||
];
|
||
}
|
||
|
||
return $plan;
|
||
}
|
||
|
||
private function defaultTemplate(): ?Template
|
||
{
|
||
foreach ($this->access->store()->liveTemplates() as $template) {
|
||
if ($template->default) {
|
||
return $template;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, array{source: string, title: string, nodes: list<mixed>, id: int}> $plan
|
||
* @param list<string> $useOn
|
||
* @param list<string> $excludeFrom
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed
|
||
*/
|
||
private function create(string $title, array $plan, array $useOn, array $excludeFrom): array|WP_Error
|
||
{
|
||
$store = $this->access->store();
|
||
$build = new TemplateBuild($store, $this->log, $this->name());
|
||
$layouts = [];
|
||
$warnings = [];
|
||
try {
|
||
foreach ($plan as $area => $spec) {
|
||
$layouts[$area] = ['id' => $spec['id'], 'enabled' => $spec['source'] !== 'hidden'];
|
||
if ($spec['source'] !== 'new') {
|
||
continue;
|
||
}
|
||
$id = $build->layout($area, $spec['title']);
|
||
$layouts[$area]['id'] = $id;
|
||
if ($spec['nodes'] === []) {
|
||
continue;
|
||
}
|
||
$composition = $this->composition->forPost($id, $area);
|
||
$layout = LayoutBuilder::build($composition, $spec['nodes'], $area . '.nodes');
|
||
if ($layout instanceof WP_Error) {
|
||
return $build->refuse($layout);
|
||
}
|
||
$warnings = array_merge(
|
||
$warnings,
|
||
$composition->warnings()->collect(NodeIndex::build($layout['document']), $layout['blocks']),
|
||
$this->saver->saveNew(
|
||
$id,
|
||
Serializer::document($layout['document']),
|
||
SaveScope::ThemeBuilderLayout
|
||
)
|
||
);
|
||
}
|
||
|
||
$themeBuilderId = $store->liveId(true);
|
||
if ($themeBuilderId <= 0) {
|
||
throw new RuntimeException('no live Theme Builder post');
|
||
}
|
||
$templateId = $build->template($themeBuilderId, [
|
||
'title' => $title,
|
||
'layouts' => $layouts,
|
||
'use_on' => $useOn,
|
||
'exclude_from' => $excludeFrom,
|
||
]);
|
||
$mismatch = self::mismatch($store->template($templateId, false), $layouts, $useOn, $excludeFrom);
|
||
if ($mismatch !== null) {
|
||
throw new RuntimeException($mismatch);
|
||
}
|
||
$build->attach($themeBuilderId, $templateId);
|
||
} catch (WriteFailed $e) {
|
||
throw $e;
|
||
} catch (Throwable $e) {
|
||
$build->rollBack($e);
|
||
}
|
||
|
||
$after = [];
|
||
FollowUp::run(
|
||
static fn () => $store->clearCaches(),
|
||
'theme builder cache',
|
||
'Der Cache des Theme Builders konnte nicht geleert werden; die Vorlage ist angelegt (deaktiviert).',
|
||
$after
|
||
);
|
||
$newLayouts = [];
|
||
foreach ($plan as $area => $spec) {
|
||
if ($spec['source'] === 'new') {
|
||
$newLayouts[] = $area . ' ' . $layouts[$area]['id'];
|
||
}
|
||
}
|
||
$logId = $this->log->record(
|
||
$this->name(),
|
||
DiviThemeBuilder::TEMPLATE,
|
||
(string) $templateId,
|
||
sprintf(
|
||
'Theme Builder: Vorlage „%s“ angelegt (deaktiviert; neue Layouts: %s)',
|
||
$title,
|
||
$newLayouts === [] ? 'keine' : implode(', ', $newLayouts)
|
||
)
|
||
);
|
||
$template = $store->template($templateId, true);
|
||
$out = [];
|
||
foreach ($layouts as $area => $layout) {
|
||
$out[$area] = $layout + ['source' => $plan[$area]['source']];
|
||
}
|
||
|
||
return ToolResult::ok(sprintf(
|
||
'Theme-Builder-Vorlage „%s“ angelegt – deaktiviert, ohne Wirkung auf die Website. Aktivieren nur mit '
|
||
. 'lit/tb-activate.',
|
||
$title
|
||
))
|
||
->with('template_id', $templateId)
|
||
->with('template_rev', $template?->rev() ?? '')
|
||
->with('enabled', false)
|
||
->with('layouts', $out)
|
||
->warnings(array_merge($warnings, $after, [self::STALE_EDITOR]))
|
||
->logId($logId)
|
||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||
->toArray();
|
||
}
|
||
|
||
/**
|
||
* Why the stored template is not what was asked for (Divi silently drops layout IDs, F17), else null.
|
||
*
|
||
* @param array<string, array{id: int, enabled: bool}> $layouts
|
||
* @param list<string> $useOn
|
||
* @param list<string> $excludeFrom
|
||
*/
|
||
private static function mismatch(?Template $stored, array $layouts, array $useOn, array $excludeFrom): ?string
|
||
{
|
||
if ($stored === null) {
|
||
return 'template not readable after store';
|
||
}
|
||
if ($stored->enabled || $stored->default) {
|
||
return 'template stored enabled or as default';
|
||
}
|
||
foreach ($layouts as $area => $layout) {
|
||
if ($stored->layouts[$area] !== $layout) {
|
||
return sprintf('layout %s stored as %d', $area, $stored->layouts[$area]['id']);
|
||
}
|
||
}
|
||
|
||
$same = $stored->useOn === $useOn && $stored->excludeFrom === $excludeFrom;
|
||
|
||
return $same ? null : 'conditions stored differently';
|
||
}
|
||
|
||
/** @return array<string, string> The refusal's fields, or its message under "<area>.nodes". */
|
||
private static function fields(WP_Error $error, string $area): array
|
||
{
|
||
$fields = Errors::fieldsOf($error);
|
||
|
||
return $fields !== [] ? $fields : [$area . '.nodes' => Errors::clientMessage($error)];
|
||
}
|
||
}
|
||
```
|
||
|
||
`ThemeBuilderIntegration::abilities()`:
|
||
|
||
```php
|
||
return [
|
||
new TbListAbility($this->access, $conditions),
|
||
new TbCreateTemplateAbility($this->access, $conditions, $this->composition, $this->saver, $this->log),
|
||
];
|
||
```
|
||
|
||
- [ ] **Step 6: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'TemplateBuildTest|TbCreateTemplateAbilityTest|DiviContentComposerTest|DiviIntegrationTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/LayoutBuilder.php src/Integrations/Divi/Pages/DiviContentComposer.php src/Integrations/Divi/ThemeBuilder tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: lit/tb-create-template – disabled templates, all or nothing`.
|
||
|
||
---
|
||
### Task 9: `lit/tb-assign` – Bedingungen setzen, entfernte Vorlagen wieder zuordnen
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/ThemeBuilder/ThemeBuilderAccess.php` (`changeableTemplate()`)
|
||
- Create: `src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php`
|
||
- Modify: `src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php`
|
||
- Test: `tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderAccessTest.php`, `TbAssignAbilityTest.php`; `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Tasks 4, 6, 7.
|
||
- Produces: `ThemeBuilderAccess::changeableTemplate(array $input): Template|WP_Error` (Rechte, Gruppe, keine TB-Sitzung, nie die Standardvorlage, aktuelle `template_rev`); `TbAssignAbility::__construct(ThemeBuilderAccess $access, TemplateConditions $conditions, ChangeLog $log)`, `REASON`; Ausgabe `ToolResult` + `template_id`, `template_rev`, `live`, `previous` (`{use_on, exclude_from}`); Probelauf-Details `before`, `after`, `overlaps`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`ThemeBuilderAccessTest` ergänzen (`use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;`):
|
||
|
||
```php
|
||
public function testChangeableTemplateNeedsRightsNoDraftNoDefaultAndTheCurrentRev(): void
|
||
{
|
||
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066), ThemeBuilderFixtures::templatePost(1065)]);
|
||
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), ThemeBuilderFixtures::template(1066)]);
|
||
$access = ThemeBuilderFixtures::access([], true, $store);
|
||
$rev = ThemeBuilderFixtures::template(1066)->rev();
|
||
|
||
self::assertInstanceOf(Template::class, $access->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]));
|
||
self::assertToolError($access->changeableTemplate(['template_id' => 1066, 'template_rev' => 'alt']), 'rev_mismatch');
|
||
self::assertToolError(
|
||
$access->changeableTemplate(['template_id' => 1065, 'template_rev' => ThemeBuilderFixtures::template(1065)->rev()]),
|
||
'unsupported'
|
||
);
|
||
$draft = ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1066)], 999));
|
||
self::assertToolError($draft->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]), 'unsupported');
|
||
self::assertToolError(
|
||
ThemeBuilderFixtures::access([], false, $store)->changeableTemplate(['template_id' => 1066, 'template_rev' => $rev]),
|
||
'forbidden'
|
||
);
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/TbAssignAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbAssignAbility;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use RuntimeException;
|
||
|
||
final class TbAssignAbilityTest extends TestCase
|
||
{
|
||
private const PERSONS = 'singular:post_type:lit_person:all';
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066), ThemeBuilderFixtures::templatePost(1065)]);
|
||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||
}
|
||
|
||
private static function ability(DiviThemeBuilder $store, ?FakeChangeLog $log = null): TbAssignAbility
|
||
{
|
||
return new TbAssignAbility(
|
||
ThemeBuilderFixtures::access([], true, $store),
|
||
new TemplateConditions($store),
|
||
$log ?? DiviWriterFixture::log()
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private static function input(Template $template, array $extra = []): array
|
||
{
|
||
return array_merge(
|
||
['template_id' => $template->id, 'template_rev' => $template->rev(), 'use_on' => [self::PERSONS]],
|
||
$extra
|
||
);
|
||
}
|
||
|
||
public function testSetsConditionsOfADisabledTemplateAtOnce(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066);
|
||
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065), $template]);
|
||
$store->shouldReceive('setConditions')->once()->with(1066, [self::PERSONS], []);
|
||
$store->shouldReceive('clearCaches')->never();
|
||
|
||
$result = self::ability($store)->execute(self::input($template));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertFalse($result['live']);
|
||
self::assertSame(['use_on' => ['singular:post_type:page:id:1053'], 'exclude_from' => []], $result['previous']);
|
||
self::assertStringContainsString('ohne Wirkung auf die Website', $result['summary']);
|
||
}
|
||
|
||
public function testActiveTemplateNeedsConfirm(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||
$other = ThemeBuilderFixtures::template(1070, ['enabled' => true, 'title' => 'Personen alt', 'use_on' => [self::PERSONS]]);
|
||
$store = ThemeBuilderFixtures::store([$template, $other]);
|
||
$calls = 0;
|
||
$store->shouldReceive('setConditions')->andReturnUsing(static function () use (&$calls): void {
|
||
++$calls;
|
||
});
|
||
$store->shouldReceive('clearCaches')->once();
|
||
|
||
$dry = self::ability($store)->execute(self::input($template));
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertSame(0, $calls);
|
||
self::assertSame(['Bestimmte Seiten: 1053'], $dry->get_error_data()['before']);
|
||
self::assertSame(['Alle Personen'], $dry->get_error_data()['after']);
|
||
self::assertSame(
|
||
[['id' => 1070, 'title' => 'Personen alt', 'conditions' => [self::PERSONS]]],
|
||
$dry->get_error_data()['overlaps']
|
||
);
|
||
|
||
$result = self::ability($store)->execute(self::input($template, ['confirm' => true]));
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(1, $calls);
|
||
}
|
||
|
||
public function testReattachKeepsTheTemplateDisabledAndUnmarksItsLayouts(): void
|
||
{
|
||
$detached = ThemeBuilderFixtures::template(1066, [
|
||
'enabled' => true,
|
||
'attached' => false,
|
||
'marked_unused' => '2026-09-20 08:00:00',
|
||
]);
|
||
$store = ThemeBuilderFixtures::store([ThemeBuilderFixtures::template(1065)], 0, [$detached]);
|
||
|
||
$refused = self::ability($store)->execute(self::input($detached));
|
||
self::assertToolError($refused, 'validation_failed');
|
||
self::assertArrayHasKey('reattach', self::errorFields($refused));
|
||
self::assertStringContainsString('2026-09-20', self::clientError($refused)['message']);
|
||
|
||
$store->shouldReceive('setEnabled')->once()->with(1066, false);
|
||
$store->shouldReceive('attach')->once()->with(1064, 1066);
|
||
$unmarked = [];
|
||
$store->shouldReceive('unmarkUnused')->andReturnUsing(static function (int $id) use (&$unmarked): void {
|
||
$unmarked[] = $id;
|
||
});
|
||
$store->shouldReceive('setConditions')->once()->with(1066, [self::PERSONS], []);
|
||
|
||
$result = self::ability($store)->execute(self::input($detached, ['reattach' => true]));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame([1066, 1061, 1062, 1063], $unmarked);
|
||
}
|
||
|
||
public function testFailureRestoresThePreviousConditions(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066);
|
||
$store = ThemeBuilderFixtures::store([$template]);
|
||
$calls = [];
|
||
$store->shouldReceive('setConditions')->andReturnUsing(static function (int $id, array $useOn) use (&$calls): void {
|
||
$calls[] = $useOn;
|
||
if (count($calls) === 1) {
|
||
throw new RuntimeException('add_post_meta failed');
|
||
}
|
||
});
|
||
|
||
try {
|
||
self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertTrue($e->rolledBack);
|
||
}
|
||
self::assertSame([[self::PERSONS], ['singular:post_type:page:id:1053']], $calls);
|
||
}
|
||
|
||
public function testInvalidConditionsAndMissingUseOnWriteNothing(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066);
|
||
$store = ThemeBuilderFixtures::store([$template]);
|
||
$store->shouldReceive('setConditions')->never();
|
||
|
||
self::assertToolError(self::ability($store)->execute(self::input($template, ['use_on' => ['erfunden']])), 'validation_failed');
|
||
$noList = self::input($template);
|
||
unset($noList['use_on']);
|
||
self::assertToolError(self::ability($store)->execute($noList), 'validation_failed');
|
||
self::assertTrue(self::ability($store)->annotations()['destructive']);
|
||
self::assertFalse(self::ability($store)->isDirect());
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegrationTest::testProvidesTheThemeBuilderGroup`: erwartete Namen `['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign']`.
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'ThemeBuilderAccessTest|TbAssignAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL – „Call to undefined method … changeableTemplate()“, „Class … TbAssignAbility not found“.
|
||
|
||
- [ ] **Step 3: changeableTemplate()**
|
||
|
||
In `ThemeBuilderAccess` nach `template()`:
|
||
|
||
```php
|
||
/**
|
||
* A template the Theme Builder tools may change (lit/tb-assign, lit/tb-activate): rights and group gate
|
||
* (template()), no Theme Builder draft (E7), never the default template (E8), the current template_rev.
|
||
*
|
||
* @param array<string, mixed> $input template_id, template_rev
|
||
*/
|
||
public function changeableTemplate(array $input): Template|WP_Error
|
||
{
|
||
$post = $this->template($input['template_id'] ?? null, true);
|
||
if ($post instanceof WP_Error) {
|
||
return $post;
|
||
}
|
||
|
||
$refusal = $this->draftRefusal();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$id = (int) $post->ID;
|
||
$attached = in_array($id, $this->store->templateIds($this->store->liveId()), true);
|
||
$template = $this->store->template($id, $attached);
|
||
if ($template === null) {
|
||
return Errors::notFound(
|
||
sprintf('Theme-Builder-Vorlage %d gibt es nicht.', $id),
|
||
'Vorlagen mit lit/tb-list auflisten.'
|
||
);
|
||
}
|
||
|
||
if ($template->default) {
|
||
return Errors::unsupported(
|
||
'Die Standardvorlage ändert LIT MCP nicht; nichts wurde gespeichert.',
|
||
'Die Standardvorlage im Divi Theme Builder bearbeiten.'
|
||
);
|
||
}
|
||
|
||
if (! is_string($input['template_rev'] ?? null) || $input['template_rev'] !== $template->rev()) {
|
||
return Errors::revMismatch(
|
||
'Die Vorlage wurde seit dem Lesen geändert (template_rev passt nicht); nichts wurde gespeichert.',
|
||
'Mit lit/tb-list neu lesen und mit der neuen template_rev wiederholen.'
|
||
);
|
||
}
|
||
|
||
return $template;
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tool**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/TbAssignAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\FollowUp;
|
||
use LIT\MCP\Abilities\Rollback;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/tb-assign (spec §5.7): replaces the conditions of a template (_et_use_on/_et_exclude_from, only from
|
||
* TemplateConditions). A live template changes the website at once, so it is a dry run first and needs
|
||
* confirm: true (listing before/after and overlaps with other live templates). reattach: true puts a template a
|
||
* stale Theme Builder save removed back on the live list – always disabled, Divi's "unused" marks removed from
|
||
* it and its layouts. Templates have no revisions: the previous conditions are in the answer and the change log;
|
||
* a failed write restores them.
|
||
*/
|
||
final class TbAssignAbility implements Ability
|
||
{
|
||
public const REASON = 'Die Bedingungen der Vorlage konnten nicht gespeichert werden.';
|
||
|
||
public function __construct(
|
||
private ThemeBuilderAccess $access,
|
||
private TemplateConditions $conditions,
|
||
private ChangeLog $log
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/tb-assign';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Theme-Builder-Vorlage zuordnen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Setzt die Bedingungen einer Theme-Builder-Vorlage neu: use_on ersetzt die bisherigen, exclude_from '
|
||
. 'ebenso (z. B. "singular:post_type:person:all" = alle Einträge des Inhaltstyps person). IDs aus '
|
||
. 'lit/tb-list mit conditions: true; template_rev aus lit/tb-list ist Pflicht. Bei einer aktiven '
|
||
. 'Vorlage wirkt das sofort auf der Website: erst Probelauf, dann confirm: true. reattach: true ordnet '
|
||
. 'eine Vorlage wieder zu, die ein Speichern im Theme Builder entfernt hat (sie bleibt deaktiviert). Die '
|
||
. 'Standardvorlage nur im Theme Builder.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
$conditions = ['type' => 'array', 'maxItems' => TemplateConditions::MAX, 'items' => ['type' => 'string']];
|
||
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'template_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'],
|
||
'use_on' => $conditions,
|
||
'exclude_from' => $conditions,
|
||
'reattach' => ['type' => 'boolean'],
|
||
'confirm' => ['type' => 'boolean'],
|
||
],
|
||
'required' => ['template_id', 'template_rev', 'use_on'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema([
|
||
'template_id' => ['type' => 'integer'],
|
||
'template_rev' => ['type' => 'string'],
|
||
'live' => ['type' => 'boolean'],
|
||
'previous' => ['type' => 'object'],
|
||
], ['template_id', 'template_rev', 'live']);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::destructive(true);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$template = $this->access->changeableTemplate($input);
|
||
|
||
return $template instanceof WP_Error ? $template : true;
|
||
}
|
||
|
||
/** @throws WriteFailed */
|
||
public function execute(array $input): mixed
|
||
{
|
||
$template = $this->access->changeableTemplate($input);
|
||
if ($template instanceof WP_Error) {
|
||
return $template;
|
||
}
|
||
|
||
$useOn = is_array($input['use_on'] ?? null)
|
||
? $this->conditions->check($input['use_on'], 'use_on')
|
||
: ['ids' => [], 'errors' => ['use_on' => 'Liste von Bedingungs-IDs erwartet (leere Liste = keine)']];
|
||
$excludeFrom = $this->conditions->check($input['exclude_from'] ?? [], 'exclude_from');
|
||
$errors = array_merge($useOn['errors'], $excludeFrom['errors']);
|
||
if ($errors !== []) {
|
||
return Errors::invalidInput('Bedingungen sind ungültig; nichts wurde gespeichert.', $errors);
|
||
}
|
||
|
||
$reattach = ($input['reattach'] ?? false) === true;
|
||
if (! $template->attached && ! $reattach) {
|
||
$marked = $template->markedUnused === null
|
||
? ''
|
||
: sprintf(' (seit %s als ungenutzt markiert; Divi löscht sie 7 Tage danach)', $template->markedUnused);
|
||
|
||
return Errors::invalidInput(
|
||
sprintf(
|
||
'Vorlage %d ist im Theme Builder nicht mehr zugeordnet%s; nichts wurde gespeichert.',
|
||
$template->id,
|
||
$marked
|
||
),
|
||
['reattach' => 'mit reattach: true wieder zuordnen (die Vorlage bleibt deaktiviert)']
|
||
);
|
||
}
|
||
|
||
if ($template->isLive() && ($input['confirm'] ?? false) !== true) {
|
||
$live = $this->access->store()->liveTemplates();
|
||
|
||
return Errors::confirmRequired(
|
||
sprintf(
|
||
'Vorlage „%s“ ist aktiv; die neuen Bedingungen wirken sofort auf der Website. Nichts wurde '
|
||
. 'gespeichert (Probelauf).',
|
||
$template->title
|
||
),
|
||
[
|
||
'before' => array_map($this->conditions->label(...), $template->useOn),
|
||
'after' => array_map($this->conditions->label(...), $useOn['ids']),
|
||
'overlaps' => Template::overlaps($live, $template->id, $useOn['ids']),
|
||
]
|
||
);
|
||
}
|
||
|
||
return $this->write($template, $useOn['ids'], $excludeFrom['ids'], $reattach && ! $template->attached);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $useOn
|
||
* @param list<string> $excludeFrom
|
||
* @return array<string, mixed>
|
||
* @throws WriteFailed
|
||
*/
|
||
private function write(Template $template, array $useOn, array $excludeFrom, bool $reattach): array
|
||
{
|
||
$store = $this->access->store();
|
||
$themeBuilderId = $store->liveId();
|
||
try {
|
||
if ($reattach) {
|
||
if ($themeBuilderId <= 0) {
|
||
throw new RuntimeException('no live Theme Builder post');
|
||
}
|
||
$store->setEnabled($template->id, false);
|
||
$store->attach($themeBuilderId, $template->id);
|
||
foreach (array_merge([$template->id], array_column($template->layouts, 'id')) as $id) {
|
||
if ($id > 0) {
|
||
$store->unmarkUnused($id);
|
||
}
|
||
}
|
||
}
|
||
$store->setConditions($template->id, $useOn, $excludeFrom);
|
||
} catch (Throwable $e) {
|
||
$this->undo($template, $reattach, $themeBuilderId, $e);
|
||
}
|
||
|
||
$warnings = [];
|
||
if ($template->isLive()) {
|
||
FollowUp::run(
|
||
static fn () => $store->clearCaches(),
|
||
'theme builder cache',
|
||
'Der Cache des Theme Builders konnte nicht geleert werden; die Bedingungen sind gespeichert, wirken '
|
||
. 'aber evtl. erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
||
$warnings
|
||
);
|
||
}
|
||
$logId = $this->log->record(
|
||
$this->name(),
|
||
DiviThemeBuilder::TEMPLATE,
|
||
(string) $template->id,
|
||
sprintf(
|
||
'Theme Builder: Bedingungen von „%s“ gesetzt%s (vorher: %s)',
|
||
$template->title,
|
||
$reattach ? ', wieder zugeordnet' : '',
|
||
$template->useOn === [] ? 'keine' : implode(', ', $template->useOn)
|
||
)
|
||
);
|
||
$after = $store->template($template->id, true);
|
||
$live = $after?->isLive() ?? false;
|
||
|
||
return ToolResult::ok(sprintf(
|
||
'Bedingungen der Vorlage „%s“ gesetzt%s.',
|
||
$template->title,
|
||
$live ? ' – wirkt sofort auf der Website' : ' (Vorlage deaktiviert, ohne Wirkung auf die Website)'
|
||
))
|
||
->with('template_id', $template->id)
|
||
->with('template_rev', $after?->rev() ?? '')
|
||
->with('live', $live)
|
||
->with('previous', ['use_on' => $template->useOn, 'exclude_from' => $template->excludeFrom])
|
||
->warnings($warnings)
|
||
->logId($logId)
|
||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||
->toArray();
|
||
}
|
||
|
||
/** @throws WriteFailed Always. */
|
||
private function undo(Template $template, bool $reattach, int $themeBuilderId, Throwable $cause): never
|
||
{
|
||
$store = $this->access->store();
|
||
$steps = [static fn () => $store->setConditions($template->id, $template->useOn, $template->excludeFrom)];
|
||
if ($reattach) {
|
||
$steps[] = static fn () => $store->detach($themeBuilderId, $template->id);
|
||
$steps[] = static fn () => $store->setEnabled($template->id, $template->enabled);
|
||
}
|
||
|
||
$failures = Rollback::attempt(...$steps);
|
||
if ($failures === []) {
|
||
throw WriteFailed::rolledBack(self::REASON, $cause, 'tb-assign failed: ' . $cause->getMessage());
|
||
}
|
||
|
||
$this->log->recordPartial(
|
||
$this->name(),
|
||
DiviThemeBuilder::TEMPLATE,
|
||
(string) $template->id,
|
||
sprintf('Theme Builder: Bedingungen von „%s“ nur teilweise zurückgenommen', $template->title)
|
||
);
|
||
throw WriteFailed::partial(
|
||
self::REASON,
|
||
$cause,
|
||
'tb-assign rollback: ' . $failures[0],
|
||
$template->id,
|
||
hint: sprintf(
|
||
'Mit lit/tb-list prüfen; vorher galt: %s.',
|
||
$template->useOn === [] ? 'keine Bedingungen' : implode(', ', $template->useOn)
|
||
)
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `ThemeBuilderIntegration::abilities()` ergänzen: `new TbAssignAbility($this->access, $conditions, $this->log),`.
|
||
|
||
- [ ] **Step 5: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'ThemeBuilderAccessTest|TbAssignAbilityTest|DiviIntegrationTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/ThemeBuilder tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: lit/tb-assign – conditions with dry run on live templates, reattach`.
|
||
|
||
---
|
||
|
||
### Task 10: `lit/tb-activate` – die ausdrückliche Aktivierung (Antwort F1)
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php`
|
||
- Modify: `src/Integrations/Divi/ThemeBuilder/ThemeBuilderIntegration.php`
|
||
- Test: `tests/Unit/Integrations/Divi/ThemeBuilder/TbActivateAbilityTest.php`; `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ThemeBuilderAccess::changeableTemplate()` (Task 9), `TemplateConditions::label()`, `Template::overlaps()`.
|
||
- Produces: `TbActivateAbility::__construct(ThemeBuilderAccess $access, TemplateConditions $conditions, ChangeLog $log)`, `REASON`; Eingabe `template_id`, `template_rev`, `enabled` (Pflicht), `confirm`; Ausgabe `ToolResult` + `template_id`, `template_rev`, `enabled`, `live`; Probelauf-Details `use_on`, `exclude_from` (Beschriftungen), `layouts`, `overlaps`.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`tests/Unit/Integrations/Divi/ThemeBuilder/TbActivateAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\ThemeBuilder;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\DiviThemeBuilder;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TbActivateAbility;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\Template;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\TemplateConditions;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class TbActivateAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
LibraryFixtures::given([ThemeBuilderFixtures::templatePost(1066), ThemeBuilderFixtures::templatePost(1065)]);
|
||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||
}
|
||
|
||
private static function ability(DiviThemeBuilder $store, ?FakeChangeLog $log = null): TbActivateAbility
|
||
{
|
||
return new TbActivateAbility(
|
||
ThemeBuilderFixtures::access([], true, $store),
|
||
new TemplateConditions($store),
|
||
$log ?? DiviWriterFixture::log()
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private static function input(Template $template, bool $enabled, bool $confirm = false): array
|
||
{
|
||
return array_filter([
|
||
'template_id' => $template->id,
|
||
'template_rev' => $template->rev(),
|
||
'enabled' => $enabled,
|
||
'confirm' => $confirm ?: null,
|
||
], static fn (mixed $value): bool => $value !== null);
|
||
}
|
||
|
||
public function testActivationIsAlwaysADryRunFirst(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066);
|
||
$other = ThemeBuilderFixtures::template(1070, [
|
||
'enabled' => true,
|
||
'title' => 'Seite 1053',
|
||
'use_on' => ['singular:post_type:page:id:1053'],
|
||
]);
|
||
$store = ThemeBuilderFixtures::store([$template, $other]);
|
||
$store->shouldReceive('setEnabled')->never();
|
||
|
||
$dry = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, true));
|
||
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertStringContainsString('Aktivieren', self::clientError($dry)['message']);
|
||
self::assertSame(['Bestimmte Seiten: 1053'], $dry->get_error_data()['use_on']);
|
||
self::assertSame(1070, $dry->get_error_data()['overlaps'][0]['id']);
|
||
self::assertSame(1062, $dry->get_error_data()['layouts']['body']['id']);
|
||
}
|
||
|
||
public function testConfirmedActivationSwitchesOnlyTheFlag(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066);
|
||
$store = ThemeBuilderFixtures::store([$template]);
|
||
$store->shouldReceive('setEnabled')->once()->with(1066, true);
|
||
$store->shouldReceive('clearCaches')->once();
|
||
$store->shouldReceive('setConditions')->never();
|
||
$store->shouldReceive('attach')->never();
|
||
|
||
$result = self::ability($store)->execute(self::input($template, true, true));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertTrue($result['enabled']);
|
||
self::assertStringContainsString('aktiviert', $result['summary']);
|
||
}
|
||
|
||
public function testDeactivationAlsoNeedsConfirm(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066, ['enabled' => true]);
|
||
$store = ThemeBuilderFixtures::store([$template]);
|
||
$store->shouldReceive('setEnabled')->once()->with(1066, false);
|
||
$store->shouldReceive('clearCaches')->once();
|
||
|
||
self::assertToolError(self::ability($store)->execute(self::input($template, false)), 'confirm_required');
|
||
self::assertTrue(self::ability($store)->execute(self::input($template, false, true))['ok']);
|
||
}
|
||
|
||
public function testRefusesTemplatesWithoutConditionsOrDetached(): void
|
||
{
|
||
$empty = ThemeBuilderFixtures::template(1066, ['use_on' => []]);
|
||
$store = ThemeBuilderFixtures::store([$empty]);
|
||
$store->shouldReceive('setEnabled')->never();
|
||
self::assertToolError(self::ability($store)->execute(self::input($empty, true, true)), 'validation_failed');
|
||
|
||
$detached = ThemeBuilderFixtures::template(1066, ['attached' => false]);
|
||
$store = ThemeBuilderFixtures::store([], 0, [$detached]);
|
||
$store->shouldReceive('setEnabled')->never();
|
||
$refused = self::ability($store)->execute(self::input($detached, true, true));
|
||
self::assertToolError($refused, 'validation_failed');
|
||
self::assertStringContainsString('reattach', (string) json_encode(self::errorFields($refused)));
|
||
}
|
||
|
||
public function testNoChangeWhenAlreadyInThatState(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066);
|
||
$store = ThemeBuilderFixtures::store([$template]);
|
||
$store->shouldReceive('setEnabled')->never();
|
||
|
||
$result = self::ability($store, FakeChangeLog::forbidden())->execute(self::input($template, false));
|
||
|
||
self::assertStringContainsString('Keine Änderung', $result['summary']);
|
||
self::assertTrue(self::ability($store)->annotations()['destructive']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegrationTest::testProvidesTheThemeBuilderGroup`: erwartete Namen `['lit/tb-list', 'lit/tb-create-template', 'lit/tb-assign', 'lit/tb-activate']`.
|
||
|
||
- [ ] **Step 2: Test läuft rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'TbActivateAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL – „Class … TbActivateAbility not found“.
|
||
|
||
- [ ] **Step 3: Tool**
|
||
|
||
`src/Integrations/Divi/ThemeBuilder/TbActivateAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\ThemeBuilder;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\FollowUp;
|
||
use LIT\MCP\Abilities\Rollback;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use Throwable;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/tb-activate (answer F1): the one place where LIT MCP makes a template visible to visitors or hides it
|
||
* again – _et_enabled, nothing else. Always a dry run first (conditions with labels, layouts, overlaps with other
|
||
* live templates), then confirm: true. Refused for the default template, a template the live Theme Builder does
|
||
* not list (reattach first) and, when enabling, one without conditions (it would apply nowhere).
|
||
*/
|
||
final class TbActivateAbility implements Ability
|
||
{
|
||
public const REASON = 'Die Vorlage konnte nicht umgeschaltet werden.';
|
||
|
||
public function __construct(
|
||
private ThemeBuilderAccess $access,
|
||
private TemplateConditions $conditions,
|
||
private ChangeLog $log
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/tb-activate';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Theme-Builder-Vorlage aktivieren';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Aktiviert (enabled: true) oder deaktiviert (enabled: false) eine Vorlage des Divi Theme Builders – die '
|
||
. 'einzige Stelle, an der LIT MCP eine Vorlage für Besucher sichtbar macht. Immer zuerst Probelauf '
|
||
. '(Bedingungen, Layouts, Überschneidungen mit anderen aktiven Vorlagen), dann nach Rückfrage beim Nutzer '
|
||
. 'confirm: true. template_rev aus lit/tb-list ist Pflicht; die Standardvorlage nur im Theme Builder.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'template_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'template_rev' => ['type' => 'string', 'description' => 'template_rev aus lit/tb-list.'],
|
||
'enabled' => ['type' => 'boolean'],
|
||
'confirm' => ['type' => 'boolean'],
|
||
],
|
||
'required' => ['template_id', 'template_rev', 'enabled'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema([
|
||
'template_id' => ['type' => 'integer'],
|
||
'template_rev' => ['type' => 'string'],
|
||
'enabled' => ['type' => 'boolean'],
|
||
'live' => ['type' => 'boolean'],
|
||
], ['template_id', 'template_rev', 'enabled', 'live']);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::destructive(true);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$template = $this->access->changeableTemplate($input);
|
||
|
||
return $template instanceof WP_Error ? $template : true;
|
||
}
|
||
|
||
/** @throws WriteFailed */
|
||
public function execute(array $input): mixed
|
||
{
|
||
$template = $this->access->changeableTemplate($input);
|
||
if ($template instanceof WP_Error) {
|
||
return $template;
|
||
}
|
||
|
||
$enabled = $input['enabled'] ?? null;
|
||
if (! is_bool($enabled)) {
|
||
return Errors::invalidInput('enabled fehlt; nichts wurde gespeichert.', ['enabled' => 'true oder false']);
|
||
}
|
||
|
||
if (! $template->attached) {
|
||
return Errors::invalidInput(
|
||
sprintf('Vorlage %d ist im Theme Builder nicht zugeordnet; nichts wurde gespeichert.', $template->id),
|
||
['template_id' => 'zuerst mit lit/tb-assign und reattach: true wieder zuordnen']
|
||
);
|
||
}
|
||
|
||
if ($template->enabled === $enabled) {
|
||
return $this->result($template, sprintf(
|
||
'Keine Änderung – die Vorlage „%s“ ist bereits %s.',
|
||
$template->title,
|
||
$enabled ? 'aktiv' : 'deaktiviert'
|
||
), null);
|
||
}
|
||
|
||
if ($enabled && $template->useOn === []) {
|
||
return Errors::invalidInput(
|
||
'Ohne Bedingungen gilt eine Vorlage nirgends; zuerst mit lit/tb-assign Bedingungen setzen. Nichts '
|
||
. 'wurde gespeichert.',
|
||
['template_id' => 'keine Bedingungen (use_on)']
|
||
);
|
||
}
|
||
|
||
if (($input['confirm'] ?? false) !== true) {
|
||
return $this->dryRun($template, $enabled);
|
||
}
|
||
|
||
return $this->switch($template, $enabled);
|
||
}
|
||
|
||
private function dryRun(Template $template, bool $enabled): WP_Error
|
||
{
|
||
$labels = array_map($this->conditions->label(...), $template->useOn);
|
||
$message = $enabled
|
||
? sprintf(
|
||
'Aktivieren: Die Vorlage „%s“ gilt danach sofort für Besucher (%s). Nichts wurde gespeichert '
|
||
. '(Probelauf).',
|
||
$template->title,
|
||
implode('; ', $labels)
|
||
)
|
||
: sprintf(
|
||
'Deaktivieren: Die Vorlage „%s“ gilt danach nicht mehr (%s); dort zeigt Divi wieder die '
|
||
. 'Standardvorlage oder das Theme. Nichts wurde gespeichert (Probelauf).',
|
||
$template->title,
|
||
implode('; ', $labels)
|
||
);
|
||
|
||
return Errors::confirmRequired($message, [
|
||
'use_on' => $labels,
|
||
'exclude_from' => array_map($this->conditions->label(...), $template->excludeFrom),
|
||
'layouts' => $template->layouts,
|
||
'overlaps' => $enabled
|
||
? Template::overlaps($this->access->store()->liveTemplates(), $template->id, $template->useOn)
|
||
: [],
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* @return array<string, mixed>
|
||
* @throws WriteFailed
|
||
*/
|
||
private function switch(Template $template, bool $enabled): array
|
||
{
|
||
$store = $this->access->store();
|
||
try {
|
||
$store->setEnabled($template->id, $enabled);
|
||
} catch (Throwable $e) {
|
||
$failures = Rollback::attempt(static fn () => $store->setEnabled($template->id, $template->enabled));
|
||
if ($failures === []) {
|
||
throw WriteFailed::rolledBack(self::REASON, $e, 'tb-activate failed: ' . $e->getMessage());
|
||
}
|
||
$this->log->recordPartial(
|
||
$this->name(),
|
||
DiviThemeBuilder::TEMPLATE,
|
||
(string) $template->id,
|
||
sprintf('Theme Builder: Umschalten von „%s“ nicht zurückgenommen', $template->title)
|
||
);
|
||
throw WriteFailed::partial(
|
||
self::REASON,
|
||
$e,
|
||
'tb-activate rollback: ' . $failures[0],
|
||
$template->id,
|
||
hint: 'Mit lit/tb-list prüfen, ob die Vorlage aktiv ist.'
|
||
);
|
||
}
|
||
|
||
$warnings = [];
|
||
FollowUp::run(
|
||
static fn () => $store->clearCaches(),
|
||
'theme builder cache',
|
||
'Der Cache des Theme Builders konnte nicht geleert werden; die Änderung ist gespeichert, wirkt aber evtl. '
|
||
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
||
$warnings
|
||
);
|
||
$logId = $this->log->record(
|
||
$this->name(),
|
||
DiviThemeBuilder::TEMPLATE,
|
||
(string) $template->id,
|
||
sprintf('Theme Builder: Vorlage „%s“ %s', $template->title, $enabled ? 'aktiviert' : 'deaktiviert')
|
||
);
|
||
$after = $store->template($template->id, true) ?? $template;
|
||
|
||
$summary = sprintf(
|
||
'Vorlage „%s“ %s.',
|
||
$template->title,
|
||
$enabled ? 'aktiviert – sie gilt jetzt für Besucher' : 'deaktiviert – sie gilt nicht mehr'
|
||
);
|
||
|
||
return $this->result($after, $summary, $logId, $enabled, $warnings);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $warnings
|
||
* @return array<string, mixed>
|
||
*/
|
||
private function result(
|
||
Template $template,
|
||
string $summary,
|
||
?int $logId,
|
||
?bool $enabled = null,
|
||
array $warnings = []
|
||
): array {
|
||
return ToolResult::ok($summary)
|
||
->with('template_id', $template->id)
|
||
->with('template_rev', $template->rev())
|
||
->with('enabled', $enabled ?? $template->enabled)
|
||
->with('live', $enabled ?? $template->isLive())
|
||
->warnings($warnings)
|
||
->logId($logId)
|
||
->urls(null, (string) admin_url('admin.php?page=et_theme_builder'))
|
||
->toArray();
|
||
}
|
||
}
|
||
```
|
||
|
||
In `ThemeBuilderIntegration::abilities()` ergänzen: `new TbActivateAbility($this->access, $conditions, $this->log),`.
|
||
|
||
- [ ] **Step 4: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'TbActivateAbilityTest|DiviIntegrationTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/ThemeBuilder tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: lit/tb-activate – the explicit switch, always a dry run first (answer F1)`.
|
||
|
||
---
|
||
### Task 11: Dynamic Content im Body-Layout (Pods-Felder) und Builder-Fixture
|
||
|
||
Ein Body-Layout zeigt Felder des Beitrags, für den die Vorlage gilt (E12). `{{field:<key>}}` erzeugt Divis `custom_meta_<key>`; ob das Feld beim Inhaltstyp existiert und als Text erscheint (Antwort F4: nur textartige Felder), prüft das Plugin gegen die Inhaltstypen der Vorlagen-Bedingungen (`singular:post_type:<typ>:…`) – als **Warnung**, gespeichert wird trotzdem. Die Feldliste kommt aus derselben Quelle wie `lit/divi-dynamic-sources` (herausgelöst als `DynamicFields`). Der Builder-Body aus Task 1 bleibt beim Bearbeiten byte-gleich.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Pages/DynamicFields.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php` (nutzt `DynamicFields`)
|
||
- Modify: `src/Integrations/Divi/Compose/VariableRef.php` (`nameOf()`)
|
||
- Modify: `src/Integrations/Divi/ThemeBuilder/Template.php` (`postTypesOf()`), `ThemeBuilderEdits.php` (`fieldWarnings()`, `checkFields()`), `TbCreateTemplateAbility.php` (Warnung beim Anlegen), `ThemeBuilderIntegration.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DiviWriter.php` (`commitLayout()`), `src/Integrations/Divi/DiviIntegration.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Pages/DynamicFieldsTest.php`, `tests/Unit/Integrations/Divi/Compose/VariableRefTest.php`, `tests/Unit/Integrations/Divi/ThemeBuilder/ThemeBuilderEditsTest.php`, `tests/Unit/Integrations/Divi/ThemeBuilder/TbCreateTemplateAbilityTest.php`, `tests/Unit/Integrations/Divi/Pages/Stage3bBodyEditTest.php` (neu); `DiviDynamicSourcesAbilityTest` unverändert grün
|
||
|
||
**Interfaces:**
|
||
- Consumes: Tasks 1, 5, 8; `ContentSchema::fields()`, `FieldDefinition`.
|
||
- Produces: `DynamicFields::__construct(ContentSchema $schema, ?Closure $podStorage = null)` (nicht final), `TEXT_TYPES`, `ID_TYPES`, `offered(string $postType): array{0: list<array<string,string>>, 1: list<array<string,string>>}`, `reason(string $postType, string $key): ?string`; `VariableRef::nameOf(string $value): ?string`; `Template::postTypesOf(array $useOn): list<string>`; `ThemeBuilderEdits::__construct(ThemeBuilderAccess $access, ?DynamicFields $fields = null)`, `fieldWarnings(DiviPage $page, DiviChange $change): list<string>`, `checkFields(array $blocks, array $postTypes, string $what): list<string>`; `TbCreateTemplateAbility::__construct(…, ChangeLog $log, ?ThemeBuilderEdits $edits = null)`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`tests/Unit/Integrations/Divi/Pages/DynamicFieldsTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicFields;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DynamicFieldsTest extends TestCase
|
||
{
|
||
private static function fields(): DynamicFields
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('fields')->with('lit_person')->andReturn([
|
||
new FieldDefinition('role', 'Funktion', 'string', 'pods', FieldDefinition::WRITABLE),
|
||
new FieldDefinition('level', 'Stufe', 'choice', 'pods', FieldDefinition::WRITABLE),
|
||
new FieldDefinition('mentor', 'Mentor', 'relation', 'pods', FieldDefinition::WRITABLE),
|
||
]);
|
||
|
||
return new DynamicFields($schema, static fn (string $type): ?string => 'meta');
|
||
}
|
||
|
||
public function testTextLikeFieldsAreOfferedOthersOmittedWithTheirReason(): void
|
||
{
|
||
[$offered, $omitted] = self::fields()->offered('lit_person');
|
||
|
||
self::assertSame(['role', 'level'], array_column($offered, 'key'));
|
||
self::assertSame('{{field:role}}', $offered[0]['placeholder']);
|
||
self::assertSame(['mentor'], array_column($omitted, 'key'));
|
||
}
|
||
|
||
public function testReasonForOneKey(): void
|
||
{
|
||
self::assertNull(self::fields()->reason('lit_person', 'role'));
|
||
self::assertStringContainsString('nur IDs', (string) self::fields()->reason('lit_person', 'mentor'));
|
||
self::assertSame('kein bekanntes Feld dieses Inhaltstyps', self::fields()->reason('lit_person', 'telefon'));
|
||
}
|
||
}
|
||
```
|
||
|
||
`VariableRefTest` ergänzen:
|
||
|
||
```php
|
||
public function testNameOfReadsTheContentOptionOnly(): void
|
||
{
|
||
self::assertSame('custom_meta_role', VariableRef::nameOf(self::content('custom_meta_role', '{}')));
|
||
self::assertNull(VariableRef::nameOf('$variable({"type":"color","value":{"name":"gcid-primary","settings":{}}})$'));
|
||
self::assertNull(VariableRef::nameOf('Text'));
|
||
self::assertNull(VariableRef::nameOf('$variable(kaputt)$'));
|
||
}
|
||
```
|
||
|
||
`ThemeBuilderEditsTest` ergänzen (`use LIT\MCP\Integrations\Divi\BlockTree\Block;`, `use LIT\MCP\Integrations\Divi\Compose\VariableRef;`, `use LIT\MCP\Integrations\Divi\Pages\DiviChange;`, `use LIT\MCP\Integrations\Divi\Pages\DynamicFields;`, `use Mockery;`):
|
||
|
||
```php
|
||
private static function textWith(string $option): Block
|
||
{
|
||
return Block::create('divi/text', ['content' => ['innerContent' => ['desktop' => [
|
||
'value' => VariableRef::serialize('content', $option, ['before' => '', 'after' => '']),
|
||
]]]]);
|
||
}
|
||
|
||
private static function fields(): DynamicFields
|
||
{
|
||
$fields = Mockery::mock(DynamicFields::class);
|
||
$fields->allows('reason')->andReturnUsing(static fn (string $type, string $key): ?string => match ($key) {
|
||
'role' => null,
|
||
'mentor' => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||
default => 'kein bekanntes Feld dieses Inhaltstyps',
|
||
});
|
||
|
||
return $fields;
|
||
}
|
||
|
||
/** E12 / answer F4: fields are checked against the post types of the templates that use the body. */
|
||
public function testFieldWarningsNameUnsuitableFieldsOfTheTemplatesPostTypes(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066, ['use_on' => ['singular:post_type:lit_person:all']]);
|
||
$edits = new ThemeBuilderEdits(
|
||
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$template])),
|
||
self::fields()
|
||
);
|
||
$change = (new DiviChange('2 Divi-Knoten eingefügt'))
|
||
->record(self::textWith('custom_meta_role'), 'inserted')
|
||
->record(self::textWith('custom_meta_mentor'), 'inserted');
|
||
|
||
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
|
||
|
||
self::assertCount(1, $warnings);
|
||
self::assertStringContainsString('„mentor“', $warnings[0]);
|
||
self::assertStringContainsString('lit_person: Beziehungs', $warnings[0]);
|
||
self::assertSame([], $edits->fieldWarnings(self::page(ThemeBuilderFixtures::HEADER, 'header'), $change));
|
||
}
|
||
|
||
public function testWithoutAPostTypeConditionTheFieldsAreUnchecked(): void
|
||
{
|
||
$template = ThemeBuilderFixtures::template(1066, ['use_on' => ['homepage']]);
|
||
$edits = new ThemeBuilderEdits(
|
||
ThemeBuilderFixtures::access([], true, ThemeBuilderFixtures::store([$template])),
|
||
self::fields()
|
||
);
|
||
$change = (new DiviChange('x'))->record(self::textWith('custom_meta_role'), 'inserted');
|
||
|
||
$warnings = $edits->fieldWarnings(self::page(ThemeBuilderFixtures::BODY, 'body'), $change);
|
||
|
||
self::assertCount(1, $warnings);
|
||
self::assertStringContainsString('ungeprüft', $warnings[0]);
|
||
}
|
||
```
|
||
|
||
`TbCreateTemplateAbilityTest` ergänzen (`use LIT\MCP\Integrations\Divi\Pages\DynamicFields;`, `use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;`). In `ability()` die Rückgabe ersetzen durch
|
||
|
||
```php
|
||
return new TbCreateTemplateAbility(
|
||
ThemeBuilderFixtures::access($deniedRoles, true, $store),
|
||
new TemplateConditions($store),
|
||
DiviWriterFixture::composition(DiviWriterFixture::designReader()),
|
||
$saver,
|
||
$log ?? DiviWriterFixture::log(),
|
||
new ThemeBuilderEdits(ThemeBuilderFixtures::access([], true, $store), self::personFields())
|
||
);
|
||
```
|
||
|
||
und ergänzen:
|
||
|
||
```php
|
||
private static function personFields(): DynamicFields
|
||
{
|
||
$fields = Mockery::mock(DynamicFields::class);
|
||
$fields->allows('reason')->andReturnUsing(
|
||
static fn (string $type, string $key): ?string => $key === 'role' ? null : 'kein bekanntes Feld dieses Inhaltstyps'
|
||
);
|
||
|
||
return $fields;
|
||
}
|
||
```
|
||
|
||
und der Test:
|
||
|
||
```php
|
||
public function testAnUnknownFieldInTheNewBodyIsAWarning(): void
|
||
{
|
||
$store = self::store();
|
||
self::expectCreation($store);
|
||
$store->shouldReceive('attach')->once();
|
||
$store->shouldReceive('clearCaches')->once();
|
||
$input = self::input();
|
||
$input['body']['nodes'][0]['modules'][] = ['type' => 'text', 'content' => '{{field:telefon}}'];
|
||
|
||
$result = $this->ability($store)->execute($input);
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, '„telefon“')));
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Pages/Stage3bBodyEditTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||
use LIT\MCP\Integrations\Divi\ThemeBuilder\ThemeBuilderEdits;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Support\ThemeBuilderFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
/** The body layout the real builder wrote with Pods fields (stage3b) stays editable and byte-identical. */
|
||
final class Stage3bBodyEditTest extends TestCase
|
||
{
|
||
public function testChangingALabelKeepsEveryOtherByteIncludingThePodsReferences(): void
|
||
{
|
||
$content = DiviFixtures::markup('stage3b/tb-body--person-body.html');
|
||
$body = ThemeBuilderFixtures::layout(ThemeBuilderFixtures::BODY, ['post_content' => $content]);
|
||
LibraryFixtures::given([$body]);
|
||
Functions\when('admin_url')->alias(static fn (string $path): string => 'https://kunde.test/wp-admin/' . $path);
|
||
$saved = [];
|
||
$failures = [];
|
||
$access = ThemeBuilderFixtures::access();
|
||
$writer = DiviWriterFixture::writer(
|
||
$saved,
|
||
$failures,
|
||
false,
|
||
ThemeBuilderFixtures::loader($access),
|
||
null,
|
||
new ThemeBuilderEdits($access)
|
||
);
|
||
|
||
$result = (new DiviUpdateAbility($writer))->execute([
|
||
'post_id' => 1062,
|
||
'tree_rev' => Rev::of($content),
|
||
'changes' => [['id' => 's0.r0.c0.m0', 'label' => 'Person Name']],
|
||
]);
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(str_replace('"value":"Person Titel"', '"value":"Person Name"', $content), $saved[0]);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DynamicFieldsTest|VariableRefTest|ThemeBuilderEditsTest|TbCreateTemplateAbilityTest|Stage3bBodyEditTest'`
|
||
Expected: FAIL – „Class … DynamicFields not found“, „Call to undefined method … nameOf()“, „… fieldWarnings()“. `Stage3bBodyEditTest` kann bereits grün sein (Task 5 trägt es); dann belegt er die Byte-Identität schon jetzt.
|
||
|
||
- [ ] **Step 3: DynamicFields herauslösen**
|
||
|
||
`src/Integrations/Divi/Pages/DynamicFields.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use Throwable;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The fields of a post type Divi can show as text through dynamic content (answer F4, E12): custom_meta_<key>
|
||
* reads post meta (F25/F26). Relation/file fields show IDs, Pods table storage is no post meta, protected keys
|
||
* never (S12), unknown types not (ruling P9) – each omitted with its reason. Hidden fields (security audit M4)
|
||
* are neither offered nor named. Used by lit/divi-dynamic-sources and the Theme Builder body check.
|
||
* Not final so abilities can be tested with a mock.
|
||
*/
|
||
class DynamicFields
|
||
{
|
||
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
|
||
public const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice'];
|
||
|
||
public const ID_TYPES = ['relation', 'file'];
|
||
|
||
/** @var Closure(string): ?string */
|
||
private Closure $podStorage;
|
||
|
||
/** @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null: none. */
|
||
public function __construct(private ContentSchema $schema, ?Closure $podStorage = null)
|
||
{
|
||
$this->podStorage = $podStorage ?? self::defaultStorage(...);
|
||
}
|
||
|
||
/** @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields. */
|
||
public function offered(string $postType): array
|
||
{
|
||
$storage = ($this->podStorage)($postType);
|
||
$fields = [];
|
||
$omitted = [];
|
||
foreach ($this->schema->fields($postType) as $field) {
|
||
if ($field->status === FieldDefinition::HIDDEN) {
|
||
continue;
|
||
}
|
||
|
||
$entry = ['key' => $field->key, 'label' => $field->label];
|
||
$reason = self::omission($field, $storage);
|
||
if ($reason !== null) {
|
||
$omitted[] = $entry + ['reason' => $reason];
|
||
continue;
|
||
}
|
||
|
||
$fields[] = $entry + [
|
||
'type' => $field->type,
|
||
'source' => $field->source,
|
||
'placeholder' => '{{field:' . $field->key . '}}',
|
||
];
|
||
}
|
||
|
||
return [$fields, $omitted];
|
||
}
|
||
|
||
/** Why {{field:$key}} would not show as text for $postType; null when it does. */
|
||
public function reason(string $postType, string $key): ?string
|
||
{
|
||
[$fields, $omitted] = $this->offered($postType);
|
||
foreach ($fields as $field) {
|
||
if ($field['key'] === $key) {
|
||
return null;
|
||
}
|
||
}
|
||
foreach ($omitted as $field) {
|
||
if ($field['key'] === $key) {
|
||
return $field['reason'];
|
||
}
|
||
}
|
||
|
||
return 'kein bekanntes Feld dieses Inhaltstyps';
|
||
}
|
||
|
||
private static function omission(FieldDefinition $field, ?string $storage): ?string
|
||
{
|
||
return match (true) {
|
||
! DynamicContentRules::isMetaKey($field->key)
|
||
=> 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
|
||
$field->source === 'pods' && $storage === 'table'
|
||
=> 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
|
||
in_array($field->type, self::ID_TYPES, true)
|
||
=> 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||
$field->type === 'unknown'
|
||
=> 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text',
|
||
! in_array($field->type, self::TEXT_TYPES, true)
|
||
=> sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
|
||
default => null,
|
||
};
|
||
}
|
||
|
||
private static function defaultStorage(string $postType): ?string
|
||
{
|
||
if (! function_exists('pods_api')) {
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
$pod = pods_api()->load_pod(['name' => $postType]);
|
||
} catch (Throwable) {
|
||
return null;
|
||
}
|
||
|
||
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviDynamicSourcesAbility`: die Konstanten `TEXT_TYPES`/`ID_TYPES`, das Feld `$podStorage` sowie die Methoden `fields()`, `omission()` und `defaultStorage()` entfernen; neues Feld `private DynamicFields $fields;`, im Konstruktor (Signatur unverändert) `$this->fields = new DynamicFields($schema, $podStorage);` und in `execute()` `[$fields, $omitted] = $this->fields->offered($postType);`. Nicht mehr benutzte `use`-Zeilen (`FieldDefinition`, `Throwable`) entfernen.
|
||
|
||
- [ ] **Step 4: VariableRef::nameOf() und Template::postTypesOf()**
|
||
|
||
`VariableRef` nach `settingsOf()`:
|
||
|
||
```php
|
||
/** The option name of $value when it is exactly one content reference ("custom_meta_role"); null otherwise. */
|
||
public static function nameOf(string $value): ?string
|
||
{
|
||
try {
|
||
$payload = self::payloadOf($value);
|
||
} catch (InvalidArgumentException) {
|
||
return null;
|
||
}
|
||
|
||
$name = $payload['value']['name'] ?? null;
|
||
|
||
return ($payload['type'] ?? null) === 'content' && is_string($name) ? $name : null;
|
||
}
|
||
```
|
||
|
||
`Template`: `postTypes()` durch zwei Methoden ersetzen:
|
||
|
||
```php
|
||
/** @return list<string> Post types of its singular conditions (singular:post_type:<type>:…). */
|
||
public function postTypes(): array
|
||
{
|
||
return self::postTypesOf($this->useOn);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $useOn
|
||
* @return list<string>
|
||
*/
|
||
public static function postTypesOf(array $useOn): array
|
||
{
|
||
$types = [];
|
||
foreach ($useOn as $condition) {
|
||
$parts = explode(':', $condition);
|
||
if (($parts[0] ?? '') === 'singular' && ($parts[1] ?? '') === 'post_type' && ($parts[2] ?? '') !== '') {
|
||
$types[] = $parts[2];
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($types));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 5: Feldprüfung in ThemeBuilderEdits**
|
||
|
||
`ThemeBuilderEdits` (`use LIT\MCP\Integrations\Divi\BlockTree\Block;`, `use LIT\MCP\Integrations\Divi\Compose\VariableRef;`, `use LIT\MCP\Integrations\Divi\Pages\DiviChange;`, `use LIT\MCP\Integrations\Divi\Pages\DynamicFields;`): Konstruktor
|
||
|
||
```php
|
||
public function __construct(private ThemeBuilderAccess $access, private ?DynamicFields $fields = null)
|
||
{
|
||
}
|
||
```
|
||
|
||
und neue Methoden:
|
||
|
||
```php
|
||
/**
|
||
* E12: in a body layout, the field references of the changed nodes checked against the post types of the
|
||
* templates that use the layout (their singular conditions). Warnings only – the layout is saved anyway.
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
public function fieldWarnings(DiviPage $page, DiviChange $change): array
|
||
{
|
||
if ($page->themeBuilderArea() !== 'body') {
|
||
return [];
|
||
}
|
||
|
||
$id = (int) $page->post->ID;
|
||
$types = [];
|
||
foreach ($this->access->store()->liveTemplates() as $template) {
|
||
if ($template->layouts['body']['id'] === $id) {
|
||
array_push($types, ...$template->postTypes());
|
||
}
|
||
}
|
||
|
||
$types = array_values(array_unique($types));
|
||
|
||
return $this->checkFields($change->touched(), $types, sprintf('Body-Layout %d', $id));
|
||
}
|
||
|
||
/**
|
||
* {{field:<key>}} (custom_meta_<key>) and Divi's own post_meta_key references in $blocks against $postTypes.
|
||
*
|
||
* @param list<Block> $blocks
|
||
* @param list<string> $postTypes
|
||
* @return list<string>
|
||
*/
|
||
public function checkFields(array $blocks, array $postTypes, string $what): array
|
||
{
|
||
$keys = [];
|
||
foreach ($blocks as $block) {
|
||
self::collectKeys($block, $keys);
|
||
}
|
||
$keys = array_values(array_unique($keys));
|
||
if ($this->fields === null || $keys === []) {
|
||
return [];
|
||
}
|
||
|
||
if ($postTypes === []) {
|
||
return [sprintf(
|
||
'%s gehört zu keiner Vorlage mit Bedingung „singular:post_type:…“; ob die Felder %s dort existieren, '
|
||
. 'ist ungeprüft (Bedingungen mit lit/tb-assign setzen).',
|
||
$what,
|
||
implode(', ', $keys)
|
||
)];
|
||
}
|
||
|
||
$warnings = [];
|
||
foreach ($keys as $key) {
|
||
$problems = [];
|
||
foreach ($postTypes as $type) {
|
||
$reason = $this->fields->reason($type, $key);
|
||
if ($reason !== null) {
|
||
$problems[] = $type . ': ' . $reason;
|
||
}
|
||
}
|
||
if ($problems !== []) {
|
||
$warnings[] = sprintf(
|
||
'Feld „%s“ zeigt Divi womöglich nicht als Text (%s); passende Felder mit '
|
||
. 'lit/divi-dynamic-sources.',
|
||
$key,
|
||
implode('; ', $problems)
|
||
);
|
||
}
|
||
}
|
||
|
||
return $warnings;
|
||
}
|
||
|
||
/** @param list<string> $keys */
|
||
private static function collectKeys(Block $block, array &$keys): void
|
||
{
|
||
if ($block->isOpaque()) {
|
||
return;
|
||
}
|
||
|
||
$attrs = $block->attrs();
|
||
array_walk_recursive($attrs, static function (mixed $value) use (&$keys): void {
|
||
if (! is_string($value)) {
|
||
return;
|
||
}
|
||
$name = VariableRef::nameOf($value);
|
||
if ($name !== null && str_starts_with($name, 'custom_meta_')) {
|
||
$keys[] = substr($name, strlen('custom_meta_'));
|
||
} elseif ($name === 'post_meta_key') {
|
||
$key = VariableRef::settingsOf($value)['meta_key'] ?? null;
|
||
if (is_string($key) && $key !== '') {
|
||
$keys[] = $key;
|
||
}
|
||
}
|
||
});
|
||
|
||
foreach ($block->children() as $child) {
|
||
self::collectKeys($child, $keys);
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviWriter::commitLayout()`: vor `$prepared = $this->themeBuilder->prepare($page, $input);` einfügen
|
||
|
||
```php
|
||
foreach ($this->themeBuilder->fieldWarnings($page, $change) as $warning) {
|
||
$change->warning($warning);
|
||
}
|
||
```
|
||
|
||
`TbCreateTemplateAbility`: Konstruktor um `private ?ThemeBuilderEdits $edits = null` (letzter Parameter) erweitern; in `create()` direkt nach `$warnings = array_merge($warnings, $composition->warnings()…` (innerhalb der Schleife, nach dem `saveNew`-Block):
|
||
|
||
```php
|
||
if ($area === 'body' && $this->edits !== null) {
|
||
$warnings = array_merge($warnings, $this->edits->checkFields(
|
||
$layout['blocks'],
|
||
Template::postTypesOf($useOn),
|
||
'Das neue Body-Layout'
|
||
));
|
||
}
|
||
```
|
||
|
||
`ThemeBuilderIntegration`: Konstruktor um `private DynamicFields $fields` (nach `$log`) erweitern; `abilities()`:
|
||
|
||
```php
|
||
$edits = new ThemeBuilderEdits($this->access, $this->fields);
|
||
|
||
return [
|
||
new TbListAbility($this->access, $conditions),
|
||
new TbCreateTemplateAbility(
|
||
$this->access,
|
||
$conditions,
|
||
$this->composition,
|
||
$this->saver,
|
||
$this->log,
|
||
$edits
|
||
),
|
||
new TbAssignAbility($this->access, $conditions, $this->log),
|
||
new TbActivateAbility($this->access, $conditions, $this->log),
|
||
];
|
||
```
|
||
|
||
`DiviIntegration`: in `writer()` `new ThemeBuilderEdits($this->themeBuilderAccess, new DynamicFields($this->schema))`; in `themeBuilder()` als fünftes Argument `new DynamicFields($this->schema)`.
|
||
|
||
- [ ] **Step 6: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DynamicFieldsTest|DiviDynamicSourcesAbilityTest|VariableRefTest|ThemeBuilderEditsTest|TbCreateTemplateAbilityTest|Stage3bBodyEditTest|DiviIntegrationTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: Pods fields in Theme Builder bodies checked against the template's post types (E12)`.
|
||
|
||
**Zwischenhalt:** Theme-Builder-Teil vollständig. Controller-Review über Tasks 1–11 (Schwerpunkte: Review Focus 1–4), danach Tasks 12–15.
|
||
|
||
---
|
||
### Task 12: Fingerabdrücke und `lit/library-find-similar`
|
||
|
||
E11: zwei Fingerabdrücke je Knoten – **Struktur** (Blocknamen und Verschachtelung, Spaltenaufteilung `flexType`, Preset-IDs) und **Inhalt** (alle Attribute außer Admin-Label, `builderVersion`, `globalParent`; Kinder rekursiv; Leerraum zwischen Blöcken egal). Die Suche liest seitenweise höchstens 200 Einträge je Aufruf (Cursor), nur Einträge, die der Benutzer bearbeiten darf, ohne Index oder Meta (F31).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/BlockTree/Fingerprint.php`
|
||
- Create: `src/Integrations/Divi/Library/LibraryFindSimilarAbility.php`
|
||
- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php` (Konstruktor `ContentSchema`, neues Tool), `src/Integrations/Divi/DiviIntegration.php` (`library()`)
|
||
- Test: `tests/Unit/Integrations/Divi/BlockTree/FingerprintTest.php`, `tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LibraryContent::load()/roots()`, `NodeIndex::entries()`, `NodeSummary::columnWidth()/presetIds()/label()`, `LibraryAccess::canList()/item()`, `DiviPageLoader::load()`, `PostGuard::postType()/ownPostsOnly()`, `Cursor`.
|
||
- Produces: `Fingerprint::structure(Block $block): string`, `Fingerprint::content(Block $block): string` (je 16 Hex-Zeichen); `LibraryFindSimilarAbility::__construct(LibraryAccess $access, DiviPageLoader $loader, ContentSchema $schema, Parser $parser = new Parser())`, `MAX_POSTS = 200`, `DEFAULT_POSTS = 100`, `MAX_MATCHES = 200`; Ausgabe `{reference: {type, structure, content, post_id?, id?, library_id?}, matches: list<{post_id, title, status, type, id, label, tree_rev, match: "identical"|"structure"}>, scanned, next_cursor}`; `LibraryIntegration::__construct(…, ?Closure $diviWritable = null, ?ContentSchema $schema = null)`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`tests/Unit/Integrations/Divi/BlockTree/FingerprintTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class FingerprintTest extends TestCase
|
||
{
|
||
/** The section of library entry 1055: section > row > column (24_24) > blurb. */
|
||
private static function section(): Block
|
||
{
|
||
$content = DiviFixtures::markup('stage3/library--library-abschnitt.html');
|
||
|
||
return LibraryContent::roots(LibraryContent::load(new Parser(), $content))[0];
|
||
}
|
||
|
||
private static function blurbOf(Block $section): Block
|
||
{
|
||
return $section->children()[0]->children()[0]->children()[0];
|
||
}
|
||
|
||
public function testAdminLabelsAndBuilderVersionDoNotCount(): void
|
||
{
|
||
$a = self::section();
|
||
$b = $a->copy();
|
||
$attrs = $b->attrs();
|
||
$attrs['module']['meta']['adminLabel']['desktop']['value'] = 'Anderes Label';
|
||
$attrs['builderVersion'] = '5.3.0';
|
||
$b->setAttrs($attrs);
|
||
|
||
self::assertSame(16, strlen(Fingerprint::content($a)));
|
||
self::assertSame(Fingerprint::content($a), Fingerprint::content($b));
|
||
self::assertSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||
}
|
||
|
||
public function testOtherTextsKeepTheStructureButNotTheContent(): void
|
||
{
|
||
$a = self::section();
|
||
$b = $a->copy();
|
||
$blurb = self::blurbOf($b);
|
||
$attrs = $blurb->attrs();
|
||
$attrs['title']['innerContent']['desktop']['value'] = 'Anderer Titel';
|
||
$blurb->setAttrs($attrs);
|
||
|
||
self::assertSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||
self::assertNotSame(Fingerprint::content($a), Fingerprint::content($b));
|
||
}
|
||
|
||
public function testColumnLayoutAndModuleTypesChangeTheStructure(): void
|
||
{
|
||
$a = self::section();
|
||
$b = $a->copy();
|
||
$column = $b->children()[0]->children()[0];
|
||
$attrs = $column->attrs();
|
||
$attrs['module']['decoration']['sizing']['desktop']['value']['flexType'] = '12_24';
|
||
$column->setAttrs($attrs);
|
||
$c = Block::create('divi/section', [], [Block::create('divi/row', [], [Block::create('divi/column', [], [
|
||
Block::create('divi/text'),
|
||
])])]);
|
||
|
||
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($b));
|
||
self::assertNotSame(Fingerprint::structure($a), Fingerprint::structure($c));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Library/LibraryFindSimilarAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryFindSimilarAbility;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
|
||
final class LibraryFindSimilarAbilityTest extends TestCase
|
||
{
|
||
private string $variation = '';
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$root = LibraryContent::roots(LibraryContent::load(
|
||
new Parser(),
|
||
DiviFixtures::markup('stage3/library--library-abschnitt.html')
|
||
))[0];
|
||
$copy = Serializer::block($root);
|
||
$this->variation = str_replace('Library-Blurb', 'Anderer Blurb', $copy);
|
||
$posts = [
|
||
LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html')),
|
||
new WP_Post([
|
||
'ID' => 8,
|
||
'post_type' => 'page',
|
||
'post_status' => 'publish',
|
||
'post_title' => 'Über uns',
|
||
'post_content' => '<!-- wp:divi/placeholder -->' . $this->variation . $copy . '<!-- /wp:divi/placeholder -->',
|
||
]),
|
||
new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Fremd', 'post_content' => $copy]),
|
||
];
|
||
$items = array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS));
|
||
LibraryFixtures::given(array_merge($posts, $items));
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9)
|
||
);
|
||
Functions\when('get_post_type_object')->justReturn((object) ['cap' => (object) [
|
||
'edit_posts' => 'edit_posts',
|
||
'edit_others_posts' => 'edit_others_posts',
|
||
]]);
|
||
}
|
||
|
||
private static function ability(array $deniedRoles = []): LibraryFindSimilarAbility
|
||
{
|
||
$access = LibraryFixtures::access($deniedRoles);
|
||
|
||
return new LibraryFindSimilarAbility($access, LibraryFixtures::loader($access), DiviFixtures::schema());
|
||
}
|
||
|
||
public function testFindsIdenticalAndStructurallySimilarNodesOfEditableEntries(): void
|
||
{
|
||
$query = [];
|
||
Functions\when('get_posts')->alias(static function (array $args) use (&$query): array {
|
||
$query = $args;
|
||
|
||
return [7, 8, 9];
|
||
});
|
||
|
||
$result = self::ability()->execute(['library_id' => 1055, 'post_type' => 'page']);
|
||
|
||
self::assertSame(1055, $result['reference']['library_id']);
|
||
self::assertSame('divi/section', $result['reference']['type']);
|
||
$found = array_map(static fn (array $m): string => $m['post_id'] . ' ' . $m['id'] . ' ' . $m['match'], $result['matches']);
|
||
self::assertSame(['7 s1 identical', '8 s0 structure', '8 s1 identical'], $found, 'post 9 is not editable');
|
||
self::assertSame(Rev::of((string) get_post(8)->post_content), $result['matches'][1]['tree_rev']);
|
||
self::assertSame(3, $result['scanned']);
|
||
self::assertNull($result['next_cursor']);
|
||
self::assertSame(['page'], $query['post_type']);
|
||
self::assertSame(101, $query['posts_per_page']);
|
||
self::assertSame('ids', $query['fields']);
|
||
}
|
||
|
||
public function testANodeOfAPageIsTheReferenceAndIsNotItsOwnMatch(): void
|
||
{
|
||
Functions\when('get_posts')->justReturn([7, 8]);
|
||
|
||
$result = self::ability()->execute(['post_id' => 7, 'id' => 's1', 'post_type' => 'page', 'limit' => 1]);
|
||
|
||
self::assertSame(['s1'], [$result['reference']['id']]);
|
||
self::assertSame([], array_filter($result['matches'], static fn (array $m): bool => $m['post_id'] === 7));
|
||
self::assertSame(1, $result['scanned']);
|
||
self::assertSame(Cursor::encode(1), $result['next_cursor']);
|
||
}
|
||
|
||
public function testRefusals(): void
|
||
{
|
||
Functions\when('get_posts')->justReturn([]);
|
||
|
||
self::assertToolError(self::ability(['divi_library'])->execute(['library_id' => 1055]), 'forbidden');
|
||
self::assertToolError(self::ability()->execute([]), 'validation_failed');
|
||
self::assertToolError(self::ability()->execute(['library_id' => 1054]), 'validation_failed', 'a layout entry has several roots');
|
||
self::assertToolError(self::ability()->execute(['post_id' => 7, 'id' => 'g0']), 'validation_failed');
|
||
self::assertToolError(self::ability()->execute(['library_id' => 1055, 'cursor' => '%%%']), 'validation_failed');
|
||
self::assertTrue(self::ability()->annotations()['readonly']);
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegrationTest::testProvidesTheLibraryGroup`: erwartete Namen `['lit/library-list', 'lit/library-save', 'lit/library-insert', 'lit/library-find-similar']`.
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'FingerprintTest|LibraryFindSimilarAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL – „Class … Fingerprint not found“.
|
||
|
||
- [ ] **Step 3: Fingerprint**
|
||
|
||
`src/Integrations/Divi/BlockTree/Fingerprint.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||
|
||
use stdClass;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Fingerprints of a node (E11) for lit/library-find-similar, the "similar section" warning (spec §5.5) and
|
||
* lit/library-make-global. structure(): block names and nesting, column layout (flexType) and preset IDs – two
|
||
* sections built alike with other texts share it. content(): every attribute but the admin label,
|
||
* builderVersion and globalParent, children included – "the same section". Whitespace between blocks never
|
||
* counts; an opaque block counts by its raw opener (content) or its effective type (structure). Pure PHP.
|
||
*/
|
||
final class Fingerprint
|
||
{
|
||
/** Top-level attributes that say nothing about what visitors see. */
|
||
private const IGNORED = ['builderVersion', 'globalParent'];
|
||
|
||
public static function structure(Block $block): string
|
||
{
|
||
return self::hash(self::shape($block));
|
||
}
|
||
|
||
public static function content(Block $block): string
|
||
{
|
||
return self::hash(self::body($block));
|
||
}
|
||
|
||
private static function shape(Block $block): string
|
||
{
|
||
if ($block->isOpaque()) {
|
||
return 'opaque:' . GlobalInstance::effectiveName($block);
|
||
}
|
||
|
||
$presets = NodeSummary::presetIds($block);
|
||
sort($presets);
|
||
$children = array_map(self::shape(...), $block->children());
|
||
|
||
return $block->name() . '|' . (NodeSummary::columnWidth($block) ?? '') . '|' . implode(',', $presets)
|
||
. '[' . implode(';', $children) . ']';
|
||
}
|
||
|
||
private static function body(Block $block): string
|
||
{
|
||
if ($block->isOpaque()) {
|
||
return 'opaque:' . (string) $block->rawOpen();
|
||
}
|
||
|
||
$attrs = $block->attrs();
|
||
foreach (self::IGNORED as $key) {
|
||
unset($attrs[$key]);
|
||
}
|
||
if (is_array($attrs['module']['meta'] ?? null)) {
|
||
unset($attrs['module']['meta']['adminLabel']);
|
||
}
|
||
$children = array_map(self::body(...), $block->children());
|
||
|
||
return $block->name() . Json::encode(self::sorted($attrs)) . '[' . implode(';', $children) . ']';
|
||
}
|
||
|
||
/** Object keys sorted at every depth, so the key order Divi or the plugin wrote does not count. */
|
||
private static function sorted(mixed $value): mixed
|
||
{
|
||
if ($value instanceof stdClass) {
|
||
$value = (array) $value;
|
||
}
|
||
if (! is_array($value)) {
|
||
return $value;
|
||
}
|
||
if (! array_is_list($value)) {
|
||
ksort($value);
|
||
}
|
||
|
||
return array_map(self::sorted(...), $value);
|
||
}
|
||
|
||
private static function hash(string $value): string
|
||
{
|
||
return substr(hash('sha256', $value), 0, 16);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tool**
|
||
|
||
`src/Integrations/Divi/Library/LibraryFindSimilarAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/library-find-similar (spec §5.7, E11): nodes across the site built like a reference node (post_id + id) or a
|
||
* library entry (library_id) – "identical" (Fingerprint::content) or "structure" (Fingerprint::structure, other
|
||
* values). Scans at most MAX_POSTS entries per call in ID order (cursor), only of Divi-enabled managed types, only
|
||
* entries the user may edit (query restricted to own posts without edit_others_posts, like content-query), no
|
||
* index or meta (F31). Needs the library right (canList).
|
||
*/
|
||
final class LibraryFindSimilarAbility implements Ability
|
||
{
|
||
public const MAX_POSTS = 200;
|
||
public const DEFAULT_POSTS = 100;
|
||
public const MAX_MATCHES = 200;
|
||
|
||
private const LIVING = ['publish', 'draft', 'pending', 'private', 'future'];
|
||
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviPageLoader $loader,
|
||
private ContentSchema $schema,
|
||
private Parser $parser = new Parser()
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/library-find-similar';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Ähnliche Abschnitte finden';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Sucht seitenübergreifend Knoten, die wie ein gegebener Knoten (post_id und id aus '
|
||
. 'lit-divi-outline) oder ein Bibliothekseintrag (library_id) aufgebaut sind: match "identical" = gleicher '
|
||
. 'Inhalt (Admin-Labels ausgenommen), "structure" = gleicher Aufbau (Modultypen, Spalten, Presets) mit '
|
||
. 'anderen Inhalten. Nur Einträge, die dieses Konto bearbeiten darf; höchstens 200 Einträge je Aufruf '
|
||
. '(limit), weiter mit next_cursor. Inhaltsgleiche Vorkommen ersetzt lit/library-make-global '
|
||
. '(library_id, targets).';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Vergleichsknotens, z. B. "s1".'],
|
||
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Statt post_id und id.'],
|
||
'post_type' => ['type' => 'string', 'description' => 'Nur diesen Inhaltstyp durchsuchen.'],
|
||
'limit' => ['type' => 'integer', 'minimum' => 1, 'maximum' => self::MAX_POSTS],
|
||
'cursor' => ['type' => 'string'],
|
||
],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'reference' => ['type' => 'object'],
|
||
'matches' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'scanned' => ['type' => 'integer'],
|
||
'next_cursor' => ['type' => ['string', 'null']],
|
||
],
|
||
'required' => ['reference', 'matches', 'scanned', 'next_cursor'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::read();
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return $this->access->canList() ?? true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->access->canList();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$reference = $this->reference($input);
|
||
if ($reference instanceof WP_Error) {
|
||
return $reference;
|
||
}
|
||
|
||
$types = $this->postTypes($input['post_type'] ?? null);
|
||
if ($types instanceof WP_Error) {
|
||
return $types;
|
||
}
|
||
|
||
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
|
||
if ($offset === null) {
|
||
return Cursor::invalid();
|
||
}
|
||
|
||
$limit = is_int($input['limit'] ?? null)
|
||
? max(1, min(self::MAX_POSTS, $input['limit']))
|
||
: self::DEFAULT_POSTS;
|
||
$args = [
|
||
'post_type' => $types,
|
||
'post_status' => self::LIVING,
|
||
'posts_per_page' => $limit + 1,
|
||
'offset' => $offset,
|
||
'orderby' => 'ID',
|
||
'order' => 'ASC',
|
||
'fields' => 'ids',
|
||
'no_found_rows' => true,
|
||
'suppress_filters' => false,
|
||
];
|
||
foreach ($types as $type) {
|
||
$own = PostGuard::ownPostsOnly($type);
|
||
if ($own !== null) {
|
||
$args['author'] = $own;
|
||
break;
|
||
}
|
||
}
|
||
$ids = array_values(array_map('intval', (array) get_posts($args)));
|
||
$scan = array_slice($ids, 0, $limit);
|
||
|
||
$matches = [];
|
||
foreach ($scan as $id) {
|
||
$post = current_user_can('edit_post', $id) ? get_post($id) : null;
|
||
if ($post instanceof WP_Post && count($matches) < self::MAX_MATCHES) {
|
||
$this->collect($post, $reference, $matches);
|
||
}
|
||
}
|
||
|
||
return [
|
||
'reference' => $reference['info'],
|
||
'matches' => $matches,
|
||
'scanned' => count($scan),
|
||
'next_cursor' => count($ids) > $limit ? Cursor::encode($offset + $limit) : null,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string}|WP_Error
|
||
*/
|
||
private function reference(array $input): array|WP_Error
|
||
{
|
||
if (isset($input['library_id'])) {
|
||
$item = $this->access->item($input['library_id'], false);
|
||
if ($item === null) {
|
||
return PostGuard::notFound((int) PostGuard::id($input['library_id']));
|
||
}
|
||
if ($item instanceof WP_Error) {
|
||
return $item;
|
||
}
|
||
|
||
try {
|
||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||
} catch (MalformedMarkup) {
|
||
$roots = [];
|
||
}
|
||
if (count($roots) !== 1 || $roots[0]->isOpaque()) {
|
||
return Errors::invalidInput(
|
||
sprintf('Bibliothekseintrag %d hat nicht genau einen lesbaren Knoten.', (int) $item->ID),
|
||
['library_id' => 'Eintrag mit genau einem Abschnitt, einer Zeile oder einem Modul']
|
||
);
|
||
}
|
||
|
||
return self::describe($roots[0], null, null, (int) $item->ID);
|
||
}
|
||
|
||
if (! isset($input['post_id'])) {
|
||
return Errors::invalidInput('Vergleich fehlt: library_id oder post_id und id angeben.', [
|
||
'library_id' => 'oder post_id und id',
|
||
]);
|
||
}
|
||
|
||
$page = $this->loader->load($input['post_id'], false);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
|
||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||
$node = $page->index->find($id);
|
||
if ($node === null) {
|
||
return DiviErrors::unknownNode($id, $page->post);
|
||
}
|
||
if ($node->isOpaque()) {
|
||
return Errors::invalidInput(
|
||
'Der Knoten ist ein globales Modul oder nicht lesbar; nichts wurde gesucht.',
|
||
['id' => 'lesbarer Knoten']
|
||
);
|
||
}
|
||
|
||
return self::describe($node, (int) $page->post->ID, $id, null);
|
||
}
|
||
|
||
/** @return array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} */
|
||
private static function describe(Block $block, ?int $postId, ?string $id, ?int $libraryId): array
|
||
{
|
||
$info = array_filter([
|
||
'type' => $block->name(),
|
||
'structure' => Fingerprint::structure($block),
|
||
'content' => Fingerprint::content($block),
|
||
'post_id' => $postId,
|
||
'id' => $id,
|
||
'library_id' => $libraryId,
|
||
], static fn (mixed $value): bool => $value !== null);
|
||
|
||
return ['block' => $block, 'info' => $info, 'post_id' => $postId, 'id' => $id];
|
||
}
|
||
|
||
/** @return list<string>|WP_Error */
|
||
private function postTypes(mixed $postType): array|WP_Error
|
||
{
|
||
if ($postType !== null) {
|
||
$type = PostGuard::postType($postType, $this->schema, 'edit_posts');
|
||
|
||
return $type instanceof WP_Error ? $type : [(string) $postType];
|
||
}
|
||
|
||
$types = array_values(array_filter(
|
||
$this->schema->postTypeNames(),
|
||
fn (string $type): bool => $this->schema->diviEnabled($type)
|
||
));
|
||
|
||
return $types !== [] ? $types : Errors::notFound(
|
||
'Kein Inhaltstyp mit Divi-Builder verfügbar; nichts wurde gesucht.',
|
||
'Inhaltstypen mit lit/content-types (direkt: lit-content-types) prüfen.'
|
||
);
|
||
}
|
||
|
||
/**
|
||
* @param array{block: Block, info: array<string, mixed>, post_id: ?int, id: ?string} $reference
|
||
* @param list<array<string, mixed>> $matches
|
||
*/
|
||
private function collect(WP_Post $post, array $reference, array &$matches): void
|
||
{
|
||
$content = (string) $post->post_content;
|
||
if (! str_contains($content, '<!-- wp:divi/')) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$index = NodeIndex::build($this->parser->parse($content));
|
||
} catch (MalformedMarkup) {
|
||
return;
|
||
}
|
||
|
||
$name = $reference['block']->name();
|
||
foreach ($index->entries() as $entry) {
|
||
$block = $entry['block'];
|
||
$self = (int) $post->ID === $reference['post_id'] && $entry['id'] === $reference['id'];
|
||
if ($self || $block->isOpaque() || $block->name() !== $name) {
|
||
continue;
|
||
}
|
||
if (Fingerprint::structure($block) !== $reference['info']['structure']) {
|
||
continue;
|
||
}
|
||
|
||
$matches[] = [
|
||
'post_id' => (int) $post->ID,
|
||
'title' => (string) $post->post_title,
|
||
'status' => (string) $post->post_status,
|
||
'type' => (string) $post->post_type,
|
||
'id' => $entry['id'],
|
||
'label' => NodeSummary::label($block),
|
||
'tree_rev' => Rev::of($content),
|
||
'match' => Fingerprint::content($block) === $reference['info']['content'] ? 'identical' : 'structure',
|
||
];
|
||
if (count($matches) >= self::MAX_MATCHES) {
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`LibraryIntegration`: Konstruktor um `private ?ContentSchema $schema = null` (letzter Parameter, `use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;`) erweitern; in `abilities()` ergänzen:
|
||
|
||
```php
|
||
if ($this->schema !== null) {
|
||
$abilities[] = new LibraryFindSimilarAbility($this->access, $this->loader, $this->schema);
|
||
}
|
||
```
|
||
|
||
(die bestehende Rückgabe dafür in `$abilities = [ … ];` umwandeln und am Ende `return $abilities;`). `DiviIntegration::library()`: als letztes Argument `$this->schema` übergeben.
|
||
|
||
- [ ] **Step 5: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'FingerprintTest|LibraryFindSimilarAbilityTest|DiviIntegrationTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/BlockTree/Fingerprint.php src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: structure and content fingerprints, lit/library-find-similar`.
|
||
|
||
---
|
||
|
||
### Task 13: Schreibwarnung „ähnlicher Abschnitt“ (Spec §5.5)
|
||
|
||
Jede Divi-Schreibantwort, die einen Abschnitt einfügt, der wie ein Abschnitt der Bibliothek aufgebaut ist, warnt und verweist auf `lit-library-insert` und `lit/library-find-similar` (D6, E11: nur Vergleich mit Bibliothekseinträgen, klein). Ohne Bibliothekszugang keine Warnung (P13 – nennt Bibliotheks-IDs).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Library/SimilarSections.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DiviWriter.php` (Konstruktor, `save()`), `src/Integrations/Divi/DiviIntegration.php` (`writer()`), `tests/Support/DiviWriterFixture.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/SimilarSectionsTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviWriterTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Fingerprint::structure()` (Task 12), `DiviLibraryStore::find()`, `LibraryAccess::revealsIds()` (Task 3), `DiviChange::entries()`.
|
||
- Produces: `SimilarSections::__construct(LibraryAccess $access, Parser $parser = new Parser())` (nicht final), `MAX_ITEMS = 200`, `warnings(DiviChange $change): list<string>`; `DiviWriter::__construct(…, ?GlobalEdits $globals = null, ?ThemeBuilderEdits $themeBuilder = null, ?SimilarSections $similar = null)`; `DiviWriterFixture::writer(…, ?ThemeBuilderEdits $themeBuilder = null, ?SimilarSections $similar = null)`.
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`tests/Unit/Integrations/Divi/Library/SimilarSectionsTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Library\SimilarSections;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class SimilarSectionsTest extends TestCase
|
||
{
|
||
private static function sectionLike1055(): Block
|
||
{
|
||
$root = LibraryContent::roots(LibraryContent::load(
|
||
new Parser(),
|
||
DiviFixtures::markup('stage3/library--library-abschnitt.html')
|
||
))[0]->copy();
|
||
$blurb = $root->children()[0]->children()[0]->children()[0];
|
||
$attrs = $blurb->attrs();
|
||
$attrs['title']['innerContent']['desktop']['value'] = 'Neuer Titel';
|
||
$blurb->setAttrs($attrs);
|
||
|
||
return $root;
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
private static function similar(array $deniedRoles = []): SimilarSections
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058)]);
|
||
$store = LibraryFixtures::store();
|
||
$store->allows('find')->andReturn([LibraryFixtures::item(1055), LibraryFixtures::item(1058)]);
|
||
|
||
return new SimilarSections(LibraryFixtures::access($deniedRoles, true, $store));
|
||
}
|
||
|
||
public function testWarnsForAnInsertedSectionBuiltLikeALibrarySection(): void
|
||
{
|
||
$change = (new DiviChange('1 Divi-Knoten eingefügt'))->record(self::sectionLike1055(), 'inserted');
|
||
|
||
$warnings = self::similar()->warnings($change);
|
||
|
||
self::assertCount(1, $warnings);
|
||
self::assertStringContainsString('„[Fixture] 1055“ (1055)', $warnings[0]);
|
||
self::assertStringContainsString('lit-library-insert', $warnings[0]);
|
||
self::assertStringContainsString('lit/library-find-similar', $warnings[0]);
|
||
}
|
||
|
||
public function testOnlyInsertedSectionsAndOnlyWithLibraryAccess(): void
|
||
{
|
||
$moved = (new DiviChange('verschoben'))->record(self::sectionLike1055(), 'moved');
|
||
$inserted = (new DiviChange('eingefügt'))->record(self::sectionLike1055(), 'inserted');
|
||
|
||
self::assertSame([], self::similar()->warnings($moved));
|
||
self::assertSame([], self::similar(['divi_library'])->warnings($inserted));
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviWriterTest` ergänzen (`use LIT\MCP\Integrations\Divi\Library\SimilarSections;`, `use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility;`, `use Mockery;` falls fehlend):
|
||
|
||
```php
|
||
/** Spec §5.5: the writer adds the similar-section warnings to every write result. */
|
||
public function testWriteResultsCarryTheSimilarSectionWarning(): void
|
||
{
|
||
$content = DiviFixtures::markup('synthetic-basic.html');
|
||
DiviFixtures::givenPost($content);
|
||
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
|
||
$similar = Mockery::mock(SimilarSections::class);
|
||
$similar->allows('warnings')->andReturn(['Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek.']);
|
||
$saved = [];
|
||
$failures = [];
|
||
$writer = DiviWriterFixture::writer($saved, $failures, false, null, null, null, $similar);
|
||
|
||
$result = (new DiviInsertAbility($writer))->execute([
|
||
'post_id' => 7,
|
||
'tree_rev' => \LIT\MCP\Abilities\Rev::of($content),
|
||
'nodes' => [['type' => 'section', 'modules' => [['type' => 'text', 'content' => 'Neu']]]],
|
||
]);
|
||
|
||
self::assertContains('Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek.', $result['warnings']);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'SimilarSectionsTest|DiviWriterTest'`
|
||
Expected: FAIL – „Class … SimilarSections not found“.
|
||
|
||
- [ ] **Step 3: Umsetzung**
|
||
|
||
`src/Integrations/Divi/Library/SimilarSections.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The quality warning "similar section" (spec §5.5, D6, E11): an inserted section built like a section entry of
|
||
* the Divi Library (Fingerprint::structure) – compared with library entries only (small; at most MAX_ITEMS, the
|
||
* ones the user may edit), once per request. Only while library IDs may be named (ruling P13). Not final so the
|
||
* writer can be tested with a mock.
|
||
*/
|
||
class SimilarSections
|
||
{
|
||
public const MAX_ITEMS = 200;
|
||
|
||
/** @var array<string, array{id: int, title: string}>|null Structure fingerprint => entry. */
|
||
private ?array $index = null;
|
||
|
||
public function __construct(private LibraryAccess $access, private Parser $parser = new Parser())
|
||
{
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function warnings(DiviChange $change): array
|
||
{
|
||
if (! $this->access->revealsIds()) {
|
||
return [];
|
||
}
|
||
|
||
$warnings = [];
|
||
foreach ($change->entries() as $entry) {
|
||
$block = $entry['block'];
|
||
if ($entry['action'] !== 'inserted' || ! $block instanceof Block || $block->name() !== 'divi/section') {
|
||
continue;
|
||
}
|
||
$match = $this->index()[Fingerprint::structure($block)] ?? null;
|
||
if ($match !== null) {
|
||
$warnings[] = sprintf(
|
||
'Ein gleich aufgebauter Abschnitt liegt in der Divi-Bibliothek: „%s“ (%d) – lit-library-insert '
|
||
. 'erwägen; weitere Vorkommen mit lit/library-find-similar.',
|
||
$match['title'],
|
||
$match['id']
|
||
);
|
||
}
|
||
}
|
||
|
||
return array_values(array_unique($warnings));
|
||
}
|
||
|
||
/** @return array<string, array{id: int, title: string}> */
|
||
private function index(): array
|
||
{
|
||
if ($this->index !== null) {
|
||
return $this->index;
|
||
}
|
||
|
||
$this->index = [];
|
||
foreach ($this->access->store()->find(['layout_type' => 'section'], self::MAX_ITEMS - 1, 0) as $item) {
|
||
if (! current_user_can('edit_post', (int) $item->ID)) {
|
||
continue;
|
||
}
|
||
try {
|
||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||
} catch (MalformedMarkup) {
|
||
continue;
|
||
}
|
||
if (count($roots) === 1) {
|
||
$this->index[Fingerprint::structure($roots[0])] ??= [
|
||
'id' => (int) $item->ID,
|
||
'title' => (string) $item->post_title,
|
||
];
|
||
}
|
||
}
|
||
|
||
return $this->index;
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviWriter`: Konstruktor-Parameter `private ?SimilarSections $similar = null` (nach `$themeBuilder`; `use LIT\MCP\Integrations\Divi\Library\SimilarSections;`); in `save()` die Zeile `$warnings = array_merge($change->warnings(), $quality);` ersetzen durch
|
||
|
||
```php
|
||
$warnings = array_merge($change->warnings(), $quality, $this->similar?->warnings($change) ?? []);
|
||
```
|
||
|
||
`DiviIntegration::writer()`: als letztes Argument `new SimilarSections($this->access)`.
|
||
|
||
`DiviWriterFixture::writer()`: Parameter `?SimilarSections $similar = null` anhängen und an `new DiviWriter(…, $themeBuilder, $similar)` übergeben.
|
||
|
||
- [ ] **Step 4: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'SimilarSectionsTest|DiviWriterTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library/SimilarSections.php src/Integrations/Divi/Pages/DiviWriter.php src/Integrations/Divi/DiviIntegration.php tests/Support/DiviWriterFixture.php tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: write results warn about sections built like a library section (spec §5.5)`.
|
||
|
||
---
|
||
### Task 14: `lit/library-make-global` – Knoten in ein globales Modul umwandeln
|
||
|
||
E10: ein Aufruf legt einen globalen Bibliothekseintrag mit genau den Bytes des Knotens an (derselbe Weg wie `lit/library-save`, herausgelöst als `NewLibraryEntry`) und ersetzt den Knoten auf der Seite durch eine Instanz im Builder-Format (`GlobalInstance::of`). Alles oder nichts: scheitert die Seite, wird der neue Eintrag gelöscht. Die Seite läuft durch `DiviWriter::commit()` – ein sichtbares TB-Layout braucht also `confirm` (Task 5). Bibliothekseinträge als Quelle sind ausgeschlossen (keine Verschachtelung, P11).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Library/NewLibraryEntry.php`
|
||
- Modify: `src/Integrations/Divi/Library/LibrarySaveAbility.php` (nutzt `NewLibraryEntry`, Konstruktor unverändert)
|
||
- Create: `src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php`
|
||
- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php`; `LibrarySaveAbilityTest` unverändert grün; `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LibraryItemSpec::forNode()`, `DiviLibraryStore::create()`, `DiviSaver::saveNew()`, `NewDraft` (`discard`, `refuse`), `GlobalInstance::of()`, `Container::replaceChild()`, `DiviWriter::page()/composition()/commit()`, `LibraryAccess::canSave()/canInsert()`.
|
||
- Produces: `NewLibraryEntry::__construct(LibraryAccess $access, DiviSaver $saver, DiviModules $modules)`, `REASON_LAYOUT`, `spec(DiviPage $page, Block $node, array $input, bool $global, StoredNodeGuard $guard): LibraryItemSpec|WP_Error`, `create(LibraryItemSpec $spec, Block $node, NewDraft $draft): array{id: int, warnings: list<string>}`, `static content(Block $node): string`; `LibraryMakeGlobalAbility::__construct(LibraryAccess $access, DiviWriter $writer, NewLibraryEntry $entries, ChangeLog $log, ?Closure $diviWritable = null)`, `REASON_PAGE`; Ergebnis = Divi-Schreibantwort + `library_id`.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryMakeGlobalAbility;
|
||
use LIT\MCP\Integrations\Divi\Library\NewLibraryEntry;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use Throwable;
|
||
use WP_Post;
|
||
|
||
final class LibraryMakeGlobalAbilityTest extends TestCase
|
||
{
|
||
private WP_Post $page;
|
||
|
||
/** @var list<LibraryItemSpec> */
|
||
private array $specs = [];
|
||
|
||
/** @var list<array{int, string, SaveScope}> */
|
||
private array $entryContents = [];
|
||
|
||
/** @var list<string> Page contents DiviWriter saved. */
|
||
private array $saved = [];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||
LibraryFixtures::given([$this->page]);
|
||
}
|
||
|
||
/**
|
||
* @param list<string> $deniedRoles
|
||
* @param list<Throwable> $pageFailures
|
||
*/
|
||
private function ability(
|
||
array $deniedRoles = [],
|
||
bool $diviWritable = true,
|
||
bool $unfiltered = false,
|
||
array $pageFailures = []
|
||
): LibraryMakeGlobalAbility {
|
||
$store = LibraryFixtures::store();
|
||
$store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
|
||
$this->specs[] = $spec;
|
||
|
||
return 2300;
|
||
});
|
||
$saver = Mockery::mock(DiviSaver::class);
|
||
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope): array {
|
||
$this->entryContents[] = [$id, $content, $scope];
|
||
|
||
return [];
|
||
});
|
||
$access = LibraryFixtures::access($deniedRoles, true, $store);
|
||
|
||
return new LibraryMakeGlobalAbility(
|
||
$access,
|
||
DiviWriterFixture::writer($this->saved, $pageFailures, $unfiltered, LibraryFixtures::loader($access)),
|
||
new NewLibraryEntry($access, $saver, DiviWriterFixture::modules()),
|
||
DiviWriterFixture::log(),
|
||
static fn (): bool => $diviWritable
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function input(array $extra = []): array
|
||
{
|
||
return array_merge(
|
||
['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'],
|
||
$extra
|
||
);
|
||
}
|
||
|
||
public function testReplacesTheNodeByAnInstanceOfTheNewGlobalEntry(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input());
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(2300, $result['library_id']);
|
||
self::assertTrue($this->specs[0]->global);
|
||
self::assertSame(
|
||
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
|
||
$this->entryContents[0][1],
|
||
'the entry holds exactly the node, framed like the builder'
|
||
);
|
||
self::assertCount(1, $this->saved);
|
||
self::assertStringContainsString(
|
||
'<!-- wp:divi/global-layout {"globalModule":"2300","blockName":"divi/section","localAttrs":',
|
||
$this->saved[0]
|
||
);
|
||
self::assertStringNotContainsString(
|
||
'<!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Library Abschnitt"',
|
||
$this->saved[0],
|
||
'the plain section is gone'
|
||
);
|
||
self::assertNotSame([], array_filter($result['warnings'], static fn (string $w): bool => str_contains($w, 'lit/library-find-similar')));
|
||
}
|
||
|
||
public function testEditorCannotMakeACodeModuleGlobal(): void
|
||
{
|
||
$this->page = LibraryFixtures::page(
|
||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||
. '<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"<b>x</b>"}}}} /-->'
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||
);
|
||
LibraryFixtures::given([$this->page]);
|
||
|
||
$result = $this->ability()->execute($this->input(['id' => 's0']));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertStringContainsString('Code-Modul', implode(' ', self::errorFields($result)));
|
||
self::assertSame([], $this->specs);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
/** All or nothing: the page save failing removes the new entry again. */
|
||
public function testFailedPageSaveDeletesTheNewEntry(): void
|
||
{
|
||
Functions\expect('wp_delete_post')->once()->with(2300, true)->andReturn(new WP_Post(['ID' => 2300]));
|
||
|
||
try {
|
||
$this->ability([], true, false, [WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||
->execute($this->input());
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertTrue($e->rolledBack);
|
||
}
|
||
self::assertCount(1, $this->specs);
|
||
}
|
||
|
||
public function testRefusesNodesThatAreOrHoldGlobalModules(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
|
||
self::assertToolError($this->ability()->execute($this->input(['id' => 's0'])), 'validation_failed');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testNeedsSaveGlobalAndInsertRightsAndTheDiviGate(): void
|
||
{
|
||
self::assertToolError($this->ability(['edit_global_library'])->execute($this->input()), 'forbidden');
|
||
self::assertToolError($this->ability(['add_library'])->execute($this->input()), 'forbidden');
|
||
self::assertToolError($this->ability([], false)->execute($this->input()), 'unsupported');
|
||
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'alt'])), 'rev_mismatch');
|
||
self::assertSame([], $this->specs);
|
||
self::assertFalse($this->ability()->isDirect());
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegrationTest::testProvidesTheLibraryGroup`: erwartete Namen um `'lit/library-make-global'` ergänzen.
|
||
|
||
- [ ] **Step 2: Test läuft rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryMakeGlobalAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL – „Class … NewLibraryEntry not found“.
|
||
|
||
- [ ] **Step 3: NewLibraryEntry (aus LibrarySaveAbility herausgelöst)**
|
||
|
||
`src/Integrations/Divi/Library/NewLibraryEntry.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||
use Throwable;
|
||
use WP_Error;
|
||
use WP_Term;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* A new Divi Library entry from a node of an entry (E4; lit/library-save and lit/library-make-global): the spec
|
||
* (title, LibraryItemSpec::forNode, StoredNodeGuard – the node moves to another post, E6 –, existing categories
|
||
* and tags) and the creation (DiviLibraryStore::create, then the node's bytes in the divi/placeholder frame with
|
||
* line breaks unified, stored by DiviSaver::saveNew with SaveScope::LibraryItem – the one content write, D7).
|
||
* All or nothing through NewDraft (ruling P6).
|
||
*/
|
||
final class NewLibraryEntry
|
||
{
|
||
public const REASON_LAYOUT = 'Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.';
|
||
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviSaver $saver,
|
||
private DiviModules $modules
|
||
) {
|
||
}
|
||
|
||
/** @param array<string, mixed> $input title, categories, tags */
|
||
public function spec(
|
||
DiviPage $page,
|
||
Block $node,
|
||
array $input,
|
||
bool $global,
|
||
StoredNodeGuard $guard
|
||
): LibraryItemSpec|WP_Error {
|
||
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
|
||
if ($title === '') {
|
||
return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']);
|
||
}
|
||
|
||
$spec = LibraryItemSpec::forNode($node, $this->modules->catalog(), $title, $global, $this->builtFor($page));
|
||
if (is_string($spec)) {
|
||
return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
|
||
}
|
||
|
||
$problems = $guard->problems($node);
|
||
if ($problems !== []) {
|
||
return Errors::invalidInput(
|
||
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
|
||
. 'angelegt.',
|
||
['id' => implode('; ', $problems)]
|
||
);
|
||
}
|
||
|
||
$categories = self::termIds('layout_category', $input['categories'] ?? []);
|
||
$tags = self::termIds('layout_tag', $input['tags'] ?? []);
|
||
foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) {
|
||
if (is_string($ids)) {
|
||
return Errors::invalidInput(
|
||
'Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.',
|
||
[$field => $ids]
|
||
);
|
||
}
|
||
}
|
||
|
||
return $spec->withTerms($categories, $tags);
|
||
}
|
||
|
||
/**
|
||
* @return array{id: int, warnings: list<string>}
|
||
* @throws WriteFailed After the insert (the new entry is rolled back first, ruling P6).
|
||
*/
|
||
public function create(LibraryItemSpec $spec, Block $node, NewDraft $draft): array
|
||
{
|
||
$id = $this->access->store()->create($spec, $draft);
|
||
$warnings = [];
|
||
try {
|
||
$warnings = $this->saver->saveNew($id, self::content($node), SaveScope::LibraryItem);
|
||
} catch (Throwable $e) {
|
||
$draft->discard($id, $e, self::REASON_LAYOUT);
|
||
}
|
||
|
||
return ['id' => $id, 'warnings' => $warnings];
|
||
}
|
||
|
||
/** The node's stored bytes framed like the builder's "add to library", line breaks unified. */
|
||
public static function content(Block $node): string
|
||
{
|
||
$copy = $node->copy();
|
||
$copy->unifyLineBreaks();
|
||
|
||
return LibraryContent::wrap($copy);
|
||
}
|
||
|
||
/** _et_pb_built_for_post_type: the source's post type; a library entry passes its own on. */
|
||
private function builtFor(DiviPage $page): string
|
||
{
|
||
if (! $page->isLibraryItem()) {
|
||
return (string) $page->post->post_type;
|
||
}
|
||
|
||
$builtFor = (string) get_post_meta((int) $page->post->ID, '_et_pb_built_for_post_type', true);
|
||
|
||
return $builtFor !== '' ? $builtFor : 'page';
|
||
}
|
||
|
||
/** @return list<int>|string Term IDs of existing terms by name, or the problem. */
|
||
private static function termIds(string $taxonomy, mixed $names): array|string
|
||
{
|
||
$ids = [];
|
||
foreach (is_array($names) ? $names : [] as $name) {
|
||
$term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false;
|
||
if (! $term instanceof WP_Term) {
|
||
return sprintf(
|
||
'„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).',
|
||
is_string($name) ? $name : '?'
|
||
);
|
||
}
|
||
$ids[] = (int) $term->term_id;
|
||
}
|
||
|
||
return array_values(array_unique($ids));
|
||
}
|
||
}
|
||
```
|
||
|
||
`LibrarySaveAbility`: Feld `private NewLibraryEntry $entries;`, im Konstruktor (Signatur unverändert) `$this->entries = new NewLibraryEntry($access, $saver, $modules);`. In `execute()` den Block von `$title = sanitize_text_field(…)` bis zum Ende der Begriffsprüfung ersetzen durch
|
||
|
||
```php
|
||
$spec = $this->entries->spec(
|
||
$page,
|
||
$node,
|
||
$input,
|
||
self::isGlobal($input),
|
||
$this->writer->composition($page)->storedGuard()
|
||
);
|
||
if ($spec instanceof WP_Error) {
|
||
return $spec;
|
||
}
|
||
|
||
return $this->create($page, $spec, $node, $input);
|
||
```
|
||
|
||
`create()` vollständig ersetzen (erhält den Knoten statt des Inhalts):
|
||
|
||
```php
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>
|
||
* @throws WriteFailed
|
||
*/
|
||
private function create(DiviPage $page, LibraryItemSpec $spec, Block $node, array $input): array
|
||
{
|
||
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
|
||
$new = $this->entries->create($spec, $node, $draft);
|
||
$libraryId = $new['id'];
|
||
|
||
$followUp = (new PostSaveFollowUp(
|
||
$this->name(),
|
||
$this->log,
|
||
$libraryId,
|
||
'der Bibliothekseintrag',
|
||
$new['warnings']
|
||
))->afterSave($input);
|
||
$created = $followUp->reread();
|
||
$kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : '');
|
||
$followUp->log(
|
||
DiviLibraryStore::POST_TYPE,
|
||
sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID)
|
||
);
|
||
|
||
$content = $created !== null ? (string) $created->post_content : NewLibraryEntry::content($node);
|
||
$result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created)
|
||
->with('library_id', $libraryId)
|
||
->with('tree_rev', Rev::of($content))
|
||
->urls(null, (string) get_edit_post_link($libraryId, 'raw'));
|
||
if ($spec->global) {
|
||
$result->warning(
|
||
'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert '
|
||
. '(mode "global") einfügen.'
|
||
);
|
||
}
|
||
|
||
return $result->toArray();
|
||
}
|
||
```
|
||
|
||
Die privaten Methoden `content()`, `builtFor()`, `termIds()` und die Konstante `REASON_LAYOUT` entfernen; nicht mehr benutzte `use`-Zeilen (`Throwable`, `WP_Term`, `SaveScope`) entfernen.
|
||
|
||
Run: `vendor/bin/phpunit --filter LibrarySaveAbilityTest`
|
||
Expected: PASS (unverändert).
|
||
|
||
- [ ] **Step 4: Tool**
|
||
|
||
`src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\Abilities\WriteFailed;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\WordPress\Content\NewDraft;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/library-make-global (spec §5.7, E10): a node becomes a global module – a new global library entry holding
|
||
* exactly its bytes (NewLibraryEntry, StoredNodeGuard) and the node replaced on its page by the instance the
|
||
* Visual Builder writes (GlobalInstance::of). All or nothing: the page is written through DiviWriter::commit()
|
||
* (tree_rev lock; a visible Theme Builder layout needs confirm), and when it is not saved the new entry is deleted
|
||
* again (NewDraft). Library entries are no source (no nested instances, ruling P11). Needs the rights of
|
||
* lit/library-save with scope "global" and of lit-library-insert, and follows lit-divi-insert (audit M2).
|
||
*/
|
||
final class LibraryMakeGlobalAbility implements Ability
|
||
{
|
||
public const REASON_PAGE = 'Die Seite konnte nicht gespeichert werden; der neue globale Eintrag wurde wieder '
|
||
. 'entfernt.';
|
||
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviWriter $writer,
|
||
private NewLibraryEntry $entries,
|
||
private ChangeLog $log,
|
||
private ?Closure $diviWritable = null
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/library-make-global';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'In globales Modul umwandeln';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Wandelt einen Divi-Knoten (post_id, tree_rev und id aus lit-divi-outline, title) in ein globales Modul '
|
||
. 'um: legt einen globalen Bibliothekseintrag mit genau diesem Inhalt an und ersetzt den Knoten auf der '
|
||
. 'Seite durch die verknüpfte Instanz – alles oder nichts. Danach zeigt die Seite den Inhalt aus der '
|
||
. 'Bibliothek; Änderungen dort wirken auf alle Seiten mit diesem Modul.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge(DiviSchema::write(), [
|
||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
|
||
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||
'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||
]),
|
||
'required' => ['post_id', 'tree_rev', 'id', 'title'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema([
|
||
'library_id' => ['type' => 'integer'],
|
||
'tree_rev' => ['type' => 'string'],
|
||
'templates' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
], ['library_id']);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::destructive(false);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return $this->precheck($input) ?? true;
|
||
}
|
||
|
||
/** @throws WriteFailed */
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->precheck($input);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
return $this->makeGlobal($input);
|
||
}
|
||
|
||
/**
|
||
* Rights of lit-library-insert (add_library, library group writable) and of lit/library-save with scope
|
||
* "global" (publish_posts, save_library, edit_global_library), and lit-divi-insert's Tools-tab state (M2).
|
||
*
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
private function precheck(array $input): ?WP_Error
|
||
{
|
||
$refusal = $this->access->canInsert() ?? $this->access->canSave(true);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
if ($this->diviWritable !== null && ! ($this->diviWritable)()) {
|
||
return Errors::unsupported(
|
||
'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder '
|
||
. 'lit-divi-insert deaktiviert).',
|
||
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed
|
||
*/
|
||
private function makeGlobal(array $input): array|WP_Error
|
||
{
|
||
$page = $this->writer->page($input);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
|
||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||
$node = $page->index->find($id);
|
||
if ($node === null) {
|
||
return DiviErrors::unknownNode($id, $page->post);
|
||
}
|
||
|
||
if ($page->isLibraryItem()) {
|
||
return Errors::invalidInput(
|
||
'Knoten in Bibliothekseinträgen lassen sich nicht in globale Module umwandeln (keine '
|
||
. 'Verschachtelung); nichts wurde angelegt.',
|
||
['post_id' => 'eine Seite, ein Beitrag oder ein Theme-Builder-Layout']
|
||
);
|
||
}
|
||
|
||
if ($node->isOpaque() || LibraryContent::containsGlobal($node)) {
|
||
return Errors::invalidInput(
|
||
'Der Knoten ist oder enthält ein globales Modul (Divi erlaubt keine Verschachtelung); nichts wurde '
|
||
. 'angelegt.',
|
||
['id' => 'Knoten ohne globale Module']
|
||
);
|
||
}
|
||
|
||
$guard = $this->writer->composition($page)->storedGuard();
|
||
$spec = $this->entries->spec($page, $node, $input, true, $guard);
|
||
if ($spec instanceof WP_Error) {
|
||
return $spec;
|
||
}
|
||
|
||
$draft = new NewDraft(null, $this->log, $this->name(), DiviLibraryStore::POST_TYPE, 'Bibliothekseintrag');
|
||
$created = $this->entries->create($spec, $node, $draft);
|
||
$libraryId = $created['id'];
|
||
|
||
$root = $node->copy();
|
||
$root->unifyLineBreaks();
|
||
$instance = GlobalInstance::of($root, $libraryId);
|
||
$page->index->parentOf($node)?->replaceChild($node, $instance);
|
||
$change = (new DiviChange(sprintf(
|
||
'Knoten %s in das globale Modul „%s“ (%d) umgewandelt',
|
||
$id,
|
||
$spec->title,
|
||
$libraryId
|
||
)))->record($instance, 'replaced');
|
||
|
||
try {
|
||
$result = $this->writer->commit($this->name(), $page, $change, $input);
|
||
} catch (WriteFailed $e) {
|
||
$draft->discard($libraryId, $e, self::REASON_PAGE);
|
||
}
|
||
|
||
if ($result instanceof WP_Error) {
|
||
return $draft->refuse($libraryId, $result);
|
||
}
|
||
|
||
$result['library_id'] = $libraryId;
|
||
$result['warnings'] = array_merge($result['warnings'], $created['warnings'], [sprintf(
|
||
'Gleichartige Vorkommen: lit/library-find-similar mit library_id %d; inhaltsgleiche („identical“) mit '
|
||
. 'lit/library-make-global (library_id, targets) durch das globale Modul ersetzen.',
|
||
$libraryId
|
||
)]);
|
||
|
||
return $result;
|
||
}
|
||
}
|
||
```
|
||
|
||
`LibraryIntegration::abilities()` ergänzen:
|
||
|
||
```php
|
||
$abilities[] = new LibraryMakeGlobalAbility(
|
||
$this->access,
|
||
$this->writer,
|
||
new NewLibraryEntry($this->access, $this->saver, $this->modules),
|
||
$this->log,
|
||
$this->diviWritable
|
||
);
|
||
```
|
||
|
||
- [ ] **Step 5: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryMakeGlobalAbilityTest|LibrarySaveAbilityTest|DiviIntegrationTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 6: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: lit/library-make-global – node to global module, all or nothing (E10)`.
|
||
|
||
---
|
||
|
||
### Task 15: `lit/library-make-global` – inhaltsgleiche Vorkommen ersetzen
|
||
|
||
E10 + Antwort F2: mit `library_id` und `targets` (je `post_id`, `tree_rev`, `ids` aus `lit/library-find-similar`, Treffer „identical“) ersetzt das Tool weitere Vorkommen durch Instanzen des globalen Eintrags. Das ist eine globale Änderung: zuerst Probelauf mit den betroffenen Seiten, dann `confirm: true`; jede Seite verlangt `edit_post` (P8a – das Konto ändert nur Seiten, die es bearbeiten darf) und ihre aktuelle `tree_rev`; nur inhaltsgleiche Knoten; der Instanz-Schnappschuss kommt aus der Bibliothek und passiert `StoredNodeGuard` der Zielseite. Ziele sind Seiten und Beiträge (keine Bibliothekseinträge, keine TB-Layouts – dort einzeln mit `lit-library-insert`). Alle Ziele werden vorab geprüft; beim Speichern bekommt jede Seite eine Revision (D10), ein Fehler mittendrin nennt die schon geänderten Seiten.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: Task 12 (`Fingerprint::content()`), Task 14; `LibraryAccess::item()/isGlobal()`, `LibraryContent::load()/roots()/containsGlobal()`, `DiviWriter::page()/composition()/save()`, `WriteFailed::withDetails()`.
|
||
- Produces: `LibraryMakeGlobalAbility` mit zweitem Modus: Eingabe `library_id`, `targets` (höchstens `MAX_TARGETS = 20` Seiten, je höchstens `MAX_IDS = 20` IDs), `confirm`; Probelauf `confirm_required` mit `affected` (`{post_id, title, status, ids}`) und `affected_count`; Ergebnis `ToolResult` + `library_id`, `pages` (`{post_id, tree_rev, revision_id}`).
|
||
|
||
- [ ] **Step 1: Failing tests**
|
||
|
||
`LibraryMakeGlobalAbilityTest` ergänzen (`use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;`, `use LIT\MCP\Integrations\Divi\BlockTree\Parser;`, `use LIT\MCP\Integrations\Divi\BlockTree\Serializer;`):
|
||
|
||
```php
|
||
/** Pages 8 and 9: a plain copy of the global section 1058; 9 also a variation with another text. */
|
||
private function givenCopiesOf1058(): string
|
||
{
|
||
$root = LibraryContent::roots(LibraryContent::load(
|
||
new Parser(),
|
||
DiviFixtures::markup('stage3/library-global--globaler-abschnitt.html')
|
||
))[0];
|
||
$copy = Serializer::block($root);
|
||
$variation = str_replace('Global', 'Lokal', $copy);
|
||
LibraryFixtures::given([
|
||
$this->page,
|
||
LibraryFixtures::item(1058),
|
||
new WP_Post(['ID' => 8, 'post_type' => 'page', 'post_status' => 'publish', 'post_title' => 'Team',
|
||
'post_content' => '<!-- wp:divi/placeholder -->' . $copy . '<!-- /wp:divi/placeholder -->']),
|
||
new WP_Post(['ID' => 9, 'post_type' => 'page', 'post_status' => 'draft', 'post_title' => 'Jobs',
|
||
'post_content' => '<!-- wp:divi/placeholder -->' . $variation . $copy . '<!-- /wp:divi/placeholder -->']),
|
||
]);
|
||
|
||
return $copy;
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private static function targets(array $ids8 = ['s0'], array $ids9 = ['s1'], bool $confirm = false): array
|
||
{
|
||
return array_filter([
|
||
'library_id' => 1058,
|
||
'targets' => [
|
||
['post_id' => 8, 'tree_rev' => Rev::of((string) get_post(8)->post_content), 'ids' => $ids8],
|
||
['post_id' => 9, 'tree_rev' => Rev::of((string) get_post(9)->post_content), 'ids' => $ids9],
|
||
],
|
||
'confirm' => $confirm ?: null,
|
||
], static fn (mixed $value): bool => $value !== null);
|
||
}
|
||
|
||
public function testReplacingOccurrencesIsADryRunFirstThenEveryPageWithItsRevision(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
$dry = $this->ability()->execute(self::targets());
|
||
self::assertToolError($dry, 'confirm_required');
|
||
self::assertSame(2, $dry->get_error_data()['affected_count']);
|
||
self::assertSame([8, 9], array_column($dry->get_error_data()['affected'], 'post_id'));
|
||
self::assertSame([], $this->saved);
|
||
|
||
$result = $this->ability()->execute(self::targets(['s0'], ['s1'], true));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame([8, 9], array_column($result['pages'], 'post_id'));
|
||
self::assertSame([811, 811], array_column($result['pages'], 'revision_id'));
|
||
self::assertCount(2, $this->saved);
|
||
self::assertStringContainsString('{"globalModule":"1058","blockName":"divi/section"', $this->saved[0]);
|
||
self::assertStringContainsString('Lokal', $this->saved[1], 'the variation stays');
|
||
self::assertSame([], $this->specs, 'no new entry');
|
||
}
|
||
|
||
public function testTargetsMustBeIdenticalEditableAndCurrent(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
$variation = $this->ability()->execute(self::targets(['s0'], ['s0'], true));
|
||
self::assertToolError($variation, 'validation_failed');
|
||
self::assertArrayHasKey('targets.1.ids.0', self::errorFields($variation));
|
||
|
||
$stale = self::targets(['s0'], ['s1'], true);
|
||
$stale['targets'][1]['tree_rev'] = 'alt';
|
||
self::assertToolError($this->ability()->execute($stale), 'rev_mismatch');
|
||
|
||
$library = self::targets(['s0'], ['s1'], true);
|
||
$library['targets'][1] = ['post_id' => 1055, 'tree_rev' => Rev::of((string) LibraryFixtures::item(1055)->post_content), 'ids' => ['s0']];
|
||
LibraryFixtures::given([LibraryFixtures::item(1055), LibraryFixtures::item(1058), get_post(8), get_post(9)]);
|
||
self::assertToolError($this->ability()->execute($library), 'validation_failed');
|
||
|
||
LibraryFixtures::given([LibraryFixtures::item(1058), get_post(8), get_post(9)]);
|
||
Functions\when('current_user_can')->alias(static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 9));
|
||
self::assertToolError($this->ability()->execute(self::targets(['s0'], ['s1'], true)), 'forbidden');
|
||
|
||
self::assertSame([], $this->saved, 'nothing was written in any case');
|
||
}
|
||
|
||
public function testOnlyGlobalEntriesWithOneReadableRoot(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
LibraryFixtures::given([LibraryFixtures::item(1055), get_post(8), get_post(9)]);
|
||
|
||
$normal = self::targets();
|
||
$normal['library_id'] = 1055;
|
||
self::assertToolError($this->ability()->execute($normal), 'validation_failed');
|
||
}
|
||
|
||
public function testAFailureMidwayNamesThePagesAlreadyChanged(): void
|
||
{
|
||
$this->givenCopiesOf1058();
|
||
|
||
try {
|
||
$this->ability([], true, false, [null, WriteFailed::rolledBack('Die Divi-Seite konnte nicht gespeichert werden.')])
|
||
->execute(self::targets(['s0'], ['s1'], true));
|
||
self::fail('no WriteFailed');
|
||
} catch (WriteFailed $e) {
|
||
self::assertSame([8], $e->details['changed_posts']);
|
||
}
|
||
}
|
||
```
|
||
|
||
(`DiviWriterFixture::saver()` nimmt je Aufruf den nächsten Eintrag aus `$failures`; `null` heißt „dieser Aufruf gelingt“ – `array_shift` liefert dann `null`, und `saver()` wirft nur bei einem Throwable.)
|
||
|
||
- [ ] **Step 2: Tests laufen rot**
|
||
|
||
Run: `vendor/bin/phpunit --filter LibraryMakeGlobalAbilityTest`
|
||
Expected: FAIL – `library_id`/`targets` sind unbekannt (`additionalProperties`) bzw. `makeGlobal()` verlangt `post_id`.
|
||
|
||
- [ ] **Step 3: Zweiter Modus**
|
||
|
||
In `LibraryMakeGlobalAbility`:
|
||
|
||
1. Konstanten und Parser:
|
||
```php
|
||
public const MAX_TARGETS = 20;
|
||
|
||
public const MAX_IDS = 20;
|
||
```
|
||
Konstruktor um `private Parser $parser = new Parser()` (letzter Parameter; `use LIT\MCP\Integrations\Divi\BlockTree\Parser;`) erweitern.
|
||
2. `description()` um den zweiten Absatz ergänzen:
|
||
```php
|
||
. ' Gleichartige Vorkommen danach ersetzen: library_id plus targets (je post_id, tree_rev und ids '
|
||
. 'inhaltsgleicher Knoten aus lit/library-find-similar, match "identical"); das ist zuerst ein Probelauf '
|
||
. 'mit den betroffenen Seiten und braucht dann confirm: true. Jede Seite bekommt eine Revision.';
|
||
```
|
||
3. `inputSchema()`: `required` leeren (`'required' => []`) und die Eigenschaften ergänzen:
|
||
```php
|
||
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Globaler Eintrag.'],
|
||
'targets' => [
|
||
'type' => 'array',
|
||
'minItems' => 1,
|
||
'maxItems' => self::MAX_TARGETS,
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'tree_rev' => ['type' => 'string'],
|
||
'ids' => [
|
||
'type' => 'array',
|
||
'minItems' => 1,
|
||
'maxItems' => self::MAX_IDS,
|
||
'items' => ['type' => 'string'],
|
||
],
|
||
],
|
||
'required' => ['post_id', 'tree_rev', 'ids'],
|
||
'additionalProperties' => false,
|
||
],
|
||
],
|
||
```
|
||
(`confirm` kommt bereits aus `DiviSchema::write()`.)
|
||
4. `outputSchema()` um `'pages' => ['type' => 'array', 'items' => ['type' => 'object']]` ergänzen.
|
||
5. `precheck()`: `canSave(true)` nur für den ersten Modus:
|
||
```php
|
||
$refusal = $this->access->canInsert()
|
||
?? (isset($input['library_id']) ? null : $this->access->canSave(true));
|
||
```
|
||
6. `execute()`:
|
||
```php
|
||
return isset($input['library_id']) ? $this->replaceOccurrences($input) : $this->makeGlobal($input);
|
||
```
|
||
7. Neue Methoden (`use LIT\MCP\Integrations\Divi\BlockTree\Block;`, `use LIT\MCP\Integrations\Divi\BlockTree\Fingerprint;`, `use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;`, `use LIT\MCP\Integrations\Divi\Pages\DiviPage;`):
|
||
|
||
```php
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed
|
||
*/
|
||
private function replaceOccurrences(array $input): array|WP_Error
|
||
{
|
||
$root = $this->globalRoot($input['library_id']);
|
||
if ($root instanceof WP_Error) {
|
||
return $root;
|
||
}
|
||
[$libraryId, $block] = $root;
|
||
$fingerprint = Fingerprint::content($block);
|
||
|
||
$targets = is_array($input['targets'] ?? null) ? array_values($input['targets']) : [];
|
||
if ($targets === [] || count($targets) > self::MAX_TARGETS) {
|
||
return Errors::invalidInput(
|
||
sprintf('targets fehlt oder hat mehr als %d Seiten; nichts wurde gespeichert.', self::MAX_TARGETS),
|
||
['targets' => sprintf('1 bis %d Seiten', self::MAX_TARGETS)]
|
||
);
|
||
}
|
||
|
||
$plans = [];
|
||
$errors = [];
|
||
$seen = [];
|
||
foreach ($targets as $i => $target) {
|
||
$target = is_array($target) ? $target : [];
|
||
$page = $this->writer->page([
|
||
'post_id' => $target['post_id'] ?? null,
|
||
'tree_rev' => $target['tree_rev'] ?? null,
|
||
]);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
$postId = (int) $page->post->ID;
|
||
if ($page->isLibraryItem() || $page->isThemeBuilderLayout() || isset($seen[$postId])) {
|
||
$errors["targets.$i.post_id"] = 'nur Seiten und Beiträge, jede einmal (Theme-Builder-Layouts und '
|
||
. 'Bibliothekseinträge einzeln mit lit-library-insert)';
|
||
continue;
|
||
}
|
||
$seen[$postId] = true;
|
||
|
||
$nodes = [];
|
||
$ids = is_array($target['ids'] ?? null) ? array_values($target['ids']) : [];
|
||
if ($ids === [] || count($ids) > self::MAX_IDS) {
|
||
$errors["targets.$i.ids"] = sprintf('1 bis %d Pfad-IDs', self::MAX_IDS);
|
||
}
|
||
foreach ($ids as $j => $id) {
|
||
$node = is_string($id) ? $page->index->find($id) : null;
|
||
if ($node === null || $node->isOpaque() || Fingerprint::content($node) !== $fingerprint) {
|
||
$errors["targets.$i.ids.$j"] = 'kein inhaltsgleiches Vorkommen des Eintrags (nur match "identical" '
|
||
. 'aus lit/library-find-similar)';
|
||
continue;
|
||
}
|
||
$nodes[(string) $id] = $node;
|
||
}
|
||
|
||
$guard = $this->writer->composition($page)->storedGuard();
|
||
$problems = $guard->problems(GlobalInstance::of($block, $libraryId));
|
||
if ($problems !== []) {
|
||
$errors["targets.$i.post_id"] = implode('; ', $problems);
|
||
}
|
||
$plans[] = [$page, $nodes];
|
||
}
|
||
|
||
if ($errors !== []) {
|
||
return Errors::invalidInput('Nicht alle Ziele lassen sich ersetzen; nichts wurde gespeichert.', $errors);
|
||
}
|
||
|
||
$affected = array_map(static fn (array $plan): array => [
|
||
'post_id' => (int) $plan[0]->post->ID,
|
||
'title' => (string) $plan[0]->post->post_title,
|
||
'status' => (string) $plan[0]->post->post_status,
|
||
'ids' => array_keys($plan[1]),
|
||
], $plans);
|
||
if (($input['confirm'] ?? false) !== true) {
|
||
return Errors::confirmRequired(
|
||
sprintf(
|
||
'%d Vorkommen auf %d %s würden durch das globale Modul %d ersetzt; die Seiten zeigen danach den '
|
||
. 'Inhalt aus der Bibliothek. Nichts wurde gespeichert (Probelauf).',
|
||
array_sum(array_map(static fn (array $row): int => count($row['ids']), $affected)),
|
||
count($affected),
|
||
count($affected) === 1 ? 'Seite' : 'Seiten',
|
||
$libraryId
|
||
),
|
||
['affected' => $affected, 'affected_count' => count($affected)]
|
||
);
|
||
}
|
||
|
||
return $this->replace($libraryId, $block, $plans, $input);
|
||
}
|
||
|
||
/**
|
||
* @param list<array{0: DiviPage, 1: array<string, Block>}> $plans
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>
|
||
* @throws WriteFailed
|
||
*/
|
||
private function replace(int $libraryId, Block $root, array $plans, array $input): array
|
||
{
|
||
$pages = [];
|
||
foreach ($plans as [$page, $nodes]) {
|
||
$change = new DiviChange(
|
||
sprintf('%d Vorkommen durch das globale Modul %d ersetzt', count($nodes), $libraryId)
|
||
);
|
||
foreach ($nodes as $node) {
|
||
$instance = GlobalInstance::of($root, $libraryId);
|
||
$page->index->parentOf($node)?->replaceChild($node, $instance);
|
||
$change->record($instance, 'replaced');
|
||
}
|
||
|
||
try {
|
||
$saved = $this->writer->save($this->name(), $page, $change, $input);
|
||
} catch (WriteFailed $e) {
|
||
$done = array_column($pages, 'post_id');
|
||
throw $done === [] ? $e : $e->withDetails(
|
||
['changed_posts' => $done],
|
||
sprintf(
|
||
'Bereits ersetzt (je mit Revision): %s. Die übrigen Seiten sind unverändert; mit '
|
||
. 'lit/library-find-similar neu prüfen.',
|
||
implode(', ', $done)
|
||
)
|
||
);
|
||
}
|
||
$pages[] = [
|
||
'post_id' => (int) $page->post->ID,
|
||
'tree_rev' => (string) ($saved['tree_rev'] ?? ''),
|
||
'revision_id' => $saved['revision_id'] ?? null,
|
||
];
|
||
}
|
||
|
||
return ToolResult::ok(sprintf(
|
||
'Vorkommen auf %d %s durch das globale Modul %d ersetzt (je mit Revision).',
|
||
count($pages),
|
||
count($pages) === 1 ? 'Seite' : 'Seiten',
|
||
$libraryId
|
||
))
|
||
->with('library_id', $libraryId)
|
||
->with('pages', $pages)
|
||
->toArray();
|
||
}
|
||
|
||
/** @return array{0: int, 1: Block}|WP_Error The global entry's single readable root without global modules. */
|
||
private function globalRoot(mixed $libraryId): array|WP_Error
|
||
{
|
||
$item = $this->access->item($libraryId, false);
|
||
if ($item === null) {
|
||
return Errors::notFound('Bibliothekseintrag gibt es nicht.', 'Einträge mit lit/library-list auflisten.');
|
||
}
|
||
if ($item instanceof WP_Error) {
|
||
return $item;
|
||
}
|
||
|
||
$id = (int) $item->ID;
|
||
try {
|
||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||
} catch (MalformedMarkup) {
|
||
$roots = [];
|
||
}
|
||
$valid = (string) $item->post_status === 'publish' && $this->access->isGlobal($item) && count($roots) === 1
|
||
&& ! $roots[0]->isOpaque() && ! LibraryContent::containsGlobal($roots[0]);
|
||
if (! $valid) {
|
||
return Errors::invalidInput(
|
||
sprintf(
|
||
'Bibliothekseintrag %d ist kein aktiver globaler Eintrag mit genau einem Knoten; nichts wurde '
|
||
. 'gespeichert.',
|
||
$id
|
||
),
|
||
['library_id' => 'globaler Eintrag (lit/library-list, scope "global")']
|
||
);
|
||
}
|
||
|
||
return [$id, $roots[0]];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests grün, Gesamtlauf**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryMakeGlobalAbilityTest|DiviIntegrationTest' && composer test && composer lint`
|
||
Expected: PASS.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library/LibraryMakeGlobalAbility.php tests/Unit/Integrations/Divi/Library/LibraryMakeGlobalAbilityTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit `feat: lit/library-make-global replaces identical occurrences – dry run, confirm, revision per page`.
|
||
|
||
---
|
||
### Task 16: Guides, Server-Hinweis, Readme, Version 0.5.0, Spec und Entscheidungsnotiz
|
||
|
||
Die Texte beschreiben das tatsächliche Verhalten der Tasks 1–15. Deutsch mit echten Umlauten, neutral („KI-Assistent“, „MCP-Client“). Direkte Tools heißen `lit-…`, indirekte `lit/…` (ToolNamingTest).
|
||
|
||
**Files:**
|
||
- Create: `docs/guides/theme-builder.md`
|
||
- Modify: `docs/guides/overview.md`, `docs/guides/divi-library.md`, `docs/guides/divi-placeholders.md`, `docs/guides/workflows.md`
|
||
- Modify: `src/Guides/InstructionsBuilder.php`, `src/Plugin.php` (`VERSION`), `wp-lit-mcp.php` (`Version`), `readme.txt`
|
||
- Modify: `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`, `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md`
|
||
- Test: `tests/Unit/Guides/InstructionsBuilderTest.php`; `ToolNamingTest`, `PluginHeaderTest`, `GuideRepositoryTest` unverändert grün
|
||
|
||
**Interfaces:**
|
||
- Consumes: alle Tool-Namen, Ergebnis-Schlüssel und Regeln aus Tasks 2–15.
|
||
- Produces: Guide-Thema `theme-builder`; Server-Hinweis „Divi Theme Builder: …“ nur bei aktiver, beschreibbarer Gruppe `divi-theme-builder`; Version 0.5.0.
|
||
|
||
- [ ] **Step 1: Failing test**
|
||
|
||
`InstructionsBuilderTest` ergänzen:
|
||
|
||
```php
|
||
private function themeBuilder(): Integration
|
||
{
|
||
$group = Mockery::mock(Integration::class);
|
||
$group->allows('id')->andReturn('divi-theme-builder');
|
||
$group->allows('isAvailable')->andReturn(true);
|
||
$activate = $this->ability('lit/tb-activate', false);
|
||
$activate->allows('annotations')->andReturn(['readonly' => false]);
|
||
$group->allows('abilities')->andReturn([$activate]);
|
||
|
||
return $group;
|
||
}
|
||
|
||
public function testAddsTheThemeBuilderRuleOnlyWhenItsGroupCanWrite(): void
|
||
{
|
||
$core = $this->coreWith($this->ability('lit/guide', true));
|
||
$registry = new IntegrationRegistry($core, $this->themeBuilder());
|
||
|
||
$with = (new InstructionsBuilder($registry))->build();
|
||
$readonly = (new InstructionsBuilder(
|
||
$registry,
|
||
null,
|
||
(new ExposurePolicy(['@readonly:divi-theme-builder' => true]))->withGroups($registry->groupMap())
|
||
))->build();
|
||
|
||
self::assertStringContainsString('Divi Theme Builder: neue Vorlagen entstehen deaktiviert', $with);
|
||
self::assertStringContainsString('lit/tb-activate', $with);
|
||
self::assertStringContainsString('topic "theme-builder"', $with);
|
||
self::assertStringNotContainsString('Divi Theme Builder:', $readonly);
|
||
self::assertLessThan(2000, strlen($with));
|
||
}
|
||
```
|
||
|
||
Run: `vendor/bin/phpunit --filter InstructionsBuilderTest`
|
||
Expected: FAIL – „Failed asserting that '…' contains "Divi Theme Builder: neue Vorlagen entstehen deaktiviert"“.
|
||
|
||
- [ ] **Step 2: Server-Hinweis**
|
||
|
||
`InstructionsBuilder`: Konstante
|
||
|
||
```php
|
||
/** Tool group of the Divi Theme Builder (ThemeBuilderAccess::GROUP); its rule needs a writable group. */
|
||
private const THEME_BUILDER_GROUP = 'divi-theme-builder';
|
||
```
|
||
|
||
in `build()` nach `$library = …;`:
|
||
|
||
```php
|
||
$themeBuilder = str_contains(', ' . $active . ',', ', ' . self::THEME_BUILDER_GROUP . ',')
|
||
&& $this->policy->groupWritable(self::THEME_BUILDER_GROUP)
|
||
? [
|
||
'- Divi Theme Builder: neue Vorlagen entstehen deaktiviert; aktivieren nur auf ausdrücklichen '
|
||
. 'Wunsch mit lit/tb-activate (Probelauf, dann confirm: true); Ablauf: lit-guide topic '
|
||
. '"theme-builder".',
|
||
]
|
||
: [];
|
||
```
|
||
|
||
und `$themeBuilder` als letztes Argument an `array_merge(…, $divi, $library, $themeBuilder)` anhängen.
|
||
|
||
Run: `vendor/bin/phpunit --filter 'InstructionsBuilderTest|ToolNamingTest'`
|
||
Expected: `InstructionsBuilderTest` PASS; `ToolNamingTest::testReferencedGuideTopicsExist` FAIL („guide topic "theme-builder"“) – der Guide folgt im nächsten Schritt.
|
||
|
||
- [ ] **Step 3: Guide „theme-builder“**
|
||
|
||
`docs/guides/theme-builder.md` vollständig:
|
||
|
||
```markdown
|
||
# Divi Theme Builder
|
||
|
||
Der Theme Builder legt fest, wie Kopfzeile, Inhalt und Fußzeile für bestimmte Seiten aussehen – z. B. für alle
|
||
Einträge eines eigenen Inhaltstyps. Eine **Vorlage** hat Bedingungen („gilt für alle Personen“) und bis zu drei
|
||
**Layouts** (Kopfzeile, Inhalt, Fußzeile). Die **Standardvorlage** gilt überall, wo keine andere greift; ihre
|
||
Kopf- und Fußzeile heißen „global“, weil andere Vorlagen sie mitbenutzen.
|
||
|
||
## Grundregel: nichts wird ohne Freigabe sichtbar
|
||
|
||
- Neue Vorlagen legt LIT MCP immer **deaktiviert** an – Besucher sehen nichts davon.
|
||
- Aktivieren und Deaktivieren gibt es nur mit `lit/tb-activate`, und nur so: Probelauf (Bedingungen, Layouts,
|
||
andere aktive Vorlagen mit denselben Bedingungen) → Nutzer fragen → `confirm: true`.
|
||
- Auch alles andere, was Besucher sofort sähen, ist zuerst ein Probelauf (`confirm_required`) und braucht
|
||
`confirm: true`: Bedingungen einer aktiven Vorlage ändern (`lit/tb-assign`) und ein Layout ändern, das eine
|
||
aktive Vorlage zeigt (alle `lit-divi-…`-Werkzeuge; die Antwort nennt die Vorlagen unter `templates`).
|
||
- Die Standardvorlage ändert LIT MCP nicht (nur im Theme Builder).
|
||
|
||
## Werkzeuge (über `mcp-adapter-execute-ability`)
|
||
|
||
- `lit/tb-list`: alle Vorlagen mit `enabled`, `live`, Bedingungen (`use_on`, `exclude_from` und Beschriftungen),
|
||
Layouts (`id`, Titel, `shared_with`) und `template_rev`. `conditions: true` liefert alle Bedingungen, die Divi
|
||
anbietet; eine ID, die auf „:“ endet, braucht ein Objekt, z. B. `singular:post_type:page:id:42`. `detached`
|
||
zeigt Vorlagen, die ein Speichern im Theme Builder entfernt hat (siehe unten).
|
||
- `lit/tb-create-template`: `title`, je Bereich (`header`, `body`, `footer`) `source`:
|
||
`"new"` (neues Layout; optional `nodes` wie bei `lit-divi-insert` und `title`), `"global"` (Kopf-/Fußzeile
|
||
der Standardvorlage), `"default"` (nichts Eigenes) oder `"hidden"` (Bereich ausblenden). Optional
|
||
`use_on`/`exclude_from`. Antwort: `template_id`, `template_rev`, `layouts`.
|
||
- `lit/tb-assign`: Bedingungen ersetzen (`template_id`, `template_rev`, `use_on`, optional `exclude_from`).
|
||
- `lit/tb-activate`: `enabled: true` oder `false` (`template_id`, `template_rev`).
|
||
- Layouts bearbeiten: `lit-divi-outline`, `lit-divi-insert`, `lit-divi-update` … mit der Layout-ID als
|
||
`post_id` (aus `lit/tb-list`). Das Modul „Beitragsinhalt“ (`post-content`) gibt es nur im Inhalt-Layout.
|
||
|
||
## Felder im Inhalt-Layout (Dynamic Content)
|
||
|
||
Im Inhalt-Layout zeigen Platzhalter die Daten des jeweiligen Eintrags: `{{post:title}}`, `{{post:excerpt}}`,
|
||
`{{post:date}}` und Felder als `{{field:<Schlüssel>}}`. Welche Felder ein Inhaltstyp hat und welche Divi als Text
|
||
zeigt, liefert `lit/divi-dynamic-sources` mit `post_type`. Nur textartige Felder eignen sich; Beziehungs- und
|
||
Dateifelder zeigen nur IDs, Pods mit Tabellen-Speicherung gar nichts. LIT MCP prüft die Felder gegen die
|
||
Inhaltstypen aus den Bedingungen der Vorlage und warnt (`warnings`), speichert aber trotzdem. Ohne Bedingung
|
||
„singular:post_type:…“ bleiben die Felder ungeprüft.
|
||
|
||
## Wenn der Theme Builder gerade offen ist
|
||
|
||
- Hat der Theme Builder ungespeicherte Änderungen (Entwurf), lehnen alle schreibenden `lit/tb-…`-Werkzeuge ab:
|
||
erst dort speichern oder verwerfen.
|
||
- Speichert jemand in einem Theme-Builder-Fenster, das vor dem Anlegen einer Vorlage geöffnet wurde, entfernt
|
||
Divi die neue Vorlage wieder aus der Liste und löscht sie 7 Tage später. `lit/tb-list` zeigt sie dann unter
|
||
`detached` (mit `marked_unused`). `lit/tb-assign` mit `reattach: true` ordnet sie wieder zu – deaktiviert.
|
||
|
||
## Rechte
|
||
|
||
- WordPress: „Beiträge anderer bearbeiten“ (`edit_others_posts`); zum Anlegen zusätzlich „veröffentlichen“.
|
||
- Divi → Rollen-Editor: „Theme Builder“ – standardmäßig nur für Administratoren. Fehlt eines davon, gibt es für
|
||
dieses Konto keine Vorlagen und Layouts (`not_found` bzw. `forbidden`).
|
||
- Bedingungen mit einer ID verlangen, dass das Konto den Eintrag lesen darf; Autoren-Bedingungen das Recht
|
||
`list_users`.
|
||
- Einstellungen → LIT MCP → Tools: Gruppe „Divi Theme Builder“ auf „nur lesen“ sperrt jedes Schreiben, auch über
|
||
die `lit-divi-…`-Werkzeuge; deaktiviert gibt es keine Vorlagen und Layouts.
|
||
|
||
## Ablauf: Vorlage für einen Inhaltstyp (custom-post-type-vorlage)
|
||
|
||
1. Inhaltstyp und Felder klären: `lit-content-types`, dann `lit/divi-dynamic-sources` mit `post_type`.
|
||
2. `lit/tb-list` lesen: gibt es schon eine Vorlage für den Typ? Gibt es eine globale Kopf-/Fußzeile?
|
||
3. Mit dem Nutzer den Aufbau abstimmen (welche Felder, welche Reihenfolge, Kopf-/Fußzeile global oder eigen).
|
||
4. `lit/tb-create-template` mit `use_on: ["singular:post_type:<typ>:all"]`, `header`/`footer` meist
|
||
`{"source": "global"}`, `body` mit `source: "new"` und `nodes` (Überschrift `{{post:title}}`, Texte mit
|
||
`{{field:…}}`, das Modul `post-content`, wenn der Eintrag eigenen Inhalt hat). Warnungen zu Feldern beachten.
|
||
5. Das Inhalt-Layout bei Bedarf mit `lit-divi-outline`/`lit-divi-update` verfeinern (Layout-ID als `post_id`).
|
||
6. Den Nutzer bitten, die Vorlage im Theme Builder anzusehen (Vorschau eines Eintrags ist erst nach dem
|
||
Aktivieren möglich).
|
||
7. Erst auf ausdrücklichen Wunsch: `lit/tb-activate` mit `enabled: true` – Probelauf zeigen, dann `confirm: true`.
|
||
```
|
||
|
||
- [ ] **Step 4: Übrige Guides**
|
||
|
||
`docs/guides/overview.md`:
|
||
1. Satz „Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek).“ → „(Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek, Divi Theme Builder).“
|
||
2. Die Aufzählung der indirekten Tools endet künftig mit: „Divi-Bibliothek (`lit/library-list`, `lit/library-save`, `lit/library-find-similar`, `lit/library-make-global`), Divi Theme Builder (`lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate`).“
|
||
3. Der Absatz zu den `lit-divi-…`-Werkzeugen lautet: „Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. Sie arbeiten auch auf Einträgen der Divi-Bibliothek und auf Layouts des Theme Builders (ID als `post_id`); globale Module: `lit-guide` topic `divi-library`, Vorlagen: `lit-guide` topic `theme-builder`.“
|
||
4. „Weitere Themen“: `… oder \`divi-library\`.` → `…, \`divi-library\` oder \`theme-builder\`.`
|
||
|
||
`docs/guides/divi-library.md`:
|
||
1. Unter „Werkzeuge“ nach dem Punkt `lit/library-save` ergänzen:
|
||
```markdown
|
||
- `lit/library-find-similar`: Knoten finden, die wie ein Knoten (`post_id` und `id`) oder ein Eintrag
|
||
(`library_id`) aufgebaut sind. `match: "identical"` = gleicher Inhalt (Admin-Labels ausgenommen),
|
||
`"structure"` = gleicher Aufbau (Modultypen, Spalten, Presets) mit anderen Texten. Höchstens 200 Einträge je
|
||
Aufruf, weiter mit `next_cursor`; nur Einträge, die das Konto bearbeiten darf. Jeder Treffer nennt `tree_rev`.
|
||
- `lit/library-make-global`: einen Knoten in ein globales Modul umwandeln (`post_id`, `tree_rev`, `id`,
|
||
`title`): neuer globaler Eintrag mit genau diesem Inhalt, der Knoten wird durch die Instanz ersetzt – alles
|
||
oder nichts. Weitere, inhaltsgleiche Vorkommen ersetzen: `library_id` und `targets` (je `post_id`, `tree_rev`,
|
||
`ids` der Treffer „identical“); zuerst Probelauf (`affected`), dann `confirm: true`; jede Seite bekommt eine
|
||
Revision. Nur Seiten und Beiträge, die das Konto bearbeiten darf.
|
||
```
|
||
2. Den Absatz ab „Grenze: Die Einträge selbst sind dann nicht lesbar …“ bis „… dann nicht weiter versuchen.“ ersetzen durch:
|
||
```markdown
|
||
Ohne das Recht „Divi-Bibliothek“ oder mit deaktivierter Gruppe nennen `lit-divi-outline`, `lit-divi-node-get`
|
||
und `lit-page-render-summary` globale Module nur als solche, ohne ihre ID.
|
||
```
|
||
3. Nach jeder Schreibantwort gilt zusätzlich (unter „Globale Module erkennen und ändern“, letzter Absatz ergänzen): „Fügt eine Divi-Änderung einen Abschnitt ein, der wie ein Abschnitt der Bibliothek aufgebaut ist, nennt `warnings` den Eintrag – dann `lit-library-insert` erwägen.“
|
||
4. „Ablauf: globale Module vorschlagen“ ersetzen:
|
||
```markdown
|
||
## Ablauf: globale Module vorschlagen
|
||
|
||
1. Wiederkehrende Abschnitte suchen: `lit/library-find-similar` mit einem typischen Abschnitt (`post_id`, `id`)
|
||
– z. B. Kontakt, Öffnungszeiten, Call-to-Action.
|
||
2. Dem Nutzer die Treffer zeigen und erklären: eine Änderung wirkt danach auf allen Seiten mit dem Modul.
|
||
Treffer mit `match: "structure"` unterscheiden sich im Text – vorher klären, welche Fassung gilt, und die
|
||
übrigen angleichen (`lit-divi-update`) oder weglassen.
|
||
3. Nach Zustimmung `lit/library-make-global` mit dem gewählten Abschnitt.
|
||
4. Die inhaltsgleichen Vorkommen mit `lit/library-make-global` (`library_id`, `targets`) ersetzen: Probelauf
|
||
zeigen, dann `confirm: true`.
|
||
5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen.
|
||
```
|
||
|
||
`docs/guides/divi-placeholders.md`: nach dem Absatz, der mit „In Link-Feldern (`url`, `button_url` …)“ beginnt, ergänzen:
|
||
```markdown
|
||
Dieselben Regeln gelten für die Vollform `post_link_url_<Typ>` mit `post_id`: das Ziel muss lesbar sein, darf
|
||
nicht im Papierkorb liegen, kein Anhang sein und muss genau den Typ im Namen haben (`post_link_url_page` nur für
|
||
Seiten). `{{link:<ID>}}` wählt den passenden Namen selbst.
|
||
```
|
||
|
||
`docs/guides/workflows.md`:
|
||
1. Abschnitt „Bibliothek und globale Module“ ersetzen:
|
||
```markdown
|
||
## Bibliothek und globale Module
|
||
|
||
Vorhandene Abschnitte aus der Divi-Bibliothek zuerst suchen (`lit/library-list`) und mit `lit-library-insert`
|
||
einfügen, statt sie neu zu bauen. Einen gelungenen Abschnitt für später speichern: `lit/library-save`.
|
||
Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. Wiederkehrende Abschnitte finden und zu globalen
|
||
Modulen machen: `lit/library-find-similar`, `lit/library-make-global` (`lit-guide` topic `divi-library`).
|
||
```
|
||
2. Neuer Abschnitt nach „Einträge eines eigenen Inhaltstyps (Pods)“:
|
||
```markdown
|
||
## Vorlage für einen Inhaltstyp (Theme Builder)
|
||
|
||
Wie Einträge eines Inhaltstyps (z. B. Personen) auf der Website aussehen, legt eine Theme-Builder-Vorlage fest.
|
||
Ablauf und Regeln: `lit-guide` topic `theme-builder`. Neue Vorlagen sind deaktiviert; aktiviert wird nur auf
|
||
ausdrücklichen Wunsch mit `lit/tb-activate`.
|
||
```
|
||
|
||
- [ ] **Step 5: Readme und Version**
|
||
|
||
`src/Plugin.php`: `public const VERSION = '0.5.0';`. `wp-lit-mcp.php`: ` * Version: 0.5.0`. `readme.txt`: `Stable tag: 0.5.0`.
|
||
|
||
`readme.txt`, Abschnitt `== Description ==`: im ersten Absatz „… sowie Divi-Seiten und die Divi-Bibliothek (auch globale Module).“ → „… sowie Divi-Seiten, die Divi-Bibliothek (auch globale Module) und den Divi Theme Builder.“
|
||
|
||
In der Liste mit den Hinweisen nach dem Punkt „Divis Rollen-Editor gilt zusätzlich …“ ergänzen:
|
||
```text
|
||
* Divi Theme Builder: neue Vorlagen legt LIT MCP deaktiviert an (über Divis eigene Funktionen, mit Layouts für
|
||
Kopfzeile, Inhalt und Fußzeile); sichtbar werden sie nur über lit/tb-activate nach Probelauf und confirm.
|
||
Änderungen an aktiven Vorlagen und an Layouts, die Besucher sehen, verlangen ebenfalls Probelauf und confirm.
|
||
Der Theme Builder verlangt „Beiträge anderer bearbeiten“ und das Divi-Recht „Theme Builder“. Solange der Theme
|
||
Builder ungespeicherte Änderungen hat, ändert LIT MCP keine Vorlagen.
|
||
```
|
||
|
||
Unter `== Rückgängig machen ==` ergänzen:
|
||
```text
|
||
* Theme Builder: Vorlagen haben keine Revisionen. lit/tb-assign nennt die bisherigen Bedingungen (previous) und
|
||
schreibt sie ins Protokoll; lit/tb-activate schaltet zurück. Layouts erhalten wie Divi-Seiten eine Revision.
|
||
Neu angelegte Vorlagen und Layouts im Divi Theme Builder löschen.
|
||
```
|
||
|
||
`== Changelog ==` oben:
|
||
```text
|
||
= 0.5.0 =
|
||
* Divi Theme Builder: Vorlagen, Bedingungen und Layouts auflisten (lit/tb-list), Vorlagen mit Kopfzeile,
|
||
Inhalt und Fußzeile anlegen – immer deaktiviert (lit/tb-create-template) –, Bedingungen setzen
|
||
(lit/tb-assign), ausdrücklich aktivieren oder deaktivieren (lit/tb-activate).
|
||
* Die Divi-Werkzeuge bearbeiten Theme-Builder-Layouts; sichtbare Layouts erst nach Probelauf und confirm.
|
||
Felder im Inhalt-Layout ({{field:…}}) werden gegen den Inhaltstyp der Vorlage geprüft.
|
||
* Ähnliche Abschnitte finden (lit/library-find-similar); Knoten in globale Module umwandeln und
|
||
inhaltsgleiche Vorkommen ersetzen (lit/library-make-global); Divi-Änderungen weisen auf gleich aufgebaute
|
||
Abschnitte der Bibliothek hin.
|
||
* Tools-Gruppe „Divi Theme Builder“; lit-site-status prüft die genutzten Theme-Builder-Funktionen.
|
||
* Sicherheit: Bibliotheks-IDs bleiben ohne Bibliothekszugang verborgen; interne Links in der Vollform
|
||
(post_link_url_*) folgen denselben Zielregeln wie {{link:ID}}.
|
||
```
|
||
|
||
- [ ] **Step 6: Spec**
|
||
|
||
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`:
|
||
1. §4.3 nach „**Stand nach Etappe 3a:** …“ anhängen: „**Stand nach Etappe 3b:** weiterhin 22 direkte Tools; indirekt neu `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate` (neue Tool-Gruppe „Divi Theme Builder“, sperrt auch Schreibzugriffe der Divi-Werkzeuge auf TB-Layouts) sowie `lit/library-find-similar`, `lit/library-make-global` (Gruppe „Divi-Bibliothek“).“
|
||
2. §4.5 Punkt 3: „`edit_others_posts` für Theme Builder (Divi-Anforderung)“ → „`edit_others_posts` und Divis Rollen-Recht „Theme Builder“ für den Theme Builder (Divi-Anforderung; Etappe 3b), dazu `edit_post` je Vorlage/Layout“.
|
||
3. §5.5 letzter Aufzählungspunkt: „… – ab Etappe 3 zusammen mit `lit/library-find-similar` (D6).“ → „… – verglichen wird nur mit Abschnitten der Divi-Bibliothek (Struktur-Fingerabdruck), nur mit Bibliothekszugang (Etappe 3b, E11).“
|
||
4. §5.6 Schritt 4: „Post-Meta `_et_pb_use_divi_5=on`, `_et_pb_use_builder=on` setzen.“ → „… setzen (Seiten und Theme-Builder-Layouts; Bibliothekseinträge nicht).“
|
||
5. §5.7 Tabelle: Zeile `lit/tb-create-template` Beschreibung ergänzen um „; immer deaktiviert (Antwort F1); je Bereich neu, global (Standardvorlage), leer oder ausgeblendet; alles oder nichts“; Zeile `lit/tb-assign` um „; aktive Vorlage nur nach Probelauf und `confirm`; `reattach` für vom Theme Builder entfernte Vorlagen“; neue Zeile nach `lit/tb-assign`: `| \`lit/tb-activate\` | schreiben | Vorlage aktivieren/deaktivieren – einzige Stelle, die eine Vorlage sichtbar macht; immer Probelauf, dann \`confirm\` (Antwort F1, Etappe 3b) |`; Zeile `lit/library-make-global` um „; gleichartige = inhaltsgleiche Vorkommen (`targets`), Probelauf + `confirm`, Revision je Seite (E10)“; Zeile `lit/library-find-similar` um „und Inhalts-Fingerabdruck; höchstens 200 Einträge je Aufruf, `cursor` (E11)“.
|
||
6. §9.1 anhängen: „- Etappe 3b: Zeile „Divi Theme Builder: …“ (neue Vorlagen deaktiviert, Aktivieren nur mit `lit/tb-activate`, Guide `theme-builder`), nur bei aktiver, beschreibbarer Gruppe „Divi Theme Builder“.“
|
||
7. §9.3: „(Stand Etappe 3a: …)“ → „(Stand Etappe 3b: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`, `custom-post-type-vorlage` im Guide `theme-builder`.)“
|
||
8. §12, Zeile Etappe 3, Spalte Ergebnis: „CPT-Vorlagen bauen, globale Module einführen“ → „CPT-Vorlagen bauen, globale Module einführen (3a Bibliothek, 3b Theme Builder und Konsolidierung)“.
|
||
|
||
- [ ] **Step 7: Entscheidungsnotiz**
|
||
|
||
In `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` am Ende:
|
||
|
||
```markdown
|
||
## 7. Umsetzung Etappe 3b (Plan 2026-09-27-etappe-3b-theme-builder.md)
|
||
|
||
- E7 präzisiert: Layouts und neue Vorlagen über `et_theme_builder_insert_layout`/`et_theme_builder_store_template`
|
||
mit Rechteprüfung davor und `wp_die`-Sperre währenddessen; Zuordnung zur Live-Liste, Bedingungen und
|
||
`_et_enabled` als Nachbildung von Divis Meta-Schreibweise (store_template würde bei bestehenden Vorlagen auch
|
||
Standard-Kennzeichen, Titel und Onboarding-Meta neu schreiben). Nach dem Anlegen Rücklesen; Abweichung =
|
||
alles zurücknehmen.
|
||
- E8/Antwort F1 umgesetzt als eigenes Tool `lit/tb-activate` (statt `enabled` in `lit/tb-assign`). Jede andere
|
||
Änderung, die Besucher sofort sähen (Bedingungen aktiver Vorlagen, Layouts aktiver Vorlagen), ist zuerst ein
|
||
Probelauf und braucht `confirm`.
|
||
- TB-Editor: Ablehnung bei TB-Entwurf; neue Vorlagen hängen sofort an der Live-Liste; `lit/tb-list` zeigt vom
|
||
Theme Builder entfernte Vorlagen (`detached`), `lit/tb-assign` mit `reattach` ordnet sie deaktiviert wieder zu.
|
||
Befunde 10–12: `tests/Fixtures/divi/stage3b/README.md`.
|
||
- E9: eigene Gruppe „Divi Theme Builder“ (`divi-theme-builder`); alle neuen Tools indirekt, 22 direkte Tools.
|
||
- E10/E11 umgesetzt mit `Fingerprint` (Struktur/Inhalt); ersetzt werden nur inhaltsgleiche Vorkommen auf Seiten
|
||
und Beiträgen, die das Konto bearbeiten darf; D10-Semantik beim Speichern mehrerer Seiten.
|
||
- E12: Feldprüfung im Inhalt-Layout als Warnung gegen die Inhaltstypen der Vorlagen-Bedingungen.
|
||
- P13 (vollständiges Ausblenden der Bibliotheks-IDs) und P10 (Zielregeln der Vollform `post_link_url_*`)
|
||
umgesetzt.
|
||
- Strenger als Divi: Autoren-Bedingungen (`archive:user:id:`) nur mit `list_users`.
|
||
```
|
||
|
||
- [ ] **Step 8: Alle Tests, Lint, Guide-Prüfungen**
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: PASS (inkl. `ToolNamingTest`, `PluginHeaderTest`, `InstructionsBuilderTest`, `GuideRepositoryTest`).
|
||
|
||
Run: `grep -rn -i "claude\|chatgpt\|chatbot" docs/guides readme.txt src --include=*.md --include=*.txt --include=*.php | grep -v "Claude-Session\|claude.ai/code" ; echo "exit $?"`
|
||
Expected: keine Treffer (nur `exit 1`).
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add docs/guides src/Guides/InstructionsBuilder.php src/Plugin.php wp-lit-mcp.php readme.txt docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md tests/Unit/Guides/InstructionsBuilderTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit `docs: Theme Builder guide, instructions, readme and spec for 0.5.0`.
|
||
|
||
---
|
||
### Task 17: Ende-zu-Ende-Smoke `tb-smoke.sh` und Abnahme-Notiz (Schritte 3–5 brauchen Pods)
|
||
|
||
Prüft über den OAuth-Endpunkt mit echten Divi-Funktionen, nur mit temporären Daten (Titel „LIT Smoke <Lauf> …“), was die Unit-Tests nicht können: Divis Theme Builder, die Darstellung für Besucher vor/nach der Aktivierung, Rechte eines Redakteurs ohne Divi-Recht „Theme Builder“, Tools-Tab-Gates, Pods-Felder, find-similar/make-global gegen die echte Datenbank, P13 und P10. Die Aufräumfunktion entfernt Vorlagen aus der Live-Liste, löscht alle angelegten Posts nach ID und stellt Optionen wieder her.
|
||
|
||
**Files:**
|
||
- Create: `tests/Integration/tb-smoke.sh`
|
||
- Create: `docs/superpowers/notes/2026-09-27-etappe-3b-abnahme.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `tests/Integration/lib.sh` (`connect`, `rpc`, `tool`, `ability`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`, `HOST`, `PLUGIN`), alle Tools aus Tasks 2–15, `tests/Fixtures/divi/stage3/library--library-abschnitt.html`, Pods-Pod `lit_person` (Task 1).
|
||
- Produces: `ALL CHECKS PASSED`; mit `REQUIRE_PODS=1` bricht das Skript ohne aktives Pods ab, sonst überspringt es den Pods-Abschnitt mit `SKIP`.
|
||
|
||
- [ ] **Step 1: Skript schreiben**
|
||
|
||
`tests/Integration/tb-smoke.sh` (ausführbar, `chmod +x`):
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# End-to-end checks of Etappe 3b (Theme Builder, find-similar, make-global, P10, P13) through the OAuth-protected
|
||
# endpoint. Runs INSIDE the DDEV web container (Divi 5 must be the active theme):
|
||
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/tb-smoke.sh
|
||
# REQUIRE_PODS=1 makes the Pods section mandatory (else it is skipped without Pods).
|
||
# Creates only temporary data titled "LIT Smoke <run> …" (pages, Pods persons, Theme Builder templates and
|
||
# layouts, library entries), a user of the built-in role "editor" (Divi's "Theme Builder" option is off for it by
|
||
# default) and changes the options lit_mcp_allowed_roles/lit_mcp_exposure. The EXIT trap removes every created
|
||
# template from the live Theme Builder list, deletes all created posts by ID (with revisions), the TB draft it
|
||
# may have created, change-log rows, the user and its app passwords, restores the options and clears Divi's
|
||
# caches. [Fixture] content is only read.
|
||
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)
|
||
STAGE3=$PLUGIN/tests/Fixtures/divi/stage3
|
||
RUN="$(date +%s)-$$"
|
||
PREFIX="LIT Smoke $RUN"
|
||
EDITOR_LOGIN="lit-tb-editor-$RUN"
|
||
EDITOR_CREATED=0
|
||
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '__none__')
|
||
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__')
|
||
TB_ID=$(wp eval 'echo (int) et_theme_builder_get_theme_builder_post_id(true, false);')
|
||
CREATED_POSTS=()
|
||
CREATED_TEMPLATES=()
|
||
DRAFT_TB=""
|
||
|
||
restore_option() { # $1 = option, $2 = original JSON or __none__
|
||
if [ "$2" = "__none__" ]; then
|
||
wp option delete "$1" >/dev/null 2>&1 || true
|
||
else
|
||
wp option update "$1" "$2" --format=json >/dev/null 2>&1 || true
|
||
fi
|
||
}
|
||
|
||
cleanup() {
|
||
local found id
|
||
found=$(LIT_PREFIX="$PREFIX " wp eval '
|
||
global $wpdb;
|
||
echo implode(" ", $wpdb->get_col($wpdb->prepare(
|
||
"SELECT ID FROM $wpdb->posts WHERE post_title LIKE %s AND post_type IN (%s, %s, %s, %s, %s, %s, %s)",
|
||
$wpdb->esc_like((string) getenv("LIT_PREFIX")) . "%",
|
||
"page", "et_pb_layout", "et_template", "et_header_layout", "et_body_layout", "et_footer_layout", "lit_person"
|
||
)));' 2>/dev/null || true)
|
||
for id in $found; do CREATED_POSTS+=("$id"); done
|
||
for id in "${CREATED_TEMPLATES[@]}"; do
|
||
LIT_ID="$id" wp eval '
|
||
$tb = (int) et_theme_builder_get_theme_builder_post_id(true, false);
|
||
if ($tb > 0) { delete_post_meta($tb, "_et_template", (string) getenv("LIT_ID")); }' >/dev/null 2>&1 || true
|
||
done
|
||
if [ -n "$DRAFT_TB" ]; then wp post delete "$DRAFT_TB" --force >/dev/null 2>&1 || true; fi
|
||
LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]} ${CREATED_TEMPLATES[*]}" wp eval '
|
||
global $wpdb;
|
||
$table = $wpdb->prefix . "lit_mcp_log";
|
||
$u = get_user_by("login", getenv("LIT_LOGIN"));
|
||
if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); }
|
||
foreach (array_unique(array_filter(explode(" ", (string) getenv("LIT_IDS")))) as $id) {
|
||
$wpdb->delete($table, ["object_id" => $id]);
|
||
}' >/dev/null 2>&1 || true
|
||
for id in "${CREATED_POSTS[@]}" "${CREATED_TEMPLATES[@]}"; do
|
||
wp post delete "$id" --force >/dev/null 2>&1 || true
|
||
done
|
||
cleanup_app_passwords_for "$ADMIN_LOGIN"
|
||
if [ "$EDITOR_CREATED" = 1 ]; then
|
||
cleanup_app_passwords_for "$EDITOR_LOGIN"
|
||
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
|
||
fi
|
||
restore_option lit_mcp_allowed_roles "$ORIGINAL_ROLES"
|
||
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
|
||
wp eval 'et_theme_builder_clear_wp_cache("all"); ET_Core_PageResource::remove_static_resources("all", "all", true);' \
|
||
>/dev/null 2>&1 || true
|
||
}
|
||
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
|
||
|
||
create_post() { # $1 = post type, $2 = title suffix, $3 = status, $4 = markup (optional) -> ID
|
||
LIT_TYPE="$1" LIT_TITLE="$PREFIX $2" LIT_STATUS="$3" LIT_CONTENT="${4:-}" wp --user="$ADMIN_LOGIN" eval '
|
||
$id = wp_insert_post(["post_type" => getenv("LIT_TYPE"), "post_status" => getenv("LIT_STATUS"),
|
||
"post_title" => getenv("LIT_TITLE"), "post_content" => wp_slash((string) getenv("LIT_CONTENT"))]);
|
||
update_post_meta($id, "_et_pb_use_builder", "on");
|
||
update_post_meta($id, "_et_pb_use_divi_5", "on");
|
||
echo $id;'
|
||
}
|
||
|
||
content_of() { wp eval "echo (string) get_post($1)->post_content;"; }
|
||
content_hash() { wp eval "echo hash('sha256', (string) get_post($1)->post_content);"; }
|
||
rev_of() { tool lit-divi-outline "{\"post_id\":$1}" | jq -r '.tree_rev'; }
|
||
enabled_of() { wp post meta get "$1" _et_enabled; }
|
||
tb_list() { # $1 = arguments JSON (default {})
|
||
local args='{}'
|
||
if [ $# -gt 0 ]; then args=$1; fi
|
||
ability lit/tb-list "$args"
|
||
}
|
||
template_rev() { tb_list "{\"template_id\":$1}" | jq -r '.templates[0].template_rev // .detached[0].template_rev'; }
|
||
render_of() { # $1 = post ID -> the front-end HTML a visitor gets
|
||
local path
|
||
path=$(wp eval "echo wp_make_link_relative(get_permalink($1));")
|
||
curl -s --max-time 30 -H "Host: $HOST" -H 'X-Forwarded-Proto: https' "http://localhost$path"
|
||
}
|
||
|
||
[ "$TB_ID" -gt 0 ] || fail "setup: no live Theme Builder post (run Task 1 fixtures or save the Theme Builder once)"
|
||
[ "$(wp post list --post_type=et_theme_builder --post_status=auto-draft --format=count)" = "0" ] \
|
||
|| fail "setup: the Theme Builder has unsaved changes – save or discard them first"
|
||
connect "$ADMIN_LOGIN"
|
||
|
||
# 1. Exposure: still 22 direct lit tools; the six new abilities are indirect and reachable
|
||
LIST=$(rpc tools/list '{}')
|
||
[ "$(echo "$LIST" | jq '[.result.tools[].name | select(startswith("lit-"))] | length')" = "22" ] \
|
||
|| fail "direct lit tools: $(echo "$LIST" | jq -c '[.result.tools[].name]')"
|
||
for a in lit/tb-list lit/tb-create-template lit/tb-assign lit/tb-activate lit/library-find-similar lit/library-make-global; do
|
||
if echo "$LIST" | jq -e --arg t "lit-${a#lit/}" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$a must be indirect"; fi
|
||
tool mcp-adapter-get-ability-info "$(jq -cn --arg a "$a" '{ability_name: $a}')" | jq -e 'has("__error") | not' >/dev/null \
|
||
|| fail "$a is not reachable"
|
||
done
|
||
tool lit-site-status '{}' | jq -e '[.. | objects | .problems? // empty | .[] | select(test("Theme Builder"))] | length == 0' \
|
||
>/dev/null || fail "lit-site-status reports Theme Builder problems"
|
||
pass "exposure: 22 direct tools, six new indirect abilities; no Theme Builder compatibility problem"
|
||
|
||
# 2. Create a template: disabled, attached, body with nodes through Divi's own functions and DiviSaver
|
||
PAGE=$(create_post page "Seite" publish '')
|
||
CREATED_POSTS+=("$PAGE")
|
||
MARKER="LIT Smoke Marker $RUN"
|
||
CREATED=$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Vorlage" --arg m "$MARKER" --argjson p "$PAGE" '{
|
||
title: $t,
|
||
header: {source: "global"},
|
||
body: {source: "new", nodes: [{type: "section", label: "Smoke", modules: [
|
||
{type: "heading", text: "{{post:title}}"}, {type: "text", content: $m}]}]},
|
||
footer: {source: "hidden"},
|
||
use_on: ["singular:post_type:page:id:\($p)"]}')")
|
||
TPL=$(echo "$CREATED" | jq -r '.template_id // empty')
|
||
[ -n "$TPL" ] || fail "tb-create-template: $CREATED"
|
||
CREATED_TEMPLATES+=("$TPL")
|
||
BODY=$(echo "$CREATED" | jq -r '.layouts.body.id')
|
||
CREATED_POSTS+=("$BODY")
|
||
echo "$CREATED" | jq -e '.enabled == false and .layouts.footer.enabled == false and .layouts.header.source == "global"' >/dev/null \
|
||
|| fail "tb-create-template result: $CREATED"
|
||
[ "$(enabled_of "$TPL")" = "0" ] || fail "new template is enabled"
|
||
wp post meta get "$TB_ID" _et_template --format=json | jq -e --arg t "$TPL" 'index($t) != null' >/dev/null \
|
||
|| fail "template $TPL is not on the live Theme Builder list"
|
||
[ "$(wp post get "$BODY" --field=post_type)" = "et_body_layout" ] || fail "body layout type"
|
||
[ "$(wp post meta get "$BODY" _et_pb_use_divi_5)" = "on" ] || fail "body layout without Divi 5 flag"
|
||
content_of "$BODY" | grep -q '^<!-- wp:divi/placeholder -->' || fail "body layout content: $(content_of "$BODY")"
|
||
render_of "$PAGE" | grep -q "$MARKER" && fail "a disabled template is visible"
|
||
pass "tb-create-template: disabled, on the live list, body layout by Divi with the plugin's content, invisible"
|
||
|
||
# 3. The Divi tools on the layout of a disabled template save at once; post-content only in the body
|
||
REV=$(rev_of "$BODY")
|
||
UPD=$(tool lit-divi-insert "$(jq -cn --argjson id "$BODY" --arg rev "$REV" \
|
||
'{post_id: $id, tree_rev: $rev, parent: "s0.r0.c0", nodes: [{type: "post-content"}]}')")
|
||
echo "$UPD" | jq -e '.ok == true and .templates == []' >/dev/null || fail "insert into the body of a disabled template: $UPD"
|
||
pass "Divi tools write to the body of a disabled template without confirm; post-content allowed in the body"
|
||
|
||
# 4. Activation: dry run, then confirm; visitors see the template only afterwards
|
||
DRY=$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" '{template_id: $id, template_rev: $rev, enabled: true}')")
|
||
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "activation without confirm: $DRY"
|
||
[ "$(enabled_of "$TPL")" = "0" ] || fail "dry run enabled the template"
|
||
ON=$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" '{template_id: $id, template_rev: $rev, enabled: true, confirm: true}')")
|
||
echo "$ON" | jq -e '.enabled == true and .live == true' >/dev/null || fail "activation: $ON"
|
||
[ "$(enabled_of "$TPL")" = "1" ] || fail "template not enabled"
|
||
render_of "$PAGE" | grep -q "$MARKER" || fail "the active template is not rendered for its page"
|
||
pass "tb-activate: dry run first, then enabled; the page shows the template"
|
||
|
||
# 5. A visible layout: every change is a dry run first
|
||
HASH=$(content_hash "$BODY")
|
||
LIVE=$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" --arg m "$MARKER geändert" \
|
||
'{post_id: $id, tree_rev: $rev, changes: [{id: "s0.r0.c0.m1", set: {content: $m}}]}')")
|
||
[ "$(error_code "$LIVE")" = "confirm_required" ] || fail "visible layout changed without confirm: $LIVE"
|
||
[ "$(content_hash "$BODY")" = "$HASH" ] || fail "dry run wrote the layout"
|
||
DONE=$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" --arg m "$MARKER geändert" \
|
||
'{post_id: $id, tree_rev: $rev, confirm: true, changes: [{id: "s0.r0.c0.m1", set: {content: $m}}]}')")
|
||
echo "$DONE" | jq -e --argjson t "$TPL" '.ok == true and (.templates | map(.id) | index($t) != null)' >/dev/null \
|
||
|| fail "confirmed layout change: $DONE"
|
||
render_of "$PAGE" | grep -q "$MARKER geändert" || fail "the changed layout is not rendered"
|
||
ASSIGN=$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
|
||
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)", "search"]}')")
|
||
[ "$(error_code "$ASSIGN")" = "confirm_required" ] || fail "conditions of an active template changed without confirm: $ASSIGN"
|
||
[ "$(wp post meta get "$TPL" _et_use_on)" = "singular:post_type:page:id:$PAGE" ] || fail "dry run changed the conditions"
|
||
pass "visible layout and active conditions: dry run first; the confirmed change is rendered"
|
||
|
||
# 6. Deactivation, the default template stays untouched, a TB draft blocks every TB write
|
||
OFF=$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" '{template_id: $id, template_rev: $rev, enabled: false, confirm: true}')")
|
||
echo "$OFF" | jq -e '.enabled == false' >/dev/null || fail "deactivation: $OFF"
|
||
render_of "$PAGE" | grep -q "$MARKER" && fail "a deactivated template is still rendered"
|
||
DEFAULT=$(tb_list | jq -r '[.templates[] | select(.default)][0].id // empty')
|
||
if [ -n "$DEFAULT" ]; then
|
||
[ "$(error_code "$(ability lit/tb-activate "$(jq -cn --argjson id "$DEFAULT" --arg rev "$(template_rev "$DEFAULT")" \
|
||
'{template_id: $id, template_rev: $rev, enabled: true, confirm: true}')")")" = "unsupported" ] || fail "default template changed"
|
||
fi
|
||
DRAFT_TB=$(wp eval 'echo (int) wp_insert_post(["post_type" => "et_theme_builder", "post_status" => "auto-draft", "post_title" => "Theme Builder"]);')
|
||
[ "$(error_code "$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Entwurf" '{title: $t}')")")" = "unsupported" ] \
|
||
|| fail "tb-create-template despite a TB draft"
|
||
[ "$(error_code "$(ability lit/tb-activate "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" '{template_id: $id, template_rev: $rev, enabled: true, confirm: true}')")")" = "unsupported" ] \
|
||
|| fail "tb-activate despite a TB draft"
|
||
wp post delete "$DRAFT_TB" --force >/dev/null
|
||
DRAFT_TB=""
|
||
pass "deactivated and invisible; default template refused; a Theme Builder draft blocks create and activate"
|
||
|
||
# 7. Detached template (a stale Theme Builder save removed it): listed, reattached disabled
|
||
wp eval "delete_post_meta($TB_ID, '_et_template', '$TPL'); update_post_meta($TPL, '_et_theme_builder_marked_as_unused', '2026-09-27 00:00:00');" >/dev/null
|
||
tb_list | jq -e --argjson t "$TPL" '.detached | map(.id) | index($t) != null' >/dev/null || fail "detached template not listed"
|
||
RE=$(ability lit/tb-assign "$(jq -cn --argjson id "$TPL" --arg rev "$(template_rev "$TPL")" --argjson p "$PAGE" \
|
||
'{template_id: $id, template_rev: $rev, use_on: ["singular:post_type:page:id:\($p)"], reattach: true}')")
|
||
echo "$RE" | jq -e '.ok == true and .live == false' >/dev/null || fail "reattach: $RE"
|
||
wp post meta get "$TB_ID" _et_template --format=json | jq -e --arg t "$TPL" 'index($t) != null' >/dev/null || fail "not reattached"
|
||
[ -z "$(wp post meta get "$TPL" _et_theme_builder_marked_as_unused 2>/dev/null || true)" ] || fail "unused mark kept"
|
||
pass "detached template listed and reattached (disabled, unmarked)"
|
||
|
||
# 8. Editor without Divi's "Theme Builder" option; Tools-tab gates of the group
|
||
wp option update lit_mcp_allowed_roles '["administrator","editor"]' --format=json >/dev/null
|
||
wp user create "$EDITOR_LOGIN" "$EDITOR_LOGIN@example.invalid" --role=editor >/dev/null
|
||
EDITOR_CREATED=1
|
||
connect "$EDITOR_LOGIN"
|
||
[ "$(error_code "$(ability lit/tb-list '{}')")" = "forbidden" ] || fail "editor lists the Theme Builder"
|
||
[ "$(error_code "$(tool lit-divi-outline "{\"post_id\":$BODY}")")" = "not_found" ] || fail "editor reads a TB layout"
|
||
connect "$ADMIN_LOGIN"
|
||
wp option update lit_mcp_exposure '{"@readonly:divi-theme-builder":true}' --format=json >/dev/null
|
||
connect "$ADMIN_LOGIN"
|
||
[ "$(error_code "$(tool lit-divi-update "$(jq -cn --argjson id "$BODY" --arg rev "$(rev_of "$BODY")" \
|
||
'{post_id: $id, tree_rev: $rev, changes: [{id: "s0.r0.c0.m1", label: "Nur lesen"}]}')")")" = "forbidden" ] \
|
||
|| fail "group read-only: TB layout written"
|
||
wp option update lit_mcp_exposure '{"@group:divi-theme-builder":"disabled"}' --format=json >/dev/null
|
||
connect "$ADMIN_LOGIN"
|
||
[ "$(error_code "$(tool lit-divi-outline "{\"post_id\":$BODY}")")" = "not_found" ] || fail "group disabled: TB layout readable"
|
||
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
|
||
connect "$ADMIN_LOGIN"
|
||
pass "editor without the Divi option: TB forbidden/not_found; group read-only and disabled gate the Divi tools"
|
||
|
||
# 9. Pods fields in a body (Pods active)
|
||
if wp plugin is-active pods >/dev/null 2>&1; then
|
||
wp eval-file "$PLUGIN/tests/Integration/pods-fixture.php" create >/dev/null
|
||
PERSON=$(LIT_TITLE="$PREFIX Person" wp --user="$ADMIN_LOGIN" eval '
|
||
echo (int) pods("lit_person")->add(["post_title" => getenv("LIT_TITLE"), "post_status" => "publish", "role" => "Smoke-Funktion"]);')
|
||
CREATED_POSTS+=("$PERSON")
|
||
PODS=$(ability lit/tb-create-template "$(jq -cn --arg t "$PREFIX Person-Vorlage" --argjson p "$PERSON" '{
|
||
title: $t, header: {source: "default"}, footer: {source: "default"},
|
||
body: {source: "new", nodes: [{type: "section", modules: [
|
||
{type: "heading", text: "{{post:title}}"}, {type: "text", content: "{{field:role}}"},
|
||
{type: "text", content: "{{field:mentor}}"}]}]},
|
||
use_on: ["singular:post_type:lit_person:id:\($p)"]}')")
|
||
PTPL=$(echo "$PODS" | jq -r '.template_id // empty')
|
||
[ -n "$PTPL" ] || fail "Pods template: $PODS"
|
||
CREATED_TEMPLATES+=("$PTPL")
|
||
CREATED_POSTS+=("$(echo "$PODS" | jq -r '.layouts.body.id')")
|
||
echo "$PODS" | jq -e '[.warnings[] | select(contains("„mentor“"))] | length == 1' >/dev/null || fail "no warning for mentor: $PODS"
|
||
echo "$PODS" | jq -e '[.warnings[] | select(contains("„role“"))] | length == 0' >/dev/null || fail "role wrongly warned: $PODS"
|
||
ability lit/tb-activate "$(jq -cn --argjson id "$PTPL" --arg rev "$(template_rev "$PTPL")" '{template_id: $id, template_rev: $rev, enabled: true, confirm: true}')" \
|
||
| jq -e '.enabled == true' >/dev/null || fail "Pods template not activated"
|
||
render_of "$PERSON" | grep -q "Smoke-Funktion" || fail "the Pods field is not rendered in the body"
|
||
ability lit/tb-activate "$(jq -cn --argjson id "$PTPL" --arg rev "$(template_rev "$PTPL")" '{template_id: $id, template_rev: $rev, enabled: false, confirm: true}')" \
|
||
| jq -e '.enabled == false' >/dev/null || fail "Pods template not deactivated"
|
||
pass "Pods: {{field:role}} rendered for the person, relation field mentor warned"
|
||
elif [ "${REQUIRE_PODS:-0}" = 1 ]; then
|
||
fail "Pods is not active (REQUIRE_PODS=1)"
|
||
else
|
||
echo "SKIP - Pods section (Pods inactive)"
|
||
fi
|
||
|
||
# 10. find-similar and make-global against the database
|
||
SECTION=$(tr -d '\r' < "$STAGE3/library--library-abschnitt.html")
|
||
A=$(create_post page "Ähnlich A" draft "$SECTION")
|
||
B=$(create_post page "Ähnlich B" draft "$SECTION")
|
||
CREATED_POSTS+=("$A" "$B")
|
||
SIM=$(ability lit/library-find-similar "$(jq -cn --argjson a "$A" '{post_id: $a, id: "s0", post_type: "page", limit: 200}')")
|
||
echo "$SIM" | jq -e --argjson b "$B" '[.matches[] | select(.post_id == $b and .id == "s0" and .match == "identical")] | length == 1' >/dev/null \
|
||
|| fail "find-similar: $SIM"
|
||
MG=$(ability lit/library-make-global "$(jq -cn --argjson a "$A" --arg rev "$(rev_of "$A")" --arg t "$PREFIX Global" \
|
||
'{post_id: $a, tree_rev: $rev, id: "s0", title: $t}')")
|
||
LIB=$(echo "$MG" | jq -r '.library_id // empty')
|
||
[ -n "$LIB" ] || fail "make-global: $MG"
|
||
CREATED_POSTS+=("$LIB")
|
||
[ "$(content_of "$LIB")" = "$SECTION" ] || fail "global entry bytes differ from the section"
|
||
content_of "$A" | grep -q "\"globalModule\":\"$LIB\",\"blockName\":\"divi/section\"" || fail "page A holds no instance"
|
||
BHASH=$(content_hash "$B")
|
||
TARGETS=$(jq -cn --argjson lib "$LIB" --argjson b "$B" --arg rev "$(rev_of "$B")" '{library_id: $lib, targets: [{post_id: $b, tree_rev: $rev, ids: ["s0"]}]}')
|
||
[ "$(error_code "$(ability lit/library-make-global "$TARGETS")")" = "confirm_required" ] || fail "replace without dry run"
|
||
[ "$(content_hash "$B")" = "$BHASH" ] || fail "dry run changed page B"
|
||
ability lit/library-make-global "$(echo "$TARGETS" | jq -c '. + {confirm: true}')" | jq -e '.pages[0].revision_id > 0' >/dev/null \
|
||
|| fail "replace occurrences"
|
||
content_of "$B" | grep -q "\"globalModule\":\"$LIB\"" || fail "page B holds no instance"
|
||
pass "find-similar finds the identical section; make-global creates the entry, replaces A, then B after dry run"
|
||
|
||
# 11. P13: with the library group disabled no library ID is named; P10: full-form link type must match
|
||
wp option update lit_mcp_exposure '{"@group:divi-library":"disabled"}' --format=json >/dev/null
|
||
connect "$ADMIN_LOGIN"
|
||
OUT=$(tool lit-divi-outline "{\"post_id\":$A}")
|
||
echo "$OUT" | grep -q "$LIB" && fail "outline names library ID $LIB with the library hidden"
|
||
tool lit-divi-node-get "{\"post_id\":$A,\"id\":\"g0\"}" | grep -q "$LIB" && fail "node-get names library ID $LIB"
|
||
tool lit-page-render-summary "{\"post_id\":$A}" | grep -q "$LIB" && fail "render summary names library ID $LIB"
|
||
restore_option lit_mcp_exposure "$ORIGINAL_EXPOSURE"
|
||
connect "$ADMIN_LOGIN"
|
||
POST=$(create_post post "Beitrag" publish '')
|
||
CREATED_POSTS+=("$POST")
|
||
LINK="\$variable({\"type\":\"content\",\"value\":{\"name\":\"post_link_url_page\",\"settings\":{\"post_id\":\"$POST\"}}})\$"
|
||
BAD=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE" --arg rev "$(rev_of "$PAGE")" --arg l "$LINK" \
|
||
'{post_id: $id, tree_rev: $rev, nodes: [{type: "section", modules: [{type: "button", text: "Mehr", url: $l}]}]}')")
|
||
[ "$(error_code "$BAD")" = "validation_failed" ] || fail "post_link_url_page accepted a post: $BAD"
|
||
pass "P13: no library ID without library access; P10: post_link_url_page refuses a post as target"
|
||
|
||
finish
|
||
```
|
||
|
||
- [ ] **Step 2: Syntax prüfen**
|
||
|
||
Run: `bash -n tests/Integration/tb-smoke.sh && chmod +x tests/Integration/tb-smoke.sh && echo ok`
|
||
Expected: `ok`
|
||
|
||
- [ ] **Step 3: Pods-Freigabe prüfen (braucht Pods)**
|
||
|
||
Der Controller fragt den Benutzer, falls Pods seit Task 1 wieder deaktiviert wurde, ob es für den Smoke-Lauf aktiviert werden darf.
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev wp plugin is-active pods && echo aktiv`
|
||
Expected: `aktiv`
|
||
|
||
- [ ] **Step 4: Smoke ausführen (braucht Pods)**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec env REQUIRE_PODS=1 bash wp-content/plugins/WP-LIT-MCP/tests/Integration/tb-smoke.sh`
|
||
Expected: elf `ok - …`-Zeilen und `ALL CHECKS PASSED`. Danach: `ddev wp post list --post_type=et_template,et_body_layout,page,et_pb_layout,lit_person --s="LIT Smoke" --format=count` → `0`; `ddev wp post meta get <TB-ID> _et_template --format=json` enthält nur die IDs von vorher.
|
||
|
||
Schlägt ein `render_of`-Check mit einer Weiterleitung fehl (leere Antwort), `curl -sI` auf dieselbe URL ausführen, das Weiterleitungsziel im Bericht nennen und den Controller entscheiden lassen – nicht den Check abschwächen.
|
||
|
||
- [ ] **Step 5: Alle Smokes (braucht Pods für `tb-smoke.sh`)**
|
||
|
||
Run:
|
||
```bash
|
||
cd /home/janniclas/Projekte/WordPress
|
||
for s in mcp-smoke wp-basis-smoke media-admin-smoke divi-read-smoke divi-write-smoke divi-library-smoke tb-smoke; do
|
||
timeout 900 ddev exec env REQUIRE_PODS=1 bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh | tail -1
|
||
done
|
||
```
|
||
Expected: siebenmal `ALL CHECKS PASSED`.
|
||
|
||
- [ ] **Step 6: Abnahme-Notiz**
|
||
|
||
`docs/superpowers/notes/2026-09-27-etappe-3b-abnahme.md`:
|
||
|
||
```markdown
|
||
# Etappe 3b – Manuelle Abnahme (Theme Builder, ähnliche Abschnitte, globale Module umwandeln)
|
||
|
||
Automatisch geprüft sind bereits:
|
||
|
||
- alle Unit-Tests (`composer test`) und Lint (`composer lint`);
|
||
- `tb-smoke.sh` (neu) und die sechs bisherigen Integrationsskripte (`tests/Integration/`).
|
||
|
||
`tb-smoke.sh` prüft über den OAuth-Endpunkt, nur mit temporären Daten, die es danach wieder löscht:
|
||
|
||
- Werkzeugliste: weiterhin 22 direkte Tools; `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`,
|
||
`lit/tb-activate`, `lit/library-find-similar`, `lit/library-make-global` indirekt; `lit-site-status` ohne
|
||
Theme-Builder-Befund;
|
||
- neue Vorlage: deaktiviert, in der Live-Liste des Theme Builders, Inhalt-Layout über Divis Funktionen mit dem
|
||
Inhalt des Plugins, für Besucher unsichtbar;
|
||
- Aktivieren nur nach Probelauf; danach zeigt die Seite die Vorlage; Änderungen an einem sichtbaren Layout und an
|
||
Bedingungen einer aktiven Vorlage erst nach Probelauf; Deaktivieren; Standardvorlage abgelehnt; ein
|
||
Theme-Builder-Entwurf sperrt Anlegen und Aktivieren; eine entfernte Vorlage wird gelistet und deaktiviert
|
||
wieder zugeordnet;
|
||
- Redakteur ohne Divi-Recht „Theme Builder“: kein Zugriff; Gruppe „nur lesen“ und „deaktiviert“ wirken auch auf
|
||
die Divi-Werkzeuge;
|
||
- Pods: `{{field:role}}` erscheint für die Person, das Beziehungsfeld erzeugt eine Warnung;
|
||
- `lit/library-find-similar` und `lit/library-make-global` (Umwandeln, dann Ersetzen nach Probelauf);
|
||
- P13 (keine Bibliotheks-IDs ohne Bibliothekszugang) und P10 (`post_link_url_page` nicht auf Beiträge).
|
||
|
||
Die folgenden Punkte brauchen den echten Theme Builder bzw. einen verbundenen KI-Assistenten. Abweichungen als
|
||
kurze Notiz zurückmelden; sie werden Folgeaufgaben.
|
||
|
||
## Vorbereitung
|
||
|
||
- [ ] DDEV läuft, Divi 5.2.1 ist aktiv, Pods ist aktiv, LIT MCP zeigt Version 0.5.0.
|
||
- [ ] Unter Einstellungen → LIT MCP → Tools stehen „Divi“, „Divi-Bibliothek“ und „Divi Theme Builder“ auf ihrem
|
||
Standard.
|
||
- [ ] Den KI-Assistenten neu verbinden; in einer neuen Unterhaltung nennt der Server-Hinweis den Theme Builder.
|
||
|
||
## Theme Builder
|
||
|
||
- [ ] Den Assistenten bitten: „Lege eine Vorlage für Personen an: globale Kopfzeile, im Inhalt Name, Funktion und
|
||
Stufe, Fußzeile wie bisher.“ Erwartet: er liest `lit/divi-dynamic-sources` für `lit_person`, legt die Vorlage
|
||
mit `lit/tb-create-template` an und sagt, dass sie deaktiviert ist.
|
||
- [ ] Divi → Theme Builder öffnen: die Vorlage steht in der Liste, deaktiviert, Bedingung „Personen → Alle
|
||
Personen“, Kopfzeile = globale Kopfzeile. Das Inhalt-Layout im Builder öffnen: Module mit Dynamic Content
|
||
„Funktion“ und „Stufe“ sind als solche erkennbar und bearbeitbar.
|
||
- [ ] Im Theme Builder eine harmlose Änderung an einer anderen Vorlage speichern: die neue Vorlage bleibt in der
|
||
Liste.
|
||
- [ ] Den Assistenten bitten, die Vorlage zu aktivieren: er zeigt zuerst den Probelauf (Bedingungen,
|
||
Überschneidungen) und fragt nach. Nach Zustimmung: eine Person im Frontend zeigt die Vorlage.
|
||
- [ ] Den Assistenten bitten, im Inhalt-Layout eine Überschrift zu ändern: er meldet, dass die Vorlage aktiv ist,
|
||
und fragt nach, bevor er speichert.
|
||
- [ ] Vorlage wieder deaktivieren lassen; im Theme Builder ist sie deaktiviert.
|
||
- [ ] Als Redakteur verbinden: der Assistent sieht keine Vorlagen und erklärt, dass das Divi-Recht „Theme Builder“
|
||
fehlt.
|
||
|
||
## Ähnliche Abschnitte und globale Module
|
||
|
||
- [ ] Zwei Seiten mit demselben Abschnitt bauen; den Assistenten bitten, wiederkehrende Abschnitte zu globalen
|
||
Modulen zu machen. Erwartet: `lit/library-find-similar`, Vorschlag, nach Zustimmung
|
||
`lit/library-make-global`, Probelauf für die zweite Seite, dann Ersetzen.
|
||
- [ ] Beide Seiten im Visual Builder öffnen: der Abschnitt ist ein globales Modul (grün markiert); eine Änderung
|
||
im Builder erscheint auf beiden Seiten.
|
||
- [ ] Einen neuen Abschnitt einfügen lassen, der wie ein Bibliotheksabschnitt aufgebaut ist: die Antwort weist auf
|
||
den Bibliothekseintrag hin.
|
||
|
||
## Aufräumen
|
||
|
||
- [ ] Angelegte Vorlagen im Theme Builder löschen und speichern; Testseiten und Bibliothekseinträge löschen.
|
||
- [ ] Pods wieder deaktivieren, falls gewünscht (der Controller fragt).
|
||
```
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add tests/Integration/tb-smoke.sh docs/superpowers/notes/2026-09-27-etappe-3b-abnahme.md
|
||
```
|
||
Commit wie in den Global Constraints mit `test: Etappe 3b end-to-end smoke and acceptance checklist`.
|
||
|
||
- [ ] **Step 8: Pods-Rückfrage**
|
||
|
||
Der Controller fragt den Benutzer, ob Pods in DDEV wieder deaktiviert werden soll (`ddev wp plugin deactivate pods`); die Pods-Fixture-Daten aus Task 1 bleiben als `[Fixture 3b]` stehen, bis der Benutzer das Aufräumen laut `tests/Fixtures/divi/stage3b/README.md` wünscht.
|
||
|
||
---
|
||
|
||
## Abschluss (nach Task 17)
|
||
|
||
Wie 3a: Whole-Branch-Review und Security-Audit (parallel) über den Bereich e049959..HEAD mit Schwerpunkt Review Focus 1–5, Objekt-Rechte des Theme Builders, Bedingungen als Eingabe in `_et_use_on`, `wp_die`-Sperre, Mehrseiten-Ersetzen; Rulings und Befunde in `docs/superpowers/notes/2026-09-27-etappe-3b-rulings.md`, Audit in `…-etappe-3b-security-audit.md`; danach Fix-Welle und Praxistest mit dem KI-Assistenten.
|