From cfc81ddbb2903c1d749bf91792f4831106e480b1 Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 00:10:27 +0200 Subject: [PATCH] docs: Etappe 3a implementation plan (Bibliothek) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../plans/2026-09-27-etappe-3a-bibliothek.md | 7316 +++++++++++++++++ 1 file changed, 7316 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md diff --git a/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md b/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md new file mode 100644 index 0000000..63e2b2e --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-etappe-3a-bibliothek.md @@ -0,0 +1,7316 @@ +# 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-`, indirekte `lit/` (ToolNamingTest). In PHP-Texten nie `topic ""` für ein Thema ohne `docs/guides/.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/.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 "" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " -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 `` 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 + + + + + + + + + tests/Unit + + + +``` + +- [ ] **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) " -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7" +``` + +--- + +### Task 2: Interner Link `{{link:ID}}` (E13a) + +Divis natives Dynamic Content `post_link_url_` 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_ 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 + 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 +}} 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 +}} 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:}}` ergänzen): + +```php + /** + * {{link:}} → Divi's own internal link post_link_url_ 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`, `GuideRepository::get(string $topic): ?string`. +- Produces: `lit/guide` mit `topic` = `all` → `{topic: "all", topics: list, 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 $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`, 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/`.) + +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 ] im Bibliothekseintrag ' + . 'bearbeiten: lit-divi-outline mit post_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": "".', + '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 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('

Text

'); + $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 + */ + 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; termNames(int $id, string $taxonomy): list; find(array $filters, int $limit, int $offset): list; describe(WP_Post $post): array; missingTaxonomies(): list }` (nicht final, mockbar; `create()`/`delete()` folgen in Task 12) + - `final class DiviCompatibility { __construct(?array $checks = null); problems(): list; static defaults(): array }` + - `EnvironmentProbe::__construct(?DiviCompatibility $divi = null)`; jede Komponente hat zusätzlich `problems: list`. + +- [ ] **Step 1: Failing tests schreiben** + +`tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`: + +```php + $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 + [ + '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 + 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 +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 + '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 +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 */ + 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 */ + 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 + */ + 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, tags: list, 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 + */ + public function missingTaxonomies(): array + { + return array_values(array_filter( + self::TAXONOMIES, + static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy) + )); + } + + /** @return list */ + 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 +|null $checks Label => check; null = defaults(). */ + public function __construct(private ?array $checks = null) + { + } + + /** @return list 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 */ + 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}> */ + 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":""` 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`, `LibraryContent::containsGlobal(Block $block): bool`, `LibraryContent::shapeViolation(list $before, list $after, ?string $layoutType, bool $global): ?string` + +- [ ] **Step 1: Failing tests schreiben** + +In `BlockTest`: + +```php + public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void + { + $document = (new Parser())->parse("\n\n"); + $row = $document->children()[0]; + + $copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']); + + self::assertSame( + "\n\n" + . '', + Serializer::block($copy) + ); + self::assertTrue($copy->isOpaque()); + self::assertSame("\n\n", 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("AB"); + $new = Block::create('divi/button'); + + $document->replaceChild($document->children()[0], $new); + + self::assertSame('AB', Serializer::document($document)); + } + + public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void + { + $document = (new Parser())->parse( + "\r\n\r\n\r\nx\r\ny" + ); + + $document->unifyLineBreaks(); + + self::assertSame( + "\n\n\nx\r\ny", + Serializer::document($document) + ); + } +``` + +(Imports `Block`, `Parser`, `Serializer`, `InvalidArgumentException` je nach Datei ergänzen.) + +`tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php`: + +```php +parse(DiviFixtures::markup('stage3/page--globale-instanzen.html'))); + } + + /** @return array */ + public static function instances(): array + { + return [ + 'global section' => ['library-global--globaler-abschnitt.html', 1058, ''], + '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, '')) + ) + ); + } + + 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 $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 +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": "" 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 +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 '' . Serializer::block($node) . ''; + } + + /** @return list 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 $before + * @param list $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 + 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 $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 $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 $posts + * @param list $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 $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 $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 +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 +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":""` 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` + - `class GlobalUsages { const LIMIT = 50; __construct(?\wpdb $db = null); of(int $libraryId): array{items: list, 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}|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 + */ + 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 +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 + 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 $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 */ + 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(''; + + 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 */ + public static function refused(): array + { + $ref = static fn (string $name, string $settings): string => '"$variable({"type":"content",' + . '"value":{"name":"' . $name . '","settings":' . $settings . '}})$"'; + + return [ + 'custom css' => [ + '', + '"css"', + ], + 'html attributes' => [ + '', + 'module.decoration.attributes', + ], + 'code module' => ['', 'Code-Modul'], + 'script in before' => [ + '', + 'nicht schreiben darf', + ], + 'protected meta' => [ + '', + 'custom_meta__edit_lock', + ], + 'css in the local attributes of an instance' => [ + '', + '"css"', + ], + 'code module deep inside a 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 + */ + 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 + 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 $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 $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 $settings + * @return array 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>; meta(): array }` + - `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 +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 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 + */ + private array $specs = []; + + /** @var list */ + 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 $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 */ + 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( + '' + . '' + . '' + ); + 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 + $categories layout_category term IDs. + * @param list $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 $categories + * @param list $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> 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 */ + 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 + '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 $input + * @return array|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|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 + */ + 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 $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 */ + 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, ''), $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( + ['', '', "\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' => '', + ]); + 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 + '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|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 + */ + 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 */ + 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('' + . '', + ['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 + '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:}}`) 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}>`; `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 +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 $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:}}', $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>|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}>|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|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 +}} (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:}}) bzw. dem Namen für die Vollform, ' + . 'und die Felder des Typs, die sich als {{field:}} 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":"",' + . '"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:}}' : null; + } + + /** + * @return array{0: list>, 1: list>} 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:}}` einfügen: + +```markdown +| `{{link:}}` | 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:}}` 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 ]`: 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 ]) 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:}} nutzen Divis eigenen dynamischen Inhalt „ 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:}}; 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:}}`: „| `{{link:}}` | interner Link: Dynamic Content `post_link_url_` 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 = ""; $close = ""; +$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 "' "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:}}` 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.