Files
WP-LIT-MCP/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md
T
2026-09-27 00:10:27 +02:00

313 KiB
Raw Blame History

Etappe 3a – Divi-Bibliothek: Auflisten, Speichern, Einfügen, Kopieren, globale Module bearbeiten – 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 arbeitet mit der Divi-Bibliothek: Einträge auflisten und lesen (lit/library-list), Knoten als normalen oder globalen Eintrag speichern (lit/library-save), Einträge als Kopie oder als globale Instanz im Format des Visual Builders einfügen (lit/library-insert, direkt), Knoten zwischen Seiten kopieren (lit/divi-node-copy), Bibliothekseinträge – auch globale, mit Probelauf, confirm und Sicherung – mit den vorhandenen Divi-Werkzeugen bearbeiten, verfügbare Dynamic-Content-Quellen abfragen (lit/divi-dynamic-sources); dazu die Übernahmen aus Etappe 2 ({{link:ID}}, lit-guide mit allen Themen, S19) und der Hinweis auf globale Module in lit/page-render-summary.

Architecture: Reines PHP in Divi/BlockTree/ bekommt die Bausteine für globale Module (Block::copyAs(), GlobalInstance – Instanz im Builder-Format und Entglobalisieren wie Divis maybe_deglobalize_nested_global_modules –, LibraryContent – Rahmen divi/placeholder, Wurzeln, Formregeln). Divi-Interna liegen hinter Adaptern mit Live-Kompatibilitätsprüfung (Entscheidung E15): DiviRoles (et_pb_is_allowed), DiviLibraryStore (Post-Typ et_pb_layout, Terme, Meta, GlobalLayout::is_global_layout_template), DiviCompatibility (Befund in lit/site-status). LibraryAccess bündelt die Rechte je Objekt (E3) und wird in DiviPageLoader eingehängt, damit alle Divi-Werkzeuge Bibliotheks-IDs annehmen (E1); GlobalEdits ergänzt DiviWriter::commit() um Formregeln, Probelauf mit betroffenen Seiten, confirm und Sicherung (E2, Antwort F2). Jeder neue Weg nach post_content läuft weiter über Composer/TreeEditor/DiviSaver; übernommene gespeicherte Knoten prüft StoredNodeGuard gegen die S7/S26-Sperren (E6). Die neuen Tools bilden die Gruppe „Divi-Bibliothek“ (LibraryIntegration), lit/divi-node-copy und lit/divi-dynamic-sources gehören zur Gruppe „Divi“.

Tech Stack: PHP ≥ 8.1, WordPress 7.x (Abilities API, wp_insert_post/wp_update_post, Revisionen, kses, Taxonomien), Divi 5.2.1 (et_pb_layout, et_pb_is_allowed, GlobalLayout, DynamicContentOptions, ET_Core_PageResource), wordpress/mcp-adapter 0.5, wp-media/mcp-oauth 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes.

Spec: docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (insb. §4.3, §4.5, §5.1–5.7, §8.1–8.2, §9, §10, §12 Etappe 3) und die Entscheidungen docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (Fakten F1–F32, Empfehlungen E1–E17, Schnittstellen-Einordnung Abschnitt 5; die Antworten des Benutzers in „5. Antworten des Benutzers“ sind verbindlich), dazu die Rulings 2026-09-26-etappe-{1a,1b,2a,2b}-rulings.md, 2026-09-26-etappe-2-entscheidungen.md (D1–D12, R2), Security-Audit und Praxistest 2b sowie die Fixtures tests/Fixtures/divi/stage3/ mit README.md (Commit e7cc4ef).

Voraussetzung: Branch etappe-3 mit Stand 30788f2 (Etappe 2b vollständig, 1597 Tests grün). Pods ist lokal deaktiviert; lit/divi-dynamic-sources wird für Pods über Unit-Tests abgesichert.

Global Constraints

  • Dateien nur im Plugin-Verzeichnis /home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Seiten, Bibliothekseinträge, Benutzer, Rollen und die Optionen lit_mcp_allowed_roles/lit_mcp_exposure wieder auf.
  • 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 (Spec §4.4). WordPress/Divi nur in Divi/Pages/, Divi/Library/, Divi/Design/, Divi/Schema/ und in DiviCompatibility.
  • Eine Engstelle nach post_content (D7, S39, E17): Jeder neue Schreibweg in post_content – Seiten wie Bibliothekseinträge – geht über Composer/AttrWriter/ShortformWriter/ValueCleaner (Client-Werte) bzw. TreeEditor (Struktur) und wird ausschließlich von DiviSaver::save()/saveNew() gespeichert (kses-Fenster nur dort). Abilities rufen setAttrs()/insertChild() nie mit Client-Werten auf. Gespeicherte Knoten, die den Post wechseln (Seite → Seite, Bibliothek → Seite, Seite → Bibliothek), laufen durch StoredNodeGuard (S7/S26-Pfade, Code-Modul, $variable-Einstellungen) – für Benutzer ohne unfiltered_html wird abgelehnt statt bereinigt; alles andere bleibt byte-identisch. Keine Builder-Route (divi/v1/…), kein et_pb_submit_layout/et_pb_create_layout.
  • Rechte je Objekt (Spec §4.5, E3 – strenger als Divi, nie lockerer): Bibliothek lesen/auflisten = edit_posts + Divi-Rolle divi_library; einen Eintrag lesen = zusätzlich edit_post auf den Eintrag; speichern/anlegen = + publish_posts des Typs et_pb_layout + save_library; bearbeiten = edit_post + save_library, bei globalen Einträgen + edit_global_library; einfügen = + add_library; Dynamic-Content-Feldliste nur mit read_dynamic_content_custom_fields. Divi-Rollen nur über DiviRoles. Ohne Bibliothekszugang bleiben Bibliotheks-IDs für alle Werkzeuge not_found wie bisher (ContentSchema::EXCLUDED bleibt unverändert).
  • Sperren und Alles-oder-nichts: Jede Divi-Schreibung verlangt tree_rev (Ruling P16); Werkzeuge mit Quellknoten (lit/library-save, lit/divi-node-copy) verlangen zusätzlich die tree_rev der Quelle. Abweichung → rev_mismatch, nichts geschrieben. Erst vollständig validieren, dann einmal speichern; beim Anlegen eines Bibliothekseintrags wird der Eintrag bei jedem Fehler endgültig gelöscht (wie lit/content-create). Unberührte Bytes bleiben identisch (Spec §1.3 Nr. 3, Ausnahme S7).
  • Globale Einträge (Antwort F2, verbindlich): Jede Änderung an einem globalen Bibliothekseintrag ist zuerst ein Probelauf (confirm_required mit affected/affected_count, nichts gespeichert); mit confirm: true wird vorher eine Revision des Eintrags angelegt – sind Revisionen abgeschaltet, eine Sicherung (PostCloner::backup, backup_id) –, danach seitenweit remove_static_resources('all', 'all', true) und et_theme_builder_clear_wp_cache('all') (Spec §5.6 Schritt 5).
  • Exposition: Direkt ist nur lit/library-insert (22 direkte Tools, Spec §4.3). Indirekt: lit/library-list, lit/library-save, lit/divi-node-copy, lit/divi-dynamic-sources. Gruppe „Divi-Bibliothek“ (divi-library) für die drei lit/library-*-Tools; „nur lesen“/deaktiviert dieser Gruppe sperrt auch Schreibzugriffe der Divi-Werkzeuge auf Bibliothekseinträge (ExposurePolicy::groupWritable('divi-library')). lit/library-insert folgt zusätzlich lit/divi-insert (Audit M2). Jeder Task mit neuem direktem Tool aktualisiert docs/guides/overview.md „Direkt sichtbar:“ im selben Task (Ruling Q2); Texte nennen direkte Tools lit-<name>, indirekte lit/<name> (ToolNamingTest). In PHP-Texten nie topic "<x>" für ein Thema ohne docs/guides/<x>.md schreiben (ToolNamingTest).
  • 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; error_log() per ini_set('error_log', …) umleiten. Reine BlockTree-/Compose-Tests erben von PHPUnit\Framework\TestCase. Integrationstests: cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh.
  • composer test braucht mehr als 128 MB (Task 1 behebt das); danach laufen composer test und php vendor/bin/phpunit mit jeder memory_limit-Vorgabe.
  • 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 kurz „Commit wie in den Global Constraints“).

Review Focus

  1. Redakteur ohne unfiltered_html überträgt gespeicherten Admin-Inhalt in einen anderen Post (Abschnitt mit eigenem CSS, HTML-Attributen, Code-Modul oder Dynamic-Content-before/after mit HTML) über lit/divi-node-copy, lit/library-insert oder lit/library-save: Erwartet Ablehnung „nur im Divi-Builder“, nichts gespeichert (E6, Audit M1, R2-Befund 1) → Tests StoredNodeGuardTest (Task 11), DiviNodeCopyAbilityTest::testEditorCannotCarryBuilderOnlyValuesToAnotherPage (Task 14), LibraryInsertAbilityTest::testEditorCannotInsertAnItemWithCustomCss (Task 13), LibrarySaveAbilityTest::testEditorCannotSaveACodeModule (Task 12).
  2. Globaler Eintrag mit Instanzen auf veröffentlichten Seiten wird geändert – auch per lit-divi-delete, das ein eigenes confirm hat: Ohne confirm nichts gespeichert, Antwort nennt die betroffenen Seiten; mit confirm Revision bzw. Sicherung und seitenweite Cache-Leerung → Tests GlobalEditsTest::testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing, ::testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm, ::testWithoutRevisionsABackupIsMadeFirst und DiviSaverTest::testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage (Task 9).
  3. Bibliothekseintrag verliert seine Form (Wurzel gelöscht, zweite Wurzel, anderer Wurzeltyp, globales Modul in einem globalen Eintrag): Instanzen auf allen Seiten würden brechen → abgelehnt → Tests LibraryContentTest::testShapeViolations (Task 7) und GlobalEditsTest::testRefusesToEmptyOrDoubleTheRoot (Task 9).
  4. Rechte: Autor ohne Divi-Rolle divi_library/save_library/add_library/edit_global_library, fremder Eintrag ohne edit_post, Mitarbeiter ohne publish_posts, Gruppe „Divi-Bibliothek“ auf „nur lesen“ – alle erhalten forbidden, nichts wird geschrieben; ohne eingehängten Bibliothekszugang bleiben Bibliotheks-IDs not_found → Tests LibraryAccessTest (Task 8), DiviPageLoaderTest::testLibraryItemsNeedTheLibraryAccess (Task 8), LibrarySaveAbilityTest::testNeedsPublishPostsAndTheDiviRole (Task 12).
  5. Parallele Änderung: Quelle oder Ziel wurde seit dem Lesen geändert (source_tree_rev/tree_rev veraltet), oder der globale Eintrag änderte sich zwischen Probelauf und confirm → rev_mismatch, nichts geschrieben → Tests DiviNodeCopyAbilityTest::testStaleSourceOrTargetWritesNothing (Task 14), LibrarySaveAbilityTest::testStaleSourceTreeRevCreatesNothing (Task 12), GlobalEditsTest::testConfirmWithAStaleTreeRevWritesNothing (Task 9).

File Structure

phpunit.xml.dist                                   (geändert) memory_limit 512M
composer.json                                      (geändert) test-Skript mit memory_limit
wp-lit-mcp.php, src/Plugin.php                     (geändert) Version 0.4.0; Verdrahtung Bibliothek + Gates
readme.txt                                         (geändert) Bibliothek, globale Module, Changelog 0.4.0
docs/guides/
  divi-library.md                                  (neu) Thema „divi-library“
  overview.md, divi-placeholders.md, divi-structure.md, workflows.md, divi-design-system.md (geändert)
docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md               (geändert) §4.3, §5.2, §5.4, §5.7, §9.2
docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md  (geändert) Abschnitt 6 „Umsetzung 3a“
docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md                (neu) manuelle Abnahme
src/
  Abilities/ExposurePolicy.php                     (geändert) groupWritable()
  Guides/InstructionsBuilder.php                   (geändert) Bibliotheks-Regel
  Integrations/Core/EnvironmentProbe.php           (geändert) Divi-Kompatibilitätsbefund
  Integrations/Core/GuideAbility.php               (geändert) topic = all
  Integrations/WordPress/Render/PageRenderSummaryAbility.php (geändert) Hinweis globale Module
  Integrations/Divi/
    DiviIntegration.php                            (geändert) Bibliothekszugang, library(), neue Divi-Tools
    DiviCompatibility.php                          (neu) Live-Prüfung der genutzten Divi-Interna
    BlockTree/
      Block.php                                    (geändert) copyAs()
      Container.php                                (geändert) replaceChild(), unifyLineBreaks()
      NodeSummary.php                              (geändert) globalRef()
      Outline.php                                  (geändert) global_id
      GlobalInstance.php                           (neu) Instanz im Builder-Format, entglobalisieren, effectiveName()
      LibraryContent.php                           (neu) Rahmen, Wurzeln, Formregeln
    Compose/
      LinkTargets.php                              (neu) Schnittstelle für {{link:ID}}
      Placeholders.php                             (geändert) {{link:ID}}
      VariableRef.php                              (geändert) post_link_url_* nur in URL-Feldern, settingsOf()
      StructureRules.php, TreeEditor.php           (geändert) Wurzeltyp globaler Instanzen
      StoredNodeGuard.php                          (neu) S7/S26 für übertragene gespeicherte Knoten
    Design/DiviDesignSystemAbility.php             (geändert) only/module-Filter (S19)
    Pages/
      WpLinkTargets.php                            (neu)
      SaveScope.php                                (neu) Seite / Bibliothekseintrag / globaler Eintrag
      DiviSaver.php                                (geändert) SaveScope: Flags, seitenweiter Cache
      DiviPage.php, DiviPageLoader.php             (geändert) Bibliothekseinträge
      DiviWriter.php, DiviWriteAbility.php         (geändert) commit(), notice(), precheck()
      DiviSchema.php                               (geändert) confirm
      DiviComposition.php                          (geändert) LinkTargets, storedGuard()
      DiviNodeGetAbility.php, DiviOutlineAbility.php (geändert) global
      DynamicContentMap.php                        (geändert) options()
      DiviNodeCopyAbility.php                      (neu) lit/divi-node-copy
      DiviDynamicSourcesAbility.php                (neu) lit/divi-dynamic-sources
    Library/
      DiviRoles.php                                (neu) Adapter et_pb_is_allowed
      DiviLibraryStore.php                         (neu) Adapter et_pb_layout, Terme, Meta
      LibraryAccess.php                            (neu) Rechte je Objekt, Gruppen-Gate
      LibraryItemSpec.php                          (neu) Terme/Meta eines neuen Eintrags
      GlobalUsages.php                             (neu) Seiten mit Instanzen
      GlobalEdits.php                              (neu) Formregeln, Probelauf, confirm, Sicherung
      LibraryIntegration.php                       (neu) Gruppe „Divi-Bibliothek“
      LibraryListAbility.php                       (neu) lit/library-list
      LibrarySaveAbility.php                       (neu) lit/library-save
      LibraryInsertAbility.php                     (neu) lit/library-insert
tests/
  Stubs/EtCorePageResource.php                     (neu) zeichnet remove_static_resources auf
  bootstrap.php                                    (geändert)
  Support/ComposeFixture.php, DiviWriterFixture.php (geändert) links(), Bibliothek
  Support/LibraryFixtures.php                      (neu) Posts nach ID, Store/Rollen/Zugang
  Fixtures/divi/stage3/README.md                   (geändert) Befund 9: CRLF in Bibliothekseinträgen
  Unit/…                                           (neu/geändert je Task)
  Integration/divi-library-smoke.sh                (neu) Ende-zu-Ende 3a

Task 1: Testlauf ohne Speicherfehler

composer test bricht mit dem PHP-Standard memory_limit=128M ab („Allowed memory size of 134217728 bytes exhausted“); der volle Lauf braucht ≈ 177 MB (gemessen: php -d memory_limit=1G vendor/bin/phpunit → „Memory: 177.00 MB“). Das ist kein Leck einzelner Tests, sondern die Summe aus 1597 Tests mit Mockery/Brain Monkey und dem 237-KB-Katalog, der je Test dekodiert wird. PHPUnit setzt Werte aus <php><ini> zur Laufzeit; das deckt composer test und den direkten Aufruf ab.

Files:

  • Modify: phpunit.xml.dist
  • Modify: composer.json (scripts.test)

Interfaces:

  • Consumes: nichts.

  • Produces: composer test und php vendor/bin/phpunit laufen unabhängig von der memory_limit der CLI.

  • Step 1: Fehler nachweisen

Run: php -d memory_limit=128M vendor/bin/phpunit 2>&1 | grep -m1 -o 'Allowed memory size of [0-9]* bytes exhausted' Expected: Allowed memory size of 134217728 bytes exhausted

  • Step 2: Grenze in der PHPUnit-Konfiguration setzen

phpunit.xml.dist vollständig:

<?xml version="1.0"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
         bootstrap="tests/bootstrap.php"
         colors="true">
    <php>
        <!-- 1597+ tests with Mockery/Brain Monkey need ~180 MB; the CLI default is 128 MB. -->
        <ini name="memory_limit" value="512M"/>
    </php>
    <testsuites>
        <testsuite name="unit">
            <directory>tests/Unit</directory>
        </testsuite>
    </testsuites>
</phpunit>
  • Step 3: Composer-Skript absichern (greift schon vor dem Laden der Konfiguration)

In composer.json den Block scripts ersetzen durch:

    "scripts": {
        "test": "@php -d memory_limit=512M vendor/bin/phpunit",
        "lint": "@php vendor/bin/phpcs"
    },
  • Step 4: Prüfen

Run: php -d memory_limit=128M vendor/bin/phpunit 2>&1 | tail -1 Expected: OK (1597 tests, 7869 assertions)

Run: composer test 2>&1 | tail -1 Expected: OK (1597 tests, 7869 assertions)

  • Step 5: Commit
git add phpunit.xml.dist composer.json
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: raise memory_limit for the unit suite (512M)" -m "Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"

Divis natives Dynamic Content post_link_url_<post_type> mit settings.post_id rendert zur Laufzeit esc_url(get_permalink($id)) (F27, DynamicContentOptionCustomPostLinkUrl.php:186-217) – entwurfssicher und nach dem Veröffentlichen automatisch die schöne URL. Der Name wird nicht fest verdrahtet: VariableRef prüft ihn gegen Divis Registry (nur öffentliche Post-Typen, et_builder_get_public_post_types), select_post prüft read_post (S37). Neu: post_link_url_* nur in URL-Feldern.

Files:

  • Create: src/Integrations/Divi/Compose/LinkTargets.php
  • Create: src/Integrations/Divi/Pages/WpLinkTargets.php
  • Modify: src/Integrations/Divi/Compose/Placeholders.php
  • Modify: src/Integrations/Divi/Compose/VariableRef.php (contentSettings())
  • Modify: src/Integrations/Divi/Pages/DiviComposition.php
  • Modify: tests/Support/ComposeFixture.php (links(), placeholders())
  • Test: tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php, tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php, tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php

Interfaces:

  • Consumes: VariableRef::serialize(string $type, string $name, array $settings): string, DynamicContentMap::fromOptions(array $options): array, DiviFixtures::dynamicContentRegistry() (enthält post_link_url_page mit post_id = select_post).

  • Produces: interface LinkTargets { public function postTypeOf(int $id): ?string; }; new Placeholders(DesignSystem $design, ?LinkTargets $links = null); WpLinkTargets implements LinkTargets; DiviComposition::__construct(…, LinkTargets $links = new WpLinkTargets()) (6. Parameter); ComposeFixture::links(): LinkTargets (839 → page, 840 → post, sonst null).

  • Step 1: Failing tests schreiben

In tests/Support/ComposeFixture.php ergänzen (Import use LIT\MCP\Integrations\Divi\Compose\LinkTargets;) und placeholders() ersetzen:

    /** Link targets of the tests: 839 is a page, 840 a post; everything else is missing, trashed or unreadable. */
    public static function links(): LinkTargets
    {
        return new class implements LinkTargets {
            public function postTypeOf(int $id): ?string
            {
                return match ($id) {
                    839 => 'page',
                    840 => 'post',
                    default => null,
                };
            }
        };
    }

    public static function placeholders(): Placeholders
    {
        return new Placeholders(DiviFixtures::designSystem(), self::links());
    }

In PlaceholdersTest::placeholders() einen Eintrag ergänzen:

            'internal link' => [
                '{{link:839}}',
                '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$',
            ],

Da testResolvesToDiviVariables das Ergebnis mit ComposeFixture::references() (Standard-Map ohne post_link_url_page) prüft, dort die Prüfung auf den Registry-Checker umstellen:

    /** @dataProvider placeholders */
    public function testResolvesToDiviVariables(string $input, string $expected): void
    {
        $placeholders = ComposeFixture::placeholders();
        $resolved = $placeholders->resolve($input);

        self::assertSame($expected, $resolved);
        if ($expected !== $input) {
            // What placeholders produce passes the strict checker unchanged (ruling S1/S25); the live
            // registry map knows post_link_url_page, the URL mode allows it.
            $references = new VariableRef(
                DiviFixtures::designSystem(),
                DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
            );
            self::assertSame($resolved, $references->canonical($resolved, ComposeFixture::sanitizer(), true));
        }
    }

(Imports: use LIT\MCP\Integrations\Divi\Compose\VariableRef;, use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;, use LIT\MCP\Tests\Support\DiviFixtures;.) Hinweis: Im URL-Modus sind before/after nur leer erlaubt – alle bisherigen Zeilen liefern leere before/after, sie bleiben gültig.

In PlaceholdersTest::invalid() ergänzen:

            'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'],
            'link to a missing post' => ['{{link:999}}', 'Eintrag 999'],
            'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'],

und am Ende von PlaceholdersTest einen Test ohne LinkTargets:

    public function testLinksNeedLinkTargets(): void
    {
        $this->expectException(InvalidArgumentException::class);
        $this->expectExceptionMessage('Eintrag 839');

        (new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}');
    }

In ShortformWriterTest (Imports ShortformWriter, Placeholders, ValueCleaner, VariableRef, DynamicContentMap, DiviFixtures):

    /** E13a: {{link:ID}} becomes Divi's post_link_url_<type> in URL fields – and only there. */
    public function testInternalLinkOnlyInUrlFields(): void
    {
        $design = DiviFixtures::designSystem();
        $cleaner = new ValueCleaner(
            DiviFixtures::catalog(),
            new VariableRef($design, DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())),
            ComposeFixture::sanitizer()
        );
        $writer = new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner);

        $button = $writer->apply('button', [], ['text' => 'Kontakt', 'url' => '{{link:839}}'], 'n');
        self::assertSame([], $button['errors']);
        self::assertSame(
            '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$',
            $button['attrs']['button']['innerContent']['desktop']['value']['linkUrl']
        );

        $text = $writer->apply('button', [], ['text' => '{{link:839}}'], 'n');
        self::assertStringContainsString('nur in Link- und URL-Feldern', $text['errors']['n.text']);

        // A post type Divi offers no link option for (not public): unknown name.
        $post = $writer->apply('button', [], ['url' => '{{link:840}}'], 'n');
        self::assertStringContainsString('post_link_url_post', $post['errors']['n.url']);
    }

tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php:

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;

final class WpLinkTargetsTest extends TestCase
{
    public function testOnlyReadableLivingPostsCanBeLinked(): void
    {
        $posts = [
            839 => new WP_Post(['ID' => 839, 'post_type' => 'page', 'post_status' => 'draft']),
            841 => new WP_Post(['ID' => 841, 'post_type' => 'page', 'post_status' => 'trash']),
            842 => new WP_Post(['ID' => 842, 'post_type' => 'page', 'post_status' => 'private']),
            843 => new WP_Post(['ID' => 843, 'post_type' => 'revision', 'post_status' => 'inherit']),
        ];
        Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null);
        Functions\when('current_user_can')->alias(
            static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842
        );

        $links = new WpLinkTargets();

        self::assertSame('page', $links->postTypeOf(839));
        self::assertNull($links->postTypeOf(841), 'trashed');
        self::assertNull($links->postTypeOf(842), 'not readable');
        self::assertNull($links->postTypeOf(843), 'revision');
        self::assertNull($links->postTypeOf(900), 'missing');
    }
}
  • Step 2: Tests laufen lassen – sie schlagen fehl

Run: vendor/bin/phpunit --filter 'PlaceholdersTest|ShortformWriterTest|WpLinkTargetsTest' Expected: FAIL (Class "LIT\MCP\Integrations\Divi\Compose\LinkTargets" not found)

  • Step 3: Implementieren

src/Integrations/Divi/Compose/LinkTargets.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Compose;

defined('ABSPATH') || exit;

/**
 * Entries {{link:<ID>}} may point to (E13a). Pure interface – WpLinkTargets asks WordPress.
 */
interface LinkTargets
{
    /** Post type of entry $id when it may be linked (exists, not trashed, readable); null otherwise. */
    public function postTypeOf(int $id): ?string;
}

src/Integrations/Divi/Pages/WpLinkTargets.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Pages;

use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
use WP_Post;

defined('ABSPATH') || exit;

/**
 * {{link:<ID>}} targets from WordPress: the entry exists, lives (no trash, auto-draft or
 * revision) and the current user may read it (read_post, like select_post settings, S37).
 */
final class WpLinkTargets implements LinkTargets
{
    private const DEAD = ['trash', 'auto-draft', 'inherit'];

    public function postTypeOf(int $id): ?string
    {
        $post = get_post($id);
        if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) {
            return null;
        }

        return current_user_can('read_post', $id) ? (string) $post->post_type : null;
    }
}

Placeholders.php: Konstruktor, Muster, Fehlermeldung und neue Methode.

    private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/';

    public function __construct(private DesignSystem $design, private ?LinkTargets $links = null)
    {
    }

In resolve() die Meldung zu „(bekannt: color, var, font, post, field, link)“ ändern und das match erweitern:

        return match ($kind) {
            'color' => $this->color($name),
            'var' => $this->variable($name),
            'font' => $this->font($name),
            'post' => $this->post($name),
            'link' => $this->link($name),
            default => $this->field($name),
        };

Neue Methode (Klassen-Docblock um {{link:<ID>}} ergänzen):

    /**
     * {{link:<ID>}} → Divi's own internal link post_link_url_<type> with settings.post_id (F27):
     * rendered as get_permalink() at view time, so a draft target gets its pretty URL once published.
     * Whether Divi offers that option (public post types only) VariableRef checks against the registry.
     */
    private function link(string $name): string
    {
        if (preg_match('/^[1-9][0-9]{0,19}$/D', $name) !== 1) {
            throw new InvalidArgumentException(sprintf(
                'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).',
                $name
            ));
        }

        $type = $this->links?->postTypeOf((int) $name);
        if ($type === null) {
            throw new InvalidArgumentException(sprintf(
                'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.',
                $name
            ));
        }

        return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]);
    }

VariableRef::contentSettings() – vor der Schleife einfügen:

        if (! $url && str_starts_with($name, 'post_link_url_')) {
            throw new InvalidArgumentException(sprintf(
                '"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.',
                $name
            ));
        }

DiviComposition: Import use LIT\MCP\Integrations\Divi\Compose\LinkTargets;, Konstruktor um private LinkTargets $links = new WpLinkTargets() als letzten Parameter erweitern und placeholders() ändern:

        return $this->placeholders ??= new Placeholders($this->design->read(), $this->links);
  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'PlaceholdersTest|ShortformWriterTest|WpLinkTargetsTest|VariableRefTest|DiviCompositionTest' Expected: PASS

Run: composer test && composer lint Expected: OK / keine Fehler

  • Step 5: Commit
git add src/Integrations/Divi/Compose/LinkTargets.php src/Integrations/Divi/Pages/WpLinkTargets.php src/Integrations/Divi/Compose/Placeholders.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Support/ComposeFixture.php tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php

Commit wie in den Global Constraints mit der Nachricht feat: {{link:ID}} placeholder via Divi's post_link_url dynamic content.


Task 3: lit-guide mit allen Themen (E13b)

Praxistest-Wunsch 7: alle Themen in einem Aufruf. topic = all liefert zuerst overview, dann die übrigen Themen alphabetisch; Obergrenze 100 000 Bytes, nicht aufgenommene Themen werden am Ende genannt.

Files:

  • Modify: src/Integrations/Core/GuideAbility.php
  • Test: tests/Unit/Integrations/Core/GuideAbilityTest.php

Interfaces:

  • Consumes: GuideRepository::topics(): list<string>, GuideRepository::get(string $topic): ?string.

  • Produces: lit/guide mit topic = all → {topic: "all", topics: list<string>, content: string}; Konstante GuideAbility::ALL_LIMIT = 100000.

  • Step 1: Failing test schreiben

In GuideAbilityTest ergänzen ($this->ability liest tests/Fixtures/guides mit overview.md und divi-shortform.md):

    public function testAllTopicsInOneCallOverviewFirst(): void
    {
        $result = $this->ability->execute(['topic' => 'all']);

        self::assertSame('all', $result['topic']);
        self::assertSame(['divi-shortform', 'overview'], $result['topics']);
        $overview = strpos($result['content'], 'Thema `overview`');
        $shortform = strpos($result['content'], 'Thema `divi-shortform`');
        self::assertIsInt($overview);
        self::assertIsInt($shortform);
        self::assertLessThan($shortform, $overview);
        self::assertStringNotContainsString('Nicht enthalten', $result['content']);
    }

    public function testAllTopicsStopAtTheSizeLimitAndNameTheRest(): void
    {
        $big = str_repeat('x', GuideAbility::ALL_LIMIT);
        // Brain Monkey owns apply_filters(): answer the guide filter through its hooks API.
        Filters\expectApplied('lit_mcp_guides')->andReturn(['gross' => $big]);
        $ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides'));

        $content = $ability->execute(['topic' => 'all'])['content'];

        self::assertLessThanOrEqual(GuideAbility::ALL_LIMIT + 200, strlen($content));
        self::assertStringContainsString('Nicht enthalten (Größenlimit): gross', $content);
    }

(Import use Brain\Monkey\Filters;.)

  • Step 2: Test laufen lassen – schlägt fehl

Run: vendor/bin/phpunit --filter GuideAbilityTest Expected: FAIL (Undefined constant … ALL_LIMIT bzw. Unbekanntes Thema)

  • Step 3: Implementieren

In GuideAbility:

    private const DEFAULT_TOPIC = 'overview';

    /** topic = all: every topic in one call (overview first). */
    public const ALL = 'all';

    /** Upper bound of the combined text of topic = all (bytes). */
    public const ALL_LIMIT = 100000;

description():

        return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen; '
            . 'topic = all: alle Themen in einem Aufruf (gut für den Einstieg).';

Beschreibung des Schema-Felds topic: 'Thema, z. B. "overview" oder "divi-shortform"; "all" = alle Themen.'

In execute() direkt nach $topics = $this->guides->topics();:

        if ($topic === self::ALL) {
            return ['topic' => self::ALL, 'topics' => $topics, 'content' => $this->all($topics)];
        }

