Files
WP-LIT-MCP/docs/superpowers/plans/2026-09-27-etappe-3b-theme-builder.md
T
2026-09-27 12:16:15 +02:00

391 KiB
Raw Blame History

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

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:

 *   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:

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:
 *   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]").
  1. Die beiden ersten Variablen ersetzen:
$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;
  1. In $lit_role die Präfix-Entfernung allgemein machen und Personen benennen:
    $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:

    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:

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:
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;'
  1. In Tab A eine harmlose Änderung machen (Titel einer Fixture-Vorlage ändern und zurück) und speichern.
  2. 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
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

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):

    /** 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:

    /** 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

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():

    /**
     * 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():

    public function linkTargetType(int $id): ?string
    {
        return (new WpLinkTargets())->postTypeOf($id);
    }

In tests/Support/ComposeFixture.php im anonymen Sanitizer nach canReadPost():

            /** 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():

            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):
    /** Divi's internal link options: post_link_url_<post type> (F27). */
    public const LINK_PREFIX = 'post_link_url_';
  1. Signatur und Aufruf in cleanSetting():
    public static function cleanSetting(
        string $key,
        mixed $value,
        string $fieldType,
        ValueSanitizer $sanitizer,
        string $option = ''
    ): mixed {

und im match:

            'select_post' => self::selectPostSetting($key, $value, $sanitizer, $option),
  1. selectPostSetting() vollständig ersetzen:
    /**
     * 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:

            $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:

        $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
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:

    /** 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:

    /** 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:

    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:

    /** 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():

    /**
     * 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():

    /** 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():

    /**
     * @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:

            'global_id' => $this->globalIds ? ($global['id'] ?? null) : null,

DiviOutlineAbility::execute(): new Outline($this->design->read()->presetNames()) ersetzen durch

        $outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());

DiviWriter::outline(): ebenso

        $outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible());

DiviNodeGetAbility::execute(): den Block ab $global = NodeSummary::globalRef($block); bis vor return [ ersetzen:

        $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:

    /**
     * @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:

        $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():

            new PageRenderSummaryAbility($this->schema, new PageRenderer(), $this->globalIdsVisible),

DiviIntegration ergänzen:

    /** 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

            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
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

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

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

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

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:

    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

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

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

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;):

            '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
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:

        '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:

    public function testThemeBuilderAreasFromDivisAppearance(): void
    {
        $catalog = DiviFixtures::catalog();

        self::assertSame(['body'], $catalog->themeBuilderAreas('divi/post-content'));
        self::assertNull($catalog->themeBuilderAreas('divi/text'));
    }

StructureRulesTest ergänzen:

    /** 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

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;):

    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

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

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():

    /**
     * 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):

    /** @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:

        $area = $this->areaViolation($child);
        if ($area !== null) {
            return $area;
        }

und als private Methode:

    /** 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):

    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():

    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:

        // 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:

        $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:

        $builderPost = $page->isLibraryItem() || $page->isThemeBuilderLayout();
        if (! $builderPost && ! $this->schema->diviEnabled((string) $post->post_type)) {

SaveScope:

    /** 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():

    /** 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():

        return new StructureRules($this->modules->catalog(), $this->themeBuilderArea);
  • Step 6: ThemeBuilderEdits

src/Integrations/Divi/ThemeBuilder/ThemeBuilderEdits.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():

        return $this->composition->forPost((int) $page->post->ID, $page->themeBuilderArea());

In commit() als erste Zeilen:

        if ($page->isThemeBuilderLayout()) {
            return $this->commitLayout($ability, $page, $change, $input);
        }

neue Methode:

    /**
     * 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():

    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:

        ?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:

        $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(:

        // 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;):

    /** 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
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

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

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
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

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):

    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

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

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;):

    /** 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
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

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

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

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

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

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():

        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
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;):

    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

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():

    /**
     * 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

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
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

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

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
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

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:

    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;):

    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

        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:

    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:

    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

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

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():

    /** 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:

    /** @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

    public function __construct(private ThemeBuilderAccess $access, private ?DynamicFields $fields = null)
    {
    }

und neue Methoden:

    /**
     * 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

        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):

                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():

        $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
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

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

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

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

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:

        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
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

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):

    /** 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

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

        $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
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

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

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

        $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):

    /**
     * @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

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:

        $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
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;):

    /** 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:
    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:

            . ' 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.';
  1. inputSchema(): required leeren ('required' => []) und die Eigenschaften ergänzen:
                '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:

        $refusal = $this->access->canInsert()
            ?? (isset($input['library_id']) ? null : $this->access->canSave(true));
  1. execute():
        return isset($input['library_id']) ? $this->replaceOccurrences($input) : $this->makeGlobal($input);
  1. 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;):
    /**
     * @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
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:

    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

    /** 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 = …;:

        $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:

# 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:
- `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.
  1. Den Absatz ab „Grenze: Die Einträge selbst sind dann nicht lesbar …“ bis „… dann nicht weiter versuchen.“ ersetzen durch:
  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.
  1. 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.“
  2. „Ablauf: globale Module vorschlagen“ ersetzen:
## 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:

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:
## 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`).
  1. Neuer Abschnitt nach „Einträge eines eigenen Inhaltstyps (Pods)“:
## 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:

* 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:

* 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:

= 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-similarum „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:

## 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
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 …“), 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):

#!/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:

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:

# 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
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.