Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
7317 lines
313 KiB
Markdown
7317 lines
313 KiB
Markdown
# Etappe 3a – Divi-Bibliothek: Auflisten, Speichern, Einfügen, Kopieren, globale Module bearbeiten – Implementation Plan
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||
|
||
**Goal:** Der KI-Assistent arbeitet mit der Divi-Bibliothek: Einträge auflisten und lesen (`lit/library-list`), Knoten als normalen oder globalen Eintrag speichern (`lit/library-save`), Einträge als Kopie oder als globale Instanz im Format des Visual Builders einfügen (`lit/library-insert`, direkt), Knoten zwischen Seiten kopieren (`lit/divi-node-copy`), Bibliothekseinträge – auch globale, mit Probelauf, `confirm` und Sicherung – mit den vorhandenen Divi-Werkzeugen bearbeiten, verfügbare Dynamic-Content-Quellen abfragen (`lit/divi-dynamic-sources`); dazu die Übernahmen aus Etappe 2 (`{{link:ID}}`, `lit-guide` mit allen Themen, S19) und der Hinweis auf globale Module in `lit/page-render-summary`.
|
||
|
||
**Architecture:** Reines PHP in `Divi/BlockTree/` bekommt die Bausteine für globale Module (`Block::copyAs()`, `GlobalInstance` – Instanz im Builder-Format und Entglobalisieren wie Divis `maybe_deglobalize_nested_global_modules` –, `LibraryContent` – Rahmen `divi/placeholder`, Wurzeln, Formregeln). Divi-Interna liegen hinter Adaptern mit Live-Kompatibilitätsprüfung (Entscheidung E15): `DiviRoles` (`et_pb_is_allowed`), `DiviLibraryStore` (Post-Typ `et_pb_layout`, Terme, Meta, `GlobalLayout::is_global_layout_template`), `DiviCompatibility` (Befund in `lit/site-status`). `LibraryAccess` bündelt die Rechte je Objekt (E3) und wird in `DiviPageLoader` eingehängt, damit alle Divi-Werkzeuge Bibliotheks-IDs annehmen (E1); `GlobalEdits` ergänzt `DiviWriter::commit()` um Formregeln, Probelauf mit betroffenen Seiten, `confirm` und Sicherung (E2, Antwort F2). Jeder neue Weg nach `post_content` läuft weiter über `Composer`/`TreeEditor`/`DiviSaver`; übernommene gespeicherte Knoten prüft `StoredNodeGuard` gegen die S7/S26-Sperren (E6). Die neuen Tools bilden die Gruppe „Divi-Bibliothek“ (`LibraryIntegration`), `lit/divi-node-copy` und `lit/divi-dynamic-sources` gehören zur Gruppe „Divi“.
|
||
|
||
**Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, `wp_insert_post`/`wp_update_post`, Revisionen, kses, Taxonomien), Divi 5.2.1 (`et_pb_layout`, `et_pb_is_allowed`, `GlobalLayout`, `DynamicContentOptions`, `ET_Core_PageResource`), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes.
|
||
|
||
**Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §4.3, §4.5, §5.1–5.7, §8.1–8.2, §9, §10, §12 Etappe 3) und die Entscheidungen `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` (Fakten F1–F32, Empfehlungen E1–E17, Schnittstellen-Einordnung Abschnitt 5; **die Antworten des Benutzers in „5. Antworten des Benutzers“ sind verbindlich**), dazu die Rulings `2026-09-26-etappe-{1a,1b,2a,2b}-rulings.md`, `2026-09-26-etappe-2-entscheidungen.md` (D1–D12, R2), Security-Audit und Praxistest 2b sowie die Fixtures `tests/Fixtures/divi/stage3/` mit `README.md` (Commit e7cc4ef).
|
||
|
||
**Voraussetzung:** Branch `etappe-3` mit Stand 30788f2 (Etappe 2b vollständig, 1597 Tests grün). Pods ist lokal deaktiviert; `lit/divi-dynamic-sources` wird für Pods über Unit-Tests abgesichert.
|
||
|
||
## Global Constraints
|
||
|
||
- **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte schreiben nur in die DDEV-Datenbank und räumen Seiten, Bibliothekseinträge, Benutzer, Rollen und die Optionen `lit_mcp_allowed_roles`/`lit_mcp_exposure` wieder auf.
|
||
- PHP ≥ 8.1 ohne 8.2+-Features; WordPress ≥ 6.9 (getestet 7.x); Divi 5.2.1. Namespace `LIT\MCP\` → `src/`, PSR-4, eine Klasse pro Datei, jede Datei mit `defined('ABSPATH') || exit;`, höchstens 120 Zeichen pro Zeile. Nach jedem Task: `composer test` und `composer lint` sauber.
|
||
- `src/Integrations/Divi/BlockTree/*` und `src/Integrations/Divi/Compose/*` bleiben reines PHP ohne WordPress-Aufrufe (Spec §4.4). WordPress/Divi nur in `Divi/Pages/`, `Divi/Library/`, `Divi/Design/`, `Divi/Schema/` und in `DiviCompatibility`.
|
||
- **Eine Engstelle nach `post_content` (D7, S39, E17):** Jeder neue Schreibweg in `post_content` – Seiten wie Bibliothekseinträge – geht über `Composer`/`AttrWriter`/`ShortformWriter`/`ValueCleaner` (Client-Werte) bzw. `TreeEditor` (Struktur) und wird ausschließlich von `DiviSaver::save()`/`saveNew()` gespeichert (kses-Fenster nur dort). Abilities rufen `setAttrs()`/`insertChild()` nie mit Client-Werten auf. Gespeicherte Knoten, die den Post wechseln (Seite → Seite, Bibliothek → Seite, Seite → Bibliothek), laufen durch `StoredNodeGuard` (S7/S26-Pfade, Code-Modul, `$variable`-Einstellungen) – für Benutzer ohne `unfiltered_html` wird abgelehnt statt bereinigt; alles andere bleibt byte-identisch. Keine Builder-Route (`divi/v1/…`), kein `et_pb_submit_layout`/`et_pb_create_layout`.
|
||
- **Rechte je Objekt (Spec §4.5, E3 – strenger als Divi, nie lockerer):** Bibliothek lesen/auflisten = `edit_posts` + Divi-Rolle `divi_library`; einen Eintrag lesen = zusätzlich `edit_post` auf den Eintrag; speichern/anlegen = + `publish_posts` des Typs `et_pb_layout` + `save_library`; bearbeiten = `edit_post` + `save_library`, bei globalen Einträgen + `edit_global_library`; einfügen = + `add_library`; Dynamic-Content-Feldliste nur mit `read_dynamic_content_custom_fields`. Divi-Rollen nur über `DiviRoles`. Ohne Bibliothekszugang bleiben Bibliotheks-IDs für alle Werkzeuge `not_found` wie bisher (`ContentSchema::EXCLUDED` bleibt unverändert).
|
||
- **Sperren und Alles-oder-nichts:** Jede Divi-Schreibung verlangt `tree_rev` (Ruling P16); Werkzeuge mit Quellknoten (`lit/library-save`, `lit/divi-node-copy`) verlangen zusätzlich die `tree_rev` der Quelle. Abweichung → `rev_mismatch`, nichts geschrieben. Erst vollständig validieren, dann einmal speichern; beim Anlegen eines Bibliothekseintrags wird der Eintrag bei jedem Fehler endgültig gelöscht (wie `lit/content-create`). Unberührte Bytes bleiben identisch (Spec §1.3 Nr. 3, Ausnahme S7).
|
||
- **Globale Einträge (Antwort F2, verbindlich):** Jede Änderung an einem globalen Bibliothekseintrag ist zuerst ein Probelauf (`confirm_required` mit `affected`/`affected_count`, nichts gespeichert); mit `confirm: true` wird vorher eine Revision des Eintrags angelegt – sind Revisionen abgeschaltet, eine Sicherung (`PostCloner::backup`, `backup_id`) –, danach seitenweit `remove_static_resources('all', 'all', true)` und `et_theme_builder_clear_wp_cache('all')` (Spec §5.6 Schritt 5).
|
||
- **Exposition:** Direkt ist nur `lit/library-insert` (22 direkte Tools, Spec §4.3). Indirekt: `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Gruppe „Divi-Bibliothek“ (`divi-library`) für die drei `lit/library-*`-Tools; „nur lesen“/deaktiviert dieser Gruppe sperrt auch Schreibzugriffe der Divi-Werkzeuge auf Bibliothekseinträge (`ExposurePolicy::groupWritable('divi-library')`). `lit/library-insert` folgt zusätzlich `lit/divi-insert` (Audit M2). Jeder Task mit neuem direktem Tool aktualisiert `docs/guides/overview.md` „Direkt sichtbar:“ im selben Task (Ruling Q2); Texte nennen direkte Tools `lit-<name>`, indirekte `lit/<name>` (ToolNamingTest). In PHP-Texten nie `topic "<x>"` für ein Thema ohne `docs/guides/<x>.md` schreiben (ToolNamingTest).
|
||
- **Sprache und Wortwahl:** Oberfläche, Fehlermeldungen, Hinweise, Guides, Readme und Abnahme auf Deutsch mit echten Umlauten (ä, ö, ü, ß). Neutral formulieren: „KI-Assistent“, „MCP-Client“, „Verbindung“ – keine Chatbot-Marken in UI, Guides, Readme oder Tool-Texten.
|
||
- Tests: Unit-Tests der Abilities über `LIT\MCP\Tests\Unit\TestCase` (Brain Monkey + Mockery); vorbelegte Stubs (`wp_json_encode`, `is_wp_error`, `absint`, `wp_slash`, `wp_unslash`, `sanitize_text_field`) nie mit `Functions\expect()` prüfen; `error_log()` per `ini_set('error_log', …)` umleiten. Reine BlockTree-/Compose-Tests erben von `PHPUnit\Framework\TestCase`. Integrationstests: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/<skript>.sh`.
|
||
- `composer test` braucht mehr als 128 MB (Task 1 behebt das); danach laufen `composer test` und `php vendor/bin/phpunit` mit jeder `memory_limit`-Vorgabe.
|
||
- Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "<Nachricht>" -m "Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"` (im Folgenden kurz „Commit wie in den Global Constraints“).
|
||
|
||
## Review Focus
|
||
|
||
1. **Redakteur ohne `unfiltered_html` überträgt gespeicherten Admin-Inhalt in einen anderen Post** (Abschnitt mit eigenem CSS, HTML-Attributen, Code-Modul oder Dynamic-Content-`before`/`after` mit HTML) über `lit/divi-node-copy`, `lit/library-insert` oder `lit/library-save`: Erwartet Ablehnung „nur im Divi-Builder“, nichts gespeichert (E6, Audit M1, R2-Befund 1) → Tests `StoredNodeGuardTest` (Task 11), `DiviNodeCopyAbilityTest::testEditorCannotCarryBuilderOnlyValuesToAnotherPage` (Task 14), `LibraryInsertAbilityTest::testEditorCannotInsertAnItemWithCustomCss` (Task 13), `LibrarySaveAbilityTest::testEditorCannotSaveACodeModule` (Task 12).
|
||
2. **Globaler Eintrag mit Instanzen auf veröffentlichten Seiten wird geändert – auch per `lit-divi-delete`, das ein eigenes `confirm` hat:** Ohne `confirm` nichts gespeichert, Antwort nennt die betroffenen Seiten; mit `confirm` Revision bzw. Sicherung und seitenweite Cache-Leerung → Tests `GlobalEditsTest::testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing`, `::testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm`, `::testWithoutRevisionsABackupIsMadeFirst` und `DiviSaverTest::testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage` (Task 9).
|
||
3. **Bibliothekseintrag verliert seine Form** (Wurzel gelöscht, zweite Wurzel, anderer Wurzeltyp, globales Modul in einem globalen Eintrag): Instanzen auf allen Seiten würden brechen → abgelehnt → Tests `LibraryContentTest::testShapeViolations` (Task 7) und `GlobalEditsTest::testRefusesToEmptyOrDoubleTheRoot` (Task 9).
|
||
4. **Rechte:** Autor ohne Divi-Rolle `divi_library`/`save_library`/`add_library`/`edit_global_library`, fremder Eintrag ohne `edit_post`, Mitarbeiter ohne `publish_posts`, Gruppe „Divi-Bibliothek“ auf „nur lesen“ – alle erhalten `forbidden`, nichts wird geschrieben; ohne eingehängten Bibliothekszugang bleiben Bibliotheks-IDs `not_found` → Tests `LibraryAccessTest` (Task 8), `DiviPageLoaderTest::testLibraryItemsNeedTheLibraryAccess` (Task 8), `LibrarySaveAbilityTest::testNeedsPublishPostsAndTheDiviRole` (Task 12).
|
||
5. **Parallele Änderung:** Quelle oder Ziel wurde seit dem Lesen geändert (`source_tree_rev`/`tree_rev` veraltet), oder der globale Eintrag änderte sich zwischen Probelauf und `confirm` → `rev_mismatch`, nichts geschrieben → Tests `DiviNodeCopyAbilityTest::testStaleSourceOrTargetWritesNothing` (Task 14), `LibrarySaveAbilityTest::testStaleSourceTreeRevCreatesNothing` (Task 12), `GlobalEditsTest::testConfirmWithAStaleTreeRevWritesNothing` (Task 9).
|
||
|
||
---
|
||
|
||
## File Structure
|
||
|
||
```
|
||
phpunit.xml.dist (geändert) memory_limit 512M
|
||
composer.json (geändert) test-Skript mit memory_limit
|
||
wp-lit-mcp.php, src/Plugin.php (geändert) Version 0.4.0; Verdrahtung Bibliothek + Gates
|
||
readme.txt (geändert) Bibliothek, globale Module, Changelog 0.4.0
|
||
docs/guides/
|
||
divi-library.md (neu) Thema „divi-library“
|
||
overview.md, divi-placeholders.md, divi-structure.md, workflows.md, divi-design-system.md (geändert)
|
||
docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (geändert) §4.3, §5.2, §5.4, §5.7, §9.2
|
||
docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (geändert) Abschnitt 6 „Umsetzung 3a“
|
||
docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md (neu) manuelle Abnahme
|
||
src/
|
||
Abilities/ExposurePolicy.php (geändert) groupWritable()
|
||
Guides/InstructionsBuilder.php (geändert) Bibliotheks-Regel
|
||
Integrations/Core/EnvironmentProbe.php (geändert) Divi-Kompatibilitätsbefund
|
||
Integrations/Core/GuideAbility.php (geändert) topic = all
|
||
Integrations/WordPress/Render/PageRenderSummaryAbility.php (geändert) Hinweis globale Module
|
||
Integrations/Divi/
|
||
DiviIntegration.php (geändert) Bibliothekszugang, library(), neue Divi-Tools
|
||
DiviCompatibility.php (neu) Live-Prüfung der genutzten Divi-Interna
|
||
BlockTree/
|
||
Block.php (geändert) copyAs()
|
||
Container.php (geändert) replaceChild(), unifyLineBreaks()
|
||
NodeSummary.php (geändert) globalRef()
|
||
Outline.php (geändert) global_id
|
||
GlobalInstance.php (neu) Instanz im Builder-Format, entglobalisieren, effectiveName()
|
||
LibraryContent.php (neu) Rahmen, Wurzeln, Formregeln
|
||
Compose/
|
||
LinkTargets.php (neu) Schnittstelle für {{link:ID}}
|
||
Placeholders.php (geändert) {{link:ID}}
|
||
VariableRef.php (geändert) post_link_url_* nur in URL-Feldern, settingsOf()
|
||
StructureRules.php, TreeEditor.php (geändert) Wurzeltyp globaler Instanzen
|
||
StoredNodeGuard.php (neu) S7/S26 für übertragene gespeicherte Knoten
|
||
Design/DiviDesignSystemAbility.php (geändert) only/module-Filter (S19)
|
||
Pages/
|
||
WpLinkTargets.php (neu)
|
||
SaveScope.php (neu) Seite / Bibliothekseintrag / globaler Eintrag
|
||
DiviSaver.php (geändert) SaveScope: Flags, seitenweiter Cache
|
||
DiviPage.php, DiviPageLoader.php (geändert) Bibliothekseinträge
|
||
DiviWriter.php, DiviWriteAbility.php (geändert) commit(), notice(), precheck()
|
||
DiviSchema.php (geändert) confirm
|
||
DiviComposition.php (geändert) LinkTargets, storedGuard()
|
||
DiviNodeGetAbility.php, DiviOutlineAbility.php (geändert) global
|
||
DynamicContentMap.php (geändert) options()
|
||
DiviNodeCopyAbility.php (neu) lit/divi-node-copy
|
||
DiviDynamicSourcesAbility.php (neu) lit/divi-dynamic-sources
|
||
Library/
|
||
DiviRoles.php (neu) Adapter et_pb_is_allowed
|
||
DiviLibraryStore.php (neu) Adapter et_pb_layout, Terme, Meta
|
||
LibraryAccess.php (neu) Rechte je Objekt, Gruppen-Gate
|
||
LibraryItemSpec.php (neu) Terme/Meta eines neuen Eintrags
|
||
GlobalUsages.php (neu) Seiten mit Instanzen
|
||
GlobalEdits.php (neu) Formregeln, Probelauf, confirm, Sicherung
|
||
LibraryIntegration.php (neu) Gruppe „Divi-Bibliothek“
|
||
LibraryListAbility.php (neu) lit/library-list
|
||
LibrarySaveAbility.php (neu) lit/library-save
|
||
LibraryInsertAbility.php (neu) lit/library-insert
|
||
tests/
|
||
Stubs/EtCorePageResource.php (neu) zeichnet remove_static_resources auf
|
||
bootstrap.php (geändert)
|
||
Support/ComposeFixture.php, DiviWriterFixture.php (geändert) links(), Bibliothek
|
||
Support/LibraryFixtures.php (neu) Posts nach ID, Store/Rollen/Zugang
|
||
Fixtures/divi/stage3/README.md (geändert) Befund 9: CRLF in Bibliothekseinträgen
|
||
Unit/… (neu/geändert je Task)
|
||
Integration/divi-library-smoke.sh (neu) Ende-zu-Ende 3a
|
||
```
|
||
|
||
---
|
||
|
||
### Task 1: Testlauf ohne Speicherfehler
|
||
|
||
`composer test` bricht mit dem PHP-Standard `memory_limit=128M` ab („Allowed memory size of 134217728 bytes exhausted“); der volle Lauf braucht ≈ 177 MB (gemessen: `php -d memory_limit=1G vendor/bin/phpunit` → „Memory: 177.00 MB“). Das ist kein Leck einzelner Tests, sondern die Summe aus 1597 Tests mit Mockery/Brain Monkey und dem 237-KB-Katalog, der je Test dekodiert wird. PHPUnit setzt Werte aus `<php><ini>` zur Laufzeit; das deckt `composer test` und den direkten Aufruf ab.
|
||
|
||
**Files:**
|
||
- Modify: `phpunit.xml.dist`
|
||
- Modify: `composer.json` (`scripts.test`)
|
||
|
||
**Interfaces:**
|
||
- Consumes: nichts.
|
||
- Produces: `composer test` und `php vendor/bin/phpunit` laufen unabhängig von der `memory_limit` der CLI.
|
||
|
||
- [ ] **Step 1: Fehler nachweisen**
|
||
|
||
Run: `php -d memory_limit=128M vendor/bin/phpunit 2>&1 | grep -m1 -o 'Allowed memory size of [0-9]* bytes exhausted'`
|
||
Expected: `Allowed memory size of 134217728 bytes exhausted`
|
||
|
||
- [ ] **Step 2: Grenze in der PHPUnit-Konfiguration setzen**
|
||
|
||
`phpunit.xml.dist` vollständig:
|
||
|
||
```xml
|
||
<?xml version="1.0"?>
|
||
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
||
bootstrap="tests/bootstrap.php"
|
||
colors="true">
|
||
<php>
|
||
<!-- 1597+ tests with Mockery/Brain Monkey need ~180 MB; the CLI default is 128 MB. -->
|
||
<ini name="memory_limit" value="512M"/>
|
||
</php>
|
||
<testsuites>
|
||
<testsuite name="unit">
|
||
<directory>tests/Unit</directory>
|
||
</testsuite>
|
||
</testsuites>
|
||
</phpunit>
|
||
```
|
||
|
||
- [ ] **Step 3: Composer-Skript absichern (greift schon vor dem Laden der Konfiguration)**
|
||
|
||
In `composer.json` den Block `scripts` ersetzen durch:
|
||
|
||
```json
|
||
"scripts": {
|
||
"test": "@php -d memory_limit=512M vendor/bin/phpunit",
|
||
"lint": "@php vendor/bin/phpcs"
|
||
},
|
||
```
|
||
|
||
- [ ] **Step 4: Prüfen**
|
||
|
||
Run: `php -d memory_limit=128M vendor/bin/phpunit 2>&1 | tail -1`
|
||
Expected: `OK (1597 tests, 7869 assertions)`
|
||
|
||
Run: `composer test 2>&1 | tail -1`
|
||
Expected: `OK (1597 tests, 7869 assertions)`
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add phpunit.xml.dist composer.json
|
||
git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "test: raise memory_limit for the unit suite (512M)" -m "Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>" -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"
|
||
```
|
||
|
||
---
|
||
|
||
### Task 2: Interner Link `{{link:ID}}` (E13a)
|
||
|
||
Divis natives Dynamic Content `post_link_url_<post_type>` mit `settings.post_id` rendert zur Laufzeit `esc_url(get_permalink($id))` (F27, `DynamicContentOptionCustomPostLinkUrl.php:186-217`) – entwurfssicher und nach dem Veröffentlichen automatisch die schöne URL. Der Name wird nicht fest verdrahtet: `VariableRef` prüft ihn gegen Divis Registry (nur öffentliche Post-Typen, `et_builder_get_public_post_types`), `select_post` prüft `read_post` (S37). Neu: `post_link_url_*` nur in URL-Feldern.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Compose/LinkTargets.php`
|
||
- Create: `src/Integrations/Divi/Pages/WpLinkTargets.php`
|
||
- Modify: `src/Integrations/Divi/Compose/Placeholders.php`
|
||
- Modify: `src/Integrations/Divi/Compose/VariableRef.php` (`contentSettings()`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviComposition.php`
|
||
- Modify: `tests/Support/ComposeFixture.php` (`links()`, `placeholders()`)
|
||
- Test: `tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php`, `tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php`, `tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `VariableRef::serialize(string $type, string $name, array $settings): string`, `DynamicContentMap::fromOptions(array $options): array`, `DiviFixtures::dynamicContentRegistry()` (enthält `post_link_url_page` mit `post_id` = `select_post`).
|
||
- Produces: `interface LinkTargets { public function postTypeOf(int $id): ?string; }`; `new Placeholders(DesignSystem $design, ?LinkTargets $links = null)`; `WpLinkTargets implements LinkTargets`; `DiviComposition::__construct(…, LinkTargets $links = new WpLinkTargets())` (6. Parameter); `ComposeFixture::links(): LinkTargets` (839 → page, 840 → post, sonst null).
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
In `tests/Support/ComposeFixture.php` ergänzen (Import `use LIT\MCP\Integrations\Divi\Compose\LinkTargets;`) und `placeholders()` ersetzen:
|
||
|
||
```php
|
||
/** Link targets of the tests: 839 is a page, 840 a post; everything else is missing, trashed or unreadable. */
|
||
public static function links(): LinkTargets
|
||
{
|
||
return new class implements LinkTargets {
|
||
public function postTypeOf(int $id): ?string
|
||
{
|
||
return match ($id) {
|
||
839 => 'page',
|
||
840 => 'post',
|
||
default => null,
|
||
};
|
||
}
|
||
};
|
||
}
|
||
|
||
public static function placeholders(): Placeholders
|
||
{
|
||
return new Placeholders(DiviFixtures::designSystem(), self::links());
|
||
}
|
||
```
|
||
|
||
In `PlaceholdersTest::placeholders()` einen Eintrag ergänzen:
|
||
|
||
```php
|
||
'internal link' => [
|
||
'{{link:839}}',
|
||
'$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$',
|
||
],
|
||
```
|
||
|
||
Da `testResolvesToDiviVariables` das Ergebnis mit `ComposeFixture::references()` (Standard-Map ohne `post_link_url_page`) prüft, dort die Prüfung auf den Registry-Checker umstellen:
|
||
|
||
```php
|
||
/** @dataProvider placeholders */
|
||
public function testResolvesToDiviVariables(string $input, string $expected): void
|
||
{
|
||
$placeholders = ComposeFixture::placeholders();
|
||
$resolved = $placeholders->resolve($input);
|
||
|
||
self::assertSame($expected, $resolved);
|
||
if ($expected !== $input) {
|
||
// What placeholders produce passes the strict checker unchanged (ruling S1/S25); the live
|
||
// registry map knows post_link_url_page, the URL mode allows it.
|
||
$references = new VariableRef(
|
||
DiviFixtures::designSystem(),
|
||
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
|
||
);
|
||
self::assertSame($resolved, $references->canonical($resolved, ComposeFixture::sanitizer(), true));
|
||
}
|
||
}
|
||
```
|
||
|
||
(Imports: `use LIT\MCP\Integrations\Divi\Compose\VariableRef;`, `use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;`, `use LIT\MCP\Tests\Support\DiviFixtures;`.) Hinweis: Im URL-Modus sind `before`/`after` nur leer erlaubt – alle bisherigen Zeilen liefern leere `before`/`after`, sie bleiben gültig.
|
||
|
||
In `PlaceholdersTest::invalid()` ergänzen:
|
||
|
||
```php
|
||
'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'],
|
||
'link to a missing post' => ['{{link:999}}', 'Eintrag 999'],
|
||
'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'],
|
||
```
|
||
|
||
und am Ende von `PlaceholdersTest` einen Test ohne `LinkTargets`:
|
||
|
||
```php
|
||
public function testLinksNeedLinkTargets(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
$this->expectExceptionMessage('Eintrag 839');
|
||
|
||
(new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}');
|
||
}
|
||
```
|
||
|
||
In `ShortformWriterTest` (Imports `ShortformWriter`, `Placeholders`, `ValueCleaner`, `VariableRef`, `DynamicContentMap`, `DiviFixtures`):
|
||
|
||
```php
|
||
/** E13a: {{link:ID}} becomes Divi's post_link_url_<type> in URL fields – and only there. */
|
||
public function testInternalLinkOnlyInUrlFields(): void
|
||
{
|
||
$design = DiviFixtures::designSystem();
|
||
$cleaner = new ValueCleaner(
|
||
DiviFixtures::catalog(),
|
||
new VariableRef($design, DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())),
|
||
ComposeFixture::sanitizer()
|
||
);
|
||
$writer = new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner);
|
||
|
||
$button = $writer->apply('button', [], ['text' => 'Kontakt', 'url' => '{{link:839}}'], 'n');
|
||
self::assertSame([], $button['errors']);
|
||
self::assertSame(
|
||
'$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$',
|
||
$button['attrs']['button']['innerContent']['desktop']['value']['linkUrl']
|
||
);
|
||
|
||
$text = $writer->apply('button', [], ['text' => '{{link:839}}'], 'n');
|
||
self::assertStringContainsString('nur in Link- und URL-Feldern', $text['errors']['n.text']);
|
||
|
||
// A post type Divi offers no link option for (not public): unknown name.
|
||
$post = $writer->apply('button', [], ['url' => '{{link:840}}'], 'n');
|
||
self::assertStringContainsString('post_link_url_post', $post['errors']['n.url']);
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
|
||
final class WpLinkTargetsTest extends TestCase
|
||
{
|
||
public function testOnlyReadableLivingPostsCanBeLinked(): void
|
||
{
|
||
$posts = [
|
||
839 => new WP_Post(['ID' => 839, 'post_type' => 'page', 'post_status' => 'draft']),
|
||
841 => new WP_Post(['ID' => 841, 'post_type' => 'page', 'post_status' => 'trash']),
|
||
842 => new WP_Post(['ID' => 842, 'post_type' => 'page', 'post_status' => 'private']),
|
||
843 => new WP_Post(['ID' => 843, 'post_type' => 'revision', 'post_status' => 'inherit']),
|
||
];
|
||
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null);
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842
|
||
);
|
||
|
||
$links = new WpLinkTargets();
|
||
|
||
self::assertSame('page', $links->postTypeOf(839));
|
||
self::assertNull($links->postTypeOf(841), 'trashed');
|
||
self::assertNull($links->postTypeOf(842), 'not readable');
|
||
self::assertNull($links->postTypeOf(843), 'revision');
|
||
self::assertNull($links->postTypeOf(900), 'missing');
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – sie schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'PlaceholdersTest|ShortformWriterTest|WpLinkTargetsTest'`
|
||
Expected: FAIL (`Class "LIT\MCP\Integrations\Divi\Compose\LinkTargets" not found`)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Compose/LinkTargets.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Entries {{link:<ID>}} may point to (E13a). Pure interface – WpLinkTargets asks WordPress.
|
||
*/
|
||
interface LinkTargets
|
||
{
|
||
/** Post type of entry $id when it may be linked (exists, not trashed, readable); null otherwise. */
|
||
public function postTypeOf(int $id): ?string;
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Pages/WpLinkTargets.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* {{link:<ID>}} targets from WordPress: the entry exists, lives (no trash, auto-draft or
|
||
* revision) and the current user may read it (read_post, like select_post settings, S37).
|
||
*/
|
||
final class WpLinkTargets implements LinkTargets
|
||
{
|
||
private const DEAD = ['trash', 'auto-draft', 'inherit'];
|
||
|
||
public function postTypeOf(int $id): ?string
|
||
{
|
||
$post = get_post($id);
|
||
if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) {
|
||
return null;
|
||
}
|
||
|
||
return current_user_can('read_post', $id) ? (string) $post->post_type : null;
|
||
}
|
||
}
|
||
```
|
||
|
||
`Placeholders.php`: Konstruktor, Muster, Fehlermeldung und neue Methode.
|
||
|
||
```php
|
||
private const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/';
|
||
|
||
public function __construct(private DesignSystem $design, private ?LinkTargets $links = null)
|
||
{
|
||
}
|
||
```
|
||
|
||
In `resolve()` die Meldung zu „(bekannt: color, var, font, post, field, link)“ ändern und das `match` erweitern:
|
||
|
||
```php
|
||
return match ($kind) {
|
||
'color' => $this->color($name),
|
||
'var' => $this->variable($name),
|
||
'font' => $this->font($name),
|
||
'post' => $this->post($name),
|
||
'link' => $this->link($name),
|
||
default => $this->field($name),
|
||
};
|
||
```
|
||
|
||
Neue Methode (Klassen-Docblock um `{{link:<ID>}}` ergänzen):
|
||
|
||
```php
|
||
/**
|
||
* {{link:<ID>}} → Divi's own internal link post_link_url_<type> with settings.post_id (F27):
|
||
* rendered as get_permalink() at view time, so a draft target gets its pretty URL once published.
|
||
* Whether Divi offers that option (public post types only) VariableRef checks against the registry.
|
||
*/
|
||
private function link(string $name): string
|
||
{
|
||
if (preg_match('/^[1-9][0-9]{0,19}$/D', $name) !== 1) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).',
|
||
$name
|
||
));
|
||
}
|
||
|
||
$type = $this->links?->postTypeOf((int) $name);
|
||
if ($type === null) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.',
|
||
$name
|
||
));
|
||
}
|
||
|
||
return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]);
|
||
}
|
||
```
|
||
|
||
`VariableRef::contentSettings()` – vor der Schleife einfügen:
|
||
|
||
```php
|
||
if (! $url && str_starts_with($name, 'post_link_url_')) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.',
|
||
$name
|
||
));
|
||
}
|
||
```
|
||
|
||
`DiviComposition`: Import `use LIT\MCP\Integrations\Divi\Compose\LinkTargets;`, Konstruktor um `private LinkTargets $links = new WpLinkTargets()` als letzten Parameter erweitern und `placeholders()` ändern:
|
||
|
||
```php
|
||
return $this->placeholders ??= new Placeholders($this->design->read(), $this->links);
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'PlaceholdersTest|ShortformWriterTest|WpLinkTargetsTest|VariableRefTest|DiviCompositionTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK / keine Fehler
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Compose/LinkTargets.php src/Integrations/Divi/Pages/WpLinkTargets.php src/Integrations/Divi/Compose/Placeholders.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Support/ComposeFixture.php tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: {{link:ID}} placeholder via Divi's post_link_url dynamic content`.
|
||
|
||
---
|
||
|
||
### Task 3: `lit-guide` mit allen Themen (E13b)
|
||
|
||
Praxistest-Wunsch 7: alle Themen in einem Aufruf. `topic` = `all` liefert zuerst `overview`, dann die übrigen Themen alphabetisch; Obergrenze 100 000 Bytes, nicht aufgenommene Themen werden am Ende genannt.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Core/GuideAbility.php`
|
||
- Test: `tests/Unit/Integrations/Core/GuideAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `GuideRepository::topics(): list<string>`, `GuideRepository::get(string $topic): ?string`.
|
||
- Produces: `lit/guide` mit `topic` = `all` → `{topic: "all", topics: list<string>, content: string}`; Konstante `GuideAbility::ALL_LIMIT = 100000`.
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
In `GuideAbilityTest` ergänzen (`$this->ability` liest `tests/Fixtures/guides` mit `overview.md` und `divi-shortform.md`):
|
||
|
||
```php
|
||
public function testAllTopicsInOneCallOverviewFirst(): void
|
||
{
|
||
$result = $this->ability->execute(['topic' => 'all']);
|
||
|
||
self::assertSame('all', $result['topic']);
|
||
self::assertSame(['divi-shortform', 'overview'], $result['topics']);
|
||
$overview = strpos($result['content'], 'Thema `overview`');
|
||
$shortform = strpos($result['content'], 'Thema `divi-shortform`');
|
||
self::assertIsInt($overview);
|
||
self::assertIsInt($shortform);
|
||
self::assertLessThan($shortform, $overview);
|
||
self::assertStringNotContainsString('Nicht enthalten', $result['content']);
|
||
}
|
||
|
||
public function testAllTopicsStopAtTheSizeLimitAndNameTheRest(): void
|
||
{
|
||
$big = str_repeat('x', GuideAbility::ALL_LIMIT);
|
||
// Brain Monkey owns apply_filters(): answer the guide filter through its hooks API.
|
||
Filters\expectApplied('lit_mcp_guides')->andReturn(['gross' => $big]);
|
||
$ability = new GuideAbility(new GuideRepository(dirname(__DIR__, 3) . '/Fixtures/guides'));
|
||
|
||
$content = $ability->execute(['topic' => 'all'])['content'];
|
||
|
||
self::assertLessThanOrEqual(GuideAbility::ALL_LIMIT + 200, strlen($content));
|
||
self::assertStringContainsString('Nicht enthalten (Größenlimit): gross', $content);
|
||
}
|
||
```
|
||
|
||
(Import `use Brain\Monkey\Filters;`.)
|
||
|
||
- [ ] **Step 2: Test laufen lassen – schlägt fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter GuideAbilityTest`
|
||
Expected: FAIL (`Undefined constant … ALL_LIMIT` bzw. `Unbekanntes Thema`)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
In `GuideAbility`:
|
||
|
||
```php
|
||
private const DEFAULT_TOPIC = 'overview';
|
||
|
||
/** topic = all: every topic in one call (overview first). */
|
||
public const ALL = 'all';
|
||
|
||
/** Upper bound of the combined text of topic = all (bytes). */
|
||
public const ALL_LIMIT = 100000;
|
||
```
|
||
|
||
`description()`:
|
||
|
||
```php
|
||
return 'Liefert Anleitungen zur Bedienung dieses MCP-Servers. Ohne topic: Übersicht und Liste aller Themen; '
|
||
. 'topic = all: alle Themen in einem Aufruf (gut für den Einstieg).';
|
||
```
|
||
|
||
Beschreibung des Schema-Felds `topic`: `'Thema, z. B. "overview" oder "divi-shortform"; "all" = alle Themen.'`
|
||
|
||
In `execute()` direkt nach `$topics = $this->guides->topics();`:
|
||
|
||
```php
|
||
if ($topic === self::ALL) {
|
||
return ['topic' => self::ALL, 'topics' => $topics, 'content' => $this->all($topics)];
|
||
}
|
||
```
|
||
|
||
Neue Methode:
|
||
|
||
```php
|
||
/**
|
||
* Every topic, overview first, separated and labelled; stops before ALL_LIMIT bytes and names
|
||
* the topics left out.
|
||
*
|
||
* @param list<string> $topics
|
||
*/
|
||
private function all(array $topics): string
|
||
{
|
||
$ordered = in_array(self::DEFAULT_TOPIC, $topics, true)
|
||
? array_merge([self::DEFAULT_TOPIC], array_values(array_diff($topics, [self::DEFAULT_TOPIC])))
|
||
: $topics;
|
||
$parts = [];
|
||
$size = 0;
|
||
$left = [];
|
||
foreach ($ordered as $topic) {
|
||
$content = $this->guides->get($topic);
|
||
if ($content === null) {
|
||
continue;
|
||
}
|
||
|
||
$part = sprintf("---\nThema `%s`:\n\n%s", $topic, rtrim($content));
|
||
if ($size + strlen($part) > self::ALL_LIMIT) {
|
||
$left[] = $topic;
|
||
continue;
|
||
}
|
||
|
||
$parts[] = $part;
|
||
$size += strlen($part) + 2;
|
||
}
|
||
|
||
if ($left !== []) {
|
||
$parts[] = sprintf(
|
||
'Nicht enthalten (Größenlimit): %s – einzeln mit topic abrufen.',
|
||
implode(', ', $left)
|
||
);
|
||
}
|
||
|
||
return implode("\n\n", $parts);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'GuideAbilityTest|ToolNamingTest'`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Core/GuideAbility.php tests/Unit/Integrations/Core/GuideAbilityTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: lit-guide topic all returns every guide in one call`.
|
||
|
||
---
|
||
|
||
### Task 4: S19 – Designsystem filtern, globale Module in `node-get`/`outline`
|
||
|
||
Ruling S19 (aus 2a F2): `lit-divi-design-system` wird auf großen Websites mit hunderten Presets unhandlich → Filter `only` (colors/variables/presets) und `module` (nur dessen Presets), höchstens 200 Presets je Antwort. `lit-divi-node-get` und `lit-divi-outline` nennen bei globalen Modulen die Bibliotheks-ID und den Wurzeltyp (E2).
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Design/DiviDesignSystemAbility.php`
|
||
- Modify: `src/Integrations/Divi/BlockTree/NodeSummary.php` (`globalRef()`)
|
||
- Modify: `src/Integrations/Divi/BlockTree/Outline.php` (`global_id`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviNodeGetAbility.php` (`global`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviOutlineAbility.php` (Schema, Hinweis)
|
||
- Test: `tests/Unit/Integrations/Divi/Design/DiviDesignSystemAbilityTest.php`, `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DesignSystem::presets(): list<array{kind: string, module: string, id: string, name: string, default: bool}>`, Fixture `tests/Fixtures/divi/stage3/page--globale-instanzen.html` (g0 = Instanz 1058 `divi/section`, s0.r0.c0.g0 = Instanz 1059 `divi/text`).
|
||
- Produces: `NodeSummary::globalRef(Block $block): ?array{id: string, block_name: string}`; Outline-Zeilen mit `global_id: ?string`; `lit/divi-node-get` mit `global: null|{id, block_name, hint}`; `lit/divi-design-system` mit `only`, `module`.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`OutlineTest::testRowsAndTextOfAPage`: im erwarteten Array `'truncated' => false]` durch `'truncated' => false, 'global_id' => null]` ersetzen. Neuer Test in `OutlineTest`:
|
||
|
||
```php
|
||
public function testGlobalInstancesShowTheirLibraryIdAndRootType(): void
|
||
{
|
||
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||
$outline = new Outline();
|
||
$rows = $outline->rows(NodeIndex::build((new Parser())->parse($content)));
|
||
|
||
$byId = array_column($rows, null, 'id');
|
||
self::assertSame('1058', $byId['g0']['global_id']);
|
||
self::assertSame('section', $byId['g0']['type']);
|
||
self::assertFalse($byId['g0']['editable']);
|
||
self::assertSame('1059', $byId['s0.r0.c0.g0']['global_id']);
|
||
self::assertSame('text', $byId['s0.r0.c0.g0']['type']);
|
||
self::assertNull($byId['s0']['global_id']);
|
||
self::assertStringContainsString('g0 section "Global Abschnitt" [global 1058]', $outline->text($rows));
|
||
}
|
||
```
|
||
|
||
(Import `use LIT\MCP\Tests\Support\DiviFixtures;` – `DiviFixtures::markup()` ist reines PHP und liest `tests/Fixtures/divi/<name>`.)
|
||
|
||
In `DiviReadAbilitiesTest`:
|
||
|
||
```php
|
||
public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void
|
||
{
|
||
DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||
$ability = new DiviNodeGetAbility(new DiviPageLoader(DiviFixtures::schema()), $this->design());
|
||
|
||
$global = $ability->execute(['post_id' => 7, 'id' => 'g0']);
|
||
$plain = $ability->execute(['post_id' => 7, 'id' => 's0']);
|
||
|
||
self::assertSame('1058', $global['global']['id']);
|
||
self::assertSame('divi/section', $global['global']['block_name']);
|
||
self::assertStringContainsString('post_id 1058', $global['global']['hint']);
|
||
self::assertFalse($global['editable']);
|
||
self::assertNull($plain['global']);
|
||
}
|
||
```
|
||
|
||
In `DiviDesignSystemAbilityTest` (Muster der vorhandenen Tests: Reader-Mock mit `DiviFixtures::designSystem()`):
|
||
|
||
```php
|
||
public function testFiltersPartsAndPresetsOfOneModule(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$reader = Mockery::mock(DesignSystemReader::class);
|
||
$reader->allows('read')->andReturn(DiviFixtures::designSystem());
|
||
$ability = new DiviDesignSystemAbility($reader);
|
||
|
||
$result = $ability->execute(['only' => ['presets'], 'module' => 'button']);
|
||
|
||
self::assertSame([], $result['colors']);
|
||
self::assertSame([], $result['variables']);
|
||
self::assertSame(['p-1', 'p-2'], array_column($result['presets'], 'id'));
|
||
self::assertSame(['divi/button'], array_values(array_unique(array_column($result['presets'], 'module'))));
|
||
self::assertNotEmpty($result['fonts']['heading']);
|
||
self::assertContains('Nicht angefordert: colors, variables.', $result['notes']);
|
||
}
|
||
|
||
public function testCapsThePresetList(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
$items = [];
|
||
for ($i = 0; $i < 250; ++$i) {
|
||
$items['p' . $i] = ['id' => 'p' . $i, 'name' => 'Preset ' . $i, 'moduleName' => 'divi/text'];
|
||
}
|
||
$design = DesignSystem::fromDivi([], [], ['module' => ['divi/text' => ['default' => '', 'items' => $items]]], []);
|
||
$reader = Mockery::mock(DesignSystemReader::class);
|
||
$reader->allows('read')->andReturn($design);
|
||
|
||
$result = (new DiviDesignSystemAbility($reader))->execute([]);
|
||
|
||
self::assertCount(DiviDesignSystemAbility::PRESET_LIMIT, $result['presets']);
|
||
self::assertContains('200 von 250 Presets gezeigt; mit module auf ein Modul eingrenzen.', $result['notes']);
|
||
}
|
||
```
|
||
|
||
(Imports: `DesignSystem`, `DesignSystemReader`, `DiviFixtures`, `Mockery`, `Functions`.)
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'OutlineTest|DiviReadAbilitiesTest|DiviDesignSystemAbilityTest'`
|
||
Expected: FAIL (`global_id`, `global`, `PRESET_LIMIT` fehlen)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`NodeSummary`:
|
||
|
||
```php
|
||
/**
|
||
* Library entry of a global module: a divi/global-layout instance (its root type in blockName)
|
||
* or a block that carries globalModule itself (Divi 4 conversions). Null for every other block.
|
||
*
|
||
* @return array{id: string, block_name: string}|null
|
||
*/
|
||
public static function globalRef(Block $block): ?array
|
||
{
|
||
if (! $block->isGlobal()) {
|
||
return null;
|
||
}
|
||
|
||
$attrs = $block->attrs();
|
||
$id = $attrs['globalModule'] ?? null;
|
||
if (! is_string($id) && ! is_int($id)) {
|
||
return null;
|
||
}
|
||
|
||
$name = $block->name() === 'divi/global-layout' ? ($attrs['blockName'] ?? '') : $block->name();
|
||
|
||
return ['id' => (string) $id, 'block_name' => is_string($name) ? $name : ''];
|
||
}
|
||
```
|
||
|
||
`Outline::row()` – Typ und `global_id` (Docblock-Rückgabetyp um `global_id: ?string` ergänzen):
|
||
|
||
```php
|
||
private function row(Block $block, string $id, int $depth): array
|
||
{
|
||
$editable = $block->isDivi() && ! $block->isOpaque();
|
||
$global = NodeSummary::globalRef($block);
|
||
$type = $block->isDivi() ? $block->shortName() : $block->name();
|
||
if ($global !== null && $global['block_name'] !== '') {
|
||
$type = str_starts_with($global['block_name'], 'divi/') ? substr($global['block_name'], 5) : $global['block_name'];
|
||
}
|
||
|
||
return [
|
||
'id' => $id,
|
||
'type' => $type,
|
||
'label' => NodeSummary::label($block),
|
||
'excerpt' => NodeSummary::excerpt($block),
|
||
'info' => $editable ? NodeSummary::info($block) : '',
|
||
'presets' => array_map(
|
||
fn (string $presetId): string => $this->presetNames[$presetId] ?? $presetId,
|
||
NodeSummary::presetIds($block)
|
||
),
|
||
'children' => $editable ? count($block->children()) : 0,
|
||
'editable' => $editable,
|
||
'depth' => $depth,
|
||
'truncated' => false,
|
||
'global_id' => $global['id'] ?? null,
|
||
];
|
||
}
|
||
```
|
||
|
||
`NodeSummary::label()` liest `module.meta.adminLabel` aus den Attributen; bei `divi/global-layout` steht das Label in `localAttrs`. In `NodeSummary::label()` die erste Zeile ersetzen durch:
|
||
|
||
```php
|
||
$attrs = $block->name() === 'divi/global-layout' && is_array($block->attrs()['localAttrs'] ?? null)
|
||
? $block->attrs()['localAttrs']
|
||
: $block->attrs();
|
||
$label = AttrMap::desktop($attrs, 'module.meta.adminLabel');
|
||
```
|
||
|
||
`Outline::text()` – nach der Label-Zeile:
|
||
|
||
```php
|
||
$line .= ($row['global_id'] ?? null) !== null ? ' [global ' . $row['global_id'] . ']' : '';
|
||
```
|
||
|
||
`DiviOutlineAbility::outputSchema()` – in den Zeilen-`properties` `'global_id' => ['type' => ['string', 'null'], 'description' => 'Bibliotheks-ID eines globalen Moduls.']` ergänzen und `'global_id'` in `required` aufnehmen. In `hints()` den Text für nur lesbare Knoten ersetzen:
|
||
|
||
```php
|
||
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
|
||
. 'verschieben, duplizieren oder löschen. Globale Module [global <ID>] im Bibliothekseintrag '
|
||
. 'bearbeiten: lit-divi-outline mit post_id <ID> – die Änderung wirkt auf alle Seiten.';
|
||
```
|
||
|
||
`DiviNodeGetAbility`: im `outputSchema` `'global' => ['type' => ['object', 'null']]` ergänzen (nicht in `required`); in `execute()` vor dem `return`:
|
||
|
||
```php
|
||
$global = NodeSummary::globalRef($block);
|
||
if ($global !== null) {
|
||
$global['hint'] = sprintf(
|
||
'Globales Modul: Inhalt im Bibliothekseintrag %s bearbeiten (lit-divi-outline mit post_id %s); '
|
||
. 'jede Änderung dort wirkt auf alle Seiten mit diesem Modul.',
|
||
$global['id'],
|
||
$global['id']
|
||
);
|
||
}
|
||
```
|
||
|
||
und `'global' => $global,` ins Ergebnis-Array.
|
||
|
||
`DiviDesignSystemAbility`:
|
||
|
||
```php
|
||
/** Presets per answer (ruling S19); narrow with "module". */
|
||
public const PRESET_LIMIT = 200;
|
||
|
||
private const PARTS = ['colors', 'variables', 'presets'];
|
||
```
|
||
|
||
`description()` um „Filter: only (colors/variables/presets) und module (Presets eines Moduls).“ ergänzen. `inputSchema()`:
|
||
|
||
```php
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'only' => [
|
||
'type' => 'array',
|
||
'items' => ['type' => 'string', 'enum' => self::PARTS],
|
||
'description' => 'Nur diese Teile liefern (Standard: alle). Schriften und Größen sind immer dabei.',
|
||
],
|
||
'module' => [
|
||
'type' => 'string',
|
||
'description' => 'Nur Presets dieses Moduls, z. B. "button" oder "divi/button".',
|
||
],
|
||
],
|
||
'additionalProperties' => false,
|
||
];
|
||
```
|
||
|
||
`execute()` vollständig:
|
||
|
||
```php
|
||
public function execute(array $input): mixed
|
||
{
|
||
$design = $this->reader->read();
|
||
$only = is_array($input['only'] ?? null) && $input['only'] !== []
|
||
? array_values(array_intersect(self::PARTS, $input['only']))
|
||
: self::PARTS;
|
||
$notes = [
|
||
'Schriften: {{font:heading}} bzw. {{font:body}}; Presets in der Kurzform über "preset": "<Name>".',
|
||
'Feste Farben oder Schriften nur verwenden, wenn der Nutzer es ausdrücklich möchte.',
|
||
];
|
||
$skipped = array_values(array_diff(self::PARTS, $only));
|
||
if ($skipped !== []) {
|
||
$notes[] = 'Nicht angefordert: ' . implode(', ', $skipped) . '.';
|
||
}
|
||
|
||
$presets = $design->presets();
|
||
$module = is_string($input['module'] ?? null) ? trim($input['module']) : '';
|
||
if ($module !== '') {
|
||
$name = str_contains($module, '/') ? $module : 'divi/' . $module;
|
||
$presets = array_values(array_filter(
|
||
$presets,
|
||
static fn (array $preset): bool => $preset['module'] === $name
|
||
));
|
||
}
|
||
if (count($presets) > self::PRESET_LIMIT) {
|
||
$notes[] = sprintf(
|
||
'%d von %d Presets gezeigt; mit module auf ein Modul eingrenzen.',
|
||
self::PRESET_LIMIT,
|
||
count($presets)
|
||
);
|
||
$presets = array_slice($presets, 0, self::PRESET_LIMIT);
|
||
}
|
||
|
||
return [
|
||
'colors' => in_array('colors', $only, true) ? array_map(
|
||
static fn (array $color): array => $color + [
|
||
'placeholder' => DesignSystem::colorPlaceholder($color['id'], $color['label']),
|
||
],
|
||
$design->colors()
|
||
) : [],
|
||
'variables' => in_array('variables', $only, true) ? array_map(
|
||
static fn (array $variable): array => $variable
|
||
+ ['placeholder' => '{{var:' . $variable['label'] . '}}'],
|
||
$design->variables()
|
||
) : [],
|
||
'fonts' => $design->fonts(),
|
||
'sizes' => $design->sizes(),
|
||
'presets' => in_array('presets', $only, true) ? $presets : [],
|
||
'notes' => $notes,
|
||
];
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'OutlineTest|DiviReadAbilitiesTest|DiviDesignSystemAbilityTest|DiviWriterTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Design/DiviDesignSystemAbility.php src/Integrations/Divi/BlockTree/NodeSummary.php src/Integrations/Divi/BlockTree/Outline.php src/Integrations/Divi/Pages/DiviNodeGetAbility.php src/Integrations/Divi/Pages/DiviOutlineAbility.php tests/Unit/Integrations/Divi
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: design-system filters and global module ids in node-get/outline (S19)`.
|
||
|
||
---
|
||
|
||
### Task 5: `lit-page-render-summary` meldet globale Module (E13d, F8)
|
||
|
||
Divis `BlockParser` expandiert `divi/global-layout` nicht bei REST-POST-Anfragen (F8, `BlockParser.php:827-831`) – unsere MCP-Aufrufe sind REST-POST, der Auszug enthält globale Module also nicht. Statt einen Divi-internen Schalter zu umgehen, meldet der Auszug jede Instanz (Divis eigene Erkennung per Zeichenkettensuche, F10). Bibliotheks-IDs bleiben für das Tool `not_found` (`ContentSchema::EXCLUDED`).
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/WordPress/Render/PageRenderSummaryAbility.php`
|
||
- Test: `tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `PageRenderer::render(WP_Post): string`, `RenderSummary::fromHtml()`.
|
||
- Produces: `notes` enthält je globaler Instanz `Globales Modul <ID> ist im Auszug nicht enthalten …` (höchstens 20).
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
Im vorhandenen Test das Muster zum Stubben von `get_post`, `current_user_can`, `get_permalink` und des `PageRenderer`-Mocks übernehmen (siehe bestehende Tests der Datei):
|
||
|
||
```php
|
||
public function testNotesEveryGlobalModuleTheSummaryCannotShow(): void
|
||
{
|
||
$content = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||
$post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]);
|
||
Functions\when('get_post')->justReturn($post);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
|
||
$renderer = Mockery::mock(PageRenderer::class);
|
||
$renderer->allows('render')->andReturn('<p>Text</p>');
|
||
$ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer);
|
||
|
||
$notes = $ability->execute(['post_id' => 7])['notes'];
|
||
|
||
$globals = array_values(array_filter($notes, static fn (string $note): bool => str_starts_with($note, 'Globales Modul')));
|
||
self::assertCount(2, $globals);
|
||
self::assertStringContainsString('Globales Modul 1058 ist im Auszug nicht enthalten', $globals[0]);
|
||
self::assertStringContainsString('lit-divi-outline mit post_id 1059', $globals[1]);
|
||
}
|
||
|
||
public function testLibraryEntriesAreNoManagedContent(): void
|
||
{
|
||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout']));
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->with('et_pb_layout')->andReturn(false);
|
||
$ability = new PageRenderSummaryAbility($schema, Mockery::mock(PageRenderer::class));
|
||
|
||
self::assertToolError($ability->permission(['post_id' => 1058]), 'not_found');
|
||
}
|
||
```
|
||
|
||
(Imports: `ContentSchema`, `SchemaFixture`, `DiviFixtures`, `PageRenderer`, `Mockery`, `WP_Post`, `Functions`.)
|
||
|
||
- [ ] **Step 2: Test laufen lassen – schlägt fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter PageRenderSummaryAbilityTest`
|
||
Expected: FAIL (`assertCount(2, …)`: 0)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
In `PageRenderSummaryAbility`:
|
||
|
||
```php
|
||
/** At most this many global-module notes per summary. */
|
||
private const GLOBAL_NOTES = 20;
|
||
```
|
||
|
||
In `execute()` nach dem Passwort-Hinweis:
|
||
|
||
```php
|
||
foreach (array_slice(self::globalModuleIds((string) $post->post_content), 0, self::GLOBAL_NOTES) as $id) {
|
||
$notes[] = sprintf(
|
||
'Globales Modul %s ist im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi '
|
||
. 'globale Module nicht); Inhalt mit lit-divi-outline mit post_id %s lesen.',
|
||
$id,
|
||
$id
|
||
);
|
||
}
|
||
```
|
||
|
||
Neue Methode:
|
||
|
||
```php
|
||
/**
|
||
* IDs of the global modules in $content, found the way Divi finds them (a string scan for
|
||
* "globalModule", BlockParserStore::get_global_layout_content – parse_blocks() is unreliable there).
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
private static function globalModuleIds(string $content): array
|
||
{
|
||
preg_match_all('/"globalModule":"?(\d+)"?/', $content, $matches);
|
||
|
||
return array_values(array_unique($matches[1]));
|
||
}
|
||
```
|
||
|
||
`description()` um den Satz „Globale Module (Divi-Bibliothek) fehlen im Auszug und werden unter notes genannt.“ ergänzen.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'PageRenderSummaryAbilityTest|ToolNamingTest'`
|
||
Expected: PASS
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/WordPress/Render/PageRenderSummaryAbility.php tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: render summary notes global modules it cannot show (F8)`.
|
||
|
||
---
|
||
|
||
### Task 6: Divi-Adapter mit Kompatibilitätsprüfung – `DiviRoles`, `DiviLibraryStore` (lesen), `DiviCompatibility`
|
||
|
||
Schnittstellen-Einordnung (Entscheidungen §5, E15): Divis Rollen-Editor (`et_pb_is_allowed`, Stufe c), der Bibliotheks-Post-Typ mit Termen/Meta (c/d) und `GlobalLayout::is_global_layout_template` (c) werden nur über Adapter angesprochen. Fehlt eine Funktion/Klasse nach einem Divi-Update, liefern die Adapter „nicht erlaubt“ bzw. den Rückfall, nie einen Fatal; `lit-site-status` zeigt den Befund. Die Builder-Routen `divi/v1/divi-library/*` werden nicht benutzt (Nonce, keine Rechte je Eintrag, F9/F11).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Library/DiviRoles.php`
|
||
- Create: `src/Integrations/Divi/Library/DiviLibraryStore.php`
|
||
- Create: `src/Integrations/Divi/DiviCompatibility.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DynamicContentMap.php` (`REGISTRY` öffentlich)
|
||
- Modify: `src/Integrations/Core/EnvironmentProbe.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`, `tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php`, `tests/Unit/Integrations/Divi/DiviCompatibilityTest.php`, `tests/Unit/Integrations/Core/EnvironmentProbeTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Errors::forbidden()`, `DiviModules::isAvailable()`, `WP_Term`-Stub.
|
||
- Produces:
|
||
- `class DiviRoles { __construct(?Closure $check = null); allows(string $capability): bool; refusal(string $capability): ?WP_Error }`
|
||
- `class DiviLibraryStore { const POST_TYPE = 'et_pb_layout'; const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout'; const LAYOUT_TYPES = ['layout','section','row','module']; const TAXONOMIES = ['scope','layout_type','module_width']; isGlobal(int $id): bool; layoutType(int $id): ?string; termSlugs(int $id, string $taxonomy): list<string>; termNames(int $id, string $taxonomy): list<string>; find(array $filters, int $limit, int $offset): list<WP_Post>; describe(WP_Post $post): array; missingTaxonomies(): list<string> }` (nicht final, mockbar; `create()`/`delete()` folgen in Task 12)
|
||
- `final class DiviCompatibility { __construct(?array $checks = null); problems(): list<string>; static defaults(): array<string, callable(): bool> }`
|
||
- `EnvironmentProbe::__construct(?DiviCompatibility $divi = null)`; jede Komponente hat zusätzlich `problems: list<string>`.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Library/DiviRolesTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
|
||
final class DiviRolesTest extends TestCase
|
||
{
|
||
public function testAsksTheCheckAndExplainsARefusal(): void
|
||
{
|
||
$roles = new DiviRoles(static fn (string $cap): bool => $cap !== 'edit_global_library');
|
||
|
||
self::assertTrue($roles->allows('divi_library'));
|
||
self::assertNull($roles->refusal('divi_library'));
|
||
$refusal = $roles->refusal('edit_global_library');
|
||
self::assertToolError($refusal, 'forbidden');
|
||
self::assertStringContainsString('Globale Bibliothekselemente bearbeiten', $refusal->get_error_message());
|
||
self::assertStringContainsString('Rollen-Editor', $refusal->get_error_message());
|
||
}
|
||
|
||
public function testWithoutDiviNothingIsAllowed(): void
|
||
{
|
||
// Unit tests have no et_pb_is_allowed(): a missing Divi function never means "allowed".
|
||
self::assertFalse((new DiviRoles())->allows('divi_library'));
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Library/DiviLibraryStoreTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
final class DiviLibraryStoreTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$terms = [
|
||
1058 => [
|
||
'scope' => [new WP_Term(['slug' => 'global', 'name' => 'global'])],
|
||
'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])],
|
||
'layout_category' => [new WP_Term(['slug' => 'kopf', 'name' => 'Kopfbereiche'])],
|
||
],
|
||
1055 => [
|
||
'scope' => [new WP_Term(['slug' => 'non_global', 'name' => 'non_global'])],
|
||
'layout_type' => [new WP_Term(['slug' => 'section', 'name' => 'section'])],
|
||
],
|
||
1057 => [],
|
||
];
|
||
Functions\when('get_the_terms')->alias(
|
||
static fn (int $id, string $taxonomy): array|false => $terms[$id][$taxonomy] ?? false
|
||
);
|
||
Functions\when('get_post_meta')->alias(
|
||
static fn (int $id, string $key): string => $id === 1057 && $key === '_et_pb_module_type' ? 'divi/blurb' : ''
|
||
);
|
||
}
|
||
|
||
public function testGlobalScopeAndLayoutTypeFromTheTerms(): void
|
||
{
|
||
$store = new DiviLibraryStore();
|
||
|
||
self::assertTrue($store->isGlobal(1058));
|
||
self::assertFalse($store->isGlobal(1055));
|
||
self::assertSame('section', $store->layoutType(1058));
|
||
self::assertNull($store->layoutType(1057));
|
||
self::assertSame(['Kopfbereiche'], $store->termNames(1058, 'layout_category'));
|
||
}
|
||
|
||
public function testDescribesAnEntry(): void
|
||
{
|
||
$post = new WP_Post([
|
||
'ID' => 1057, 'post_type' => 'et_pb_layout', 'post_title' => '[Fixture] Library-Modul',
|
||
'post_modified_gmt' => '2026-09-26 20:56:29',
|
||
]);
|
||
|
||
self::assertSame(
|
||
[
|
||
'id' => 1057, 'title' => '[Fixture] Library-Modul', 'layout_type' => null, 'scope' => 'normal',
|
||
'module_type' => 'divi/blurb', 'categories' => [], 'tags' => [], 'modified' => '2026-09-26 20:56:29',
|
||
],
|
||
(new DiviLibraryStore())->describe($post)
|
||
);
|
||
}
|
||
|
||
public function testFindQueriesPublishedEntriesWithFilters(): void
|
||
{
|
||
$captured = [];
|
||
Functions\when('get_posts')->alias(static function (array $args) use (&$captured): array {
|
||
$captured = $args;
|
||
|
||
return [];
|
||
});
|
||
|
||
(new DiviLibraryStore())->find(
|
||
['layout_type' => 'section', 'scope' => 'normal', 'search' => 'Kopf', 'category' => 'Kopfbereiche'],
|
||
20,
|
||
40
|
||
);
|
||
|
||
self::assertSame('et_pb_layout', $captured['post_type']);
|
||
self::assertSame('publish', $captured['post_status']);
|
||
self::assertSame(21, $captured['posts_per_page']);
|
||
self::assertSame(40, $captured['offset']);
|
||
self::assertSame('Kopf', $captured['s']);
|
||
self::assertSame(
|
||
[
|
||
'relation' => 'AND',
|
||
['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => ['section']],
|
||
['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'],
|
||
['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => ['Kopfbereiche']],
|
||
],
|
||
$captured['tax_query']
|
||
);
|
||
}
|
||
|
||
public function testReportsMissingTaxonomies(): void
|
||
{
|
||
Functions\when('taxonomy_exists')->alias(static fn (string $taxonomy): bool => $taxonomy !== 'scope');
|
||
|
||
self::assertSame(['scope'], (new DiviLibraryStore())->missingTaxonomies());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/DiviCompatibilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi;
|
||
|
||
use LIT\MCP\Integrations\Divi\DiviCompatibility;
|
||
use PHPUnit\Framework\TestCase;
|
||
use RuntimeException;
|
||
|
||
final class DiviCompatibilityTest extends TestCase
|
||
{
|
||
public function testListsFailedAndThrowingChecks(): void
|
||
{
|
||
$compatibility = new DiviCompatibility([
|
||
'A' => static fn (): bool => true,
|
||
'B' => static fn (): bool => false,
|
||
'C' => static function (): bool {
|
||
throw new RuntimeException('Divi changed');
|
||
},
|
||
]);
|
||
|
||
self::assertSame(['B', 'C'], $compatibility->problems());
|
||
}
|
||
|
||
public function testDefaultsCoverTheDiviInternalsInUse(): void
|
||
{
|
||
self::assertSame(
|
||
[
|
||
'ModuleRegistration (Modul-Katalog)',
|
||
'DynamicContentOptions (Dynamic Content)',
|
||
'et_pb_is_allowed (Divi-Rollen)',
|
||
'Post-Typ et_pb_layout (Bibliothek)',
|
||
'Taxonomien scope, layout_type, module_width (Bibliothek)',
|
||
'GlobalLayout::is_global_layout_template (globale Module)',
|
||
'ET_Core_PageResource (CSS-Cache)',
|
||
],
|
||
array_keys(DiviCompatibility::defaults())
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Core/EnvironmentProbeTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Core;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Core\EnvironmentProbe;
|
||
use LIT\MCP\Integrations\Divi\DiviCompatibility;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class EnvironmentProbeTest extends TestCase
|
||
{
|
||
public function testDiviComponentCarriesTheCompatibilityFindings(): void
|
||
{
|
||
$theme = Mockery::mock();
|
||
$theme->allows('get_template')->andReturn('Divi');
|
||
$theme->allows('parent')->andReturn(false);
|
||
$theme->allows('get')->with('Version')->andReturn('5.2.1');
|
||
Functions\when('wp_get_theme')->justReturn($theme);
|
||
|
||
$probe = new EnvironmentProbe(new DiviCompatibility(['GlobalLayout' => static fn (): bool => false]));
|
||
$components = array_column($probe->components(), null, 'id');
|
||
|
||
self::assertSame(['GlobalLayout'], $components['divi']['problems']);
|
||
self::assertSame([], $components['pods']['problems']);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DiviRolesTest|DiviLibraryStoreTest|DiviCompatibilityTest|EnvironmentProbeTest'`
|
||
Expected: FAIL (Klassen fehlen)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Library/DiviRoles.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter for Divi's role editor (E3, E15): et_pb_is_allowed() (Divi 5.2.1 builder/core.php:1128)
|
||
* answers for the current user's role, "dangerous" options (theme_builder,
|
||
* read_dynamic_content_custom_fields) default to off for every role but administrator. When
|
||
* Divi no longer has the function, nothing is allowed – DiviCompatibility reports it.
|
||
* Not final so tests can pass a check.
|
||
*/
|
||
class DiviRoles
|
||
{
|
||
private const LABELS = [
|
||
'divi_library' => 'Divi-Bibliothek',
|
||
'save_library' => 'In der Bibliothek speichern',
|
||
'add_library' => 'Aus der Bibliothek hinzufügen',
|
||
'edit_global_library' => 'Globale Bibliothekselemente bearbeiten',
|
||
'read_dynamic_content_custom_fields' => 'Dynamic Content: benutzerdefinierte Felder lesen',
|
||
];
|
||
|
||
/** @param (Closure(string): bool)|null $check Test seam; null = Divi's et_pb_is_allowed(). */
|
||
public function __construct(private ?Closure $check = null)
|
||
{
|
||
}
|
||
|
||
public function allows(string $capability): bool
|
||
{
|
||
if ($this->check !== null) {
|
||
return ($this->check)($capability);
|
||
}
|
||
|
||
return function_exists('et_pb_is_allowed') && (bool) et_pb_is_allowed($capability);
|
||
}
|
||
|
||
/** Null when the role allows $capability, else a forbidden error naming the option. */
|
||
public function refusal(string $capability): ?WP_Error
|
||
{
|
||
if ($this->allows($capability)) {
|
||
return null;
|
||
}
|
||
|
||
return Errors::forbidden(
|
||
sprintf('Die Divi-Rolle dieses Kontos erlaubt „%s“ nicht.', self::LABELS[$capability] ?? $capability),
|
||
'Einen Administrator bitten, das Recht unter Divi → Rollen-Editor freizugeben.'
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Library/DiviLibraryStore.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Adapter for the Divi Library (E4, E15): post type et_pb_layout, the taxonomies scope,
|
||
* layout_type, module_width, layout_category, layout_tag and the meta Divi's et_pb_submit_layout()
|
||
* writes (F1, F2, fixtures stage3). "Global" is Divi's own definition
|
||
* GlobalLayout::is_global_layout_template() (F5) with the scope term as fallback when the class is
|
||
* gone. Library entries are always "publish" (F2). Not final so abilities can be tested with a mock.
|
||
*/
|
||
class DiviLibraryStore
|
||
{
|
||
public const POST_TYPE = 'et_pb_layout';
|
||
|
||
public const GLOBAL_LAYOUT = 'ET\Builder\Packages\GlobalLayout\GlobalLayout';
|
||
|
||
public const LAYOUT_TYPES = ['layout', 'section', 'row', 'module'];
|
||
|
||
/** Taxonomies every new entry needs (et_pb_submit_layout). */
|
||
public const TAXONOMIES = ['scope', 'layout_type', 'module_width'];
|
||
|
||
public function isGlobal(int $id): bool
|
||
{
|
||
if (method_exists(self::GLOBAL_LAYOUT, 'is_global_layout_template')) {
|
||
return (bool) call_user_func([self::GLOBAL_LAYOUT, 'is_global_layout_template'], $id);
|
||
}
|
||
|
||
return in_array('global', $this->termSlugs($id, 'scope'), true);
|
||
}
|
||
|
||
/** "layout", "section", "row" or "module"; null when the entry has no known type. */
|
||
public function layoutType(int $id): ?string
|
||
{
|
||
foreach ($this->termSlugs($id, 'layout_type') as $slug) {
|
||
if (in_array($slug, self::LAYOUT_TYPES, true)) {
|
||
return $slug;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function termSlugs(int $id, string $taxonomy): array
|
||
{
|
||
return array_map(static fn (WP_Term $term): string => (string) $term->slug, $this->terms($id, $taxonomy));
|
||
}
|
||
|
||
/** @return list<string> */
|
||
public function termNames(int $id, string $taxonomy): array
|
||
{
|
||
return array_map(static fn (WP_Term $term): string => (string) $term->name, $this->terms($id, $taxonomy));
|
||
}
|
||
|
||
/**
|
||
* Published entries, $limit + 1 of them (Cursor::page() tells whether there are more).
|
||
*
|
||
* @param array{layout_type?: string, scope?: string, search?: string, category?: string} $filters
|
||
* scope: "global" or "normal"; category: name of a layout_category term.
|
||
* @return list<WP_Post>
|
||
*/
|
||
public function find(array $filters, int $limit, int $offset): array
|
||
{
|
||
$taxQuery = [];
|
||
if (in_array($filters['layout_type'] ?? '', self::LAYOUT_TYPES, true)) {
|
||
$taxQuery[] = ['taxonomy' => 'layout_type', 'field' => 'slug', 'terms' => [$filters['layout_type']]];
|
||
}
|
||
if (($filters['scope'] ?? '') === 'global') {
|
||
$taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global']];
|
||
} elseif (($filters['scope'] ?? '') === 'normal') {
|
||
$taxQuery[] = ['taxonomy' => 'scope', 'field' => 'slug', 'terms' => ['global'], 'operator' => 'NOT IN'];
|
||
}
|
||
if (($filters['category'] ?? '') !== '') {
|
||
$taxQuery[] = ['taxonomy' => 'layout_category', 'field' => 'name', 'terms' => [$filters['category']]];
|
||
}
|
||
|
||
$args = [
|
||
'post_type' => self::POST_TYPE,
|
||
'post_status' => 'publish',
|
||
'posts_per_page' => $limit + 1,
|
||
'offset' => $offset,
|
||
'orderby' => 'title',
|
||
'order' => 'ASC',
|
||
'no_found_rows' => true,
|
||
'suppress_filters' => false,
|
||
];
|
||
if (($filters['search'] ?? '') !== '') {
|
||
$args['s'] = $filters['search'];
|
||
}
|
||
if ($taxQuery !== []) {
|
||
// phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- the library is small.
|
||
$args['tax_query'] = array_merge(['relation' => 'AND'], $taxQuery);
|
||
}
|
||
|
||
return array_values(array_filter(get_posts($args), static fn (mixed $post): bool => $post instanceof WP_Post));
|
||
}
|
||
|
||
/**
|
||
* @return array{id: int, title: string, layout_type: ?string, scope: string, module_type: ?string,
|
||
* categories: list<string>, tags: list<string>, modified: string}
|
||
*/
|
||
public function describe(WP_Post $post): array
|
||
{
|
||
$id = (int) $post->ID;
|
||
$moduleType = (string) get_post_meta($id, '_et_pb_module_type', true);
|
||
|
||
return [
|
||
'id' => $id,
|
||
'title' => (string) $post->post_title,
|
||
'layout_type' => $this->layoutType($id),
|
||
'scope' => $this->isGlobal($id) ? 'global' : 'normal',
|
||
'module_type' => $moduleType === '' ? null : $moduleType,
|
||
'categories' => $this->termNames($id, 'layout_category'),
|
||
'tags' => $this->termNames($id, 'layout_tag'),
|
||
'modified' => (string) $post->post_modified_gmt,
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Library taxonomies not registered in this request (F2: Divi registers them in
|
||
* ET_Builder_Library; fixture finding 6: not in every context).
|
||
*
|
||
* @return list<string>
|
||
*/
|
||
public function missingTaxonomies(): array
|
||
{
|
||
return array_values(array_filter(
|
||
self::TAXONOMIES,
|
||
static fn (string $taxonomy): bool => ! taxonomy_exists($taxonomy)
|
||
));
|
||
}
|
||
|
||
/** @return list<WP_Term> */
|
||
private function terms(int $id, string $taxonomy): array
|
||
{
|
||
$terms = get_the_terms($id, $taxonomy);
|
||
|
||
return is_array($terms)
|
||
? array_values(array_filter($terms, static fn (mixed $term): bool => $term instanceof WP_Term))
|
||
: [];
|
||
}
|
||
}
|
||
```
|
||
|
||
`DynamicContentMap`: `private const REGISTRY` → `public const REGISTRY` (Docblock: „also checked by DiviCompatibility“).
|
||
|
||
`src/Integrations/Divi/DiviCompatibility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi;
|
||
|
||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use Throwable;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Live check of the Divi internals LIT MCP relies on (decisions note §5, E15): classes,
|
||
* functions, the library post type and taxonomies. lit-site-status shows the failed checks, so a
|
||
* Divi update that moves one of them is visible before a tool reports "unsupported".
|
||
*/
|
||
final class DiviCompatibility
|
||
{
|
||
/** @param array<string, callable(): bool>|null $checks Label => check; null = defaults(). */
|
||
public function __construct(private ?array $checks = null)
|
||
{
|
||
}
|
||
|
||
/** @return list<string> Labels of the checks that failed or threw. */
|
||
public function problems(): array
|
||
{
|
||
$problems = [];
|
||
foreach ($this->checks ?? self::defaults() as $label => $check) {
|
||
try {
|
||
$ok = (bool) $check();
|
||
} catch (Throwable) {
|
||
$ok = false;
|
||
}
|
||
if (! $ok) {
|
||
$problems[] = (string) $label;
|
||
}
|
||
}
|
||
|
||
return $problems;
|
||
}
|
||
|
||
/** @return array<string, callable(): bool> */
|
||
public static function defaults(): array
|
||
{
|
||
return [
|
||
'ModuleRegistration (Modul-Katalog)' => static fn (): bool => DiviModules::isAvailable(),
|
||
'DynamicContentOptions (Dynamic Content)' => static fn (): bool => class_exists(DynamicContentMap::REGISTRY),
|
||
'et_pb_is_allowed (Divi-Rollen)' => static fn (): bool => function_exists('et_pb_is_allowed'),
|
||
'Post-Typ et_pb_layout (Bibliothek)' => static fn (): bool => post_type_exists(DiviLibraryStore::POST_TYPE),
|
||
'Taxonomien scope, layout_type, module_width (Bibliothek)' =>
|
||
static fn (): bool => (new DiviLibraryStore())->missingTaxonomies() === [],
|
||
'GlobalLayout::is_global_layout_template (globale Module)' =>
|
||
static fn (): bool => method_exists(DiviLibraryStore::GLOBAL_LAYOUT, 'is_global_layout_template'),
|
||
'ET_Core_PageResource (CSS-Cache)' => static fn (): bool => class_exists('ET_Core_PageResource'),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`EnvironmentProbe::components()` vollständig ersetzen (Konstruktor ergänzen, Import `use LIT\MCP\Integrations\Divi\DiviCompatibility;`):
|
||
|
||
```php
|
||
public function __construct(private ?DiviCompatibility $divi = null)
|
||
{
|
||
}
|
||
|
||
/** @return list<array{id: string, active: bool, version: ?string, problems: list<string>}> */
|
||
public function components(): array
|
||
{
|
||
$theme = wp_get_theme();
|
||
$isDivi = $theme->get_template() === 'Divi';
|
||
$parent = $theme->parent();
|
||
$diviVersion = $isDivi ? (string) ($parent ? $parent->get('Version') : $theme->get('Version')) : null;
|
||
|
||
$tecActive = class_exists('Tribe__Events__Main');
|
||
|
||
return [
|
||
[
|
||
'id' => 'divi',
|
||
'active' => $isDivi,
|
||
'version' => $diviVersion,
|
||
'problems' => $isDivi ? ($this->divi ?? new DiviCompatibility())->problems() : [],
|
||
],
|
||
[
|
||
'id' => 'pods',
|
||
'active' => defined('PODS_VERSION'),
|
||
'version' => defined('PODS_VERSION') ? (string) PODS_VERSION : null,
|
||
'problems' => [],
|
||
],
|
||
[
|
||
'id' => 'the-events-calendar',
|
||
'active' => $tecActive,
|
||
'version' => $tecActive ? (string) constant('Tribe__Events__Main::VERSION') : null,
|
||
'problems' => [],
|
||
],
|
||
];
|
||
}
|
||
```
|
||
|
||
In `SiteStatusAbility::description()` den Satz „Bei Divi nennt problems Divi-Funktionen, die nach einem Update fehlen.“ ergänzen.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DiviRolesTest|DiviLibraryStoreTest|DiviCompatibilityTest|EnvironmentProbeTest|SiteStatusAbilityTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library/DiviRoles.php src/Integrations/Divi/Library/DiviLibraryStore.php src/Integrations/Divi/DiviCompatibility.php src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Core/EnvironmentProbe.php src/Integrations/Core/SiteStatusAbility.php tests/Unit/Integrations/Divi/Library tests/Unit/Integrations/Divi/DiviCompatibilityTest.php tests/Unit/Integrations/Core/EnvironmentProbeTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: Divi adapters for roles and library with a live compatibility check`.
|
||
|
||
---
|
||
|
||
### Task 7: Bausteine globaler Module im BlockTree – `copyAs`, `GlobalInstance`, `LibraryContent`
|
||
|
||
Rein PHP, gegen die echten Builder-Fixtures. Befunde aus `tests/Fixtures/divi/stage3/`: (a) eine globale Instanz ist `divi/global-layout` mit `globalModule`, `blockName`, `localAttrs` (vollständiger Schnappschuss der Wurzel-Attribute, oberste Schlüssel sortiert – im Fixture `builderVersion, content, module` gegenüber `module, content, builderVersion` im Bibliothekseintrag), die Kinder mit ihren echten Blocknamen und `"globalParent":"<id>"` am Ende; eine Instanz ohne Kinder ist selbstschließend. (b) **Neu:** Bibliothekseinträge, die über Divis Dialog „Zur Bibliothek hinzufügen“ gespeichert wurden, haben zwischen den Blöcken `\r\n` (Formular-Übertragung des Browsers), Seiten `\n`; der Builder schreibt Instanzen mit `\n`. Deshalb normalisiert `LibraryContent::load()` Zeilenumbrüche im reinen Leerraum zwischen Blöcken. Mit beidem entsteht die Instanz byte-genau wie im Fixture (vorab per Skript geprüft).
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/BlockTree/Block.php` (`copyAs()`)
|
||
- Modify: `src/Integrations/Divi/BlockTree/Container.php` (`replaceChild()`, `unifyLineBreaks()`)
|
||
- Create: `src/Integrations/Divi/BlockTree/GlobalInstance.php`
|
||
- Create: `src/Integrations/Divi/BlockTree/LibraryContent.php`
|
||
- Modify: `src/Integrations/Divi/Compose/TreeEditor.php` (Wurzeltyp globaler Instanzen)
|
||
- Modify: `tests/Fixtures/divi/stage3/README.md` (Befund 9)
|
||
- Test: `tests/Unit/Integrations/Divi/BlockTree/BlockTest.php`, `…/ContainerTest.php`, `…/GlobalInstanceTest.php`, `…/LibraryContentTest.php`, `tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `Parser::parse()`, `Serializer::block()`, `NodeIndex::build()`, `DiviFixtures::markup()`.
|
||
- Produces:
|
||
- `Block::copyAs(string $name, array $attrs): Block`
|
||
- `Container::replaceChild(Block $old, Block $new): void`, `Container::unifyLineBreaks(): void`
|
||
- `GlobalInstance::BLOCK = 'divi/global-layout'`, `GlobalInstance::effectiveName(Block): string`, `GlobalInstance::of(Block $root, int $libraryId): Block`, `GlobalInstance::deglobalize(Block $block): Block`
|
||
- `LibraryContent::load(Parser $parser, string $content): Document` (wirft `MalformedMarkup`), `LibraryContent::wrap(Block $node): string`, `LibraryContent::roots(Document $document): list<Block>`, `LibraryContent::containsGlobal(Block $block): bool`, `LibraryContent::shapeViolation(list<Block> $before, list<Block> $after, ?string $layoutType, bool $global): ?string`
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
In `BlockTest`:
|
||
|
||
```php
|
||
public function testCopyAsRegeneratesTheDelimitersAndKeepsChildrenAndText(): void
|
||
{
|
||
$document = (new Parser())->parse("<!-- wp:divi/row {\"a\":1} -->\n<!-- wp:divi/column /-->\n<!-- /wp:divi/row -->");
|
||
$row = $document->children()[0];
|
||
|
||
$copy = $row->copyAs('divi/global-layout', ['globalModule' => '5', 'blockName' => 'divi/row']);
|
||
|
||
self::assertSame(
|
||
"<!-- wp:divi/global-layout {\"globalModule\":\"5\",\"blockName\":\"divi/row\"} -->\n<!-- wp:divi/column /-->\n"
|
||
. '<!-- /wp:divi/global-layout -->',
|
||
Serializer::block($copy)
|
||
);
|
||
self::assertTrue($copy->isOpaque());
|
||
self::assertSame("<!-- wp:divi/row {\"a\":1} -->\n<!-- wp:divi/column /-->\n<!-- /wp:divi/row -->", Serializer::block($row));
|
||
self::assertFalse($row->copyAs('divi/row', [])->isOpaque());
|
||
}
|
||
|
||
public function testCopyAsRefusesInvalidNames(): void
|
||
{
|
||
$this->expectException(InvalidArgumentException::class);
|
||
|
||
Block::create('divi/text')->copyAs('Divi Text', []);
|
||
}
|
||
```
|
||
|
||
In `ContainerTest`:
|
||
|
||
```php
|
||
public function testReplaceChildKeepsTheTextAround(): void
|
||
{
|
||
$document = (new Parser())->parse("A<!-- wp:divi/text /-->B");
|
||
$new = Block::create('divi/button');
|
||
|
||
$document->replaceChild($document->children()[0], $new);
|
||
|
||
self::assertSame('A<!-- wp:divi/button --><!-- /wp:divi/button -->B', Serializer::document($document));
|
||
}
|
||
|
||
public function testUnifyLineBreaksOnlyTouchesWhitespaceBetweenBlocks(): void
|
||
{
|
||
$document = (new Parser())->parse(
|
||
"<!-- wp:divi/section -->\r\n<!-- wp:divi/row -->\r\n<!-- /wp:divi/row -->\r\n<!-- /wp:divi/section -->x\r\ny"
|
||
);
|
||
|
||
$document->unifyLineBreaks();
|
||
|
||
self::assertSame(
|
||
"<!-- wp:divi/section -->\n<!-- wp:divi/row -->\n<!-- /wp:divi/row -->\n<!-- /wp:divi/section -->x\r\ny",
|
||
Serializer::document($document)
|
||
);
|
||
}
|
||
```
|
||
|
||
(Imports `Block`, `Parser`, `Serializer`, `InvalidArgumentException` je nach Datei ergänzen.)
|
||
|
||
`tests/Unit/Integrations/Divi/BlockTree/GlobalInstanceTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class GlobalInstanceTest extends TestCase
|
||
{
|
||
private static function libraryRoot(string $file): Block
|
||
{
|
||
return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0];
|
||
}
|
||
|
||
private static function pageIndex(): NodeIndex
|
||
{
|
||
return NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
|
||
}
|
||
|
||
/** @return array<string, array{string, int, string}> */
|
||
public static function instances(): array
|
||
{
|
||
return [
|
||
'global section' => ['library-global--globaler-abschnitt.html', 1058, '<!-- /wp:divi/global-layout -->'],
|
||
'global module (void)' => ['library-global--globales-modul.html', 1059, '/-->'],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider instances */
|
||
public function testInstanceMatchesTheVisualBuilderByteForByte(string $file, int $id, string $end): void
|
||
{
|
||
$page = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||
$start = (int) strpos($page, '<!-- wp:divi/global-layout {"globalModule":"' . $id . '"');
|
||
$expected = substr($page, $start, (int) strpos($page, $end, $start) + strlen($end) - $start);
|
||
|
||
$instance = GlobalInstance::of(self::libraryRoot($file), $id);
|
||
|
||
self::assertSame($expected, Serializer::block($instance));
|
||
self::assertTrue($instance->isOpaque());
|
||
self::assertTrue($instance->isGlobal());
|
||
}
|
||
|
||
public function testEffectiveNameOfAnInstanceIsItsRootType(): void
|
||
{
|
||
$index = self::pageIndex();
|
||
|
||
self::assertSame('divi/section', GlobalInstance::effectiveName($index->find('g0')));
|
||
self::assertSame('divi/text', GlobalInstance::effectiveName($index->find('s0.r0.c0.g0')));
|
||
self::assertSame('divi/section', GlobalInstance::effectiveName($index->find('s0')));
|
||
}
|
||
|
||
public function testDeglobalizeRestoresTheLibraryRoot(): void
|
||
{
|
||
$root = self::libraryRoot('library-global--globaler-abschnitt.html');
|
||
|
||
$plain = GlobalInstance::deglobalize(self::pageIndex()->find('g0')->copy());
|
||
|
||
self::assertSame('divi/section', $plain->name());
|
||
self::assertFalse($plain->isOpaque());
|
||
self::assertEquals($root->attrs(), $plain->attrs());
|
||
self::assertSame(
|
||
array_map([Serializer::class, 'block'], $root->children()),
|
||
array_map([Serializer::class, 'block'], $plain->children())
|
||
);
|
||
self::assertStringNotContainsString('globalParent', Serializer::block($plain));
|
||
}
|
||
|
||
public function testDeglobalizeReachesNestedInstances(): void
|
||
{
|
||
$section = GlobalInstance::deglobalize(self::pageIndex()->find('s0')->copy());
|
||
|
||
$markup = Serializer::block($section);
|
||
self::assertStringNotContainsString('global-layout', $markup);
|
||
self::assertStringContainsString('<!-- wp:divi/text {"builderVersion":"5.2.1","content":', $markup);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/BlockTree/LibraryContentTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\BlockTree;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class LibraryContentTest extends TestCase
|
||
{
|
||
/** @return array<string, array{string, string}> */
|
||
public static function savedNodes(): array
|
||
{
|
||
return [
|
||
'section' => ['s1', 'library--library-abschnitt.html'],
|
||
'row' => ['s1.r0', 'library--library-zeile.html'],
|
||
'module' => ['s1.r0.c0.m0', 'library--library-modul.html'],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* The builder's "add to library" wraps the node in divi/placeholder (F4); the fixtures only
|
||
* differ by the CRLF of the form upload (finding 9).
|
||
*
|
||
* @dataProvider savedNodes
|
||
*/
|
||
public function testWrapMatchesTheBuilderEntry(string $id, string $file): void
|
||
{
|
||
$index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
|
||
|
||
self::assertSame(
|
||
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/' . $file)),
|
||
LibraryContent::wrap($index->find($id))
|
||
);
|
||
}
|
||
|
||
public function testLoadUnifiesLineBreaksAndRootsSkipThePlaceholder(): void
|
||
{
|
||
$document = LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/library--layout-ganze-seite.html'));
|
||
|
||
self::assertStringNotContainsString("\r", Serializer::document($document));
|
||
self::assertSame(
|
||
['divi/section', 'divi/section', 'divi/section'],
|
||
array_map(static fn (Block $block): string => $block->name(), LibraryContent::roots($document))
|
||
);
|
||
self::assertSame(
|
||
['divi/text'],
|
||
array_map(
|
||
static fn (Block $block): string => $block->name(),
|
||
LibraryContent::roots((new Parser())->parse('<!-- wp:divi/text /-->'))
|
||
)
|
||
);
|
||
}
|
||
|
||
public function testContainsGlobal(): void
|
||
{
|
||
$index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
|
||
|
||
self::assertTrue(LibraryContent::containsGlobal($index->find('s0')));
|
||
self::assertFalse(LibraryContent::containsGlobal($index->find('s1')));
|
||
}
|
||
|
||
public function testShapeViolations(): void
|
||
{
|
||
$section = Block::create('divi/section');
|
||
$row = Block::create('divi/row');
|
||
$instance = GlobalInstance::of(Block::create('divi/text'), 9);
|
||
$withInstance = Block::create('divi/section', [], [Block::create('divi/row', [], [
|
||
Block::create('divi/column', [], [$instance]),
|
||
])]);
|
||
|
||
self::assertNull(LibraryContent::shapeViolation([$section], [$section], 'section', false));
|
||
self::assertStringContainsString('nicht leer', (string) LibraryContent::shapeViolation([$section], [], 'section', false));
|
||
self::assertStringContainsString(
|
||
'genau ein divi/section',
|
||
(string) LibraryContent::shapeViolation([$section], [$section, $section], 'section', false)
|
||
);
|
||
self::assertStringContainsString(
|
||
'genau ein divi/section',
|
||
(string) LibraryContent::shapeViolation([$section], [$row], null, false)
|
||
);
|
||
self::assertNull(LibraryContent::shapeViolation([$section, $section], [$section], 'layout', false));
|
||
self::assertStringContainsString(
|
||
'nur Abschnitte',
|
||
(string) LibraryContent::shapeViolation([$section, $section], [$section, $row], 'layout', false)
|
||
);
|
||
self::assertNull(LibraryContent::shapeViolation([$section], [$withInstance], 'section', false));
|
||
self::assertStringContainsString(
|
||
'keine globalen Module',
|
||
(string) LibraryContent::shapeViolation([$section], [$withInstance], 'section', true)
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `TreeEditorTest`:
|
||
|
||
```php
|
||
/** A global instance counts as its root type: a section instance at the top, a module instance in a column. */
|
||
public function testGlobalInstancesFollowTheRulesOfTheirRootType(): void
|
||
{
|
||
$document = $this->document('stage3/page--globale-instanzen.html');
|
||
$index = NodeIndex::build($document);
|
||
$editor = new TreeEditor(ComposeFixture::rules());
|
||
|
||
self::assertNull($editor->move($index, $index->find('g0'), $editor->position($index, ['after' => 's1'])));
|
||
|
||
$index = NodeIndex::build($document);
|
||
$module = GlobalInstance::of(Block::create('divi/text'), 1059);
|
||
self::assertNull($editor->insert($editor->position($index, ['parent' => 's1.r0.c0']), [$module]));
|
||
self::assertSame(
|
||
'auf oberster Ebene sind nur Abschnitte (section) erlaubt',
|
||
$editor->insert($editor->position($index, []), [GlobalInstance::of(Block::create('divi/text'), 1059)])
|
||
);
|
||
}
|
||
```
|
||
|
||
(Import `use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;`.)
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'BlockTest|ContainerTest|GlobalInstanceTest|LibraryContentTest|TreeEditorTest'`
|
||
Expected: FAIL (Methoden/Klassen fehlen)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`Block.php` – neue Methode nach `copy()`:
|
||
|
||
```php
|
||
/**
|
||
* A deep copy of this block under another name and attributes: opener and closer are
|
||
* regenerated, children and the text between them are kept, the void flag too. Opaque exactly
|
||
* when the Parser would make it so. GlobalInstance uses it to turn a library root into an
|
||
* instance and back.
|
||
*
|
||
* @param array<string, mixed> $attrs
|
||
*/
|
||
public function copyAs(string $name, array $attrs): self
|
||
{
|
||
if (preg_match(self::NAME_PATTERN, $name) !== 1) {
|
||
throw new InvalidArgumentException(sprintf('"%s" is not a valid block name.', $name));
|
||
}
|
||
|
||
$copy = clone $this;
|
||
$copy->name = $name;
|
||
$copy->attrs = self::deepCloneAttrs($attrs);
|
||
$copy->rawOpen = null;
|
||
$copy->rawClose = null;
|
||
$copy->opaque = $name === 'divi/global-layout' || array_key_exists('globalModule', $attrs);
|
||
|
||
return $copy;
|
||
}
|
||
```
|
||
|
||
`Container.php` – neue Methoden nach `removeChild()`:
|
||
|
||
```php
|
||
/** Puts $new where the child $old is; the text around it stays. */
|
||
public function replaceChild(Block $old, Block $new): void
|
||
{
|
||
$this->guardMutable();
|
||
$this->parts[$this->partPosition($old)] = $new;
|
||
$this->touched();
|
||
}
|
||
|
||
/**
|
||
* "\r\n" → "\n" in whitespace-only text between blocks, at every depth. Library entries saved
|
||
* through Divi's "add to library" form carry CRLF (browsers normalise form fields, fixture
|
||
* finding 9); the Visual Builder writes "\n". Text inside blocks' own content stays untouched.
|
||
*/
|
||
public function unifyLineBreaks(): void
|
||
{
|
||
foreach ($this->parts as $position => $part) {
|
||
if ($part instanceof Block) {
|
||
$part->unifyLineBreaks();
|
||
} elseif (trim($part) === '' && str_contains($part, "\r\n")) {
|
||
$this->parts[$position] = str_replace("\r\n", "\n", $part);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/BlockTree/GlobalInstance.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Global modules the way Divi 5 stores them (F6, F10, fixtures stage3): an instance on a page is a
|
||
* divi/global-layout block whose content Divi takes from the library entry at render time (F7).
|
||
* Pure PHP.
|
||
*/
|
||
final class GlobalInstance
|
||
{
|
||
public const BLOCK = 'divi/global-layout';
|
||
|
||
/** Block name the node counts as for structure rules: an instance counts as its root type. */
|
||
public static function effectiveName(Block $block): string
|
||
{
|
||
if ($block->name() !== self::BLOCK) {
|
||
return $block->name();
|
||
}
|
||
|
||
$name = $block->attrs()['blockName'] ?? '';
|
||
|
||
return is_string($name) ? $name : '';
|
||
}
|
||
|
||
/**
|
||
* The instance the Visual Builder writes for library entry $libraryId whose content root is
|
||
* $root (serialized-post.js, filter divi.serializedPost.parseFlatModuleObjectIntoBlockArray.nested;
|
||
* fixture page--globale-instanzen.html): divi/global-layout with globalModule, blockName and
|
||
* localAttrs – the root's attributes, top-level keys sorted like the builder's store, left out
|
||
* when empty –, the root's children as snapshot with "globalParent": "<id>" appended to every
|
||
* block below. A void root gives a void instance. $root is not changed.
|
||
*/
|
||
public static function of(Block $root, int $libraryId): Block
|
||
{
|
||
$id = (string) $libraryId;
|
||
$local = $root->attrs();
|
||
ksort($local);
|
||
$attrs = ['globalModule' => $id, 'blockName' => $root->name()];
|
||
if ($local !== []) {
|
||
$attrs['localAttrs'] = $local;
|
||
}
|
||
|
||
$instance = $root->copyAs(self::BLOCK, $attrs);
|
||
foreach ($instance->children() as $child) {
|
||
self::markParent($child, $id);
|
||
}
|
||
|
||
return $instance;
|
||
}
|
||
|
||
/**
|
||
* Divi's PortabilityPost::maybe_deglobalize_nested_global_modules() on a tree (Divi 5.2.1
|
||
* PortabilityPost.php:3518, used by DiviLibraryController::load for global entries): every
|
||
* divi/global-layout at or below $block becomes its blockName with localAttrs as attributes,
|
||
* and globalParent disappears from every block. Other opaque blocks stay. Changes $block in
|
||
* place (pass a copy) and returns the result – a new block when $block itself was an instance.
|
||
*/
|
||
public static function deglobalize(Block $block): Block
|
||
{
|
||
if ($block->name() === self::BLOCK) {
|
||
$name = self::effectiveName($block);
|
||
if ($name === '') {
|
||
return $block;
|
||
}
|
||
$local = $block->attrs()['localAttrs'] ?? [];
|
||
$block = $block->copyAs($name, is_array($local) ? $local : []);
|
||
}
|
||
|
||
if ($block->isOpaque()) {
|
||
return $block;
|
||
}
|
||
|
||
if (array_key_exists('globalParent', $block->attrs())) {
|
||
$attrs = $block->attrs();
|
||
unset($attrs['globalParent']);
|
||
$block->setAttrs($attrs);
|
||
}
|
||
|
||
foreach ($block->children() as $child) {
|
||
$plain = self::deglobalize($child);
|
||
if ($plain !== $child) {
|
||
$block->replaceChild($child, $plain);
|
||
}
|
||
}
|
||
|
||
return $block;
|
||
}
|
||
|
||
private static function markParent(Block $block, string $id): void
|
||
{
|
||
if ($block->isOpaque()) {
|
||
return;
|
||
}
|
||
|
||
$attrs = $block->attrs();
|
||
unset($attrs['globalParent']);
|
||
$attrs['globalParent'] = $id;
|
||
$block->setAttrs($attrs);
|
||
foreach ($block->children() as $child) {
|
||
self::markParent($child, $id);
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/BlockTree/LibraryContent.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\BlockTree;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* post_content of Divi Library entries (F4, fixtures stage3): the nodes sit in a divi/placeholder
|
||
* frame; a section/row/module entry has exactly one root, a layout entry sections. Pure PHP.
|
||
*/
|
||
final class LibraryContent
|
||
{
|
||
public const WRAPPER = 'divi/placeholder';
|
||
|
||
/**
|
||
* Parses library content for copying and unifies its line breaks (Container::unifyLineBreaks()).
|
||
* Never used for the content a tool edits in place – that keeps its bytes.
|
||
*
|
||
* @throws MalformedMarkup
|
||
*/
|
||
public static function load(Parser $parser, string $content): Document
|
||
{
|
||
$document = $parser->parse($content);
|
||
$document->unifyLineBreaks();
|
||
|
||
return $document;
|
||
}
|
||
|
||
/** post_content of a library entry holding $node, framed like the builder's "add to library". */
|
||
public static function wrap(Block $node): string
|
||
{
|
||
return '<!-- wp:' . self::WRAPPER . ' -->' . Serializer::block($node) . '<!-- /wp:' . self::WRAPPER . ' -->';
|
||
}
|
||
|
||
/** @return list<Block> Top-level nodes: the children of the first divi/placeholder, else of the document. */
|
||
public static function roots(Document $document): array
|
||
{
|
||
foreach ($document->children() as $block) {
|
||
if ($block->isPlaceholder()) {
|
||
return $block->children();
|
||
}
|
||
}
|
||
|
||
return $document->children();
|
||
}
|
||
|
||
/** Whether $block is or contains a global module. */
|
||
public static function containsGlobal(Block $block): bool
|
||
{
|
||
if ($block->isGlobal()) {
|
||
return true;
|
||
}
|
||
|
||
foreach ($block->children() as $child) {
|
||
if (self::containsGlobal($child)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Why the roots $after may not replace $before, or null. Instances on pages expect their
|
||
* entry's root type (blockName), so a section/row/module entry keeps exactly one root of the
|
||
* same type; a layout entry (or one with several roots) holds sections only. A global entry
|
||
* contains no global modules (F10: Divi forbids nesting).
|
||
*
|
||
* @param list<Block> $before
|
||
* @param list<Block> $after
|
||
*/
|
||
public static function shapeViolation(array $before, array $after, ?string $layoutType, bool $global): ?string
|
||
{
|
||
if ($after === []) {
|
||
return 'Der Bibliothekseintrag darf nicht leer werden.';
|
||
}
|
||
|
||
if ($layoutType === 'layout' || count($before) !== 1) {
|
||
foreach ($after as $root) {
|
||
if (GlobalInstance::effectiveName($root) !== 'divi/section') {
|
||
return 'Ein Layout-Eintrag der Bibliothek enthält nur Abschnitte.';
|
||
}
|
||
}
|
||
} else {
|
||
$expected = GlobalInstance::effectiveName($before[0]);
|
||
if (count($after) !== 1 || GlobalInstance::effectiveName($after[0]) !== $expected) {
|
||
return sprintf(
|
||
'Der Bibliothekseintrag muss genau ein %s bleiben (Instanzen auf den Seiten erwarten diesen Typ).',
|
||
$expected
|
||
);
|
||
}
|
||
}
|
||
|
||
if ($global) {
|
||
foreach ($after as $root) {
|
||
if (self::containsGlobal($root)) {
|
||
return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
|
||
. 'Verschachtelung).';
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
}
|
||
```
|
||
|
||
`TreeEditor.php` – Import `use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;`; in `insert()`:
|
||
|
||
```php
|
||
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($block));
|
||
```
|
||
|
||
und in `move()`:
|
||
|
||
```php
|
||
$violation = $this->rules->violation($position['parent'], GlobalInstance::effectiveName($node));
|
||
```
|
||
|
||
Klassen-Docblock von `TreeEditor` um „globale Instanzen gelten als ihr Wurzeltyp (blockName)“ ergänzen.
|
||
|
||
`tests/Fixtures/divi/stage3/README.md` – unter „Format-Befunde“ als Punkt 9 anfügen:
|
||
|
||
```markdown
|
||
9. **Zeilenumbrüche in Bibliothekseinträgen:** Einträge aus dem Dialog „Zur Bibliothek hinzufügen“ haben zwischen den Blöcken `\r\n` (1054–1056, 1058; der Browser normalisiert Formularfelder beim Senden an `divi-library/create-item`), Seiten und TB-Layouts `\n`. Der Builder schreibt globale Instanzen mit `\n`. `LibraryContent::load()` vereinheitlicht deshalb Zeilenumbrüche im Leerraum zwischen Blöcken, bevor Inhalte aus der Bibliothek übernommen werden; damit entstehen Instanzen byte-genau wie in `page--globale-instanzen.html`.
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'BlockTest|ContainerTest|GlobalInstanceTest|LibraryContentTest|TreeEditorTest|ParserTest|SerializerTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/BlockTree src/Integrations/Divi/Compose/TreeEditor.php tests/Unit/Integrations/Divi/BlockTree tests/Unit/Integrations/Divi/Compose/TreeEditorTest.php tests/Fixtures/divi/stage3/README.md
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: global instance in the builder's format, deglobalize, library content rules`.
|
||
|
||
---
|
||
|
||
### Task 8: Bibliothekseinträge mit den Divi-Werkzeugen lesen – `LibraryAccess`, `DiviPageLoader`
|
||
|
||
E1/E3: `lit-divi-outline` und `lit-divi-node-get` nehmen die ID eines Bibliothekseintrags an. `LibraryAccess::item()` prüft zuerst, ob die ID ein `et_pb_layout` ist; sonst geht es unverändert über `PostGuard` weiter (Seiten bleiben wie bisher, `ContentSchema::EXCLUDED` bleibt). Schreiben folgt in Task 9 – bis dahin laufen Schreibzugriffe auf Bibliotheks-IDs weiter über `PostGuard` und enden in `not_found`.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Library/LibraryAccess.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DiviPage.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`
|
||
- Create: `tests/Support/LibraryFixtures.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DiviLibraryStore`, `DiviRoles` (Task 6), `PostGuard::id()`, `Errors`.
|
||
- Produces:
|
||
- `class LibraryAccess { __construct(DiviLibraryStore $store, DiviRoles $roles, ?Closure $writable = null); store(): DiviLibraryStore; roles(): DiviRoles; item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null; canList(): ?WP_Error; canRead(WP_Post $item): ?WP_Error; isGlobal(WP_Post $item): bool }` (nicht final; `canEdit`, `canSave`, `canInsert` folgen in Task 9/12/13)
|
||
- `DiviPage::__construct(WP_Post $post, Document $document, NodeIndex $index, bool $libraryItem = false)`, `DiviPage::isLibraryItem(): bool`; `editUrl()` liefert für Bibliothekseinträge `get_edit_post_link($id, 'raw')`.
|
||
- `DiviPageLoader::__construct(ContentSchema $schema, Parser $parser = new Parser(), ?LibraryAccess $library = null)`, `DiviPageLoader::library(): ?LibraryAccess`.
|
||
- `LibraryFixtures` (Tests): `ITEMS`, `item(int $id, array $fields = []): WP_Post`, `page(string $content, array $fields = []): WP_Post`, `given(array $posts, array $denied = []): void`, `store(): DiviLibraryStore`, `roles(array $deniedRoles = []): DiviRoles`, `access(array $deniedRoles = [], bool $writable = true, ?DiviLibraryStore $store = null): LibraryAccess`, `loader(?LibraryAccess $access = null): DiviPageLoader`.
|
||
|
||
- [ ] **Step 1: Test-Helfer und failing tests schreiben**
|
||
|
||
`tests/Support/LibraryFixtures.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Support;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviLibraryStore;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryAccess;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
/**
|
||
* The Divi Library of the tests: the real builder entries of tests/Fixtures/divi/stage3 by their
|
||
* IDs, a page (post 7), WordPress stubs by post ID, a store answering from ITEMS, Divi roles and
|
||
* the library access built from them.
|
||
*/
|
||
final class LibraryFixtures
|
||
{
|
||
public const PAGE = 7;
|
||
|
||
/** @var array<int, array{string, string, bool}> ID => [fixture file, layout type, global]. */
|
||
public const ITEMS = [
|
||
1054 => ['library--layout-ganze-seite.html', 'layout', false],
|
||
1055 => ['library--library-abschnitt.html', 'section', false],
|
||
1056 => ['library--library-zeile.html', 'row', false],
|
||
1057 => ['library--library-modul.html', 'module', false],
|
||
1058 => ['library-global--globaler-abschnitt.html', 'section', true],
|
||
1059 => ['library-global--globales-modul.html', 'module', true],
|
||
];
|
||
|
||
/** @param array<string, mixed> $fields */
|
||
public static function item(int $id, array $fields = []): WP_Post
|
||
{
|
||
return new WP_Post(array_merge([
|
||
'ID' => $id,
|
||
'post_type' => DiviLibraryStore::POST_TYPE,
|
||
'post_status' => 'publish',
|
||
'post_title' => '[Fixture] ' . $id,
|
||
'post_content' => DiviFixtures::markup('stage3/' . self::ITEMS[$id][0]),
|
||
], $fields));
|
||
}
|
||
|
||
/** @param array<string, mixed> $fields */
|
||
public static function page(string $content, array $fields = []): WP_Post
|
||
{
|
||
return new WP_Post(array_merge([
|
||
'ID' => self::PAGE,
|
||
'post_type' => 'page',
|
||
'post_status' => 'draft',
|
||
'post_title' => 'Start',
|
||
'post_content' => $content,
|
||
], $fields));
|
||
}
|
||
|
||
/**
|
||
* WordPress stubs for $posts (get_post by ID) and a user with every capability but $denied.
|
||
*
|
||
* @param list<WP_Post> $posts
|
||
* @param list<string> $denied
|
||
*/
|
||
public static function given(array $posts, array $denied = []): void
|
||
{
|
||
$byId = [];
|
||
foreach ($posts as $post) {
|
||
$byId[(int) $post->ID] = $post;
|
||
}
|
||
Functions\when('get_post')->alias(
|
||
static fn (mixed $id): ?WP_Post => $byId[(int) ($id instanceof WP_Post ? $id->ID : $id)] ?? null
|
||
);
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap, mixed ...$args): bool => ! in_array($cap, $denied, true)
|
||
);
|
||
Functions\when('get_post_type_object')->justReturn(null);
|
||
Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7');
|
||
Functions\when('get_edit_post_link')->alias(static fn (mixed $post): string => sprintf(
|
||
'https://kunde.test/wp-admin/post.php?post=%d&action=edit',
|
||
$post instanceof WP_Post ? $post->ID : (int) $post
|
||
));
|
||
Functions\when('add_query_arg')->alias(
|
||
static fn (string $key, string $value, string $url): string => $url . '&' . $key . '=' . $value
|
||
);
|
||
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
|
||
}
|
||
|
||
/** A store that answers isGlobal()/layoutType() from ITEMS (no database). */
|
||
public static function store(): DiviLibraryStore
|
||
{
|
||
$store = Mockery::mock(DiviLibraryStore::class)->makePartial();
|
||
$store->allows('isGlobal')->andReturnUsing(static fn (int $id): bool => self::ITEMS[$id][2] ?? false);
|
||
$store->allows('layoutType')->andReturnUsing(static fn (int $id): ?string => self::ITEMS[$id][1] ?? null);
|
||
|
||
return $store;
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles Divi role options the user lacks. */
|
||
public static function roles(array $deniedRoles = []): DiviRoles
|
||
{
|
||
return new DiviRoles(static fn (string $cap): bool => ! in_array($cap, $deniedRoles, true));
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
public static function access(
|
||
array $deniedRoles = [],
|
||
bool $writable = true,
|
||
?DiviLibraryStore $store = null
|
||
): LibraryAccess {
|
||
return new LibraryAccess($store ?? self::store(), self::roles($deniedRoles), static fn (): bool => $writable);
|
||
}
|
||
|
||
public static function loader(?LibraryAccess $access = null): DiviPageLoader
|
||
{
|
||
return new DiviPageLoader(DiviFixtures::schema(), new Parser(), $access ?? self::access());
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
|
||
final class LibraryAccessTest extends TestCase
|
||
{
|
||
public function testReadsLibraryItemsAndLeavesOtherIdsToPostGuard(): void
|
||
{
|
||
$item = LibraryFixtures::item(1055);
|
||
LibraryFixtures::given([$item, LibraryFixtures::page('')]);
|
||
$access = LibraryFixtures::access();
|
||
|
||
self::assertSame($item, $access->item(1055, false));
|
||
self::assertNull($access->item(LibraryFixtures::PAGE, false), 'a page is PostGuard\'s business');
|
||
self::assertNull($access->item(9999, false));
|
||
self::assertNull($access->item('abc', false));
|
||
self::assertNull($access->canList());
|
||
}
|
||
|
||
public function testReadingNeedsEditPostsTheDiviLibraryRoleAndEditPost(): void
|
||
{
|
||
$item = LibraryFixtures::item(1055);
|
||
|
||
LibraryFixtures::given([$item], ['edit_posts']);
|
||
self::assertToolError(LibraryFixtures::access()->item(1055, false), 'forbidden');
|
||
|
||
LibraryFixtures::given([$item]);
|
||
self::assertToolError(LibraryFixtures::access(['divi_library'])->item(1055, false), 'forbidden');
|
||
|
||
LibraryFixtures::given([$item], ['edit_post']);
|
||
$refusal = LibraryFixtures::access()->item(1055, false);
|
||
self::assertToolError($refusal, 'forbidden');
|
||
self::assertStringContainsString('Bibliothekseintrag 1055', $refusal->get_error_message());
|
||
}
|
||
|
||
public function testTrashedItemsCanBeRead(): void
|
||
{
|
||
$item = LibraryFixtures::item(1055, ['post_status' => 'trash']);
|
||
LibraryFixtures::given([$item]);
|
||
|
||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1055, false));
|
||
}
|
||
|
||
public function testGlobalFromTheStore(): void
|
||
{
|
||
$access = LibraryFixtures::access();
|
||
|
||
self::assertTrue($access->isGlobal(LibraryFixtures::item(1058)));
|
||
self::assertFalse($access->isGlobal(LibraryFixtures::item(1055)));
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviPageLoaderTest`:
|
||
|
||
```php
|
||
public function testLoadsALibraryItemForReading(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1058)]);
|
||
|
||
$page = LibraryFixtures::loader()->load(1058, false);
|
||
|
||
self::assertInstanceOf(DiviPage::class, $page);
|
||
self::assertTrue($page->isLibraryItem());
|
||
self::assertSame('divi/section', $page->index->find('s0')?->name());
|
||
self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $page->editUrl());
|
||
}
|
||
|
||
public function testLibraryItemsNeedTheLibraryAccess(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1058)]);
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->with('et_pb_layout')->andReturn(false);
|
||
|
||
self::assertToolError((new DiviPageLoader($schema))->load(1058, false), 'not_found');
|
||
self::assertToolError(
|
||
(new DiviPageLoader($schema, new Parser(), LibraryFixtures::access(['divi_library'])))->load(1058, false),
|
||
'forbidden'
|
||
);
|
||
}
|
||
```
|
||
|
||
(Imports: `LibraryFixtures`, `ContentSchema`, `Parser`, `Mockery`.)
|
||
|
||
In `DiviReadAbilitiesTest`:
|
||
|
||
```php
|
||
public function testOutlineOfALibraryItem(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||
$ability = new DiviOutlineAbility(LibraryFixtures::loader(), $this->design());
|
||
|
||
$result = $ability->execute(['post_id' => 1059]);
|
||
|
||
self::assertSame(['m0'], array_column($result['nodes'], 'id'));
|
||
self::assertStringStartsWith('m0 text "Globales Modul Öffnungszeiten"', $result['outline']);
|
||
self::assertSame('https://kunde.test/wp-admin/post.php?post=1059&action=edit', $result['edit_url']);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|DiviPageLoaderTest|DiviReadAbilitiesTest'`
|
||
Expected: FAIL (`LibraryAccess` fehlt)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Library/LibraryAccess.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Rights on Divi Library entries (spec §4.5, E3 – never laxer than Divi): the library at all needs
|
||
* edit_posts and the Divi role option divi_library; one entry additionally edit_post on it (Divi's
|
||
* builder routes do not check that, F9). The Divi tools reach library entries only through
|
||
* item(); every other ID goes on to PostGuard unchanged.
|
||
* Not final so abilities can be tested with a mock.
|
||
*/
|
||
class LibraryAccess
|
||
{
|
||
/**
|
||
* @param (Closure(): bool)|null $writable Whether the Tools tab lets this connection write to the
|
||
* library (group "Divi-Bibliothek" not read-only or disabled); null = always.
|
||
*/
|
||
public function __construct(
|
||
private DiviLibraryStore $store,
|
||
private DiviRoles $roles,
|
||
private ?Closure $writable = null
|
||
) {
|
||
}
|
||
|
||
public function store(): DiviLibraryStore
|
||
{
|
||
return $this->store;
|
||
}
|
||
|
||
public function roles(): DiviRoles
|
||
{
|
||
return $this->roles;
|
||
}
|
||
|
||
/**
|
||
* The library entry $postId after the checks for reading, or their refusal; null when $postId
|
||
* names no library entry (the caller goes on with PostGuard).
|
||
*/
|
||
public function item(mixed $postId, bool $forWrite): WP_Post|WP_Error|null
|
||
{
|
||
$id = PostGuard::id($postId);
|
||
$post = $id === null ? null : get_post($id);
|
||
if (! $post instanceof WP_Post || $post->post_type !== DiviLibraryStore::POST_TYPE) {
|
||
return null;
|
||
}
|
||
|
||
return $this->canRead($post) ?? $post;
|
||
}
|
||
|
||
/** Listing the library: edit_posts and the Divi role option divi_library. */
|
||
public function canList(): ?WP_Error
|
||
{
|
||
if (! current_user_can('edit_posts')) {
|
||
return Errors::forbidden('Keine Berechtigung, die Divi-Bibliothek zu nutzen.');
|
||
}
|
||
|
||
return $this->roles->refusal('divi_library');
|
||
}
|
||
|
||
/** Reading one entry: canList() and edit_post on the entry. */
|
||
public function canRead(WP_Post $item): ?WP_Error
|
||
{
|
||
$refusal = $this->canList();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
return current_user_can('edit_post', (int) $item->ID)
|
||
? null
|
||
: Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu bearbeiten.', (int) $item->ID));
|
||
}
|
||
|
||
public function isGlobal(WP_Post $item): bool
|
||
{
|
||
return $this->store->isGlobal((int) $item->ID);
|
||
}
|
||
}
|
||
```
|
||
|
||
(`$forWrite` wird in Task 9 ausgewertet; bis dahin ruft `DiviPageLoader` `item()` nur zum Lesen auf.)
|
||
|
||
`DiviPage.php` – Konstruktor und `editUrl()`:
|
||
|
||
```php
|
||
public function __construct(
|
||
public readonly WP_Post $post,
|
||
public readonly Document $document,
|
||
public readonly NodeIndex $index,
|
||
private bool $libraryItem = false
|
||
) {
|
||
}
|
||
|
||
/** Whether the entry is a Divi Library entry (et_pb_layout), loaded through LibraryAccess. */
|
||
public function isLibraryItem(): bool
|
||
{
|
||
return $this->libraryItem;
|
||
}
|
||
|
||
public function editUrl(): string
|
||
{
|
||
// A library entry has no public permalink (public: false); Divi's library list opens it here.
|
||
return $this->libraryItem
|
||
? (string) get_edit_post_link($this->post, 'raw')
|
||
: self::builderUrl($this->post);
|
||
}
|
||
```
|
||
|
||
`DiviPageLoader.php` – Konstruktor, `library()` und Anfang von `load()` (Import `use LIT\MCP\Integrations\Divi\Library\LibraryAccess;`, `use WP_Post;`):
|
||
|
||
```php
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private Parser $parser = new Parser(),
|
||
private ?LibraryAccess $library = null
|
||
) {
|
||
}
|
||
|
||
/** The library access the Divi tools use, null when library entries are not reachable. */
|
||
public function library(): ?LibraryAccess
|
||
{
|
||
return $this->library;
|
||
}
|
||
|
||
/** Readers pass $forWrite = false (trashed entries may be read, like lit-content-get). */
|
||
public function load(mixed $postId, bool $forWrite): DiviPage|WP_Error
|
||
{
|
||
// Library entries (E1): only for reading so far, and only with a library access.
|
||
$item = $forWrite ? null : $this->library?->item($postId, false);
|
||
$post = $item ?? PostGuard::editable($postId, $this->schema, 'post_id', ! $forWrite);
|
||
if ($post instanceof WP_Error) {
|
||
return $post;
|
||
}
|
||
```
|
||
|
||
und am Ende von `load()`:
|
||
|
||
```php
|
||
return new DiviPage($post, $document, NodeIndex::build($document), $item instanceof WP_Post);
|
||
```
|
||
|
||
`DiviIntegration.php` – Bibliothekszugang als Feld (Imports `DiviLibraryStore`, `DiviRoles`, `LibraryAccess`, `Parser`):
|
||
|
||
```php
|
||
private LibraryAccess $access;
|
||
|
||
public function __construct(private ContentSchema $schema, private ChangeLog $log)
|
||
{
|
||
$this->modules = new DiviModules();
|
||
$this->design = new DesignSystemReader();
|
||
$this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles());
|
||
}
|
||
```
|
||
|
||
und in `abilities()`:
|
||
|
||
```php
|
||
$loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|DiviPageLoaderTest|DiviReadAbilitiesTest|DiviIntegrationTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library/LibraryAccess.php src/Integrations/Divi/Pages/DiviPage.php src/Integrations/Divi/Pages/DiviPageLoader.php src/Integrations/Divi/DiviIntegration.php tests/Support/LibraryFixtures.php tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: Divi read tools accept library entries (library access, E1/E3)`.
|
||
|
||
---
|
||
|
||
### Task 9: Bibliothekseinträge schreiben – Formregeln, globale Einträge mit Probelauf, `confirm` und Sicherung
|
||
|
||
E2 und Antwort F2: Die Divi-Schreibwerkzeuge (`lit-divi-insert`, `-update`, `-move`, `-delete`, `lit/divi-duplicate-node`) ändern Bibliothekseinträge wie Seiten. Zusätzlich (`GlobalEdits`, aufgerufen aus `DiviWriter::commit()` nach der vollständig validierten Änderung): Der Eintrag behält seine Form (`LibraryContent::shapeViolation`); ist er global, wird ohne `confirm: true` nichts gespeichert und die Antwort nennt die betroffenen Seiten (Probelauf, `GlobalUsages` sucht `"globalModule":"<id>"` wie Divi); mit `confirm: true` sichert `DiviSaver` wie immer eine Revision im kses-Fenster, bei abgeschalteten Revisionen legt `GlobalEdits` vorher eine Sicherung an (`PostCloner::backup`), danach leert `DiviSaver` seitenweit den CSS- und Theme-Builder-Cache (`SaveScope::GlobalItem`). Bibliothekseinträge bekommen keine Divi-Flags (Fixture-Befund 4). Das Gruppen-Gate: ist „Divi-Bibliothek“ auf „nur lesen“ oder deaktiviert, sind Bibliothekseinträge für alle Divi-Werkzeuge nur lesbar.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`item()` für Schreiben, `canEdit()`, `writableGate()`, `GROUP`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php` (Schreiben, `writable()`)
|
||
- Create: `src/Integrations/Divi/Pages/SaveScope.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DiviSaver.php`
|
||
- Create: `src/Integrations/Divi/Library/GlobalUsages.php`
|
||
- Create: `src/Integrations/Divi/Library/GlobalEdits.php`
|
||
- Modify: `src/Integrations/Divi/Pages/DiviWriter.php` (`save()`-Scope, `commit()`, `notice()`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviWriteAbility.php` (`precheck()`, `commit`, `notice`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviSchema.php` (`confirm`)
|
||
- Modify: `src/Abilities/ExposurePolicy.php` (`groupWritable()`)
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php`
|
||
- Create: `tests/Stubs/EtCorePageResource.php`; Modify: `tests/bootstrap.php`, `tests/Support/DiviWriterFixture.php`
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `…/Library/GlobalUsagesTest.php`, `…/Library/GlobalEditsTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviPageLoaderTest.php`, `…/Pages/DiviSaverTest.php`, `tests/Unit/Abilities/ExposurePolicyTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LibraryContent::load/roots/shapeViolation` (Task 7), `LibraryAccess`, `LibraryFixtures` (Task 8), `PostCloner::backup(WP_Post): int`, `Errors::confirmRequired/clientMessage/codeOf/unsupported/invalidInput`.
|
||
- Produces:
|
||
- `LibraryAccess::GROUP = 'divi-library'`, `LibraryAccess::canEdit(WP_Post $item): ?WP_Error`, `LibraryAccess::writableGate(): ?WP_Error`; `item($id, true)` prüft `canEdit()`.
|
||
- `enum SaveScope { Page, LibraryItem, GlobalItem; setsFlags(): bool; clearsSiteCss(): bool }`
|
||
- `DiviSaver::save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array`, `DiviSaver::saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): list<string>`
|
||
- `class GlobalUsages { const LIMIT = 50; __construct(?\wpdb $db = null); of(int $libraryId): array{items: list<array{post_id: int, title: string, status: string, type: string}>, count: int} }` (nicht final)
|
||
- `final class GlobalEdits { __construct(LibraryAccess $access, GlobalUsages $usages, ?Closure $backup = null, Parser $parser = new Parser()); prepare(DiviPage $page, array $input): array{scope: SaveScope, details: array<string, mixed>}|WP_Error; notice(DiviPage $page, WP_Error $error): WP_Error }`
|
||
- `DiviWriter::__construct(…, DiviComposition $composition, ?GlobalEdits $globals = null)`, `DiviWriter::save(…, SaveScope $scope = SaveScope::Page)`, `DiviWriter::commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error`, `DiviWriter::notice(DiviPage $page, WP_Error $error): WP_Error`
|
||
- `DiviWriteAbility::precheck(array $input): ?WP_Error` (protected, Standard `null`)
|
||
- `ExposurePolicy::groupWritable(string $group): bool`
|
||
- `DiviIntegration::__construct(ContentSchema $schema, ChangeLog $log, ?Closure $libraryWritable = null)`
|
||
- `DiviWriterFixture::writer(array &$saved, array &$failures = [], bool $unfiltered = false, ?DiviPageLoader $loader = null, ?GlobalEdits $globals = null)`
|
||
|
||
- [ ] **Step 1: Stub und failing tests schreiben**
|
||
|
||
`tests/Stubs/EtCorePageResource.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
if (! class_exists('ET_Core_PageResource')) {
|
||
/**
|
||
* Divi's static CSS cache for the saver tests: records every remove_static_resources() call.
|
||
*/
|
||
class ET_Core_PageResource
|
||
{
|
||
/** @var list<array{0: int|string, 1: string, 2: bool}> */
|
||
public static array $removed = [];
|
||
|
||
public static function remove_static_resources($postId, $owner, $force = false): void
|
||
{
|
||
self::$removed[] = [$postId, $owner, (bool) $force];
|
||
}
|
||
}
|
||
}
|
||
```
|
||
|
||
In `tests/bootstrap.php` nach `RequestsException.php` ergänzen: `require_once __DIR__ . '/Stubs/EtCorePageResource.php';`
|
||
|
||
`DiviWriterFixture::writer()` um den Parameter `?GlobalEdits $globals = null` (letzter Parameter, Import `LIT\MCP\Integrations\Divi\Library\GlobalEdits`) erweitern und an `new DiviWriter(…, self::composition($design, $unfiltered), $globals)` durchreichen.
|
||
|
||
In `DiviSaverTest`:
|
||
|
||
```php
|
||
public function testLibraryItemsGetNoDiviFlagsAndGlobalOnesClearEveryPage(): void
|
||
{
|
||
\ET_Core_PageResource::$removed = [];
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('wp_update_post')->justReturn(7);
|
||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
|
||
$cleared = [];
|
||
Functions\when('et_theme_builder_clear_wp_cache')->alias(static function (string $what) use (&$cleared): void {
|
||
$cleared[] = $what;
|
||
});
|
||
|
||
(new DiviSaver())->save($this->post, 'NEU', SaveScope::LibraryItem);
|
||
self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:')));
|
||
self::assertSame([[7, 'all', true]], \ET_Core_PageResource::$removed);
|
||
self::assertSame([], $cleared);
|
||
|
||
\ET_Core_PageResource::$removed = [];
|
||
(new DiviSaver())->save($this->post, 'NEU', SaveScope::GlobalItem);
|
||
self::assertSame([[7, 'all', true], ['all', 'all', true]], \ET_Core_PageResource::$removed);
|
||
self::assertSame(['all'], $cleared);
|
||
}
|
||
|
||
public function testSaveNewOfALibraryItemSetsNoFlags(): void
|
||
{
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
Functions\when('wp_update_post')->justReturn(42);
|
||
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 42, 'post_content' => 'NEU']));
|
||
|
||
self::assertSame([], (new DiviSaver())->saveNew(42, 'NEU', SaveScope::LibraryItem));
|
||
self::assertSame([], array_filter($this->calls, static fn (string $call): bool => str_starts_with($call, 'meta:')));
|
||
}
|
||
```
|
||
|
||
(Import `use LIT\MCP\Integrations\Divi\Pages\SaveScope;`.)
|
||
|
||
In `LibraryAccessTest`:
|
||
|
||
```php
|
||
public function testEditingNeedsSaveLibraryAPublishedItemAWritableGroupAndForGlobalsEditGlobalLibrary(): void
|
||
{
|
||
LibraryFixtures::given([
|
||
LibraryFixtures::item(1057),
|
||
LibraryFixtures::item(1059),
|
||
LibraryFixtures::item(1055, ['post_status' => 'trash']),
|
||
]);
|
||
|
||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1057, true));
|
||
self::assertToolError(LibraryFixtures::access(['save_library'])->item(1057, true), 'forbidden');
|
||
$readonly = LibraryFixtures::access([], false)->item(1057, true);
|
||
self::assertToolError($readonly, 'forbidden');
|
||
self::assertStringContainsString('Divi-Bibliothek', $readonly->get_error_message());
|
||
self::assertToolError(LibraryFixtures::access()->item(1055, true), 'validation_failed');
|
||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access()->item(1059, true));
|
||
self::assertToolError(LibraryFixtures::access(['edit_global_library'])->item(1059, true), 'forbidden');
|
||
self::assertInstanceOf(WP_Post::class, LibraryFixtures::access(['edit_global_library'])->item(1057, true));
|
||
}
|
||
```
|
||
|
||
In `DiviPageLoaderTest`:
|
||
|
||
```php
|
||
public function testLibraryItemsAreWritableWhateverTheBuilderSettingOfTheirType(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1057)]);
|
||
$loader = new DiviPageLoader(DiviFixtures::schema(false), new Parser(), LibraryFixtures::access());
|
||
|
||
$page = $loader->loadWritable(1057);
|
||
|
||
self::assertInstanceOf(DiviPage::class, $page);
|
||
self::assertTrue($page->isLibraryItem());
|
||
}
|
||
```
|
||
|
||
In `ExposurePolicyTest`:
|
||
|
||
```php
|
||
public function testGroupWritable(): void
|
||
{
|
||
self::assertTrue((new ExposurePolicy([]))->groupWritable('divi-library'));
|
||
self::assertFalse((new ExposurePolicy(['@readonly:divi-library' => true]))->groupWritable('divi-library'));
|
||
self::assertFalse((new ExposurePolicy(['@group:divi-library' => 'disabled']))->groupWritable('divi-library'));
|
||
self::assertTrue((new ExposurePolicy(['@group:divi-library' => 'indirect']))->groupWritable('divi-library'));
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Library/GlobalUsagesTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class GlobalUsagesTest extends TestCase
|
||
{
|
||
public function testFindsInstancesLikeDiviAndHidesUnreadableTitles(): void
|
||
{
|
||
$db = Mockery::mock(\wpdb::class)->makePartial();
|
||
$queries = [];
|
||
$db->allows('get_var')->andReturnUsing(static function (string $sql) use (&$queries): string {
|
||
$queries[] = $sql;
|
||
|
||
return '3';
|
||
});
|
||
$db->allows('get_results')->andReturnUsing(static function (string $sql) use (&$queries): array {
|
||
$queries[] = $sql;
|
||
|
||
return [
|
||
['ID' => '1053', 'post_title' => 'Globale Instanzen', 'post_status' => 'draft', 'post_type' => 'page'],
|
||
['ID' => '1061', 'post_title' => 'Geheim', 'post_status' => 'private', 'post_type' => 'et_header_layout'],
|
||
];
|
||
});
|
||
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 1061);
|
||
|
||
$usages = (new GlobalUsages($db))->of(1058);
|
||
|
||
self::assertSame(3, $usages['count']);
|
||
self::assertSame(
|
||
[
|
||
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page'],
|
||
['post_id' => 1061, 'title' => '(nicht lesbar)', 'status' => 'private', 'type' => 'et_header_layout'],
|
||
],
|
||
$usages['items']
|
||
);
|
||
self::assertStringContainsString("LIKE '%\"globalModule\":\"1058\"%'", $queries[0]);
|
||
self::assertStringContainsString("post_status NOT IN ('trash', 'auto-draft', 'inherit')", $queries[1]);
|
||
self::assertStringContainsString('LIMIT 50', $queries[1]);
|
||
}
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Library/GlobalEditsTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
/** The Divi write tools on library entries (E2, answer F2). */
|
||
final class GlobalEditsTest extends TestCase
|
||
{
|
||
/** @var list<array{string, SaveScope}> Content and scope handed to the saver. */
|
||
private array $saved = [];
|
||
|
||
private int $backups = 0;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('wp_revisions_enabled')->justReturn(true);
|
||
}
|
||
|
||
private function usages(): GlobalUsages
|
||
{
|
||
$usages = Mockery::mock(GlobalUsages::class);
|
||
$usages->allows('of')->andReturn([
|
||
'items' => [
|
||
['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'publish', 'type' => 'page'],
|
||
['post_id' => 1070, 'title' => '(nicht lesbar)', 'status' => 'draft', 'type' => 'page'],
|
||
],
|
||
'count' => 2,
|
||
]);
|
||
|
||
return $usages;
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
private function writer(bool $writable = true, array $deniedRoles = []): DiviWriter
|
||
{
|
||
$saver = Mockery::mock(DiviSaver::class);
|
||
$saver->allows('save')->andReturnUsing(function (WP_Post $post, string $content, SaveScope $scope): array {
|
||
$this->saved[] = [$content, $scope];
|
||
$post->post_content = $content;
|
||
|
||
return ['revision_id' => 811, 'warnings' => []];
|
||
});
|
||
$access = LibraryFixtures::access($deniedRoles, $writable);
|
||
$design = DiviWriterFixture::designReader();
|
||
$globals = new GlobalEdits($access, $this->usages(), function (WP_Post $item): int {
|
||
++$this->backups;
|
||
|
||
return 1071;
|
||
});
|
||
|
||
return new DiviWriter(
|
||
LibraryFixtures::loader($access),
|
||
$saver,
|
||
DiviWriterFixture::log(),
|
||
$design,
|
||
DiviWriterFixture::composition($design),
|
||
$globals
|
||
);
|
||
}
|
||
|
||
private static function rev(int $id): string
|
||
{
|
||
return Rev::of((string) LibraryFixtures::item($id)->post_content);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private static function relabel(int $id, bool $confirm = false): array
|
||
{
|
||
return array_merge(
|
||
['post_id' => $id, 'tree_rev' => self::rev($id), 'changes' => [['id' => 'm0', 'label' => 'Neu']]],
|
||
$confirm ? ['confirm' => true] : []
|
||
);
|
||
}
|
||
|
||
public function testPlainLibraryItemIsSavedWithoutConfirmAndWithoutDiviFlags(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1057)]);
|
||
|
||
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1057));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(SaveScope::LibraryItem, $this->saved[0][1]);
|
||
self::assertStringStartsWith('<!-- wp:divi/placeholder --><!-- wp:divi/blurb ', $this->saved[0][0]);
|
||
self::assertArrayNotHasKey('affected', $result);
|
||
}
|
||
|
||
public function testGlobalItemDryRunNamesTheAffectedPagesAndSavesNothing(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||
|
||
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059));
|
||
|
||
self::assertToolError($result, 'confirm_required');
|
||
self::assertStringContainsString('wirkt sofort auf 2 Seiten', $result->get_error_message());
|
||
self::assertSame(1053, $result->get_error_data()['affected'][0]['post_id']);
|
||
self::assertSame(2, $result->get_error_data()['affected_count']);
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testConfirmedGlobalEditClearsEveryPageAndKeepsARevision(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||
|
||
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(SaveScope::GlobalItem, $this->saved[0][1]);
|
||
self::assertSame(811, $result['revision_id']);
|
||
self::assertSame(2, $result['affected_count']);
|
||
self::assertArrayNotHasKey('backup_id', $result);
|
||
self::assertSame(0, $this->backups);
|
||
}
|
||
|
||
public function testWithoutRevisionsABackupIsMadeFirst(): void
|
||
{
|
||
Functions\when('wp_revisions_enabled')->justReturn(false);
|
||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||
|
||
$result = (new DiviUpdateAbility($this->writer()))->execute(self::relabel(1059, true));
|
||
|
||
self::assertSame(1071, $result['backup_id']);
|
||
self::assertSame(1, $this->backups);
|
||
}
|
||
|
||
public function testRefusesToEmptyOrDoubleTheRoot(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1058)]);
|
||
|
||
$emptied = (new DiviDeleteAbility($this->writer()))->execute(
|
||
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0'], 'confirm' => true]
|
||
);
|
||
$doubled = (new DiviDuplicateNodeAbility($this->writer()))->execute(
|
||
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'id' => 's0', 'confirm' => true]
|
||
);
|
||
|
||
self::assertToolError($emptied, 'validation_failed');
|
||
self::assertStringContainsString('nicht leer', $emptied->get_error_message());
|
||
self::assertToolError($doubled, 'validation_failed');
|
||
self::assertStringContainsString('genau ein divi/section', $doubled->get_error_message());
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testDeleteInAGlobalItemNamesTheAffectedPagesBeforeConfirm(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1058)]);
|
||
|
||
$result = (new DiviDeleteAbility($this->writer()))->execute(
|
||
['post_id' => 1058, 'tree_rev' => self::rev(1058), 'ids' => ['s0.r0.c0.m1']]
|
||
);
|
||
|
||
self::assertToolError($result, 'confirm_required');
|
||
self::assertStringContainsString('Globaler Bibliothekseintrag: wirkt auf 2 Seiten', $result->get_error_message());
|
||
self::assertSame(['s0.r0.c0.m1'], $result->get_error_data()['nodes']);
|
||
self::assertCount(2, $result->get_error_data()['affected']);
|
||
}
|
||
|
||
public function testConfirmWithAStaleTreeRevWritesNothing(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1059)]);
|
||
|
||
$result = (new DiviUpdateAbility($this->writer()))->execute(
|
||
['tree_rev' => 'abcdef012345'] + self::relabel(1059, true)
|
||
);
|
||
|
||
self::assertToolError($result, 'rev_mismatch');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testGlobalItemsNeedEditGlobalLibraryAndAWritableGroup(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1059), LibraryFixtures::item(1057)]);
|
||
|
||
self::assertToolError(
|
||
(new DiviUpdateAbility($this->writer(true, ['edit_global_library'])))->permission(self::relabel(1059)),
|
||
'forbidden'
|
||
);
|
||
self::assertToolError((new DiviUpdateAbility($this->writer(false)))->permission(self::relabel(1057)), 'forbidden');
|
||
self::assertTrue(
|
||
(new DiviUpdateAbility($this->writer(true, ['edit_global_library'])))->permission(self::relabel(1057))
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DiviSaverTest|LibraryAccessTest|DiviPageLoaderTest|ExposurePolicyTest|GlobalUsagesTest|GlobalEditsTest'`
|
||
Expected: FAIL (`SaveScope`, `GlobalEdits`, `GlobalUsages`, `groupWritable` fehlen)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Pages/SaveScope.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/** What DiviSaver writes to – it decides the Divi flags and how much static CSS is cleared. */
|
||
enum SaveScope
|
||
{
|
||
/** A page or post: Divi flags, its own static CSS. */
|
||
case Page;
|
||
|
||
/** A library entry: no Divi flags (Divi sets none there, fixture finding 4), its own CSS. */
|
||
case LibraryItem;
|
||
|
||
/** A global library entry: like LibraryItem, plus the CSS of every page and the Theme Builder cache (spec §5.6). */
|
||
case GlobalItem;
|
||
|
||
public function setsFlags(): bool
|
||
{
|
||
return $this === self::Page;
|
||
}
|
||
|
||
public function clearsSiteCss(): bool
|
||
{
|
||
return $this === self::GlobalItem;
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviSaver::save()` – Signatur `public function save(WP_Post $post, string $content, SaveScope $scope = SaveScope::Page): array`; die beiden `FollowUp::run`-Aufrufe nach dem Speichern ersetzen durch:
|
||
|
||
```php
|
||
$warnings = [];
|
||
if ($scope->setsFlags()) {
|
||
FollowUp::run(
|
||
static fn () => DiviFlags::set($postId),
|
||
'divi builder flags',
|
||
'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
|
||
. 'einmal im Divi-Builder öffnen und speichern.',
|
||
$warnings
|
||
);
|
||
}
|
||
FollowUp::run(
|
||
static function () use ($postId, $scope): void {
|
||
if (! class_exists('ET_Core_PageResource')) {
|
||
return;
|
||
}
|
||
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
|
||
// A global library entry is part of every page that shows it (spec §5.6 step 5).
|
||
if ($scope->clearsSiteCss()) {
|
||
\ET_Core_PageResource::remove_static_resources('all', 'all', true);
|
||
if (function_exists('et_theme_builder_clear_wp_cache')) {
|
||
et_theme_builder_clear_wp_cache('all');
|
||
}
|
||
}
|
||
},
|
||
'divi static css',
|
||
'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. '
|
||
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
||
$warnings
|
||
);
|
||
```
|
||
|
||
`DiviSaver::saveNew()` – Signatur `public function saveNew(int $postId, string $content, SaveScope $scope = SaveScope::Page): array`, statt `DiviFlags::set($postId);`:
|
||
|
||
```php
|
||
if ($scope->setsFlags()) {
|
||
DiviFlags::set($postId);
|
||
}
|
||
```
|
||
|
||
Klassen-Docblock von `DiviSaver` um einen Absatz ergänzen: „SaveScope: Bibliothekseinträge bekommen keine Divi-Flags; ein globaler Eintrag leert zusätzlich den CSS-Cache aller Seiten und den Theme-Builder-Cache.“
|
||
|
||
`LibraryAccess` – Konstante, `item()`, `canEdit()`, `writableGate()`:
|
||
|
||
```php
|
||
/** Group (integration id) of the library tools in the Tools tab. */
|
||
public const GROUP = 'divi-library';
|
||
```
|
||
|
||
In `item()` die letzte Zeile ersetzen:
|
||
|
||
```php
|
||
return ($forWrite ? $this->canEdit($post) : $this->canRead($post)) ?? $post;
|
||
```
|
||
|
||
Neue Methoden:
|
||
|
||
```php
|
||
/**
|
||
* Changing one entry: canRead(), the role option save_library, the Tools-tab gate and a live
|
||
* entry; a global entry additionally needs edit_global_library (E3 – Divi's builder route
|
||
* does not check edit_post on the entry, F9; LIT MCP does).
|
||
*/
|
||
public function canEdit(WP_Post $item): ?WP_Error
|
||
{
|
||
$refusal = $this->canRead($item) ?? $this->roles->refusal('save_library') ?? $this->writableGate();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
if ((string) $item->post_status !== 'publish') {
|
||
return Errors::invalidInput(
|
||
sprintf(
|
||
'Bibliothekseintrag %d ist nicht aktiv (Status „%s“); nichts wurde gespeichert.',
|
||
(int) $item->ID,
|
||
(string) $item->post_status
|
||
),
|
||
['post_id' => 'nur aktive Bibliothekseinträge'],
|
||
'Den Eintrag zuerst in der Divi-Bibliothek wiederherstellen.'
|
||
);
|
||
}
|
||
|
||
return $this->isGlobal($item) ? $this->roles->refusal('edit_global_library') : null;
|
||
}
|
||
|
||
/** Refusal while the Tools tab keeps this connection from writing to the library. */
|
||
public function writableGate(): ?WP_Error
|
||
{
|
||
if ($this->writable === null || ($this->writable)()) {
|
||
return null;
|
||
}
|
||
|
||
return Errors::forbidden(
|
||
'Schreiben in die Divi-Bibliothek ist für diese Verbindung abgeschaltet (Gruppe „Divi-Bibliothek“ auf '
|
||
. '„nur lesen“ oder deaktiviert).',
|
||
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||
);
|
||
}
|
||
```
|
||
|
||
`DiviPageLoader::load()` – die Bibliothekszeile ersetzen durch:
|
||
|
||
```php
|
||
// Library entries (E1): with a library access, for reading and writing.
|
||
$item = $this->library?->item($postId, $forWrite);
|
||
```
|
||
|
||
`DiviPageLoader::writable()` – die Builder-Prüfung auf Nicht-Bibliothekseinträge beschränken:
|
||
|
||
```php
|
||
if (! $page->isLibraryItem() && ! $this->schema->diviEnabled((string) $post->post_type)) {
|
||
```
|
||
|
||
`src/Integrations/Divi/Library/GlobalUsages.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Pages (and Theme Builder layouts) that show global library entry $id: every living post whose
|
||
* content has "globalModule":"<id>" – Divi's own way to find instances (string scan,
|
||
* BlockParserStore, F10). Titles only for posts the user may read. Not final so tests can mock it.
|
||
*/
|
||
class GlobalUsages
|
||
{
|
||
public const LIMIT = 50;
|
||
|
||
private const WHERE = "post_content LIKE %s AND post_type <> 'revision' "
|
||
. "AND post_status NOT IN ('trash', 'auto-draft', 'inherit')";
|
||
|
||
public function __construct(private ?\wpdb $db = null)
|
||
{
|
||
}
|
||
|
||
/** @return array{items: list<array{post_id: int, title: string, status: string, type: string}>, count: int} */
|
||
public function of(int $libraryId): array
|
||
{
|
||
$db = $this->db ?? $GLOBALS['wpdb'];
|
||
$like = '%' . $db->esc_like('"globalModule":"' . $libraryId . '"') . '%';
|
||
// phpcs:disable WordPress.DB.PreparedSQL.InterpolatedNotPrepared, WordPress.DB.DirectDatabaseQuery
|
||
$count = (int) $db->get_var($db->prepare('SELECT COUNT(*) FROM ' . $db->posts . ' WHERE ' . self::WHERE, $like));
|
||
$rows = $db->get_results(
|
||
$db->prepare(
|
||
'SELECT ID, post_title, post_status, post_type FROM ' . $db->posts . ' WHERE ' . self::WHERE
|
||
. ' ORDER BY ID ASC LIMIT %d',
|
||
$like,
|
||
self::LIMIT
|
||
),
|
||
'ARRAY_A'
|
||
);
|
||
// phpcs:enable
|
||
|
||
$items = [];
|
||
foreach (is_array($rows) ? $rows : [] as $row) {
|
||
$id = (int) $row['ID'];
|
||
$items[] = [
|
||
'post_id' => $id,
|
||
'title' => current_user_can('read_post', $id) ? (string) $row['post_title'] : '(nicht lesbar)',
|
||
'status' => (string) $row['post_status'],
|
||
'type' => (string) $row['post_type'],
|
||
];
|
||
}
|
||
|
||
return ['items' => $items, 'count' => max($count, count($items))];
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Library/GlobalEdits.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Integrations\WordPress\Status\PostCloner;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The extra rules of a Divi write to a library entry (E2, answer F2), checked after the whole
|
||
* change validated and before anything is saved:
|
||
* - every entry keeps its shape (LibraryContent::shapeViolation) – instances expect their root type;
|
||
* - a global entry changes every page that shows it at once (F7): without confirm: true nothing is
|
||
* saved and the answer lists those pages (a dry run of the fully validated change); with it the
|
||
* save scope is GlobalItem (site-wide cache) and, when revisions are off for the entry, a backup
|
||
* copy is made first – the revision itself is DiviSaver's restore point, as for every page.
|
||
*/
|
||
final class GlobalEdits
|
||
{
|
||
/** @var Closure(WP_Post): int */
|
||
private Closure $backup;
|
||
|
||
/** @param (Closure(WP_Post): int)|null $backup Backup copy of the entry; null = PostCloner::backup(). */
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private GlobalUsages $usages,
|
||
?Closure $backup = null,
|
||
private Parser $parser = new Parser()
|
||
) {
|
||
$this->backup = $backup ?? static fn (WP_Post $item): int => (new PostCloner())->backup($item);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array{scope: SaveScope, details: array<string, mixed>}|WP_Error
|
||
*/
|
||
public function prepare(DiviPage $page, array $input): array|WP_Error
|
||
{
|
||
$item = $page->post;
|
||
$id = (int) $item->ID;
|
||
$global = $this->access->isGlobal($item);
|
||
try {
|
||
$before = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||
} catch (MalformedMarkup) {
|
||
$before = [];
|
||
}
|
||
|
||
$violation = LibraryContent::shapeViolation(
|
||
$before,
|
||
LibraryContent::roots($page->document),
|
||
$this->access->store()->layoutType($id),
|
||
$global
|
||
);
|
||
if ($violation !== null) {
|
||
return Errors::invalidInput($violation . ' Nichts wurde gespeichert.', ['post_id' => $violation]);
|
||
}
|
||
|
||
if (! $global) {
|
||
return ['scope' => SaveScope::LibraryItem, 'details' => []];
|
||
}
|
||
|
||
$usages = $this->usages->of($id);
|
||
$details = ['affected' => $usages['items'], 'affected_count' => $usages['count']];
|
||
if (($input['confirm'] ?? false) !== true) {
|
||
return Errors::confirmRequired(
|
||
sprintf(
|
||
'Globaler Bibliothekseintrag %d: die Änderung wirkt sofort auf %d %s (siehe affected), auch '
|
||
. 'veröffentlichte. Nichts wurde gespeichert (Probelauf).',
|
||
$id,
|
||
$usages['count'],
|
||
$usages['count'] === 1 ? 'Seite' : 'Seiten'
|
||
),
|
||
$details
|
||
);
|
||
}
|
||
|
||
if (! wp_revisions_enabled($item)) {
|
||
$details['backup_id'] = ($this->backup)($item);
|
||
}
|
||
|
||
return ['scope' => SaveScope::GlobalItem, 'details' => $details];
|
||
}
|
||
|
||
/**
|
||
* A tool's own confirm_required (lit-divi-delete) on a global entry, extended by the affected
|
||
* pages – so the one confirm the client sends covers both questions.
|
||
*/
|
||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||
{
|
||
if (Errors::codeOf($error) !== 'confirm_required' || ! $this->access->isGlobal($page->post)) {
|
||
return $error;
|
||
}
|
||
|
||
$usages = $this->usages->of((int) $page->post->ID);
|
||
$data = $error->get_error_data();
|
||
$details = is_array($data) ? array_diff_key($data, ['status' => true, 'hint' => true]) : [];
|
||
|
||
return Errors::confirmRequired(
|
||
Errors::clientMessage($error)
|
||
. sprintf(' Globaler Bibliothekseintrag: wirkt auf %d Seiten (siehe affected).', $usages['count']),
|
||
array_merge($details, ['affected' => $usages['items'], 'affected_count' => $usages['count']])
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviWriter.php` – Konstruktor um `private ?GlobalEdits $globals = null` als letzten Parameter erweitern (Imports `GlobalEdits`, `Errors` ist vorhanden, `WP_Error` vorhanden). `save()` bekommt `SaveScope $scope = SaveScope::Page` als letzten Parameter und reicht ihn weiter: `$saved = $this->saver->save($post, $content, $scope);`. Neue Methoden:
|
||
|
||
```php
|
||
/**
|
||
* save() for the Divi tools (E2): pages as before; a library entry keeps its shape, a global one
|
||
* needs confirm (dry run first), gets a backup when revisions are off and clears every page's
|
||
* cache; the result then names the affected pages.
|
||
*
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error
|
||
* @throws WriteFailed
|
||
*/
|
||
public function commit(string $ability, DiviPage $page, DiviChange $change, array $input): array|WP_Error
|
||
{
|
||
if (! $page->isLibraryItem()) {
|
||
return $this->save($ability, $page, $change, $input);
|
||
}
|
||
|
||
if ($this->globals === null) {
|
||
return Errors::unsupported(
|
||
'Bibliothekseinträge lassen sich über diese Verbindung nicht ändern.',
|
||
'Den Eintrag im Divi-Builder bearbeiten.'
|
||
);
|
||
}
|
||
|
||
if (Serializer::document($page->document) === (string) $page->post->post_content) {
|
||
return $this->save($ability, $page, $change, $input, SaveScope::LibraryItem);
|
||
}
|
||
|
||
$prepared = $this->globals->prepare($page, $input);
|
||
if ($prepared instanceof WP_Error) {
|
||
return $prepared;
|
||
}
|
||
|
||
return array_merge($this->save($ability, $page, $change, $input, $prepared['scope']), $prepared['details']);
|
||
}
|
||
|
||
/** A tool's confirm_required on a global library entry, extended by the affected pages. */
|
||
public function notice(DiviPage $page, WP_Error $error): WP_Error
|
||
{
|
||
return $page->isLibraryItem() && $this->globals !== null ? $this->globals->notice($page, $error) : $error;
|
||
}
|
||
```
|
||
|
||
`DiviWriteAbility.php` – `permission()`/`execute()` ersetzen und `precheck()` ergänzen:
|
||
|
||
```php
|
||
final public function permission(array $input): bool|WP_Error
|
||
{
|
||
$refusal = $this->precheck($input);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$page = $this->writer->page($input);
|
||
|
||
return $page instanceof WP_Error ? $page : true;
|
||
}
|
||
|
||
final public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->precheck($input);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$page = $this->writer->page($input);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
|
||
$change = $this->change($page, $input, $this->writer->composition($page));
|
||
if ($change instanceof WP_Error) {
|
||
return $this->writer->notice($page, $change);
|
||
}
|
||
|
||
return $this->writer->commit($this->name(), $page, $change, $input);
|
||
}
|
||
|
||
/**
|
||
* Checks of a tool beyond the page (rights and Tools-tab gates of another group) – run in
|
||
* permission() and again in execute(). Null = none.
|
||
*
|
||
* @param array<string, mixed> $input
|
||
*/
|
||
protected function precheck(array $input): ?WP_Error
|
||
{
|
||
return null;
|
||
}
|
||
```
|
||
|
||
Klassen-Docblock von `DiviWriteAbility`: „… `DiviWriter::commit()` stores it (library entries: `GlobalEdits`) and builds the result.“
|
||
|
||
`DiviSchema::write()`:
|
||
|
||
```php
|
||
public static function write(): array
|
||
{
|
||
return [
|
||
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'tree_rev' => [
|
||
'type' => 'string',
|
||
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
|
||
],
|
||
'confirm' => [
|
||
'type' => 'boolean',
|
||
'description' => 'Bei globalen Bibliothekseinträgen (und beim Löschen): true erst, nachdem der Nutzer '
|
||
. 'den Probelauf (confirm_required mit den betroffenen Seiten) bestätigt hat.',
|
||
],
|
||
];
|
||
}
|
||
```
|
||
|
||
`ExposurePolicy` – nach `isReadonly()`:
|
||
|
||
```php
|
||
/** Whether abilities of $group may write: the group is neither "nur lesen" nor disabled as a whole. */
|
||
public function groupWritable(string $group): bool
|
||
{
|
||
return ! $this->isReadonly($group) && $this->groupMode($group) !== self::DISABLED;
|
||
}
|
||
```
|
||
|
||
`DiviIntegration.php` – Konstruktor und Writer (Imports `Closure`, `GlobalEdits`, `GlobalUsages`):
|
||
|
||
```php
|
||
/**
|
||
* @param (Closure(): bool)|null $libraryWritable Tools-tab gate for writes to library entries
|
||
* (group "Divi-Bibliothek" writable); null = always.
|
||
*/
|
||
public function __construct(private ContentSchema $schema, private ChangeLog $log, ?Closure $libraryWritable = null)
|
||
{
|
||
$this->modules = new DiviModules();
|
||
$this->design = new DesignSystemReader();
|
||
$this->access = new LibraryAccess(new DiviLibraryStore(), new DiviRoles(), $libraryWritable);
|
||
}
|
||
```
|
||
|
||
In `abilities()`:
|
||
|
||
```php
|
||
$loader = new DiviPageLoader($this->schema, new Parser(), $this->access);
|
||
$composition = new DiviComposition($this->modules, $this->design);
|
||
$writer = new DiviWriter(
|
||
$loader,
|
||
new DiviSaver(),
|
||
$this->log,
|
||
$this->design,
|
||
$composition,
|
||
new GlobalEdits($this->access, new GlobalUsages())
|
||
);
|
||
```
|
||
|
||
`Plugin::boot()` (Import `use LIT\MCP\Integrations\Divi\Library\LibraryAccess;`): die Zeile `$divi = new DiviIntegration($schema, $changeLog);` entfällt an ihrer bisherigen Stelle; der Block ab `$schema = self::contentSchema();` bis vor `$registry = new IntegrationRegistry(` lautet danach:
|
||
|
||
```php
|
||
$schema = self::contentSchema();
|
||
$registry = null;
|
||
$policy = null;
|
||
// Security M2: content-create's divi input follows lit/divi-insert (asked at execute time,
|
||
// when registry and policy exist), so the Divi group's switches stop every Divi layout write.
|
||
$diviWritable = static function () use (&$registry, &$policy): bool {
|
||
return $registry instanceof IntegrationRegistry
|
||
&& $policy instanceof ExposurePolicy
|
||
&& $policy->allows('lit/divi-insert', $registry->abilities());
|
||
};
|
||
// The Divi tools write to library entries only while the group "Divi-Bibliothek" may write (E9).
|
||
$libraryWritable = static function () use (&$policy): bool {
|
||
return $policy instanceof ExposurePolicy && $policy->groupWritable(LibraryAccess::GROUP);
|
||
};
|
||
$divi = new DiviIntegration($schema, $changeLog, $libraryWritable);
|
||
```
|
||
|
||
(Beide Closures fragen erst bei der Ausführung; `$policy` wird weiter unten gesetzt.)
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DiviSaverTest|LibraryAccessTest|DiviPageLoaderTest|ExposurePolicyTest|GlobalUsagesTest|GlobalEditsTest|DiviWriterTest|DiviWriteAbilityTest|DiviDeleteAbilityTest|DiviReplaceTextAbilityTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library src/Integrations/Divi/Pages/SaveScope.php src/Integrations/Divi/Pages/DiviSaver.php src/Integrations/Divi/Pages/DiviPageLoader.php src/Integrations/Divi/Pages/DiviWriter.php src/Integrations/Divi/Pages/DiviWriteAbility.php src/Integrations/Divi/Pages/DiviSchema.php src/Abilities/ExposurePolicy.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php tests/Stubs/EtCorePageResource.php tests/bootstrap.php tests/Support/DiviWriterFixture.php tests/Unit
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: Divi tools write library entries; global ones with dry run, confirm and backup (E2, F2)`.
|
||
|
||
---
|
||
|
||
### Task 10: Gruppe „Divi-Bibliothek“ und `lit/library-list` (mit `id` als Detail)
|
||
|
||
E9: `lit/library-list` ersetzt `lit/library-get` (Spec §5.7 wird in Task 16 angepasst). Ohne `id` eine Seite der Liste mit Filtern und `cursor`, mit `id` ein Eintrag mit Knoten-Übersicht, `tree_rev`, `rev` und – bei globalen Einträgen – den Seiten, die ihn zeigen. Die drei `lit/library-*`-Tools bilden eine eigene Integration (= Gruppe im Tools-Tab).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Library/LibraryIntegration.php`
|
||
- Create: `src/Integrations/Divi/Library/LibraryListAbility.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php` (`library()`, `loader()`)
|
||
- Modify: `src/Plugin.php` (Registrierung)
|
||
- Modify: `docs/guides/overview.md` (indirekte Tools, Gruppenliste)
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LibraryAccess::canList()`, `DiviPageLoader::load()`, `DiviLibraryStore::find/describe`, `GlobalUsages::of()`, `Outline`, `Cursor`, `Rev`.
|
||
- Produces:
|
||
- `final class LibraryIntegration implements Integration { __construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design); id() = LibraryAccess::GROUP; label() = 'Divi-Bibliothek' }` (Task 12/13 erweitern den Konstruktor)
|
||
- `final class LibraryListAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design, GlobalUsages $usages) }` – Name `lit/library-list`, indirekt, nur lesend; Ausgabe `{items, next_cursor}` bzw. mit `id` zusätzlich `outline`, `tree_rev`, `rev`, `edit_url`, bei globalen `affected`, `affected_count`.
|
||
- `DiviIntegration::library(): LibraryIntegration`
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryListAbility;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class LibraryListAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('get_the_terms')->justReturn(false);
|
||
Functions\when('get_post_meta')->justReturn('');
|
||
}
|
||
|
||
private function ability(array $deniedRoles = []): LibraryListAbility
|
||
{
|
||
$store = LibraryFixtures::store();
|
||
$store->allows('find')->andReturnUsing(static fn (array $filters, int $limit, int $offset): array => array_slice(
|
||
[LibraryFixtures::item(1055), LibraryFixtures::item(1058), LibraryFixtures::item(1059)],
|
||
$offset,
|
||
$limit + 1
|
||
));
|
||
$access = LibraryFixtures::access($deniedRoles, true, $store);
|
||
$usages = Mockery::mock(GlobalUsages::class);
|
||
$usages->allows('of')->with(1058)->andReturn([
|
||
'items' => [['post_id' => 1053, 'title' => 'Globale Instanzen', 'status' => 'draft', 'type' => 'page']],
|
||
'count' => 1,
|
||
]);
|
||
|
||
return new LibraryListAbility($access, LibraryFixtures::loader($access), DiviWriterFixture::designReader(), $usages);
|
||
}
|
||
|
||
public function testListsAPageWithCursor(): void
|
||
{
|
||
LibraryFixtures::given([]);
|
||
|
||
$first = $this->ability()->execute(['limit' => 2]);
|
||
$second = $this->ability()->execute(['limit' => 2, 'cursor' => $first['next_cursor']]);
|
||
|
||
self::assertSame([1055, 1058], array_column($first['items'], 'id'));
|
||
self::assertSame(['normal', 'global'], array_column($first['items'], 'scope'));
|
||
self::assertSame('section', $first['items'][0]['layout_type']);
|
||
self::assertSame(Cursor::encode(2), $first['next_cursor']);
|
||
self::assertSame([1059], array_column($second['items'], 'id'));
|
||
self::assertNull($second['next_cursor']);
|
||
}
|
||
|
||
public function testPassesTheFilters(): void
|
||
{
|
||
LibraryFixtures::given([]);
|
||
$store = LibraryFixtures::store();
|
||
$store->expects('find')->with(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten'], 20, 0)
|
||
->andReturn([]);
|
||
$access = LibraryFixtures::access([], true, $store);
|
||
$ability = new LibraryListAbility(
|
||
$access,
|
||
LibraryFixtures::loader($access),
|
||
DiviWriterFixture::designReader(),
|
||
Mockery::mock(GlobalUsages::class)
|
||
);
|
||
|
||
$result = $ability->execute(['layout_type' => 'module', 'scope' => 'global', 'search' => 'Zeiten', 'category' => '']);
|
||
|
||
self::assertSame(['items' => [], 'next_cursor' => null], $result);
|
||
}
|
||
|
||
public function testDetailOfAGlobalEntryWithOutlineAndAffectedPages(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::item(1058)]);
|
||
|
||
$result = $this->ability()->execute(['id' => 1058]);
|
||
|
||
self::assertSame(1058, $result['items'][0]['id']);
|
||
self::assertNull($result['next_cursor']);
|
||
self::assertStringStartsWith('s0 section "Global Abschnitt"', $result['outline']);
|
||
self::assertSame(12, strlen($result['tree_rev']));
|
||
self::assertSame(1, $result['affected_count']);
|
||
self::assertSame('https://kunde.test/wp-admin/post.php?post=1058&action=edit', $result['edit_url']);
|
||
}
|
||
|
||
public function testDetailOfSomethingElseIsNotFoundAndTheRoleIsNeeded(): void
|
||
{
|
||
LibraryFixtures::given([LibraryFixtures::page('')]);
|
||
|
||
self::assertToolError($this->ability()->execute(['id' => LibraryFixtures::PAGE]), 'not_found');
|
||
self::assertToolError($this->ability(['divi_library'])->permission([]), 'forbidden');
|
||
self::assertToolError($this->ability()->execute(['cursor' => '!!']), 'validation_failed');
|
||
self::assertTrue($this->ability()->annotations()['readonly']);
|
||
self::assertFalse($this->ability()->isDirect());
|
||
}
|
||
}
|
||
```
|
||
|
||
(`LibraryFixtures::given([])` stubbt die Rechte ohne Posts; `Cursor::invalid()` liefert `validation_failed`.)
|
||
|
||
In `DiviIntegrationTest`:
|
||
|
||
```php
|
||
public function testProvidesTheLibraryGroup(): void
|
||
{
|
||
$library = (new DiviIntegration(Mockery::mock(ContentSchema::class), FakeChangeLog::forbidden()))->library();
|
||
|
||
self::assertSame('divi-library', $library->id());
|
||
self::assertFalse($library->isAvailable(), 'no Divi classes in unit tests');
|
||
self::assertSame(
|
||
['lit/library-list'],
|
||
array_map(static fn (Ability $ability): string => $ability->name(), $library->abilities())
|
||
);
|
||
foreach ($library->abilities() as $ability) {
|
||
self::assertFalse($ability->inputSchema()['additionalProperties'], $ability->name());
|
||
self::assertNotNull($ability->outputSchema(), $ability->name());
|
||
}
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryListAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL (Klassen fehlen)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Library/LibraryListAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Cursor;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/library-list: the Divi Library (spec §5.7, E9 – replaces lit/library-get). Without id a page
|
||
* of published entries (filters, cursor); with id one entry with its node outline, tree_rev and,
|
||
* for a global entry, the pages that show it. Rights: LibraryAccess (edit_posts + divi_library;
|
||
* one entry also edit_post).
|
||
*/
|
||
final class LibraryListAbility implements Ability
|
||
{
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviPageLoader $loader,
|
||
private DesignSystemReader $design,
|
||
private GlobalUsages $usages
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/library-list';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Divi-Bibliothek auflisten';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Listet Einträge der Divi-Bibliothek (Layouts, Abschnitte, Zeilen, Module; normal oder global), '
|
||
. 'filterbar nach layout_type, scope, search und category, blätterbar mit cursor. Mit id: ein Eintrag '
|
||
. 'im Detail – Knoten-Übersicht, tree_rev und bei globalen Einträgen die Seiten, die ihn zeigen '
|
||
. '(affected). Einfügen mit lit-library-insert; bearbeiten mit den Divi-Werkzeugen und der ID als post_id.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge([
|
||
'id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Ein Eintrag im Detail.'],
|
||
'layout_type' => ['type' => 'string', 'enum' => DiviLibraryStore::LAYOUT_TYPES],
|
||
'scope' => ['type' => 'string', 'enum' => ['normal', 'global']],
|
||
'search' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Suche im Titel.'],
|
||
'category' => ['type' => 'string', 'maxLength' => 100, 'description' => 'Name einer Kategorie.'],
|
||
], Cursor::inputProperties()),
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'items' => [
|
||
'type' => 'array',
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'id' => ['type' => 'integer'],
|
||
'title' => ['type' => 'string'],
|
||
'layout_type' => ['type' => ['string', 'null']],
|
||
'scope' => ['type' => 'string'],
|
||
'module_type' => ['type' => ['string', 'null']],
|
||
'categories' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
'tags' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
'modified' => ['type' => 'string'],
|
||
],
|
||
'required' => ['id', 'title', 'layout_type', 'scope', 'module_type', 'categories', 'tags', 'modified'],
|
||
],
|
||
],
|
||
'next_cursor' => ['type' => ['string', 'null']],
|
||
'outline' => ['type' => 'string'],
|
||
'tree_rev' => ['type' => 'string'],
|
||
'rev' => ['type' => 'string'],
|
||
'edit_url' => ['type' => 'string'],
|
||
'affected' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'affected_count' => ['type' => 'integer'],
|
||
],
|
||
'required' => ['items', 'next_cursor'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::read();
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$refusal = $this->access->canList();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
if (! isset($input['id'])) {
|
||
return true;
|
||
}
|
||
|
||
$page = $this->loader->load($input['id'], false);
|
||
|
||
return $page instanceof WP_Error ? $page : true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->access->canList();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
if (isset($input['id'])) {
|
||
return $this->detail($input['id']);
|
||
}
|
||
|
||
$offset = Cursor::decode(is_string($input['cursor'] ?? null) ? $input['cursor'] : null);
|
||
if ($offset === null) {
|
||
return Cursor::invalid();
|
||
}
|
||
|
||
$limit = Cursor::limit($input['limit'] ?? null);
|
||
$filters = array_filter(
|
||
[
|
||
'layout_type' => $input['layout_type'] ?? null,
|
||
'scope' => $input['scope'] ?? null,
|
||
'search' => $input['search'] ?? null,
|
||
'category' => $input['category'] ?? null,
|
||
],
|
||
static fn (mixed $value): bool => is_string($value) && $value !== ''
|
||
);
|
||
$store = $this->access->store();
|
||
$page = Cursor::page(
|
||
array_map(static fn (WP_Post $post): array => $store->describe($post), $store->find($filters, $limit, $offset)),
|
||
$limit,
|
||
$offset
|
||
);
|
||
|
||
return ['items' => $page['items'], 'next_cursor' => $page['next_cursor']];
|
||
}
|
||
|
||
/** @return array<string, mixed>|WP_Error */
|
||
private function detail(mixed $id): array|WP_Error
|
||
{
|
||
$page = $this->loader->load($id, false);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
|
||
if (! $page->isLibraryItem()) {
|
||
return Errors::notFound(
|
||
sprintf('Eintrag %d ist kein Eintrag der Divi-Bibliothek.', (int) $page->post->ID),
|
||
'Einträge mit lit/library-list (ohne id) auflisten.'
|
||
);
|
||
}
|
||
|
||
$item = $this->access->store()->describe($page->post);
|
||
$outline = new Outline($this->design->read()->presetNames());
|
||
$result = [
|
||
'items' => [$item],
|
||
'next_cursor' => null,
|
||
'outline' => $outline->text($outline->rows($page->index)),
|
||
'tree_rev' => $page->treeRev(),
|
||
'rev' => Rev::ofPost($page->post),
|
||
'edit_url' => $page->editUrl(),
|
||
];
|
||
if ($item['scope'] === 'global') {
|
||
$usages = $this->usages->of((int) $page->post->ID);
|
||
$result['affected'] = $usages['items'];
|
||
$result['affected_count'] = $usages['count'];
|
||
}
|
||
|
||
return $result;
|
||
}
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Library/LibraryIntegration.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use LIT\MCP\Integrations\Integration;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* The group "Divi-Bibliothek" (E9): lit/library-list, lit/library-save, lit/library-insert. Built by
|
||
* DiviIntegration::library() so it shares the library access (rights, Tools-tab gate). Available
|
||
* with the Divi 5 builder, like the Divi group.
|
||
*/
|
||
final class LibraryIntegration implements Integration
|
||
{
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviPageLoader $loader,
|
||
private DesignSystemReader $design
|
||
) {
|
||
}
|
||
|
||
public function id(): string
|
||
{
|
||
return LibraryAccess::GROUP;
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return __('Divi-Bibliothek', 'wp-lit-mcp');
|
||
}
|
||
|
||
public function isAvailable(): bool
|
||
{
|
||
return DiviModules::isAvailable();
|
||
}
|
||
|
||
public function abilities(): array
|
||
{
|
||
return [
|
||
new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()),
|
||
];
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegration.php` (Import `LibraryIntegration`):
|
||
|
||
```php
|
||
/** The group "Divi-Bibliothek" (lit/library-*), sharing this integration's library access. */
|
||
public function library(): LibraryIntegration
|
||
{
|
||
return new LibraryIntegration($this->access, $this->loader(), $this->design);
|
||
}
|
||
|
||
private function loader(): DiviPageLoader
|
||
{
|
||
return new DiviPageLoader($this->schema, new Parser(), $this->access);
|
||
}
|
||
```
|
||
|
||
und in `abilities()` `$loader = $this->loader();`.
|
||
|
||
`Plugin::boot()` – die Registry um die Gruppe erweitern:
|
||
|
||
```php
|
||
$registry = new IntegrationRegistry(
|
||
new CoreIntegration(self::pluginDir() . '/docs/guides'),
|
||
new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),
|
||
new MediaIntegration($schema, $changeLog, $limits),
|
||
$divi,
|
||
$divi->library()
|
||
);
|
||
```
|
||
|
||
`docs/guides/overview.md`: in „Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi)“ → „(Grundlagen, WordPress, Medien, Divi, Divi-Bibliothek)“; am Ende der indirekten Liste „Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`).“ → „Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`), Divi-Bibliothek (`lit/library-list`).“
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryListAbilityTest|DiviIntegrationTest|ToolNamingTest|ToolsTabTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library/LibraryIntegration.php src/Integrations/Divi/Library/LibraryListAbility.php src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi/Library/LibraryListAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: group Divi-Bibliothek with lit/library-list`.
|
||
|
||
---
|
||
|
||
### Task 11: `StoredNodeGuard` – gespeicherte Knoten, die den Post wechseln (E6)
|
||
|
||
Audit M1 und R2-Befund 1: Wer einen gespeicherten Knoten über eine andere Post-ID überträgt, darf die S7/S26-Sperre nicht umgehen, und Divi filtert Dynamic-Content-`before`/`after` nach dem **Autor des Ziel-Posts**. Für Benutzer ohne `unfiltered_html` wird deshalb abgelehnt (nicht bereinigt): Builder-only-Pfade (`ValueCleaner::guardPath()`), Code-Modul, `$variable`-Verweise, die `VariableRef::canonical()` für das Ziel ablehnt oder deren Einstellungen es verändern würde. Alles andere bleibt byte-identisch. Auch `localAttrs` globaler Instanzen werden geprüft (Divi nutzt sie für entkoppelte Attribute, F7).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Compose/StoredNodeGuard.php`
|
||
- Modify: `src/Integrations/Divi/Compose/VariableRef.php` (`settingsOf()`)
|
||
- Modify: `src/Integrations/Divi/Pages/DiviComposition.php` (`storedGuard()`)
|
||
- Test: `tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `ValueCleaner::guardPath()/kindOf()`, `VariableRef::canonical()`, `AttrMap::flatten()`, `GlobalInstance::effectiveName()/BLOCK`.
|
||
- Produces: `final class StoredNodeGuard { const CODE_MODULES = ['divi/code', 'divi/fullwidth-code']; __construct(ValueCleaner $cleaner, VariableRef $references, ValueSanitizer $sanitizer); problems(Block $root): list<string> }`; `VariableRef::settingsOf(string $value): array`; `DiviComposition::storedGuard(): StoredNodeGuard`.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Compose\StoredNodeGuard;
|
||
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
|
||
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Tests\Support\ComposeFixture;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class StoredNodeGuardTest extends TestCase
|
||
{
|
||
private static function guard(bool $unfiltered = false): StoredNodeGuard
|
||
{
|
||
$references = new VariableRef(
|
||
DiviFixtures::designSystem(),
|
||
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
|
||
);
|
||
$sanitizer = ComposeFixture::sanitizer($unfiltered);
|
||
|
||
return new StoredNodeGuard(new ValueCleaner(DiviFixtures::catalog(), $references, $sanitizer), $references, $sanitizer);
|
||
}
|
||
|
||
private static function block(string $markup): Block
|
||
{
|
||
return (new Parser())->parse($markup)->children()[0];
|
||
}
|
||
|
||
public function testBuilderContentWithInstancesAndLinksPasses(): void
|
||
{
|
||
$index = NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')));
|
||
$link = '<!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt","linkUrl":'
|
||
. '"$variable({"type":"content","value":{"name":'
|
||
. '"post_link_url_page","settings":{"post_id":"839"}}})$"}}}}} /-->';
|
||
|
||
self::assertSame([], self::guard()->problems($index->find('s0')));
|
||
self::assertSame([], self::guard()->problems($index->find('g0')));
|
||
self::assertSame([], self::guard()->problems(self::block($link)));
|
||
}
|
||
|
||
/** @return array<string, array{string, string}> */
|
||
public static function refused(): array
|
||
{
|
||
$ref = static fn (string $name, string $settings): string => '"$variable({"type":"content",'
|
||
. '"value":{"name":"' . $name . '","settings":' . $settings . '}})$"';
|
||
|
||
return [
|
||
'custom css' => [
|
||
'<!-- wp:divi/text {"css":{"desktop":{"value":{"freeForm":"body{display:none}"}}}} /-->',
|
||
'"css"',
|
||
],
|
||
'html attributes' => [
|
||
'<!-- wp:divi/text {"module":{"decoration":{"attributes":{"desktop":{"value":{"attributes":'
|
||
. '[{"name":"data-x","value":"1"}]}}}}}} /-->',
|
||
'module.decoration.attributes',
|
||
],
|
||
'code module' => ['<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"x"}}}} /-->', 'Code-Modul'],
|
||
'script in before' => [
|
||
'<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":' . $ref(
|
||
'post_title',
|
||
'{"before":"<script>x</script>","after":""}'
|
||
) . '}}}} /-->',
|
||
'nicht schreiben darf',
|
||
],
|
||
'protected meta' => [
|
||
'<!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":' . $ref('custom_meta__edit_lock', '{}')
|
||
. '}}}} /-->',
|
||
'custom_meta__edit_lock',
|
||
],
|
||
'css in the local attributes of an instance' => [
|
||
'<!-- wp:divi/global-layout {"globalModule":"5","blockName":"divi/text","localAttrs":{"css":{"desktop":'
|
||
. '{"value":{"freeForm":"a{}"}}}}} /-->',
|
||
'"css"',
|
||
],
|
||
'code module deep inside a section' => [
|
||
'<!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column --><!-- wp:divi/code /-->'
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section -->',
|
||
'Code-Modul',
|
||
],
|
||
];
|
||
}
|
||
|
||
/** @dataProvider refused */
|
||
public function testRefusesBuilderOnlyValuesForUsersWithoutUnfilteredHtml(string $markup, string $problem): void
|
||
{
|
||
$problems = self::guard()->problems(self::block($markup));
|
||
|
||
self::assertNotSame([], $problems);
|
||
self::assertStringContainsString($problem, implode(' | ', $problems));
|
||
self::assertSame([], self::guard(true)->problems(self::block($markup)), 'unfiltered_html may carry it');
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviCompositionTest` ergänzen:
|
||
|
||
```php
|
||
public function testStoredGuardSharesTheCleanerAndReferences(): void
|
||
{
|
||
$composition = DiviWriterFixture::composition(DiviWriterFixture::designReader())->forPost(7);
|
||
|
||
self::assertInstanceOf(StoredNodeGuard::class, $composition->storedGuard());
|
||
self::assertSame(
|
||
[],
|
||
$composition->storedGuard()->problems(Block::create('divi/text', ['builderVersion' => '5.2.1']))
|
||
);
|
||
}
|
||
```
|
||
|
||
(Imports `StoredNodeGuard`, `Block`, `DiviWriterFixture`.)
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'StoredNodeGuardTest|DiviCompositionTest'`
|
||
Expected: FAIL (`StoredNodeGuard` fehlt)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`VariableRef.php` – nach `typeOf()`:
|
||
|
||
```php
|
||
/**
|
||
* The settings of $value when it is exactly one reference ([] otherwise). StoredNodeGuard compares
|
||
* a stored reference with canonical(): different settings mean cleaning would change them.
|
||
*
|
||
* @return array<array-key, mixed>
|
||
*/
|
||
public static function settingsOf(string $value): array
|
||
{
|
||
try {
|
||
$payload = self::payloadOf($value);
|
||
} catch (InvalidArgumentException) {
|
||
return [];
|
||
}
|
||
|
||
$settings = $payload['value']['settings'] ?? [];
|
||
|
||
return is_array($settings) ? $settings : [];
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Compose/StoredNodeGuard.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* E6 (security audit M1, R2 finding 1): a stored node that moves to another post – page → page
|
||
* (lit/divi-node-copy), library → page (lit/library-insert), page → library (lit/library-save) – keeps
|
||
* its bytes, but the builder-only gate must hold across post IDs, and Divi filters dynamic content
|
||
* before/after by the author of the TARGET post. For users without unfiltered_html the node is
|
||
* refused when anything in it (localAttrs of instances included) is
|
||
* - a builder-only path (ValueCleaner::guardPath(): own HTML, HTML attributes, custom CSS – S7/S26),
|
||
* - a code module,
|
||
* - a $variable(…)$ reference VariableRef::canonical() refuses for the target post, or whose
|
||
* settings canonical() would clean (e.g. before/after with markup such a user may not write).
|
||
* Nothing is cleaned or rewritten – a refused node stays a job for the Divi builder. Pure PHP.
|
||
*/
|
||
final class StoredNodeGuard
|
||
{
|
||
public const CODE_MODULES = ['divi/code', 'divi/fullwidth-code'];
|
||
|
||
/** Keys of a breakpoint map that are no sub-field names. */
|
||
private const STATE_KEYS = ['value', 'hover', 'sticky'];
|
||
|
||
public function __construct(
|
||
private ValueCleaner $cleaner,
|
||
private VariableRef $references,
|
||
private ValueSanitizer $sanitizer
|
||
) {
|
||
}
|
||
|
||
/** @return list<string> German problems; [] when $root may move to the target post. */
|
||
public function problems(Block $root): array
|
||
{
|
||
if ($this->sanitizer->allowsUnfilteredHtml()) {
|
||
return [];
|
||
}
|
||
|
||
$problems = [];
|
||
$this->walk($root, $problems);
|
||
|
||
return array_values(array_unique($problems));
|
||
}
|
||
|
||
/** @param list<string> $problems */
|
||
private function walk(Block $block, array &$problems): void
|
||
{
|
||
$module = GlobalInstance::effectiveName($block);
|
||
if (in_array($module, self::CODE_MODULES, true)) {
|
||
$problems[] = sprintf(
|
||
'%s: Code-Modul – nur im Divi-Builder übertragbar (oder mit dem Recht unfiltered_html).',
|
||
$module
|
||
);
|
||
}
|
||
|
||
$attrs = $block->attrs();
|
||
if ($block->name() === GlobalInstance::BLOCK) {
|
||
$attrs = is_array($attrs['localAttrs'] ?? null) ? $attrs['localAttrs'] : [];
|
||
}
|
||
|
||
foreach (AttrMap::flatten($attrs) as $path => $value) {
|
||
try {
|
||
$this->cleaner->guardPath((string) $path);
|
||
} catch (InvalidArgumentException $e) {
|
||
$problems[] = $e->getMessage();
|
||
continue;
|
||
}
|
||
$this->references($module, (string) $path, $value, null, $problems);
|
||
}
|
||
|
||
foreach ($block->children() as $child) {
|
||
$this->walk($child, $problems);
|
||
}
|
||
}
|
||
|
||
/** @param list<string> $problems */
|
||
private function references(string $module, string $path, mixed $value, ?string $key, array &$problems): void
|
||
{
|
||
if (is_array($value)) {
|
||
foreach ($value as $childKey => $item) {
|
||
$isField = is_string($childKey)
|
||
&& ! in_array($childKey, AttrMap::BREAKPOINTS, true)
|
||
&& ! in_array($childKey, self::STATE_KEYS, true);
|
||
$this->references($module, $path, $item, $isField ? $childKey : $key, $problems);
|
||
}
|
||
|
||
return;
|
||
}
|
||
|
||
if (! is_string($value) || ! VariableRef::contains($value)) {
|
||
return;
|
||
}
|
||
|
||
try {
|
||
$url = $this->cleaner->kindOf($module, $path, $key) === ValueKind::Url;
|
||
$canonical = $this->references->canonical($value, $this->sanitizer, $url);
|
||
} catch (InvalidArgumentException $e) {
|
||
$problems[] = sprintf('%s: %s', $path, $e->getMessage());
|
||
|
||
return;
|
||
}
|
||
|
||
if (self::normalized(VariableRef::settingsOf($canonical)) !== self::normalized(VariableRef::settingsOf($value))) {
|
||
$problems[] = sprintf(
|
||
'%s: Divi-Verweis mit Einstellungen, die dieses Konto nicht schreiben darf – nur im Divi-Builder '
|
||
. 'übertragbar.',
|
||
$path
|
||
);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* @param array<array-key, mixed> $settings
|
||
* @return array<array-key, mixed> Keys sorted, scalars as strings (post_id 839 = "839").
|
||
*/
|
||
private static function normalized(array $settings): array
|
||
{
|
||
ksort($settings);
|
||
|
||
return array_map(static fn (mixed $value): mixed => is_scalar($value) ? (string) $value : $value, $settings);
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviComposition` (Import `StoredNodeGuard`):
|
||
|
||
```php
|
||
/** E6: the check for stored nodes that move into this composition's entry. */
|
||
public function storedGuard(): StoredNodeGuard
|
||
{
|
||
return new StoredNodeGuard($this->cleaner(), $this->references(), $this->sanitizer);
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'StoredNodeGuardTest|DiviCompositionTest|VariableRefTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Compose/StoredNodeGuard.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Pages/DiviComposition.php tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: StoredNodeGuard keeps S7/S26 for nodes moving between posts (E6)`.
|
||
|
||
---
|
||
|
||
### Task 12: `lit/library-save` – Knoten als Bibliothekseintrag (E4)
|
||
|
||
E4: Das Plugin legt Einträge selbst an – kein `et_pb_submit_layout` (im REST-Kontext nicht geladen, speichert mit kses, F2), keine Builder-Route (Nonce, Preset-Import, F3). `wp_insert_post` mit leerem Inhalt und Status `publish` (Divi kennt keine Bibliotheksentwürfe), Terme und Meta wie `et_pb_submit_layout` (verglichen mit den echten Builder-Einträgen der Fixtures; `_et_pb_row_layout` wird nicht geschrieben – Divi selbst speichert dort `"[object Object]"`, Befund 5), danach der Inhalt über `DiviSaver::saveNew` (`SaveScope::LibraryItem`, ohne Divi-Flags, Befund 4) als `divi/placeholder`-Rahmen um die unveränderten Bytes des Knotens. Alles oder nichts: jeder Fehler löscht den neuen Eintrag endgültig. Der Knoten auf der Seite bleibt unverändert (das Verknüpfen ist `lit/library-make-global`, Etappe 3b).
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Library/LibraryItemSpec.php`
|
||
- Modify: `src/Integrations/Divi/Library/DiviLibraryStore.php` (`create()`, `delete()`)
|
||
- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`canSave()`)
|
||
- Create: `src/Integrations/Divi/Library/LibrarySaveAbility.php`
|
||
- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php`, `src/Integrations/Divi/DiviIntegration.php`
|
||
- Modify: `docs/guides/overview.md` (indirekte Tools)
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php`, `…/DiviLibraryStoreTest.php`, `…/LibraryAccessTest.php`, `…/LibrarySaveAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `LibraryContent::wrap/containsGlobal`, `NodeSummary::globalRef`, `ModuleCatalog::has/category`, `DiviSaver::saveNew(…, SaveScope::LibraryItem)`, `StoredNodeGuard` über `DiviWriter::composition($page)->storedGuard()`, `PostSaveFollowUp`, `Errors::writeFailed/partialWrite/revMismatch`.
|
||
- Produces:
|
||
- `final class LibraryItemSpec { __construct(string $title, string $layoutType, bool $global, string $moduleWidth, ?string $moduleType, string $builtFor, array $categories = [], array $tags = []); static forNode(Block $node, ModuleCatalog $catalog, string $title, bool $global, string $builtFor): self|string; withTerms(array $categories, array $tags): self; terms(): array<string, list<int|string>>; meta(): array<string, string> }`
|
||
- `DiviLibraryStore::create(LibraryItemSpec $spec): int` (wirft `RuntimeException`, löscht bei Teilfehlern), `DiviLibraryStore::delete(int $id): bool`
|
||
- `LibraryAccess::canSave(): ?WP_Error`
|
||
- `final class LibrarySaveAbility implements Ability { __construct(LibraryAccess $access, DiviPageLoader $loader, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules) }` – `lit/library-save`, indirekt, schreibend (nicht idempotent); Ergebnis `ToolResult` + `library_id`, `tree_rev`.
|
||
- `LibraryIntegration::__construct(LibraryAccess $access, DiviPageLoader $loader, DesignSystemReader $design, DiviWriter $writer, DiviSaver $saver, ChangeLog $log, DiviModules $modules)`
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Library/LibraryItemSpecTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use PHPUnit\Framework\TestCase;
|
||
|
||
final class LibraryItemSpecTest extends TestCase
|
||
{
|
||
private static function pageNode(string $id): Block
|
||
{
|
||
return NodeIndex::build((new Parser())->parse(DiviFixtures::markup('stage3/page--globale-instanzen.html')))
|
||
->find($id);
|
||
}
|
||
|
||
private static function libraryRoot(string $file): Block
|
||
{
|
||
return LibraryContent::roots(LibraryContent::load(new Parser(), DiviFixtures::markup('stage3/' . $file)))[0];
|
||
}
|
||
|
||
/** @return array<string, array{string, bool}> Fixture role => [node source, global]. */
|
||
public static function builderEntries(): array
|
||
{
|
||
return [
|
||
'library--library-abschnitt' => ['page:s1', false],
|
||
'library--library-zeile' => ['page:s1.r0', false],
|
||
'library--library-modul' => ['page:s1.r0.c0.m0', false],
|
||
'library-global--globaler-abschnitt' => ['library:library-global--globaler-abschnitt.html', true],
|
||
'library-global--globales-modul' => ['library:library-global--globales-modul.html', true],
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Terms and meta as Divi's own "add to library" wrote them (fixtures stage3, JSON of each entry),
|
||
* without Divi's broken _et_pb_row_layout ("[object Object]", finding 5).
|
||
*
|
||
* @dataProvider builderEntries
|
||
*/
|
||
public function testTermsAndMetaMatchTheBuilderEntries(string $source, bool $global): void
|
||
{
|
||
[$kind, $ref] = explode(':', $source, 2);
|
||
$node = $kind === 'page' ? self::pageNode($ref) : self::libraryRoot($ref);
|
||
$role = (string) $this->dataName();
|
||
$json = json_decode(DiviFixtures::markup('stage3/' . $role . '.json'), true);
|
||
|
||
$spec = LibraryItemSpec::forNode($node, DiviFixtures::catalog(), 'Titel', $global, 'page');
|
||
|
||
self::assertInstanceOf(LibraryItemSpec::class, $spec);
|
||
$expectedTerms = [];
|
||
foreach (['scope', 'layout_type', 'module_width'] as $taxonomy) {
|
||
$expectedTerms[$taxonomy] = array_column($json['terms'][$taxonomy], 'slug');
|
||
}
|
||
self::assertEquals($expectedTerms, $spec->terms());
|
||
$expectedMeta = array_map(static fn (array $values): string => $values[0], $json['meta']);
|
||
unset($expectedMeta['_et_pb_row_layout']);
|
||
self::assertEquals($expectedMeta, $spec->meta());
|
||
}
|
||
|
||
public function testRefusesInstancesColumnsAndGlobalsInsideGlobals(): void
|
||
{
|
||
$catalog = DiviFixtures::catalog();
|
||
|
||
self::assertStringContainsString(
|
||
'bereits ein globales Modul (Bibliothekseintrag 1058)',
|
||
(string) LibraryItemSpec::forNode(self::pageNode('g0'), $catalog, 'T', false, 'page')
|
||
);
|
||
self::assertStringContainsString(
|
||
'nur Abschnitte, Zeilen und Module',
|
||
(string) LibraryItemSpec::forNode(self::pageNode('s1.r0.c0'), $catalog, 'T', false, 'page')
|
||
);
|
||
self::assertStringContainsString(
|
||
'keine globalen Module',
|
||
(string) LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', true, 'page')
|
||
);
|
||
self::assertInstanceOf(LibraryItemSpec::class, LibraryItemSpec::forNode(self::pageNode('s0'), $catalog, 'T', false, 'page'));
|
||
}
|
||
|
||
public function testFullwidthAndTerms(): void
|
||
{
|
||
$section = Block::create('divi/section', ['module' => ['advanced' => ['type' => ['desktop' => ['value' => 'fullwidth']]]]]);
|
||
|
||
$spec = LibraryItemSpec::forNode($section, DiviFixtures::catalog(), 'Kopf', false, 'page');
|
||
|
||
self::assertSame(['fullwidth'], $spec->terms()['module_width']);
|
||
self::assertSame([26], $spec->withTerms([26], [])->terms()['layout_category']);
|
||
self::assertArrayNotHasKey('layout_tag', $spec->withTerms([26], [])->terms());
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviLibraryStoreTest`:
|
||
|
||
```php
|
||
public function testCreatesAPublishedEntryWithTermsAndMeta(): void
|
||
{
|
||
Functions\when('taxonomy_exists')->justReturn(true);
|
||
$inserted = [];
|
||
Functions\when('wp_insert_post')->alias(static function (array $data) use (&$inserted): int {
|
||
$inserted = $data;
|
||
|
||
return 2001;
|
||
});
|
||
$terms = [];
|
||
Functions\when('wp_set_object_terms')->alias(static function (int $id, array $values, string $taxonomy) use (&$terms): array {
|
||
$terms[$taxonomy] = $values;
|
||
|
||
return [1];
|
||
});
|
||
$meta = [];
|
||
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$meta): bool {
|
||
$meta[$key] = $value;
|
||
|
||
return true;
|
||
});
|
||
Functions\when('get_post_meta')->alias(static fn (int $id, string $key): string => $meta[$key] ?? '');
|
||
$spec = new LibraryItemSpec('Öffnungszeiten', 'module', true, 'regular', 'divi/text', 'page');
|
||
|
||
self::assertSame(2001, (new DiviLibraryStore())->create($spec));
|
||
self::assertSame(
|
||
['post_type' => 'et_pb_layout', 'post_status' => 'publish', 'post_title' => 'Öffnungszeiten', 'post_content' => ''],
|
||
$inserted
|
||
);
|
||
self::assertSame(['scope' => ['global'], 'layout_type' => ['module'], 'module_width' => ['regular']], $terms);
|
||
self::assertSame('[]', $meta['_et_pb_excluded_global_options']);
|
||
}
|
||
|
||
public function testAFailedStepDeletesTheNewEntry(): void
|
||
{
|
||
Functions\when('taxonomy_exists')->justReturn(true);
|
||
Functions\when('wp_insert_post')->justReturn(2001);
|
||
Functions\when('wp_set_object_terms')->justReturn(new \WP_Error('invalid_taxonomy', 'Ungültige Taxonomie.'));
|
||
Functions\expect('wp_delete_post')->once()->with(2001, true)->andReturn(new WP_Post(['ID' => 2001]));
|
||
|
||
$this->expectException(\RuntimeException::class);
|
||
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'));
|
||
}
|
||
|
||
public function testRefusesWhenTheLibraryTaxonomiesAreMissing(): void
|
||
{
|
||
Functions\when('taxonomy_exists')->justReturn(false);
|
||
Functions\expect('wp_insert_post')->never();
|
||
|
||
$this->expectException(\RuntimeException::class);
|
||
(new DiviLibraryStore())->create(new LibraryItemSpec('X', 'section', false, 'regular', null, 'page'));
|
||
}
|
||
```
|
||
|
||
(Import `LibraryItemSpec`. Die Stubs aus `setUp()` für `get_post_meta` werden im ersten Test per `Functions\when` überschrieben.)
|
||
|
||
In `LibraryAccessTest`:
|
||
|
||
```php
|
||
public function testSavingNeedsPublishPostsSaveLibraryAndAWritableGroup(): void
|
||
{
|
||
LibraryFixtures::given([]);
|
||
self::assertNull(LibraryFixtures::access()->canSave());
|
||
|
||
LibraryFixtures::given([], ['publish_posts']);
|
||
self::assertToolError(LibraryFixtures::access()->canSave(), 'forbidden');
|
||
|
||
LibraryFixtures::given([]);
|
||
self::assertToolError(LibraryFixtures::access(['save_library'])->canSave(), 'forbidden');
|
||
self::assertToolError(LibraryFixtures::access([], false)->canSave(), 'forbidden');
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Library/LibrarySaveAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryItemSpec;
|
||
use LIT\MCP\Integrations\Divi\Library\LibrarySaveAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\FakeChangeLog;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use RuntimeException;
|
||
use WP_Post;
|
||
use WP_Term;
|
||
|
||
final class LibrarySaveAbilityTest extends TestCase
|
||
{
|
||
private WP_Post $page;
|
||
|
||
/** @var list<LibraryItemSpec> */
|
||
private array $specs = [];
|
||
|
||
/** @var list<array{int, string, SaveScope}> */
|
||
private array $contents = [];
|
||
|
||
private int $deleted = 0;
|
||
|
||
private FakeChangeLog $log;
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->page = LibraryFixtures::page(DiviFixtures::markup('stage3/page--globale-instanzen.html'));
|
||
LibraryFixtures::given([$this->page]);
|
||
Functions\when('get_term_by')->alias(static fn (string $field, string $name, string $taxonomy): WP_Term|false => $name === 'Fixture'
|
||
? new WP_Term(['term_id' => 26, 'name' => 'Fixture', 'taxonomy' => $taxonomy])
|
||
: false);
|
||
$this->log = new FakeChangeLog(4711);
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
private function ability(array $deniedRoles = [], bool $writable = true, ?\Throwable $layoutFailure = null): LibrarySaveAbility
|
||
{
|
||
$store = LibraryFixtures::store();
|
||
$store->allows('create')->andReturnUsing(function (LibraryItemSpec $spec): int {
|
||
$this->specs[] = $spec;
|
||
|
||
return 2001;
|
||
});
|
||
$store->allows('delete')->andReturnUsing(function (int $id): bool {
|
||
++$this->deleted;
|
||
|
||
return true;
|
||
});
|
||
$saver = Mockery::mock(DiviSaver::class);
|
||
$saver->allows('saveNew')->andReturnUsing(function (int $id, string $content, SaveScope $scope) use ($layoutFailure): array {
|
||
if ($layoutFailure !== null) {
|
||
throw $layoutFailure;
|
||
}
|
||
$this->contents[] = [$id, $content, $scope];
|
||
LibraryFixtures::given([$this->page, LibraryFixtures::item(1055, ['ID' => 2001, 'post_content' => $content])]);
|
||
|
||
return [];
|
||
});
|
||
$access = LibraryFixtures::access($deniedRoles, $writable, $store);
|
||
$saved = [];
|
||
$failures = [];
|
||
|
||
return new LibrarySaveAbility(
|
||
$access,
|
||
LibraryFixtures::loader($access),
|
||
DiviWriterFixture::writer($saved, $failures, false, LibraryFixtures::loader($access)),
|
||
$saver,
|
||
$this->log,
|
||
DiviWriterFixture::modules()
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function input(array $extra = []): array
|
||
{
|
||
return array_merge(
|
||
['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content), 'id' => 's1', 'title' => 'Leistungen'],
|
||
$extra
|
||
);
|
||
}
|
||
|
||
public function testSavesASectionLikeTheBuilder(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['categories' => ['Fixture']]));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertSame(2001, $result['library_id']);
|
||
self::assertSame('section', $this->specs[0]->layoutType);
|
||
self::assertSame([26], $this->specs[0]->terms()['layout_category']);
|
||
self::assertSame(
|
||
str_replace("\r\n", "\n", DiviFixtures::markup('stage3/library--library-abschnitt.html')),
|
||
$this->contents[0][1]
|
||
);
|
||
self::assertSame(SaveScope::LibraryItem, $this->contents[0][2]);
|
||
self::assertSame(Rev::of($this->contents[0][1]), $result['tree_rev']);
|
||
self::assertSame('et_pb_layout', $this->log->records[0]['object_type']);
|
||
self::assertSame('2001', $this->log->records[0]['object_id']);
|
||
}
|
||
|
||
public function testSavesAGlobalModule(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['id' => 's0.r0.c0.m0', 'title' => 'Kontakt-Button', 'scope' => 'global']));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertTrue($this->specs[0]->global);
|
||
self::assertSame('divi/button', $this->specs[0]->meta()['_et_pb_module_type']);
|
||
self::assertSame('[]', $this->specs[0]->meta()['_et_pb_excluded_global_options']);
|
||
}
|
||
|
||
public function testRefusesAGlobalInstanceAndColumnsAndCreatesNothing(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['id' => 'g0'])), 'validation_failed');
|
||
self::assertToolError($this->ability()->execute($this->input(['id' => 's1.r0.c0'])), 'validation_failed');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testStaleSourceTreeRevCreatesNothing(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testNeedsPublishPostsAndTheDiviRole(): void
|
||
{
|
||
self::assertToolError($this->ability(['save_library'])->permission($this->input()), 'forbidden');
|
||
self::assertToolError($this->ability([], false)->permission($this->input()), 'forbidden');
|
||
LibraryFixtures::given([$this->page], ['publish_posts']);
|
||
self::assertToolError($this->ability()->permission($this->input()), 'forbidden');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testEditorCannotSaveACodeModule(): void
|
||
{
|
||
$this->page = LibraryFixtures::page(
|
||
'<!-- wp:divi/placeholder --><!-- wp:divi/section --><!-- wp:divi/row --><!-- wp:divi/column -->'
|
||
. '<!-- wp:divi/code {"content":{"innerContent":{"desktop":{"value":"<b>x</b>"}}}} /-->'
|
||
. '<!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->'
|
||
);
|
||
LibraryFixtures::given([$this->page]);
|
||
|
||
$result = $this->ability()->execute($this->input(['id' => 's0']));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertStringContainsString('Code-Modul', implode(' ', \LIT\MCP\Abilities\Errors::fieldsOf($result)));
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testUnknownCategoryCreatesNothing(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['categories' => ['Gibt es nicht']]));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertSame([], $this->specs);
|
||
}
|
||
|
||
public function testFailedLayoutWriteDeletesTheEntry(): void
|
||
{
|
||
$log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
|
||
$previous = ini_set('error_log', $log);
|
||
try {
|
||
$result = $this->ability([], true, new RuntimeException('hook failed'))->execute($this->input());
|
||
} finally {
|
||
ini_set('error_log', (string) $previous);
|
||
@unlink($log);
|
||
}
|
||
|
||
self::assertToolError($result, 'write_failed');
|
||
self::assertSame(1, $this->deleted);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviIntegrationTest::testProvidesTheLibraryGroup` die erwartete Liste auf `['lit/library-list', 'lit/library-save']` erweitern.
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryItemSpecTest|DiviLibraryStoreTest|LibraryAccessTest|LibrarySaveAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL (`LibraryItemSpec`, `create`, `canSave`, `LibrarySaveAbility` fehlen)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Library/LibraryItemSpec.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||
use LIT\MCP\Integrations\Divi\Schema\ModuleCatalog;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* Title, terms and meta of a new library entry, like Divi's et_pb_submit_layout() writes them
|
||
* (builder/shortcode-core.php:654-734; compared with the real builder entries, fixtures stage3):
|
||
* terms scope (global / non_global), layout_type, module_width, optional layout_category/layout_tag;
|
||
* meta _et_pb_module_type (modules), _et_pb_excluded_global_options = "[]" (global modules),
|
||
* _et_pb_built_for_post_type, _et_pb_template_type. _et_pb_row_layout is left out – Divi 5.2.1 stores
|
||
* "[object Object]" there (finding 5). Pure PHP.
|
||
*/
|
||
final class LibraryItemSpec
|
||
{
|
||
private const MODULE_CATEGORIES = ['module', 'fullwidth-module'];
|
||
|
||
/**
|
||
* @param list<int> $categories layout_category term IDs.
|
||
* @param list<int> $tags layout_tag term IDs.
|
||
*/
|
||
public function __construct(
|
||
public readonly string $title,
|
||
public readonly string $layoutType,
|
||
public readonly bool $global,
|
||
public readonly string $moduleWidth,
|
||
public readonly ?string $moduleType,
|
||
public readonly string $builtFor,
|
||
public readonly array $categories = [],
|
||
public readonly array $tags = []
|
||
) {
|
||
}
|
||
|
||
/**
|
||
* The entry that holds $node, or the German reason it cannot be saved: sections, rows and
|
||
* modules only (Divi's "add to library"); a global module is already an entry; a global entry
|
||
* holds no global module (F10).
|
||
*/
|
||
public static function forNode(
|
||
Block $node,
|
||
ModuleCatalog $catalog,
|
||
string $title,
|
||
bool $global,
|
||
string $builtFor
|
||
): self|string {
|
||
$ref = NodeSummary::globalRef($node);
|
||
if ($ref !== null) {
|
||
return sprintf(
|
||
'Dieser Knoten ist bereits ein globales Modul (Bibliothekseintrag %s); ihn mit lit-library-insert '
|
||
. 'einfügen statt erneut speichern.',
|
||
$ref['id']
|
||
);
|
||
}
|
||
|
||
$name = $node->name();
|
||
if ($name === 'divi/section') {
|
||
$type = 'section';
|
||
$width = AttrMap::desktop($node->attrs(), 'module.advanced.type') === 'fullwidth' ? 'fullwidth' : 'regular';
|
||
} elseif ($name === 'divi/row') {
|
||
$type = 'row';
|
||
$width = 'regular';
|
||
} elseif ($catalog->has($name) && in_array($catalog->category($name), self::MODULE_CATEGORIES, true)) {
|
||
$type = 'module';
|
||
$width = $catalog->category($name) === 'fullwidth-module' ? 'fullwidth' : 'regular';
|
||
} else {
|
||
return sprintf(
|
||
'%s lässt sich nicht einzeln in der Bibliothek speichern (nur Abschnitte, Zeilen und Module).',
|
||
$name
|
||
);
|
||
}
|
||
|
||
if ($global && LibraryContent::containsGlobal($node)) {
|
||
return 'Ein globaler Bibliothekseintrag darf keine globalen Module enthalten (Divi erlaubt keine '
|
||
. 'Verschachtelung); als normalen Eintrag speichern.';
|
||
}
|
||
|
||
return new self($title, $type, $global, $width, $type === 'module' ? $name : null, $builtFor);
|
||
}
|
||
|
||
/**
|
||
* @param list<int> $categories
|
||
* @param list<int> $tags
|
||
*/
|
||
public function withTerms(array $categories, array $tags): self
|
||
{
|
||
return new self(
|
||
$this->title,
|
||
$this->layoutType,
|
||
$this->global,
|
||
$this->moduleWidth,
|
||
$this->moduleType,
|
||
$this->builtFor,
|
||
$categories,
|
||
$tags
|
||
);
|
||
}
|
||
|
||
/** @return array<string, list<int|string>> Taxonomy => slugs (system terms) or term IDs. */
|
||
public function terms(): array
|
||
{
|
||
$terms = [
|
||
'scope' => [$this->global ? 'global' : 'non_global'],
|
||
'layout_type' => [$this->layoutType],
|
||
'module_width' => [$this->moduleWidth],
|
||
];
|
||
if ($this->categories !== []) {
|
||
$terms['layout_category'] = $this->categories;
|
||
}
|
||
if ($this->tags !== []) {
|
||
$terms['layout_tag'] = $this->tags;
|
||
}
|
||
|
||
return $terms;
|
||
}
|
||
|
||
/** @return array<string, string> */
|
||
public function meta(): array
|
||
{
|
||
$meta = [];
|
||
if ($this->moduleType !== null) {
|
||
$meta['_et_pb_module_type'] = $this->moduleType;
|
||
}
|
||
if ($this->global && $this->layoutType === 'module') {
|
||
$meta['_et_pb_excluded_global_options'] = '[]';
|
||
}
|
||
$meta['_et_pb_built_for_post_type'] = $this->builtFor;
|
||
$meta['_et_pb_template_type'] = $this->layoutType;
|
||
|
||
return $meta;
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviLibraryStore` – neue Methoden (Imports `RuntimeException`, `WP_Error`):
|
||
|
||
```php
|
||
/**
|
||
* A published entry with the terms and meta of $spec and empty content (E4; the layout follows
|
||
* through DiviSaver::saveNew()). All or nothing: when a term or meta write fails, the entry is
|
||
* deleted again before the exception leaves.
|
||
*
|
||
* @throws RuntimeException
|
||
*/
|
||
public function create(LibraryItemSpec $spec): int
|
||
{
|
||
$missing = $this->missingTaxonomies();
|
||
if ($missing !== []) {
|
||
throw new RuntimeException('Divi Library taxonomies not registered: ' . implode(', ', $missing));
|
||
}
|
||
|
||
$id = wp_insert_post(wp_slash([
|
||
'post_type' => self::POST_TYPE,
|
||
'post_status' => 'publish',
|
||
'post_title' => $spec->title,
|
||
'post_content' => '',
|
||
]), true);
|
||
if ($id instanceof WP_Error || (int) $id <= 0) {
|
||
throw new RuntimeException(
|
||
'wp_insert_post refused the library entry: ' . ($id instanceof WP_Error ? $id->get_error_message() : '0')
|
||
);
|
||
}
|
||
|
||
$id = (int) $id;
|
||
try {
|
||
foreach ($spec->terms() as $taxonomy => $terms) {
|
||
$result = wp_set_object_terms($id, $terms, $taxonomy);
|
||
if ($result instanceof WP_Error) {
|
||
throw new RuntimeException(sprintf('terms %s: %s', $taxonomy, $result->get_error_message()));
|
||
}
|
||
}
|
||
foreach ($spec->meta() as $key => $value) {
|
||
update_post_meta($id, $key, $value);
|
||
if ((string) get_post_meta($id, $key, true) !== $value) {
|
||
throw new RuntimeException(sprintf('meta %s of library entry %d not stored', $key, $id));
|
||
}
|
||
}
|
||
} catch (RuntimeException $e) {
|
||
$this->delete($id);
|
||
throw $e;
|
||
}
|
||
|
||
return $id;
|
||
}
|
||
|
||
/** Deletes an entry for good (only this plugin's own, just created entries). */
|
||
public function delete(int $id): bool
|
||
{
|
||
return (bool) wp_delete_post($id, true);
|
||
}
|
||
```
|
||
|
||
`LibraryAccess::canSave()`:
|
||
|
||
```php
|
||
/**
|
||
* Creating an entry: canList(), publish_posts of et_pb_layout (library entries are always
|
||
* published, F2), the role option save_library and the Tools-tab gate.
|
||
*/
|
||
public function canSave(): ?WP_Error
|
||
{
|
||
$refusal = $this->canList();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$type = get_post_type_object(DiviLibraryStore::POST_TYPE);
|
||
$publish = is_object($type) && isset($type->cap->publish_posts) ? (string) $type->cap->publish_posts : 'publish_posts';
|
||
if (! current_user_can($publish)) {
|
||
return Errors::forbidden('Keine Berechtigung, Einträge der Divi-Bibliothek anzulegen (publish_posts).');
|
||
}
|
||
|
||
return $this->roles->refusal('save_library') ?? $this->writableGate();
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Library/LibrarySaveAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Abilities\ToolResult;
|
||
use LIT\MCP\ChangeLog\ChangeLog;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\Divi\Pages\SaveScope;
|
||
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||
use LIT\MCP\Integrations\WordPress\PostSaveFollowUp;
|
||
use LIT\MCP\Log;
|
||
use RuntimeException;
|
||
use Throwable;
|
||
use WP_Error;
|
||
use WP_Term;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/library-save: a node of a Divi page (or library entry) becomes a new Divi Library entry,
|
||
* normal or global (spec §5.7, E4). The entry gets the unchanged bytes of the node in the
|
||
* divi/placeholder frame (LibraryContent::wrap), saved through DiviSaver::saveNew (the one content
|
||
* write, D7); the stored node is checked by StoredNodeGuard (E6) because it moves to another post.
|
||
* The source stays as it is. All or nothing: any failure deletes the new entry.
|
||
*/
|
||
final class LibrarySaveAbility implements Ability
|
||
{
|
||
private const TYPE_LABELS = ['section' => 'Abschnitt', 'row' => 'Zeile', 'module' => 'Modul'];
|
||
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviPageLoader $loader,
|
||
private DiviWriter $writer,
|
||
private DiviSaver $saver,
|
||
private ChangeLog $log,
|
||
private DiviModules $modules
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/library-save';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'In der Divi-Bibliothek speichern';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Speichert einen Divi-Knoten (Abschnitt, Zeile oder Modul) als neuen Eintrag der Divi-Bibliothek – '
|
||
. 'normal oder global (scope). Der Knoten auf der Seite bleibt unverändert. post_id, tree_rev und die '
|
||
. 'Pfad-ID aus lit-divi-outline; Kategorien und Schlagwörter nur vorhandene. Globale Einträge mit '
|
||
. 'lit-library-insert (mode "global") auf Seiten einfügen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'Seite (oder Eintrag) mit dem Knoten.'],
|
||
'tree_rev' => ['type' => 'string', 'description' => 'tree_rev aus lit-divi-outline dieser Seite.'],
|
||
'id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens, z. B. "s1".'],
|
||
'title' => ['type' => 'string', 'minLength' => 1, 'maxLength' => 200],
|
||
'scope' => ['type' => 'string', 'enum' => ['normal', 'global'], 'description' => 'Standard: normal.'],
|
||
'categories' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||
'tags' => ['type' => 'array', 'maxItems' => 10, 'items' => ['type' => 'string']],
|
||
],
|
||
'required' => ['post_id', 'tree_rev', 'id', 'title'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return ToolResult::schema(
|
||
['library_id' => ['type' => 'integer'], 'tree_rev' => ['type' => 'string']],
|
||
['library_id', 'tree_rev']
|
||
);
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::write(false);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$refusal = $this->access->canSave();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$page = $this->loader->load($input['post_id'] ?? null, false);
|
||
|
||
return $page instanceof WP_Error ? $page : true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = $this->access->canSave();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
$page = $this->loader->load($input['post_id'] ?? null, false);
|
||
if ($page instanceof WP_Error) {
|
||
return $page;
|
||
}
|
||
|
||
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
|
||
return Errors::revMismatch(
|
||
'Die Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde angelegt.',
|
||
DiviPageLoader::OUTLINE_HINT
|
||
);
|
||
}
|
||
|
||
$id = is_string($input['id'] ?? null) ? $input['id'] : '';
|
||
$node = $page->index->find($id);
|
||
if ($node === null) {
|
||
return DiviErrors::unknownNode($id, $page->post);
|
||
}
|
||
|
||
$title = sanitize_text_field(is_string($input['title'] ?? null) ? $input['title'] : '');
|
||
if ($title === '') {
|
||
return Errors::invalidInput('Titel fehlt; nichts wurde angelegt.', ['title' => 'nicht leer']);
|
||
}
|
||
|
||
$spec = LibraryItemSpec::forNode(
|
||
$node,
|
||
$this->modules->catalog(),
|
||
$title,
|
||
($input['scope'] ?? 'normal') === 'global',
|
||
(string) $page->post->post_type
|
||
);
|
||
if (is_string($spec)) {
|
||
return Errors::invalidInput($spec . ' Nichts wurde angelegt.', ['id' => $spec]);
|
||
}
|
||
|
||
$problems = $this->writer->composition($page)->storedGuard()->problems($node);
|
||
if ($problems !== []) {
|
||
return Errors::invalidInput(
|
||
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde angelegt.',
|
||
['id' => implode('; ', $problems)]
|
||
);
|
||
}
|
||
|
||
$categories = $this->termIds('layout_category', $input['categories'] ?? []);
|
||
$tags = $this->termIds('layout_tag', $input['tags'] ?? []);
|
||
foreach (['categories' => $categories, 'tags' => $tags] as $field => $ids) {
|
||
if (is_string($ids)) {
|
||
return Errors::invalidInput('Unbekannte Kategorie oder unbekanntes Schlagwort; nichts wurde angelegt.', [
|
||
$field => $ids,
|
||
]);
|
||
}
|
||
}
|
||
|
||
return $this->create($page, $spec->withTerms($categories, $tags), LibraryContent::wrap($node), $input);
|
||
}
|
||
|
||
/**
|
||
* @param array<string, mixed> $input
|
||
* @return array<string, mixed>|WP_Error
|
||
*/
|
||
private function create(DiviPage $page, LibraryItemSpec $spec, string $content, array $input): array|WP_Error
|
||
{
|
||
try {
|
||
$libraryId = $this->access->store()->create($spec);
|
||
} catch (RuntimeException $e) {
|
||
Log::failure('lit/library-save create', $e);
|
||
|
||
return Errors::writeFailed('Der Bibliothekseintrag konnte nicht angelegt werden.');
|
||
}
|
||
|
||
try {
|
||
$warnings = $this->saver->saveNew($libraryId, $content, SaveScope::LibraryItem);
|
||
} catch (Throwable $e) {
|
||
Log::failure('lit/library-save layout', $e);
|
||
if (! $this->access->store()->delete($libraryId)) {
|
||
return Errors::partialWrite('Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.', $libraryId);
|
||
}
|
||
|
||
return Errors::writeFailed('Der Inhalt des Bibliothekseintrags konnte nicht gespeichert werden.');
|
||
}
|
||
|
||
$followUp = (new PostSaveFollowUp($this->name(), $this->log, $libraryId, 'der Bibliothekseintrag', $warnings))
|
||
->afterSave($input);
|
||
$created = $followUp->reread();
|
||
$kind = (self::TYPE_LABELS[$spec->layoutType] ?? $spec->layoutType) . ($spec->global ? ', global' : '');
|
||
$followUp->log(
|
||
DiviLibraryStore::POST_TYPE,
|
||
sprintf('Bibliothek: „%s“ angelegt (%s) aus Eintrag %d', $spec->title, $kind, (int) $page->post->ID)
|
||
);
|
||
|
||
$result = $followUp->result(sprintf('Bibliothekseintrag „%s“ angelegt (%s).', $spec->title, $kind), $created)
|
||
->with('library_id', $libraryId)
|
||
->with('tree_rev', Rev::of($created !== null ? (string) $created->post_content : $content))
|
||
->urls(null, (string) get_edit_post_link($libraryId, 'raw'));
|
||
if ($spec->global) {
|
||
$result->warning(
|
||
'Der Knoten auf der Seite bleibt eine eigenständige Kopie; verknüpfte Instanzen mit lit-library-insert '
|
||
. '(mode "global") einfügen.'
|
||
);
|
||
}
|
||
|
||
return $result->toArray();
|
||
}
|
||
|
||
/** @return list<int>|string Term IDs of existing terms by name, or the problem. */
|
||
private function termIds(string $taxonomy, mixed $names): array|string
|
||
{
|
||
$ids = [];
|
||
foreach (is_array($names) ? $names : [] as $name) {
|
||
$term = is_string($name) ? get_term_by('name', $name, $taxonomy) : false;
|
||
if (! $term instanceof WP_Term) {
|
||
return sprintf(
|
||
'„%s“ gibt es nicht; nur vorhandene Begriffe (neue in der Divi-Bibliothek anlegen).',
|
||
is_string($name) ? $name : '?'
|
||
);
|
||
}
|
||
$ids[] = (int) $term->term_id;
|
||
}
|
||
|
||
return $ids;
|
||
}
|
||
}
|
||
```
|
||
|
||
`LibraryIntegration` – Konstruktor und `abilities()` (Imports `ChangeLog`, `DiviSaver`, `DiviWriter`):
|
||
|
||
```php
|
||
public function __construct(
|
||
private LibraryAccess $access,
|
||
private DiviPageLoader $loader,
|
||
private DesignSystemReader $design,
|
||
private DiviWriter $writer,
|
||
private DiviSaver $saver,
|
||
private ChangeLog $log,
|
||
private DiviModules $modules
|
||
) {
|
||
}
|
||
|
||
public function abilities(): array
|
||
{
|
||
return [
|
||
new LibraryListAbility($this->access, $this->loader, $this->design, new GlobalUsages()),
|
||
new LibrarySaveAbility($this->access, $this->loader, $this->writer, $this->saver, $this->log, $this->modules),
|
||
];
|
||
}
|
||
```
|
||
|
||
`DiviIntegration` – Writer-Fabrik teilen und `library()` anpassen:
|
||
|
||
```php
|
||
public function library(): LibraryIntegration
|
||
{
|
||
$loader = $this->loader();
|
||
|
||
return new LibraryIntegration(
|
||
$this->access,
|
||
$loader,
|
||
$this->design,
|
||
$this->writer($loader),
|
||
new DiviSaver(),
|
||
$this->log,
|
||
$this->modules
|
||
);
|
||
}
|
||
|
||
private function writer(DiviPageLoader $loader): DiviWriter
|
||
{
|
||
return new DiviWriter(
|
||
$loader,
|
||
new DiviSaver(),
|
||
$this->log,
|
||
$this->design,
|
||
new DiviComposition($this->modules, $this->design),
|
||
new GlobalEdits($this->access, new GlobalUsages())
|
||
);
|
||
}
|
||
```
|
||
|
||
In `abilities()` `$writer = $this->writer($loader);` statt des bisherigen `new DiviWriter(…)`.
|
||
|
||
`docs/guides/overview.md`: „Divi-Bibliothek (`lit/library-list`)“ → „Divi-Bibliothek (`lit/library-list`, `lit/library-save`)“.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryItemSpecTest|DiviLibraryStoreTest|LibraryAccessTest|LibrarySaveAbilityTest|DiviIntegrationTest|ToolNamingTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: lit/library-save creates library entries like Divi (E4)`.
|
||
|
||
---
|
||
|
||
### Task 13: `lit/library-insert` – Kopie oder globale Instanz im Builder-Format (E5, direkt)
|
||
|
||
Das einzige neue direkte Tool (22 direkte Tools). `mode` = `global` (Standard für globale Einträge) fügt die Instanz genau so ein, wie der Visual Builder sie schreibt (`GlobalInstance::of`, byte-gleich zum Fixture) – den Inhalt liefert Divi beim Rendern aus der Bibliothek (F7). `mode` = `copy` (Standard sonst) fügt die Wurzeln des Eintrags ohne `divi/placeholder` ein; bei globalen Einträgen vorher entglobalisiert wie Divis `/load` (`GlobalInstance::deglobalize`, F10). Rechte: Ziel wie `lit-divi-insert` (gleiche Sperre, alles oder nichts), dazu `add_library` (E3) und – Audit M2 – nur solange `lit/divi-insert` nicht gesperrt ist. Übernommene Knoten laufen durch `StoredNodeGuard`.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Library/LibraryInsertAbility.php`
|
||
- Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`canInsert()`)
|
||
- Modify: `src/Integrations/Divi/Library/LibraryIntegration.php`, `src/Integrations/Divi/DiviIntegration.php`, `src/Plugin.php`
|
||
- Modify: `docs/guides/overview.md` („Direkt sichtbar:“, Ruling Q2)
|
||
- Test: `tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php`, `…/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DiviWriteAbility` (Task 9: `precheck`, `commit`), `GlobalInstance::of/deglobalize`, `LibraryContent::load/roots`, `DiviComposition::storedGuard/editor`, `TreeEditor::position/insert`, `DiviSchema::write/position`.
|
||
- Produces: `LibraryAccess::canInsert(): ?WP_Error`; `final class LibraryInsertAbility extends DiviWriteAbility { __construct(DiviWriter $writer, LibraryAccess $access, ?Closure $diviWritable = null, Parser $parser = new Parser()) }` – `lit/library-insert`, direkt; `LibraryIntegration::__construct(…, DiviModules $modules, ?Closure $diviWritable = null)`; `DiviIntegration::library(?Closure $diviWritable = null)`.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
In `LibraryAccessTest`:
|
||
|
||
```php
|
||
public function testInsertingNeedsAddLibrary(): void
|
||
{
|
||
LibraryFixtures::given([]);
|
||
|
||
self::assertNull(LibraryFixtures::access()->canInsert());
|
||
self::assertToolError(LibraryFixtures::access(['add_library'])->canInsert(), 'forbidden');
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Library/LibraryInsertAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Library;
|
||
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\Library\LibraryInsertAbility;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use WP_Post;
|
||
|
||
final class LibraryInsertAbilityTest extends TestCase
|
||
{
|
||
private WP_Post $page;
|
||
|
||
/** @var list<string> */
|
||
private array $saved = [];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->page = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html'));
|
||
LibraryFixtures::given(array_merge(
|
||
[$this->page],
|
||
array_map(static fn (int $id): WP_Post => LibraryFixtures::item($id), array_keys(LibraryFixtures::ITEMS))
|
||
));
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
private function ability(array $deniedRoles = [], bool $diviWritable = true): LibraryInsertAbility
|
||
{
|
||
$access = LibraryFixtures::access($deniedRoles);
|
||
$failures = [];
|
||
|
||
return new LibraryInsertAbility(
|
||
DiviWriterFixture::writer($this->saved, $failures, false, LibraryFixtures::loader($access)),
|
||
$access,
|
||
static fn (): bool => $diviWritable
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function input(array $extra): array
|
||
{
|
||
return array_merge(['post_id' => 7, 'tree_rev' => Rev::of((string) $this->page->post_content)], $extra);
|
||
}
|
||
|
||
private static function fixtureInstance(int $id, string $end): string
|
||
{
|
||
$page = DiviFixtures::markup('stage3/page--globale-instanzen.html');
|
||
$start = (int) strpos($page, '<!-- wp:divi/global-layout {"globalModule":"' . $id . '"');
|
||
|
||
return substr($page, $start, (int) strpos($page, $end, $start) + strlen($end) - $start);
|
||
}
|
||
|
||
public function testInsertsAGlobalSectionExactlyLikeTheBuilder(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['library_id' => 1058, 'after' => 's0']));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertStringContainsString(self::fixtureInstance(1058, '<!-- /wp:divi/global-layout -->'), $this->saved[0]);
|
||
self::assertSame('g0', $result['changed'][0]['id']);
|
||
self::assertStringContainsString('Bibliothekseintrag 1058', implode(' ', $result['warnings']));
|
||
}
|
||
|
||
public function testInsertsAGlobalModuleIntoAColumn(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['library_id' => 1059, 'parent' => 's0.r0.c1']));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertStringContainsString(self::fixtureInstance(1059, '/-->'), $this->saved[0]);
|
||
}
|
||
|
||
public function testCopyInsertsTheRootsWithoutFrameAndCrlf(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['library_id' => 1055]));
|
||
|
||
$section = str_replace(
|
||
['<!-- wp:divi/placeholder -->', '<!-- /wp:divi/placeholder -->', "\r\n"],
|
||
['', '', "\n"],
|
||
DiviFixtures::markup('stage3/library--library-abschnitt.html')
|
||
);
|
||
self::assertTrue($result['ok']);
|
||
self::assertStringContainsString($section, $this->saved[0]);
|
||
self::assertStringNotContainsString("\r", $this->saved[0]);
|
||
}
|
||
|
||
public function testLayoutCopyInsertsEverySection(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['library_id' => 1054, 'mode' => 'copy']));
|
||
|
||
self::assertCount(3, $result['changed']);
|
||
self::assertStringContainsString('Library-Blurb', $this->saved[0]);
|
||
}
|
||
|
||
public function testGlobalModeNeedsAGlobalEntryAndUnknownEntriesAreNotFound(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['library_id' => 1055, 'mode' => 'global'])), 'validation_failed');
|
||
self::assertToolError($this->ability()->execute($this->input(['library_id' => 9999])), 'not_found');
|
||
self::assertToolError($this->ability()->execute($this->input(['library_id' => 7])), 'not_found');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testNeedsAddLibraryAndAWritableDiviGroup(): void
|
||
{
|
||
self::assertToolError($this->ability(['add_library'])->permission($this->input(['library_id' => 1058])), 'forbidden');
|
||
self::assertToolError($this->ability([], false)->permission($this->input(['library_id' => 1058])), 'unsupported');
|
||
self::assertTrue($this->ability()->isDirect());
|
||
}
|
||
|
||
public function testEditorCannotInsertAnItemWithCustomCss(): void
|
||
{
|
||
$custom = LibraryFixtures::item(1055, [
|
||
'ID' => 1080,
|
||
'post_content' => '<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":'
|
||
. '{"freeForm":"body{display:none}"}}}} --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
|
||
]);
|
||
LibraryFixtures::given([$this->page, $custom]);
|
||
|
||
$result = $this->ability()->execute($this->input(['library_id' => 1080]));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviIntegrationTest::testProvidesTheLibraryGroup` die Liste auf `['lit/library-list', 'lit/library-save', 'lit/library-insert']` erweitern.
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|LibraryInsertAbilityTest|DiviIntegrationTest|ToolNamingTest'`
|
||
Expected: FAIL (`canInsert`, `LibraryInsertAbility` fehlen; ToolNamingTest bleibt grün, bis das Tool existiert)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`LibraryAccess::canInsert()`:
|
||
|
||
```php
|
||
/** Inserting from the library: canList() and the role option add_library (E3). */
|
||
public function canInsert(): ?WP_Error
|
||
{
|
||
return $this->canList() ?? $this->roles->refusal('add_library');
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Library/LibraryInsertAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Library;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\GlobalInstance;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\LibraryContent;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\MalformedMarkup;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviErrors;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviSchema;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriteAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use WP_Error;
|
||
use WP_Post;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/library-insert (spec §5.7, E5): a Divi Library entry into a Divi page – as the global instance
|
||
* the Visual Builder writes (GlobalInstance::of; Divi renders the entry's current content, F7) or
|
||
* as an independent copy of its roots (a global entry deglobalized like Divi's /load, F10). The
|
||
* target works like lit-divi-insert (tree_rev lock, StructureRules, all or nothing, DiviWriter);
|
||
* stored nodes from the entry pass StoredNodeGuard (E6). Needs add_library (E3) and follows
|
||
* lit-divi-insert's Tools-tab state (audit M2).
|
||
*/
|
||
final class LibraryInsertAbility extends DiviWriteAbility
|
||
{
|
||
/** @param (Closure(): bool)|null $diviWritable Whether lit/divi-insert is enabled; null = always. */
|
||
public function __construct(
|
||
DiviWriter $writer,
|
||
private LibraryAccess $access,
|
||
private ?Closure $diviWritable = null,
|
||
private Parser $parser = new Parser()
|
||
) {
|
||
parent::__construct($writer);
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/library-insert';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Aus der Divi-Bibliothek einfügen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Fügt einen Eintrag der Divi-Bibliothek in eine Divi-Seite ein: mode "global" als verknüpfte Instanz '
|
||
. '(der Inhalt kommt immer aus der Bibliothek; Änderungen dort wirken auf allen Seiten) oder mode "copy" '
|
||
. 'als unabhängige Kopie. Standard: global für globale Einträge, sonst copy. Einträge über lit/library-list; '
|
||
. 'Position über parent, before oder after (Pfad-IDs aus lit-divi-outline), sonst ans Seitenende. '
|
||
. 'tree_rev ist Pflicht; alles oder nichts.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge(DiviSchema::write(), [
|
||
'library_id' => ['type' => 'integer', 'minimum' => 1, 'description' => 'ID aus lit/library-list.'],
|
||
'mode' => ['type' => 'string', 'enum' => ['copy', 'global']],
|
||
], DiviSchema::position()),
|
||
'required' => ['post_id', 'tree_rev', 'library_id'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::write(false);
|
||
}
|
||
|
||
protected function precheck(array $input): ?WP_Error
|
||
{
|
||
$refusal = $this->access->canInsert();
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
|
||
if ($this->diviWritable !== null && ! ($this->diviWritable)()) {
|
||
return Errors::unsupported(
|
||
'Divi-Änderungen sind für diese Verbindung abgeschaltet (Gruppe „Divi“ auf „nur lesen“ oder '
|
||
. 'lit-divi-insert deaktiviert).',
|
||
'Einen Administrator fragen: Einstellungen → LIT MCP → Tools.'
|
||
);
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
|
||
{
|
||
$item = $this->item($input['library_id'] ?? null);
|
||
if ($item instanceof WP_Error) {
|
||
return $item;
|
||
}
|
||
|
||
$id = (int) $item->ID;
|
||
$global = $this->access->isGlobal($item);
|
||
$mode = is_string($input['mode'] ?? null) ? $input['mode'] : ($global ? 'global' : 'copy');
|
||
if ($mode === 'global' && ! $global) {
|
||
return Errors::invalidInput(
|
||
'Nur globale Bibliothekseinträge lassen sich als Instanz einfügen; nichts wurde gespeichert.',
|
||
['mode' => 'der Eintrag ist nicht global – mode "copy" verwenden']
|
||
);
|
||
}
|
||
|
||
$blocks = $this->blocks($item, $mode, $global);
|
||
if ($blocks instanceof WP_Error) {
|
||
return $blocks;
|
||
}
|
||
|
||
$guard = $composition->storedGuard();
|
||
foreach ($blocks as $block) {
|
||
$problems = $guard->problems($block);
|
||
if ($problems !== []) {
|
||
return Errors::invalidInput(
|
||
'Der Bibliothekseintrag enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; '
|
||
. 'nichts wurde gespeichert.',
|
||
['library_id' => implode('; ', $problems)]
|
||
);
|
||
}
|
||
}
|
||
|
||
$editor = $composition->editor();
|
||
$position = $editor->position($page->index, $input);
|
||
if (is_string($position)) {
|
||
return DiviErrors::position($position);
|
||
}
|
||
|
||
$violation = $editor->insert($position, $blocks);
|
||
if ($violation !== null) {
|
||
return Errors::invalidInput(
|
||
'Der Bibliothekseintrag passt nicht an diese Stelle; nichts wurde gespeichert.',
|
||
['library_id' => $violation]
|
||
);
|
||
}
|
||
|
||
$change = new DiviChange(sprintf(
|
||
'Bibliothekseintrag „%s“ %s eingefügt',
|
||
(string) $item->post_title,
|
||
$mode === 'global' ? 'als globale Instanz' : 'als Kopie'
|
||
));
|
||
foreach ($blocks as $block) {
|
||
$change->record($block, 'inserted');
|
||
}
|
||
if ($mode === 'global') {
|
||
$change->warning(sprintf(
|
||
'Globale Instanz: den Inhalt liefert Bibliothekseintrag %d; Änderungen dort wirken auf alle Seiten mit '
|
||
. 'diesem Modul (lit-divi-outline mit post_id %d).',
|
||
$id,
|
||
$id
|
||
));
|
||
}
|
||
|
||
return $change;
|
||
}
|
||
|
||
/** The active library entry $libraryId the user may read, or the error. */
|
||
private function item(mixed $libraryId): WP_Post|WP_Error
|
||
{
|
||
$id = PostGuard::id($libraryId);
|
||
$item = $id === null ? null : get_post($id);
|
||
if (
|
||
! $item instanceof WP_Post
|
||
|| $item->post_type !== DiviLibraryStore::POST_TYPE
|
||
|| $item->post_status !== 'publish'
|
||
) {
|
||
return Errors::notFound(
|
||
sprintf('Bibliothekseintrag %s gibt es nicht (oder er ist nicht aktiv).', (string) ($id ?? '?')),
|
||
'Einträge mit lit/library-list auflisten.'
|
||
);
|
||
}
|
||
|
||
return current_user_can('read_post', (int) $item->ID)
|
||
? $item
|
||
: Errors::forbidden(sprintf('Keine Berechtigung, Bibliothekseintrag %d zu lesen.', (int) $item->ID));
|
||
}
|
||
|
||
/** @return list<Block>|WP_Error The blocks to insert. */
|
||
private function blocks(WP_Post $item, string $mode, bool $global): array|WP_Error
|
||
{
|
||
$id = (int) $item->ID;
|
||
try {
|
||
$roots = LibraryContent::roots(LibraryContent::load($this->parser, (string) $item->post_content));
|
||
} catch (MalformedMarkup) {
|
||
return Errors::unsupported(
|
||
sprintf('Bibliothekseintrag %d ist kein gültiges Block-Markup; nichts wurde gespeichert.', $id),
|
||
'Den Eintrag im Divi-Builder öffnen und speichern.'
|
||
);
|
||
}
|
||
|
||
if ($roots === []) {
|
||
return Errors::invalidInput(
|
||
sprintf('Bibliothekseintrag %d ist leer; nichts wurde gespeichert.', $id),
|
||
['library_id' => 'leer']
|
||
);
|
||
}
|
||
|
||
if ($mode === 'global') {
|
||
if (count($roots) !== 1) {
|
||
return Errors::invalidInput(
|
||
'Dieser globale Eintrag hat mehrere Wurzeln und lässt sich nur als Kopie einfügen; nichts wurde '
|
||
. 'gespeichert.',
|
||
['mode' => 'mode "copy" verwenden']
|
||
);
|
||
}
|
||
|
||
return [GlobalInstance::of($roots[0], $id)];
|
||
}
|
||
|
||
return array_map(
|
||
static fn (Block $root): Block => $global ? GlobalInstance::deglobalize($root->copy()) : $root->copy(),
|
||
$roots
|
||
);
|
||
}
|
||
}
|
||
```
|
||
|
||
`LibraryIntegration` – Konstruktor um `private ?Closure $diviWritable = null` (letzter Parameter, Import `Closure`) erweitern und in `abilities()` ergänzen:
|
||
|
||
```php
|
||
new LibraryInsertAbility($this->writer, $this->access, $this->diviWritable),
|
||
```
|
||
|
||
`DiviIntegration::library(?Closure $diviWritable = null)` reicht `$diviWritable` als letzten Parameter an `LibraryIntegration` weiter. `Plugin::boot()`: `$divi->library($diviWritable)`.
|
||
|
||
`docs/guides/overview.md` – „Direkt sichtbar:“ um `lit-library-insert` ergänzen (nach `lit-divi-delete`, Reihenfolge wie bisher, der Absatz endet mit einer Leerzeile – der ToolNamingTest liest bis dahin):
|
||
|
||
```markdown
|
||
Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-content-query`,
|
||
`lit-content-get`, `lit-content-create`, `lit-content-update`, `lit-post-publish`, `lit-media-search`,
|
||
`lit-media-preview`, `lit-media-download`, `lit-media-upload-from-url`, `lit-page-render-summary`,
|
||
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
|
||
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`, `lit-library-insert`.
|
||
```
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|LibraryInsertAbilityTest|DiviIntegrationTest|ToolNamingTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Library src/Integrations/Divi/DiviIntegration.php src/Plugin.php docs/guides/overview.md tests/Unit/Integrations/Divi
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: lit-library-insert as copy or global instance in the builder's format (E5)`.
|
||
|
||
---
|
||
|
||
### Task 14: `lit/divi-node-copy` – Knoten zwischen Seiten kopieren (E6)
|
||
|
||
Kopiert einen gespeicherten Knoten byte-gleich an eine Position eines anderen Posts (Seite oder Bibliothekseintrag). Quelle: Leserechte wie `lit-divi-outline` (auch Bibliothekseinträge) und die `source_tree_rev` (Pfad-IDs gelten nur für ihren Stand); Ziel: wie `lit-divi-insert` (`tree_rev`, StructureRules, alles oder nichts, bei Bibliothekszielen `GlobalEdits`). Globale Instanzen bleiben verknüpft. Die Kopie läuft durch `StoredNodeGuard` des Ziels. Gruppe „Divi“, indirekt.
|
||
|
||
**Files:**
|
||
- Create: `src/Integrations/Divi/Pages/DiviNodeCopyAbility.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`
|
||
- Modify: `docs/guides/overview.md` (indirekte Tools)
|
||
- Test: `tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DiviWriteAbility` (`precheck`, `change`, `commit`), `DiviPageLoader::load()`, `DiviComposition::storedGuard/editor`, `GlobalEdits`, `LibraryFixtures`.
|
||
- Produces: `final class DiviNodeCopyAbility extends DiviWriteAbility { __construct(DiviWriter $writer, DiviPageLoader $loader) }` – `lit/divi-node-copy`, indirekt, Eingabe `post_id`, `tree_rev`, `source_post_id`, `source_id`, `source_tree_rev`, Position.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
`tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Abilities\Rev;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Parser;
|
||
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalEdits;
|
||
use LIT\MCP\Integrations\Divi\Library\GlobalUsages;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\DiviWriterFixture;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
use WP_Post;
|
||
|
||
final class DiviNodeCopyAbilityTest extends TestCase
|
||
{
|
||
private WP_Post $target;
|
||
private WP_Post $source;
|
||
|
||
/** @var list<string> */
|
||
private array $saved = [];
|
||
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
$this->target = LibraryFixtures::page(DiviFixtures::markup('synthetic-basic.html'));
|
||
$this->source = LibraryFixtures::page(
|
||
DiviFixtures::markup('stage3/page--globale-instanzen.html'),
|
||
['ID' => 8, 'post_title' => 'Quelle']
|
||
);
|
||
LibraryFixtures::given([$this->target, $this->source, LibraryFixtures::item(1055)]);
|
||
}
|
||
|
||
private function ability(): DiviNodeCopyAbility
|
||
{
|
||
$access = LibraryFixtures::access();
|
||
$loader = LibraryFixtures::loader($access);
|
||
$failures = [];
|
||
$globals = new GlobalEdits($access, Mockery::mock(GlobalUsages::class));
|
||
|
||
return new DiviNodeCopyAbility(
|
||
DiviWriterFixture::writer($this->saved, $failures, false, $loader, $globals),
|
||
$loader
|
||
);
|
||
}
|
||
|
||
/** @return array<string, mixed> */
|
||
private function input(array $extra = []): array
|
||
{
|
||
return array_merge([
|
||
'post_id' => 7,
|
||
'tree_rev' => Rev::of((string) $this->target->post_content),
|
||
'source_post_id' => 8,
|
||
'source_tree_rev' => Rev::of((string) $this->source->post_content),
|
||
'source_id' => 's1',
|
||
'after' => 's0',
|
||
], $extra);
|
||
}
|
||
|
||
public function testCopiesANodeByteForByteBetweenPages(): void
|
||
{
|
||
$bytes = Serializer::block(
|
||
NodeIndex::build((new Parser())->parse((string) $this->source->post_content))->find('s1')
|
||
);
|
||
|
||
$result = $this->ability()->execute($this->input());
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertCount(1, $this->saved, 'only the target is written');
|
||
self::assertStringContainsString($bytes, $this->saved[0]);
|
||
self::assertSame('s1', $result['changed'][0]['id']);
|
||
self::assertFalse($this->ability()->isDirect());
|
||
}
|
||
|
||
public function testGlobalInstancesStayLinked(): void
|
||
{
|
||
$result = $this->ability()->execute($this->input(['source_id' => 'g0']));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertStringContainsString('<!-- wp:divi/global-layout {"globalModule":"1058"', $this->saved[0]);
|
||
}
|
||
|
||
public function testStaleSourceOrTargetWritesNothing(): void
|
||
{
|
||
self::assertToolError($this->ability()->execute($this->input(['source_tree_rev' => 'abcdef012345'])), 'rev_mismatch');
|
||
self::assertToolError($this->ability()->execute($this->input(['tree_rev' => 'abcdef012345'])), 'rev_mismatch');
|
||
self::assertToolError($this->ability()->execute($this->input(['source_id' => 's9'])), 'not_found');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testEditorCannotCarryBuilderOnlyValuesToAnotherPage(): void
|
||
{
|
||
$this->source = LibraryFixtures::page(
|
||
'<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":{"freeForm":"body{display:none}"}}}} -->'
|
||
. '<!-- /wp:divi/section --><!-- /wp:divi/placeholder -->',
|
||
['ID' => 8]
|
||
);
|
||
LibraryFixtures::given([$this->target, $this->source]);
|
||
|
||
$result = $this->ability()->execute($this->input(['source_id' => 's0']));
|
||
|
||
self::assertToolError($result, 'validation_failed');
|
||
self::assertSame([], $this->saved);
|
||
}
|
||
|
||
public function testTheSourceNeedsItsOwnRights(): void
|
||
{
|
||
Functions\when('current_user_can')->alias(
|
||
static fn (string $cap, mixed ...$args): bool => ! ($cap === 'edit_post' && ($args[0] ?? 0) === 8)
|
||
);
|
||
|
||
self::assertToolError($this->ability()->permission($this->input()), 'forbidden');
|
||
}
|
||
|
||
public function testCopiesIntoALibraryEntry(): void
|
||
{
|
||
$item = LibraryFixtures::item(1055);
|
||
|
||
$result = $this->ability()->execute($this->input([
|
||
'post_id' => 1055,
|
||
'tree_rev' => Rev::of((string) $item->post_content),
|
||
'source_id' => 's0.r0.c0.m0',
|
||
'after' => null,
|
||
'parent' => 's0.r0.c0',
|
||
]));
|
||
|
||
self::assertTrue($result['ok']);
|
||
self::assertStringContainsString('Kontakt aufnehmen', $this->saved[0]);
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviIntegrationTest::EXPECTED` am Ende `'lit/divi-node-copy'` ergänzen.
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DiviNodeCopyAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL (`DiviNodeCopyAbility` fehlt)
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`src/Integrations/Divi/Pages/DiviNodeCopyAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-node-copy (spec §5.7): a node with everything inside from one Divi entry (page or library
|
||
* entry) to a position in another. The copy keeps its bytes – global instances stay linked – and
|
||
* passes StoredNodeGuard of the target (E6: builder-only values never move to another post for users
|
||
* without unfiltered_html). Source: read rights (DiviPageLoader) and its own tree_rev, because path
|
||
* IDs are only valid for the state they were read in. Target: like lit-divi-insert.
|
||
*/
|
||
final class DiviNodeCopyAbility extends DiviWriteAbility
|
||
{
|
||
public function __construct(DiviWriter $writer, private DiviPageLoader $loader)
|
||
{
|
||
parent::__construct($writer);
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-node-copy';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Divi-Knoten kopieren';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Kopiert einen Divi-Knoten samt Inhalt von einem Eintrag (source_post_id, source_id und '
|
||
. 'source_tree_rev aus lit-divi-outline der Quelle) an eine Position eines anderen Eintrags (post_id, '
|
||
. 'tree_rev, parent/before/after). Die Kopie ist byte-gleich; globale Module bleiben verknüpft. Quelle '
|
||
. 'und Ziel dürfen auch Bibliothekseinträge sein. Alles oder nichts.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => array_merge(DiviSchema::write(), [
|
||
'source_post_id' => ['type' => 'integer', 'minimum' => 1],
|
||
'source_id' => ['type' => 'string', 'description' => 'Pfad-ID des Knotens in der Quelle.'],
|
||
'source_tree_rev' => ['type' => 'string', 'description' => 'tree_rev der Quelle (lit-divi-outline).'],
|
||
], DiviSchema::position()),
|
||
'required' => ['post_id', 'tree_rev', 'source_post_id', 'source_id', 'source_tree_rev'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::write(false);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
/** The source needs its own read rights – checked in permission() too. */
|
||
protected function precheck(array $input): ?WP_Error
|
||
{
|
||
$source = $this->loader->load($input['source_post_id'] ?? null, false);
|
||
|
||
return $source instanceof WP_Error ? $source : null;
|
||
}
|
||
|
||
protected function change(DiviPage $page, array $input, DiviComposition $composition): DiviChange|WP_Error
|
||
{
|
||
$source = $this->loader->load($input['source_post_id'] ?? null, false);
|
||
if ($source instanceof WP_Error) {
|
||
return $source;
|
||
}
|
||
|
||
if (! is_string($input['source_tree_rev'] ?? null) || $input['source_tree_rev'] !== $source->treeRev()) {
|
||
return Errors::revMismatch(
|
||
'Die Quelle wurde seit dem letzten Lesen geändert (source_tree_rev passt nicht); nichts wurde gespeichert.',
|
||
'Die Quelle mit lit-divi-outline neu lesen und source_tree_rev sowie die Pfad-ID übernehmen.'
|
||
);
|
||
}
|
||
|
||
$id = is_string($input['source_id'] ?? null) ? $input['source_id'] : '';
|
||
$node = $source->index->find($id);
|
||
if ($node === null) {
|
||
return DiviErrors::unknownNode($id, $source->post);
|
||
}
|
||
|
||
$copy = $node->copy();
|
||
$problems = $composition->storedGuard()->problems($copy);
|
||
if ($problems !== []) {
|
||
return Errors::invalidInput(
|
||
'Der Knoten enthält Einstellungen, die nur im Divi-Builder übertragen werden dürfen; nichts wurde '
|
||
. 'gespeichert.',
|
||
['source_id' => implode('; ', $problems)]
|
||
);
|
||
}
|
||
|
||
$editor = $composition->editor();
|
||
$position = $editor->position($page->index, $input);
|
||
if (is_string($position)) {
|
||
return DiviErrors::position($position);
|
||
}
|
||
|
||
$violation = $editor->insert($position, [$copy]);
|
||
if ($violation !== null) {
|
||
return Errors::invalidInput(
|
||
'Der Knoten passt nicht an diese Stelle; nichts wurde gespeichert.',
|
||
['source_id' => $violation]
|
||
);
|
||
}
|
||
|
||
return (new DiviChange(sprintf('Knoten %s aus Eintrag %d kopiert', $id, (int) $source->post->ID)))
|
||
->record($copy, 'inserted');
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegration::abilities()` – nach `DiviReplaceTextAbility` ergänzen: `new DiviNodeCopyAbility($writer, $loader),`.
|
||
|
||
`docs/guides/overview.md`: „Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`)“ → „Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`)“.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DiviNodeCopyAbilityTest|DiviIntegrationTest|ToolNamingTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DiviNodeCopyAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi/Pages/DiviNodeCopyAbilityTest.php tests/Unit/Integrations/Divi/DiviIntegrationTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: lit/divi-node-copy between pages and library entries`.
|
||
|
||
---
|
||
|
||
### Task 15: `lit/divi-dynamic-sources` (E12, Antwort F4)
|
||
|
||
E14 ordnet das Tool 3a zu. Es liefert Divis Dynamic-Content-Optionen (`DynamicContentOptions::get_options` für einen Beispiel-Eintrag des Typs, sonst 0; über `DynamicContentMap`, ohne `loop_*`, S36) mit dem passenden Platzhalter (`{{post:…}}`, `{{link:<ID>}}`) bzw. dem Namen für die Vollform, und die Felder des Typs aus `ContentSchema` (Pods, registriertes und gefundenes Meta). Antwort F4 (verbindlich): nur textartige Felder als Quelle anbieten, andere mit Grund auslassen (Beziehung/Datei zeigt Divi nur als IDs, F26; Pods mit Tabellen-Speicherung liest Divi nicht; geschützte Schlüssel mit `_` nie, S12). Die Feldliste nur mit der Divi-Rolle `read_dynamic_content_custom_fields` (E3, F25). Gruppe „Divi“, indirekt, nur lesend.
|
||
|
||
**Files:**
|
||
- Modify: `src/Integrations/Divi/Pages/DynamicContentMap.php` (`options()`)
|
||
- Create: `src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php`
|
||
- Modify: `src/Integrations/Divi/DiviIntegration.php`
|
||
- Modify: `docs/guides/overview.md` (indirekte Tools)
|
||
- Test: `tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`, `tests/Unit/Integrations/Divi/DiviIntegrationTest.php`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `DynamicContentMap::fromOptions()`, `DynamicContentRules::DEFAULT_DYNAMIC_CONTENT/isMetaKey()`, `Placeholders::POST`, `ContentSchema::fields()`, `FieldDefinition`, `PostGuard::postType()`, `JsonMap::of()`, `DiviRoles`.
|
||
- Produces: `DynamicContentMap::options(int $postId): ?array<string, array{label: string, group: string, settings: array<string, string>}>`; `final class DiviDynamicSourcesAbility implements Ability { __construct(ContentSchema $schema, DynamicContentMap $map, DiviRoles $roles, ?Closure $samplePost = null, ?Closure $podStorage = null) }` – `lit/divi-dynamic-sources`.
|
||
|
||
- [ ] **Step 1: Failing tests schreiben**
|
||
|
||
In `DynamicContentMapTest`:
|
||
|
||
```php
|
||
public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void
|
||
{
|
||
$map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry());
|
||
|
||
$options = $map->options(831);
|
||
|
||
self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']);
|
||
self::assertSame('post_meta_key', $options['post_meta_key']['label']);
|
||
self::assertSame('Standard', $options['post_meta_key']['group']);
|
||
self::assertArrayNotHasKey('loop_post_title', $options);
|
||
self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831));
|
||
}
|
||
```
|
||
|
||
`tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||
|
||
use Brain\Monkey\Functions;
|
||
use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility;
|
||
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use LIT\MCP\Tests\Support\DiviFixtures;
|
||
use LIT\MCP\Tests\Support\LibraryFixtures;
|
||
use LIT\MCP\Tests\Unit\TestCase;
|
||
use Mockery;
|
||
|
||
final class DiviDynamicSourcesAbilityTest extends TestCase
|
||
{
|
||
protected function setUp(): void
|
||
{
|
||
parent::setUp();
|
||
Functions\when('get_post_type_object')->justReturn(
|
||
(object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']]
|
||
);
|
||
Functions\when('current_user_can')->justReturn(true);
|
||
}
|
||
|
||
private function schema(): ContentSchema
|
||
{
|
||
$schema = Mockery::mock(ContentSchema::class);
|
||
$schema->allows('isManaged')->andReturn(true);
|
||
$schema->allows('fields')->with('page')->andReturn([
|
||
'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE),
|
||
'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE),
|
||
'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE),
|
||
'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE),
|
||
'_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE),
|
||
'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN),
|
||
]);
|
||
|
||
return $schema;
|
||
}
|
||
|
||
/** @param list<string> $deniedRoles */
|
||
private function ability(array $deniedRoles = [], ?string $storage = 'meta', ?array $registry = null): DiviDynamicSourcesAbility
|
||
{
|
||
$registry ??= DiviFixtures::dynamicContentRegistry();
|
||
|
||
return new DiviDynamicSourcesAbility(
|
||
$this->schema(),
|
||
new DynamicContentMap(static fn (int $id): array => $registry),
|
||
LibraryFixtures::roles($deniedRoles),
|
||
static fn (string $postType): int => 831,
|
||
static fn (string $postType): ?string => $storage
|
||
);
|
||
}
|
||
|
||
public function testListsDiviSourcesWithTheirPlaceholders(): void
|
||
{
|
||
$result = $this->ability()->execute(['post_type' => 'page']);
|
||
|
||
$sources = array_column($result['sources'], null, 'name');
|
||
self::assertSame(831, $result['sample_post_id']);
|
||
self::assertSame('{{post:title}}', $sources['post_title']['placeholder']);
|
||
self::assertSame('{{link:<ID>}}', $sources['post_link_url_page']['placeholder']);
|
||
self::assertNull($sources['site_title']['placeholder']);
|
||
self::assertArrayNotHasKey('loop_post_title', $sources);
|
||
self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']);
|
||
self::assertFalse($this->ability()->isDirect());
|
||
self::assertTrue($this->ability()->annotations()['readonly']);
|
||
}
|
||
|
||
public function testOffersTextLikeFieldsAndNamesTheOthers(): void
|
||
{
|
||
$result = $this->ability()->execute(['post_type' => 'page']);
|
||
|
||
self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key'));
|
||
self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']);
|
||
$omitted = array_column($result['omitted'], 'reason', 'key');
|
||
self::assertSame(['foto', 'projekte', '_intern'], array_keys($omitted));
|
||
self::assertStringContainsString('nur IDs', $omitted['foto']);
|
||
self::assertStringContainsString('„_“', $omitted['_intern']);
|
||
}
|
||
|
||
public function testPodsTableStorageIsNotReadableByDivi(): void
|
||
{
|
||
$result = $this->ability([], 'table')->execute(['post_type' => 'page']);
|
||
|
||
self::assertSame(['telefon'], array_column($result['fields'], 'key'));
|
||
self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']);
|
||
}
|
||
|
||
public function testFieldListNeedsTheDiviRole(): void
|
||
{
|
||
$result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']);
|
||
|
||
self::assertSame([], $result['fields']);
|
||
self::assertSame([], $result['omitted']);
|
||
self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes']));
|
||
}
|
||
|
||
public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void
|
||
{
|
||
$result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']);
|
||
|
||
self::assertContains('post_title', array_column($result['sources'], 'name'));
|
||
self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes']));
|
||
}
|
||
|
||
public function testUnknownPostTypeIsNotFound(): void
|
||
{
|
||
Functions\when('get_post_type_object')->justReturn(null);
|
||
|
||
self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found');
|
||
}
|
||
}
|
||
```
|
||
|
||
In `DiviIntegrationTest::EXPECTED` am Ende `'lit/divi-dynamic-sources'` ergänzen.
|
||
|
||
- [ ] **Step 2: Tests laufen lassen – schlagen fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DynamicContentMapTest|DiviDynamicSourcesAbilityTest|DiviIntegrationTest'`
|
||
Expected: FAIL
|
||
|
||
- [ ] **Step 3: Implementieren**
|
||
|
||
`DynamicContentMap` – `forPost()` auf eine gemeinsame Rohabfrage umstellen und `options()` ergänzen:
|
||
|
||
```php
|
||
/** @return array<string, array<string, string>>|null Null = use DynamicContentRules' default map. */
|
||
public function forPost(int $postId): ?array
|
||
{
|
||
$raw = $this->raw($postId);
|
||
|
||
return $raw === null ? null : self::fromOptions($raw);
|
||
}
|
||
|
||
/**
|
||
* The options for lit/divi-dynamic-sources: name => label, group and setting types (the same
|
||
* names and types as forPost(), loop_* left out). Null when Divi's registry is unavailable.
|
||
*
|
||
* @return array<string, array{label: string, group: string, settings: array<string, string>}>|null
|
||
*/
|
||
public function options(int $postId): ?array
|
||
{
|
||
$raw = $this->raw($postId);
|
||
if ($raw === null) {
|
||
return null;
|
||
}
|
||
|
||
$options = [];
|
||
foreach (self::fromOptions($raw) as $name => $settings) {
|
||
$option = is_array($raw[$name] ?? null) ? $raw[$name] : [];
|
||
$options[$name] = [
|
||
'label' => is_string($option['label'] ?? null) ? $option['label'] : $name,
|
||
'group' => is_string($option['group'] ?? null) ? $option['group'] : '',
|
||
'settings' => $settings,
|
||
];
|
||
}
|
||
|
||
return $options;
|
||
}
|
||
|
||
/** @return array<array-key, mixed>|null */
|
||
private function raw(int $postId): ?array
|
||
{
|
||
try {
|
||
$options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId);
|
||
} catch (Throwable $e) {
|
||
Log::failure(sprintf('divi dynamic content options of post %d', $postId), $e->getMessage());
|
||
|
||
return null;
|
||
}
|
||
|
||
return is_array($options) && $options !== [] ? $options : null;
|
||
}
|
||
```
|
||
|
||
`src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php`:
|
||
|
||
```php
|
||
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Pages;
|
||
|
||
use Closure;
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\JsonMap;
|
||
use LIT\MCP\Integrations\Divi\Compose\DynamicContentRules;
|
||
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||
use LIT\MCP\Integrations\Divi\Library\DiviRoles;
|
||
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||
use LIT\MCP\Integrations\WordPress\Schema\ContentSchema;
|
||
use LIT\MCP\Integrations\WordPress\Schema\FieldDefinition;
|
||
use Throwable;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-dynamic-sources (spec §5.7, E12): Divi's dynamic content options for a post type
|
||
* (DynamicContentOptions::get_options with a sample entry of the type, else 0; no loop_* – S36) with
|
||
* the placeholder that writes each, and the fields of the type usable as {{field:<key>}} (answer
|
||
* F4: text-like fields only; relation/file fields show IDs in Divi (F26), Pods table storage is not
|
||
* post meta, protected keys never (S12) – each left out with its reason). The field list needs the
|
||
* Divi role option read_dynamic_content_custom_fields (E3, F25).
|
||
*/
|
||
final class DiviDynamicSourcesAbility implements Ability
|
||
{
|
||
/** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */
|
||
private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice', 'unknown'];
|
||
|
||
private const ID_TYPES = ['relation', 'file'];
|
||
|
||
/** @var Closure(string): int */
|
||
private Closure $samplePost;
|
||
|
||
/** @var Closure(string): ?string */
|
||
private Closure $podStorage;
|
||
|
||
/**
|
||
* @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none).
|
||
* @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"), null without a pod.
|
||
*/
|
||
public function __construct(
|
||
private ContentSchema $schema,
|
||
private DynamicContentMap $map,
|
||
private DiviRoles $roles,
|
||
?Closure $samplePost = null,
|
||
?Closure $podStorage = null
|
||
) {
|
||
$this->samplePost = $samplePost ?? static function (string $postType): int {
|
||
$ids = get_posts([
|
||
'post_type' => $postType,
|
||
'post_status' => ['publish', 'draft', 'pending', 'private', 'future'],
|
||
'posts_per_page' => 1,
|
||
'orderby' => 'modified',
|
||
'order' => 'DESC',
|
||
'fields' => 'ids',
|
||
'no_found_rows' => true,
|
||
]);
|
||
|
||
return is_array($ids) && isset($ids[0]) ? (int) $ids[0] : 0;
|
||
};
|
||
$this->podStorage = $podStorage ?? static function (string $postType): ?string {
|
||
if (! function_exists('pods_api')) {
|
||
return null;
|
||
}
|
||
try {
|
||
$pod = pods_api()->load_pod(['name' => $postType]);
|
||
} catch (Throwable) {
|
||
return null;
|
||
}
|
||
|
||
return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null;
|
||
};
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-dynamic-sources';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Dynamic-Content-Quellen';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, '
|
||
. 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:<ID>}}) bzw. dem Namen für die Vollform, '
|
||
. 'und die Felder des Typs, die sich als {{field:<Schlüssel>}} eignen. Nur textartige Felder; Beziehungs-, '
|
||
. 'Datei- und Tabellenfelder stehen mit Grund unter omitted.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']],
|
||
'required' => ['post_type'],
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'post_type' => ['type' => 'string'],
|
||
'sample_post_id' => ['type' => 'integer'],
|
||
'sources' => [
|
||
'type' => 'array',
|
||
'items' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'name' => ['type' => 'string'],
|
||
'label' => ['type' => 'string'],
|
||
'group' => ['type' => 'string'],
|
||
'placeholder' => ['type' => ['string', 'null']],
|
||
'settings' => ['type' => 'object'],
|
||
],
|
||
'required' => ['name', 'label', 'group', 'placeholder', 'settings'],
|
||
],
|
||
],
|
||
'fields' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'omitted' => ['type' => 'array', 'items' => ['type' => 'object']],
|
||
'notes' => ['type' => 'array', 'items' => ['type' => 'string']],
|
||
],
|
||
'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'],
|
||
];
|
||
}
|
||
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::read();
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
|
||
|
||
return $type instanceof WP_Error ? $type : true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts');
|
||
if ($type instanceof WP_Error) {
|
||
return $type;
|
||
}
|
||
|
||
$postType = (string) $input['post_type'];
|
||
$sample = ($this->samplePost)($postType);
|
||
$notes = [
|
||
'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"<name>",'
|
||
. '"settings":{…}}})$ mit den genannten Einstellungen schreiben.',
|
||
];
|
||
$options = $this->map->options($sample);
|
||
if ($options === null) {
|
||
$notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.';
|
||
$options = [];
|
||
foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) {
|
||
$options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings];
|
||
}
|
||
}
|
||
|
||
$sources = [];
|
||
foreach ($options as $name => $option) {
|
||
$sources[] = [
|
||
'name' => (string) $name,
|
||
'label' => $option['label'],
|
||
'group' => $option['group'],
|
||
'placeholder' => self::placeholder((string) $name),
|
||
'settings' => JsonMap::of($option['settings']),
|
||
];
|
||
}
|
||
|
||
$fields = [];
|
||
$omitted = [];
|
||
if ($this->roles->allows('read_dynamic_content_custom_fields')) {
|
||
[$fields, $omitted] = $this->fields($postType);
|
||
} else {
|
||
$notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte '
|
||
. 'Felder lesen“ nicht (Divi → Rollen-Editor).';
|
||
}
|
||
|
||
return [
|
||
'post_type' => $postType,
|
||
'sample_post_id' => $sample,
|
||
'sources' => $sources,
|
||
'fields' => $fields,
|
||
'omitted' => $omitted,
|
||
'notes' => $notes,
|
||
];
|
||
}
|
||
|
||
private static function placeholder(string $name): ?string
|
||
{
|
||
$post = array_search($name, Placeholders::POST, true);
|
||
if ($post !== false) {
|
||
return '{{post:' . $post . '}}';
|
||
}
|
||
|
||
return str_starts_with($name, 'post_link_url_') ? '{{link:<ID>}}' : null;
|
||
}
|
||
|
||
/**
|
||
* @return array{0: list<array<string, string>>, 1: list<array<string, string>>} Offered and omitted fields.
|
||
*/
|
||
private function fields(string $postType): array
|
||
{
|
||
$storage = ($this->podStorage)($postType);
|
||
$fields = [];
|
||
$omitted = [];
|
||
foreach ($this->schema->fields($postType) as $field) {
|
||
if ($field->status === FieldDefinition::HIDDEN) {
|
||
continue;
|
||
}
|
||
|
||
$entry = ['key' => $field->key, 'label' => $field->label];
|
||
$reason = match (true) {
|
||
! DynamicContentRules::isMetaKey($field->key) => 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)',
|
||
$field->source === 'pods' && $storage === 'table' => 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta',
|
||
in_array($field->type, self::ID_TYPES, true) => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs',
|
||
! in_array($field->type, self::TEXT_TYPES, true) => sprintf('Feldtyp „%s“ ist nicht textartig', $field->type),
|
||
default => null,
|
||
};
|
||
if ($reason !== null) {
|
||
$omitted[] = $entry + ['reason' => $reason];
|
||
continue;
|
||
}
|
||
|
||
$fields[] = $entry + [
|
||
'type' => $field->type,
|
||
'source' => $field->source,
|
||
'placeholder' => '{{field:' . $field->key . '}}',
|
||
];
|
||
}
|
||
|
||
return [$fields, $omitted];
|
||
}
|
||
}
|
||
```
|
||
|
||
`DiviIntegration::abilities()` – nach `DiviNodeCopyAbility` (Import `DynamicContentMap`, `DiviDynamicSourcesAbility`):
|
||
|
||
```php
|
||
new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()),
|
||
```
|
||
|
||
`docs/guides/overview.md`: „…, `lit/divi-node-copy`)“ → „…, `lit/divi-node-copy`, `lit/divi-dynamic-sources`)“.
|
||
|
||
- [ ] **Step 4: Tests laufen lassen**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'DynamicContentMapTest|DiviDynamicSourcesAbilityTest|DiviIntegrationTest|ToolNamingTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/Integrations/Divi/Pages/DynamicContentMap.php src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php src/Integrations/Divi/DiviIntegration.php docs/guides/overview.md tests/Unit/Integrations/Divi
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `feat: lit/divi-dynamic-sources with text-like fields only (E12, F4)`.
|
||
|
||
---
|
||
|
||
### Task 16: Guides, Server-Instructions, Readme, Version 0.4.0, Spec- und Entscheidungs-Nachträge
|
||
|
||
Dokumentation zu allem aus 3a, deutsch mit Umlauten, neutral formuliert (keine Chatbot-Marke; der Readme-Satz „z. B. Claude oder ein anderer MCP-fähiger Client“ wird dabei neutralisiert). Neues Guide-Thema `divi-library` mit dem Ablauf „Globale Module vorschlagen“ (E14; MCP-Prompts gibt es im Plugin noch nicht, siehe offene Punkte). Server-Instructions bekommen eine Zeile zur Bibliothek, wenn die Gruppe aktiv ist.
|
||
|
||
**Files:**
|
||
- Create: `docs/guides/divi-library.md`
|
||
- Modify: `docs/guides/overview.md`, `docs/guides/divi-placeholders.md`, `docs/guides/divi-structure.md`, `docs/guides/divi-design-system.md`, `docs/guides/workflows.md`
|
||
- Modify: `src/Guides/InstructionsBuilder.php`
|
||
- Modify: `readme.txt`, `wp-lit-mcp.php`, `src/Plugin.php`
|
||
- Modify: `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`, `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md`
|
||
- Test: `tests/Unit/Guides/InstructionsBuilderTest.php`, `tests/Unit/Guides/ToolNamingTest.php` (unverändert, muss grün bleiben)
|
||
|
||
**Interfaces:**
|
||
- Consumes: alle Tools aus Task 2–15.
|
||
- Produces: Guide-Thema `divi-library`; `Plugin::VERSION = '0.4.0'`; Instructions-Zeile „Divi-Bibliothek: …“.
|
||
|
||
- [ ] **Step 1: Failing test schreiben**
|
||
|
||
In `InstructionsBuilderTest`:
|
||
|
||
```php
|
||
public function testAddsTheLibraryRuleOnlyWhenTheLibraryGroupIsActive(): void
|
||
{
|
||
$library = Mockery::mock(Integration::class);
|
||
$library->allows('id')->andReturn('divi-library');
|
||
$library->allows('isAvailable')->andReturn(true);
|
||
$library->allows('abilities')->andReturn([$this->ability('lit/library-insert', true)]);
|
||
$core = $this->coreWith($this->ability('lit/guide', true));
|
||
|
||
$with = (new InstructionsBuilder(new IntegrationRegistry($core, $library)))->build();
|
||
$without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();
|
||
|
||
self::assertStringContainsString('Divi-Bibliothek: globale Module', $with);
|
||
self::assertStringContainsString('confirm: true', $with);
|
||
self::assertStringNotContainsString('Divi-Bibliothek:', $without);
|
||
self::assertLessThan(2000, strlen($with));
|
||
}
|
||
```
|
||
|
||
- [ ] **Step 2: Test laufen lassen – schlägt fehl**
|
||
|
||
Run: `vendor/bin/phpunit --filter InstructionsBuilderTest`
|
||
Expected: FAIL
|
||
|
||
- [ ] **Step 3: Guide `divi-library` anlegen**
|
||
|
||
`docs/guides/divi-library.md`:
|
||
|
||
```markdown
|
||
# Divi-Bibliothek und globale Module
|
||
|
||
Die Divi-Bibliothek speichert Layouts, Abschnitte, Zeilen und Module zur Wiederverwendung.
|
||
|
||
- **Normale Einträge** werden beim Einfügen kopiert; die Kopie ist danach unabhängig.
|
||
- **Globale Einträge** werden als Instanz eingefügt. Divi zeigt überall den aktuellen Inhalt des
|
||
Bibliothekseintrags: eine Änderung dort wirkt sofort auf allen Seiten mit diesem Modul – auch auf
|
||
veröffentlichten.
|
||
|
||
## Werkzeuge
|
||
|
||
- `lit/library-list` (über `mcp-adapter-execute-ability`): Einträge auflisten, Filter `layout_type`
|
||
(`layout`, `section`, `row`, `module`), `scope` (`normal`, `global`), `search`, `category`, weiterblättern
|
||
mit `cursor`. Mit `id`: ein Eintrag im Detail – Knoten-Übersicht (`outline`), `tree_rev` und bei globalen
|
||
Einträgen die Seiten, die ihn zeigen (`affected`, `affected_count`).
|
||
- `lit-library-insert`: einen Eintrag in eine Divi-Seite einfügen. `mode: "global"` (Standard bei globalen
|
||
Einträgen) fügt eine verknüpfte Instanz ein, `mode: "copy"` (Standard sonst) eine unabhängige Kopie.
|
||
Position wie bei `lit-divi-insert` (`parent`, `before`, `after`, ohne Angabe ans Seitenende); `tree_rev`
|
||
der Zielseite ist Pflicht.
|
||
- `lit/library-save`: einen Abschnitt, eine Zeile oder ein Modul als neuen Eintrag speichern (`title`,
|
||
`scope: "normal"` oder `"global"`, vorhandene `categories`/`tags`). Der Knoten auf der Seite bleibt eine
|
||
eigenständige Kopie – um ihn zu verknüpfen, ihn durch eine Instanz ersetzen (siehe Ablauf unten).
|
||
- `lit/divi-node-copy`: einen Knoten von einem Eintrag an eine Position eines anderen kopieren
|
||
(`source_post_id`, `source_id`, `source_tree_rev` aus `lit-divi-outline` der Quelle; Ziel mit `post_id`,
|
||
`tree_rev`, Position). Die Kopie ist byte-gleich; globale Module bleiben verknüpft.
|
||
- Die Divi-Werkzeuge arbeiten auch auf Bibliothekseinträgen: `lit-divi-outline`, `lit-divi-node-get`,
|
||
`lit-divi-insert`, `lit-divi-update`, `lit-divi-move`, `lit-divi-delete` und `lit/divi-duplicate-node`
|
||
mit der ID des Eintrags als `post_id`.
|
||
|
||
## Globale Module erkennen und ändern
|
||
|
||
1. In `lit-divi-outline` einer Seite erscheinen Instanzen als z. B. `g0 section "Kontakt" [global 1058]
|
||
(nur lesbar)`; `lit-divi-node-get` nennt unter `global` die ID des Bibliothekseintrags. Auf der Seite
|
||
lassen sie sich nur als Ganzes verschieben, duplizieren oder löschen.
|
||
2. `lit-divi-outline` mit `post_id` = ID des Eintrags lesen und die Änderung wie auf einer Seite senden.
|
||
3. Ohne `confirm` ist das ein Probelauf: Antwort `confirm_required` mit `affected` (betroffene Seiten, Titel
|
||
nicht lesbarer Seiten als „(nicht lesbar)“) und `affected_count`; gespeichert wird nichts.
|
||
4. Dem Nutzer die betroffenen Seiten nennen und fragen. Erst nach Zustimmung dieselbe Änderung mit
|
||
`confirm: true` wiederholen (bei `lit-divi-delete` gilt dasselbe `confirm`).
|
||
5. Vorher entsteht eine Revision des Eintrags (`revision_id`); sind Revisionen abgeschaltet, eine
|
||
Sicherungskopie (`backup_id`). Divis CSS-Cache wird für alle Seiten geleert.
|
||
|
||
Regeln für Bibliothekseinträge: ein Abschnitts-, Zeilen- oder Modul-Eintrag behält genau einen Knoten dieses
|
||
Typs (die Instanzen auf den Seiten erwarten ihn); ein Layout-Eintrag enthält nur Abschnitte; ein globaler
|
||
Eintrag enthält keine globalen Module.
|
||
|
||
`lit-page-render-summary` zeigt globale Module nicht (über die Schnittstelle rendert Divi sie nicht) und nennt
|
||
sie in `notes`; ihren Inhalt mit `lit-divi-outline` und der ID lesen.
|
||
|
||
## Rechte
|
||
|
||
- WordPress: die Bibliothek nutzen = Beiträge bearbeiten dürfen; einen Eintrag lesen oder ändern = diesen
|
||
Eintrag bearbeiten dürfen; neue Einträge anlegen = Beiträge veröffentlichen dürfen (Bibliothekseinträge
|
||
sind immer aktiv).
|
||
- Divi → Rollen-Editor: „Divi-Bibliothek“, „In der Bibliothek speichern“, „Aus der Bibliothek hinzufügen“,
|
||
„Globale Bibliothekselemente bearbeiten“. Fehlt eines, antwortet das Werkzeug mit `forbidden` und diesem
|
||
Hinweis.
|
||
- Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ auf „nur lesen“ sperrt jeden Schreibzugriff auf
|
||
Bibliothekseinträge, auch über die Divi-Werkzeuge; `lit-library-insert` ist außerdem gesperrt, wenn
|
||
`lit-divi-insert` gesperrt ist.
|
||
- Ohne das Recht `unfiltered_html` lassen sich Knoten mit eigenem CSS, eigenen HTML-Attributen, dem
|
||
Code-Modul oder dynamischem Inhalt mit HTML davor/danach nicht in einen anderen Eintrag übertragen
|
||
(Kopieren, Einfügen, Speichern) – „nur im Divi-Builder“.
|
||
|
||
## Ablauf: globale Module vorschlagen
|
||
|
||
1. Die Seiten lesen (`lit-divi-outline`) und wiederkehrende Abschnitte suchen (gleiche Admin-Labels oder
|
||
Texte, z. B. Kontakt, Öffnungszeiten, Call-to-Action).
|
||
2. Dem Nutzer vorschlagen, welche davon globale Module werden sollen, und erklären: eine Änderung wirkt
|
||
danach auf allen Seiten.
|
||
3. Nach Zustimmung den Abschnitt einer Seite mit `lit/library-save` und `scope: "global"` speichern.
|
||
4. Auf jeder Seite (auch der ersten) die Instanz mit `lit-library-insert` direkt vor dem bisherigen Abschnitt
|
||
einfügen und den bisherigen Abschnitt nach Rückfrage mit `lit-divi-delete` entfernen.
|
||
5. Den Nutzer bitten, das Ergebnis im Divi-Builder anzusehen.
|
||
|
||
Unterscheiden sich die Abschnitte in Details, vorher mit dem Nutzer klären, welche Fassung gilt.
|
||
```
|
||
|
||
- [ ] **Step 4: Übrige Guides anpassen**
|
||
|
||
`docs/guides/overview.md`:
|
||
- In „Weitere Themen:“ `divi-library` ergänzen:
|
||
|
||
```markdown
|
||
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
|
||
`divi-shortform`, `divi-attrs`, `divi-placeholders`, `divi-design-system` oder `divi-library`.
|
||
```
|
||
|
||
- Direkt darunter einen eigenen Absatz (nach einer Leerzeile, damit der Themen-Test ihn nicht mitliest):
|
||
|
||
```markdown
|
||
Für den Einstieg liefert `lit-guide` mit dem Thema „all“ alle Themen in einem Aufruf.
|
||
```
|
||
|
||
- Nach „Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.“ ergänzen: „Sie arbeiten
|
||
auch auf Einträgen der Divi-Bibliothek (ID als `post_id`); globale Module: `lit-guide` topic `divi-library`.“
|
||
|
||
`docs/guides/divi-placeholders.md` – Tabellenzeile nach `{{field:<meta_key>}}` einfügen:
|
||
|
||
```markdown
|
||
| `{{link:<ID>}}` | Link auf einen anderen Eintrag (Seite, Beitrag …), z. B. `{{link:42}}` – nur in Link-Feldern; Divi setzt beim Anzeigen die aktuelle Adresse ein, also auch für Entwürfe, die erst später veröffentlicht werden |
|
||
```
|
||
|
||
Den Absatz „In Link-Feldern (`url`, `button_url` …) …“ ersetzen durch:
|
||
|
||
```markdown
|
||
In Link-Feldern (`url`, `button_url` …) für interne Links immer `{{link:<ID>}}` verwenden statt fester
|
||
Adressen wie `/?page_id=…`. Der Eintrag muss existieren, lesbar sein und darf nicht im Papierkorb liegen;
|
||
`{{link:…}}` außerhalb von Link-Feldern lehnt das Werkzeug ab. Sonst nimmt ein Link-Feld jeden dynamischen
|
||
Inhalt an; sinnvoll sind nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine
|
||
Link-Variable).
|
||
|
||
Welche dynamischen Quellen und Felder es für einen Inhaltstyp gibt, liefert `lit/divi-dynamic-sources`
|
||
(über `mcp-adapter-execute-ability`): Divis Quellen mit Platzhalter und die Felder, die sich als
|
||
`{{field:…}}` eignen (nur textartige; Beziehungs- und Dateifelder zeigt Divi nur als IDs).
|
||
```
|
||
|
||
`docs/guides/divi-structure.md` – den ersten Punkt unter „Was nicht bearbeitet wird“ ersetzen durch:
|
||
|
||
```markdown
|
||
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben,
|
||
duplizieren oder löschen. Globale Module zeigt die Übersicht mit `[global <ID>]`: ihren Inhalt im
|
||
Bibliothekseintrag ändern (`lit-divi-outline` mit `post_id` = ID; Ablauf mit Probelauf und `confirm` siehe
|
||
`lit-guide` topic `divi-library`). `lit/divi-replace-text` ändert sie nicht, sondern nennt sie in
|
||
`skipped_nodes`.
|
||
```
|
||
|
||
`docs/guides/divi-design-system.md` – vor „Regeln:“ einfügen:
|
||
|
||
```markdown
|
||
Auf großen Websites eingrenzen: `only` (Liste aus `colors`, `variables`, `presets`) liefert nur diese Teile,
|
||
`module` (z. B. `"button"`) nur die Presets dieses Moduls; höchstens 200 Presets je Antwort.
|
||
```
|
||
|
||
`docs/guides/workflows.md` – nach „Divi-Seite bauen“ einfügen:
|
||
|
||
```markdown
|
||
## Bibliothek und globale Module
|
||
|
||
Vorhandene Abschnitte aus der Divi-Bibliothek zuerst suchen (`lit/library-list`) und mit `lit-library-insert`
|
||
einfügen, statt sie neu zu bauen. Einen gelungenen Abschnitt für später speichern: `lit/library-save`.
|
||
Abschnitte zwischen Seiten kopieren: `lit/divi-node-copy`. Globale Module ändern und neue vorschlagen:
|
||
`lit-guide` topic `divi-library`.
|
||
```
|
||
|
||
Im Abschnitt „Text auf vielen Seiten ersetzen“ den letzten Satz ersetzen durch: „Globale Module werden nicht
|
||
geändert, sondern in `skipped_nodes` genannt – sie im Bibliothekseintrag ändern (`lit-guide` topic
|
||
`divi-library`).“
|
||
|
||
- [ ] **Step 5: Server-Instructions**
|
||
|
||
`InstructionsBuilder::build()` – nach dem `$divi`-Block:
|
||
|
||
```php
|
||
$library = str_contains(', ' . $active . ',', ', divi-library,')
|
||
? [
|
||
'- Divi-Bibliothek: globale Module (lit-divi-outline zeigt [global <ID>]) im Bibliothekseintrag ändern; '
|
||
. 'das wirkt auf alle Seiten und braucht nach dem Probelauf confirm: true (lit-guide topic '
|
||
. '"divi-library").',
|
||
]
|
||
: [];
|
||
```
|
||
|
||
und im `implode` `array_merge([...], $divi, $library)`.
|
||
|
||
- [ ] **Step 6: Readme und Version**
|
||
|
||
`wp-lit-mcp.php`: `* Version: 0.4.0`. `src/Plugin.php`: `public const VERSION = '0.4.0';`. `readme.txt`: `Stable tag: 0.4.0`.
|
||
|
||
`readme.txt` – im ersten Satz der Beschreibung „(z. B. Claude oder ein anderer MCP-fähiger Client mit OAuth)“ durch „(ein MCP-fähiger Client mit OAuth)“ ersetzen und die Aufzählung um „Divi-Seiten und die Divi-Bibliothek (auch globale Module)“ ergänzen.
|
||
|
||
Unter „Auswirkungen auf die gesamte Website“ anfügen:
|
||
|
||
```text
|
||
* Divi-Bibliothek: LIT MCP legt Bibliothekseinträge selbst an (Post-Typ et_pb_layout, Status publish, mit
|
||
Divis Begriffen scope, layout_type, module_width und dessen Meta-Feldern) und benutzt keine Builder-Routen.
|
||
Eine Änderung an einem globalen Bibliothekseintrag wirkt sofort auf allen Seiten, die ihn zeigen; LIT MCP
|
||
verlangt dafür einen Probelauf mit Liste der betroffenen Seiten und confirm, legt vorher eine Revision (bei
|
||
abgeschalteten Revisionen eine Sicherungskopie im Papierkorb) an und leert danach Divis CSS-Cache aller Seiten
|
||
und den Theme-Builder-Cache.
|
||
* Divis Rollen-Editor gilt zusätzlich zu den WordPress-Rechten: ohne „Divi-Bibliothek“, „In der Bibliothek
|
||
speichern“, „Aus der Bibliothek hinzufügen“ bzw. „Globale Bibliothekselemente bearbeiten“ lehnt LIT MCP ab.
|
||
* Interne Links über {{link:<ID>}} nutzen Divis eigenen dynamischen Inhalt „<Typ> Link“: die Adresse wird beim
|
||
Anzeigen ermittelt.
|
||
```
|
||
|
||
Unter „Rückgängig machen“ nach dem Punkt „Divi-Seiten“ einfügen:
|
||
|
||
```text
|
||
* Bibliothekseinträge: jede Änderung legt vorher eine Revision an (revision_id), bei abgeschalteten Revisionen
|
||
eine Sicherungskopie im Papierkorb (backup_id); zurück über WordPress (Eintrag der Divi-Bibliothek
|
||
bearbeiten → Revisionen). Neu angelegte Einträge (lit/library-save) in der Divi-Bibliothek löschen.
|
||
```
|
||
|
||
Unter „Frequently Asked Questions“ anfügen:
|
||
|
||
```text
|
||
= Was passiert, wenn der Assistent ein globales Modul ändert? =
|
||
|
||
Die Änderung wirkt auf allen Seiten mit diesem Modul. LIT MCP speichert erst nach einem Probelauf, der die
|
||
betroffenen Seiten nennt, und einer ausdrücklichen Bestätigung (confirm); vorher entsteht eine Revision des
|
||
Bibliothekseintrags.
|
||
```
|
||
|
||
Changelog oben:
|
||
|
||
```text
|
||
= 0.4.0 =
|
||
* Divi-Bibliothek: Einträge auflisten und lesen (lit/library-list), Knoten als normalen oder globalen Eintrag
|
||
speichern (lit/library-save), als Kopie oder globale Instanz einfügen (lit-library-insert, direkt).
|
||
* Globale Module: Bibliothekseinträge mit den Divi-Werkzeugen bearbeiten; bei globalen Einträgen Probelauf
|
||
mit betroffenen Seiten, confirm, Revision bzw. Sicherung und seitenweite Cache-Leerung.
|
||
* Knoten zwischen Seiten und Bibliothekseinträgen kopieren (lit/divi-node-copy).
|
||
* Interne Links mit {{link:<ID>}}; lit/divi-dynamic-sources listet Divis dynamische Quellen und geeignete Felder.
|
||
* lit-guide mit dem Thema „all“; lit-divi-design-system mit Filtern (only, module); lit-divi-node-get und
|
||
lit-divi-outline nennen die Bibliotheks-ID globaler Module; lit-page-render-summary weist auf globale Module
|
||
hin; lit-site-status prüft die genutzten Divi-Funktionen.
|
||
* Tools-Gruppe „Divi-Bibliothek“; „nur lesen“ sperrt auch Schreibzugriffe der Divi-Werkzeuge auf die Bibliothek.
|
||
```
|
||
|
||
- [ ] **Step 7: Spec und Entscheidungen nachtragen**
|
||
|
||
In `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md`:
|
||
|
||
- §4.3, zweiter Punkt, anfügen: „**Stand nach Etappe 3a:** 22 direkte Tools (dazu `lit/library-insert`); indirekt neu `lit/library-list`, `lit/library-save`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`. Neue Tool-Gruppe „Divi-Bibliothek“.“
|
||
- §5.2, letzter Punkt: „(Bearbeitung ab Etappe 3)“ → „(bearbeitet werden globale Module seit Etappe 3a im Bibliothekseintrag, E2)“.
|
||
- §5.4, Platzhalter-Tabelle nach `{{field:<meta_key>}}`: „| `{{link:<ID>}}` | interner Link: Dynamic Content `post_link_url_<Typ>` mit `post_id` (nur Link-Felder; Etappe 3a, E13a) |“
|
||
- §5.7: Zeile `lit/divi-dynamic-sources` → „… – Etappe 3a (E12, E14); nur textartige Felder (Antwort F4)“; Zeile `lit/library-list`, `lit/library-get` → „| `lit/library-list` | lesen | Einträge nach Typ (`layout_type`) und Scope, mit `cursor`; mit `id` Detail inkl. Knoten-Übersicht und betroffener Seiten (ersetzt `lit/library-get`, E9) |“; Zeile `lit/library-insert` → „… als Kopie oder globale Instanz im Format des Visual Builders (`localAttrs`-Schnappschuss, `globalParent`) einfügen; direkt“; Zeile `lit/divi-node-copy` → „Knoten von Eintrag A nach Eintrag B kopieren (Seiten und Bibliothekseinträge; byte-gleich, S7/S26-Prüfung E6)“.
|
||
- §9.2, Themenliste: „`divi-library`“ und „`media`“ ergänzen sowie „Thema `all` liefert alle Themen (Etappe 3a)“.
|
||
- §9.3: „(Stand Etappe 3a: noch keine MCP-Prompts im Plugin; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.)“
|
||
|
||
In `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` am Ende anfügen:
|
||
|
||
```markdown
|
||
## 6. Umsetzung Etappe 3a (Plan 2026-09-27-etappe-3a-bibliothek.md)
|
||
|
||
- E5 präzisiert durch die Builder-Fixtures: Instanzen werden wie im Visual Builder erzeugt – `localAttrs` =
|
||
vollständiger Schnappschuss der Wurzel-Attribute (oberste Schlüssel sortiert), Kinder mit `globalParent`,
|
||
ohne Kinder selbstschließend – statt der minimalen Form ohne `localAttrs`. Byte-Test gegen
|
||
`page--globale-instanzen.html`.
|
||
- Neuer Befund 9: Bibliothekseinträge aus Divis Dialog haben `\r\n` zwischen Blöcken (Formular-Upload);
|
||
`LibraryContent::load()` vereinheitlicht das vor jeder Übernahme.
|
||
- Kopie eines normalen Eintrags mit globalen Instanzen: Instanzen bleiben verknüpft (wie Divis `/load`);
|
||
entglobalisiert wird nur der Inhalt globaler Einträge (Divis Schadensbegrenzung, F10).
|
||
- E6 erweitert: `StoredNodeGuard` prüft neben den S7/S26-Pfaden und dem Code-Modul auch `$variable`-Verweise
|
||
(Ablehnung, wenn `canonical()` sie für das Ziel ablehnt oder ihre Einstellungen verändern würde) – Grund:
|
||
R2-Befund 1 (before/after nach dem Autor des Ziel-Posts).
|
||
- E3 ergänzt: neue Einträge zusätzlich nur mit `publish_posts` (Bibliothekseinträge sind immer `publish`).
|
||
- E9 umgesetzt mit: `lit/divi-node-copy` und `lit/divi-dynamic-sources` in der Gruppe „Divi“; die Gruppe
|
||
„Divi-Bibliothek“ sperrt als Ganzes („nur lesen“/deaktiviert) auch Schreibzugriffe der Divi-Werkzeuge auf
|
||
Bibliothekseinträge (`ExposurePolicy::groupWritable`).
|
||
- Antwort F2: jede Änderung eines globalen Eintrags ist zuerst ein Probelauf (auch ohne veröffentlichte
|
||
Instanzen); `lit-divi-delete` nutzt dasselbe `confirm`, sein `confirm_required` nennt dann ebenfalls die
|
||
betroffenen Seiten.
|
||
- Bibliothekseinträge bekommen keine Divi-Flags (Befund 4); `_et_pb_row_layout` wird nicht geschrieben (Befund 5).
|
||
- E15: Adapter `DiviRoles`, `DiviLibraryStore`, Prüfung `DiviCompatibility` in `lit-site-status`.
|
||
- E13d umgesetzt als Hinweis in `notes`; eine eigene Expansion globaler Module im Auszug unterbleibt.
|
||
```
|
||
|
||
- [ ] **Step 8: Tests und Lint**
|
||
|
||
Run: `vendor/bin/phpunit --filter 'InstructionsBuilderTest|ToolNamingTest|GuideRepositoryTest|GuideAbilityTest'`
|
||
Expected: PASS
|
||
|
||
Run: `composer test && composer lint`
|
||
Expected: OK
|
||
|
||
Run: `grep -rn "Claude\|ChatGPT\|claude.ai" docs/guides readme.txt src/Admin src/Guides`
|
||
Expected: keine Treffer
|
||
|
||
- [ ] **Step 9: Commit**
|
||
|
||
```bash
|
||
git add docs/guides src/Guides/InstructionsBuilder.php readme.txt wp-lit-mcp.php src/Plugin.php docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md tests/Unit/Guides/InstructionsBuilderTest.php
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `docs: guides, readme and spec for the Divi Library (v0.4.0)`.
|
||
|
||
---
|
||
|
||
### Task 17: Ende-zu-Ende-Test `divi-library-smoke.sh` und Abnahme-Notiz
|
||
|
||
Prüft 3a über den echten OAuth-Endpunkt in DDEV (Divi 5.2.1): Exposition, Speichern normal/global mit Divis Termen, Einfügen als Instanz und Kopie, globaler Probelauf/`confirm`, Knoten kopieren, `{{link:ID}}`, die Übernahmen, ein Redakteur ohne `unfiltered_html` und die Gruppe „nur lesen“. Räumt alles wieder auf. Danach die manuelle Abnahme-Notiz für die Punkte, die den Visual Builder brauchen.
|
||
|
||
**Files:**
|
||
- Create: `tests/Integration/divi-library-smoke.sh`
|
||
- Create: `docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md`
|
||
|
||
**Interfaces:**
|
||
- Consumes: `tests/Integration/lib.sh` (`connect`, `tool`, `ability`, `rpc`, `error_code`, `pass`, `fail`, `finish`, `guard_exit`, `cleanup_app_passwords_for`), Fixture `tests/Fixtures/divi/converted-landing.html`.
|
||
- Produces: Skript, das mit `ALL CHECKS PASSED` endet.
|
||
|
||
- [ ] **Step 1: Skript schreiben**
|
||
|
||
`tests/Integration/divi-library-smoke.sh`:
|
||
|
||
```bash
|
||
#!/usr/bin/env bash
|
||
# End-to-end checks of Etappe 3a (Divi Library, global modules, node copy) through the OAuth-protected
|
||
# endpoint. Runs INSIDE the DDEV web container (Divi 5 must be the active theme):
|
||
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-library-smoke.sh
|
||
# Creates draft pages and library entries (titles "LIT Smoke …"), a temporary role/user without
|
||
# unfiltered_html, and removes everything again (posts with their revisions, backups, change-log rows,
|
||
# role, user, app passwords, options).
|
||
set -euo pipefail
|
||
cd /var/www/html
|
||
# shellcheck source=lib.sh
|
||
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
|
||
|
||
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
|
||
EDITOR_LOGIN=lit-lib-editor
|
||
FIXTURES=$PLUGIN/tests/Fixtures/divi
|
||
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]')
|
||
ORIGINAL_EXPOSURE=$(wp option get lit_mcp_exposure --format=json 2>/dev/null || echo '__none__')
|
||
CREATED_POSTS=()
|
||
|
||
restore_exposure() {
|
||
if [ "$ORIGINAL_EXPOSURE" = "__none__" ]; then
|
||
wp option delete lit_mcp_exposure >/dev/null 2>&1 || true
|
||
else
|
||
wp option update lit_mcp_exposure "$ORIGINAL_EXPOSURE" --format=json >/dev/null 2>&1 || true
|
||
fi
|
||
}
|
||
|
||
cleanup() {
|
||
# Library entries and backups the tools created ("LIT Smoke …", also from a run that stopped early).
|
||
for id in $(wp post list --post_type=et_pb_layout --post_status=publish,draft,trash --s="LIT Smoke" --field=ID 2>/dev/null); do
|
||
CREATED_POSTS+=("$id")
|
||
done
|
||
for id in "${CREATED_POSTS[@]}"; do
|
||
wp post delete "$id" --force >/dev/null 2>&1 || true
|
||
done
|
||
cleanup_app_passwords_for "$ADMIN_LOGIN"
|
||
cleanup_app_passwords_for "$EDITOR_LOGIN"
|
||
LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]}" wp eval '
|
||
global $wpdb;
|
||
$table = $wpdb->prefix . "lit_mcp_log";
|
||
$u = get_user_by("login", getenv("LIT_LOGIN"));
|
||
if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); }
|
||
foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
|
||
$wpdb->delete($table, ["object_id" => $id]);
|
||
}' >/dev/null 2>&1 || true
|
||
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
|
||
wp role delete lit_lib_editor >/dev/null 2>&1 || true
|
||
wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
|
||
restore_exposure
|
||
}
|
||
trap 'rc=$?; cleanup; guard_exit "$rc" || exit $?' EXIT
|
||
|
||
create_page() { # $1 = markup (not a file), $2 = title -> prints the ID
|
||
LIT_CONTENT="$1" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval '
|
||
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
|
||
"post_content" => wp_slash((string) getenv("LIT_CONTENT"))]);
|
||
update_post_meta($id, "_et_pb_use_builder", "on");
|
||
update_post_meta($id, "_et_pb_use_divi_5", "on");
|
||
echo $id;'
|
||
}
|
||
|
||
content_of() { # $1 = post ID
|
||
wp post get "$1" --field=post_content
|
||
}
|
||
|
||
outline() { # $1 = post ID -> the lit-divi-outline result
|
||
tool lit-divi-outline "{\"post_id\":$1}"
|
||
}
|
||
|
||
# 0. Two pages from the converted landing page fixture
|
||
LANDING=$(cat "$FIXTURES/converted-landing.html")
|
||
PAGE_ID=$(create_page "$LANDING" "LIT Smoke Quelle"); CREATED_POSTS+=("$PAGE_ID")
|
||
TARGET_ID=$(create_page "$LANDING" "LIT Smoke Ziel"); CREATED_POSTS+=("$TARGET_ID")
|
||
connect "$ADMIN_LOGIN"
|
||
|
||
# 1. Exposure: lit-library-insert direct, the other new tools indirect
|
||
LIST=$(rpc tools/list '{}')
|
||
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null || fail "lit-library-insert is not direct"
|
||
for t in lit-library-list lit-library-save lit-divi-node-copy lit-divi-dynamic-sources; do
|
||
if echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null; then fail "$t must be indirect"; fi
|
||
done
|
||
pass "tools/list: lit-library-insert direct, library-list/-save, node-copy, dynamic-sources indirect"
|
||
|
||
# 2. Save a section as normal library entry: Divi's terms, no Divi flags, unchanged node bytes
|
||
SOURCE=$(outline "$PAGE_ID")
|
||
REV=$(echo "$SOURCE" | jq -r '.tree_rev')
|
||
SECTION=$(echo "$SOURCE" | jq -r '[.nodes[] | select(.type == "section")][1].id')
|
||
SAVED=$(ability lit/library-save "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$REV" --arg s "$SECTION" \
|
||
'{post_id: $id, tree_rev: $rev, id: $s, title: "LIT Smoke Abschnitt"}')")
|
||
LIB_ID=$(echo "$SAVED" | jq -r '.library_id // empty')
|
||
[ -n "$LIB_ID" ] || fail "library-save: $SAVED"
|
||
CREATED_POSTS+=("$LIB_ID")
|
||
[ "$(wp post term list "$LIB_ID" layout_type --field=slug)" = "section" ] || fail "layout_type of $LIB_ID"
|
||
[ "$(wp post term list "$LIB_ID" scope --field=slug)" = "non_global" ] || fail "scope of $LIB_ID"
|
||
[ -z "$(wp post meta get "$LIB_ID" _et_pb_use_divi_5 2>/dev/null || true)" ] || fail "library entry got Divi flags"
|
||
[ "$(LIT_PAGE="$PAGE_ID" LIT_LIB="$LIB_ID" wp eval '
|
||
$page = (string) get_post((int) getenv("LIT_PAGE"))->post_content;
|
||
$lib = (string) get_post((int) getenv("LIT_LIB"))->post_content;
|
||
$open = "<!-- wp:divi/placeholder -->"; $close = "<!-- /wp:divi/placeholder -->";
|
||
$inner = str_starts_with($lib, $open) && str_ends_with($lib, $close) ? substr($lib, strlen($open), -strlen($close)) : "";
|
||
echo $inner !== "" && str_contains($page, $inner) ? "yes" : "no";')" = "yes" ] || fail "library entry does not hold the node bytes"
|
||
ability lit/library-list '{"search":"LIT Smoke"}' | jq -e --argjson id "$LIB_ID" '.items | map(.id) | index($id) != null' >/dev/null \
|
||
|| fail "library-list does not show $LIB_ID"
|
||
pass "lit/library-save: normal section with Divi's terms, node bytes in a divi/placeholder frame, listed"
|
||
|
||
# 3. Save a module as global entry, insert it as instance into the target page
|
||
TEXT=$(echo "$SOURCE" | jq -r '[.nodes[] | select(.type == "text")][0].id')
|
||
GSAVED=$(ability lit/library-save "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$REV" --arg m "$TEXT" \
|
||
'{post_id: $id, tree_rev: $rev, id: $m, title: "LIT Smoke Globales Modul", scope: "global"}')")
|
||
GLOB_ID=$(echo "$GSAVED" | jq -r '.library_id // empty')
|
||
[ -n "$GLOB_ID" ] || fail "global library-save: $GSAVED"
|
||
CREATED_POSTS+=("$GLOB_ID")
|
||
[ "$(wp post term list "$GLOB_ID" scope --field=slug)" = "global" ] || fail "scope of $GLOB_ID"
|
||
[ "$(wp post meta get "$GLOB_ID" _et_pb_excluded_global_options)" = "[]" ] || fail "excluded global options of $GLOB_ID"
|
||
TARGET=$(outline "$TARGET_ID")
|
||
COLUMN=$(echo "$TARGET" | jq -r '[.nodes[] | select(.type == "column")][0].id')
|
||
INSERTED=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(echo "$TARGET" | jq -r .tree_rev)" \
|
||
--argjson lib "$GLOB_ID" --arg c "$COLUMN" '{post_id: $id, tree_rev: $rev, library_id: $lib, parent: $c}')")
|
||
echo "$INSERTED" | jq -e '.ok == true' >/dev/null || fail "library-insert global: $INSERTED"
|
||
content_of "$TARGET_ID" | grep -q "<!-- wp:divi/global-layout {\"globalModule\":\"$GLOB_ID\",\"blockName\":\"divi/text\",\"localAttrs\":{" \
|
||
|| fail "instance not in the builder's format"
|
||
outline "$TARGET_ID" | jq -e --arg g "[global $GLOB_ID]" '.outline | contains($g)' >/dev/null || fail "outline does not name the instance"
|
||
tool lit-page-render-summary "{\"post_id\":$TARGET_ID}" | jq -e --arg g "Globales Modul $GLOB_ID" '.notes | map(contains($g)) | any' >/dev/null \
|
||
|| fail "render summary does not note the global module"
|
||
pass "global entry saved; lit-library-insert writes the builder's instance; outline and render summary name it"
|
||
|
||
# 4. Change the global entry: dry run lists the target page, confirm saves with a revision, pages untouched
|
||
BEFORE_TARGET=$(content_of "$TARGET_ID" | sha256sum)
|
||
GREV=$(outline "$GLOB_ID" | jq -r '.tree_rev')
|
||
DRY=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" '{post_id: $id, tree_rev: $rev,
|
||
changes: [{id: "m0", label: "LIT Smoke geändert"}]}')")
|
||
[ "$(error_code "$DRY")" = "confirm_required" ] || fail "global edit without confirm: $DRY"
|
||
echo "$DRY" | jq -r '.__error' | jq -e --argjson t "$TARGET_ID" '.affected | map(.post_id) | index($t) != null' >/dev/null \
|
||
|| fail "dry run does not list the target page: $DRY"
|
||
[ "$(outline "$GLOB_ID" | jq -r '.tree_rev')" = "$GREV" ] || fail "dry run wrote something"
|
||
DONE=$(tool lit-divi-update "$(jq -cn --argjson id "$GLOB_ID" --arg rev "$GREV" '{post_id: $id, tree_rev: $rev, confirm: true,
|
||
changes: [{id: "m0", label: "LIT Smoke geändert"}]}')")
|
||
echo "$DONE" | jq -e '.ok == true and .revision_id != null and .affected_count >= 1' >/dev/null || fail "confirmed global edit: $DONE"
|
||
[ "$(content_of "$TARGET_ID" | sha256sum)" = "$BEFORE_TARGET" ] || fail "the global edit changed the page bytes"
|
||
pass "global entry: dry run with affected pages, confirm saves with revision, pages keep their bytes"
|
||
|
||
# 5. Insert the normal entry as copy: no frame, no CRLF
|
||
TREV=$(outline "$TARGET_ID" | jq -r '.tree_rev')
|
||
COPY=$(tool lit-library-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson lib "$LIB_ID" \
|
||
'{post_id: $id, tree_rev: $rev, library_id: $lib, mode: "copy"}')")
|
||
echo "$COPY" | jq -e '.ok == true' >/dev/null || fail "library-insert copy: $COPY"
|
||
if content_of "$TARGET_ID" | grep -q $'\r'; then fail "copy brought CRLF into the page"; fi
|
||
pass "lit-library-insert copy"
|
||
|
||
# 6. Copy a node between pages; a stale source tree_rev writes nothing
|
||
TREV=$(echo "$COPY" | jq -r '.tree_rev')
|
||
STALE=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$PAGE_ID" --arg s "$SECTION" \
|
||
'{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: $s, source_tree_rev: "abcdef012345"}')")
|
||
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale source_tree_rev: $STALE"
|
||
NODE_COPY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --argjson src "$PAGE_ID" --arg s "$SECTION" \
|
||
--arg srev "$(outline "$PAGE_ID" | jq -r .tree_rev)" '{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: $s, source_tree_rev: $srev}')")
|
||
echo "$NODE_COPY" | jq -e '.ok == true' >/dev/null || fail "node-copy: $NODE_COPY"
|
||
pass "lit/divi-node-copy: stale source refused, copy saved"
|
||
|
||
# 7. {{link:ID}} in a URL field; refused in a text field
|
||
TREV=$(echo "$NODE_COPY" | jq -r '.tree_rev')
|
||
LINKED=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --arg c "$COLUMN" --arg u "{{link:$PAGE_ID}}" \
|
||
'{post_id: $id, tree_rev: $rev, parent: $c, nodes: [{type: "button", text: "Zur Quelle", url: $u}]}')")
|
||
echo "$LINKED" | jq -e '.ok == true' >/dev/null || fail "{{link}} in a button: $LINKED"
|
||
content_of "$TARGET_ID" | grep -q 'post_link_url_page' || fail "{{link}} not stored as post_link_url_page"
|
||
TREV=$(echo "$LINKED" | jq -r '.tree_rev')
|
||
TEXT_LINK=$(tool lit-divi-insert "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$TREV" --arg c "$COLUMN" --arg u "{{link:$PAGE_ID}}" \
|
||
'{post_id: $id, tree_rev: $rev, parent: $c, nodes: [{type: "heading", text: $u}]}')")
|
||
[ "$(error_code "$TEXT_LINK")" = "validation_failed" ] || fail "{{link}} in a text field: $TEXT_LINK"
|
||
pass "{{link:ID}} stored as Divi's internal link, refused outside link fields"
|
||
|
||
# 8. Carry-overs: guide "all", design-system filter, dynamic sources, site-status compatibility
|
||
tool lit-guide '{"topic":"all"}' | jq -e '.content | contains("Thema `divi-library`")' >/dev/null || fail "guide all"
|
||
tool lit-divi-design-system '{"only":["colors"]}' | jq -e '.variables == [] and .presets == []' >/dev/null || fail "design-system only"
|
||
ability lit/divi-dynamic-sources '{"post_type":"page"}' | jq -e '.sources | map(.name) | index("post_title") != null' >/dev/null \
|
||
|| fail "dynamic sources"
|
||
tool lit-site-status '{}' | jq -e '.components[] | select(.id == "divi") | .problems == []' >/dev/null || fail "Divi compatibility findings"
|
||
pass "lit-guide all, design-system only, dynamic sources, site-status without Divi findings"
|
||
|
||
# 9. A user without unfiltered_html cannot carry custom CSS to another post (E6)
|
||
CSS_ID=$(create_page '<!-- wp:divi/placeholder --><!-- wp:divi/section {"css":{"desktop":{"value":{"freeForm":"body{outline:1px solid red}"}}}} --><!-- wp:divi/row --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}}} --><!-- wp:divi/text {"content":{"innerContent":{"desktop":{"value":"<p>CSS</p>"}}}} /--><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->' "LIT Smoke CSS")
|
||
CREATED_POSTS+=("$CSS_ID")
|
||
wp role create lit_lib_editor "LIT Smoke Bibliothek" --clone=editor >/dev/null
|
||
wp cap remove lit_lib_editor unfiltered_html >/dev/null
|
||
wp option update lit_mcp_allowed_roles '["administrator","lit_lib_editor"]' --format=json >/dev/null
|
||
wp user create "$EDITOR_LOGIN" lit-lib-editor@example.invalid --role=lit_lib_editor >/dev/null
|
||
connect "$EDITOR_LOGIN"
|
||
BEFORE_TARGET=$(content_of "$TARGET_ID" | sha256sum)
|
||
CARRY=$(ability lit/divi-node-copy "$(jq -cn --argjson id "$TARGET_ID" --arg rev "$(outline "$TARGET_ID" | jq -r .tree_rev)" \
|
||
--argjson src "$CSS_ID" --arg srev "$(outline "$CSS_ID" | jq -r .tree_rev)" \
|
||
'{post_id: $id, tree_rev: $rev, source_post_id: $src, source_id: "s0", source_tree_rev: $srev}')")
|
||
[ "$(error_code "$CARRY")" = "validation_failed" ] || fail "editor carried custom CSS: $CARRY"
|
||
KEEP=$(ability lit/library-save "$(jq -cn --argjson id "$CSS_ID" --arg rev "$(outline "$CSS_ID" | jq -r .tree_rev)" \
|
||
'{post_id: $id, tree_rev: $rev, id: "s0", title: "LIT Smoke CSS-Eintrag"}')")
|
||
[ "$(error_code "$KEEP")" = "validation_failed" ] || fail "editor saved custom CSS to the library: $KEEP"
|
||
[ "$(content_of "$TARGET_ID" | sha256sum)" = "$BEFORE_TARGET" ] || fail "a refused copy wrote something"
|
||
pass "user without unfiltered_html: custom CSS neither copied nor saved to the library"
|
||
|
||
# 10. Group "Divi-Bibliothek" read-only: no writes to library entries, lit-library-insert gone
|
||
wp option update lit_mcp_exposure '{"@readonly:divi-library":true}' --format=json >/dev/null
|
||
connect "$ADMIN_LOGIN"
|
||
if rpc tools/list '{}' | jq -e '.result.tools[] | select(.name == "lit-library-insert")' >/dev/null; then
|
||
fail "lit-library-insert listed although the group is read-only"
|
||
fi
|
||
LREV=$(outline "$LIB_ID" | jq -r '.tree_rev')
|
||
[ "$LREV" != "null" ] || fail "library entry no longer readable"
|
||
BLOCKED=$(tool lit-divi-update "$(jq -cn --argjson id "$LIB_ID" --arg rev "$LREV" '{post_id: $id, tree_rev: $rev,
|
||
changes: [{id: "s0", label: "LIT Smoke gesperrt"}]}')")
|
||
[ "$(error_code "$BLOCKED")" = "forbidden" ] || fail "write to a library entry with the group read-only: $BLOCKED"
|
||
restore_exposure
|
||
pass "group Divi-Bibliothek read-only: entries readable, not writable, lit-library-insert not listed"
|
||
|
||
finish
|
||
```
|
||
|
||
- [ ] **Step 2: Skript ausführen**
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-library-smoke.sh`
|
||
Expected: alle Zeilen `ok - …`, am Ende `ALL CHECKS PASSED`. Danach prüfen, dass nichts übrig ist:
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && ddev wp post list --post_type=et_pb_layout,page --post_status=publish,draft,trash --s="LIT Smoke" --format=count`
|
||
Expected: `0`
|
||
|
||
Die übrigen Smokes laufen unverändert grün:
|
||
|
||
Run: `cd /home/janniclas/Projekte/WordPress && for s in divi-write-smoke divi-read-smoke mcp-smoke wp-basis-smoke media-admin-smoke; do timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/$s.sh | tail -1; done`
|
||
Expected: fünfmal `ALL CHECKS PASSED`
|
||
|
||
- [ ] **Step 3: Abnahme-Notiz schreiben**
|
||
|
||
`docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md`:
|
||
|
||
```markdown
|
||
# Etappe 3a – Manuelle Abnahme (Divi-Bibliothek)
|
||
|
||
Automatisch geprüft sind bereits:
|
||
|
||
- alle Unit-Tests (`composer test`) und Lint (`composer lint`);
|
||
- `divi-library-smoke.sh` (neu) und die fünf bisherigen Integrationsskripte (alle in `tests/Integration/`).
|
||
|
||
`divi-library-smoke.sh` prüft über den OAuth-Endpunkt: `lit-library-insert` direkt, die übrigen neuen Tools
|
||
indirekt; Speichern als normaler und globaler Eintrag mit Divis Begriffen, ohne Divi-Kennzeichen und mit den
|
||
unveränderten Bytes des Knotens; Einfügen als Instanz im Format des Visual Builders und als Kopie; Probelauf,
|
||
`confirm` und Revision beim Ändern eines globalen Eintrags; Knoten kopieren (auch Ablehnung einer veralteten
|
||
Quelle); `{{link:ID}}`; die Übernahmen aus Etappe 2; einen Redakteur ohne `unfiltered_html`, der eigenes CSS
|
||
weder kopieren noch in die Bibliothek speichern kann; die Gruppe „Divi-Bibliothek“ auf „nur lesen“.
|
||
|
||
Die folgenden Punkte brauchen den echten Visual Builder bzw. einen verbundenen KI-Assistenten. Abweichungen
|
||
als kurze Notiz zurückmelden; sie werden Folgeaufgaben.
|
||
|
||
## 1. Vom Plugin angelegte Bibliothekseinträge
|
||
|
||
1. Über den KI-Assistenten einen Abschnitt einer Entwurfsseite als normalen Eintrag und ein Modul als
|
||
globalen Eintrag speichern lassen.
|
||
2. Divi → Divi-Bibliothek öffnen: beide Einträge erscheinen mit richtigem Typ (Abschnitt/Modul), der globale
|
||
als global.
|
||
3. Im Visual Builder einer Seite „Aus Bibliothek hinzufügen“: beide Einträge sind auswählbar und lassen sich
|
||
einfügen.
|
||
|
||
## 2. Vom Plugin eingefügte globale Instanz
|
||
|
||
1. Den globalen Eintrag mit `lit-library-insert` in eine Entwurfsseite einfügen lassen.
|
||
2. Seite im Visual Builder öffnen: das Modul ist als global markiert und zeigt den Inhalt der Bibliothek.
|
||
3. Das Modul im Builder ändern und speichern: die Änderung erscheint im Bibliothekseintrag und auf einer
|
||
zweiten Seite mit derselben Instanz.
|
||
4. Vorschau der Seite: der Inhalt des globalen Moduls wird angezeigt.
|
||
|
||
## 3. Globales Modul über den Assistenten ändern
|
||
|
||
1. Den Assistenten bitten, den Text des globalen Moduls zu ändern. Er muss zuerst die betroffenen Seiten nennen
|
||
und nachfragen.
|
||
2. Nach Zustimmung: Vorschau beider Seiten zeigt den neuen Text; unter Einstellungen → LIT MCP → Protokoll
|
||
steht der Eintrag mit Revision; die Revision ist in WordPress beim Bibliothekseintrag sichtbar.
|
||
|
||
## 4. Kopie und Knoten kopieren
|
||
|
||
1. Einen normalen Eintrag als Kopie einfügen und einen Abschnitt mit `lit/divi-node-copy` auf eine andere Seite
|
||
kopieren lassen.
|
||
2. Beide im Builder öffnen, ändern, speichern – nichts Ungewöhnliches (keine verknüpften Änderungen an anderen
|
||
Seiten).
|
||
|
||
## 5. Interner Link
|
||
|
||
1. Einen Button mit `{{link:<ID einer Entwurfsseite>}}` einfügen lassen.
|
||
2. Im Builder zeigt das Link-Feld dynamischen Inhalt „Seite Link“ mit der gewählten Seite.
|
||
3. Die Zielseite veröffentlichen: der Button verlinkt auf die schöne Adresse.
|
||
|
||
## 6. Rechte und Einstellungen
|
||
|
||
1. In Divi → Rollen-Editor für „Redakteur“ „Globale Bibliothekselemente bearbeiten“ abschalten und als
|
||
Redakteur ein globales Modul ändern lassen: Ablehnung mit Hinweis auf den Rollen-Editor.
|
||
2. Einstellungen → LIT MCP → Tools: Gruppe „Divi-Bibliothek“ ist sichtbar; „nur lesen“ entfernt
|
||
`lit-library-insert` aus der Werkzeugliste (nach neuer Verbindung).
|
||
3. `lit-site-status`: bei Divi keine Einträge unter `problems`.
|
||
|
||
## Aufräumen
|
||
|
||
Die Testeinträge in der Divi-Bibliothek und die Entwurfsseiten löschen (Titel wie bei der Abnahme vergeben).
|
||
Divi legt fehlende Systembegriffe (`scope`, `layout_type`, `module_width`) beim nächsten Speichern selbst an.
|
||
```
|
||
|
||
- [ ] **Step 4: Commit**
|
||
|
||
```bash
|
||
chmod +x tests/Integration/divi-library-smoke.sh
|
||
git add tests/Integration/divi-library-smoke.sh docs/superpowers/notes/2026-09-27-etappe-3a-abnahme.md
|
||
```
|
||
Commit wie in den Global Constraints mit der Nachricht `test: end-to-end smoke and acceptance note for the Divi Library (3a)`.
|
||
|
||
---
|
||
|
||
## Offene Punkte (nicht Teil dieses Plans)
|
||
|
||
- `lit/revision-restore` erreicht Bibliothekseinträge nicht (`ContentSchema::EXCLUDED`); Zurücksetzen über die WordPress-Oberfläche. Kandidat für 3b.
|
||
- `lit/divi-replace-text` durchsucht keine Bibliothekseinträge (je Inhaltstyp); globale Module werden weiter als `skipped_nodes` gemeldet.
|
||
- MCP-Prompts (Spec §9.3) gibt es im Plugin noch nicht; `globale-module-vorschlagen` steht als Ablauf im Guide `divi-library`.
|
||
- Ob der Visual Builder Instanzen mit tief verschachtelten `localAttrs` anders sortiert als nur auf oberster Ebene, zeigt erst ein reicheres Builder-Fixture; die Abnahme (Abschnitt 2) prüft das Verhalten im Builder.
|
||
- Bearbeiten nicht synchronisierter Attribute einer einzelnen Instanz (`localAttrsMap`) bleibt Builder-Sache (E2).
|
||
- `lit/library-make-global`, `lit/library-find-similar`, Theme Builder: Etappe 3b.
|