Neue Methode:

    /**
     * Every topic, overview first, separated and labelled; stops before ALL_LIMIT bytes and names
     * the topics left out.
     *
     * @param list<string> $topics
     */
    private function all(array $topics): string
    {
        $ordered = in_array(self::DEFAULT_TOPIC, $topics, true)
            ? array_merge([self::DEFAULT_TOPIC], array_values(array_diff($topics, [self::DEFAULT_TOPIC])))
            : $topics;
        $parts = [];
        $size = 0;
        $left = [];
        foreach ($ordered as $topic) {
            $content = $this->guides->get($topic);
            if ($content === null) {
                continue;
            }

            $part = sprintf("---\nThema `%s`:\n\n%s", $topic, rtrim($content));
            if ($size + strlen($part) > self::ALL_LIMIT) {
                $left[] = $topic;
                continue;
            }

            $parts[] = $part;
            $size += strlen($part) + 2;
        }

        if ($left !== []) {
            $parts[] = sprintf(
                'Nicht enthalten (Größenlimit): %s – einzeln mit topic abrufen.',
                implode(', ', $left)
            );
        }

        return implode("\n\n", $parts);
    }
  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'GuideAbilityTest|ToolNamingTest' Expected: PASS

  • Step 5: Commit
git add src/Integrations/Core/GuideAbility.php tests/Unit/Integrations/Core/GuideAbilityTest.php

Commit wie in den Global Constraints mit der Nachricht feat: lit-guide topic all returns every guide in one call.


Task 4: S19 – Designsystem filtern, globale Module in node-get/outline

Ruling S19 (aus 2a F2): lit-divi-design-system wird auf großen Websites mit hunderten Presets unhandlich → Filter only (colors/variables/presets) und module (nur dessen Presets), höchstens 200 Presets je Antwort. lit-divi-node-get und lit-divi-outline nennen bei globalen Modulen die Bibliotheks-ID und den Wurzeltyp (E2).

Files:

  • Modify: src/Integrations/Divi/Design/DiviDesignSystemAbility.php
  • Modify: src/Integrations/Divi/BlockTree/NodeSummary.php (globalRef())
  • Modify: src/Integrations/Divi/BlockTree/Outline.php (global_id)
  • Modify: src/Integrations/Divi/Pages/DiviNodeGetAbility.php (global)
  • Modify: src/Integrations/Divi/Pages/DiviOutlineAbility.php (Schema, Hinweis)
  • Test: tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php, tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php, tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php

Interfaces:

  • Consumes: DesignSystem::presets(): list<array{kind: string, module: string, id: string, name: string, default: bool}>, Fixture tests/Fixtures/divi/stage3/page--globale-instanzen.html (g0 = Instanz 1058 divi/section, s0.r0.c0.g0 = Instanz 1059 divi/text).

  • Produces: NodeSummary::globalRef(Block $block): ?array{id: string, block_name: string}; Outline-Zeilen mit global_id: ?string; lit/divi-node-get mit global: null|{id, block_name, hint}; lit/divi-design-system mit only, module.

  • Step 1: Failing tests schreiben

OutlineTest::testRowsAndTextOfAPage: im erwarteten Array 'truncated' => false] durch 'truncated' => false, 'global_id' => null] ersetzen. Neuer Test in OutlineTest:

    public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void
    {
        $content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
        $outline = new Outline();
        $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content)));

        $byId = array_column($rows, null, 'id');
        self::assertSame('1058', $byId['g0']['global_id']);
        self::assertSame('section', $byId['g0']['type']);
        self::assertFalse($byId['g0']['editable']);
        self::assertSame('1059', $byId['s0.r0.c0.g0']['global_id']);
        self::assertSame('text', $byId['s0.r0.c0.g0']['type']);
        self::assertNull($byId['s0']['global_id']);
        self::assertStringContainsString('g0 section "Global Abschnitt" [global 1058]', $outline->text($rows));
    }

(Import use LIT\MCP\Tests\Support\DiviFixtures; – DiviFixtures::markup() ist reines PHP und liest tests/Fixtures/divi/<name>.)

In DiviReadAbilitiesTest:

    public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void
    {
        DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
        $ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $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::assertFalse($global['editable']);
        self::assertNull($plain['global']);
    }

In DiviDesignSystemAbilityTest (Muster der vorhandenen Tests: Reader-Mock mit DiviFixtures::designSystem()):

    public function testFiltersPartsAndPresetsOfOneModule(): void
    {
        Functions\when('current_user_can')->justReturn(true);
        $reader = Mockery::mock(DesignSystemReader::class);
        $reader->allows('read')->andReturn(DiviFixtures::designSystem());
        $ability = new DiviDesignSystemAbility($reader);

        $result = $ability->execute(['only' => ['presets'], 'module' => 'button']);

        self::assertSame([], $result['colors']);
        self::assertSame([], $result['variables']);
        self::assertSame(['p-1', 'p-2'], array_column($result['presets'], 'id'));
        self::assertSame(['divi/button'], array_values(array_unique(array_column($result['presets'], 'module'))));
        self::assertNotEmpty($result['fonts']['heading']);
        self::assertContains('Nicht angefordert: colors, variables.', $result['notes']);
    }

    public function testCapsThePresetList(): void
    {
        Functions\when('current_user_can')->justReturn(true);
        $items = [];
        for ($i = 0; $i < 250; ++$i) {
            $items['p' . $i] = ['id' => 'p' . $i, 'name' => 'Preset ' . $i, 'moduleName' => 'divi/text'];
        }
        $design = DesignSystem::fromDivi([], [], ['module' => ['divi/text' => ['default' => '', 'items' => $items]]], []);
        $reader = Mockery::mock(DesignSystemReader::class);
        $reader->allows('read')->andReturn($design);

        $result = (new DiviDesignSystemAbility($reader))->execute([]);

        self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']);
        self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']);
    }

(Imports: DesignSystem, DesignSystemReader, DiviFixtures, Mockery, Functions.)

  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'OutlineTest|DiviReadAbilitiesTest|DiviDesignSystemAbilityTest' Expected: FAIL (global_id, global, PRESET_LIMIT fehlen)

  • Step 3: Implementieren

NodeSummary:

    /**
     * Library entry of a global module: a divi/global-layout instance (its root type in blockName)
     * or a block that carries globalModule itself (Divi 4 conversions). Null for every other block.
     *
     * @return array{id: string, block_name: string}|null
     */
    public static function globalRef(Block $block): ?array
    {
        if (! $block->isGlobal()) {
            return null;
        }

        $attrs = $block->attrs();
        $id = $attrs['globalModule'] ?? null;
        if (! is_string($id) && ! is_int($id)) {
            return null;
        }

        $name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name();

        return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : ''];
    }

Outline::row() – Typ und global_id (Docblock-Rückgabetyp um global_id: ?string ergänzen):

    private function row(Block $block, string $id, int $depth): array
    {
        $editable = $block->isDivi() && ! $block->isOpaque();
        $global = NodeSummary::globalRef($block);
        $type = $block->isDivi() ? $block->shortName() : $block->name();
        if ($global !== null && $global['block_name'] !== '') {
            $type = str_starts_with($global['block_name'], 'divi/') ? substr($global['block_name'], 5) : $global['block_name'];
        }

        return [
            'id' => $id,
            'type' => $type,
            'label' => NodeSummary::label($block),
            'excerpt' => NodeSummary::excerpt($block),
            'info' => $editable ? NodeSummary::info($block) : '',
            'presets' => array_map(
                fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId,
                NodeSummary::presetIds($block)
            ),
            'children' => $editable ? count($block->children()) : 0,
            'editable' => $editable,
            'depth' => $depth,
            'truncated' => false,
            'global_id' => $global['id'] ?? null,
        ];
    }

NodeSummary::label() liest module.meta.adminLabel aus den Attributen; bei divi/global-layout steht das Label in localAttrs. In NodeSummary::label() die erste Zeile ersetzen durch:

        $attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null)
            ? $block->attrs()['localAttrs']
            : $block->attrs();
        $label = AttrMap::desktop($attrs, 'module.meta.adminLabel');

Outline::text() – nach der Label-Zeile:

            $line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : '';

DiviOutlineAbility::outputSchema() – in den Zeilen-properties 'global_id' => ['type' => ['string', 'null'], 'description' => 'Bibliotheks-ID eines globalen Moduls.'] ergänzen und 'global_id' in required aufnehmen. In hints() den Text für nur lesbare Knoten ersetzen:

            $hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
                . 'verschieben, duplizieren oder löschen. Globale Module [global <ID>] im Bibliothekseintrag '
                . 'bearbeiten: lit-divi-outline mit post_id <ID> – die Änderung wirkt auf alle Seiten.';

DiviNodeGetAbility: im outputSchema 'global' => ['type' => ['object', 'null']] ergänzen (nicht in required); in execute() vor dem return:

        $global = NodeSummary::globalRef($block);
        if ($global !== null) {
            $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']
            );
        }

und 'global' => $global, ins Ergebnis-Array.

DiviDesignSystemAbility:

    /** Presets per answer (ruling S19); narrow with "module". */
    public const PRESET_LIMIT = 200;

    private const PARTS = ['colors', 'variables', 'presets'];

description() um „Filter: only (colors/variables/presets) und module (Presets eines Moduls).“ ergänzen. inputSchema():

        return [
            'type' => 'object',
            'properties' => [
                'only' => [
                    'type' => 'array',
                    'items' => ['type' => 'string', 'enum' => self::PARTS],
                    'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer dabei.',
                ],
                'module' => [
                    'type' => 'string',
                    'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".',
                ],
            ],
            'additionalProperties' => false,
        ];

execute() vollständig:

    public function execute(array $input): mixed
    {
        $design = $this->reader->read();
        $only = is_array($input['only'] ?? null) && $input['only'] !== []
            ? array_values(array_intersect(self::PARTS, $input['only']))
            : self::PARTS;
        $notes = [
            'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "<Name>".',
            'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
        ];
        $skipped = array_values(array_diff(self::PARTS, $only));
        if ($skipped !== []) {
            $notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.';
        }

        $presets = $design->presets();
        $module = is_string($input['module'] ?? null) ? trim($input['module']) : '';
        if ($module !== '') {
            $name = str_contains($module, '/') ? $module : 'divi/' . $module;
            $presets = array_values(array_filter(
                $presets,
                static fn (array $preset): bool => $preset['module'] === $name
            ));
        }
        if (count($presets) > self::PRESET_LIMIT) {
            $notes[] = sprintf(
                '%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.',
                self::PRESET_LIMIT,
                count($presets)
            );
            $presets = array_slice($presets, 0, self::PRESET_LIMIT);
        }

        return [
            'colors' => in_array('colors', $only, true) ? array_map(
                static fn (array $color): array => $color + [
                    'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']),
                ],
                $design->colors()
            ) : [],
            'variables' => in_array('variables', $only, true) ? array_map(
                static fn (array $variable): array => $variable
                    + ['placeholder' => '{{var:' . $variable['label'] . '}}'],
                $design->variables()
            ) : [],
            'fonts' => $design->fonts(),
            'sizes' => $design->sizes(),
            'presets' => in_array('presets', $only, true) ? $presets : [],
            'notes' => $notes,
        ];
    }
  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'OutlineTest|DiviReadAbilitiesTest|DiviDesignSystemAbilityTest|DiviWriterTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Design/DiviDesignSystemAbility.php src/Integrations/Divi/BlockTree/NodeSummary.php src/Integrations/Divi/BlockTree/Outline.php src/Integrations/Divi/Pages/DiviNodeGetAbility.php src/Integrations/Divi/Pages/DiviOutlineAbility.php tests/Unit/Integrations/Divi

Commit wie in den Global Constraints mit der Nachricht feat: design-system filters and global module ids in node-get/outline (S19).


Task 5: lit-page-render-summary meldet globale Module (E13d, F8)

Divis BlockParser expandiert divi/global-layout nicht bei REST-POST-Anfragen (F8, BlockParser.php:827-831) – unsere MCP-Aufrufe sind REST-POST, der Auszug enthält globale Module also nicht. Statt einen Divi-internen Schalter zu umgehen, meldet der Auszug jede Instanz (Divis eigene Erkennung per Zeichenkettensuche, F10). Bibliotheks-IDs bleiben für das Tool not_found (ContentSchema::EXCLUDED).

Files:

  • Modify: src/Integrations/WordPress/Render/PageRenderSummaryAbility.php
  • Test: tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php

Interfaces:

  • Consumes: PageRenderer::render(WP_Post): string, RenderSummary::fromHtml().

  • Produces: notes enthält je globaler Instanz Globales Modul <ID> ist im Auszug nicht enthalten … (höchstens 20).

  • Step 1: Failing test schreiben

Im vorhandenen Test das Muster zum Stubben von get_post, current_user_can, get_permalink und des PageRenderer-Mocks übernehmen (siehe bestehende Tests der Datei):

    public function testNotesEveryGlobalModuleTheSummaryCannotShow(): 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);

        $notes = $ability->execute(['post_id' => 7])['notes'];

        $globals = array_values(array_filter($notes, static fn (string $note): bool => str_starts_with($note, 'Globales Modul')));
        self::assertCount(2, $globals);
        self::assertStringContainsString('Globales Modul 1058 ist im Auszug nicht enthalten', $globals[0]);
        self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]);
    }

    public function testLibraryEntriesAreNoManagedContent(): void
    {
        Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout']));
        $schema = Mockery::mock(ContentSchema::class);
        $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false);
        $ability = new PageRenderSummaryAbility($schema, Mockery::mock(PageRenderer::class));

        self::assertToolError($ability->permission(['post_id' => 1058]), 'not_found');
    }

(Imports: ContentSchema, SchemaFixture, DiviFixtures, PageRenderer, Mockery, WP_Post, Functions.)

  • Step 2: Test laufen lassen – schlägt fehl

Run: vendor/bin/phpunit --filter PageRenderSummaryAbilityTest Expected: FAIL (assertCount(2, …): 0)

  • Step 3: Implementieren

In PageRenderSummaryAbility:

    /** At most this many global-module notes per summary. */
    private const GLOBAL_NOTES = 20;

In execute() nach dem Passwort-Hinweis:

        foreach (array_slice(self::globalModuleIds((string) $post->post_content), 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
            );
        }

Neue Methode:

    /**
     * IDs of the global modules in $content, found the way Divi finds them (a string scan for
     * "globalModule", BlockParserStore::get_global_layout_content – parse_blocks() is unreliable there).
     *
     * @return list<string>
     */
    private static function globalModuleIds(string $content): array
    {
        preg_match_all('/"globalModule":"?(\d+)"?/', $content, $matches);

        return array_values(array_unique($matches[1]));
    }

description() um den Satz „Globale Module (Divi-Bibliothek) fehlen im Auszug und werden unter notes genannt.“ ergänzen.

  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'PageRenderSummaryAbilityTest|ToolNamingTest' Expected: PASS

  • Step 5: Commit
git add src/Integrations/WordPress/Render/PageRenderSummaryAbility.php tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php

Commit wie in den Global Constraints mit der Nachricht feat: render summary notes global modules it cannot show (F8).


Task 6: Divi-Adapter mit Kompatibilitätsprüfung – DiviRoles, DiviLibraryStore (lesen), DiviCompatibility

Schnittstellen-Einordnung (Entscheidungen §5, E15): Divis Rollen-Editor (et_pb_is_allowed, Stufe c), der Bibliotheks-Post-Typ mit Termen/Meta (c/d) und GlobalLayout::is_global_layout_template (c) werden nur über Adapter angesprochen. Fehlt eine Funktion/Klasse nach einem Divi-Update, liefern die Adapter „nicht erlaubt“ bzw. den Rückfall, nie einen Fatal; lit-site-status zeigt den Befund. Die Builder-Routen divi/v1/divi-library/* werden nicht benutzt (Nonce, keine Rechte je Eintrag, F9/F11).

Files:

  • Create: src/Integrations/Divi/Library/DiviRoles.php
  • Create: src/Integrations/Divi/Library/DiviLibraryStore.php
  • Create: src/Integrations/Divi/DiviCompatibility.php
  • Modify: src/Integrations/Divi/Pages/DynamicContentMap.php (REGISTRY öffentlich)
  • Modify: src/Integrations/Core/EnvironmentProbe.php
  • Test: tests/Unit/Integrations/Divi/Library/DiviRolesTest.php, tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php, tests/Unit/Integrations/Divi/DiviCompatibilityTest.php, tests/Unit/Integrations/Core/EnvironmentProbeTest.php

Interfaces:

  • Consumes: Errors::forbidden(), DiviModules::isAvailable(), WP_Term-Stub.

  • Produces:

    • class DiviRoles { __construct(?Closure $check = null); allows(string $capability): bool; refusal(string $capability): ?WP_Error }
    • class DiviLibraryStore { const POST_TYPE = 'et_pb_layout'; const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout'; const LAYOUT_TYPES = ['layout','section','row','module']; const TAXONOMIES = ['scope','layout_type','module_width']; isGlobal(int $id): bool; layoutType(int $id): ?string; termSlugs(int $id, string $taxonomy): list<string>; termNames(int $id, string $taxonomy): list<string>; find(array $filters, int $limit, int $offset): list<WP_Post>; describe(WP_Post $post): array; missingTaxonomies(): list<string> } (nicht final, mockbar; create()/delete() folgen in Task 12)
    • final class DiviCompatibility { __construct(?array $checks = null); problems(): list<string>; static defaults(): array<string, callable(): bool> }
    • EnvironmentProbe::__construct(?DiviCompatibility $divi = null); jede Komponente hat zusätzlich problems: list<string>.
  • Step 1: Failing tests schreiben

tests/Unit/Integrations/Divi/Library/DiviRolesTest.php:

<?php

declare(strict_types=1);

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

use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Tests\Unit\TestCase;

final class DiviRolesTest extends TestCase
{
    public function testAsksTheCheckAndExplainsARefusal(): void
    {
        $roles = new DiviRoles(static fn (string $cap): bool => $cap !== 'edit_global_library');

        self::assertTrue($roles->allows('divi_library'));
        self::assertNull($roles->refusal('divi_library'));
        $refusal = $roles->refusal('edit_global_library');
        self::assertToolError($refusal, 'forbidden');
        self::assertStringContainsString('Globale Bibliothekselemente bearbeiten', $refusal->get_error_message());
        self::assertStringContainsString('Rollen-Editor', $refusal->get_error_message());
    }

    public function testWithoutDiviNothingIsAllowed(): void
    {
        // Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
        self::assertFalse((new DiviRoles())->allows('divi_library'));
    }
}

tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php:

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
use WP_Term;

final class DiviLibraryStoreTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        $terms = [
            1058 => [
                'scope' => [new WP_Term(['slug' => 'global', 'name' => 'global'])],
                'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])],
                'layout_category' => [new WP_Term(['slug' => 'kopf', 'name' => 'Kopfbereiche'])],
            ],
            1055 => [
                'scope' => [new WP_Term(['slug' => 'non_global', 'name' => 'non_global'])],
                'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])],
            ],
            1057 => [],
        ];
        Functions\when('get_the_terms')->alias(
            static fn (int $id, string $taxonomy): array|false => $terms[$id][$taxonomy] ?? false
        );
        Functions\when('get_post_meta')->alias(
            static fn (int $id, string $key): string => $id === 1057 && $key === '_et_pb_module_type' ? 'divi/blurb' : ''
        );
    }

    public function testGlobalScopeAndLayoutTypeFromTheTerms(): void
    {
        $store = new DiviLibraryStore();

        self::assertTrue($store->isGlobal(1058));
        self::assertFalse($store->isGlobal(1055));
        self::assertSame('section', $store->layoutType(1058));
        self::assertNull($store->layoutType(1057));
        self::assertSame(['Kopfbereiche'], $store->termNames(1058, 'layout_category'));
    }

    public function testDescribesAnEntry(): void
    {
        $post = new WP_Post([
            'ID' => 1057, 'post_type' => 'et_pb_layout', 'post_title' => '[Fixture] Library-Modul',
            'post_modified_gmt' => '2026-09-26 20:56:29',
        ]);

        self::assertSame(
            [
                'id' => 1057, 'title' => '[Fixture] Library-Modul', 'layout_type' => null, 'scope' => 'normal',
                'module_type' => 'divi/blurb', 'categories' => [], 'tags' => [], 'modified' => '2026-09-26 20:56:29',
            ],
            (new DiviLibraryStore())->describe($post)
        );
    }

    public function testFindQueriesPublishedEntriesWithFilters(): void
    {
        $captured = [];
        Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array {
            $captured = $args;

            return [];
        });

        (new DiviLibraryStore())->find(
            ['layout_type' => 'section', 'scope' => 'normal', 'search' => 'Kopf', 'category' => 'Kopfbereiche'],
            20,
            40
        );

        self::assertSame('et_pb_layout', $captured['post_type']);
        self::assertSame('publish', $captured['post_status']);
        self::assertSame(21, $captured['posts_per_page']);
        self::assertSame(40, $captured['offset']);
        self::assertSame('Kopf', $captured['s']);
        self::assertSame(
            [
                'relation' => 'AND',
                ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => ['section']],
                ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'],
                ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => ['Kopfbereiche']],
            ],
            $captured['tax_query']
        );
    }

    public function testReportsMissingTaxonomies(): void
    {
        Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope');

        self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies());
    }
}

tests/Unit/Integrations/Divi/DiviCompatibilityTest.php:

<?php

declare(strict_types=1);

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

use LIT\MCP\Integrations\Divi\DiviCompatibility;
use PHPUnit\Framework\TestCase;
use RuntimeException;

final class DiviCompatibilityTest extends TestCase
{
    public function testListsFailedAndThrowingChecks(): void
    {
        $compatibility = new DiviCompatibility([
            'A' => static fn (): bool => true,
            'B' => static fn (): bool => false,
            'C' => static function (): bool {
                throw new RuntimeException('Divi changed');
            },
        ]);

        self::assertSame(['B', 'C'], $compatibility->problems());
    }

    public function testDefaultsCoverTheDiviInternalsInUse(): void
    {
        self::assertSame(
            [
                'ModuleRegistration (Modul-Katalog)',
                'DynamicContentOptions (Dynamic Content)',
                'et_pb_is_allowed (Divi-Rollen)',
                'Post-Typ et_pb_layout (Bibliothek)',
                'Taxonomien scope, layout_type, module_width (Bibliothek)',
                'GlobalLayout::is_global_layout_template (globale Module)',
                'ET_Core_PageResource (CSS-Cache)',
            ],
            array_keys(DiviCompatibility::defaults())
        );
    }
}

tests/Unit/Integrations/Core/EnvironmentProbeTest.php:

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Core\EnvironmentProbe;
use LIT\MCP\Integrations\Divi\DiviCompatibility;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class EnvironmentProbeTest extends TestCase
{
    public function testDiviComponentCarriesTheCompatibilityFindings(): void
    {
        $theme = Mockery::mock();
        $theme->allows('get_template')->andReturn('Divi');
        $theme->allows('parent')->andReturn(false);
        $theme->allows('get')->with('Version')->andReturn('5.2.1');
        Functions\when('wp_get_theme')->justReturn($theme);

        $probe = new EnvironmentProbe(new DiviCompatibility(['GlobalLayout' => static fn (): bool => false]));
        $components = array_column($probe->components(), null, 'id');

        self::assertSame(['GlobalLayout'], $components['divi']['problems']);
        self::assertSame([], $components['pods']['problems']);
    }
}
  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'DiviRolesTest|DiviLibraryStoreTest|DiviCompatibilityTest|EnvironmentProbeTest' Expected: FAIL (Klassen fehlen)

  • Step 3: Implementieren

src/Integrations/Divi/Library/DiviRoles.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Library;

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

defined('ABSPATH') || exit;

/**
 * Adapter for Divi's role editor (E3, E15): et_pb_is_allowed() (Divi 5.2.1 builder/core.php:1128)
 * answers for the current user's role, "dangerous" options (theme_builder,
 * read_dynamic_content_custom_fields) default to off for every role but administrator. When
 * Divi no longer has the function, nothing is allowed – DiviCompatibility reports it.
 * Not final so tests can pass a check.
 */
class DiviRoles
{
    private const LABELS = [
        'divi_library' => 'Divi-Bibliothek',
        'save_library' => 'In der Bibliothek speichern',
        'add_library' => 'Aus der Bibliothek hinzufügen',
        'edit_global_library' => 'Globale Bibliothekselemente bearbeiten',
        'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen',
    ];

    /** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */
    public function __construct(private ?Closure $check = null)
    {
    }

    public function allows(string $capability): bool
    {
        if ($this->check !== null) {
            return ($this->check)($capability);
        }

        return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability);
    }

    /** Null when the role allows $capability, else a forbidden error naming the option. */
    public function refusal(string $capability): ?WP_Error
    {
        if ($this->allows($capability)) {
            return null;
        }

        return Errors::forbidden(
            sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability),
            'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
        );
    }
}

src/Integrations/Divi/Library/DiviLibraryStore.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Library;

use WP_Post;
use WP_Term;

defined('ABSPATH') || exit;

/**
 * Adapter for the Divi Library (E4, E15): post type et_pb_layout, the taxonomies scope,
 * layout_type, module_width, layout_category, layout_tag and the meta Divi's et_pb_submit_layout()
 * writes (F1, F2, fixtures stage3). "Global" is Divi's own definition
 * GlobalLayout::is_global_layout_template() (F5) with the scope term as fallback when the class is
 * gone. Library entries are always "publish" (F2). Not final so abilities can be tested with a mock.
 */
class DiviLibraryStore
{
    public const POST_TYPE = 'et_pb_layout';

    public const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout';

    public const LAYOUT_TYPES = ['layout', 'section', 'row', 'module'];

    /** Taxonomies every new entry needs (et_pb_submit_layout). */
    public const TAXONOMIES = ['scope', 'layout_type', 'module_width'];

    public function isGlobal(int $id): bool
    {
        if (method_exists(self::GLOBAL_LAYOUT, 'is_global_layout_template')) {
            return (bool) call_user_func([self::GLOBAL_LAYOUT, 'is_global_layout_template'], $id);
        }

        return in_array('global', $this->termSlugs($id, 'scope'), true);
    }

    /** "layout", "section", "row" or "module"; null when the entry has no known type. */
    public function layoutType(int $id): ?string
    {
        foreach ($this->termSlugs($id, 'layout_type') as $slug) {
            if (in_array($slug, self::LAYOUT_TYPES, true)) {
                return $slug;
            }
        }

        return null;
    }

    /** @return list<string> */
    public function termSlugs(int $id, string $taxonomy): array
    {
        return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy));
    }

    /** @return list<string> */
    public function termNames(int $id, string $taxonomy): array
    {
        return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy));
    }

    /**
     * Published entries, $limit + 1 of them (Cursor::page() tells whether there are more).
     *
     * @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters
     *        scope: "global" or "normal"; category: name of a layout_category term.
     * @return list<WP_Post>
     */
    public function find(array $filters, int $limit, int $offset): array
    {
        $taxQuery = [];
        if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) {
            $taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]];
        }
        if (($filters['scope'] ?? '') === 'global') {
            $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']];
        } elseif (($filters['scope'] ?? '') === 'normal') {
            $taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'];
        }
        if (($filters['category'] ?? '') !== '') {
            $taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]];
        }

        $args = [
            'post_type' => self::POST_TYPE,
            'post_status' => 'publish',
            'posts_per_page' => $limit + 1,
            'offset' => $offset,
            'orderby' => 'title',
            'order' => 'ASC',
            'no_found_rows' => true,
            'suppress_filters' => false,
        ];
        if (($filters['search'] ?? '') !== '') {
            $args['s'] = $filters['search'];
        }
        if ($taxQuery !== []) {
            // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small.
            $args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery);
        }

        return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post));
    }

    /**
     * @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string,
     *     categories: list<string>, tags: list<string>, modified: string}
     */
    public function describe(WP_Post $post): array
    {
        $id = (int) $post->ID;
        $moduleType = (string) get_post_meta($id, '_et_pb_module_type', true);

        return [
            'id' => $id,
            'title' => (string) $post->post_title,
            'layout_type' => $this->layoutType($id),
            'scope' => $this->isGlobal($id) ? 'global' : 'normal',
            'module_type' => $moduleType === '' ? null : $moduleType,
            'categories' => $this->termNames($id, 'layout_category'),
            'tags' => $this->termNames($id, 'layout_tag'),
            'modified' => (string) $post->post_modified_gmt,
        ];
    }

    /**
     * Library taxonomies not registered in this request (F2: Divi registers them in
     * ET_Builder_Library; fixture finding 6: not in every context).
     *
     * @return list<string>
     */
    public function missingTaxonomies(): array
    {
        return array_values(array_filter(
            self::TAXONOMIES,
            static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy)
        ));
    }

    /** @return list<WP_Term> */
    private function terms(int $id, string $taxonomy): array
    {
        $terms = get_the_terms($id, $taxonomy);

        return is_array($terms)
            ? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term))
            : [];
    }
}

DynamicContentMap: private const REGISTRY → public const REGISTRY (Docblock: „also checked by DiviCompatibility“).

src/Integrations/Divi/DiviCompatibility.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi;

use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use Throwable;

defined('ABSPATH') || exit;

/**
 * Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes,
 * functions, the library post type and taxonomies. lit-site-status shows the failed checks, so a
 * Divi update that moves one of them is visible before a tool reports "unsupported".
 */
final class DiviCompatibility
{
    /** @param array<string, callable(): bool>|null $checks Label => check; null = defaults(). */
    public function __construct(private ?array $checks = null)
    {
    }

    /** @return list<string> Labels of the checks that failed or threw. */
    public function problems(): array
    {
        $problems = [];
        foreach ($this->checks ?? self::defaults() as $label => $check) {
            try {
                $ok = (bool) $check();
            } catch (Throwable) {
                $ok = false;
            }
            if (! $ok) {
                $problems[] = (string) $label;
            }
        }

        return $problems;
    }

    /** @return array<string, callable(): bool> */
    public static function defaults(): array
    {
        return [
            'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(),
            'DynamicContentOptions (Dynamic Content)' => static fn (): bool => class_exists(DynamicContentMap::REGISTRY),
            'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'),
            'Post-Typ et_pb_layout (Bibliothek)' => static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE),
            'Taxonomien scope, layout_type, module_width (Bibliothek)' =>
                static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [],
            'GlobalLayout::is_global_layout_template (globale Module)' =>
                static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'),
            'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'),
        ];
    }
}

EnvironmentProbe::components() vollständig ersetzen (Konstruktor ergänzen, Import use LIT\MCP\Integrations\Divi\DiviCompatibility;):

    public function __construct(private ?DiviCompatibility $divi = null)
    {
    }

    /** @return list<array{id: string, active: bool, version: ?string, problems: list<string>}> */
    public function components(): array
    {
        $theme = wp_get_theme();
        $isDivi = $theme->get_template() === 'Divi';
        $parent = $theme->parent();
        $diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null;

        $tecActive = class_exists('Tribe__Events__Main');

        return [
            [
                'id' => 'divi',
                'active' => $isDivi,
                'version' => $diviVersion,
                'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [],
            ],
            [
                'id' => 'pods',
                'active' => defined('PODS_VERSION'),
                'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
                'problems' => [],
            ],
            [
                'id' => 'the-events-calendar',
                'active' => $tecActive,
                'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
                'problems' => [],
            ],
        ];
    }

