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

7317 lines
313 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Etappe 3a – Divi-Bibliothek: Auflisten, Speichern, Einfügen, Kopieren, globale Module bearbeiten – Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Der KI-Assistent arbeitet mit der Divi-Bibliothek: Einträge auflisten und lesen (`lit/library-list`), Knoten als normalen oder globalen Eintrag speichern (`lit/library-save`), Einträge als Kopie oder als globale Instanz im Format des Visual Builders einfügen (`lit/library-insert`, direkt), Knoten zwischen Seiten kopieren (`lit/divi-node-copy`), Bibliothekseinträge – auch globale, mit Probelauf, `confirm` und Sicherung – mit den vorhandenen Divi-Werkzeugen bearbeiten, verfügbare Dynamic-Content-Quellen abfragen (`lit/divi-dynamic-sources`); dazu die Übernahmen aus Etappe 2 (`{{link:ID}}`, `lit-guide` mit allen Themen, S19) und der Hinweis auf globale Module in `lit/page-render-summary`.
**Architecture:** Reines PHP in `Divi/BlockTree/` bekommt die Bausteine für globale Module (`Block::copyAs()`, `GlobalInstance` – Instanz im Builder-Format und Entglobalisieren wie Divis `maybe_deglobalize_nested_global_modules` –, `LibraryContent` – Rahmen `divi/placeholder`, Wurzeln, Formregeln). Divi-Interna liegen hinter Adaptern mit Live-Kompatibilitätsprüfung (Entscheidung E15): `DiviRoles` (`et_pb_is_allowed`), `DiviLibraryStore` (Post-Typ `et_pb_layout`, Terme, Meta, `GlobalLayout::is_global_layout_template`), `DiviCompatibility` (Befund in `lit/site-status`). `LibraryAccess` bündelt die Rechte je Objekt (E3) und wird in `DiviPageLoader` eingehängt, damit alle Divi-Werkzeuge Bibliotheks-IDs annehmen (E1); `GlobalEdits` ergänzt `DiviWriter::commit()` um Formregeln, Probelauf mit betroffenen Seiten, `confirm` und Sicherung (E2, Antwort F2). Jeder neue Weg nach `post_content` läuft weiter über `Composer`/`TreeEditor`/`DiviSaver`; übernommene gespeicherte Knoten prüft `StoredNodeGuard` gegen die S7/S26-Sperren (E6). Die neuen Tools bilden die Gruppe „Divi-Bibliothek“ (`LibraryIntegration`), `lit/divi-node-copy` und `lit/divi-dynamic-sources` gehören zur Gruppe „Divi“.
**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, `wp_insert_post`/`wp_update_post`, Revisionen, kses, Taxonomien), Divi 5.2.1 (`et_pb_layout`, `et_pb_is_allowed`, `GlobalLayout`, `DynamicContentOptions`, `ET_Core_PageResource`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes.
**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4.3, §4.5, §5.1–5.7, §8.1–8.2, §9, §10, §12 Etappe 3) und die Entscheidungen `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` (Fakten F1–F32, Empfehlungen E1–E17, Schnittstellen-Einordnung Abschnitt 5; **die Antworten des Benutzers in „5. Antworten des Benutzers“ sind verbindlich**), dazu die Rulings `2026-09-26-etappe-{1a,1b,2a,2b}-rulings.md`, `2026-09-26-etappe-2-entscheidungen.md` (D1–D12, R2), Security-Audit und Praxistest 2b sowie die Fixtures `tests/Fixtures/divi/stage3/` mit `README.md` (Commit e7cc4ef).
**Voraussetzung:** Branch `etappe-3` mit Stand 30788f2 (Etappe 2b vollständig, 1597 Tests grün). Pods ist lokal deaktiviert; `lit/divi-dynamic-sources` wird für Pods über Unit-Tests abgesichert.
## Global Constraints
- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Seiten, Bibliothekseinträge, Benutzer, Rollen und die Optionen `lit_mcp_allowed_roles`/`lit_mcp_exposure` wieder auf.
- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.2.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber.
- `src/Integrations/Divi/BlockTree/*` und `src/Integrations/Divi/Compose/*` bleiben reines PHP ohne WordPress-Aufrufe (Spec §4.4). WordPress/Divi nur in `Divi/Pages/`, `Divi/Library/`, `Divi/Design/`, `Divi/Schema/` und in `DiviCompatibility`.
- **Eine Engstelle nach `post_content` (D7, S39, E17):** Jeder neue Schreibweg in `post_content` – Seiten wie Bibliothekseinträge – geht über `Composer`/`AttrWriter`/`ShortformWriter`/`ValueCleaner` (Client-Werte) bzw. `TreeEditor` (Struktur) und wird ausschließlich von `DiviSaver::save()`/`saveNew()` gespeichert (kses-Fenster nur dort). Abilities rufen `setAttrs()`/`insertChild()` nie mit Client-Werten auf. Gespeicherte Knoten, die den Post wechseln (Seite → Seite, Bibliothek → Seite, Seite → Bibliothek), laufen durch `StoredNodeGuard` (S7/S26-Pfade, Code-Modul, `$variable`-Einstellungen) – für Benutzer ohne `unfiltered_html` wird abgelehnt statt bereinigt; alles andere bleibt byte-identisch. Keine Builder-Route (`divi/v1/…`), kein `et_pb_submit_layout`/`et_pb_create_layout`.
- **Rechte je Objekt (Spec §4.5, E3 – strenger als Divi, nie lockerer):** Bibliothek lesen/auflisten = `edit_posts` + Divi-Rolle `divi_library`; einen Eintrag lesen = zusätzlich `edit_post` auf den Eintrag; speichern/anlegen = + `publish_posts` des Typs `et_pb_layout` + `save_library`; bearbeiten = `edit_post` + `save_library`, bei globalen Einträgen + `edit_global_library`; einfügen = + `add_library`; Dynamic-Content-Feldliste nur mit `read_dynamic_content_custom_fields`. Divi-Rollen nur über `DiviRoles`. Ohne Bibliothekszugang bleiben Bibliotheks-IDs für alle Werkzeuge `not_found` wie bisher (`ContentSchema::EXCLUDED` bleibt unverändert).
- **Sperren und Alles-oder-nichts:** Jede Divi-Schreibung verlangt `tree_rev` (Ruling P16); Werkzeuge mit Quellknoten (`lit/library-save`, `lit/divi-node-copy`) verlangen zusätzlich die `tree_rev` der Quelle. Abweichung → `rev_mismatch`, nichts geschrieben. Erst vollständig validieren, dann einmal speichern; beim Anlegen eines Bibliothekseintrags wird der Eintrag bei jedem Fehler endgültig gelöscht (wie `lit/content-create`). Unberührte Bytes bleiben identisch (Spec §1.3 Nr. 3, Ausnahme S7).
- **Globale Einträge (Antwort F2, verbindlich):** Jede Änderung an einem globalen Bibliothekseintrag ist zuerst ein Probelauf (`confirm_required` mit `affected`/`affected_count`, nichts gespeichert); mit `confirm: true` wird vorher eine Revision des Eintrags angelegt – sind Revisionen abgeschaltet, eine Sicherung (`PostCloner::backup`, `backup_id`) –, danach seitenweit `remove_static_resources('all', 'all', true)` und `et_theme_builder_clear_wp_cache('all')` (Spec §5.6 Schritt 5).
- **Exposition:** Direkt ist nur `lit/library-insert` (22 direkte Tools, Spec §4.3). Indirekt: `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Gruppe „Divi-Bibliothek“ (`divi-library`) für die drei `lit/library-*`-Tools; „nur lesen“/deaktiviert dieser Gruppe sperrt auch Schreibzugriffe der Divi-Werkzeuge auf Bibliothekseinträge (`ExposurePolicy::groupWritable('divi-library')`). `lit/library-insert` folgt zusätzlich `lit/divi-insert` (Audit M2). Jeder Task mit neuem direktem Tool aktualisiert `docs/guides/overview.md` „Direkt sichtbar:“ im selben Task (Ruling Q2); Texte nennen direkte Tools `lit-<name>`, indirekte `lit/<name>` (ToolNamingTest). In PHP-Texten nie `topic "<x>"` für ein Thema ohne `docs/guides/<x>.md` schreiben (ToolNamingTest).
- **Sprache und Wortwahl:** Oberfläche, Fehlermeldungen, Hinweise, Guides, Readme und Abnahme auf Deutsch mit echten Umlauten (ä, ö, ü, ß). Neutral formulieren: „KI-Assistent“, „MCP-Client“, „Verbindung“ – keine Chatbot-Marken in UI, Guides, Readme oder Tool-Texten.
- Tests: Unit-Tests der Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen; `error_log()` per `ini_set('error_log', …)` umleiten. Reine BlockTree-/Compose-Tests erben von `PHPUnit\Framework\TestCase`. Integrationstests: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh`.
- `composer test` braucht mehr als 128 MB (Task 1 behebt das); danach laufen `composer test` und `php vendor/bin/phpunit` mit jeder `memory_limit`-Vorgabe.
- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "<Nachricht>" -m "Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"` (im Folgenden kurz „Commit wie in den Global Constraints“).
## Review Focus
1. **Redakteur ohne `unfiltered_html` überträgt gespeicherten Admin-Inhalt in einen anderen Post** (Abschnitt mit eigenem CSS, HTML-Attributen, Code-Modul oder Dynamic-Content-`before`/`after` mit HTML) über `lit/divi-node-copy`, `lit/library-insert` oder `lit/library-save`: Erwartet Ablehnung „nur im Divi-Builder“, nichts gespeichert (E6, Audit M1, R2-Befund 1) → Tests `StoredNodeGuardTest` (Task 11), `DiviNodeCopyAbilityTest::testEditorCannotCarryBuilderOnlyValuesToAnotherPage` (Task 14), `LibraryInsertAbilityTest::testEditorCannotInsertAnItemWithCustomCss` (Task 13), `LibrarySaveAbilityTest::testEditorCannotSaveACodeModule` (Task 12).
2. **Globaler Eintrag mit Instanzen auf veröffentlichten Seiten wird geändert – auch per `lit-divi-delete`, das ein eigenes `confirm` hat:** Ohne `confirm` nichts gespeichert, Antwort nennt die betroffenen Seiten; mit `confirm` Revision bzw. Sicherung und seitenweite Cache-Leerung → Tests `GlobalEditsTest::testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing`, `::testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm`, `::testWithoutRevisionsABackupIsMadeFirst` und `DiviSaverTest::testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage` (Task 9).
3. **Bibliothekseintrag verliert seine Form** (Wurzel gelöscht, zweite Wurzel, anderer Wurzeltyp, globales Modul in einem globalen Eintrag): Instanzen auf allen Seiten würden brechen → abgelehnt → Tests `LibraryContentTest::testShapeViolations` (Task 7) und `GlobalEditsTest::testRefusesToEmptyOrDoubleTheRoot` (Task 9).
4. **Rechte:** Autor ohne Divi-Rolle `divi_library`/`save_library`/`add_library`/`edit_global_library`, fremder Eintrag ohne `edit_post`, Mitarbeiter ohne `publish_posts`, Gruppe „Divi-Bibliothek“ auf „nur lesen“ – alle erhalten `forbidden`, nichts wird geschrieben; ohne eingehängten Bibliothekszugang bleiben Bibliotheks-IDs `not_found` → Tests `LibraryAccessTest` (Task 8), `DiviPageLoaderTest::testLibraryItemsNeedTheLibraryAccess` (Task 8), `LibrarySaveAbilityTest::testNeedsPublishPostsAndTheDiviRole` (Task 12).
5. **Parallele Änderung:** Quelle oder Ziel wurde seit dem Lesen geändert (`source_tree_rev`/`tree_rev` veraltet), oder der globale Eintrag änderte sich zwischen Probelauf und `confirm` → `rev_mismatch`, nichts geschrieben → Tests `DiviNodeCopyAbilityTest::testStaleSourceOrTargetWritesNothing` (Task 14), `LibrarySaveAbilityTest::testStaleSourceTreeRevCreatesNothing` (Task 12), `GlobalEditsTest::testConfirmWithAStaleTreeRevWritesNothing` (Task 9).
---
## File Structure
```
phpunit.xml.dist (geändert) memory_limit 512M
composer.json (geändert) test-Skript mit memory_limit
wp-lit-mcp.php, src/Plugin.php (geändert) Version 0.4.0; Verdrahtung Bibliothek + Gates
readme.txt (geändert) Bibliothek, globale Module, Changelog 0.4.0
docs/guides/
divi-library.md (neu) Thema „divi-library“
overview.md, divi-placeholders.md, divi-structure.md, workflows.md, divi-design-system.md (geändert)
docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (geändert) §4.3, §5.2, §5.4, §5.7, §9.2
docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (geändert) Abschnitt 6 „Umsetzung 3a“
docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md (neu) manuelle Abnahme
src/
Abilities/ExposurePolicy.php (geändert) groupWritable()
Guides/InstructionsBuilder.php (geändert) Bibliotheks-Regel
Integrations/Core/EnvironmentProbe.php (geändert) Divi-Kompatibilitätsbefund
Integrations/Core/GuideAbility.php (geändert) topic = all
Integrations/WordPress/Render/PageRenderSummaryAbility.php (geändert) Hinweis globale Module
Integrations/Divi/
DiviIntegration.php (geändert) Bibliothekszugang, library(), neue Divi-Tools
DiviCompatibility.php (neu) Live-Prüfung der genutzten Divi-Interna
BlockTree/
Block.php (geändert) copyAs()
Container.php (geändert) replaceChild(), unifyLineBreaks()
NodeSummary.php (geändert) globalRef()
Outline.php (geändert) global_id
GlobalInstance.php (neu) Instanz im Builder-Format, entglobalisieren, effectiveName()
LibraryContent.php (neu) Rahmen, Wurzeln, Formregeln
Compose/
LinkTargets.php (neu) Schnittstelle für {{link:ID}}
Placeholders.php (geändert) {{link:ID}}
VariableRef.php (geändert) post_link_url_* nur in URL-Feldern, settingsOf()
StructureRules.php, TreeEditor.php (geändert) Wurzeltyp globaler Instanzen
StoredNodeGuard.php (neu) S7/S26 für übertragene gespeicherte Knoten
Design/DiviDesignSystemAbility.php (geändert) only/module-Filter (S19)
Pages/
WpLinkTargets.php (neu)
SaveScope.php (neu) Seite / Bibliothekseintrag / globaler Eintrag
DiviSaver.php (geändert) SaveScope: Flags, seitenweiter Cache
DiviPage.php, DiviPageLoader.php (geändert) Bibliothekseinträge
DiviWriter.php, DiviWriteAbility.php (geändert) commit(), notice(), precheck()
DiviSchema.php (geändert) confirm
DiviComposition.php (geändert) LinkTargets, storedGuard()
DiviNodeGetAbility.php, DiviOutlineAbility.php (geändert) global
DynamicContentMap.php (geändert) options()
DiviNodeCopyAbility.php (neu) lit/divi-node-copy
DiviDynamicSourcesAbility.php (neu) lit/divi-dynamic-sources
Library/
DiviRoles.php (neu) Adapter et_pb_is_allowed
DiviLibraryStore.php (neu) Adapter et_pb_layout, Terme, Meta
LibraryAccess.php (neu) Rechte je Objekt, Gruppen-Gate
LibraryItemSpec.php (neu) Terme/Meta eines neuen Eintrags
GlobalUsages.php (neu) Seiten mit Instanzen
GlobalEdits.php (neu) Formregeln, Probelauf, confirm, Sicherung
LibraryIntegration.php (neu) Gruppe „Divi-Bibliothek“
LibraryListAbility.php (neu) lit/library-list
LibrarySaveAbility.php (neu) lit/library-save
LibraryInsertAbility.php (neu) lit/library-insert
tests/
Stubs/EtCorePageResource.php (neu) zeichnet remove_static_resources auf
bootstrap.php (geändert)
Support/ComposeFixture.php, DiviWriterFixture.php (geändert) links(), Bibliothek
Support/LibraryFixtures.php (neu) Posts nach ID, Store/Rollen/Zugang
Fixtures/divi/stage3/README.md (geändert) Befund 9: CRLF in Bibliothekseinträgen
Unit/… (neu/geändert je Task)
Integration/divi-library-smoke.sh (neu) Ende-zu-Ende 3a
```
---
### Task 1: Testlauf ohne Speicherfehler
`composer test` bricht mit dem PHP-Standard `memory_limit=128M` ab („Allowed memory size of 134217728 bytes exhausted“); der volle Lauf braucht ≈ 177 MB (gemessen: `php -d memory_limit=1G vendor/bin/phpunit` → „Memory: 177.00 MB“). Das ist kein Leck einzelner Tests, sondern die Summe aus 1597 Tests mit Mockery/Brain Monkey und dem 237-KB-Katalog, der je Test dekodiert wird. PHPUnit setzt Werte aus `<php><ini>` zur Laufzeit; das deckt `composer test` und den direkten Aufruf ab.
**Files:**
- Modify: `phpunit.xml.dist`
- Modify: `composer.json` (`scripts.test`)
**Interfaces:**
- Consumes: nichts.
- Produces: `composer test` und `php vendor/bin/phpunit` laufen unabhängig von der `memory_limit` der CLI.
- [ ] **Step 1: Fehler nachweisen**
Run: `php -d memory_limit=128M vendor/bin/phpunit 2>&1 | grep -m1 -o 'Allowed memory size of [0-9]* bytes exhausted'`
Expected: `Allowed memory size of 134217728 bytes exhausted`
- [ ] **Step 2: Grenze in der PHPUnit-Konfiguration setzen**
`phpunit.xml.dist` vollständig:
```xml
<?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:
```json
"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**
```bash
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ält `post_link_url_page` mit `post_id` = `select_post`).
- Produces: `interface LinkTargets { public function postTypeOf(int $id): ?string; }`; `new Placeholders(DesignSystem $design, ?LinkTargets $links = null)`; `WpLinkTargets implements LinkTargets`; `DiviComposition::__construct(…, LinkTargets $links = new WpLinkTargets())` (6. Parameter); `ComposeFixture::links(): LinkTargets` (839 → page, 840 → post, sonst null).
- [ ] **Step 1: Failing tests schreiben**
In `tests/Support/ComposeFixture.php` ergänzen (Import `use LIT\MCP\Integrations\Divi\Compose\LinkTargets;`) und `placeholders()` ersetzen:
```php
/** 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:
```php
'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:
```php
/** @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:
```php
'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`:
```php
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`):
```php
/** 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
<?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
<?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
<?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.
```php
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:
```php
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):
```php
/**
* {{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:
```php
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:
```php
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**
```bash
git add src/Integrations/Divi/Compose/LinkTargets.php src/Integrations/Divi/Pages/WpLinkTargets.php src/Integrations/Divi/Compose/Placeholders.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Support/ComposeFixture.php tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php
```
Commit wie in den Global Constraints mit der Nachricht `feat: {{link:ID}} placeholder via Divi's post_link_url dynamic content`.
---
### Task 3: `lit-guide` mit allen Themen (E13b)
Praxistest-Wunsch 7: alle Themen in einem Aufruf. `topic` = `all` liefert zuerst `overview`, dann die übrigen Themen alphabetisch; Obergrenze 100 000 Bytes, nicht aufgenommene Themen werden am Ende genannt.
**Files:**
- Modify: `src/Integrations/Core/GuideAbility.php`
- Test: `tests/Unit/Integrations/Core/GuideAbilityTest.php`
**Interfaces:**
- Consumes: `GuideRepository::topics(): list<string>`, `GuideRepository::get(string $topic): ?string`.
- Produces: `lit/guide` mit `topic` = `all` → `{topic: "all", topics: list<string>, content: string}`; Konstante `GuideAbility::ALL_LIMIT = 100000`.
- [ ] **Step 1: Failing test schreiben**
In `GuideAbilityTest` ergänzen (`$this->ability` liest `tests/Fixtures/guides` mit `overview.md` und `divi-shortform.md`):
```php
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`:
```php
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()`:
```php
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();`:
```php
if ($topic === self::ALL) {
return ['topic' => self::ALL, 'topics' => $topics, 'content' => $this->all($topics)];
}
```
Neue Methode:
```php
/**
* 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**
```bash
git add src/Integrations/Core/GuideAbility.php tests/Unit/Integrations/Core/GuideAbilityTest.php
```
Commit wie in den Global Constraints mit der Nachricht `feat: lit-guide topic all returns every guide in one call`.
---
### Task 4: S19 – Designsystem filtern, globale Module in `node-get`/`outline`
Ruling S19 (aus 2a F2): `lit-divi-design-system` wird auf großen Websites mit hunderten Presets unhandlich → Filter `only` (colors/variables/presets) und `module` (nur dessen Presets), höchstens 200 Presets je Antwort. `lit-divi-node-get` und `lit-divi-outline` nennen bei globalen Modulen die Bibliotheks-ID und den Wurzeltyp (E2).
**Files:**
- Modify: `src/Integrations/Divi/Design/DiviDesignSystemAbility.php`
- Modify: `src/Integrations/Divi/BlockTree/NodeSummary.php` (`globalRef()`)
- Modify: `src/Integrations/Divi/BlockTree/Outline.php` (`global_id`)
- Modify: `src/Integrations/Divi/Pages/DiviNodeGetAbility.php` (`global`)
- Modify: `src/Integrations/Divi/Pages/DiviOutlineAbility.php` (Schema, Hinweis)
- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php`, `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`
**Interfaces:**
- Consumes: `DesignSystem::presets(): list<array{kind: string, module: string, id: string, name: string, default: bool}>`, Fixture `tests/Fixtures/divi/stage3/page--globale-instanzen.html` (g0 = Instanz 1058 `divi/section`, s0.r0.c0.g0 = Instanz 1059 `divi/text`).
- Produces: `NodeSummary::globalRef(Block $block): ?array{id: string, block_name: string}`; Outline-Zeilen mit `global_id: ?string`; `lit/divi-node-get` mit `global: null|{id, block_name, hint}`; `lit/divi-design-system` mit `only`, `module`.
- [ ] **Step 1: Failing tests schreiben**
`OutlineTest::testRowsAndTextOfAPage`: im erwarteten Array `'truncated' => false]` durch `'truncated' => false, 'global_id' => null]` ersetzen. Neuer Test in `OutlineTest`:
```php
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`:
```php
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()`):
```php
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`:
```php
/**
* 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):
```php
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:
```php
$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:
```php
$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:
```php
$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`:
```php
$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`:
```php
/** 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()`:
```php
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:
```php
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**
```bash
git add src/Integrations/Divi/Design/DiviDesignSystemAbility.php src/Integrations/Divi/BlockTree/NodeSummary.php src/Integrations/Divi/BlockTree/Outline.php src/Integrations/Divi/Pages/DiviNodeGetAbility.php src/Integrations/Divi/Pages/DiviOutlineAbility.php tests/Unit/Integrations/Divi
```
Commit wie in den Global Constraints mit der Nachricht `feat: design-system filters and global module ids in node-get/outline (S19)`.
---
### Task 5: `lit-page-render-summary` meldet globale Module (E13d, F8)
Divis `BlockParser` expandiert `divi/global-layout` nicht bei REST-POST-Anfragen (F8, `BlockParser.php:827-831`) – unsere MCP-Aufrufe sind REST-POST, der Auszug enthält globale Module also nicht. Statt einen Divi-internen Schalter zu umgehen, meldet der Auszug jede Instanz (Divis eigene Erkennung per Zeichenkettensuche, F10). Bibliotheks-IDs bleiben für das Tool `not_found` (`ContentSchema::EXCLUDED`).
**Files:**
- Modify: `src/Integrations/WordPress/Render/PageRenderSummaryAbility.php`
- Test: `tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php`
**Interfaces:**
- Consumes: `PageRenderer::render(WP_Post): string`, `RenderSummary::fromHtml()`.
- Produces: `notes` enthält je globaler Instanz `Globales Modul <ID> ist im Auszug nicht enthalten …` (höchstens 20).
- [ ] **Step 1: Failing test schreiben**
Im vorhandenen Test das Muster zum Stubben von `get_post`, `current_user_can`, `get_permalink` und des `PageRenderer`-Mocks übernehmen (siehe bestehende Tests der Datei):
```php
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`:
```php
/** At most this many global-module notes per summary. */
private const GLOBAL_NOTES = 20;
```
In `execute()` nach dem Passwort-Hinweis:
```php
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:
```php
/**
* 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**
```bash
git add src/Integrations/WordPress/Render/PageRenderSummaryAbility.php tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php
```
Commit wie in den Global Constraints mit der Nachricht `feat: render summary notes global modules it cannot show (F8)`.
---
### Task 6: Divi-Adapter mit Kompatibilitätsprüfung – `DiviRoles`, `DiviLibraryStore` (lesen), `DiviCompatibility`
Schnittstellen-Einordnung (Entscheidungen §5, E15): Divis Rollen-Editor (`et_pb_is_allowed`, Stufe c), der Bibliotheks-Post-Typ mit Termen/Meta (c/d) und `GlobalLayout::is_global_layout_template` (c) werden nur über Adapter angesprochen. Fehlt eine Funktion/Klasse nach einem Divi-Update, liefern die Adapter „nicht erlaubt“ bzw. den Rückfall, nie einen Fatal; `lit-site-status` zeigt den Befund. Die Builder-Routen `divi/v1/divi-library/*` werden nicht benutzt (Nonce, keine Rechte je Eintrag, F9/F11).
**Files:**
- Create: `src/Integrations/Divi/Library/DiviRoles.php`
- Create: `src/Integrations/Divi/Library/DiviLibraryStore.php`
- Create: `src/Integrations/Divi/DiviCompatibility.php`
- Modify: `src/Integrations/Divi/Pages/DynamicContentMap.php` (`REGISTRY` öffentlich)
- Modify: `src/Integrations/Core/EnvironmentProbe.php`
- Test: `tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`, `tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php`, `tests/Unit/Integrations/Divi/DiviCompatibilityTest.php`, `tests/Unit/Integrations/Core/EnvironmentProbeTest.php`
**Interfaces:**
- Consumes: `Errors::forbidden()`, `DiviModules::isAvailable()`, `WP_Term`-Stub.
- Produces:
- `class DiviRoles { __construct(?Closure $check = null); allows(string $capability): bool; refusal(string $capability): ?WP_Error }`
- `class DiviLibraryStore { const POST_TYPE = 'et_pb_layout'; const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout'; const LAYOUT_TYPES = ['layout','section','row','module']; const TAXONOMIES = ['scope','layout_type','module_width']; isGlobal(int $id): bool; layoutType(int $id): ?string; termSlugs(int $id, string $taxonomy): list<string>; termNames(int $id, string $taxonomy): list<string>; find(array $filters, int $limit, int $offset): list<WP_Post>; describe(WP_Post $post): array; missingTaxonomies(): list<string> }` (nicht final, mockbar; `create()`/`delete()` folgen in Task 12)
- `final class DiviCompatibility { __construct(?array $checks = null); problems(): list<string>; static defaults(): array<string, callable(): bool> }`
- `EnvironmentProbe::__construct(?DiviCompatibility $divi = null)`; jede Komponente hat zusätzlich `problems: list<string>`.
- [ ] **Step 1: Failing tests schreiben**
`tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`:
```php
<?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
<?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
<?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
<?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
<?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
<?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
<?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;`):
```php
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**
```bash
git add src/Integrations/Divi/Library/DiviRoles.php src/Integrations/Divi/Library/DiviLibraryStore.php src/Integrations/Divi/DiviCompatibility.php src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Core/EnvironmentProbe.php src/Integrations/Core/SiteStatusAbility.php tests/Unit/Integrations/Divi/Library tests/Unit/Integrations/Divi/DiviCompatibilityTest.php tests/Unit/Integrations/Core/EnvironmentProbeTest.php
```
Commit wie in den Global Constraints mit der Nachricht `feat: Divi adapters for roles and library with a live compatibility check`.
---
### Task 7: Bausteine globaler Module im BlockTree – `copyAs`, `GlobalInstance`, `LibraryContent`
Rein PHP, gegen die echten Builder-Fixtures. Befunde aus `tests/Fixtures/divi/stage3/`: (a) eine globale Instanz ist `divi/global-layout` mit `globalModule`, `blockName`, `localAttrs` (vollständiger Schnappschuss der Wurzel-Attribute, oberste Schlüssel sortiert – im Fixture `builderVersion, content, module` gegenüber `module, content, builderVersion` im Bibliothekseintrag), die Kinder mit ihren echten Blocknamen und `"globalParent":"<id>"` am Ende; eine Instanz ohne Kinder ist selbstschließend. (b) **Neu:** Bibliothekseinträge, die über Divis Dialog „Zur Bibliothek hinzufügen“ gespeichert wurden, haben zwischen den Blöcken `\r\n` (Formular-Übertragung des Browsers), Seiten `\n`; der Builder schreibt Instanzen mit `\n`. Deshalb normalisiert `LibraryContent::load()` Zeilenumbrüche im reinen Leerraum zwischen Blöcken. Mit beidem entsteht die Instanz byte-genau wie im Fixture (vorab per Skript geprüft).
**Files:**
- Modify: `src/Integrations/Divi/BlockTree/Block.php` (`copyAs()`)
- Modify: `src/Integrations/Divi/BlockTree/Container.php` (`replaceChild()`, `unifyLineBreaks()`)
- Create: `src/Integrations/Divi/BlockTree/GlobalInstance.php`
- Create: `src/Integrations/Divi/BlockTree/LibraryContent.php`
- Modify: `src/Integrations/Divi/Compose/TreeEditor.php` (Wurzeltyp globaler Instanzen)
- Modify: `tests/Fixtures/divi/stage3/README.md` (Befund 9)
- Test: `tests/Unit/Integrations/Divi/BlockTree/BlockTest.php`, `…/ContainerTest.php`, `…/GlobalInstanceTest.php`, `…/LibraryContentTest.php`, `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`
**Interfaces:**
- Consumes: `Parser::parse()`, `Serializer::block()`, `NodeIndex::build()`, `DiviFixtures::markup()`.
- Produces:
- `Block::copyAs(string $name, array $attrs): Block`
- `Container::replaceChild(Block $old, Block $new): void`, `Container::unifyLineBreaks(): void`
- `GlobalInstance::BLOCK = 'divi/global-layout'`, `GlobalInstance::effectiveName(Block): string`, `GlobalInstance::of(Block $root, int $libraryId): Block`, `GlobalInstance::deglobalize(Block $block): Block`
- `LibraryContent::load(Parser $parser, string $content): Document` (wirft `MalformedMarkup`), `LibraryContent::wrap(Block $node): string`, `LibraryContent::roots(Document $document): list<Block>`, `LibraryContent::containsGlobal(Block $block): bool`, `LibraryContent::shapeViolation(list<Block> $before, list<Block> $after, ?string $layoutType, bool $global): ?string`
- [ ] **Step 1: Failing tests schreiben**
In `BlockTest`:
```php
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`:
```php
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
<?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
<?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`:
```php
/** 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()`:
```php
/**
* 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()`:
```php
/** 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
<?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
<?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()`:
```php
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
```
und in `move()`:
```php
$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:
```markdown
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**
```bash
git add src/Integrations/Divi/BlockTree src/Integrations/Divi/Compose/TreeEditor.php tests/Unit/Integrations/Divi/BlockTree tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Fixtures/divi/stage3/README.md
```
Commit wie in den Global Constraints mit der Nachricht `feat: global instance in the builder's format, deglobalize, library content rules`.
---
### Task 8: Bibliothekseinträge mit den Divi-Werkzeugen lesen – `LibraryAccess`, `DiviPageLoader`
E1/E3: `lit-divi-outline` und `lit-divi-node-get` nehmen die ID eines Bibliothekseintrags an. `LibraryAccess::item()` prüft zuerst, ob die ID ein `et_pb_layout` ist; sonst geht es unverändert über `PostGuard` weiter (Seiten bleiben wie bisher, `ContentSchema::EXCLUDED` bleibt). Schreiben folgt in Task 9 – bis dahin laufen Schreibzugriffe auf Bibliotheks-IDs weiter über `PostGuard` und enden in `not_found`.
**Files:**
- Create: `src/Integrations/Divi/Library/LibraryAccess.php`
- Modify: `src/Integrations/Divi/Pages/DiviPage.php`
- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php`
- Modify: `src/Integrations/Divi/DiviIntegration.php`
- Create: `tests/Support/LibraryFixtures.php`
- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`
**Interfaces:**
- Consumes: `DiviLibraryStore`, `DiviRoles` (Task 6), `PostGuard::id()`, `Errors`.
- Produces:
- `class LibraryAccess { __construct(DiviLibraryStore $store, DiviRoles $roles, ?Closure $writable = null); store(): DiviLibraryStore; roles(): DiviRoles; item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null; canList(): ?WP_Error; canRead(WP_Post $item): ?WP_Error; isGlobal(WP_Post $item): bool }` (nicht final; `canEdit`, `canSave`, `canInsert` folgen in Task 9/12/13)
- `DiviPage::__construct(WP_Post $post, Document $document, NodeIndex $index, bool $libraryItem = false)`, `DiviPage::isLibraryItem(): bool`; `editUrl()` liefert für Bibliothekseinträge `get_edit_post_link($id, 'raw')`.
- `DiviPageLoader::__construct(ContentSchema $schema, Parser $parser = new Parser(), ?LibraryAccess $library = null)`, `DiviPageLoader::library(): ?LibraryAccess`.
- `LibraryFixtures` (Tests): `ITEMS`, `item(int $id, array $fields = []): WP_Post`, `page(string $content, array $fields = []): WP_Post`, `given(array $posts, array $denied = []): void`, `store(): DiviLibraryStore`, `roles(array $deniedRoles = []): DiviRoles`, `access(array $deniedRoles = [], bool $writable = true, ?DiviLibraryStore $store = null): LibraryAccess`, `loader(?LibraryAccess $access = null): DiviPageLoader`.
- [ ] **Step 1: Test-Helfer und failing tests schreiben**
`tests/Support/LibraryFixtures.php`:
```php
<?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
<?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`:
```php
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`:
```php
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
<?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()`:
```php
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;`):
```php
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()`:
```php
return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post);
```
`DiviIntegration.php` – Bibliothekszugang als Feld (Imports `DiviLibraryStore`, `DiviRoles`, `LibraryAccess`, `Parser`):
```php
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()`:
```php
$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**
```bash
git add src/Integrations/Divi/Library/LibraryAccess.php src/Integrations/Divi/Pages/DiviPage.php src/Integrations/Divi/Pages/DiviPageLoader.php src/Integrations/Divi/DiviIntegration.php tests/Support/LibraryFixtures.php tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php
```
Commit wie in den Global Constraints mit der Nachricht `feat: Divi read tools accept library entries (library access, E1/E3)`.
---
### Task 9: Bibliothekseinträge schreiben – Formregeln, globale Einträge mit Probelauf, `confirm` und Sicherung
E2 und Antwort F2: Die Divi-Schreibwerkzeuge (`lit-divi-insert`, `-update`, `-move`, `-delete`, `lit/divi-duplicate-node`) ändern Bibliothekseinträge wie Seiten. Zusätzlich (`GlobalEdits`, aufgerufen aus `DiviWriter::commit()` nach der vollständig validierten Änderung): Der Eintrag behält seine Form (`LibraryContent::shapeViolation`); ist er global, wird ohne `confirm: true` nichts gespeichert und die Antwort nennt die betroffenen Seiten (Probelauf, `GlobalUsages` sucht `"globalModule":"<id>"` wie Divi); mit `confirm: true` sichert `DiviSaver` wie immer eine Revision im kses-Fenster, bei abgeschalteten Revisionen legt `GlobalEdits` vorher eine Sicherung an (`PostCloner::backup`), danach leert `DiviSaver` seitenweit den CSS- und Theme-Builder-Cache (`SaveScope::GlobalItem`). Bibliothekseinträge bekommen keine Divi-Flags (Fixture-Befund 4). Das Gruppen-Gate: ist „Divi-Bibliothek“ auf „nur lesen“ oder deaktiviert, sind Bibliothekseinträge für alle Divi-Werkzeuge nur lesbar.
**Files:**
- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`item()` für Schreiben, `canEdit()`, `writableGate()`, `GROUP`)
- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php` (Schreiben, `writable()`)
- Create: `src/Integrations/Divi/Pages/SaveScope.php`
- Modify: `src/Integrations/Divi/Pages/DiviSaver.php`
- Create: `src/Integrations/Divi/Library/GlobalUsages.php`
- Create: `src/Integrations/Divi/Library/GlobalEdits.php`
- Modify: `src/Integrations/Divi/Pages/DiviWriter.php` (`save()`-Scope, `commit()`, `notice()`)
- Modify: `src/Integrations/Divi/Pages/DiviWriteAbility.php` (`precheck()`, `commit`, `notice`)
- Modify: `src/Integrations/Divi/Pages/DiviSchema.php` (`confirm`)
- Modify: `src/Abilities/ExposurePolicy.php` (`groupWritable()`)
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php`
- Create: `tests/Stubs/EtCorePageResource.php`; Modify: `tests/bootstrap.php`, `tests/Support/DiviWriterFixture.php`
- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `…/Library/GlobalUsagesTest.php`, `…/Library/GlobalEditsTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `…/Pages/DiviSaverTest.php`, `tests/Unit/Abilities/ExposurePolicyTest.php`
**Interfaces:**
- Consumes: `LibraryContent::load/roots/shapeViolation` (Task 7), `LibraryAccess`, `LibraryFixtures` (Task 8), `PostCloner::backup(WP_Post): int`, `Errors::confirmRequired/clientMessage/codeOf/unsupported/invalidInput`.
- Produces:
- `LibraryAccess::GROUP = 'divi-library'`, `LibraryAccess::canEdit(WP_Post $item): ?WP_Error`, `LibraryAccess::writableGate(): ?WP_Error`; `item($id, true)` prüft `canEdit()`.
- `enum SaveScope { Page, LibraryItem, GlobalItem; setsFlags(): bool; clearsSiteCss(): bool }`
- `DiviSaver::save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array`, `DiviSaver::saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): list<string>`
- `class GlobalUsages { const LIMIT = 50; __construct(?\wpdb $db = null); of(int $libraryId): array{items: list<array{post_id: int, title: string, status: string, type: string}>, count: int} }` (nicht final)
- `final class GlobalEdits { __construct(LibraryAccess $access, GlobalUsages $usages, ?Closure $backup = null, Parser $parser = new Parser()); prepare(DiviPage $page, array $input): array{scope: SaveScope, details: array<string, mixed>}|WP_Error; notice(DiviPage $page, WP_Error $error): WP_Error }`
- `DiviWriter::__construct(…, DiviComposition $composition, ?GlobalEdits $globals = null)`, `DiviWriter::save(…, SaveScope $scope = SaveScope::Page)`, `DiviWriter::commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error`, `DiviWriter::notice(DiviPage $page, WP_Error $error): WP_Error`
- `DiviWriteAbility::precheck(array $input): ?WP_Error` (protected, Standard `null`)
- `ExposurePolicy::groupWritable(string $group): bool`
- `DiviIntegration::__construct(ContentSchema $schema, ChangeLog $log, ?Closure $libraryWritable = null)`
- `DiviWriterFixture::writer(array &$saved, array &$failures = [], bool $unfiltered = false, ?DiviPageLoader $loader = null, ?GlobalEdits $globals = null)`
- [ ] **Step 1: Stub und failing tests schreiben**
`tests/Stubs/EtCorePageResource.php`:
```php
<?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`:
```php
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`:
```php
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`:
```php
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`:
```php
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
<?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
<?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
<?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:
```php
$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);`:
```php
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()`:
```php
/** Group (integration id) of the library tools in the Tools tab. */
public const GROUP = 'divi-library';
```
In `item()` die letzte Zeile ersetzen:
```php
return ($forWrite ? $this->canEdit($post) : $this->canRead($post)) ?? $post;
```
Neue Methoden:
```php
/**
* 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:
```php
// 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:
```php
if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {
```
`src/Integrations/Divi/Library/GlobalUsages.php`:
```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
<?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:
```php
/**
* 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:
```php
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()`:
```php
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()`:
```php
/** 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`):
```php
/**
* @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()`:
```php
$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:
```php
$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**
```bash
git add src/Integrations/Divi/Library src/Integrations/Divi/Pages/SaveScope.php src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviPageLoader.php src/Integrations/Divi/Pages/DiviWriter.php src/Integrations/Divi/Pages/DiviWriteAbility.php src/Integrations/Divi/Pages/DiviSchema.php src/Abilities/ExposurePolicy.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php tests/Stubs/EtCorePageResource.php tests/bootstrap.php tests/Support/DiviWriterFixture.php tests/Unit
```
Commit wie in den Global Constraints mit der Nachricht `feat: Divi tools write library entries; global ones with dry run, confirm and backup (E2, F2)`.
---
### Task 10: Gruppe „Divi-Bibliothek“ und `lit/library-list` (mit `id` als Detail)
E9: `lit/library-list` ersetzt `lit/library-get` (Spec §5.7 wird in Task 16 angepasst). Ohne `id` eine Seite der Liste mit Filtern und `cursor`, mit `id` ein Eintrag mit Knoten-Übersicht, `tree_rev`, `rev` und – bei globalen Einträgen – den Seiten, die ihn zeigen. Die drei `lit/library-*`-Tools bilden eine eigene Integration (= Gruppe im Tools-Tab).
**Files:**
- Create: `src/Integrations/Divi/Library/LibraryIntegration.php`
- Create: `src/Integrations/Divi/Library/LibraryListAbility.php`
- Modify: `src/Integrations/Divi/DiviIntegration.php` (`library()`, `loader()`)
- Modify: `src/Plugin.php` (Registrierung)
- Modify: `docs/guides/overview.md` (indirekte Tools, Gruppenliste)
- Test: `tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
**Interfaces:**
- Consumes: `LibraryAccess::canList()`, `DiviPageLoader::load()`, `DiviLibraryStore::find/describe`, `GlobalUsages::of()`, `Outline`, `Cursor`, `Rev`.
- Produces:
- `final class LibraryIntegration implements Integration { __construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design); id() = LibraryAccess::GROUP; label() = 'Divi-Bibliothek' }` (Task 12/13 erweitern den Konstruktor)
- `final class LibraryListAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design, GlobalUsages $usages) }` – Name `lit/library-list`, indirekt, nur lesend; Ausgabe `{items, next_cursor}` bzw. mit `id` zusätzlich `outline`, `tree_rev`, `rev`, `edit_url`, bei globalen `affected`, `affected_count`.
- `DiviIntegration::library(): LibraryIntegration`
- [ ] **Step 1: Failing tests schreiben**
`tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php`:
```php
<?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`:
```php
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
<?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
<?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`):
```php
/** 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:
```php
$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**
```bash
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
<?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:
```php
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()`:
```php
/**
* 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
<?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`):
```php
/** 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**
```bash
git add src/Integrations/Divi/Compose/StoredNodeGuard.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
```
Commit wie in den Global Constraints mit der Nachricht `feat: StoredNodeGuard keeps S7/S26 for nodes moving between posts (E6)`.
---
### Task 12: `lit/library-save` – Knoten als Bibliothekseintrag (E4)
E4: Das Plugin legt Einträge selbst an – kein `et_pb_submit_layout` (im REST-Kontext nicht geladen, speichert mit kses, F2), keine Builder-Route (Nonce, Preset-Import, F3). `wp_insert_post` mit leerem Inhalt und Status `publish` (Divi kennt keine Bibliotheksentwürfe), Terme und Meta wie `et_pb_submit_layout` (verglichen mit den echten Builder-Einträgen der Fixtures; `_et_pb_row_layout` wird nicht geschrieben – Divi selbst speichert dort `"[object Object]"`, Befund 5), danach der Inhalt über `DiviSaver::saveNew` (`SaveScope::LibraryItem`, ohne Divi-Flags, Befund 4) als `divi/placeholder`-Rahmen um die unveränderten Bytes des Knotens. Alles oder nichts: jeder Fehler löscht den neuen Eintrag endgültig. Der Knoten auf der Seite bleibt unverändert (das Verknüpfen ist `lit/library-make-global`, Etappe 3b).
**Files:**
- Create: `src/Integrations/Divi/Library/LibraryItemSpec.php`
- Modify: `src/Integrations/Divi/Library/DiviLibraryStore.php` (`create()`, `delete()`)
- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`canSave()`)
- Create: `src/Integrations/Divi/Library/LibrarySaveAbility.php`
- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php`, `src/Integrations/Divi/DiviIntegration.php`
- Modify: `docs/guides/overview.md` (indirekte Tools)
- Test: `tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php`, `…/DiviLibraryStoreTest.php`, `…/LibraryAccessTest.php`, `…/LibrarySaveAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
**Interfaces:**
- Consumes: `LibraryContent::wrap/containsGlobal`, `NodeSummary::globalRef`, `ModuleCatalog::has/category`, `DiviSaver::saveNew(…, SaveScope::LibraryItem)`, `StoredNodeGuard` über `DiviWriter::composition($page)->storedGuard()`, `PostSaveFollowUp`, `Errors::writeFailed/partialWrite/revMismatch`.
- Produces:
- `final class LibraryItemSpec { __construct(string $title, string $layoutType, bool $global, string $moduleWidth, ?string $moduleType, string $builtFor, array $categories = [], array $tags = []); static forNode(Block $node, ModuleCatalog $catalog, string $title, bool $global, string $builtFor): self|string; withTerms(array $categories, array $tags): self; terms(): array<string, list<int|string>>; meta(): array<string, string> }`
- `DiviLibraryStore::create(LibraryItemSpec $spec): int` (wirft `RuntimeException`, löscht bei Teilfehlern), `DiviLibraryStore::delete(int $id): bool`
- `LibraryAccess::canSave(): ?WP_Error`
- `final class LibrarySaveAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules) }` – `lit/library-save`, indirekt, schreibend (nicht idempotent); Ergebnis `ToolResult` + `library_id`, `tree_rev`.
- `LibraryIntegration::__construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules)`
- [ ] **Step 1: Failing tests schreiben**
`tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php`:
```php
<?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`:
```php
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`:
```php
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
<?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
<?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`):
```php
/**
* 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()`:
```php
/**
* 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
<?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`):
```php
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:
```php
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**
```bash
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`:
```php
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
<?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()`:
```php
/** 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
<?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:
```php
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):
```markdown
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**
```bash
git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi
```
Commit wie in den Global Constraints mit der Nachricht `feat: lit-library-insert as copy or global instance in the builder's format (E5)`.
---
### Task 14: `lit/divi-node-copy` – Knoten zwischen Seiten kopieren (E6)
Kopiert einen gespeicherten Knoten byte-gleich an eine Position eines anderen Posts (Seite oder Bibliothekseintrag). Quelle: Leserechte wie `lit-divi-outline` (auch Bibliothekseinträge) und die `source_tree_rev` (Pfad-IDs gelten nur für ihren Stand); Ziel: wie `lit-divi-insert` (`tree_rev`, StructureRules, alles oder nichts, bei Bibliothekszielen `GlobalEdits`). Globale Instanzen bleiben verknüpft. Die Kopie läuft durch `StoredNodeGuard` des Ziels. Gruppe „Divi“, indirekt.
**Files:**
- Create: `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php`
- Modify: `src/Integrations/Divi/DiviIntegration.php`
- Modify: `docs/guides/overview.md` (indirekte Tools)
- Test: `tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
**Interfaces:**
- Consumes: `DiviWriteAbility` (`precheck`, `change`, `commit`), `DiviPageLoader::load()`, `DiviComposition::storedGuard/editor`, `GlobalEdits`, `LibraryFixtures`.
- Produces: `final class DiviNodeCopyAbility extends DiviWriteAbility { __construct(DiviWriter $writer, DiviPageLoader $loader) }` – `lit/divi-node-copy`, indirekt, Eingabe `post_id`, `tree_rev`, `source_post_id`, `source_id`, `source_tree_rev`, Position.
- [ ] **Step 1: Failing tests schreiben**
`tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php`:
```php
<?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
<?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**
```bash
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`:
```php
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
<?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:
```php
/** @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
<?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`):
```php
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**
```bash
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`:
```php
public function testAddsTheLibraryRuleOnlyWhenTheLibraryGroupIsActive(): void
{
$library = Mockery::mock(Integration::class);
$library->allows('id')->andReturn('divi-library');
$library->allows('isAvailable')->andReturn(true);
$library->allows('abilities')->andReturn([$this->ability('lit/library-insert', true)]);
$core = $this->coreWith($this->ability('lit/guide', true));
$with = (new InstructionsBuilder(new IntegrationRegistry($core, $library)))->build();
$without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();
self::assertStringContainsString('Divi-Bibliothek: globale Module', $with);
self::assertStringContainsString('confirm: true', $with);
self::assertStringNotContainsString('Divi-Bibliothek:', $without);
self::assertLessThan(2000, strlen($with));
}
```
- [ ] **Step 2: Test laufen lassen – schlägt fehl**
Run: `vendor/bin/phpunit --filter InstructionsBuilderTest`
Expected: FAIL
- [ ] **Step 3: Guide `divi-library` anlegen**
`docs/guides/divi-library.md`:
```markdown
# Divi-Bibliothek und globale Module
Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederverwendung.
- **Normale Einträge** werden beim Einfügen kopiert; die Kopie ist danach unabhängig.
- **Globale Einträge** werden als Instanz eingefügt. Divi zeigt überall den aktuellen Inhalt des
Bibliothekseintrags: eine Änderung dort wirkt sofort auf allen Seiten mit diesem Modul – auch auf
veröffentlichten.
## Werkzeuge
- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type`
(`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search`, `category`, weiterblättern
mit `cursor`. Mit `id`: ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und bei globalen
Einträgen die Seiten, die ihn zeigen (`affected`, `affected_count`).
- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen
Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie.
Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev`
der Zielseite ist Pflicht.
- `lit/library-save`: einen Abschnitt, eine Zeile oder ein Modul als neuen Eintrag speichern (`title`,
`scope: "normal"` oder `"global"`, vorhandene `categories`/`tags`). Der Knoten auf der Seite bleibt eine
eigenständige Kopie – um ihn zu verknüpfen, ihn durch eine Instanz ersetzen (siehe Ablauf unten).
- `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren
(`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle; Ziel mit `post_id`,
`tree_rev`, Position). Die Kopie ist byte-gleich; globale Module bleiben verknüpft.
- Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`,
`lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete` und `lit/divi-duplicate-node`
mit der ID des Eintrags als `post_id`.
## Globale Module erkennen und ändern
1. In `lit-divi-outline` einer Seite erscheinen Instanzen als z. B. `g0 section "Kontakt" [global 1058]
(nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite
lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen.
2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden.
3. Ohne `confirm` ist das ein Probelauf: Antwort `confirm_required` mit `affected` (betroffene Seiten, Titel
nicht lesbarer Seiten als „(nicht lesbar)“) und `affected_count`; gespeichert wird nichts.
4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit
`confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`).
5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine
Sicherungskopie (`backup_id`). Divis CSS-Cache wird für alle Seiten geleert.
Regeln für Bibliothekseinträge: ein Abschnitts-, Zeilen- oder Modul-Eintrag behält genau einen Knoten dieses
Typs (die Instanzen auf den Seiten erwarten ihn); ein Layout-Eintrag enthält nur Abschnitte; ein globaler
Eintrag enthält keine globalen Module.
`lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie nicht) und nennt
sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen.
## Rechte
- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen
Eintrag bearbeiten dürfen; neue Einträge anlegen = Beiträge veröffentlichen dürfen (Bibliothekseinträge
sind immer aktiv).
- Divi → Rollen-Editor: „Divi-Bibliothek“, „In der Bibliothek speichern“, „Aus der Bibliothek hinzufügen“,
„Globale Bibliothekselemente bearbeiten“. Fehlt eines, antwortet das Werkzeug mit `forbidden` und diesem
Hinweis.
- Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf
Bibliothekseinträge, auch über die Divi-Werkzeuge; `lit-library-insert` ist außerdem gesperrt, wenn
`lit-divi-insert` gesperrt ist.
- Ohne das Recht `unfiltered_html` lassen sich Knoten mit eigenem CSS, eigenen HTML-Attributen, dem
Code-Modul oder dynamischem Inhalt mit HTML davor/danach nicht in einen anderen Eintrag übertragen
(Kopieren, Einfügen, Speichern) – „nur im Divi-Builder“.
## Ablauf: globale Module vorschlagen
1. Die Seiten lesen (`lit-divi-outline`) und wiederkehrende Abschnitte suchen (gleiche Admin-Labels oder
Texte, z. B. Kontakt, Öffnungszeiten, Call-to-Action).
2. Dem Nutzer vorschlagen, welche davon globale Module werden sollen, und erklären: eine Änderung wirkt
danach auf allen Seiten.
3. Nach Zustimmung den Abschnitt einer Seite mit `lit/library-save` und `scope: "global"` speichern.
4. Auf jeder Seite (auch der ersten) die Instanz mit `lit-library-insert` direkt vor dem bisherigen Abschnitt
einfügen und den bisherigen Abschnitt nach Rückfrage mit `lit-divi-delete` entfernen.
5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen.
Unterscheiden sich die Abschnitte in Details, vorher mit dem Nutzer klären, welche Fassung gilt.
```
- [ ] **Step 4: Übrige Guides anpassen**
`docs/guides/overview.md`:
- In „Weitere Themen:“ `divi-library` ergänzen:
```markdown
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):
```markdown
Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf.
```
- Nach „Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.“ ergänzen: „Sie arbeiten
auch auf Einträgen der Divi-Bibliothek (ID als `post_id`); globale Module: `lit-guide` topic `divi-library`.“
`docs/guides/divi-placeholders.md` – Tabellenzeile nach `{{field:<meta_key>}}` einfügen:
```markdown
| `{{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:
```markdown
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:
```markdown
- 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:
```markdown
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:
```markdown
## Bibliothek und globale Module
Vorhandene Abschnitte aus der Divi-Bibliothek zuerst suchen (`lit/library-list`) und mit `lit-library-insert`
einfügen, statt sie neu zu bauen. Einen gelungenen Abschnitt für später speichern: `lit/library-save`.
Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. 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:
```php
$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:
```text
* 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:
```text
* 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:
```text
= 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:
```text
= 0.4.0 =
* Divi-Bibliothek: Einträge auflisten und lesen (lit/library-list), Knoten als normalen oder globalen Eintrag
speichern (lit/library-save), als Kopie oder globale Instanz einfügen (lit-library-insert, direkt).
* Globale Module: Bibliothekseinträge mit den Divi-Werkzeugen bearbeiten; bei globalen Einträgen Probelauf
mit betroffenen Seiten, confirm, Revision bzw. Sicherung und seitenweite Cache-Leerung.
* Knoten zwischen Seiten und Bibliothekseinträgen kopieren (lit/divi-node-copy).
* Interne Links mit {{link:<ID>}}; lit/divi-dynamic-sources listet Divis dynamische Quellen und geeignete Felder.
* lit-guide mit dem Thema „all“; lit-divi-design-system mit Filtern (only, module); lit-divi-node-get und
lit-divi-outline nennen die Bibliotheks-ID globaler Module; lit-page-render-summary weist auf globale Module
hin; lit-site-status prüft die genutzten Divi-Funktionen.
* Tools-Gruppe „Divi-Bibliothek“; „nur lesen“ sperrt auch Schreibzugriffe der Divi-Werkzeuge auf die Bibliothek.
```
- [ ] **Step 7: Spec und Entscheidungen nachtragen**
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`:
- §4.3, zweiter Punkt, anfügen: „**Stand nach Etappe 3a:** 22 direkte Tools (dazu `lit/library-insert`); indirekt neu `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Neue Tool-Gruppe „Divi-Bibliothek“.“
- §5.2, letzter Punkt: „(Bearbeitung ab Etappe 3)“ → „(bearbeitet werden globale Module seit Etappe 3a im Bibliothekseintrag, E2)“.
- §5.4, Platzhalter-Tabelle nach `{{field:<meta_key>}}`: „| `{{link:<ID>}}` | interner Link: Dynamic Content `post_link_url_<Typ>` mit `post_id` (nur Link-Felder; Etappe 3a, E13a) |“
- §5.7: Zeile `lit/divi-dynamic-sources` → „… – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4)“; Zeile `lit/library-list`, `lit/library-get` → „| `lit/library-list` | lesen | Einträge nach Typ (`layout_type`) und Scope, mit `cursor`; mit `id` Detail inkl. Knoten-Übersicht und betroffener Seiten (ersetzt `lit/library-get`, E9) |“; Zeile `lit/library-insert` → „… als Kopie oder globale Instanz im Format des Visual Builders (`localAttrs`-Schnappschuss, `globalParent`) einfügen; direkt“; Zeile `lit/divi-node-copy` → „Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, S7/S26-Prüfung E6)“.
- §9.2, Themenliste: „`divi-library`“ und „`media`“ ergänzen sowie „Thema `all` liefert alle Themen (Etappe 3a)“.
- §9.3: „(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.)“
In `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` am Ende anfügen:
```markdown
## 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**
```bash
git add docs/guides src/Guides/InstructionsBuilder.php readme.txt wp-lit-mcp.php src/Plugin.php docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md tests/Unit/Guides/InstructionsBuilderTest.php
```
Commit wie in den Global Constraints mit der Nachricht `docs: guides, readme and spec for the Divi Library (v0.4.0)`.
---
### Task 17: Ende-zu-Ende-Test `divi-library-smoke.sh` und Abnahme-Notiz
Prüft 3a über den echten OAuth-Endpunkt in DDEV (Divi 5.2.1): Exposition, Speichern normal/global mit Divis Termen, Einfügen als Instanz und Kopie, globaler Probelauf/`confirm`, Knoten kopieren, `{{link:ID}}`, die Übernahmen, ein Redakteur ohne `unfiltered_html` und die Gruppe „nur lesen“. Räumt alles wieder auf. Danach die manuelle Abnahme-Notiz für die Punkte, die den Visual Builder brauchen.
**Files:**
- Create: `tests/Integration/divi-library-smoke.sh`
- Create: `docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md`
**Interfaces:**
- Consumes: `tests/Integration/lib.sh` (`connect`, `tool`, `ability`, `rpc`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`), Fixture `tests/Fixtures/divi/converted-landing.html`.
- Produces: Skript, das mit `ALL CHECKS PASSED` endet.
- [ ] **Step 1: Skript schreiben**
`tests/Integration/divi-library-smoke.sh`:
```bash
#!/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`:
```markdown
# 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**
```bash
chmod +x tests/Integration/divi-library-smoke.sh
git add tests/Integration/divi-library-smoke.sh docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md
```
Commit wie in den Global Constraints mit der Nachricht `test: end-to-end smoke and acceptance note for the Divi Library (3a)`.
---
## Offene Punkte (nicht Teil dieses Plans)
- `lit/revision-restore` erreicht Bibliothekseinträge nicht (`ContentSchema::EXCLUDED`); Zurücksetzen über die WordPress-Oberfläche. Kandidat für 3b.
- `lit/divi-replace-text` durchsucht keine Bibliothekseinträge (je Inhaltstyp); globale Module werden weiter als `skipped_nodes` gemeldet.
- MCP-Prompts (Spec §9.3) gibt es im Plugin noch nicht; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.
- Ob der Visual Builder Instanzen mit tief verschachtelten `localAttrs` anders sortiert als nur auf oberster Ebene, zeigt erst ein reicheres Builder-Fixture; die Abnahme (Abschnitt 2) prüft das Verhalten im Builder.
- Bearbeiten nicht synchronisierter Attribute einer einzelnen Instanz (`localAttrsMap`) bleibt Builder-Sache (E2).
- `lit/library-make-global`, `lit/library-find-similar`, Theme Builder: Etappe 3b.