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