In SiteStatusAbility::description() den Satz „Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.“ ergänzen.

  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'DiviRolesTest|DiviLibraryStoreTest|DiviCompatibilityTest|EnvironmentProbeTest|SiteStatusAbilityTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Library/DiviRoles.php src/Integrations/Divi/Library/DiviLibraryStore.php src/Integrations/Divi/DiviCompatibility.php src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Core/EnvironmentProbe.php src/Integrations/Core/SiteStatusAbility.php tests/Unit/Integrations/Divi/Library tests/Unit/Integrations/Divi/DiviCompatibilityTest.php tests/Unit/Integrations/Core/EnvironmentProbeTest.php

Commit wie in den Global Constraints mit der Nachricht feat: Divi adapters for roles and library with a live compatibility check.


Task 7: Bausteine globaler Module im BlockTree – copyAs, GlobalInstance, LibraryContent

Rein PHP, gegen die echten Builder-Fixtures. Befunde aus tests/Fixtures/divi/stage3/: (a) eine globale Instanz ist divi/global-layout mit globalModule, blockName, localAttrs (vollständiger Schnappschuss der Wurzel-Attribute, oberste Schlüssel sortiert – im Fixture builderVersion, content, module gegenüber module, content, builderVersion im Bibliothekseintrag), die Kinder mit ihren echten Blocknamen und "globalParent":"<id>" am Ende; eine Instanz ohne Kinder ist selbstschließend. (b) Neu: Bibliothekseinträge, die über Divis Dialog „Zur Bibliothek hinzufügen“ gespeichert wurden, haben zwischen den Blöcken \r\n (Formular-Übertragung des Browsers), Seiten \n; der Builder schreibt Instanzen mit \n. Deshalb normalisiert LibraryContent::load() Zeilenumbrüche im reinen Leerraum zwischen Blöcken. Mit beidem entsteht die Instanz byte-genau wie im Fixture (vorab per Skript geprüft).

Files:

  • Modify: src/Integrations/Divi/BlockTree/Block.php (copyAs())
  • Modify: src/Integrations/Divi/BlockTree/Container.php (replaceChild(), unifyLineBreaks())
  • Create: src/Integrations/Divi/BlockTree/GlobalInstance.php
  • Create: src/Integrations/Divi/BlockTree/LibraryContent.php
  • Modify: src/Integrations/Divi/Compose/TreeEditor.php (Wurzeltyp globaler Instanzen)
  • Modify: tests/Fixtures/divi/stage3/README.md (Befund 9)
  • Test: tests/Unit/Integrations/Divi/BlockTree/BlockTest.php, …/ContainerTest.php, …/GlobalInstanceTest.php, …/LibraryContentTest.php, tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php

Interfaces:

  • Consumes: Parser::parse(), Serializer::block(), NodeIndex::build(), DiviFixtures::markup().

  • Produces:

    • Block::copyAs(string $name, array $attrs): Block
    • Container::replaceChild(Block $old, Block $new): void, Container::unifyLineBreaks(): void
    • GlobalInstance::BLOCK = 'divi/global-layout', GlobalInstance::effectiveName(Block): string, GlobalInstance::of(Block $root, int $libraryId): Block, GlobalInstance::deglobalize(Block $block): Block
    • LibraryContent::load(Parser $parser, string $content): Document (wirft MalformedMarkup), LibraryContent::wrap(Block $node): string, LibraryContent::roots(Document $document): list<Block>, LibraryContent::containsGlobal(Block $block): bool, LibraryContent::shapeViolation(list<Block> $before, list<Block> $after, ?string $layoutType, bool $global): ?string
  • Step 1: Failing tests schreiben

In BlockTest:

    public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void
    {
        $document = (new Parser())->parse("<!-- wp:divi/row {\"a\":1} -->\n<!-- wp:divi/column /-->\n<!-- /wp:divi/row -->");
        $row = $document->children()[0];

        $copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']);

        self::assertSame(
            "<!-- wp:divi/global-layout {\"globalModule\":\"5\",\"blockName\":\"divi/row\"} -->\n<!-- wp:divi/column /-->\n"
                . '<!-- /wp:divi/global-layout -->',
            Serializer::block($copy)
        );
        self::assertTrue($copy->isOpaque());
        self::assertSame("<!-- wp:divi/row {\"a\":1} -->\n<!-- wp:divi/column /-->\n<!-- /wp:divi/row -->", Serializer::block($row));
        self::assertFalse($row->copyAs('divi/row', [])->isOpaque());
    }

    public function testCopyAsRefusesInvalidNames(): void
    {
        $this->expectException(InvalidArgumentException::class);

        Block::create('divi/text')->copyAs('Divi Text', []);
    }

In ContainerTest:

    public function testReplaceChildKeepsTheTextAround(): void
    {
        $document = (new Parser())->parse("A<!-- wp:divi/text /-->B");
        $new = Block::create('divi/button');

        $document->replaceChild($document->children()[0], $new);

        self::assertSame('A<!-- wp:divi/button --><!-- /wp:divi/button -->B', Serializer::document($document));
    }

    public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void
    {
        $document = (new Parser())->parse(
            "<!-- wp:divi/section -->\r\n<!-- wp:divi/row -->\r\n<!-- /wp:divi/row -->\r\n<!-- /wp:divi/section -->x\r\ny"
        );

        $document->unifyLineBreaks();

        self::assertSame(
            "<!-- wp:divi/section -->\n<!-- wp:divi/row -->\n<!-- /wp:divi/row -->\n<!-- /wp:divi/section -->x\r\ny",
            Serializer::document($document)
        );
    }

(Imports Block, Parser, Serializer, InvalidArgumentException je nach Datei ergänzen.)

tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.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\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;

final class GlobalInstanceTest extends TestCase
{
    private static function libraryRoot(string $file): Block
    {
        return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0];
    }

    private static function pageIndex(): NodeIndex
    {
        return NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
    }

    /** @return array<string, array{string, int, string}> */
    public static function instances(): array
    {
        return [
            'global section' => ['library-global--globaler-abschnitt.html', 1058, '<!-- /wp:divi/global-layout -->'],
            'global module (void)' => ['library-global--globales-modul.html', 1059, '/-->'],
        ];
    }

    /** @dataProvider instances */
    public function testInstanceMatchesTheVisualBuilderByteForByte(string $file, int $id, string $end): void
    {
        $page = DiviFixtures::markup('stage3/page--globale-instanzen.html');
        $start = (int) strpos($page, '<!-- wp:divi/global-layout {"globalModule":"' . $id . '"');
        $expected = substr($page, $start, (int) strpos($page, $end, $start) + strlen($end) - $start);

        $instance = GlobalInstance::of(self::libraryRoot($file), $id);

        self::assertSame($expected, Serializer::block($instance));
        self::assertTrue($instance->isOpaque());
        self::assertTrue($instance->isGlobal());
    }

    public function testEffectiveNameOfAnInstanceIsItsRootType(): void
    {
        $index = self::pageIndex();

        self::assertSame('divi/section', GlobalInstance::effectiveName($index->find('g0')));
        self::assertSame('divi/text', GlobalInstance::effectiveName($index->find('s0.r0.c0.g0')));
        self::assertSame('divi/section', GlobalInstance::effectiveName($index->find('s0')));
    }

    public function testDeglobalizeRestoresTheLibraryRoot(): void
    {
        $root = self::libraryRoot('library-global--globaler-abschnitt.html');

        $plain = GlobalInstance::deglobalize(self::pageIndex()->find('g0')->copy());

        self::assertSame('divi/section', $plain->name());
        self::assertFalse($plain->isOpaque());
        self::assertEquals($root->attrs(), $plain->attrs());
        self::assertSame(
            array_map([Serializer::class, 'block'], $root->children()),
            array_map([Serializer::class, 'block'], $plain->children())
        );
        self::assertStringNotContainsString('globalParent', Serializer::block($plain));
    }

    public function testDeglobalizeReachesNestedInstances(): void
    {
        $section = GlobalInstance::deglobalize(self::pageIndex()->find('s0')->copy());

        $markup = Serializer::block($section);
        self::assertStringNotContainsString('global-layout', $markup);
        self::assertStringContainsString('<!-- wp:divi/text {"builderVersion":"5.2.1","content":', $markup);
    }
}

tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.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\GlobalInstance;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;

final class LibraryContentTest extends TestCase
{
    /** @return array<string, array{string, string}> */
    public static function savedNodes(): array
    {
        return [
            'section' => ['s1', 'library--library-abschnitt.html'],
            'row' => ['s1.r0', 'library--library-zeile.html'],
            'module' => ['s1.r0.c0.m0', 'library--library-modul.html'],
        ];
    }

    /**
     * The builder's "add to library" wraps the node in divi/placeholder (F4); the fixtures only
     * differ by the CRLF of the form upload (finding 9).
     *
     * @dataProvider savedNodes
     */
    public function testWrapMatchesTheBuilderEntry(string $id, string $file): void
    {
        $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));

        self::assertSame(
            str_replace("\r\n", "\n", DiviFixtures::markup('stage3/' . $file)),
            LibraryContent::wrap($index->find($id))
        );
    }

    public function testLoadUnifiesLineBreaksAndRootsSkipThePlaceholder(): void
    {
        $document = LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/library--layout-ganze-seite.html'));

        self::assertStringNotContainsString("\r", Serializer::document($document));
        self::assertSame(
            ['divi/section', 'divi/section', 'divi/section'],
            array_map(static fn (Block $block): string => $block->name(), LibraryContent::roots($document))
        );
        self::assertSame(
            ['divi/text'],
            array_map(
                static fn (Block $block): string => $block->name(),
                LibraryContent::roots((new Parser())->parse('<!-- wp:divi/text /-->'))
            )
        );
    }

    public function testContainsGlobal(): void
    {
        $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));

        self::assertTrue(LibraryContent::containsGlobal($index->find('s0')));
        self::assertFalse(LibraryContent::containsGlobal($index->find('s1')));
    }

    public function testShapeViolations(): void
    {
        $section = Block::create('divi/section');
        $row = Block::create('divi/row');
        $instance = GlobalInstance::of(Block::create('divi/text'), 9);
        $withInstance = Block::create('divi/section', [], [Block::create('divi/row', [], [
            Block::create('divi/column', [], [$instance]),
        ])]);

        self::assertNull(LibraryContent::shapeViolation([$section], [$section], 'section', false));
        self::assertStringContainsString('nicht leer', (string) LibraryContent::shapeViolation([$section], [], 'section', false));
        self::assertStringContainsString(
            'genau ein divi/section',
            (string) LibraryContent::shapeViolation([$section], [$section, $section], 'section', false)
        );
        self::assertStringContainsString(
            'genau ein divi/section',
            (string) LibraryContent::shapeViolation([$section], [$row], null, false)
        );
        self::assertNull(LibraryContent::shapeViolation([$section, $section], [$section], 'layout', false));
        self::assertStringContainsString(
            'nur Abschnitte',
            (string) LibraryContent::shapeViolation([$section, $section], [$section, $row], 'layout', false)
        );
        self::assertNull(LibraryContent::shapeViolation([$section], [$withInstance], 'section', false));
        self::assertStringContainsString(
            'keine globalen Module',
            (string) LibraryContent::shapeViolation([$section], [$withInstance], 'section', true)
        );
    }
}

In TreeEditorTest:

    /** A global instance counts as its root type: a section instance at the top, a module instance in a column. */
    public function testGlobalInstancesFollowTheRulesOfTheirRootType(): void
    {
        $document = $this->document('stage3/page--globale-instanzen.html');
        $index = NodeIndex::build($document);
        $editor = new TreeEditor(ComposeFixture::rules());

        self::assertNull($editor->move($index, $index->find('g0'), $editor->position($index, ['after' => 's1'])));

        $index = NodeIndex::build($document);
        $module = GlobalInstance::of(Block::create('divi/text'), 1059);
        self::assertNull($editor->insert($editor->position($index, ['parent' => 's1.r0.c0']), [$module]));
        self::assertSame(
            'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
            $editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)])
        );
    }

(Import use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;.)

  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'BlockTest|ContainerTest|GlobalInstanceTest|LibraryContentTest|TreeEditorTest' Expected: FAIL (Methoden/Klassen fehlen)

  • Step 3: Implementieren

Block.php – neue Methode nach copy():

    /**
     * A deep copy of this block under another name and attributes: opener and closer are
     * regenerated, children and the text between them are kept, the void flag too. Opaque exactly
     * when the Parser would make it so. GlobalInstance uses it to turn a library root into an
     * instance and back.
     *
     * @param array<string, mixed> $attrs
     */
    public function copyAs(string $name, array $attrs): self
    {
        if (preg_match(self::NAME_PATTERN, $name) !== 1) {
            throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name));
        }

        $copy = clone $this;
        $copy->name = $name;
        $copy->attrs = self::deepCloneAttrs($attrs);
        $copy->rawOpen = null;
        $copy->rawClose = null;
        $copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs);

        return $copy;
    }

Container.php – neue Methoden nach removeChild():

    /** Puts $new where the child $old is; the text around it stays. */
    public function replaceChild(Block $old, Block $new): void
    {
        $this->guardMutable();
        $this->parts[$this->partPosition($old)] = $new;
        $this->touched();
    }

    /**
     * "\r\n" → "\n" in whitespace-only text between blocks, at every depth. Library entries saved
     * through Divi's "add to library" form carry CRLF (browsers normalise form fields, fixture
     * finding 9); the Visual Builder writes "\n". Text inside blocks' own content stays untouched.
     */
    public function unifyLineBreaks(): void
    {
        foreach ($this->parts as $position => $part) {
            if ($part instanceof Block) {
                $part->unifyLineBreaks();
            } elseif (trim($part) === '' && str_contains($part, "\r\n")) {
                $this->parts[$position] = str_replace("\r\n", "\n", $part);
            }
        }
    }

src/Integrations/Divi/BlockTree/GlobalInstance.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\BlockTree;

defined('ABSPATH') || exit;

/**
 * Global modules the way Divi 5 stores them (F6, F10, fixtures stage3): an instance on a page is a
 * divi/global-layout block whose content Divi takes from the library entry at render time (F7).
 * Pure PHP.
 */
final class GlobalInstance
{
    public const BLOCK = 'divi/global-layout';

    /** Block name the node counts as for structure rules: an instance counts as its root type. */
    public static function effectiveName(Block $block): string
    {
        if ($block->name() !== self::BLOCK) {
            return $block->name();
        }

        $name = $block->attrs()['blockName'] ?? '';

        return is_string($name) ? $name : '';
    }

    /**
     * The instance the Visual Builder writes for library entry $libraryId whose content root is
     * $root (serialized-post.js, filter divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested;
     * fixture page--globale-instanzen.html): divi/global-layout with globalModule, blockName and
     * localAttrs – the root's attributes, top-level keys sorted like the builder's store, left out
     * when empty –, the root's children as snapshot with "globalParent": "<id>" appended to every
     * block below. A void root gives a void instance. $root is not changed.
     */
    public static function of(Block $root, int $libraryId): Block
    {
        $id = (string) $libraryId;
        $local = $root->attrs();
        ksort($local);
        $attrs = ['globalModule' => $id, 'blockName' => $root->name()];
        if ($local !== []) {
            $attrs['localAttrs'] = $local;
        }

        $instance = $root->copyAs(self::BLOCK, $attrs);
        foreach ($instance->children() as $child) {
            self::markParent($child, $id);
        }

        return $instance;
    }

    /**
     * Divi's PortabilityPost::maybe_deglobalize_nested_global_modules() on a tree (Divi 5.2.1
     * PortabilityPost.php:3518, used by DiviLibraryController::load for global entries): every
     * divi/global-layout at or below $block becomes its blockName with localAttrs as attributes,
     * and globalParent disappears from every block. Other opaque blocks stay. Changes $block in
     * place (pass a copy) and returns the result – a new block when $block itself was an instance.
     */
    public static function deglobalize(Block $block): Block
    {
        if ($block->name() === self::BLOCK) {
            $name = self::effectiveName($block);
            if ($name === '') {
                return $block;
            }
            $local = $block->attrs()['localAttrs'] ?? [];
            $block = $block->copyAs($name, is_array($local) ? $local : []);
        }

        if ($block->isOpaque()) {
            return $block;
        }

        if (array_key_exists('globalParent', $block->attrs())) {
            $attrs = $block->attrs();
            unset($attrs['globalParent']);
            $block->setAttrs($attrs);
        }

        foreach ($block->children() as $child) {
            $plain = self::deglobalize($child);
            if ($plain !== $child) {
                $block->replaceChild($child, $plain);
            }
        }

        return $block;
    }

    private static function markParent(Block $block, string $id): void
    {
        if ($block->isOpaque()) {
            return;
        }

        $attrs = $block->attrs();
        unset($attrs['globalParent']);
        $attrs['globalParent'] = $id;
        $block->setAttrs($attrs);
        foreach ($block->children() as $child) {
            self::markParent($child, $id);
        }
    }
}

src/Integrations/Divi/BlockTree/LibraryContent.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\BlockTree;

defined('ABSPATH') || exit;

/**
 * post_content of Divi Library entries (F4, fixtures stage3): the nodes sit in a divi/placeholder
 * frame; a section/row/module entry has exactly one root, a layout entry sections. Pure PHP.
 */
final class LibraryContent
{
    public const WRAPPER = 'divi/placeholder';

    /**
     * Parses library content for copying and unifies its line breaks (Container::unifyLineBreaks()).
     * Never used for the content a tool edits in place – that keeps its bytes.
     *
     * @throws MalformedMarkup
     */
    public static function load(Parser $parser, string $content): Document
    {
        $document = $parser->parse($content);
        $document->unifyLineBreaks();

        return $document;
    }

    /** post_content of a library entry holding $node, framed like the builder's "add to library". */
    public static function wrap(Block $node): string
    {
        return '<!-- wp:' . self::WRAPPER . ' -->' . Serializer::block($node) . '<!-- /wp:' . self::WRAPPER . ' -->';
    }

    /** @return list<Block> Top-level nodes: the children of the first divi/placeholder, else of the document. */
    public static function roots(Document $document): array
    {
        foreach ($document->children() as $block) {
            if ($block->isPlaceholder()) {
                return $block->children();
            }
        }

        return $document->children();
    }

    /** Whether $block is or contains a global module. */
    public static function containsGlobal(Block $block): bool
    {
        if ($block->isGlobal()) {
            return true;
        }

        foreach ($block->children() as $child) {
            if (self::containsGlobal($child)) {
                return true;
            }
        }

        return false;
    }

    /**
     * Why the roots $after may not replace $before, or null. Instances on pages expect their
     * entry's root type (blockName), so a section/row/module entry keeps exactly one root of the
     * same type; a layout entry (or one with several roots) holds sections only. A global entry
     * contains no global modules (F10: Divi forbids nesting).
     *
     * @param list<Block> $before
     * @param list<Block> $after
     */
    public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string
    {
        if ($after === []) {
            return 'Der Bibliothekseintrag darf nicht leer werden.';
        }

        if ($layoutType === 'layout' || count($before) !== 1) {
            foreach ($after as $root) {
                if (GlobalInstance::effectiveName($root) !== 'divi/section') {
                    return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.';
                }
            }
        } else {
            $expected = GlobalInstance::effectiveName($before[0]);
            if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) {
                return sprintf(
                    'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).',
                    $expected
                );
            }
        }

        if ($global) {
            foreach ($after as $root) {
                if (self::containsGlobal($root)) {
                    return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
                        . 'Verschachtelung).';
                }
            }
        }

        return null;
    }
}

TreeEditor.php – Import use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;; in insert():

            $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));

und in move():

        $violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node));

Klassen-Docblock von TreeEditor um „globale Instanzen gelten als ihr Wurzeltyp (blockName)“ ergänzen.

tests/Fixtures/divi/stage3/README.md – unter „Format-Befunde“ als Punkt 9 anfügen:

9. **Zeilenumbrüche in Bibliothekseinträgen:** Einträge aus dem Dialog „Zur Bibliothek hinzufügen“ haben zwischen den Blöcken `\r\n` (1054–1056, 1058; der Browser normalisiert Formularfelder beim Senden an `divi-library/create-item`), Seiten und TB-Layouts `\n`. Der Builder schreibt globale Instanzen mit `\n`. `LibraryContent::load()` vereinheitlicht deshalb Zeilenumbrüche im Leerraum zwischen Blöcken, bevor Inhalte aus der Bibliothek übernommen werden; damit entstehen Instanzen byte-genau wie in `page--globale-instanzen.html`.
  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'BlockTest|ContainerTest|GlobalInstanceTest|LibraryContentTest|TreeEditorTest|ParserTest|SerializerTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/BlockTree src/Integrations/Divi/Compose/TreeEditor.php tests/Unit/Integrations/Divi/BlockTree tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Fixtures/divi/stage3/README.md

Commit wie in den Global Constraints mit der Nachricht feat: global instance in the builder's format, deglobalize, library content rules.


Task 8: Bibliothekseinträge mit den Divi-Werkzeugen lesen – LibraryAccess, DiviPageLoader

E1/E3: lit-divi-outline und lit-divi-node-get nehmen die ID eines Bibliothekseintrags an. LibraryAccess::item() prüft zuerst, ob die ID ein et_pb_layout ist; sonst geht es unverändert über PostGuard weiter (Seiten bleiben wie bisher, ContentSchema::EXCLUDED bleibt). Schreiben folgt in Task 9 – bis dahin laufen Schreibzugriffe auf Bibliotheks-IDs weiter über PostGuard und enden in not_found.

Files:

  • Create: src/Integrations/Divi/Library/LibraryAccess.php
  • Modify: src/Integrations/Divi/Pages/DiviPage.php
  • Modify: src/Integrations/Divi/Pages/DiviPageLoader.php
  • Modify: src/Integrations/Divi/DiviIntegration.php
  • Create: tests/Support/LibraryFixtures.php
  • Test: tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php, tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php, tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php

Interfaces:

  • Consumes: DiviLibraryStore, DiviRoles (Task 6), PostGuard::id(), Errors.

  • Produces:

    • class LibraryAccess { __construct(DiviLibraryStore $store, DiviRoles $roles, ?Closure $writable = null); store(): DiviLibraryStore; roles(): DiviRoles; item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null; canList(): ?WP_Error; canRead(WP_Post $item): ?WP_Error; isGlobal(WP_Post $item): bool } (nicht final; canEdit, canSave, canInsert folgen in Task 9/12/13)
    • DiviPage::__construct(WP_Post $post, Document $document, NodeIndex $index, bool $libraryItem = false), DiviPage::isLibraryItem(): bool; editUrl() liefert für Bibliothekseinträge get_edit_post_link($id, 'raw').
    • DiviPageLoader::__construct(ContentSchema $schema, Parser $parser = new Parser(), ?LibraryAccess $library = null), DiviPageLoader::library(): ?LibraryAccess.
    • LibraryFixtures (Tests): ITEMS, item(int $id, array $fields = []): WP_Post, page(string $content, array $fields = []): WP_Post, given(array $posts, array $denied = []): void, store(): DiviLibraryStore, roles(array $deniedRoles = []): DiviRoles, access(array $deniedRoles = [], bool $writable = true, ?DiviLibraryStore $store = null): LibraryAccess, loader(?LibraryAccess $access = null): DiviPageLoader.
  • Step 1: Test-Helfer und failing tests schreiben

tests/Support/LibraryFixtures.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Tests\Support;

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use Mockery;
use WP_Post;

/**
 * The Divi Library of the tests: the real builder entries of tests/Fixtures/divi/stage3 by their
 * IDs, a page (post 7), WordPress stubs by post ID, a store answering from ITEMS, Divi roles and
 * the library access built from them.
 */
final class LibraryFixtures
{
    public const PAGE = 7;

    /** @var array<int, array{string, string, bool}> ID => [fixture file, layout type, global]. */
    public const ITEMS = [
        1054 => ['library--layout-ganze-seite.html', 'layout', false],
        1055 => ['library--library-abschnitt.html', 'section', false],
        1056 => ['library--library-zeile.html', 'row', false],
        1057 => ['library--library-modul.html', 'module', false],
        1058 => ['library-global--globaler-abschnitt.html', 'section', true],
        1059 => ['library-global--globales-modul.html', 'module', true],
    ];

    /** @param array<string, mixed> $fields */
    public static function item(int $id, array $fields = []): WP_Post
    {
        return new WP_Post(array_merge([
            'ID' => $id,
            'post_type' => DiviLibraryStore::POST_TYPE,
            'post_status' => 'publish',
            'post_title' => '[Fixture] ' . $id,
            'post_content' => DiviFixtures::markup('stage3/' . self::ITEMS[$id][0]),
        ], $fields));
    }

    /** @param array<string, mixed> $fields */
    public static function page(string $content, array $fields = []): WP_Post
    {
        return new WP_Post(array_merge([
            'ID' => self::PAGE,
            'post_type' => 'page',
            'post_status' => 'draft',
            'post_title' => 'Start',
            'post_content' => $content,
        ], $fields));
    }

    /**
     * WordPress stubs for $posts (get_post by ID) and a user with every capability but $denied.
     *
     * @param list<WP_Post> $posts
     * @param list<string>  $denied
     */
    public static function given(array $posts, array $denied = []): void
    {
        $byId = [];
        foreach ($posts as $post) {
            $byId[(int) $post->ID] = $post;
        }
        Functions\when('get_post')->alias(
            static fn (mixed $id): ?WP_Post => $byId[(int) ($id instanceof WP_Post ? $id->ID : $id)] ?? null
        );
        Functions\when('current_user_can')->alias(
            static fn (string $cap, mixed ...$args): bool => ! in_array($cap, $denied, true)
        );
        Functions\when('get_post_type_object')->justReturn(null);
        Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
        Functions\when('get_edit_post_link')->alias(static fn (mixed $post): string => sprintf(
            'https://kunde.test/wp-admin/post.php?post=%d&action=edit',
            $post instanceof WP_Post ? $post->ID : (int) $post
        ));
        Functions\when('add_query_arg')->alias(
            static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value
        );
        Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
    }

    /** A store that answers isGlobal()/layoutType() from ITEMS (no database). */
    public static function store(): DiviLibraryStore
    {
        $store = Mockery::mock(DiviLibraryStore::class)->makePartial();
        $store->allows('isGlobal')->andReturnUsing(static fn (int $id): bool => self::ITEMS[$id][2] ?? false);
        $store->allows('layoutType')->andReturnUsing(static fn (int $id): ?string => self::ITEMS[$id][1] ?? null);

        return $store;
    }

    /** @param list<string> $deniedRoles Divi role options the user lacks. */
    public static function roles(array $deniedRoles = []): DiviRoles
    {
        return new DiviRoles(static fn (string $cap): bool => ! in_array($cap, $deniedRoles, true));
    }

    /** @param list<string> $deniedRoles */
    public static function access(
        array $deniedRoles = [],
        bool $writable = true,
        ?DiviLibraryStore $store = null
    ): LibraryAccess {
        return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable);
    }

    public static function loader(?LibraryAccess $access = null): DiviPageLoader
    {
        return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access());
    }
}

tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php:

<?php

declare(strict_types=1);

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

use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;

final class LibraryAccessTest extends TestCase
{
    public function testReadsLibraryItemsAndLeavesOtherIdsToPostGuard(): void
    {
        $item = LibraryFixtures::item(1055);
        LibraryFixtures::given([$item, LibraryFixtures::page('')]);
        $access = LibraryFixtures::access();

        self::assertSame($item, $access->item(1055, false));
        self::assertNull($access->item(LibraryFixtures::PAGE, false), 'a page is PostGuard\'s business');
        self::assertNull($access->item(9999, false));
        self::assertNull($access->item('abc', false));
        self::assertNull($access->canList());
    }

    public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void
    {
        $item = LibraryFixtures::item(1055);

        LibraryFixtures::given([$item], ['edit_posts']);
        self::assertToolError(LibraryFixtures::access()->item(1055, false), 'forbidden');

        LibraryFixtures::given([$item]);
        self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'forbidden');

        LibraryFixtures::given([$item], ['edit_post']);
        $refusal = LibraryFixtures::access()->item(1055, false);
        self::assertToolError($refusal, 'forbidden');
        self::assertStringContainsString('Bibliothekseintrag 1055', $refusal->get_error_message());
    }

    public function testTrashedItemsCanBeRead(): void
    {
        $item = LibraryFixtures::item(1055, ['post_status' => 'trash']);
        LibraryFixtures::given([$item]);

        self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1055, false));
    }

    public function testGlobalFromTheStore(): void
    {
        $access = LibraryFixtures::access();

        self::assertTrue($access->isGlobal(LibraryFixtures::item(1058)));
        self::assertFalse($access->isGlobal(LibraryFixtures::item(1055)));
    }
}

In DiviPageLoaderTest:

    public function testLoadsALibraryItemForReading(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1058)]);

        $page = LibraryFixtures::loader()->load(1058, false);

        self::assertInstanceOf(DiviPage::class, $page);
        self::assertTrue($page->isLibraryItem());
        self::assertSame('divi/section', $page->index->find('s0')?->name());
        self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $page->editUrl());
    }

    public function testLibraryItemsNeedTheLibraryAccess(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1058)]);
        $schema = Mockery::mock(ContentSchema::class);
        $schema->allows('isManaged')->with('et_pb_layout')->andReturn(false);

        self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found');
        self::assertToolError(
            (new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false),
            'forbidden'
        );
    }

(Imports: LibraryFixtures, ContentSchema, Parser, Mockery.)

In DiviReadAbilitiesTest:

    public function testOutlineOfALibraryItem(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1059)]);
        $ability = new DiviOutlineAbility(LibraryFixtures::loader(), $this->design());

        $result = $ability->execute(['post_id' => 1059]);

        self::assertSame(['m0'], array_column($result['nodes'], 'id'));
        self::assertStringStartsWith('m0 text "Globales Modul Öffnungszeiten"', $result['outline']);
        self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']);
    }
  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'LibraryAccessTest|DiviPageLoaderTest|DiviReadAbilitiesTest' Expected: FAIL (LibraryAccess fehlt)

  • Step 3: Implementieren

src/Integrations/Divi/Library/LibraryAccess.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Library;

use Closure;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;

defined('ABSPATH') || exit;

/**
 * Rights on Divi Library entries (spec §4.5, E3 – never laxer than Divi): the library at all needs
 * edit_posts and the Divi role option divi_library; one entry additionally edit_post on it (Divi's
 * builder routes do not check that, F9). The Divi tools reach library entries only through
 * item(); every other ID goes on to PostGuard unchanged.
 * Not final so abilities can be tested with a mock.
 */
