Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
313 KiB
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-MCPanlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Seiten, Bibliothekseinträge, Benutzer, Rollen und die Optionenlit_mcp_allowed_roles/lit_mcp_exposurewieder 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 mitdefined('ABSPATH') || exit;, höchstens 120 Zeichen pro Zeile. Nach jedem Task:composer testundcomposer lintsauber. src/Integrations/Divi/BlockTree/*undsrc/Integrations/Divi/Compose/*bleiben reines PHP ohne WordPress-Aufrufe (Spec §4.4). WordPress/Divi nur inDivi/Pages/,Divi/Library/,Divi/Design/,Divi/Schema/und inDiviCompatibility.- Eine Engstelle nach
post_content(D7, S39, E17): Jeder neue Schreibweg inpost_content– Seiten wie Bibliothekseinträge – geht überComposer/AttrWriter/ShortformWriter/ValueCleaner(Client-Werte) bzw.TreeEditor(Struktur) und wird ausschließlich vonDiviSaver::save()/saveNew()gespeichert (kses-Fenster nur dort). Abilities rufensetAttrs()/insertChild()nie mit Client-Werten auf. Gespeicherte Knoten, die den Post wechseln (Seite → Seite, Bibliothek → Seite, Seite → Bibliothek), laufen durchStoredNodeGuard(S7/S26-Pfade, Code-Modul,$variable-Einstellungen) – für Benutzer ohneunfiltered_htmlwird abgelehnt statt bereinigt; alles andere bleibt byte-identisch. Keine Builder-Route (divi/v1/…), keinet_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-Rolledivi_library; einen Eintrag lesen = zusätzlichedit_postauf den Eintrag; speichern/anlegen = +publish_postsdes Typset_pb_layout+save_library; bearbeiten =edit_post+save_library, bei globalen Einträgen +edit_global_library; einfügen = +add_library; Dynamic-Content-Feldliste nur mitread_dynamic_content_custom_fields. Divi-Rollen nur überDiviRoles. Ohne Bibliothekszugang bleiben Bibliotheks-IDs für alle Werkzeugenot_foundwie bisher (ContentSchema::EXCLUDEDbleibt 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 dietree_revder 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 (wielit/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_requiredmitaffected/affected_count, nichts gespeichert); mitconfirm: truewird vorher eine Revision des Eintrags angelegt – sind Revisionen abgeschaltet, eine Sicherung (PostCloner::backup,backup_id) –, danach seitenweitremove_static_resources('all', 'all', true)undet_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 dreilit/library-*-Tools; „nur lesen“/deaktiviert dieser Gruppe sperrt auch Schreibzugriffe der Divi-Werkzeuge auf Bibliothekseinträge (ExposurePolicy::groupWritable('divi-library')).lit/library-insertfolgt zusätzlichlit/divi-insert(Audit M2). Jeder Task mit neuem direktem Tool aktualisiertdocs/guides/overview.md„Direkt sichtbar:“ im selben Task (Ruling Q2); Texte nennen direkte Toolslit-<name>, indirektelit/<name>(ToolNamingTest). In PHP-Texten nietopic "<x>"für ein Thema ohnedocs/guides/<x>.mdschreiben (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 mitFunctions\expect()prüfen;error_log()perini_set('error_log', …)umleiten. Reine BlockTree-/Compose-Tests erben vonPHPUnit\Framework\TestCase. Integrationstests:cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh. composer testbraucht mehr als 128 MB (Task 1 behebt das); danach laufencomposer testundphp vendor/bin/phpunitmit jedermemory_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
- 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/aftermit HTML) überlit/divi-node-copy,lit/library-insertoderlit/library-save: Erwartet Ablehnung „nur im Divi-Builder“, nichts gespeichert (E6, Audit M1, R2-Befund 1) → TestsStoredNodeGuardTest(Task 11),DiviNodeCopyAbilityTest::testEditorCannotCarryBuilderOnlyValuesToAnotherPage(Task 14),LibraryInsertAbilityTest::testEditorCannotInsertAnItemWithCustomCss(Task 13),LibrarySaveAbilityTest::testEditorCannotSaveACodeModule(Task 12). - Globaler Eintrag mit Instanzen auf veröffentlichten Seiten wird geändert – auch per
lit-divi-delete, das ein eigenesconfirmhat: Ohneconfirmnichts gespeichert, Antwort nennt die betroffenen Seiten; mitconfirmRevision bzw. Sicherung und seitenweite Cache-Leerung → TestsGlobalEditsTest::testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing,::testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm,::testWithoutRevisionsABackupIsMadeFirstundDiviSaverTest::testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(Task 9). - 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) undGlobalEditsTest::testRefusesToEmptyOrDoubleTheRoot(Task 9). - Rechte: Autor ohne Divi-Rolle
divi_library/save_library/add_library/edit_global_library, fremder Eintrag ohneedit_post, Mitarbeiter ohnepublish_posts, Gruppe „Divi-Bibliothek“ auf „nur lesen“ – alle erhaltenforbidden, nichts wird geschrieben; ohne eingehängten Bibliothekszugang bleiben Bibliotheks-IDsnot_found→ TestsLibraryAccessTest(Task 8),DiviPageLoaderTest::testLibraryItemsNeedTheLibraryAccess(Task 8),LibrarySaveAbilityTest::testNeedsPublishPostsAndTheDiviRole(Task 12). - Parallele Änderung: Quelle oder Ziel wurde seit dem Lesen geändert (
source_tree_rev/tree_revveraltet), oder der globale Eintrag änderte sich zwischen Probelauf undconfirm→rev_mismatch, nichts geschrieben → TestsDiviNodeCopyAbilityTest::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 testundphp vendor/bin/phpunitlaufen unabhängig von dermemory_limitder 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"
Task 2: Interner Link {{link:ID}} (E13a)
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ältpost_link_url_pagemitpost_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/guidemittopic=all→{topic: "all", topics: list<string>, content: string}; KonstanteGuideAbility::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}>, Fixturetests/Fixtures/divi/stage3/page--globale-instanzen.html(g0 = Instanz 1058divi/section, s0.r0.c0.g0 = Instanz 1059divi/text). -
Produces:
NodeSummary::globalRef(Block $block): ?array{id: string, block_name: string}; Outline-Zeilen mitglobal_id: ?string;lit/divi-node-getmitglobal: null|{id, block_name, hint};lit/divi-design-systemmitonly,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:
notesenthält je globaler InstanzGlobales 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ätzlichproblems: 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): BlockContainer::replaceChild(Block $old, Block $new): void,Container::unifyLineBreaks(): voidGlobalInstance::BLOCK = 'divi/global-layout',GlobalInstance::effectiveName(Block): string,GlobalInstance::of(Block $root, int $libraryId): Block,GlobalInstance::deglobalize(Block $block): BlockLibraryContent::load(Parser $parser, string $content): Document(wirftMalformedMarkup),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,canInsertfolgen 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ägeget_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üftcanEdit().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_ErrorDiviWriteAbility::precheck(array $input): ?WP_Error(protected, Standardnull)ExposurePolicy::groupWritable(string $group): boolDiviIntegration::__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) }– Namelit/library-list, indirekt, nur lesend; Ausgabe{items, next_cursor}bzw. mitidzusätzlichoutline,tree_rev,rev,edit_url, bei globalenaffected,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überDiviWriter::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(wirftRuntimeException, löscht bei Teilfehlern),DiviLibraryStore::delete(int $id): boolLibraryAccess::canSave(): ?WP_Errorfinal class LibrarySaveAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules) }–lit/library-save, indirekt, schreibend (nicht idempotent); ErgebnisToolResult+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, Eingabepost_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-libraryanlegen
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-libraryergä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 alspost_id); globale Module:lit-guidetopicdivi-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 neulit/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 Contentpost_link_url_<Typ>mitpost_id(nur Link-Felder; Etappe 3a, E13a) |“ - §5.7: Zeile
lit/divi-dynamic-sources→ „… – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4)“; Zeilelit/library-list,lit/library-get→ „|lit/library-list| lesen | Einträge nach Typ (layout_type) und Scope, mitcursor; mitidDetail inkl. Knoten-Übersicht und betroffener Seiten (ersetztlit/library-get, E9) |“; Zeilelit/library-insert→ „… als Kopie oder globale Instanz im Format des Visual Builders (localAttrs-Schnappschuss,globalParent) einfügen; direkt“; Zeilelit/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 „Themaallliefert alle Themen (Etappe 3a)“. - §9.3: „(Stand Etappe 3a: noch keine MCP-Prompts im Plugin;
globale-module-vorschlagensteht als Ablauf im Guidedivi-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), Fixturetests/Fixtures/divi/converted-landing.html. -
Produces: Skript, das mit
ALL CHECKS PASSEDendet. -
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-restoreerreicht Bibliothekseinträge nicht (ContentSchema::EXCLUDED); Zurücksetzen über die WordPress-Oberfläche. Kandidat für 3b.lit/divi-replace-textdurchsucht keine Bibliothekseinträge (je Inhaltstyp); globale Module werden weiter alsskipped_nodesgemeldet.- MCP-Prompts (Spec §9.3) gibt es im Plugin noch nicht;
globale-module-vorschlagensteht als Ablauf im Guidedivi-library. - Ob der Visual Builder Instanzen mit tief verschachtelten
localAttrsanders 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.