class LibraryAccess
{
    /**
     * @param (Closure(): bool)|null $writable Whether the Tools tab lets this connection write to the
     *        library (group "Divi-Bibliothek" not read-only or disabled); null = always.
     */
    public function __construct(
        private DiviLibraryStore $store,
        private DiviRoles $roles,
        private ?Closure $writable = null
    ) {
    }

    public function store(): DiviLibraryStore
    {
        return $this->store;
    }

    public function roles(): DiviRoles
    {
        return $this->roles;
    }

    /**
     * The library entry $postId after the checks for reading, or their refusal; null when $postId
     * names no library entry (the caller goes on with PostGuard).
     */
    public function item(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 || $post->post_type !== DiviLibraryStore::POST_TYPE) {
            return null;
        }

        return $this->canRead($post) ?? $post;
    }

    /** Listing the library: edit_posts and the Divi role option divi_library. */
    public function canList(): ?WP_Error
    {
        if (! current_user_can('edit_posts')) {
            return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.');
        }

        return $this->roles->refusal('divi_library');
    }

    /** Reading one entry: canList() and edit_post on the entry. */
    public function canRead(WP_Post $item): ?WP_Error
    {
        $refusal = $this->canList();
        if ($refusal !== null) {
            return $refusal;
        }

        return current_user_can('edit_post', (int) $item->ID)
            ? null
            : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID));
    }

    public function isGlobal(WP_Post $item): bool
    {
        return $this->store->isGlobal((int) $item->ID);
    }
}

($forWrite wird in Task 9 ausgewertet; bis dahin ruft DiviPageLoader item() nur zum Lesen auf.)

DiviPage.php – Konstruktor und editUrl():

    public function __construct(
        public readonly WP_Post $post,
        public readonly Document $document,
        public readonly NodeIndex $index,
        private bool $libraryItem = false
    ) {
    }

    /** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */
    public function isLibraryItem(): bool
    {
        return $this->libraryItem;
    }

    public function editUrl(): string
    {
        // 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.php – Konstruktor, library() und Anfang von load() (Import use LIT\MCP\Integrations\Divi\Library\LibraryAccess;, use WP_Post;):

    public function __construct(
        private ContentSchema $schema,
        private Parser $parser = new Parser(),
        private ?LibraryAccess $library = null
    ) {
    }

    /** The library access the Divi tools use, null when library entries are not reachable. */
    public function library(): ?LibraryAccess
    {
        return $this->library;
    }

    /** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
    public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
    {
        // Library entries (E1): only for reading so far, and only with a library access.
        $item = $forWrite ? null : $this->library?->item($postId, false);
        $post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
        if ($post instanceof WP_Error) {
            return $post;
        }

und am Ende von load():

        return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post);

DiviIntegration.php – Bibliothekszugang als Feld (Imports DiviLibraryStore, DiviRoles, LibraryAccess, Parser):

    private LibraryAccess $access;

    public function __construct(private ContentSchema $schema, private ChangeLog $log)
    {
        $this->modules = new DiviModules();
        $this->design = new DesignSystemReader();
        $this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles());
    }

und in abilities():

        $loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'LibraryAccessTest|DiviPageLoaderTest|DiviReadAbilitiesTest|DiviIntegrationTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Library/LibraryAccess.php src/Integrations/Divi/Pages/DiviPage.php src/Integrations/Divi/Pages/DiviPageLoader.php src/Integrations/Divi/DiviIntegration.php tests/Support/LibraryFixtures.php tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php

Commit wie in den Global Constraints mit der Nachricht feat: Divi read tools accept library entries (library access, E1/E3).


Task 9: Bibliothekseinträge schreiben – Formregeln, globale Einträge mit Probelauf, confirm und Sicherung

E2 und Antwort F2: Die Divi-Schreibwerkzeuge (lit-divi-insert, -update, -move, -delete, lit/divi-duplicate-node) ändern Bibliothekseinträge wie Seiten. Zusätzlich (GlobalEdits, aufgerufen aus DiviWriter::commit() nach der vollständig validierten Änderung): Der Eintrag behält seine Form (LibraryContent::shapeViolation); ist er global, wird ohne confirm: true nichts gespeichert und die Antwort nennt die betroffenen Seiten (Probelauf, GlobalUsages sucht "globalModule":"<id>" wie Divi); mit confirm: true sichert DiviSaver wie immer eine Revision im kses-Fenster, bei abgeschalteten Revisionen legt GlobalEdits vorher eine Sicherung an (PostCloner::backup), danach leert DiviSaver seitenweit den CSS- und Theme-Builder-Cache (SaveScope::GlobalItem). Bibliothekseinträge bekommen keine Divi-Flags (Fixture-Befund 4). Das Gruppen-Gate: ist „Divi-Bibliothek“ auf „nur lesen“ oder deaktiviert, sind Bibliothekseinträge für alle Divi-Werkzeuge nur lesbar.

Files:

  • Modify: src/Integrations/Divi/Library/LibraryAccess.php (item() für Schreiben, canEdit(), writableGate(), GROUP)
  • Modify: src/Integrations/Divi/Pages/DiviPageLoader.php (Schreiben, writable())
  • Create: src/Integrations/Divi/Pages/SaveScope.php
  • Modify: src/Integrations/Divi/Pages/DiviSaver.php
  • Create: src/Integrations/Divi/Library/GlobalUsages.php
  • Create: src/Integrations/Divi/Library/GlobalEdits.php
  • Modify: src/Integrations/Divi/Pages/DiviWriter.php (save()-Scope, commit(), notice())
  • Modify: src/Integrations/Divi/Pages/DiviWriteAbility.php (precheck(), commit, notice)
  • Modify: src/Integrations/Divi/Pages/DiviSchema.php (confirm)
  • Modify: src/Abilities/ExposurePolicy.php (groupWritable())
  • Modify: src/Integrations/Divi/DiviIntegration.php, src/Plugin.php
  • Create: tests/Stubs/EtCorePageResource.php; Modify: tests/bootstrap.php, tests/Support/DiviWriterFixture.php
  • Test: tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php, …/Library/GlobalUsagesTest.php, …/Library/GlobalEditsTest.php, tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php, …/Pages/DiviSaverTest.php, tests/Unit/Abilities/ExposurePolicyTest.php

Interfaces:

  • Consumes: LibraryContent::load/roots/shapeViolation (Task 7), LibraryAccess, LibraryFixtures (Task 8), PostCloner::backup(WP_Post): int, Errors::confirmRequired/clientMessage/codeOf/unsupported/invalidInput.

  • Produces:

    • LibraryAccess::GROUP = 'divi-library', LibraryAccess::canEdit(WP_Post $item): ?WP_Error, LibraryAccess::writableGate(): ?WP_Error; item($id, true) prüft canEdit().
    • enum SaveScope { Page, LibraryItem, GlobalItem; setsFlags(): bool; clearsSiteCss(): bool }
    • DiviSaver::save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array, DiviSaver::saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): list<string>
    • class GlobalUsages { const LIMIT = 50; __construct(?\wpdb $db = null); of(int $libraryId): array{items: list<array{post_id: int, title: string, status: string, type: string}>, count: int} } (nicht final)
    • final class GlobalEdits { __construct(LibraryAccess $access, GlobalUsages $usages, ?Closure $backup = null, Parser $parser = new Parser()); prepare(DiviPage $page, array $input): array{scope: SaveScope, details: array<string, mixed>}|WP_Error; notice(DiviPage $page, WP_Error $error): WP_Error }
    • DiviWriter::__construct(…, DiviComposition $composition, ?GlobalEdits $globals = null), DiviWriter::save(…, SaveScope $scope = SaveScope::Page), DiviWriter::commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error, DiviWriter::notice(DiviPage $page, WP_Error $error): WP_Error
    • DiviWriteAbility::precheck(array $input): ?WP_Error (protected, Standard null)
    • ExposurePolicy::groupWritable(string $group): bool
    • DiviIntegration::__construct(ContentSchema $schema, ChangeLog $log, ?Closure $libraryWritable = null)
    • DiviWriterFixture::writer(array &$saved, array &$failures = [], bool $unfiltered = false, ?DiviPageLoader $loader = null, ?GlobalEdits $globals = null)
  • Step 1: Stub und failing tests schreiben

tests/Stubs/EtCorePageResource.php:

<?php

declare(strict_types=1);

if (! class_exists('ET_Core_PageResource')) {
    /**
     * Divi's static CSS cache for the saver tests: records every remove_static_resources() call.
     */
    class ET_Core_PageResource
    {
        /** @var list<array{0: int|string, 1: string, 2: bool}> */
        public static array $removed = [];

        public static function remove_static_resources($postId, $owner, $force = false): void
        {
            self::$removed[] = [$postId, $owner, (bool) $force];
        }
    }
}

In tests/bootstrap.php nach RequestsException.php ergänzen: require_once __DIR__ . '/Stubs/EtCorePageResource.php';

DiviWriterFixture::writer() um den Parameter ?GlobalEdits $globals = null (letzter Parameter, Import LIT\MCP\Integrations\Divi\Library\GlobalEdits) erweitern und an new DiviWriter(…, self::composition($design, $unfiltered), $globals) durchreichen.

In DiviSaverTest:

    public function testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(): void
    {
        \ET_Core_PageResource::$removed = [];
        Functions\when('current_user_can')->justReturn(true);
        Functions\when('wp_update_post')->justReturn(7);
        Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
        $cleared = [];
        Functions\when('et_theme_builder_clear_wp_cache')->alias(static function (string $what) use (&$cleared): void {
            $cleared[] = $what;
        });

        (new DiviSaver())->save($this->post, 'NEU', SaveScope::LibraryItem);
        self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:')));
        self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed);
        self::assertSame([], $cleared);

        \ET_Core_PageResource::$removed = [];
        (new DiviSaver())->save($this->post, 'NEU', SaveScope::GlobalItem);
        self::assertSame([[7, 'all', true], ['all', 'all', true]], \ET_Core_PageResource::$removed);
        self::assertSame(['all'], $cleared);
    }

    public function testSaveNewOfALibraryItemSetsNoFlags(): void
    {
        Functions\when('current_user_can')->justReturn(true);
        Functions\when('wp_update_post')->justReturn(42);
        Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU']));

        self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU', SaveScope::LibraryItem));
        self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:')));
    }

(Import use LIT\MCP\Integrations\Divi\Pages\SaveScope;.)

In LibraryAccessTest:

    public function testEditingNeedsSaveLibraryAPublishedItemAWritableGroupAndForGlobalsEditGlobalLibrary(): void
    {
        LibraryFixtures::given([
            LibraryFixtures::item(1057),
            LibraryFixtures::item(1059),
            LibraryFixtures::item(1055, ['post_status' => 'trash']),
        ]);

        self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1057, true));
        self::assertToolError(LibraryFixtures::access(['save_library'])->item(1057, true), 'forbidden');
        $readonly = LibraryFixtures::access([], false)->item(1057, true);
        self::assertToolError($readonly, 'forbidden');
        self::assertStringContainsString('Divi-Bibliothek', $readonly->get_error_message());
        self::assertToolError(LibraryFixtures::access()->item(1055, true), 'validation_failed');
        self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1059, true));
        self::assertToolError(LibraryFixtures::access(['edit_global_library'])->item(1059, true), 'forbidden');
        self::assertInstanceOf(WP_Post::class, LibraryFixtures::access(['edit_global_library'])->item(1057, true));
    }

In DiviPageLoaderTest:

    public function testLibraryItemsAreWritableWhateverTheBuilderSettingOfTheirType(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1057)]);
        $loader = new DiviPageLoader(DiviFixtures::schema(false), new Parser(), LibraryFixtures::access());

        $page = $loader->loadWritable(1057);

        self::assertInstanceOf(DiviPage::class, $page);
        self::assertTrue($page->isLibraryItem());
    }

In ExposurePolicyTest:

    public function testGroupWritable(): void
    {
        self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library'));
        self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library'));
        self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library'));
        self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library'));
    }

tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php:

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class GlobalUsagesTest extends TestCase
{
    public function testFindsInstancesLikeDiviAndHidesUnreadableTitles(): void
    {
        $db = Mockery::mock(\wpdb::class)->makePartial();
        $queries = [];
        $db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string {
            $queries[] = $sql;

            return '3';
        });
        $db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
            $queries[] = $sql;

            return [
                ['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'],
                ['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'],
            ];
        });
        Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061);

        $usages = (new GlobalUsages($db))->of(1058);

        self::assertSame(3, $usages['count']);
        self::assertSame(
            [
                ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'],
                ['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'],
            ],
            $usages['items']
        );
        self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]);
        self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]);
        self::assertStringContainsString('LIMIT 50', $queries[1]);
    }
}

tests/Unit/Integrations/Divi/Library/GlobalEditsTest.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\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;

/** The Divi write tools on library entries (E2, answer F2). */
final class GlobalEditsTest extends TestCase
{
    /** @var list<array{string, SaveScope}> Content and scope handed to the saver. */
    private array $saved = [];

    private int $backups = 0;

    protected function setUp(): void
    {
        parent::setUp();
        Functions\when('wp_revisions_enabled')->justReturn(true);
    }

    private function usages(): GlobalUsages
    {
        $usages = Mockery::mock(GlobalUsages::class);
        $usages->allows('of')->andReturn([
            'items' => [
                ['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'],
                ['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'],
            ],
            'count' => 2,
        ]);

        return $usages;
    }

    /** @param list<string> $deniedRoles */
    private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter
    {
        $saver = Mockery::mock(DiviSaver::class);
        $saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array {
            $this->saved[] = [$content, $scope];
            $post->post_content = $content;

            return ['revision_id' => 811, 'warnings' => []];
        });
        $access = LibraryFixtures::access($deniedRoles, $writable);
        $design = DiviWriterFixture::designReader();
        $globals = new GlobalEdits($access, $this->usages(), function (WP_Post $item): int {
            ++$this->backups;

            return 1071;
        });

        return new DiviWriter(
            LibraryFixtures::loader($access),
            $saver,
            DiviWriterFixture::log(),
            $design,
            DiviWriterFixture::composition($design),
            $globals
        );
    }

    private static function rev(int $id): string
    {
        return Rev::of((string) LibraryFixtures::item($id)->post_content);
    }

    /** @return array<string, mixed> */
    private static function relabel(int $id, bool $confirm = false): array
    {
        return array_merge(
            ['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]],
            $confirm ? ['confirm' => true] : []
        );
    }

    public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1057)]);

        $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057));

        self::assertTrue($result['ok']);
        self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]);
        self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/blurb ', $this->saved[0][0]);
        self::assertArrayNotHasKey('affected', $result);
    }

    public function testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1059)]);

        $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));

        self::assertToolError($result, 'confirm_required');
        self::assertStringContainsString('wirkt sofort auf 2 Seiten', $result->get_error_message());
        self::assertSame(1053, $result->get_error_data()['affected'][0]['post_id']);
        self::assertSame(2, $result->get_error_data()['affected_count']);
        self::assertSame([], $this->saved);
    }

    public function testConfirmedGlobalEditClearsEveryPageAndKeepsARevision(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1059)]);

        $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));

        self::assertTrue($result['ok']);
        self::assertSame(SaveScope::GlobalItem, $this->saved[0][1]);
        self::assertSame(811, $result['revision_id']);
        self::assertSame(2, $result['affected_count']);
        self::assertArrayNotHasKey('backup_id', $result);
        self::assertSame(0, $this->backups);
    }

    public function testWithoutRevisionsABackupIsMadeFirst(): void
    {
        Functions\when('wp_revisions_enabled')->justReturn(false);
        LibraryFixtures::given([LibraryFixtures::item(1059)]);

        $result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));

        self::assertSame(1071, $result['backup_id']);
        self::assertSame(1, $this->backups);
    }

    public function testRefusesToEmptyOrDoubleTheRoot(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1058)]);

        $emptied = (new DiviDeleteAbility($this->writer()))->execute(
            ['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0'], 'confirm' => true]
        );
        $doubled = (new DiviDuplicateNodeAbility($this->writer()))->execute(
            ['post_id' => 1058, 'tree_rev' => self::rev(1058), 'id' => 's0', 'confirm' => true]
        );

        self::assertToolError($emptied, 'validation_failed');
        self::assertStringContainsString('nicht leer', $emptied->get_error_message());
        self::assertToolError($doubled, 'validation_failed');
        self::assertStringContainsString('genau ein divi/section', $doubled->get_error_message());
        self::assertSame([], $this->saved);
    }

    public function testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1058)]);

        $result = (new DiviDeleteAbility($this->writer()))->execute(
            ['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0.r0.c0.m1']]
        );

        self::assertToolError($result, 'confirm_required');
        self::assertStringContainsString('Globaler Bibliothekseintrag: wirkt auf 2 Seiten', $result->get_error_message());
        self::assertSame(['s0.r0.c0.m1'], $result->get_error_data()['nodes']);
        self::assertCount(2, $result->get_error_data()['affected']);
    }

    public function testConfirmWithAStaleTreeRevWritesNothing(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1059)]);

        $result = (new DiviUpdateAbility($this->writer()))->execute(
            ['tree_rev' => 'abcdef012345'] + self::relabel(1059, true)
        );

        self::assertToolError($result, 'rev_mismatch');
        self::assertSame([], $this->saved);
    }

    public function testGlobalItemsNeedEditGlobalLibraryAndAWritableGroup(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1059), LibraryFixtures::item(1057)]);

        self::assertToolError(
            (new DiviUpdateAbility($this->writer(true, ['edit_global_library'])))->permission(self::relabel(1059)),
            'forbidden'
        );
        self::assertToolError((new DiviUpdateAbility($this->writer(false)))->permission(self::relabel(1057)), 'forbidden');
        self::assertTrue(
            (new DiviUpdateAbility($this->writer(true, ['edit_global_library'])))->permission(self::relabel(1057))
        );
    }
}
  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'DiviSaverTest|LibraryAccessTest|DiviPageLoaderTest|ExposurePolicyTest|GlobalUsagesTest|GlobalEditsTest' Expected: FAIL (SaveScope, GlobalEdits, GlobalUsages, groupWritable fehlen)

  • Step 3: Implementieren

src/Integrations/Divi/Pages/SaveScope.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Pages;

defined('ABSPATH') || exit;

/** What DiviSaver writes to – it decides the Divi flags and how much static CSS is cleared. */
enum SaveScope
{
    /** A page or post: Divi flags, its own static CSS. */
    case Page;

    /** A library entry: no Divi flags (Divi sets none there, fixture finding 4), its own CSS. */
    case LibraryItem;

    /** A global library entry: like LibraryItem, plus the CSS of every page and the Theme Builder cache (spec §5.6). */
    case GlobalItem;

    public function setsFlags(): bool
    {
        return $this === self::Page;
    }

    public function clearsSiteCss(): bool
    {
        return $this === self::GlobalItem;
    }
}

DiviSaver::save() – Signatur public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array; die beiden FollowUp::run-Aufrufe nach dem Speichern ersetzen durch:

        $warnings = [];
        if ($scope->setsFlags()) {
            FollowUp::run(
                static fn () => DiviFlags::set($postId),
                'divi builder flags',
                'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
                    . 'einmal im Divi-Builder öffnen und speichern.',
                $warnings
            );
        }
        FollowUp::run(
            static function () use ($postId, $scope): void {
                if (! class_exists('ET_Core_PageResource')) {
                    return;
                }
                \ET_Core_PageResource::remove_static_resources($postId, 'all', true);
                // A global library entry is part of every page that shows it (spec §5.6 step 5).
                if ($scope->clearsSiteCss()) {
                    \ET_Core_PageResource::remove_static_resources('all', 'all', true);
                    if (function_exists('et_theme_builder_clear_wp_cache')) {
                        et_theme_builder_clear_wp_cache('all');
                    }
                }
            },
            'divi static css',
            'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. '
                . 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
            $warnings
        );

DiviSaver::saveNew() – Signatur public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array, statt DiviFlags::set($postId);:

        if ($scope->setsFlags()) {
            DiviFlags::set($postId);
        }

Klassen-Docblock von DiviSaver um einen Absatz ergänzen: „SaveScope: Bibliothekseinträge bekommen keine Divi-Flags; ein globaler Eintrag leert zusätzlich den CSS-Cache aller Seiten und den Theme-Builder-Cache.“

LibraryAccess – Konstante, item(), canEdit(), writableGate():

    /** Group (integration id) of the library tools in the Tools tab. */
    public const GROUP = 'divi-library';

In item() die letzte Zeile ersetzen:

        return ($forWrite ? $this->canEdit($post) : $this->canRead($post)) ?? $post;

Neue Methoden:

    /**
     * Changing one entry: canRead(), the role option save_library, the Tools-tab gate and a live
     * entry; a global entry additionally needs edit_global_library (E3 – Divi's builder route
     * does not check edit_post on the entry, F9; LIT MCP does).
     */
    public function canEdit(WP_Post $item): ?WP_Error
    {
        $refusal = $this->canRead($item) ?? $this->roles->refusal('save_library') ?? $this->writableGate();
        if ($refusal !== null) {
            return $refusal;
        }

        if ((string) $item->post_status !== 'publish') {
            return Errors::invalidInput(
                sprintf(
                    'Bibliothekseintrag %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
                    (int) $item->ID,
                    (string) $item->post_status
                ),
                ['post_id' => 'nur aktive Bibliothekseinträge'],
                'Den Eintrag zuerst in der Divi-Bibliothek wiederherstellen.'
            );
        }

        return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null;
    }

    /** Refusal while the Tools tab keeps this connection from writing to the library. */
    public function writableGate(): ?WP_Error
    {
        if ($this->writable === null || ($this->writable)()) {
            return null;
        }

        return Errors::forbidden(
            'Schreiben in die Divi-Bibliothek ist für diese Verbindung abgeschaltet (Gruppe „Divi-Bibliothek“ auf '
                . '„nur lesen“ oder deaktiviert).',
            'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
        );
    }

DiviPageLoader::load() – die Bibliothekszeile ersetzen durch:

        // Library entries (E1): with a library access, for reading and writing.
        $item = $this->library?->item($postId, $forWrite);

DiviPageLoader::writable() – die Builder-Prüfung auf Nicht-Bibliothekseinträge beschränken:

        if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {

src/Integrations/Divi/Library/GlobalUsages.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Library;

defined('ABSPATH') || exit;

/**
 * Pages (and Theme Builder layouts) that show global library entry $id: every living post whose
 * content has "globalModule":"<id>" – Divi's own way to find instances (string scan,
 * BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it.
 */
class GlobalUsages
{
    public const LIMIT = 50;

    private const WHERE = "post_content LIKE %s AND post_type <> 'revision' "
        . "AND post_status NOT IN ('trash', 'auto-draft', 'inherit')";

    public function __construct(private ?\wpdb $db = null)
    {
    }

    /** @return array{items: list<array{post_id: int, title: string, status: string, type: string}>, count: int} */
    public function of(int $libraryId): array
    {
        $db = $this->db ?? $GLOBALS['wpdb'];
        $like = '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%';
        // phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
        $count = (int) $db->get_var($db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::WHERE, $like));
        $rows = $db->get_results(
            $db->prepare(
                'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' . self::WHERE
                    . ' ORDER BY ID ASC LIMIT %d',
                $like,
                self::LIMIT
            ),
            'ARRAY_A'
        );
        // phpcs:enable

        $items = [];
        foreach (is_array($rows) ? $rows : [] as $row) {
            $id = (int) $row['ID'];
            $items[] = [
                'post_id' => $id,
                'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)',
                'status' => (string) $row['post_status'],
                'type' => (string) $row['post_type'],
            ];
        }

        return ['items' => $items, 'count' => max($count, count($items))];
    }
}

src/Integrations/Divi/Library/GlobalEdits.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Library;

use Closure;
use LIT\MCP\Abilities\Errors;
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\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
use WP_Error;
use WP_Post;

defined('ABSPATH') || exit;

/**
 * The extra rules of a Divi write to a library entry (E2, answer F2), checked after the whole
 * change validated and before anything is saved:
 * - every entry keeps its shape (LibraryContent::shapeViolation) – instances expect their root type;
 * - a global entry changes every page that shows it at once (F7): without confirm: true nothing is
 *   saved and the answer lists those pages (a dry run of the fully validated change); with it the
 *   save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
 *   copy is made first – the revision itself is DiviSaver's restore point, as for every page.
 */
final class GlobalEdits
{
    /** @var Closure(WP_Post): int */
    private Closure $backup;

    /** @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup(). */
    public function __construct(
        private LibraryAccess $access,
        private GlobalUsages $usages,
        ?Closure $backup = null,
        private Parser $parser = new Parser()
    ) {
        $this->backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item);
    }

    /**
     * @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
    {
        $item = $page->post;
        $id = (int) $item->ID;
        $global = $this->access->isGlobal($item);
        try {
            $before = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
        } catch (MalformedMarkup) {
            $before = [];
        }

        $violation = LibraryContent::shapeViolation(
            $before,
            LibraryContent::roots($page->document),
            $this->access->store()->layoutType($id),
            $global
        );
        if ($violation !== null) {
            return Errors::invalidInput($violation . ' Nichts wurde gespeichert.', ['post_id' => $violation]);
        }

        if (! $global) {
            return ['scope' => SaveScope::LibraryItem, 'details' => []];
        }

        $usages = $this->usages->of($id);
        $details = ['affected' => $usages['items'], 'affected_count' => $usages['count']];
        if (($input['confirm'] ?? false) !== true) {
            return Errors::confirmRequired(
                sprintf(
                    'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch '
                        . 'veröffentlichte. Nichts wurde gespeichert (Probelauf).',
                    $id,
                    $usages['count'],
                    $usages['count'] === 1 ? 'Seite' : 'Seiten'
                ),
                $details
            );
        }

        if (! wp_revisions_enabled($item)) {
            $details['backup_id'] = ($this->backup)($item);
        }

        return ['scope' => SaveScope::GlobalItem, 'details' => $details];
    }

    /**
     * A tool's own confirm_required (lit-divi-delete) on a global entry, extended by the affected
     * pages – so the one confirm the client sends covers both questions.
     */
    public function notice(DiviPage $page, WP_Error $error): WP_Error
    {
        if (Errors::codeOf($error) !== 'confirm_required' || ! $this->access->isGlobal($page->post)) {
            return $error;
        }

        $usages = $this->usages->of((int) $page->post->ID);
        $data = $error->get_error_data();
        $details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];

        return Errors::confirmRequired(
            Errors::clientMessage($error)
                . sprintf(' Globaler Bibliothekseintrag: wirkt auf %d Seiten (siehe affected).', $usages['count']),
            array_merge($details, ['affected' => $usages['items'], 'affected_count' => $usages['count']])
        );
    }
}

DiviWriter.php – Konstruktor um private ?GlobalEdits $globals = null als letzten Parameter erweitern (Imports GlobalEdits, Errors ist vorhanden, WP_Error vorhanden). save() bekommt SaveScope $scope = SaveScope::Page als letzten Parameter und reicht ihn weiter: $saved = $this->saver->save($post, $content, $scope);. Neue Methoden:

    /**
     * save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one
     * needs confirm (dry run first), gets a backup when revisions are off and clears every page's
     * cache; the result then names the affected pages.
     *
     * @param array<string, mixed> $input
     * @return array<string, mixed>|WP_Error
     * @throws WriteFailed
     */
    public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
    {
        if (! $page->isLibraryItem()) {
            return $this->save($ability, $page, $change, $input);
        }

        if ($this->globals === null) {
            return Errors::unsupported(
                'Bibliothekseinträge lassen sich über diese Verbindung nicht ändern.',
                'Den Eintrag im Divi-Builder bearbeiten.'
            );
        }

        if (Serializer::document($page->document) === (string) $page->post->post_content) {
            return $this->save($ability, $page, $change, $input, SaveScope::LibraryItem);
        }

        $prepared = $this->globals->prepare($page, $input);
        if ($prepared instanceof WP_Error) {
            return $prepared;
        }

        return array_merge($this->save($ability, $page, $change, $input, $prepared['scope']), $prepared['details']);
    }

    /** A tool's confirm_required on a global library entry, extended by the affected pages. */
    public function notice(DiviPage $page, WP_Error $error): WP_Error
    {
        return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error;
    }

DiviWriteAbility.php – permission()/execute() ersetzen und precheck() ergänzen:

    final public function permission(array $input): bool|WP_Error
    {
        $refusal = $this->precheck($input);
        if ($refusal !== null) {
            return $refusal;
        }

        $page = $this->writer->page($input);

        return $page instanceof WP_Error ? $page : true;
    }

    final public function execute(array $input): mixed
    {
        $refusal = $this->precheck($input);
        if ($refusal !== null) {
            return $refusal;
        }

        $page = $this->writer->page($input);
        if ($page instanceof WP_Error) {
            return $page;
        }

        $change = $this->change($page, $input, $this->writer->composition($page));
        if ($change instanceof WP_Error) {
            return $this->writer->notice($page, $change);
        }

        return $this->writer->commit($this->name(), $page, $change, $input);
    }

    /**
     * Checks of a tool beyond the page (rights and Tools-tab gates of another group) – run in
     * permission() and again in execute(). Null = none.
     *
     * @param array<string, mixed> $input
     */
    protected function precheck(array $input): ?WP_Error
    {
        return null;
    }

Klassen-Docblock von DiviWriteAbility: „… DiviWriter::commit() stores it (library entries: GlobalEdits) and builds the result.“

DiviSchema::write():

    public static function write(): array
    {
        return [
            'post_id' => ['type' => 'integer', 'minimum' => 1],
            'tree_rev' => [
                'type' => 'string',
                'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
            ],
            'confirm' => [
                'type' => 'boolean',
                'description' => 'Bei globalen Bibliothekseinträgen (und beim Löschen): true erst, nachdem der Nutzer '
                    . 'den Probelauf (confirm_required mit den betroffenen Seiten) bestätigt hat.',
            ],
        ];
    }

ExposurePolicy – nach isReadonly():

    /** Whether abilities of $group may write: the group is neither "nur lesen" nor disabled as a whole. */
    public function groupWritable(string $group): bool
    {
        return ! $this->isReadonly($group) && $this->groupMode($group) !== self::DISABLED;
    }

DiviIntegration.php – Konstruktor und Writer (Imports Closure, GlobalEdits, GlobalUsages):

    /**
     * @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
     *        (group "Divi-Bibliothek" writable); null = always.
     */
    public function __construct(private ContentSchema $schema, private ChangeLog $log, ?Closure $libraryWritable = null)
    {
        $this->modules = new DiviModules();
        $this->design = new DesignSystemReader();
        $this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable);
    }

In abilities():

        $loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
        $composition = new DiviComposition($this->modules, $this->design);
        $writer = new DiviWriter(
            $loader,
            new DiviSaver(),
            $this->log,
            $this->design,
            $composition,
            new GlobalEdits($this->access, new GlobalUsages())
        );

Plugin::boot() (Import use LIT\MCP\Integrations\Divi\Library\LibraryAccess;): die Zeile $divi = new DiviIntegration($schema, $changeLog); entfällt an ihrer bisherigen Stelle; der Block ab $schema = self::contentSchema(); bis vor $registry = new IntegrationRegistry( lautet danach:

        $schema = self::contentSchema();
        $registry = null;
        $policy = null;
        // Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
        // when registry and policy exist), so the Divi group's switches stop every Divi layout write.
        $diviWritable = static function () use (&$registry, &$policy): bool {
            return $registry instanceof IntegrationRegistry
                && $policy instanceof ExposurePolicy
                && $policy->allows('lit/divi-insert', $registry->abilities());
        };
        // The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9).
        $libraryWritable = static function () use (&$policy): bool {
            return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP);
        };
        $divi = new DiviIntegration($schema, $changeLog, $libraryWritable);

(Beide Closures fragen erst bei der Ausführung; $policy wird weiter unten gesetzt.)

  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'DiviSaverTest|LibraryAccessTest|DiviPageLoaderTest|ExposurePolicyTest|GlobalUsagesTest|GlobalEditsTest|DiviWriterTest|DiviWriteAbilityTest|DiviDeleteAbilityTest|DiviReplaceTextAbilityTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Library src/Integrations/Divi/Pages/SaveScope.php src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviPageLoader.php src/Integrations/Divi/Pages/DiviWriter.php src/Integrations/Divi/Pages/DiviWriteAbility.php src/Integrations/Divi/Pages/DiviSchema.php src/Abilities/ExposurePolicy.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php tests/Stubs/EtCorePageResource.php tests/bootstrap.php tests/Support/DiviWriterFixture.php tests/Unit

Commit wie in den Global Constraints mit der Nachricht feat: Divi tools write library entries; global ones with dry run, confirm and backup (E2, F2).


Task 10: Gruppe „Divi-Bibliothek“ und lit/library-list (mit id als Detail)

E9: lit/library-list ersetzt lit/library-get (Spec §5.7 wird in Task 16 angepasst). Ohne id eine Seite der Liste mit Filtern und cursor, mit id ein Eintrag mit Knoten-Übersicht, tree_rev, rev und – bei globalen Einträgen – den Seiten, die ihn zeigen. Die drei lit/library-*-Tools bilden eine eigene Integration (= Gruppe im Tools-Tab).

Files:

  • Create: src/Integrations/Divi/Library/LibraryIntegration.php
  • Create: src/Integrations/Divi/Library/LibraryListAbility.php
  • Modify: src/Integrations/Divi/DiviIntegration.php (library(), loader())
  • Modify: src/Plugin.php (Registrierung)
  • Modify: docs/guides/overview.md (indirekte Tools, Gruppenliste)
  • Test: tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php, tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Interfaces:

  • Consumes: LibraryAccess::canList(), DiviPageLoader::load(), DiviLibraryStore::find/describe, GlobalUsages::of(), Outline, Cursor, Rev.

  • Produces:

    • final class LibraryIntegration implements Integration { __construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design); id() = LibraryAccess::GROUP; label() = 'Divi-Bibliothek' } (Task 12/13 erweitern den Konstruktor)
    • final class LibraryListAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design, GlobalUsages $usages) } – Name lit/library-list, indirekt, nur lesend; Ausgabe {items, next_cursor} bzw. mit id zusätzlich outline, tree_rev, rev, edit_url, bei globalen affected, affected_count.
    • DiviIntegration::library(): LibraryIntegration
  • Step 1: Failing tests schreiben

tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.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\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Library\LibraryListAbility;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class LibraryListAbilityTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Functions\when('get_the_terms')->justReturn(false);
        Functions\when('get_post_meta')->justReturn('');
    }

    private function ability(array $deniedRoles = []): LibraryListAbility
    {
        $store = LibraryFixtures::store();
        $store->allows('find')->andReturnUsing(static fn (array $filters, int $limit, int $offset): array => array_slice(
            [LibraryFixtures::item(1055), LibraryFixtures::item(1058), LibraryFixtures::item(1059)],
            $offset,
            $limit + 1
        ));
        $access = LibraryFixtures::access($deniedRoles, true, $store);
        $usages = Mockery::mock(GlobalUsages::class);
        $usages->allows('of')->with(1058)->andReturn([
            'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page']],
            'count' => 1,
        ]);

        return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages);
    }

    public function testListsAPageWithCursor(): void
    {
        LibraryFixtures::given([]);

        $first = $this->ability()->execute(['limit' => 2]);
        $second = $this->ability()->execute(['limit' => 2, 'cursor' => $first['next_cursor']]);

        self::assertSame([1055, 1058], array_column($first['items'], 'id'));
        self::assertSame(['normal', 'global'], array_column($first['items'], 'scope'));
        self::assertSame('section', $first['items'][0]['layout_type']);
        self::assertSame(Cursor::encode(2), $first['next_cursor']);
        self::assertSame([1059], array_column($second['items'], 'id'));
        self::assertNull($second['next_cursor']);
    }

    public function testPassesTheFilters(): void
    {
        LibraryFixtures::given([]);
        $store = LibraryFixtures::store();
        $store->expects('find')->with(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten'], 20, 0)
            ->andReturn([]);
        $access = LibraryFixtures::access([], true, $store);
        $ability = new LibraryListAbility(
            $access,
            LibraryFixtures::loader($access),
            DiviWriterFixture::designReader(),
            Mockery::mock(GlobalUsages::class)
        );

        $result = $ability->execute(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten', 'category' => '']);

        self::assertSame(['items' => [], 'next_cursor' => null], $result);
    }

    public function testDetailOfAGlobalEntryWithOutlineAndAffectedPages(): void
    {
        LibraryFixtures::given([LibraryFixtures::item(1058)]);

        $result = $this->ability()->execute(['id' => 1058]);

        self::assertSame(1058, $result['items'][0]['id']);
        self::assertNull($result['next_cursor']);
        self::assertStringStartsWith('s0 section "Global Abschnitt"', $result['outline']);
        self::assertSame(12, strlen($result['tree_rev']));
        self::assertSame(1, $result['affected_count']);
        self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $result['edit_url']);
    }

    public function testDetailOfSomethingElseIsNotFoundAndTheRoleIsNeeded(): void
    {
        LibraryFixtures::given([LibraryFixtures::page('')]);

        self::assertToolError($this->ability()->execute(['id' => LibraryFixtures::PAGE]), 'not_found');
        self::assertToolError($this->ability(['divi_library'])->permission([]), 'forbidden');
        self::assertToolError($this->ability()->execute(['cursor' => '!!']), 'validation_failed');
        self::assertTrue($this->ability()->annotations()['readonly']);
        self::assertFalse($this->ability()->isDirect());
    }
}

(LibraryFixtures::given([]) stubbt die Rechte ohne Posts; Cursor::invalid() liefert validation_failed.)

In DiviIntegrationTest:

    public function testProvidesTheLibraryGroup(): void
    {
        $library = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->library();

        self::assertSame('divi-library', $library->id());
        self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
        self::assertSame(
            ['lit/library-list'],
            array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
        );
        foreach ($library->abilities() as $ability) {
            self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
            self::assertNotNull($ability->outputSchema(), $ability->name());
        }
    }
  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'LibraryListAbilityTest|DiviIntegrationTest' Expected: FAIL (Klassen fehlen)

  • Step 3: Implementieren

src/Integrations/Divi/Library/LibraryListAbility.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\Outline;
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use WP_Error;
use WP_Post;

defined('ABSPATH') || exit;

/**
 * lit/library-list: the Divi Library (spec §5.7, E9 – replaces lit/library-get). Without id a page
 * of published entries (filters, cursor); with id one entry with its node outline, tree_rev and,
 * for a global entry, the pages that show it. Rights: LibraryAccess (edit_posts + divi_library;
 * one entry also edit_post).
 */
final class LibraryListAbility implements Ability
{
    public function __construct(
        private LibraryAccess $access,
        private DiviPageLoader $loader,
        private DesignSystemReader $design,
        private GlobalUsages $usages
    ) {
    }

    public function name(): string
    {
        return 'lit/library-list';
    }

    public function label(): string
    {
        return 'Divi-Bibliothek auflisten';
    }

    public function description(): string
    {
        return 'Listet Einträge der Divi-Bibliothek (Layouts, Abschnitte, Zeilen, Module; normal oder global), '
            . 'filterbar nach layout_type, scope, search und category, blätterbar mit cursor. Mit id: ein Eintrag '
            . 'im Detail – Knoten-Übersicht, tree_rev und bei globalen Einträgen die Seiten, die ihn zeigen '
            . '(affected). Einfügen mit lit-library-insert; bearbeiten mit den Divi-Werkzeugen und der ID als post_id.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => array_merge([
                'id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Ein Eintrag im Detail.'],
                'layout_type' => ['type' => 'string', 'enum' => DiviLibraryStore::LAYOUT_TYPES],
                'scope' => ['type' => 'string', 'enum' => ['normal', 'global']],
                'search' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Suche im Titel.'],
                'category' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Name einer Kategorie.'],
            ], Cursor::inputProperties()),
            'additionalProperties' => false,
        ];
    }

    public function outputSchema(): ?array
    {
        return [
            'type' => 'object',
            'properties' => [
                'items' => [
                    'type' => 'array',
                    'items' => [
                        'type' => 'object',
                        'properties' => [
                            'id' => ['type' => 'integer'],
                            'title' => ['type' => 'string'],
                            'layout_type' => ['type' => ['string', 'null']],
                            'scope' => ['type' => 'string'],
                            'module_type' => ['type' => ['string', 'null']],
                            'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
                            'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
                            'modified' => ['type' => 'string'],
                        ],
                        'required' => ['id', 'title', 'layout_type', 'scope', 'module_type', 'categories', 'tags', 'modified'],
                    ],
                ],
                'next_cursor' => ['type' => ['string', 'null']],
                'outline' => ['type' => 'string'],
                'tree_rev' => ['type' => 'string'],
                'rev' => ['type' => 'string'],
                'edit_url' => ['type' => 'string'],
                'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
                'affected_count' => ['type' => 'integer'],
            ],
            'required' => ['items', 'next_cursor'],
        ];
    }

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

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

    public function permission(array $input): bool|WP_Error
    {
        $refusal = $this->access->canList();
        if ($refusal !== null) {
            return $refusal;
        }

        if (! isset($input['id'])) {
            return true;
        }

        $page = $this->loader->load($input['id'], false);

        return $page instanceof WP_Error ? $page : true;
    }

    public function execute(array $input): mixed
    {
        $refusal = $this->access->canList();
        if ($refusal !== null) {
            return $refusal;
        }

        if (isset($input['id'])) {
            return $this->detail($input['id']);
        }

        $offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
        if ($offset === null) {
            return Cursor::invalid();
        }

        $limit = Cursor::limit($input['limit'] ?? null);
        $filters = array_filter(
            [
                'layout_type' => $input['layout_type'] ?? null,
                'scope' => $input['scope'] ?? null,
                'search' => $input['search'] ?? null,
                'category' => $input['category'] ?? null,
            ],
            static fn (mixed $value): bool => is_string($value) && $value !== ''
        );
        $store = $this->access->store();
        $page = Cursor::page(
            array_map(static fn (WP_Post $post): array => $store->describe($post), $store->find($filters, $limit, $offset)),
            $limit,
            $offset
        );

        return ['items' => $page['items'], 'next_cursor' => $page['next_cursor']];
    }

    /** @return array<string, mixed>|WP_Error */
    private function detail(mixed $id): array|WP_Error
    {
        $page = $this->loader->load($id, false);
        if ($page instanceof WP_Error) {
            return $page;
        }

        if (! $page->isLibraryItem()) {
            return Errors::notFound(
                sprintf('Eintrag %d ist kein Eintrag der Divi-Bibliothek.', (int) $page->post->ID),
                'Einträge mit lit/library-list (ohne id) auflisten.'
            );
        }

        $item = $this->access->store()->describe($page->post);
        $outline = new Outline($this->design->read()->presetNames());
        $result = [
            'items' => [$item],
            'next_cursor' => null,
            'outline' => $outline->text($outline->rows($page->index)),
            'tree_rev' => $page->treeRev(),
            'rev' => Rev::ofPost($page->post),
            'edit_url' => $page->editUrl(),
        ];
        if ($item['scope'] === 'global') {
            $usages = $this->usages->of((int) $page->post->ID);
            $result['affected'] = $usages['items'];
            $result['affected_count'] = $usages['count'];
        }

        return $result;
    }
}

src/Integrations/Divi/Library/LibraryIntegration.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Library;

use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\Integration;

defined('ABSPATH') || exit;

/**
 * The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, lit/library-insert. Built by
 * DiviIntegration::library() so it shares the library access (rights, Tools-tab gate). Available
 * with the Divi 5 builder, like the Divi group.
 */
final class LibraryIntegration implements Integration
{
    public function __construct(
        private LibraryAccess $access,
        private DiviPageLoader $loader,
        private DesignSystemReader $design
    ) {
    }

    public function id(): string
    {
        return LibraryAccess::GROUP;
    }

    public function label(): string
    {
        return __('Divi-Bibliothek', 'wp-lit-mcp');
    }

    public function isAvailable(): bool
    {
        return DiviModules::isAvailable();
    }

    public function abilities(): array
    {
        return [
            new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()),
        ];
    }
}

DiviIntegration.php (Import LibraryIntegration):

    /** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */
    public function library(): LibraryIntegration
    {
        return new LibraryIntegration($this->access, $this->loader(), $this->design);
    }

    private function loader(): DiviPageLoader
    {
        return new DiviPageLoader($this->schema, new Parser(), $this->access);
    }

und in abilities() $loader = $this->loader();.

Plugin::boot() – die Registry um die Gruppe erweitern:

        $registry = new IntegrationRegistry(
            new CoreIntegration(self::pluginDir() . '/docs/guides'),
            new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
            new MediaIntegration($schema, $changeLog, $limits),
            $divi,
            $divi->library()
        );

docs/guides/overview.md: in „Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi)“ → „(Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek)“; am Ende der indirekten Liste „Divi (lit/divi-duplicate-node, lit/divi-replace-text).“ → „Divi (lit/divi-duplicate-node, lit/divi-replace-text), Divi-Bibliothek (lit/library-list).“

  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'LibraryListAbilityTest|DiviIntegrationTest|ToolNamingTest|ToolsTabTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Library/LibraryIntegration.php src/Integrations/Divi/Library/LibraryListAbility.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Commit wie in den Global Constraints mit der Nachricht feat: group Divi-Bibliothek with lit/library-list.


Task 11: StoredNodeGuard – gespeicherte Knoten, die den Post wechseln (E6)

Audit M1 und R2-Befund 1: Wer einen gespeicherten Knoten über eine andere Post-ID überträgt, darf die S7/S26-Sperre nicht umgehen, und Divi filtert Dynamic-Content-before/after nach dem Autor des Ziel-Posts. Für Benutzer ohne unfiltered_html wird deshalb abgelehnt (nicht bereinigt): Builder-only-Pfade (ValueCleaner::guardPath()), Code-Modul, $variable-Verweise, die VariableRef::canonical() für das Ziel ablehnt oder deren Einstellungen es verändern würde. Alles andere bleibt byte-identisch. Auch localAttrs globaler Instanzen werden geprüft (Divi nutzt sie für entkoppelte Attribute, F7).

Files:

  • Create: src/Integrations/Divi/Compose/StoredNodeGuard.php
  • Modify: src/Integrations/Divi/Compose/VariableRef.php (settingsOf())
  • Modify: src/Integrations/Divi/Pages/DiviComposition.php (storedGuard())
  • Test: tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php, tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php

Interfaces:

  • Consumes: ValueCleaner::guardPath()/kindOf(), VariableRef::canonical(), AttrMap::flatten(), GlobalInstance::effectiveName()/BLOCK.

  • Produces: final class StoredNodeGuard { const CODE_MODULES = ['divi/code', 'divi/fullwidth-code']; __construct(ValueCleaner $cleaner, VariableRef $references, ValueSanitizer $sanitizer); problems(Block $root): list<string> }; VariableRef::settingsOf(string $value): array; DiviComposition::storedGuard(): StoredNodeGuard.

  • Step 1: Failing tests schreiben

tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php:

<?php

declare(strict_types=1);

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

use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;

final class StoredNodeGuardTest extends TestCase
{
    private static function guard(bool $unfiltered = false): StoredNodeGuard
    {
        $references = new VariableRef(
            DiviFixtures::designSystem(),
            DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
        );
        $sanitizer = ComposeFixture::sanitizer($unfiltered);

        return new StoredNodeGuard(new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer), $references, $sanitizer);
    }

    private static function block(string $markup): Block
    {
        return (new Parser())->parse($markup)->children()[0];
    }

    public function testBuilderContentWithInstancesAndLinksPasses(): void
    {
        $index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
        $link = '<!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt","linkUrl":'
            . '"$variable({"type":"content","value":{"name":'
            . '"post_link_url_page","settings":{"post_id":"839"}}})$"}}}}} /-->';

        self::assertSame([], self::guard()->problems($index->find('s0')));
        self::assertSame([], self::guard()->problems($index->find('g0')));
        self::assertSame([], self::guard()->problems(self::block($link)));
    }

    /** @return array<string, array{string, string}> */
    public static function refused(): array
    {
        $ref = static fn (string $name, string $settings): string => '"$variable({"type":"content",'
            . '"value":{"name":"' . $name . '","settings":' . $settings . '}})$"';

        return [
            'custom css' => [
                '<!-- wp:divi/text {"css":{"desktop":{"value":{"freeForm":"body{display:none}"}}}} /-->',
                '"css"',
            ],
            'html attributes' => [
                '<!-- wp:divi/text {"module":{"decoration":{"attributes":{"desktop":{"value":{"attributes":'
                    . '[{"name":"data-x","value":"1"}]}}}}}} /-->',
                'module.decoration.attributes',
            ],
            'code module' => ['<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"x"}}}} /-->', 'Code-Modul'],
            'script in before' => [
                '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":' . $ref(
                    'post_title',
                    '{"before":"<script>x</script>","after":""}'
                ) . '}}}} /-->',
                'nicht schreiben darf',
            ],
            'protected meta' => [
                '<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":' . $ref('custom_meta__edit_lock', '{}')
                    . '}}}} /-->',
                'custom_meta__edit_lock',
            ],
            'css in the local attributes of an instance' => [
                '<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/text","localAttrs":{"css":{"desktop":'
                    . '{"value":{"freeForm":"a{}"}}}}} /-->',
                '"css"',
            ],
            'code module deep inside a section' => [
                '<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column --><!-- wp:divi/code /-->'
                    . '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->',
                'Code-Modul',
            ],
        ];
    }

    /** @dataProvider refused */
    public function testRefusesBuilderOnlyValuesForUsersWithoutUnfilteredHtml(string $markup, string $problem): void
    {
        $problems = self::guard()->problems(self::block($markup));

        self::assertNotSame([], $problems);
        self::assertStringContainsString($problem, implode(' | ', $problems));
        self::assertSame([], self::guard(true)->problems(self::block($markup)), 'unfiltered_html may carry it');
    }
}

In DiviCompositionTest ergänzen:

    public function testStoredGuardSharesTheCleanerAndReferences(): void
    {
        $composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7);

        self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard());
        self::assertSame(
            [],
            $composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1']))
        );
    }

(Imports StoredNodeGuard, Block, DiviWriterFixture.)

  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'StoredNodeGuardTest|DiviCompositionTest' Expected: FAIL (StoredNodeGuard fehlt)

  • Step 3: Implementieren

VariableRef.php – nach typeOf():

    /**
     * The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares
     * a stored reference with canonical(): different settings mean cleaning would change them.
     *
     * @return array<array-key, mixed>
     */
    public static function settingsOf(string $value): array
    {
        try {
            $payload = self::payloadOf($value);
        } catch (InvalidArgumentException) {
            return [];
        }

        $settings = $payload['value']['settings'] ?? [];

        return is_array($settings) ? $settings : [];
    }

src/Integrations/Divi/Compose/StoredNodeGuard.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Compose;

use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;

defined('ABSPATH') || exit;

/**
 * E6 (security audit M1, R2 finding 1): a stored node that moves to another post – page → page
 * (lit/divi-node-copy), library → page (lit/library-insert), page → library (lit/library-save) – keeps
 * its bytes, but the builder-only gate must hold across post IDs, and Divi filters dynamic content
 * before/after by the author of the TARGET post. For users without unfiltered_html the node is
 * refused when anything in it (localAttrs of instances included) is
 *  - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26),
 *  - a code module,
 *  - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose
 *    settings canonical() would clean (e.g. before/after with markup such a user may not write).
 * Nothing is cleaned or rewritten – a refused node stays a job for the Divi builder. Pure PHP.
 */
final class StoredNodeGuard
{
    public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];

    /** Keys of a breakpoint map that are no sub-field names. */
    private const STATE_KEYS = ['value', 'hover', 'sticky'];

    public function __construct(
        private ValueCleaner $cleaner,
        private VariableRef $references,
        private ValueSanitizer $sanitizer
    ) {
    }

    /** @return list<string> German problems; [] when $root may move to the target post. */
    public function problems(Block $root): array
    {
        if ($this->sanitizer->allowsUnfilteredHtml()) {
            return [];
        }

        $problems = [];
        $this->walk($root, $problems);

        return array_values(array_unique($problems));
    }

    /** @param list<string> $problems */
    private function walk(Block $block, array &$problems): void
    {
        $module = GlobalInstance::effectiveName($block);
        if (in_array($module, self::CODE_MODULES, true)) {
            $problems[] = sprintf(
                '%s: Code-Modul – nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html).',
                $module
            );
        }

        $attrs = $block->attrs();
        if ($block->name() === GlobalInstance::BLOCK) {
            $attrs = is_array($attrs['localAttrs'] ?? null) ? $attrs['localAttrs'] : [];
        }

        foreach (AttrMap::flatten($attrs) as $path => $value) {
            try {
                $this->cleaner->guardPath((string) $path);
            } catch (InvalidArgumentException $e) {
                $problems[] = $e->getMessage();
                continue;
            }
            $this->references($module, (string) $path, $value, null, $problems);
        }

        foreach ($block->children() as $child) {
            $this->walk($child, $problems);
        }
    }

    /** @param list<string> $problems */
    private function references(string $module, string $path, mixed $value, ?string $key, array &$problems): void
    {
        if (is_array($value)) {
            foreach ($value as $childKey => $item) {
                $isField = is_string($childKey)
                    && ! in_array($childKey, AttrMap::BREAKPOINTS, true)
                    && ! in_array($childKey, self::STATE_KEYS, true);
                $this->references($module, $path, $item, $isField ? $childKey : $key, $problems);
            }

            return;
        }

        if (! is_string($value) || ! VariableRef::contains($value)) {
            return;
        }

        try {
            $url = $this->cleaner->kindOf($module, $path, $key) === ValueKind::Url;
            $canonical = $this->references->canonical($value, $this->sanitizer, $url);
        } catch (InvalidArgumentException $e) {
            $problems[] = sprintf('%s: %s', $path, $e->getMessage());

            return;
        }

        if (self::normalized(VariableRef::settingsOf($canonical)) !== self::normalized(VariableRef::settingsOf($value))) {
            $problems[] = sprintf(
                '%s: Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – nur im Divi-Builder '
                    . 'übertragbar.',
                $path
            );
        }
    }

    /**
     * @param array<array-key, mixed> $settings
     * @return array<array-key, mixed> Keys sorted, scalars as strings (post_id 839 = "839").
     */
    private static function normalized(array $settings): array
    {
        ksort($settings);

        return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings);
    }
}

DiviComposition (Import StoredNodeGuard):

    /** E6: the check for stored nodes that move into this composition's entry. */
    public function storedGuard(): StoredNodeGuard
    {
        return new StoredNodeGuard($this->cleaner(), $this->references(), $this->sanitizer);
    }
  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'StoredNodeGuardTest|DiviCompositionTest|VariableRefTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Compose/StoredNodeGuard.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php

Commit wie in den Global Constraints mit der Nachricht feat: StoredNodeGuard keeps S7/S26 for nodes moving between posts (E6).


Task 12: lit/library-save – Knoten als Bibliothekseintrag (E4)

E4: Das Plugin legt Einträge selbst an – kein et_pb_submit_layout (im REST-Kontext nicht geladen, speichert mit kses, F2), keine Builder-Route (Nonce, Preset-Import, F3). wp_insert_post mit leerem Inhalt und Status publish (Divi kennt keine Bibliotheksentwürfe), Terme und Meta wie et_pb_submit_layout (verglichen mit den echten Builder-Einträgen der Fixtures; _et_pb_row_layout wird nicht geschrieben – Divi selbst speichert dort "[object Object]", Befund 5), danach der Inhalt über DiviSaver::saveNew (SaveScope::LibraryItem, ohne Divi-Flags, Befund 4) als divi/placeholder-Rahmen um die unveränderten Bytes des Knotens. Alles oder nichts: jeder Fehler löscht den neuen Eintrag endgültig. Der Knoten auf der Seite bleibt unverändert (das Verknüpfen ist lit/library-make-global, Etappe 3b).

Files:

  • Create: src/Integrations/Divi/Library/LibraryItemSpec.php
  • Modify: src/Integrations/Divi/Library/DiviLibraryStore.php (create(), delete())
  • Modify: src/Integrations/Divi/Library/LibraryAccess.php (canSave())
  • Create: src/Integrations/Divi/Library/LibrarySaveAbility.php
  • Modify: src/Integrations/Divi/Library/LibraryIntegration.php, src/Integrations/Divi/DiviIntegration.php
  • Modify: docs/guides/overview.md (indirekte Tools)
  • Test: tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php, …/DiviLibraryStoreTest.php, …/LibraryAccessTest.php, …/LibrarySaveAbilityTest.php, tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Interfaces:

  • Consumes: LibraryContent::wrap/containsGlobal, NodeSummary::globalRef, ModuleCatalog::has/category, DiviSaver::saveNew(…, SaveScope::LibraryItem), StoredNodeGuard über DiviWriter::composition($page)->storedGuard(), PostSaveFollowUp, Errors::writeFailed/partialWrite/revMismatch.

  • Produces:

    • final class LibraryItemSpec { __construct(string $title, string $layoutType, bool $global, string $moduleWidth, ?string $moduleType, string $builtFor, array $categories = [], array $tags = []); static forNode(Block $node, ModuleCatalog $catalog, string $title, bool $global, string $builtFor): self|string; withTerms(array $categories, array $tags): self; terms(): array<string, list<int|string>>; meta(): array<string, string> }
    • DiviLibraryStore::create(LibraryItemSpec $spec): int (wirft RuntimeException, löscht bei Teilfehlern), DiviLibraryStore::delete(int $id): bool
    • LibraryAccess::canSave(): ?WP_Error
    • final class LibrarySaveAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules) } – lit/library-save, indirekt, schreibend (nicht idempotent); Ergebnis ToolResult + library_id, tree_rev.
    • LibraryIntegration::__construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules)
  • Step 1: Failing tests schreiben

tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.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\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;

final class LibraryItemSpecTest extends TestCase
{
    private static function pageNode(string $id): Block
    {
        return NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')))
            ->find($id);
    }

    private static function libraryRoot(string $file): Block
    {
        return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0];
    }

    /** @return array<string, array{string, bool}> Fixture role => [node source, global]. */
    public static function builderEntries(): array
    {
        return [
            'library--library-abschnitt' => ['page:s1', false],
            'library--library-zeile' => ['page:s1.r0', false],
            'library--library-modul' => ['page:s1.r0.c0.m0', false],
            'library-global--globaler-abschnitt' => ['library:library-global--globaler-abschnitt.html', true],
            'library-global--globales-modul' => ['library:library-global--globales-modul.html', true],
        ];
    }

    /**
     * Terms and meta as Divi's own "add to library" wrote them (fixtures stage3, JSON of each entry),
     * without Divi's broken _et_pb_row_layout ("[object Object]", finding 5).
     *
     * @dataProvider builderEntries
     */
    public function testTermsAndMetaMatchTheBuilderEntries(string $source, bool $global): void
    {
        [$kind, $ref] = explode(':', $source, 2);
        $node = $kind === 'page' ? self::pageNode($ref) : self::libraryRoot($ref);
        $role = (string) $this->dataName();
        $json = json_decode(DiviFixtures::markup('stage3/' . $role . '.json'), true);

        $spec = LibraryItemSpec::forNode($node, DiviFixtures::catalog(), 'Titel', $global, 'page');

        self::assertInstanceOf(LibraryItemSpec::class, $spec);
        $expectedTerms = [];
        foreach (['scope', 'layout_type', 'module_width'] as $taxonomy) {
            $expectedTerms[$taxonomy] = array_column($json['terms'][$taxonomy], 'slug');
        }
        self::assertEquals($expectedTerms, $spec->terms());
        $expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']);
        unset($expectedMeta['_et_pb_row_layout']);
        self::assertEquals($expectedMeta, $spec->meta());
    }

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

        self::assertStringContainsString(
            'bereits ein globales Modul (Bibliothekseintrag 1058)',
            (string) LibraryItemSpec::forNode(self::pageNode('g0'), $catalog, 'T', false, 'page')
        );
        self::assertStringContainsString(
            'nur Abschnitte, Zeilen und Module',
            (string) LibraryItemSpec::forNode(self::pageNode('s1.r0.c0'), $catalog, 'T', false, 'page')
        );
        self::assertStringContainsString(
            'keine globalen Module',
            (string) LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', true, 'page')
        );
        self::assertInstanceOf(LibraryItemSpec::class, LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', false, 'page'));
    }

    public function testFullwidthAndTerms(): void
    {
        $section = Block::create('divi/section', ['module' => ['advanced' => ['type' => ['desktop' => ['value' => 'fullwidth']]]]]);

        $spec = LibraryItemSpec::forNode($section, DiviFixtures::catalog(), 'Kopf', false, 'page');

        self::assertSame(['fullwidth'], $spec->terms()['module_width']);
        self::assertSame([26], $spec->withTerms([26], [])->terms()['layout_category']);
        self::assertArrayNotHasKey('layout_tag', $spec->withTerms([26], [])->terms());
    }
}

In DiviLibraryStoreTest:

    public function testCreatesAPublishedEntryWithTermsAndMeta(): void
    {
        Functions\when('taxonomy_exists')->justReturn(true);
        $inserted = [];
        Functions\when('wp_insert_post')->alias(static function (array $data) use (&$inserted): int {
            $inserted = $data;

            return 2001;
        });
        $terms = [];
        Functions\when('wp_set_object_terms')->alias(static function (int $id, array $values, string $taxonomy) use (&$terms): array {
            $terms[$taxonomy] = $values;

            return [1];
        });
        $meta = [];
        Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$meta): bool {
            $meta[$key] = $value;

            return true;
        });
        Functions\when('get_post_meta')->alias(static fn (int $id, string $key): string => $meta[$key] ?? '');
        $spec = new LibraryItemSpec('Öffnungszeiten', 'module', true, 'regular', 'divi/text', 'page');

        self::assertSame(2001, (new DiviLibraryStore())->create($spec));
        self::assertSame(
            ['post_type' => 'et_pb_layout', 'post_status' => 'publish', 'post_title' => 'Öffnungszeiten', 'post_content' => ''],
            $inserted
        );
        self::assertSame(['scope' => ['global'], 'layout_type' => ['module'], 'module_width' => ['regular']], $terms);
        self::assertSame('[]', $meta['_et_pb_excluded_global_options']);
    }

    public function testAFailedStepDeletesTheNewEntry(): void
    {
        Functions\when('taxonomy_exists')->justReturn(true);
        Functions\when('wp_insert_post')->justReturn(2001);
        Functions\when('wp_set_object_terms')->justReturn(new \WP_Error('invalid_taxonomy', 'Ungültige Taxonomie.'));
        Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(new WP_Post(['ID' => 2001]));

        $this->expectException(\RuntimeException::class);
        (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'));
    }

    public function testRefusesWhenTheLibraryTaxonomiesAreMissing(): void
    {
        Functions\when('taxonomy_exists')->justReturn(false);
        Functions\expect('wp_insert_post')->never();

        $this->expectException(\RuntimeException::class);
        (new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'));
    }

(Import LibraryItemSpec. Die Stubs aus setUp() für get_post_meta werden im ersten Test per Functions\when überschrieben.)

In LibraryAccessTest:

    public function testSavingNeedsPublishPostsSaveLibraryAndAWritableGroup(): void
    {
        LibraryFixtures::given([]);
        self::assertNull(LibraryFixtures::access()->canSave());

        LibraryFixtures::given([], ['publish_posts']);
        self::assertToolError(LibraryFixtures::access()->canSave(), 'forbidden');

        LibraryFixtures::given([]);
        self::assertToolError(LibraryFixtures::access(['save_library'])->canSave(), 'forbidden');
        self::assertToolError(LibraryFixtures::access([], false)->canSave(), 'forbidden');
    }

tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.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\Integrations\Divi\Library\LibraryItemSpec;
use LIT\MCP\Integrations\Divi\Library\LibrarySaveAbility;
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 RuntimeException;
use WP_Post;
use WP_Term;

final class LibrarySaveAbilityTest extends TestCase
{
    private WP_Post $page;

    /** @var list<LibraryItemSpec> */
    private array $specs = [];

    /** @var list<array{int, string, SaveScope}> */
    private array $contents = [];

    private int $deleted = 0;

    private FakeChangeLog $log;

    protected function setUp(): void
    {
        parent::setUp();
        $this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
        LibraryFixtures::given([$this->page]);
        Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture'
            ? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy])
            : false);
        $this->log = new FakeChangeLog(4711);
    }

    /** @param list<string> $deniedRoles */
    private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility
    {
        $store = LibraryFixtures::store();
        $store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
            $this->specs[] = $spec;

            return 2001;
        });
        $store->allows('delete')->andReturnUsing(function (int $id): bool {
            ++$this->deleted;

            return true;
        });
        $saver = Mockery::mock(DiviSaver::class);
        $saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array {
            if ($layoutFailure !== null) {
                throw $layoutFailure;
            }
            $this->contents[] = [$id, $content, $scope];
            LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]);

            return [];
        });
        $access = LibraryFixtures::access($deniedRoles, $writable, $store);
        $saved = [];
        $failures = [];

        return new LibrarySaveAbility(
            $access,
            LibraryFixtures::loader($access),
            DiviWriterFixture::writer($saved, $failures, false, LibraryFixtures::loader($access)),
            $saver,
            $this->log,
            DiviWriterFixture::modules()
        );
    }

    /** @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 testSavesASectionLikeTheBuilder(): void
    {
        $result = $this->ability()->execute($this->input(['categories' => ['Fixture']]));

        self::assertTrue($result['ok']);
        self::assertSame(2001, $result['library_id']);
        self::assertSame('section', $this->specs[0]->layoutType);
        self::assertSame([26], $this->specs[0]->terms()['layout_category']);
        self::assertSame(
            str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
            $this->contents[0][1]
        );
        self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]);
        self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']);
        self::assertSame('et_pb_layout', $this->log->records[0]['object_type']);
        self::assertSame('2001', $this->log->records[0]['object_id']);
    }

    public function testSavesAGlobalModule(): void
    {
        $result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global']));

        self::assertTrue($result['ok']);
        self::assertTrue($this->specs[0]->global);
        self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']);
        self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']);
    }

    public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void
    {
        self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
        self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed');
        self::assertSame([], $this->specs);
    }

    public function testStaleSourceTreeRevCreatesNothing(): void
    {
        self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch');
        self::assertSame([], $this->specs);
    }

    public function testNeedsPublishPostsAndTheDiviRole(): void
    {
        self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden');
        self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden');
        LibraryFixtures::given([$this->page], ['publish_posts']);
        self::assertToolError($this->ability()->permission($this->input()), 'forbidden');
        self::assertSame([], $this->specs);
    }

    public function testEditorCannotSaveACodeModule(): 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(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result)));
        self::assertSame([], $this->specs);
    }

    public function testUnknownCategoryCreatesNothing(): void
    {
        $result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']]));

        self::assertToolError($result, 'validation_failed');
        self::assertSame([], $this->specs);
    }

    public function testFailedLayoutWriteDeletesTheEntry(): void
    {
        $log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
        $previous = ini_set('error_log', $log);
        try {
            $result = $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input());
        } finally {
            ini_set('error_log', (string) $previous);
            @unlink($log);
        }

        self::assertToolError($result, 'write_failed');
        self::assertSame(1, $this->deleted);
    }
}

In DiviIntegrationTest::testProvidesTheLibraryGroup die erwartete Liste auf ['lit/library-list', 'lit/library-save'] erweitern.

  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'LibraryItemSpecTest|DiviLibraryStoreTest|LibraryAccessTest|LibrarySaveAbilityTest|DiviIntegrationTest' Expected: FAIL (LibraryItemSpec, create, canSave, LibrarySaveAbility fehlen)

  • Step 3: Implementieren

src/Integrations/Divi/Library/LibraryItemSpec.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Library;

use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;

defined('ABSPATH') || exit;

/**
 * Title, terms and meta of a new library entry, like Divi's et_pb_submit_layout() writes them
 * (builder/shortcode-core.php:654-734; compared with the real builder entries, fixtures stage3):
 * terms scope (global / non_global), layout_type, module_width, optional layout_category/layout_tag;
 * meta _et_pb_module_type (modules), _et_pb_excluded_global_options = "[]" (global modules),
 * _et_pb_built_for_post_type, _et_pb_template_type. _et_pb_row_layout is left out – Divi 5.2.1 stores
 * "[object Object]" there (finding 5). Pure PHP.
 */
final class LibraryItemSpec
{
    private const MODULE_CATEGORIES = ['module', 'fullwidth-module'];

    /**
     * @param list<int> $categories layout_category term IDs.
     * @param list<int> $tags       layout_tag term IDs.
     */
    public function __construct(
        public readonly string $title,
        public readonly string $layoutType,
        public readonly bool $global,
        public readonly string $moduleWidth,
        public readonly ?string $moduleType,
        public readonly string $builtFor,
        public readonly array $categories = [],
        public readonly array $tags = []
    ) {
    }

    /**
     * The entry that holds $node, or the German reason it cannot be saved: sections, rows and
     * modules only (Divi's "add to library"); a global module is already an entry; a global entry
     * holds no global module (F10).
     */
    public static function forNode(
        Block $node,
        ModuleCatalog $catalog,
        string $title,
        bool $global,
        string $builtFor
    ): self|string {
        $ref = NodeSummary::globalRef($node);
        if ($ref !== null) {
            return sprintf(
                'Dieser Knoten ist bereits ein globales Modul (Bibliothekseintrag %s); ihn mit lit-library-insert '
                    . 'einfügen statt erneut speichern.',
                $ref['id']
            );
        }

        $name = $node->name();
        if ($name === 'divi/section') {
            $type = 'section';
            $width = AttrMap::desktop($node->attrs(), 'module.advanced.type') === 'fullwidth' ? 'fullwidth' : 'regular';
        } elseif ($name === 'divi/row') {
            $type = 'row';
            $width = 'regular';
        } elseif ($catalog->has($name) && in_array($catalog->category($name), self::MODULE_CATEGORIES, true)) {
            $type = 'module';
            $width = $catalog->category($name) === 'fullwidth-module' ? 'fullwidth' : 'regular';
        } else {
            return sprintf(
                '%s lässt sich nicht einzeln in der Bibliothek speichern (nur Abschnitte, Zeilen und Module).',
                $name
            );
        }

        if ($global && LibraryContent::containsGlobal($node)) {
            return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
                . 'Verschachtelung); als normalen Eintrag speichern.';
        }

        return new self($title, $type, $global, $width, $type === 'module' ? $name : null, $builtFor);
    }

    /**
     * @param list<int> $categories
     * @param list<int> $tags
     */
    public function withTerms(array $categories, array $tags): self
    {
        return new self(
            $this->title,
            $this->layoutType,
            $this->global,
            $this->moduleWidth,
            $this->moduleType,
            $this->builtFor,
            $categories,
            $tags
        );
    }

    /** @return array<string, list<int|string>> Taxonomy => slugs (system terms) or term IDs. */
    public function terms(): array
    {
        $terms = [
            'scope' => [$this->global ? 'global' : 'non_global'],
            'layout_type' => [$this->layoutType],
            'module_width' => [$this->moduleWidth],
        ];
        if ($this->categories !== []) {
            $terms['layout_category'] = $this->categories;
        }
        if ($this->tags !== []) {
            $terms['layout_tag'] = $this->tags;
        }

        return $terms;
    }

    /** @return array<string, string> */
    public function meta(): array
    {
        $meta = [];
        if ($this->moduleType !== null) {
            $meta['_et_pb_module_type'] = $this->moduleType;
        }
        if ($this->global && $this->layoutType === 'module') {
            $meta['_et_pb_excluded_global_options'] = '[]';
        }
        $meta['_et_pb_built_for_post_type'] = $this->builtFor;
        $meta['_et_pb_template_type'] = $this->layoutType;

        return $meta;
    }
}

DiviLibraryStore – neue Methoden (Imports RuntimeException, WP_Error):

    /**
     * A published entry with the terms and meta of $spec and empty content (E4; the layout follows
     * through DiviSaver::saveNew()). All or nothing: when a term or meta write fails, the entry is
     * deleted again before the exception leaves.
     *
     * @throws RuntimeException
     */
    public function create(LibraryItemSpec $spec): int
    {
        $missing = $this->missingTaxonomies();
        if ($missing !== []) {
            throw new RuntimeException('Divi Library taxonomies not registered: ' . implode(', ', $missing));
        }

        $id = wp_insert_post(wp_slash([
            'post_type' => self::POST_TYPE,
            'post_status' => 'publish',
            'post_title' => $spec->title,
            'post_content' => '',
        ]), true);
        if ($id instanceof WP_Error || (int) $id <= 0) {
            throw new RuntimeException(
                'wp_insert_post refused the library entry: ' . ($id instanceof WP_Error ? $id->get_error_message() : '0')
            );
        }

        $id = (int) $id;
        try {
            foreach ($spec->terms() as $taxonomy => $terms) {
                $result = wp_set_object_terms($id, $terms, $taxonomy);
                if ($result instanceof WP_Error) {
                    throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message()));
                }
            }
            foreach ($spec->meta() as $key => $value) {
                update_post_meta($id, $key, $value);
                if ((string) get_post_meta($id, $key, true) !== $value) {
                    throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id));
                }
            }
        } catch (RuntimeException $e) {
            $this->delete($id);
            throw $e;
        }

        return $id;
    }

    /** Deletes an entry for good (only this plugin's own, just created entries). */
    public function delete(int $id): bool
    {
        return (bool) wp_delete_post($id, true);
    }

LibraryAccess::canSave():

    /**
     * Creating an entry: canList(), publish_posts of et_pb_layout (library entries are always
     * published, F2), the role option save_library and the Tools-tab gate.
     */
    public function canSave(): ?WP_Error
    {
        $refusal = $this->canList();
        if ($refusal !== null) {
            return $refusal;
        }

        $type = get_post_type_object(DiviLibraryStore::POST_TYPE);
        $publish = is_object($type) && isset($type->cap->publish_posts) ? (string) $type->cap->publish_posts : 'publish_posts';
        if (! current_user_can($publish)) {
            return Errors::forbidden('Keine Berechtigung, Einträge der Divi-Bibliothek anzulegen (publish_posts).');
        }

        return $this->roles->refusal('save_library') ?? $this->writableGate();
    }

src/Integrations/Divi/Library/LibrarySaveAbility.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\Errors;
use LIT\MCP\Abilities\Rev;
use LIT\MCP\Abilities\ToolResult;
use LIT\MCP\ChangeLog\ChangeLog;
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
use LIT\MCP\Log;
use RuntimeException;
use Throwable;
use WP_Error;
use WP_Term;

defined('ABSPATH') || exit;

/**
 * lit/library-save: a node of a Divi page (or library entry) becomes a new Divi Library entry,
 * normal or global (spec §5.7, E4). The entry gets the unchanged bytes of the node in the
 * divi/placeholder frame (LibraryContent::wrap), saved through DiviSaver::saveNew (the one content
 * write, D7); the stored node is checked by StoredNodeGuard (E6) because it moves to another post.
 * The source stays as it is. All or nothing: any failure deletes the new entry.
 */
final class LibrarySaveAbility implements Ability
{
    private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];

    public function __construct(
        private LibraryAccess $access,
        private DiviPageLoader $loader,
        private DiviWriter $writer,
        private DiviSaver $saver,
        private ChangeLog $log,
        private DiviModules $modules
    ) {
    }

    public function name(): string
    {
        return 'lit/library-save';
    }

    public function label(): string
    {
        return 'In der Divi-Bibliothek speichern';
    }

    public function description(): string
    {
        return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – '
            . 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die '
            . 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit '
            . 'lit-library-insert (mode "global") auf Seiten einfügen.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'post_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Seite (oder Eintrag) mit dem Knoten.'],
                'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'],
                'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
                'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
                'scope' => ['type' => 'string', 'enum' => ['normal', 'global'], 'description' => 'Standard: normal.'],
                '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']],
            ['library_id', 'tree_rev']
        );
    }

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

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

    public function permission(array $input): bool|WP_Error
    {
        $refusal = $this->access->canSave();
        if ($refusal !== null) {
            return $refusal;
        }

        $page = $this->loader->load($input['post_id'] ?? null, false);

        return $page instanceof WP_Error ? $page : true;
    }

    public function execute(array $input): mixed
    {
        $refusal = $this->access->canSave();
        if ($refusal !== null) {
            return $refusal;
        }

        $page = $this->loader->load($input['post_id'] ?? null, false);
        if ($page instanceof WP_Error) {
            return $page;
        }

        if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
            return Errors::revMismatch(
                'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.',
                DiviPageLoader::OUTLINE_HINT
            );
        }

        $id = is_string($input['id'] ?? null) ? $input['id'] : '';
        $node = $page->index->find($id);
        if ($node === null) {
            return DiviErrors::unknownNode($id, $page->post);
        }

        $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,
            ($input['scope'] ?? 'normal') === 'global',
            (string) $page->post->post_type
        );
        if (is_string($spec)) {
            return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
        }

        $problems = $this->writer->composition($page)->storedGuard()->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 = $this->termIds('layout_category', $input['categories'] ?? []);
        $tags = $this->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 $this->create($page, $spec->withTerms($categories, $tags), LibraryContent::wrap($node), $input);
    }

    /**
     * @param array<string, mixed> $input
     * @return array<string, mixed>|WP_Error
     */
    private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array|WP_Error
    {
        try {
            $libraryId = $this->access->store()->create($spec);
        } catch (RuntimeException $e) {
            Log::failure('lit/library-save create', $e);

            return Errors::writeFailed('Der Bibliothekseintrag konnte nicht angelegt werden.');
        }

        try {
            $warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem);
        } catch (Throwable $e) {
            Log::failure('lit/library-save layout', $e);
            if (! $this->access->store()->delete($libraryId)) {
                return Errors::partialWrite('Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.', $libraryId);
            }

            return Errors::writeFailed('Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.');
        }

        $followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $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)
        );

        $result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created)
            ->with('library_id', $libraryId)
            ->with('tree_rev', Rev::of($created !== null ? (string) $created->post_content : $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();
    }

    /** @return list<int>|string Term IDs of existing terms by name, or the problem. */
    private 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 $ids;
    }
}

LibraryIntegration – Konstruktor und abilities() (Imports ChangeLog, DiviSaver, DiviWriter):

    public function __construct(
        private LibraryAccess $access,
        private DiviPageLoader $loader,
        private DesignSystemReader $design,
        private DiviWriter $writer,
        private DiviSaver $saver,
        private ChangeLog $log,
        private DiviModules $modules
    ) {
    }

    public function abilities(): array
    {
        return [
            new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()),
            new LibrarySaveAbility($this->access, $this->loader, $this->writer, $this->saver, $this->log, $this->modules),
        ];
    }

DiviIntegration – Writer-Fabrik teilen und library() anpassen:

    public function library(): LibraryIntegration
    {
        $loader = $this->loader();

        return new LibraryIntegration(
            $this->access,
            $loader,
            $this->design,
            $this->writer($loader),
            new DiviSaver(),
            $this->log,
            $this->modules
        );
    }

    private function writer(DiviPageLoader $loader): DiviWriter
    {
        return new DiviWriter(
            $loader,
            new DiviSaver(),
            $this->log,
            $this->design,
            new DiviComposition($this->modules, $this->design),
            new GlobalEdits($this->access, new GlobalUsages())
        );
    }

In abilities() $writer = $this->writer($loader); statt des bisherigen new DiviWriter(…).

docs/guides/overview.md: „Divi-Bibliothek (lit/library-list)“ → „Divi-Bibliothek (lit/library-list, lit/library-save)“.

  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'LibraryItemSpecTest|DiviLibraryStoreTest|LibraryAccessTest|LibrarySaveAbilityTest|DiviIntegrationTest|ToolNamingTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi

Commit wie in den Global Constraints mit der Nachricht feat: lit/library-save creates library entries like Divi (E4).


Task 13: lit/library-insert – Kopie oder globale Instanz im Builder-Format (E5, direkt)

Das einzige neue direkte Tool (22 direkte Tools). mode = global (Standard für globale Einträge) fügt die Instanz genau so ein, wie der Visual Builder sie schreibt (GlobalInstance::of, byte-gleich zum Fixture) – den Inhalt liefert Divi beim Rendern aus der Bibliothek (F7). mode = copy (Standard sonst) fügt die Wurzeln des Eintrags ohne divi/placeholder ein; bei globalen Einträgen vorher entglobalisiert wie Divis /load (GlobalInstance::deglobalize, F10). Rechte: Ziel wie lit-divi-insert (gleiche Sperre, alles oder nichts), dazu add_library (E3) und – Audit M2 – nur solange lit/divi-insert nicht gesperrt ist. Übernommene Knoten laufen durch StoredNodeGuard.

Files:

  • Create: src/Integrations/Divi/Library/LibraryInsertAbility.php
  • Modify: src/Integrations/Divi/Library/LibraryAccess.php (canInsert())
  • Modify: src/Integrations/Divi/Library/LibraryIntegration.php, src/Integrations/Divi/DiviIntegration.php, src/Plugin.php
  • Modify: docs/guides/overview.md („Direkt sichtbar:“, Ruling Q2)
  • Test: tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php, …/LibraryAccessTest.php, tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Interfaces:

  • Consumes: DiviWriteAbility (Task 9: precheck, commit), GlobalInstance::of/deglobalize, LibraryContent::load/roots, DiviComposition::storedGuard/editor, TreeEditor::position/insert, DiviSchema::write/position.

  • Produces: LibraryAccess::canInsert(): ?WP_Error; final class LibraryInsertAbility extends DiviWriteAbility { __construct(DiviWriter $writer, LibraryAccess $access, ?Closure $diviWritable = null, Parser $parser = new Parser()) } – lit/library-insert, direkt; LibraryIntegration::__construct(…, DiviModules $modules, ?Closure $diviWritable = null); DiviIntegration::library(?Closure $diviWritable = null).

  • Step 1: Failing tests schreiben

In LibraryAccessTest:

    public function testInsertingNeedsAddLibrary(): void
    {
        LibraryFixtures::given([]);

        self::assertNull(LibraryFixtures::access()->canInsert());
        self::assertToolError(LibraryFixtures::access(['add_library'])->canInsert(), 'forbidden');
    }

tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php:

<?php

declare(strict_types=1);

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

use LIT\MCP\Abilities\Rev;
use LIT\MCP\Integrations\Divi\Library\LibraryInsertAbility;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;

final class LibraryInsertAbilityTest extends TestCase
{
    private WP_Post $page;

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

    protected function setUp(): void
    {
        parent::setUp();
        $this->page = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html'));
        LibraryFixtures::given(array_merge(
            [$this->page],
            array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS))
        ));
    }

    /** @param list<string> $deniedRoles */
    private function ability(array $deniedRoles = [], bool $diviWritable = true): LibraryInsertAbility
    {
        $access = LibraryFixtures::access($deniedRoles);
        $failures = [];

        return new LibraryInsertAbility(
            DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)),
            $access,
            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)], $extra);
    }

    private static function fixtureInstance(int $id, string $end): string
    {
        $page = DiviFixtures::markup('stage3/page--globale-instanzen.html');
        $start = (int) strpos($page, '<!-- wp:divi/global-layout {"globalModule":"' . $id . '"');

        return substr($page, $start, (int) strpos($page, $end, $start) + strlen($end) - $start);
    }

    public function testInsertsAGlobalSectionExactlyLikeTheBuilder(): void
    {
        $result = $this->ability()->execute($this->input(['library_id' => 1058, 'after' => 's0']));

        self::assertTrue($result['ok']);
        self::assertStringContainsString(self::fixtureInstance(1058, '<!-- /wp:divi/global-layout -->'), $this->saved[0]);
        self::assertSame('g0', $result['changed'][0]['id']);
        self::assertStringContainsString('Bibliothekseintrag 1058', implode(' ', $result['warnings']));
    }

    public function testInsertsAGlobalModuleIntoAColumn(): void
    {
        $result = $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1']));

        self::assertTrue($result['ok']);
        self::assertStringContainsString(self::fixtureInstance(1059, '/-->'), $this->saved[0]);
    }

    public function testCopyInsertsTheRootsWithoutFrameAndCrlf(): void
    {
        $result = $this->ability()->execute($this->input(['library_id' => 1055]));

        $section = str_replace(
            ['<!-- wp:divi/placeholder -->', '<!-- /wp:divi/placeholder -->', "\r\n"],
            ['', '', "\n"],
            DiviFixtures::markup('stage3/library--library-abschnitt.html')
        );
        self::assertTrue($result['ok']);
        self::assertStringContainsString($section, $this->saved[0]);
        self::assertStringNotContainsString("\r", $this->saved[0]);
    }

    public function testLayoutCopyInsertsEverySection(): void
    {
        $result = $this->ability()->execute($this->input(['library_id' => 1054, 'mode' => 'copy']));

        self::assertCount(3, $result['changed']);
        self::assertStringContainsString('Library-Blurb', $this->saved[0]);
    }

    public function testGlobalModeNeedsAGlobalEntryAndUnknownEntriesAreNotFound(): void
    {
        self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'global'])), 'validation_failed');
        self::assertToolError($this->ability()->execute($this->input(['library_id' => 9999])), 'not_found');
        self::assertToolError($this->ability()->execute($this->input(['library_id' => 7])), 'not_found');
        self::assertSame([], $this->saved);
    }

    public function testNeedsAddLibraryAndAWritableDiviGroup(): void
    {
        self::assertToolError($this->ability(['add_library'])->permission($this->input(['library_id' => 1058])), 'forbidden');
        self::assertToolError($this->ability([], false)->permission($this->input(['library_id' => 1058])), 'unsupported');
        self::assertTrue($this->ability()->isDirect());
    }

    public function testEditorCannotInsertAnItemWithCustomCss(): void
    {
        $custom = LibraryFixtures::item(1055, [
            'ID' => 1080,
            'post_content' => '<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":'
                . '{"freeForm":"body{display:none}"}}}} --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
        ]);
        LibraryFixtures::given([$this->page, $custom]);

        $result = $this->ability()->execute($this->input(['library_id' => 1080]));

        self::assertToolError($result, 'validation_failed');
        self::assertSame([], $this->saved);
    }
}

In DiviIntegrationTest::testProvidesTheLibraryGroup die Liste auf ['lit/library-list', 'lit/library-save', 'lit/library-insert'] erweitern.

  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'LibraryAccessTest|LibraryInsertAbilityTest|DiviIntegrationTest|ToolNamingTest' Expected: FAIL (canInsert, LibraryInsertAbility fehlen; ToolNamingTest bleibt grün, bis das Tool existiert)

  • Step 3: Implementieren

LibraryAccess::canInsert():

    /** Inserting from the library: canList() and the role option add_library (E3). */
    public function canInsert(): ?WP_Error
    {
        return $this->canList() ?? $this->roles->refusal('add_library');
    }

src/Integrations/Divi/Library/LibraryInsertAbility.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Library;

use Closure;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\Errors;
use LIT\MCP\Integrations\Divi\BlockTree\Block;
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
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;
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
use LIT\MCP\Integrations\Divi\Pages\DiviWriteAbility;
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
use LIT\MCP\Integrations\WordPress\PostGuard;
use WP_Error;
use WP_Post;

defined('ABSPATH') || exit;

/**
 * lit/library-insert (spec §5.7, E5): a Divi Library entry into a Divi page – as the global instance
 * the Visual Builder writes (GlobalInstance::of; Divi renders the entry's current content, F7) or
 * as an independent copy of its roots (a global entry deglobalized like Divi's /load, F10). The
 * target works like lit-divi-insert (tree_rev lock, StructureRules, all or nothing, DiviWriter);
 * stored nodes from the entry pass StoredNodeGuard (E6). Needs add_library (E3) and follows
 * lit-divi-insert's Tools-tab state (audit M2).
 */
final class LibraryInsertAbility extends DiviWriteAbility
{
    /** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. */
    public function __construct(
        DiviWriter $writer,
        private LibraryAccess $access,
        private ?Closure $diviWritable = null,
        private Parser $parser = new Parser()
    ) {
        parent::__construct($writer);
    }

    public function name(): string
    {
        return 'lit/library-insert';
    }

    public function label(): string
    {
        return 'Aus der Divi-Bibliothek einfügen';
    }

    public function description(): string
    {
        return 'Fügt einen Eintrag der Divi-Bibliothek in eine Divi-Seite ein: mode "global" als verknüpfte Instanz '
            . '(der Inhalt kommt immer aus der Bibliothek; Änderungen dort wirken auf allen Seiten) oder mode "copy" '
            . 'als unabhängige Kopie. Standard: global für globale Einträge, sonst copy. Einträge über lit/library-list; '
            . 'Position über parent, before oder after (Pfad-IDs aus lit-divi-outline), sonst ans Seitenende. '
            . 'tree_rev ist Pflicht; alles oder nichts.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => array_merge(DiviSchema::write(), [
                'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'],
                'mode' => ['type' => 'string', 'enum' => ['copy', 'global']],
            ], DiviSchema::position()),
            'required' => ['post_id', 'tree_rev', 'library_id'],
            'additionalProperties' => false,
        ];
    }

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

    protected function precheck(array $input): ?WP_Error
    {
        $refusal = $this->access->canInsert();
        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;
    }

    protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
    {
        $item = $this->item($input['library_id'] ?? null);
        if ($item instanceof WP_Error) {
            return $item;
        }

        $id = (int) $item->ID;
        $global = $this->access->isGlobal($item);
        $mode = is_string($input['mode'] ?? null) ? $input['mode'] : ($global ? 'global' : 'copy');
        if ($mode === 'global' && ! $global) {
            return Errors::invalidInput(
                'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.',
                ['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden']
            );
        }

        $blocks = $this->blocks($item, $mode, $global);
        if ($blocks instanceof WP_Error) {
            return $blocks;
        }

        $guard = $composition->storedGuard();
        foreach ($blocks as $block) {
            $problems = $guard->problems($block);
            if ($problems !== []) {
                return Errors::invalidInput(
                    'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; '
                        . 'nichts wurde gespeichert.',
                    ['library_id' => implode('; ', $problems)]
                );
            }
        }

        $editor = $composition->editor();
        $position = $editor->position($page->index, $input);
        if (is_string($position)) {
            return DiviErrors::position($position);
        }

        $violation = $editor->insert($position, $blocks);
        if ($violation !== null) {
            return Errors::invalidInput(
                'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.',
                ['library_id' => $violation]
            );
        }

        $change = new DiviChange(sprintf(
            'Bibliothekseintrag „%s“ %s eingefügt',
            (string) $item->post_title,
            $mode === 'global' ? 'als globale Instanz' : 'als Kopie'
        ));
        foreach ($blocks as $block) {
            $change->record($block, 'inserted');
        }
        if ($mode === 'global') {
            $change->warning(sprintf(
                'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit '
                    . 'diesem Modul (lit-divi-outline mit post_id %d).',
                $id,
                $id
            ));
        }

        return $change;
    }

    /** The active library entry $libraryId the user may read, or the error. */
    private function item(mixed $libraryId): WP_Post|WP_Error
    {
        $id = PostGuard::id($libraryId);
        $item = $id === null ? null : get_post($id);
        if (
            ! $item instanceof WP_Post
            || $item->post_type !== DiviLibraryStore::POST_TYPE
            || $item->post_status !== 'publish'
        ) {
            return Errors::notFound(
                sprintf('Bibliothekseintrag %s gibt es nicht (oder er ist nicht aktiv).', (string) ($id ?? '?')),
                'Einträge mit lit/library-list auflisten.'
            );
        }

        return current_user_can('read_post', (int) $item->ID)
            ? $item
            : Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu lesen.', (int) $item->ID));
    }

    /** @return list<Block>|WP_Error The blocks to insert. */
    private function blocks(WP_Post $item, string $mode, bool $global): array|WP_Error
    {
        $id = (int) $item->ID;
        try {
            $roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
        } catch (MalformedMarkup) {
            return Errors::unsupported(
                sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id),
                'Den Eintrag im Divi-Builder öffnen und speichern.'
            );
        }

        if ($roots === []) {
            return Errors::invalidInput(
                sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id),
                ['library_id' => 'leer']
            );
        }

        if ($mode === 'global') {
            if (count($roots) !== 1) {
                return Errors::invalidInput(
                    'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde '
                        . 'gespeichert.',
                    ['mode' => 'mode "copy" verwenden']
                );
            }

            return [GlobalInstance::of($roots[0], $id)];
        }

        return array_map(
            static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(),
            $roots
        );
    }
}

LibraryIntegration – Konstruktor um private ?Closure $diviWritable = null (letzter Parameter, Import Closure) erweitern und in abilities() ergänzen:

            new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable),

DiviIntegration::library(?Closure $diviWritable = null) reicht $diviWritable als letzten Parameter an LibraryIntegration weiter. Plugin::boot(): $divi->library($diviWritable).

docs/guides/overview.md – „Direkt sichtbar:“ um lit-library-insert ergänzen (nach lit-divi-delete, Reihenfolge wie bisher, der Absatz endet mit einer Leerzeile – der ToolNamingTest liest bis dahin):

Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`,
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`.
  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'LibraryAccessTest|LibraryInsertAbilityTest|DiviIntegrationTest|ToolNamingTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi

Commit wie in den Global Constraints mit der Nachricht feat: lit-library-insert as copy or global instance in the builder's format (E5).


Task 14: lit/divi-node-copy – Knoten zwischen Seiten kopieren (E6)

Kopiert einen gespeicherten Knoten byte-gleich an eine Position eines anderen Posts (Seite oder Bibliothekseintrag). Quelle: Leserechte wie lit-divi-outline (auch Bibliothekseinträge) und die source_tree_rev (Pfad-IDs gelten nur für ihren Stand); Ziel: wie lit-divi-insert (tree_rev, StructureRules, alles oder nichts, bei Bibliothekszielen GlobalEdits). Globale Instanzen bleiben verknüpft. Die Kopie läuft durch StoredNodeGuard des Ziels. Gruppe „Divi“, indirekt.

Files:

  • Create: src/Integrations/Divi/Pages/DiviNodeCopyAbility.php
  • Modify: src/Integrations/Divi/DiviIntegration.php
  • Modify: docs/guides/overview.md (indirekte Tools)
  • Test: tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php, tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Interfaces:

  • Consumes: DiviWriteAbility (precheck, change, commit), DiviPageLoader::load(), DiviComposition::storedGuard/editor, GlobalEdits, LibraryFixtures.

  • Produces: final class DiviNodeCopyAbility extends DiviWriteAbility { __construct(DiviWriter $writer, DiviPageLoader $loader) } – lit/divi-node-copy, indirekt, Eingabe post_id, tree_rev, source_post_id, source_id, source_tree_rev, Position.

  • Step 1: Failing tests schreiben

tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.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\BlockTree\NodeIndex;
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\DiviWriterFixture;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;
use WP_Post;

final class DiviNodeCopyAbilityTest extends TestCase
{
    private WP_Post $target;
    private WP_Post $source;

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

    protected function setUp(): void
    {
        parent::setUp();
        $this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html'));
        $this->source = LibraryFixtures::page(
            DiviFixtures::markup('stage3/page--globale-instanzen.html'),
            ['ID' => 8, 'post_title' => 'Quelle']
        );
        LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]);
    }

    private function ability(): DiviNodeCopyAbility
    {
        $access = LibraryFixtures::access();
        $loader = LibraryFixtures::loader($access);
        $failures = [];
        $globals = new GlobalEdits($access, Mockery::mock(GlobalUsages::class));

        return new DiviNodeCopyAbility(
            DiviWriterFixture::writer($this->saved, $failures, false, $loader, $globals),
            $loader
        );
    }

    /** @return array<string, mixed> */
    private function input(array $extra = []): array
    {
        return array_merge([
            'post_id' => 7,
            'tree_rev' => Rev::of((string) $this->target->post_content),
            'source_post_id' => 8,
            'source_tree_rev' => Rev::of((string) $this->source->post_content),
            'source_id' => 's1',
            'after' => 's0',
        ], $extra);
    }

    public function testCopiesANodeByteForByteBetweenPages(): void
    {
        $bytes = Serializer::block(
            NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find('s1')
        );

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

        self::assertTrue($result['ok']);
        self::assertCount(1, $this->saved, 'only the target is written');
        self::assertStringContainsString($bytes, $this->saved[0]);
        self::assertSame('s1', $result['changed'][0]['id']);
        self::assertFalse($this->ability()->isDirect());
    }

    public function testGlobalInstancesStayLinked(): void
    {
        $result = $this->ability()->execute($this->input(['source_id' => 'g0']));

        self::assertTrue($result['ok']);
        self::assertStringContainsString('<!-- wp:divi/global-layout {"globalModule":"1058"', $this->saved[0]);
    }

    public function testStaleSourceOrTargetWritesNothing(): void
    {
        self::assertToolError($this->ability()->execute($this->input(['source_tree_rev' => 'abcdef012345'])), 'rev_mismatch');
        self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch');
        self::assertToolError($this->ability()->execute($this->input(['source_id' => 's9'])), 'not_found');
        self::assertSame([], $this->saved);
    }

    public function testEditorCannotCarryBuilderOnlyValuesToAnotherPage(): void
    {
        $this->source = LibraryFixtures::page(
            '<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":{"freeForm":"body{display:none}"}}}} -->'
                . '<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
            ['ID' => 8]
        );
        LibraryFixtures::given([$this->target, $this->source]);

        $result = $this->ability()->execute($this->input(['source_id' => 's0']));

        self::assertToolError($result, 'validation_failed');
        self::assertSame([], $this->saved);
    }

    public function testTheSourceNeedsItsOwnRights(): void
    {
        Functions\when('current_user_can')->alias(
            static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8)
        );

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

    public function testCopiesIntoALibraryEntry(): void
    {
        $item = LibraryFixtures::item(1055);

        $result = $this->ability()->execute($this->input([
            'post_id' => 1055,
            'tree_rev' => Rev::of((string) $item->post_content),
            'source_id' => 's0.r0.c0.m0',
            'after' => null,
            'parent' => 's0.r0.c0',
        ]));

        self::assertTrue($result['ok']);
        self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]);
    }
}

In DiviIntegrationTest::EXPECTED am Ende 'lit/divi-node-copy' ergänzen.

  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'DiviNodeCopyAbilityTest|DiviIntegrationTest' Expected: FAIL (DiviNodeCopyAbility fehlt)

  • Step 3: Implementieren

src/Integrations/Divi/Pages/DiviNodeCopyAbility.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Pages;

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

defined('ABSPATH') || exit;

/**
 * lit/divi-node-copy (spec §5.7): a node with everything inside from one Divi entry (page or library
 * entry) to a position in another. The copy keeps its bytes – global instances stay linked – and
 * passes StoredNodeGuard of the target (E6: builder-only values never move to another post for users
 * without unfiltered_html). Source: read rights (DiviPageLoader) and its own tree_rev, because path
 * IDs are only valid for the state they were read in. Target: like lit-divi-insert.
 */
final class DiviNodeCopyAbility extends DiviWriteAbility
{
    public function __construct(DiviWriter $writer, private DiviPageLoader $loader)
    {
        parent::__construct($writer);
    }

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

    public function label(): string
    {
        return 'Divi-Knoten kopieren';
    }

    public function description(): string
    {
        return 'Kopiert einen Divi-Knoten samt Inhalt von einem Eintrag (source_post_id, source_id und '
            . 'source_tree_rev aus lit-divi-outline der Quelle) an eine Position eines anderen Eintrags (post_id, '
            . 'tree_rev, parent/before/after). Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Quelle '
            . 'und Ziel dürfen auch Bibliothekseinträge sein. Alles oder nichts.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => array_merge(DiviSchema::write(), [
                'source_post_id' => ['type' => 'integer', 'minimum' => 1],
                'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'],
                'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'],
            ], DiviSchema::position()),
            'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'],
            'additionalProperties' => false,
        ];
    }

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

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

    /** The source needs its own read rights – checked in permission() too. */
    protected function precheck(array $input): ?WP_Error
    {
        $source = $this->loader->load($input['source_post_id'] ?? null, false);

        return $source instanceof WP_Error ? $source : null;
    }

    protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
    {
        $source = $this->loader->load($input['source_post_id'] ?? null, false);
        if ($source instanceof WP_Error) {
            return $source;
        }

        if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) {
            return Errors::revMismatch(
                'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde gespeichert.',
                'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.'
            );
        }

        $id = is_string($input['source_id'] ?? null) ? $input['source_id'] : '';
        $node = $source->index->find($id);
        if ($node === null) {
            return DiviErrors::unknownNode($id, $source->post);
        }

        $copy = $node->copy();
        $problems = $composition->storedGuard()->problems($copy);
        if ($problems !== []) {
            return Errors::invalidInput(
                'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
                    . 'gespeichert.',
                ['source_id' => implode('; ', $problems)]
            );
        }

        $editor = $composition->editor();
        $position = $editor->position($page->index, $input);
        if (is_string($position)) {
            return DiviErrors::position($position);
        }

        $violation = $editor->insert($position, [$copy]);
        if ($violation !== null) {
            return Errors::invalidInput(
                'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.',
                ['source_id' => $violation]
            );
        }

        return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID)))
            ->record($copy, 'inserted');
    }
}

DiviIntegration::abilities() – nach DiviReplaceTextAbility ergänzen: new DiviNodeCopyAbility($writer, $loader),.

docs/guides/overview.md: „Divi (lit/divi-duplicate-node, lit/divi-replace-text)“ → „Divi (lit/divi-duplicate-node, lit/divi-replace-text, lit/divi-node-copy)“.

  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'DiviNodeCopyAbilityTest|DiviIntegrationTest|ToolNamingTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Pages/DiviNodeCopyAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Commit wie in den Global Constraints mit der Nachricht feat: lit/divi-node-copy between pages and library entries.


Task 15: lit/divi-dynamic-sources (E12, Antwort F4)

E14 ordnet das Tool 3a zu. Es liefert Divis Dynamic-Content-Optionen (DynamicContentOptions::get_options für einen Beispiel-Eintrag des Typs, sonst 0; über DynamicContentMap, ohne loop_*, S36) mit dem passenden Platzhalter ({{post:…}}, {{link:<ID>}}) bzw. dem Namen für die Vollform, und die Felder des Typs aus ContentSchema (Pods, registriertes und gefundenes Meta). Antwort F4 (verbindlich): nur textartige Felder als Quelle anbieten, andere mit Grund auslassen (Beziehung/Datei zeigt Divi nur als IDs, F26; Pods mit Tabellen-Speicherung liest Divi nicht; geschützte Schlüssel mit _ nie, S12). Die Feldliste nur mit der Divi-Rolle read_dynamic_content_custom_fields (E3, F25). Gruppe „Divi“, indirekt, nur lesend.

Files:

  • Modify: src/Integrations/Divi/Pages/DynamicContentMap.php (options())
  • Create: src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php
  • Modify: src/Integrations/Divi/DiviIntegration.php
  • Modify: docs/guides/overview.md (indirekte Tools)
  • Test: tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php, tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php, tests/Unit/Integrations/Divi/DiviIntegrationTest.php

Interfaces:

  • Consumes: DynamicContentMap::fromOptions(), DynamicContentRules::DEFAULT_DYNAMIC_CONTENT/isMetaKey(), Placeholders::POST, ContentSchema::fields(), FieldDefinition, PostGuard::postType(), JsonMap::of(), DiviRoles.

  • Produces: DynamicContentMap::options(int $postId): ?array<string, array{label: string, group: string, settings: array<string, string>}>; final class DiviDynamicSourcesAbility implements Ability { __construct(ContentSchema $schema, DynamicContentMap $map, DiviRoles $roles, ?Closure $samplePost = null, ?Closure $podStorage = null) } – lit/divi-dynamic-sources.

  • Step 1: Failing tests schreiben

In DynamicContentMapTest:

    public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void
    {
        $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());

        $options = $map->options(831);

        self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']);
        self::assertSame('post_meta_key', $options['post_meta_key']['label']);
        self::assertSame('Standard', $options['post_meta_key']['group']);
        self::assertArrayNotHasKey('loop_post_title', $options);
        self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831));
    }

tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php:

<?php

declare(strict_types=1);

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

use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use LIT\MCP\Tests\Support\DiviFixtures;
use LIT\MCP\Tests\Support\LibraryFixtures;
use LIT\MCP\Tests\Unit\TestCase;
use Mockery;

final class DiviDynamicSourcesAbilityTest extends TestCase
{
    protected function setUp(): void
    {
        parent::setUp();
        Functions\when('get_post_type_object')->justReturn(
            (object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']]
        );
        Functions\when('current_user_can')->justReturn(true);
    }

    private function schema(): ContentSchema
    {
        $schema = Mockery::mock(ContentSchema::class);
        $schema->allows('isManaged')->andReturn(true);
        $schema->allows('fields')->with('page')->andReturn([
            'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE),
            'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE),
            'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE),
            'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE),
            '_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE),
            'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN),
        ]);

        return $schema;
    }

    /** @param list<string> $deniedRoles */
    private function ability(array $deniedRoles = [], ?string $storage = 'meta', ?array $registry = null): DiviDynamicSourcesAbility
    {
        $registry ??= DiviFixtures::dynamicContentRegistry();

        return new DiviDynamicSourcesAbility(
            $this->schema(),
            new DynamicContentMap(static fn (int $id): array => $registry),
            LibraryFixtures::roles($deniedRoles),
            static fn (string $postType): int => 831,
            static fn (string $postType): ?string => $storage
        );
    }

    public function testListsDiviSourcesWithTheirPlaceholders(): void
    {
        $result = $this->ability()->execute(['post_type' => 'page']);

        $sources = array_column($result['sources'], null, 'name');
        self::assertSame(831, $result['sample_post_id']);
        self::assertSame('{{post:title}}', $sources['post_title']['placeholder']);
        self::assertSame('{{link:<ID>}}', $sources['post_link_url_page']['placeholder']);
        self::assertNull($sources['site_title']['placeholder']);
        self::assertArrayNotHasKey('loop_post_title', $sources);
        self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']);
        self::assertFalse($this->ability()->isDirect());
        self::assertTrue($this->ability()->annotations()['readonly']);
    }

    public function testOffersTextLikeFieldsAndNamesTheOthers(): void
    {
        $result = $this->ability()->execute(['post_type' => 'page']);

        self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key'));
        self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']);
        $omitted = array_column($result['omitted'], 'reason', 'key');
        self::assertSame(['foto', 'projekte', '_intern'], array_keys($omitted));
        self::assertStringContainsString('nur IDs', $omitted['foto']);
        self::assertStringContainsString('„_“', $omitted['_intern']);
    }

    public function testPodsTableStorageIsNotReadableByDivi(): void
    {
        $result = $this->ability([], 'table')->execute(['post_type' => 'page']);

        self::assertSame(['telefon'], array_column($result['fields'], 'key'));
        self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']);
    }

    public function testFieldListNeedsTheDiviRole(): void
    {
        $result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']);

        self::assertSame([], $result['fields']);
        self::assertSame([], $result['omitted']);
        self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes']));
    }

    public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void
    {
        $result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']);

        self::assertContains('post_title', array_column($result['sources'], 'name'));
        self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes']));
    }

    public function testUnknownPostTypeIsNotFound(): void
    {
        Functions\when('get_post_type_object')->justReturn(null);

        self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found');
    }
}

In DiviIntegrationTest::EXPECTED am Ende 'lit/divi-dynamic-sources' ergänzen.

  • Step 2: Tests laufen lassen – schlagen fehl

Run: vendor/bin/phpunit --filter 'DynamicContentMapTest|DiviDynamicSourcesAbilityTest|DiviIntegrationTest' Expected: FAIL

  • Step 3: Implementieren

DynamicContentMap – forPost() auf eine gemeinsame Rohabfrage umstellen und options() ergänzen:

    /** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */
    public function forPost(int $postId): ?array
    {
        $raw = $this->raw($postId);

        return $raw === null ? null : self::fromOptions($raw);
    }

    /**
     * The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
     * names and types as forPost(), loop_* left out). Null when Divi's registry is unavailable.
     *
     * @return array<string, array{label: string, group: string, settings: array<string, string>}>|null
     */
    public function options(int $postId): ?array
    {
        $raw = $this->raw($postId);
        if ($raw === null) {
            return null;
        }

        $options = [];
        foreach (self::fromOptions($raw) as $name => $settings) {
            $option = is_array($raw[$name] ?? null) ? $raw[$name] : [];
            $options[$name] = [
                'label' => is_string($option['label'] ?? null) ? $option['label'] : $name,
                'group' => is_string($option['group'] ?? null) ? $option['group'] : '',
                'settings' => $settings,
            ];
        }

        return $options;
    }

    /** @return array<array-key, mixed>|null */
    private function raw(int $postId): ?array
    {
        try {
            $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId);
        } catch (Throwable $e) {
            Log::failure(sprintf('divi dynamic content options of post %d', $postId), $e->getMessage());

            return null;
        }

        return is_array($options) && $options !== [] ? $options : null;
    }

src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php:

<?php

declare(strict_types=1);

namespace LIT\MCP\Integrations\Divi\Pages;

use Closure;
use LIT\MCP\Abilities\Ability;
use LIT\MCP\Abilities\Annotations;
use LIT\MCP\Abilities\JsonMap;
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
use LIT\MCP\Integrations\WordPress\PostGuard;
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
use Throwable;
use WP_Error;

defined('ABSPATH') || exit;

/**
 * lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
 * (DynamicContentOptions::get_options with a sample entry of the type, else 0; no loop_* – S36) with
 * the placeholder that writes each, and the fields of the type usable as {{field:<key>}} (answer
 * F4: text-like fields only; relation/file fields show IDs in Divi (F26), Pods table storage is not
 * post meta, protected keys never (S12) – each left out with its reason). The field list needs the
 * Divi role option read_dynamic_content_custom_fields (E3, F25).
 */
final class DiviDynamicSourcesAbility implements Ability
{
    /** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
    private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice', 'unknown'];

    private const ID_TYPES = ['relation', 'file'];

    /** @var Closure(string): int */
    private Closure $samplePost;

    /** @var Closure(string): ?string */
    private Closure $podStorage;

    /**
     * @param (Closure(string): int)|null     $samplePost Post type → an entry to ask Divi with (0 = none).
     * @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null without a pod.
     */
    public function __construct(
        private ContentSchema $schema,
        private DynamicContentMap $map,
        private DiviRoles $roles,
        ?Closure $samplePost = null,
        ?Closure $podStorage = null
    ) {
        $this->samplePost = $samplePost ?? static function (string $postType): int {
            $ids = get_posts([
                'post_type' => $postType,
                'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
                'posts_per_page' => 1,
                'orderby' => 'modified',
                'order' => 'DESC',
                'fields' => 'ids',
                'no_found_rows' => true,
            ]);

            return is_array($ids) && isset($ids[0]) ? (int) $ids[0] : 0;
        };
        $this->podStorage = $podStorage ?? static function (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;
        };
    }

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

    public function label(): string
    {
        return 'Dynamic-Content-Quellen';
    }

    public function description(): string
    {
        return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, '
            . 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:<ID>}}) bzw. dem Namen für die Vollform, '
            . 'und die Felder des Typs, die sich als {{field:<Schlüssel>}} eignen. Nur textartige Felder; Beziehungs-, '
            . 'Datei- und Tabellenfelder stehen mit Grund unter omitted.';
    }

    public function inputSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']],
            'required' => ['post_type'],
            'additionalProperties' => false,
        ];
    }

    public function outputSchema(): ?array
    {
        return [
            'type' => 'object',
            'properties' => [
                'post_type' => ['type' => 'string'],
                'sample_post_id' => ['type' => 'integer'],
                'sources' => [
                    'type' => 'array',
                    'items' => [
                        'type' => 'object',
                        'properties' => [
                            'name' => ['type' => 'string'],
                            'label' => ['type' => 'string'],
                            'group' => ['type' => 'string'],
                            'placeholder' => ['type' => ['string', 'null']],
                            'settings' => ['type' => 'object'],
                        ],
                        'required' => ['name', 'label', 'group', 'placeholder', 'settings'],
                    ],
                ],
                'fields' => ['type' => 'array', 'items' => ['type' => 'object']],
                'omitted' => ['type' => 'array', 'items' => ['type' => 'object']],
                'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
            ],
            'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'],
        ];
    }

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

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

    public function permission(array $input): bool|WP_Error
    {
        $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');

        return $type instanceof WP_Error ? $type : true;
    }

    public function execute(array $input): mixed
    {
        $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
        if ($type instanceof WP_Error) {
            return $type;
        }

        $postType = (string) $input['post_type'];
        $sample = ($this->samplePost)($postType);
        $notes = [
            'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"<name>",'
                . '"settings":{…}}})$ mit den genannten Einstellungen schreiben.',
        ];
        $options = $this->map->options($sample);
        if ($options === null) {
            $notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.';
            $options = [];
            foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) {
                $options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings];
            }
        }

        $sources = [];
        foreach ($options as $name => $option) {
            $sources[] = [
                'name' => (string) $name,
                'label' => $option['label'],
                'group' => $option['group'],
                'placeholder' => self::placeholder((string) $name),
                'settings' => JsonMap::of($option['settings']),
            ];
        }

        $fields = [];
        $omitted = [];
        if ($this->roles->allows('read_dynamic_content_custom_fields')) {
            [$fields, $omitted] = $this->fields($postType);
        } else {
            $notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte '
                . 'Felder lesen“ nicht (Divi → Rollen-Editor).';
        }

        return [
            'post_type' => $postType,
            'sample_post_id' => $sample,
            'sources' => $sources,
            'fields' => $fields,
            'omitted' => $omitted,
            'notes' => $notes,
        ];
    }

    private static function placeholder(string $name): ?string
    {
        $post = array_search($name, Placeholders::POST, true);
        if ($post !== false) {
            return '{{post:' . $post . '}}';
        }

        return str_starts_with($name, 'post_link_url_') ? '{{link:<ID>}}' : null;
    }

    /**
     * @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields.
     */
    private function fields(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 = 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',
                ! in_array($field->type, self::TEXT_TYPES, true) => sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
                default => null,
            };
            if ($reason !== null) {
                $omitted[] = $entry + ['reason' => $reason];
                continue;
            }

            $fields[] = $entry + [
                'type' => $field->type,
                'source' => $field->source,
                'placeholder' => '{{field:' . $field->key . '}}',
            ];
        }

        return [$fields, $omitted];
    }
}

DiviIntegration::abilities() – nach DiviNodeCopyAbility (Import DynamicContentMap, DiviDynamicSourcesAbility):

            new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),

docs/guides/overview.md: „…, lit/divi-node-copy)“ → „…, lit/divi-node-copy, lit/divi-dynamic-sources)“.

  • Step 4: Tests laufen lassen

Run: vendor/bin/phpunit --filter 'DynamicContentMapTest|DiviDynamicSourcesAbilityTest|DiviIntegrationTest|ToolNamingTest' Expected: PASS

Run: composer test && composer lint Expected: OK

  • Step 5: Commit
git add src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi

Commit wie in den Global Constraints mit der Nachricht feat: lit/divi-dynamic-sources with text-like fields only (E12, F4).


Task 16: Guides, Server-Instructions, Readme, Version 0.4.0, Spec- und Entscheidungs-Nachträge

Dokumentation zu allem aus 3a, deutsch mit Umlauten, neutral formuliert (keine Chatbot-Marke; der Readme-Satz „z. B. Claude oder ein anderer MCP-fähiger Client“ wird dabei neutralisiert). Neues Guide-Thema divi-library mit dem Ablauf „Globale Module vorschlagen“ (E14; MCP-Prompts gibt es im Plugin noch nicht, siehe offene Punkte). Server-Instructions bekommen eine Zeile zur Bibliothek, wenn die Gruppe aktiv ist.

Files:

  • Create: docs/guides/divi-library.md
  • Modify: docs/guides/overview.md, docs/guides/divi-placeholders.md, docs/guides/divi-structure.md, docs/guides/divi-design-system.md, docs/guides/workflows.md
  • Modify: src/Guides/InstructionsBuilder.php
  • Modify: readme.txt, wp-lit-mcp.php, src/Plugin.php
  • 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, tests/Unit/Guides/ToolNamingTest.php (unverändert, muss grün bleiben)

Interfaces:

  • Consumes: alle Tools aus Task 2–15.

  • Produces: Guide-Thema divi-library; Plugin::VERSION = '0.4.0'; Instructions-Zeile „Divi-Bibliothek: …“.

  • Step 1: Failing test schreiben

In InstructionsBuilderTest:

    public function testAddsTheLibraryRuleOnlyWhenTheLibraryGroupIsActive(): void
    {
        $library = Mockery::mock(Integration::class);
        $library->allows('id')->andReturn('divi-library');
        $library->allows('isAvailable')->andReturn(true);
        $library->allows('abilities')->andReturn([$this->ability('lit/library-insert', true)]);
        $core = $this->coreWith($this->ability('lit/guide', true));

        $with = (new InstructionsBuilder(new IntegrationRegistry($core, $library)))->build();
        $without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();

        self::assertStringContainsString('Divi-Bibliothek: globale Module', $with);
        self::assertStringContainsString('confirm: true', $with);
        self::assertStringNotContainsString('Divi-Bibliothek:', $without);
        self::assertLessThan(2000, strlen($with));
    }
  • Step 2: Test laufen lassen – schlägt fehl

Run: vendor/bin/phpunit --filter InstructionsBuilderTest Expected: FAIL

  • Step 3: Guide divi-library anlegen

docs/guides/divi-library.md:

# Divi-Bibliothek und globale Module

Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederverwendung.

- **Normale Einträge** werden beim Einfügen kopiert; die Kopie ist danach unabhängig.
- **Globale Einträge** werden als Instanz eingefügt. Divi zeigt überall den aktuellen Inhalt des
  Bibliothekseintrags: eine Änderung dort wirkt sofort auf allen Seiten mit diesem Modul – auch auf
  veröffentlichten.

## Werkzeuge

- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type`
  (`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search`, `category`, weiterblättern
  mit `cursor`. Mit `id`: ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und bei globalen
  Einträgen die Seiten, die ihn zeigen (`affected`, `affected_count`).
- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen
  Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie.
  Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev`
  der Zielseite ist Pflicht.
- `lit/library-save`: einen Abschnitt, eine Zeile oder ein Modul als neuen Eintrag speichern (`title`,
  `scope: "normal"` oder `"global"`, vorhandene `categories`/`tags`). Der Knoten auf der Seite bleibt eine
  eigenständige Kopie – um ihn zu verknüpfen, ihn durch eine Instanz ersetzen (siehe Ablauf unten).
- `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren
  (`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle; Ziel mit `post_id`,
  `tree_rev`, Position). Die Kopie ist byte-gleich; globale Module bleiben verknüpft.
- Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`,
  `lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete` und `lit/divi-duplicate-node`
  mit der ID des Eintrags als `post_id`.

## Globale Module erkennen und ändern

1. In `lit-divi-outline` einer Seite erscheinen Instanzen als z. B. `g0 section "Kontakt" [global 1058]
   (nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite
   lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen.
2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden.
3. Ohne `confirm` ist das ein Probelauf: Antwort `confirm_required` mit `affected` (betroffene Seiten, Titel
   nicht lesbarer Seiten als „(nicht lesbar)“) und `affected_count`; gespeichert wird nichts.
4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit
   `confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`).
5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine
   Sicherungskopie (`backup_id`). Divis CSS-Cache wird für alle Seiten geleert.

Regeln für Bibliothekseinträge: ein Abschnitts-, Zeilen- oder Modul-Eintrag behält genau einen Knoten dieses
Typs (die Instanzen auf den Seiten erwarten ihn); ein Layout-Eintrag enthält nur Abschnitte; ein globaler
Eintrag enthält keine globalen Module.

`lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie nicht) und nennt
sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen.

## Rechte

- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen
  Eintrag bearbeiten dürfen; neue Einträge anlegen = Beiträge veröffentlichen dürfen (Bibliothekseinträge
  sind immer aktiv).
- Divi → Rollen-Editor: „Divi-Bibliothek“, „In der Bibliothek speichern“, „Aus der Bibliothek hinzufügen“,
  „Globale Bibliothekselemente bearbeiten“. Fehlt eines, antwortet das Werkzeug mit `forbidden` und diesem
  Hinweis.
- Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf
  Bibliothekseinträge, auch über die Divi-Werkzeuge; `lit-library-insert` ist außerdem gesperrt, wenn
  `lit-divi-insert` gesperrt ist.
- Ohne das Recht `unfiltered_html` lassen sich Knoten mit eigenem CSS, eigenen HTML-Attributen, dem
  Code-Modul oder dynamischem Inhalt mit HTML davor/danach nicht in einen anderen Eintrag übertragen
  (Kopieren, Einfügen, Speichern) – „nur im Divi-Builder“.

## Ablauf: globale Module vorschlagen

1. Die Seiten lesen (`lit-divi-outline`) und wiederkehrende Abschnitte suchen (gleiche Admin-Labels oder
   Texte, z. B. Kontakt, Öffnungszeiten, Call-to-Action).
2. Dem Nutzer vorschlagen, welche davon globale Module werden sollen, und erklären: eine Änderung wirkt
   danach auf allen Seiten.
3. Nach Zustimmung den Abschnitt einer Seite mit `lit/library-save` und `scope: "global"` speichern.
4. Auf jeder Seite (auch der ersten) die Instanz mit `lit-library-insert` direkt vor dem bisherigen Abschnitt
   einfügen und den bisherigen Abschnitt nach Rückfrage mit `lit-divi-delete` entfernen.
5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen.

Unterscheiden sich die Abschnitte in Details, vorher mit dem Nutzer klären, welche Fassung gilt.
  • Step 4: Übrige Guides anpassen

docs/guides/overview.md:

  • In „Weitere Themen:“ divi-library ergänzen:
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
`divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system` oder `divi-library`.
  • Direkt darunter einen eigenen Absatz (nach einer Leerzeile, damit der Themen-Test ihn nicht mitliest):
Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf.
  • Nach „Die lit-divi-…-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.“ ergänzen: „Sie arbeiten auch auf Einträgen der Divi-Bibliothek (ID als post_id); globale Module: lit-guide topic divi-library.“

docs/guides/divi-placeholders.md – Tabellenzeile nach {{field:<meta_key>}} einfügen:

| `{{link:<ID>}}` | Link auf einen anderen Eintrag (Seite, Beitrag …), z. B. `{{link:42}}` – nur in Link-Feldern; Divi setzt beim Anzeigen die aktuelle Adresse ein, also auch für Entwürfe, die erst später veröffentlicht werden |

Den Absatz „In Link-Feldern (url, button_url …) …“ ersetzen durch:

In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:<ID>}}` verwenden statt fester
Adressen wie `/?page_id=…`. Der Eintrag muss existieren, lesbar sein und darf nicht im Papierkorb liegen;
`{{link:…}}` außerhalb von Link-Feldern lehnt das Werkzeug ab. Sonst nimmt ein Link-Feld jeden dynamischen
Inhalt an; sinnvoll sind nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine
Link-Variable).

Welche dynamischen Quellen und Felder es für einen Inhaltstyp gibt, liefert `lit/divi-dynamic-sources`
(über `mcp-adapter-execute-ability`): Divis Quellen mit Platzhalter und die Felder, die sich als
`{{field:…}}` eignen (nur textartige; Beziehungs- und Dateifelder zeigt Divi nur als IDs).

docs/guides/divi-structure.md – den ersten Punkt unter „Was nicht bearbeitet wird“ ersetzen durch:

- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben,
  duplizieren oder löschen. Globale Module zeigt die Übersicht mit `[global <ID>]`: ihren Inhalt im
  Bibliothekseintrag ändern (`lit-divi-outline` mit `post_id` = ID; Ablauf mit Probelauf und `confirm` siehe
  `lit-guide` topic `divi-library`). `lit/divi-replace-text` ändert sie nicht, sondern nennt sie in
  `skipped_nodes`.

docs/guides/divi-design-system.md – vor „Regeln:“ einfügen:

Auf großen Websites eingrenzen: `only` (Liste aus `colors`, `variables`, `presets`) liefert nur diese Teile,
`module` (z. B. `"button"`) nur die Presets dieses Moduls; höchstens 200 Presets je Antwort.

docs/guides/workflows.md – nach „Divi-Seite bauen“ einfügen:

## 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`. Globale Module ändern und neue vorschlagen:
`lit-guide` topic `divi-library`.

Im Abschnitt „Text auf vielen Seiten ersetzen“ den letzten Satz ersetzen durch: „Globale Module werden nicht geändert, sondern in skipped_nodes genannt – sie im Bibliothekseintrag ändern (lit-guide topic divi-library).“

  • Step 5: Server-Instructions

InstructionsBuilder::build() – nach dem $divi-Block:

        $library = str_contains(', ' . $active . ',', ', divi-library,')
            ? [
                '- Divi-Bibliothek: globale Module (lit-divi-outline zeigt [global <ID>]) im Bibliothekseintrag ändern; '
                    . 'das wirkt auf alle Seiten und braucht nach dem Probelauf confirm: true (lit-guide topic '
                    . '"divi-library").',
            ]
            : [];

und im implode array_merge([...], $divi, $library).

  • Step 6: Readme und Version

wp-lit-mcp.php: * Version: 0.4.0. src/Plugin.php: public const VERSION = '0.4.0';. readme.txt: Stable tag: 0.4.0.

readme.txt – im ersten Satz der Beschreibung „(z. B. Claude oder ein anderer MCP-fähiger Client mit OAuth)“ durch „(ein MCP-fähiger Client mit OAuth)“ ersetzen und die Aufzählung um „Divi-Seiten und die Divi-Bibliothek (auch globale Module)“ ergänzen.

Unter „Auswirkungen auf die gesamte Website“ anfügen:

* Divi-Bibliothek: LIT MCP legt Bibliothekseinträge selbst an (Post-Typ et_pb_layout, Status publish, mit
  Divis Begriffen scope, layout_type, module_width und dessen Meta-Feldern) und benutzt keine Builder-Routen.
  Eine Änderung an einem globalen Bibliothekseintrag wirkt sofort auf allen Seiten, die ihn zeigen; LIT MCP
  verlangt dafür einen Probelauf mit Liste der betroffenen Seiten und confirm, legt vorher eine Revision (bei
  abgeschalteten Revisionen eine Sicherungskopie im Papierkorb) an und leert danach Divis CSS-Cache aller Seiten
  und den Theme-Builder-Cache.
* Divis Rollen-Editor gilt zusätzlich zu den WordPress-Rechten: ohne „Divi-Bibliothek“, „In der Bibliothek
  speichern“, „Aus der Bibliothek hinzufügen“ bzw. „Globale Bibliothekselemente bearbeiten“ lehnt LIT MCP ab.
* Interne Links über {{link:<ID>}} nutzen Divis eigenen dynamischen Inhalt „<Typ> Link“: die Adresse wird beim
  Anzeigen ermittelt.

Unter „Rückgängig machen“ nach dem Punkt „Divi-Seiten“ einfügen:

* Bibliothekseinträge: jede Änderung legt vorher eine Revision an (revision_id), bei abgeschalteten Revisionen
  eine Sicherungskopie im Papierkorb (backup_id); zurück über WordPress (Eintrag der Divi-Bibliothek
  bearbeiten → Revisionen). Neu angelegte Einträge (lit/library-save) in der Divi-Bibliothek löschen.

Unter „Frequently Asked Questions“ anfügen:

= Was passiert, wenn der Assistent ein globales Modul ändert? =

Die Änderung wirkt auf allen Seiten mit diesem Modul. LIT MCP speichert erst nach einem Probelauf, der die
betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des
Bibliothekseintrags.

Changelog oben:

= 0.4.0 =
* Divi-Bibliothek: Einträge auflisten und lesen (lit/library-list), Knoten als normalen oder globalen Eintrag
  speichern (lit/library-save), als Kopie oder globale Instanz einfügen (lit-library-insert, direkt).
* Globale Module: Bibliothekseinträge mit den Divi-Werkzeugen bearbeiten; bei globalen Einträgen Probelauf
  mit betroffenen Seiten, confirm, Revision bzw. Sicherung und seitenweite Cache-Leerung.
* Knoten zwischen Seiten und Bibliothekseinträgen kopieren (lit/divi-node-copy).
* Interne Links mit {{link:<ID>}}; lit/divi-dynamic-sources listet Divis dynamische Quellen und geeignete Felder.
* lit-guide mit dem Thema „all“; lit-divi-design-system mit Filtern (only, module); lit-divi-node-get und
  lit-divi-outline nennen die Bibliotheks-ID globaler Module; lit-page-render-summary weist auf globale Module
  hin; lit-site-status prüft die genutzten Divi-Funktionen.
* Tools-Gruppe „Divi-Bibliothek“; „nur lesen“ sperrt auch Schreibzugriffe der Divi-Werkzeuge auf die Bibliothek.
  • Step 7: Spec und Entscheidungen nachtragen

In docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md:

  • §4.3, zweiter Punkt, anfügen: „Stand nach Etappe 3a: 22 direkte Tools (dazu lit/library-insert); indirekt neu lit/library-list, lit/library-save, lit/divi-node-copy, lit/divi-dynamic-sources. Neue Tool-Gruppe „Divi-Bibliothek“.“
  • §5.2, letzter Punkt: „(Bearbeitung ab Etappe 3)“ → „(bearbeitet werden globale Module seit Etappe 3a im Bibliothekseintrag, E2)“.
  • §5.4, Platzhalter-Tabelle nach {{field:<meta_key>}}: „| {{link:<ID>}} | interner Link: Dynamic Content post_link_url_<Typ> mit post_id (nur Link-Felder; Etappe 3a, E13a) |“
  • §5.7: Zeile lit/divi-dynamic-sources → „… – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4)“; Zeile lit/library-list, lit/library-get → „| lit/library-list | lesen | Einträge nach Typ (layout_type) und Scope, mit cursor; mit id Detail inkl. Knoten-Übersicht und betroffener Seiten (ersetzt lit/library-get, E9) |“; Zeile lit/library-insert → „… als Kopie oder globale Instanz im Format des Visual Builders (localAttrs-Schnappschuss, globalParent) einfügen; direkt“; Zeile lit/divi-node-copy → „Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, S7/S26-Prüfung E6)“.
  • §9.2, Themenliste: „divi-library“ und „media“ ergänzen sowie „Thema all liefert alle Themen (Etappe 3a)“.
  • §9.3: „(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; globale-module-vorschlagen steht als Ablauf im Guide divi-library.)“

In docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md am Ende anfügen:

## 6. Umsetzung Etappe 3a (Plan 2026-09-27-etappe-3a-bibliothek.md)

- E5 präzisiert durch die Builder-Fixtures: Instanzen werden wie im Visual Builder erzeugt – `localAttrs` =
  vollständiger Schnappschuss der Wurzel-Attribute (oberste Schlüssel sortiert), Kinder mit `globalParent`,
  ohne Kinder selbstschließend – statt der minimalen Form ohne `localAttrs`. Byte-Test gegen
  `page--globale-instanzen.html`.
- Neuer Befund 9: Bibliothekseinträge aus Divis Dialog haben `\r\n` zwischen Blöcken (Formular-Upload);
  `LibraryContent::load()` vereinheitlicht das vor jeder Übernahme.
- Kopie eines normalen Eintrags mit globalen Instanzen: Instanzen bleiben verknüpft (wie Divis `/load`);
  entglobalisiert wird nur der Inhalt globaler Einträge (Divis Schadensbegrenzung, F10).
- E6 erweitert: `StoredNodeGuard` prüft neben den S7/S26-Pfaden und dem Code-Modul auch `$variable`-Verweise
  (Ablehnung, wenn `canonical()` sie für das Ziel ablehnt oder ihre Einstellungen verändern würde) – Grund:
  R2-Befund 1 (before/after nach dem Autor des Ziel-Posts).
- E3 ergänzt: neue Einträge zusätzlich nur mit `publish_posts` (Bibliothekseinträge sind immer `publish`).
- E9 umgesetzt mit: `lit/divi-node-copy` und `lit/divi-dynamic-sources` in der Gruppe „Divi“; die Gruppe
  „Divi-Bibliothek“ sperrt als Ganzes („nur lesen“/deaktiviert) auch Schreibzugriffe der Divi-Werkzeuge auf
  Bibliothekseinträge (`ExposurePolicy::groupWritable`).
- Antwort F2: jede Änderung eines globalen Eintrags ist zuerst ein Probelauf (auch ohne veröffentlichte
  Instanzen); `lit-divi-delete` nutzt dasselbe `confirm`, sein `confirm_required` nennt dann ebenfalls die
  betroffenen Seiten.
- Bibliothekseinträge bekommen keine Divi-Flags (Befund 4); `_et_pb_row_layout` wird nicht geschrieben (Befund 5).
- E15: Adapter `DiviRoles`, `DiviLibraryStore`, Prüfung `DiviCompatibility` in `lit-site-status`.
- E13d umgesetzt als Hinweis in `notes`; eine eigene Expansion globaler Module im Auszug unterbleibt.
  • Step 8: Tests und Lint

Run: vendor/bin/phpunit --filter 'InstructionsBuilderTest|ToolNamingTest|GuideRepositoryTest|GuideAbilityTest' Expected: PASS

Run: composer test && composer lint Expected: OK

Run: grep -rn "Claude\|ChatGPT\|claude.ai" docs/guides readme.txt src/Admin src/Guides Expected: keine Treffer

  • Step 9: Commit
git add docs/guides src/Guides/InstructionsBuilder.php readme.txt wp-lit-mcp.php src/Plugin.php 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 der Nachricht docs: guides, readme and spec for the Divi Library (v0.4.0).


Task 17: Ende-zu-Ende-Test divi-library-smoke.sh und Abnahme-Notiz

Prüft 3a über den echten OAuth-Endpunkt in DDEV (Divi 5.2.1): Exposition, Speichern normal/global mit Divis Termen, Einfügen als Instanz und Kopie, globaler Probelauf/confirm, Knoten kopieren, {{link:ID}}, die Übernahmen, ein Redakteur ohne unfiltered_html und die Gruppe „nur lesen“. Räumt alles wieder auf. Danach die manuelle Abnahme-Notiz für die Punkte, die den Visual Builder brauchen.

Files:

  • Create: tests/Integration/divi-library-smoke.sh
  • Create: docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md

Interfaces:

  • Consumes: tests/Integration/lib.sh (connect, tool, ability, rpc, error_code, pass, fail, finish, guard_exit, cleanup_app_passwords_for), Fixture tests/Fixtures/divi/converted-landing.html.

  • Produces: Skript, das mit ALL CHECKS PASSED endet.

  • Step 1: Skript schreiben

tests/Integration/divi-library-smoke.sh:

#!/usr/bin/env bash
# End-to-end checks of Etappe 3a (Divi Library, global modules, node copy) 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/divi-library-smoke.sh
# Creates draft pages and library entries (titles "LIT Smoke …"), a temporary role/user without
# unfiltered_html, and removes everything again (posts with their revisions, backups, change-log rows,
# role, user, app passwords, options).
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)
EDITOR_LOGIN=lit-lib-editor
FIXTURES=$PLUGIN/tests/Fixtures/divi
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]')
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__')
CREATED_POSTS=()

restore_exposure() {
  if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then
    wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
  else
    wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true
  fi
}

cleanup() {
  # Library entries and backups the tools created ("LIT Smoke …", also from a run that stopped early).
  for id in $(wp post list --post_type=et_pb_layout --post_status=publish,draft,trash --s="LIT Smoke" --field=ID 2>/dev/null); do
    CREATED_POSTS+=("$id")
  done
  for id in "${CREATED_POSTS[@]}"; do
    wp post delete "$id" --force >/dev/null 2>&1 || true
  done
  cleanup_app_passwords_for "$ADMIN_LOGIN"
  cleanup_app_passwords_for "$EDITOR_LOGIN"
  LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]}" 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_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
    $wpdb->delete($table, ["object_id" => $id]);
}' >/dev/null 2>&1 || true
  wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
  wp role delete lit_lib_editor >/dev/null 2>&1 || true
  wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
  restore_exposure
}
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT

create_page() { # $1 = markup (not a file), $2 = title -> prints the ID
  LIT_CONTENT="$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval '
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "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() { # $1 = post ID
  wp post get "$1" --field=post_content
}

outline() { # $1 = post ID -> the lit-divi-outline result
  tool lit-divi-outline "{\"post_id\":$1}"
}

# 0. Two pages from the converted landing page fixture
LANDING=$(cat "$FIXTURES/converted-landing.html")
PAGE_ID=$(create_page "$LANDING" "LIT Smoke Quelle"); CREATED_POSTS+=("$PAGE_ID")
TARGET_ID=$(create_page "$LANDING" "LIT Smoke Ziel"); CREATED_POSTS+=("$TARGET_ID")
connect "$ADMIN_LOGIN"

# 1. Exposure: lit-library-insert direct, the other new tools indirect
LIST=$(rpc tools/list '{}')
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null || fail "lit-library-insert is not direct"
for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do
  if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi
done
pass "tools/list: lit-library-insert direct, library-list/-save, node-copy, dynamic-sources indirect"

# 2. Save a section as normal library entry: Divi's terms, no Divi flags, unchanged node bytes
SOURCE=$(outline "$PAGE_ID")
REV=$(echo "$SOURCE" | jq -r '.tree_rev')
SECTION=$(echo "$SOURCE" | jq -r '[.nodes[] | select(.type == "section")][1].id')
SAVED=$(ability lit/library-save "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$REV" --arg s "$SECTION" \
  '{post_id: $id, tree_rev: $rev, id: $s, title: "LIT Smoke Abschnitt"}')")
LIB_ID=$(echo "$SAVED" | jq -r '.library_id // empty')
[ -n "$LIB_ID" ] || fail "library-save: $SAVED"
CREATED_POSTS+=("$LIB_ID")
[ "$(wp post term list "$LIB_ID" layout_type --field=slug)" = "section" ] || fail "layout_type of $LIB_ID"
[ "$(wp post term list "$LIB_ID" scope --field=slug)" = "non_global" ] || fail "scope of $LIB_ID"
[ -z "$(wp post meta get "$LIB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "library entry got Divi flags"
[ "$(LIT_PAGE="$PAGE_ID" LIT_LIB="$LIB_ID" wp eval '
$page = (string) get_post((int) getenv("LIT_PAGE"))->post_content;
$lib = (string) get_post((int) getenv("LIT_LIB"))->post_content;
$open = "<!-- wp:divi/placeholder -->"; $close = "<!-- /wp:divi/placeholder -->";
$inner = str_starts_with($lib, $open) && str_ends_with($lib, $close) ? substr($lib, strlen($open), -strlen($close)) : "";
echo $inner !== "" && str_contains($page, $inner) ? "yes" : "no";')" = "yes" ] || fail "library entry does not hold the node bytes"
ability lit/library-list '{"search":"LIT Smoke"}' | jq -e --argjson id "$LIB_ID" '.items | map(.id) | index($id) != null' >/dev/null \
  || fail "library-list does not show $LIB_ID"
pass "lit/library-save: normal section with Divi's terms, node bytes in a divi/placeholder frame, listed"

# 3. Save a module as global entry, insert it as instance into the target page
TEXT=$(echo "$SOURCE" | jq -r '[.nodes[] | select(.type == "text")][0].id')
GSAVED=$(ability lit/library-save "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$REV" --arg m "$TEXT" \
  '{post_id: $id, tree_rev: $rev, id: $m, title: "LIT Smoke Globales Modul", scope: "global"}')")
GLOB_ID=$(echo "$GSAVED" | jq -r '.library_id // empty')
[ -n "$GLOB_ID" ] || fail "global library-save: $GSAVED"
CREATED_POSTS+=("$GLOB_ID")
[ "$(wp post term list "$GLOB_ID" scope --field=slug)" = "global" ] || fail "scope of $GLOB_ID"
[ "$(wp post meta get "$GLOB_ID" _et_pb_excluded_global_options)" = "[]" ] || fail "excluded global options of $GLOB_ID"
TARGET=$(outline "$TARGET_ID")
COLUMN=$(echo "$TARGET" | jq -r '[.nodes[] | select(.type == "column")][0].id')
INSERTED=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$TARGET" | jq -r .tree_rev)" \
  --argjson lib "$GLOB_ID" --arg c "$COLUMN" '{post_id: $id, tree_rev: $rev, library_id: $lib, parent: $c}')")
echo "$INSERTED" | jq -e '.ok == true' >/dev/null || fail "library-insert global: $INSERTED"
content_of "$TARGET_ID" | grep -q "<!-- wp:divi/global-layout {\"globalModule\":\"$GLOB_ID\",\"blockName\":\"divi/text\",\"localAttrs\":{" \
  || fail "instance not in the builder's format"
outline "$TARGET_ID" | jq -e --arg g "[global $GLOB_ID]" '.outline | contains($g)' >/dev/null || fail "outline does not name the instance"
tool lit-page-render-summary "{\"post_id\":$TARGET_ID}" | jq -e --arg g "Globales Modul $GLOB_ID" '.notes | map(contains($g)) | any' >/dev/null \
  || fail "render summary does not note the global module"
pass "global entry saved; lit-library-insert writes the builder's instance; outline and render summary name it"

# 4. Change the global entry: dry run lists the target page, confirm saves with a revision, pages untouched
BEFORE_TARGET=$(content_of "$TARGET_ID" | sha256sum)
GREV=$(outline "$GLOB_ID" | jq -r '.tree_rev')
DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" '{post_id: $id, tree_rev: $rev,
  changes: [{id: "m0", label: "LIT Smoke geändert"}]}')")
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "global edit without confirm: $DRY"
echo "$DRY" | jq -r '.__error' | jq -e --argjson t "$TARGET_ID" '.affected | map(.post_id) | index($t) != null' >/dev/null \
  || fail "dry run does not list the target page: $DRY"
[ "$(outline "$GLOB_ID" | jq -r '.tree_rev')" = "$GREV" ] || fail "dry run wrote something"
DONE=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" '{post_id: $id, tree_rev: $rev, confirm: true,
  changes: [{id: "m0", label: "LIT Smoke geändert"}]}')")
echo "$DONE" | jq -e '.ok == true and .revision_id != null and .affected_count >= 1' >/dev/null || fail "confirmed global edit: $DONE"
[ "$(content_of "$TARGET_ID" | sha256sum)" = "$BEFORE_TARGET" ] || fail "the global edit changed the page bytes"
pass "global entry: dry run with affected pages, confirm saves with revision, pages keep their bytes"

# 5. Insert the normal entry as copy: no frame, no CRLF
TREV=$(outline "$TARGET_ID" | jq -r '.tree_rev')
COPY=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson lib "$LIB_ID" \
  '{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')")
echo "$COPY" | jq -e '.ok == true' >/dev/null || fail "library-insert copy: $COPY"
if content_of "$TARGET_ID" | grep -q $'\r'; then fail "copy brought CRLF into the page"; fi
pass "lit-library-insert copy"

# 6. Copy a node between pages; a stale source tree_rev writes nothing
TREV=$(echo "$COPY" | jq -r '.tree_rev')
STALE=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$PAGE_ID" --arg s "$SECTION" \
  '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: $s, source_tree_rev: "abcdef012345"}')")
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale source_tree_rev: $STALE"
NODE_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$PAGE_ID" --arg s "$SECTION" \
  --arg srev "$(outline "$PAGE_ID" | jq -r .tree_rev)" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: $s, source_tree_rev: $srev}')")
echo "$NODE_COPY" | jq -e '.ok == true' >/dev/null || fail "node-copy: $NODE_COPY"
pass "lit/divi-node-copy: stale source refused, copy saved"

# 7. {{link:ID}} in a URL field; refused in a text field
TREV=$(echo "$NODE_COPY" | jq -r '.tree_rev')
LINKED=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --arg c "$COLUMN" --arg u "{{link:$PAGE_ID}}" \
  '{post_id: $id, tree_rev: $rev, parent: $c, nodes: [{type: "button", text: "Zur Quelle", url: $u}]}')")
echo "$LINKED" | jq -e '.ok == true' >/dev/null || fail "{{link}} in a button: $LINKED"
content_of "$TARGET_ID" | grep -q 'post_link_url_page' || fail "{{link}} not stored as post_link_url_page"
TREV=$(echo "$LINKED" | jq -r '.tree_rev')
TEXT_LINK=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --arg c "$COLUMN" --arg u "{{link:$PAGE_ID}}" \
  '{post_id: $id, tree_rev: $rev, parent: $c, nodes: [{type: "heading", text: $u}]}')")
[ "$(error_code "$TEXT_LINK")" = "validation_failed" ] || fail "{{link}} in a text field: $TEXT_LINK"
pass "{{link:ID}} stored as Divi's internal link, refused outside link fields"

# 8. Carry-overs: guide "all", design-system filter, dynamic sources, site-status compatibility
tool lit-guide '{"topic":"all"}' | jq -e '.content | contains("Thema `divi-library`")' >/dev/null || fail "guide all"
tool lit-divi-design-system '{"only":["colors"]}' | jq -e '.variables == [] and .presets == []' >/dev/null || fail "design-system only"
ability lit/divi-dynamic-sources '{"post_type":"page"}' | jq -e '.sources | map(.name) | index("post_title") != null' >/dev/null \
  || fail "dynamic sources"
tool lit-site-status '{}' | jq -e '.components[] | select(.id == "divi") | .problems == []' >/dev/null || fail "Divi compatibility findings"
pass "lit-guide all, design-system only, dynamic sources, site-status without Divi findings"

# 9. A user without unfiltered_html cannot carry custom CSS to another post (E6)
CSS_ID=$(create_page '<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":{"freeForm":"body{outline:1px solid red}"}}}} --><!-- wp:divi/row --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}}} --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"<p>CSS</p>"}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->' "LIT Smoke CSS")
CREATED_POSTS+=("$CSS_ID")
wp role create lit_lib_editor "LIT Smoke Bibliothek" --clone=editor >/dev/null
wp cap remove lit_lib_editor unfiltered_html >/dev/null
wp option update lit_mcp_allowed_roles '["administrator","lit_lib_editor"]' --format=json >/dev/null
wp user create "$EDITOR_LOGIN" lit-lib-editor@example.invalid --role=lit_lib_editor >/dev/null
connect "$EDITOR_LOGIN"
BEFORE_TARGET=$(content_of "$TARGET_ID" | sha256sum)
CARRY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(outline "$TARGET_ID" | jq -r .tree_rev)" \
  --argjson src "$CSS_ID" --arg srev "$(outline "$CSS_ID" | jq -r .tree_rev)" \
  '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s0", source_tree_rev: $srev}')")
[ "$(error_code "$CARRY")" = "validation_failed" ] || fail "editor carried custom CSS: $CARRY"
KEEP=$(ability lit/library-save "$(jq -cn --argjson id "$CSS_ID" --arg rev "$(outline "$CSS_ID" | jq -r .tree_rev)" \
  '{post_id: $id, tree_rev: $rev, id: "s0", title: "LIT Smoke CSS-Eintrag"}')")
[ "$(error_code "$KEEP")" = "validation_failed" ] || fail "editor saved custom CSS to the library: $KEEP"
[ "$(content_of "$TARGET_ID" | sha256sum)" = "$BEFORE_TARGET" ] || fail "a refused copy wrote something"
pass "user without unfiltered_html: custom CSS neither copied nor saved to the library"

# 10. Group "Divi-Bibliothek" read-only: no writes to library entries, lit-library-insert gone
wp option update lit_mcp_exposure '{"@readonly:divi-library":true}' --format=json >/dev/null
connect "$ADMIN_LOGIN"
if rpc tools/list '{}' | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null; then
  fail "lit-library-insert listed although the group is read-only"
fi
LREV=$(outline "$LIB_ID" | jq -r '.tree_rev')
[ "$LREV" != "null" ] || fail "library entry no longer readable"
BLOCKED=$(tool lit-divi-update "$(jq -cn --argjson id "$LIB_ID" --arg rev "$LREV" '{post_id: $id, tree_rev: $rev,
  changes: [{id: "s0", label: "LIT Smoke gesperrt"}]}')")
[ "$(error_code "$BLOCKED")" = "forbidden" ] || fail "write to a library entry with the group read-only: $BLOCKED"
restore_exposure
pass "group Divi-Bibliothek read-only: entries readable, not writable, lit-library-insert not listed"

finish
  • Step 2: Skript ausführen

Run: cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-library-smoke.sh Expected: alle Zeilen ok - …, am Ende ALL CHECKS PASSED. Danach prüfen, dass nichts übrig ist:

Run: cd /home/janniclas/Projekte/WordPress && ddev wp post list --post_type=et_pb_layout,page --post_status=publish,draft,trash --s="LIT Smoke" --format=count Expected: 0

Die übrigen Smokes laufen unverändert grün:

Run: cd /home/janniclas/Projekte/WordPress && for s in divi-write-smoke divi-read-smoke mcp-smoke wp-basis-smoke media-admin-smoke; do timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh | tail -1; done Expected: fünfmal ALL CHECKS PASSED

  • Step 3: Abnahme-Notiz schreiben

docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md:

# Etappe 3a – Manuelle Abnahme (Divi-Bibliothek)

Automatisch geprüft sind bereits:

- alle Unit-Tests (`composer test`) und Lint (`composer lint`);
- `divi-library-smoke.sh` (neu) und die fünf bisherigen Integrationsskripte (alle in `tests/Integration/`).

`divi-library-smoke.sh` prüft über den OAuth-Endpunkt: `lit-library-insert` direkt, die übrigen neuen Tools
indirekt; Speichern als normaler und globaler Eintrag mit Divis Begriffen, ohne Divi-Kennzeichen und mit den
unveränderten Bytes des Knotens; Einfügen als Instanz im Format des Visual Builders und als Kopie; Probelauf,
`confirm` und Revision beim Ändern eines globalen Eintrags; Knoten kopieren (auch Ablehnung einer veralteten
Quelle); `{{link:ID}}`; die Übernahmen aus Etappe 2; einen Redakteur ohne `unfiltered_html`, der eigenes CSS
weder kopieren noch in die Bibliothek speichern kann; die Gruppe „Divi-Bibliothek“ auf „nur lesen“.

Die folgenden Punkte brauchen den echten Visual Builder bzw. einen verbundenen KI-Assistenten. Abweichungen
als kurze Notiz zurückmelden; sie werden Folgeaufgaben.

## 1. Vom Plugin angelegte Bibliothekseinträge

1. Über den KI-Assistenten einen Abschnitt einer Entwurfsseite als normalen Eintrag und ein Modul als
   globalen Eintrag speichern lassen.
2. Divi → Divi-Bibliothek öffnen: beide Einträge erscheinen mit richtigem Typ (Abschnitt/Modul), der globale
   als global.
3. Im Visual Builder einer Seite „Aus Bibliothek hinzufügen“: beide Einträge sind auswählbar und lassen sich
   einfügen.

## 2. Vom Plugin eingefügte globale Instanz

1. Den globalen Eintrag mit `lit-library-insert` in eine Entwurfsseite einfügen lassen.
2. Seite im Visual Builder öffnen: das Modul ist als global markiert und zeigt den Inhalt der Bibliothek.
3. Das Modul im Builder ändern und speichern: die Änderung erscheint im Bibliothekseintrag und auf einer
   zweiten Seite mit derselben Instanz.
4. Vorschau der Seite: der Inhalt des globalen Moduls wird angezeigt.

## 3. Globales Modul über den Assistenten ändern

1. Den Assistenten bitten, den Text des globalen Moduls zu ändern. Er muss zuerst die betroffenen Seiten nennen
   und nachfragen.
2. Nach Zustimmung: Vorschau beider Seiten zeigt den neuen Text; unter Einstellungen → LIT MCP → Protokoll
   steht der Eintrag mit Revision; die Revision ist in WordPress beim Bibliothekseintrag sichtbar.

## 4. Kopie und Knoten kopieren

1. Einen normalen Eintrag als Kopie einfügen und einen Abschnitt mit `lit/divi-node-copy` auf eine andere Seite
   kopieren lassen.
2. Beide im Builder öffnen, ändern, speichern – nichts Ungewöhnliches (keine verknüpften Änderungen an anderen
   Seiten).

## 5. Interner Link

1. Einen Button mit `{{link:<ID einer Entwurfsseite>}}` einfügen lassen.
2. Im Builder zeigt das Link-Feld dynamischen Inhalt „Seite Link“ mit der gewählten Seite.
3. Die Zielseite veröffentlichen: der Button verlinkt auf die schöne Adresse.

## 6. Rechte und Einstellungen

1. In Divi → Rollen-Editor für „Redakteur“ „Globale Bibliothekselemente bearbeiten“ abschalten und als
   Redakteur ein globales Modul ändern lassen: Ablehnung mit Hinweis auf den Rollen-Editor.
2. Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ ist sichtbar; „nur lesen“ entfernt
   `lit-library-insert` aus der Werkzeugliste (nach neuer Verbindung).
3. `lit-site-status`: bei Divi keine Einträge unter `problems`.

## Aufräumen

Die Testeinträge in der Divi-Bibliothek und die Entwurfsseiten löschen (Titel wie bei der Abnahme vergeben).
Divi legt fehlende Systembegriffe (`scope`, `layout_type`, `module_width`) beim nächsten Speichern selbst an.
  • Step 4: Commit
chmod +x tests/Integration/divi-library-smoke.sh
git add tests/Integration/divi-library-smoke.sh docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md

Commit wie in den Global Constraints mit der Nachricht test: end-to-end smoke and acceptance note for the Divi Library (3a).


Offene Punkte (nicht Teil dieses Plans)

  • lit/revision-restore erreicht Bibliothekseinträge nicht (ContentSchema::EXCLUDED); Zurücksetzen über die WordPress-Oberfläche. Kandidat für 3b.
  • lit/divi-replace-text durchsucht keine Bibliothekseinträge (je Inhaltstyp); globale Module werden weiter als skipped_nodes gemeldet.
  • MCP-Prompts (Spec §9.3) gibt es im Plugin noch nicht; globale-module-vorschlagen steht als Ablauf im Guide divi-library.
  • Ob der Visual Builder Instanzen mit tief verschachtelten localAttrs anders sortiert als nur auf oberster Ebene, zeigt erst ein reicheres Builder-Fixture; die Abnahme (Abschnitt 2) prüft das Verhalten im Builder.
  • Bearbeiten nicht synchronisierter Attribute einer einzelnen Instanz (localAttrsMap) bleibt Builder-Sache (E2).
  • lit/library-make-global, lit/library-find-similar, Theme Builder: Etappe 3b.