# Etappe 3b – Theme Builder, ähnliche Abschnitte, globale Module umwandeln – 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 baut Theme-Builder-Vorlagen für Inhaltstypen: Vorlagen, Bedingungen und Layouts lesen (`lit/tb-list`), Vorlagen mit Kopf-, Inhalts- und Fußbereich anlegen – immer **deaktiviert** (`lit/tb-create-template`) –, Bedingungen setzen (`lit/tb-assign`), ausdrücklich aktivieren oder deaktivieren (`lit/tb-activate`), die Layouts mit den vorhandenen Divi-Werkzeugen bearbeiten (Body-Layouts mit Dynamic Content, auch Pods-Feldern); dazu ähnliche Abschnitte finden (`lit/library-find-similar`), Knoten in globale Module umwandeln und inhaltsgleiche Vorkommen ersetzen (`lit/library-make-global`), die aus 3a verschobenen Punkte P13 (Bibliotheks-IDs vollständig ausblenden) und P10 (gleiche Zielregeln für rohe `post_link_url_*`), Guides, Readme, Version 0.5.0, Smoke-Skript und Abnahme. **Architecture:** Divis Theme Builder liegt hinter dem Adapter `DiviThemeBuilder` (Lesen über Divis Funktionen; Anlegen von Layouts und Vorlagen über `et_theme_builder_insert_layout`/`et_theme_builder_store_template` mit eigener Rechteprüfung davor und einer `wp_die`-Sperre währenddessen; Zuordnung zum Live-TB-Post, Bedingungen und Aktiv-Schalter als genaue Nachbildung von Divis Meta-Schreibweise). `ThemeBuilderAccess` bündelt die Rechte je Objekt (`edit_others_posts` + Divi-Rolle `theme_builder` + `edit_post`) und die Gates der neuen Tool-Gruppe „Divi Theme Builder“; `DiviPageLoader` nimmt darüber TB-Layouts an, sodass alle Divi-Werkzeuge sie bearbeiten. `ThemeBuilderEdits` ergänzt `DiviWriter::commit()` für Layouts, die Besucher sehen (Probelauf + `confirm`). Jeder Weg nach `post_content` bleibt `Composer`/`TreeEditor` → `DiviSaver`; übernommene gespeicherte Knoten prüft `StoredNodeGuard`. Reine Fingerabdrücke (`Fingerprint`) tragen `lit/library-find-similar`, die Schreibwarnung „ähnlicher Abschnitt“ und `lit/library-make-global`. **Tech Stack:** PHP ≥ 8.1, WordPress 7.x (Abilities API, `wp_insert_post`/`wp_update_post`, Revisionen, Post-Meta, kses), Divi 5.2.1 (`et_theme_builder_*`, `et_pb_is_allowed`, `DynamicContentOptions`, `ModuleRegistration`, `ET_Core_PageResource`), Pods 3.x (nur für Fixtures, Smoke und Abnahme), `wordpress/mcp-adapter` 0.5, `wp-media/mcp-oauth` 1.1, PHPUnit 9.6 + Brain Monkey + Mockery, phpcs (WPCS, PHPCompatibilityWP), DDEV + bash/jq-Smokes, headless Chrome (DevTools-Protokoll) für die Builder-Fixtures. **Spec:** `docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md` (insb. §2 Prinzip 5, §4.3, §4.5, §5.1, §5.5, §5.6, §5.7 Theme-Builder- und Library-Zeilen, §9, §10, §12 Etappe 3) und `docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md` (Fakten F14–F32, Empfehlungen E1–E17, Schnittstellen-Einordnung Abschnitt 5, **Antworten des Benutzers „5. Antworten des Benutzers“ verbindlich – insbesondere F1: TB-Vorlagen werden deaktiviert angelegt und nur über ein eigenes Tool aktiviert**, Abschnitt 6 „Umsetzung 3a“), dazu `2026-09-27-etappe-3a-rulings.md` (P1–P25; offen für 3b: P13, P10), `2026-09-27-etappe-3a-security-audit.md` (L1–L6, I1–I3), `2026-09-26-etappe-2b-rulings.md` (S1–S52, v. a. S7, S37, S39), `2026-09-26-etappe-2-entscheidungen.md` (D7, D10) und die Fixtures `tests/Fixtures/divi/stage3/` mit `README.md` (Befunde 1–9). **Voraussetzung:** Branch `etappe-3`, Stand e049959 (Etappe 3a vollständig, 1901 Tests grün, sechs Smokes grün). **Pods:** Pods ist in DDEV deaktiviert. **Pods muss aktiv sein** für Task 1 (Fixtures), Task 17 Schritte 3–5 (Smoke-Abschnitt „Dynamic Content“ und Gesamtlauf) und die Pods-Punkte der manuellen Abnahme. Alle Unit-Tests laufen ohne Pods. Der Controller fragt den Benutzer vor Task 1, ob Pods aktiviert werden darf (`ddev wp plugin activate pods`), und nach Task 17, ob es wieder deaktiviert werden soll. ## Entscheidungen dieses Plans 1. **Divis `wp_die`-Wege (F17, F18):** Layouts und neue Vorlagen entstehen über Divis eigene Funktionen `et_theme_builder_insert_layout()` und `et_theme_builder_store_template()` (Stufe c, Spec §5.7 nennt sie). Sie rufen `wp_die()` ohne `edit_others_posts` – das Plugin prüft das Recht vorher selbst (`ThemeBuilderAccess::canCreate()` und nochmals im Adapter) und ersetzt während des Aufrufs jeden `wp_die`-Handler durch einen, der eine Ausnahme wirft (`DiviThemeBuilder::guarded()`; Verteidigung in der Tiefe). Nachgebildet werden nur die Schreibwege, die im REST-Kontext fehlen oder zu viel ändern würden: die Zuordnung zum Live-TB-Post (`et_theme_builder_api_save`, `api.php:223-243`, nur admin-ajax), Bedingungen (`theme-builder.php:1565-1583`) und der Aktiv-Schalter – `store_template` würde bei einer bestehenden Vorlage zusätzlich Standard-Kennzeichen, Titel und Onboarding-Meta neu schreiben. Nach dem Anlegen liest das Plugin die Vorlage zurück; weicht sie ab (Divi verwirft Layout-IDs stillschweigend), wird alles zurückgenommen. 2. **Divis TB-Editor löscht unsere Vorlagen nicht:** (a) Solange ein TB-Entwurf existiert (`auto-draft`, ungespeicherte Sitzung im TB-Editor), lehnen alle schreibenden TB-Tools ab (E7). (b) Neue Vorlagen hängen sofort an der Live-Liste `_et_template` – ein danach geöffneter TB-Editor kennt und behält sie; ihre Layouts sind referenziert und werden nie als ungenutzt markiert. (c) Das Plugin ruft nie `et_theme_builder_trash_draft_and_unused_posts()` auf und fasst den Entwurfs-TB-Post nie an. (d) Restfall „TB-Editor war vor dem Anlegen schon offen und speichert danach“: jede Anlege-Antwort warnt; `lit/tb-list` zeigt solche Vorlagen unter `detached` mit Markierungsdatum; `lit/tb-assign` mit `reattach: true` ordnet sie ausdrücklich wieder zu – immer deaktiviert, Markierungen „ungenutzt“ entfernt. Task 1 prüft den Restfall am echten TB-Editor (Befund 10); bestätigt er sich nicht, bleibt die Vorsorge trotzdem (harmlos). 3. **Keine Aufteilung von 3b in zwei Pläne.** 17 Tasks wie 3a; die Library-Grundlagen aus 3a sind stabil, TB- und Library-Teil berühren getrennte Dateien. Nach Task 11 (Theme Builder vollständig) ist ein **Zwischenhalt**: Controller-Review des TB-Teils und – falls der Benutzer es wünscht – vorgezogene TB-Abnahme; Tasks 12–15 (Konsolidierung) hängen nicht davon ab. 4. **Exposition:** neue Gruppe **„Divi Theme Builder“** (`divi-theme-builder`) mit `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate` – nicht die Gruppe „Divi“, damit ein Betreiber den Theme Builder getrennt abschalten oder auf „nur lesen“ stellen kann; wie bei „Divi-Bibliothek“ (P9) sperrt die Gruppe auch Schreibzugriffe der Divi-Werkzeuge auf TB-Layouts, und „deaktiviert“ lässt TB-Layout-IDs in den Divi-Werkzeugen wie fehlende IDs erscheinen. `lit/library-find-similar` und `lit/library-make-global` gehören zur Gruppe „Divi-Bibliothek“. Alle sechs neuen Tools sind **indirekt**; es bleiben 22 direkte Tools. 5. **Sichtbare Website nur mit ausdrücklicher Freigabe:** Neue Vorlagen sind deaktiviert (F1). Aktivieren/Deaktivieren nur mit `lit/tb-activate` (Probelauf, dann `confirm: true`). Jede andere Änderung, die Besucher sofort sähen – Bedingungen einer aktiven Vorlage (`lit/tb-assign`), Inhalt eines Layouts, das eine aktive Vorlage zeigt (alle Divi-Werkzeuge) –, ist zuerst ein Probelauf und braucht `confirm: true` (wie globale Module, Antwort F2). Offener Punkt für den Benutzer: statt `confirm` strikt ablehnen (siehe Zusammenfassung). ## Global Constraints - **Dateien nur im Plugin-Verzeichnis** `/home/janniclas/Projekte/WordPress/wp-content/plugins/WP-LIT-MCP` anlegen oder ändern. Integrationsskripte und Fixture-Erzeugung schreiben nur in die DDEV-Datenbank und räumen temporäre Seiten, Personen, Vorlagen, Layouts, Benutzer, Rollen, die TB-Zuordnung und die Optionen `lit_mcp_allowed_roles`/`lit_mcp_exposure` wieder auf. `[Fixture]`/`[Fixture 3b]`-Inhalte werden nach ihrer Erzeugung nur gelesen. - 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. WordPress/Divi nur in `Divi/Pages/`, `Divi/Library/`, `Divi/ThemeBuilder/`, `Divi/Design/`, `Divi/Schema/` und `DiviCompatibility`. - **Eine Engstelle nach `post_content` (D7, S39, E17):** Jeder Schreibweg in `post_content` – Seiten, Bibliothekseinträge und TB-Layouts – geht über `Composer`/`AttrWriter`/`ShortformWriter`/`ValueCleaner` (Client-Werte) bzw. `TreeEditor`/`Container::replaceChild()` (Struktur) und wird ausschließlich von `DiviSaver::save()`/`saveNew()` gespeichert (kses-Fenster nur dort). Kein `post_content` über `et_theme_builder_insert_layout`, `wp_insert_post` oder Divis Builder-Routen. Abilities rufen `setAttrs()`/`insertChild()` nie mit Client-Werten auf. - **StoredNodeGuard für übertragene Inhalte (E6, P3, P15–P19):** Gespeicherte Knoten, die den Post wechseln – Seite → Bibliothek (`lit/library-make-global`), Bibliothek → Seite (Instanz-Schnappschuss beim Ersetzen), Seite/Bibliothek ↔ TB-Layout (`lit/divi-node-copy`, `lit-library-insert`) – laufen durch `StoredNodeGuard` der Ziel-Komposition; für Benutzer ohne `unfiltered_html` wird abgelehnt statt bereinigt. - **Rechte je Objekt (Spec §4.5, E3 – nie lockerer als Divi):** Theme Builder lesen und schreiben = `edit_others_posts` + Divi-Rolle `theme_builder` (über `DiviRoles`), dazu `edit_post` auf jede berührte Vorlage bzw. jedes Layout; Anlegen zusätzlich `publish_posts` der Typen `et_template`, `et_header_layout`, `et_body_layout`, `et_footer_layout`. Ohne `theme_builder`/`edit_others_posts` oder mit deaktivierter Gruppe sind TB-IDs für alle Werkzeuge `not_found` wie fehlende IDs (P12-Muster). Bibliotheksrechte bleiben wie 3a (`divi_library`, `save_library`, `add_library`, `edit_global_library`, `edit_post`, `publish_posts`). Bedingungen mit Objekt-IDs verlangen Leserechte auf das Objekt (`read_post`, vorhandener Begriff, `list_users`). - **Sperren, Alles-oder-nichts, Byte-Identität:** Divi-Schreibungen verlangen `tree_rev` (P16), TB-Vorlagen-Schreibungen `template_rev` (aus `lit/tb-list`), Ersetzen mehrerer Seiten `tree_rev` je Seite. Abweichung → `rev_mismatch`, nichts geschrieben. Erst vollständig validieren, dann schreiben; beim Anlegen einer Vorlage werden bei jedem Fehler alle neu angelegten Layouts, die Vorlage und die Zuordnung wieder entfernt; `lit/library-make-global` löscht den neuen Bibliothekseintrag, wenn die Seite nicht gespeichert werden kann. Unberührte Bytes bleiben identisch (Spec §1.3 Nr. 3, Ausnahme S7). Mehrere Seiten beim Ersetzen: alle vorab geprüft, jede Seite mit eigener Revision; ein Fehler mittendrin nennt die schon geänderten Seiten (D10). - **Sichtbare Website nie ohne ausdrückliche Freigabe:** neue Vorlagen `_et_enabled = 0`; nur `lit/tb-activate` schaltet `_et_enabled`; jede andere Änderung an etwas, das Besucher sehen (aktive Vorlage, Layout einer aktiven Vorlage), erst Probelauf, dann `confirm: true`. Die Standardvorlage (`_et_default = 1`) ändert kein Tool. Nie der TB-Entwurf, nie `et_theme_builder_trash_draft_and_unused_posts()`. - **Exposition:** Gruppe „Divi Theme Builder“ (`divi-theme-builder`): `lit/tb-list`, `lit/tb-create-template`, `lit/tb-assign`, `lit/tb-activate`; Gruppe „Divi-Bibliothek“ zusätzlich `lit/library-find-similar`, `lit/library-make-global`. Alle indirekt, weiterhin 22 direkte Tools. „nur lesen“/deaktiviert der TB-Gruppe sperrt auch Schreibzugriffe der Divi-Werkzeuge auf TB-Layouts (`ExposurePolicy::groupWritable('divi-theme-builder')`); deaktiviert blendet TB-Layouts aus (`groupEnabled`). `lit/library-make-global` folgt zusätzlich `lit/divi-insert` (Audit M2). Texte nennen direkte Tools `lit-`, indirekte `lit/` (ToolNamingTest); `topic ""` nur für vorhandene `docs/guides/.md`. - **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. Reine BlockTree-/Compose-Tests erben von `PHPUnit\Framework\TestCase`. Einzelne Tests: `vendor/bin/phpunit --filter ` (die `memory_limit` setzt `phpunit.xml.dist`). Integrationstests: `cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/.sh`. - Commits: `git -c user.name="Jan-Niclas Loosen" -c user.email="janniclas@loosen-it.de" commit -m "" -m "Co-Authored-By: Claude Opus 5.5 (1M context) " -m "Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7"` (im Folgenden „Commit wie in den Global Constraints“). ## Review Focus 1. **Die Website ändert sich ohne ausdrückliche Freigabe:** ein Layout, das eine aktive Vorlage zeigt (auch die globale Kopfzeile der Standardvorlage), wird mit `lit-divi-update`/`lit-divi-delete` geändert, oder die Bedingungen einer aktiven Vorlage werden ersetzt, oder eine neu angelegte Vorlage wäre aktiv. Erwartet: neue Vorlagen `enabled=false`; alles andere zuerst `confirm_required` mit den betroffenen Vorlagen, nichts gespeichert → Tests `ThemeBuilderEditsTest::testLiveLayoutNeedsConfirmAndNamesTheTemplates`, `DiviWriterThemeBuilderTest::testDeleteInALiveLayoutAsksOnceForBoth` (Task 5), `TbCreateTemplateAbilityTest::testNewTemplateIsDisabledAndAttached` (Task 8), `TbAssignAbilityTest::testActiveTemplateNeedsConfirm` (Task 9), `TbActivateAbilityTest::testActivationIsAlwaysADryRunFirst` (Task 10). 2. **Theme-Builder-Editor und Entwurf:** eine ungespeicherte TB-Sitzung existiert, oder ein zuvor geöffneter TB-Editor hat die Vorlagenliste überschrieben. Erwartet: Anlegen/Zuordnen/Aktivieren abgelehnt, solange ein Entwurf existiert; entfernte Vorlagen erscheinen unter `detached` mit Markierungsdatum und lassen sich nur ausdrücklich (`reattach`) und deaktiviert wieder zuordnen → Tests `TbCreateTemplateAbilityTest::testRefusesWhileTheThemeBuilderHasADraft` (Task 8), `TbListAbilityTest::testReportsDetachedAndMarkedTemplates` (Task 7), `TbAssignAbilityTest::testReattachKeepsTheTemplateDisabledAndUnmarksItsLayouts` (Task 9). 3. **Rechte und Bedingungen:** Redakteur mit `edit_others_posts`, aber ohne Divi-Rolle „Theme Builder“; Gruppe „nur lesen“/deaktiviert; Bedingung mit fremder privater Seiten-ID, erfundener ID-Form oder Steuerzeichen. Erwartet: TB-IDs `not_found`, Schreiben `forbidden`, unzulässige Bedingungen `validation_failed`, keine Meta geschrieben → Tests `ThemeBuilderAccessTest` (Task 4), `DiviPageLoaderTest::testThemeBuilderLayoutsNeedTheThemeBuilderRole` (Task 5), `TemplateConditionsTest::testRefusesUnknownUnreadableAndMalformedConditions` (Task 6). 4. **Fehlschlag mitten im Anlegen:** Layout angelegt, dann scheitert `store_template`, Divi verwirft eine Layout-ID, das Anhängen an die Live-Liste scheitert, oder Divi ruft `wp_die()`. Erwartet: alle neuen Posts gelöscht, Zuordnung entfernt, `write_failed`; scheitert das Aufräumen, ehrliches `partial_write` mit den IDs → Tests `TemplateBuildTest::testRollsBackEveryCreatedPostInReverse`, `TemplateBuildTest::testAFailedDeleteIsAPartialWrite`, `TbCreateTemplateAbilityTest::testFailedTemplateStoreDeletesTheLayouts`, `TbCreateTemplateAbilityTest::testDiviDroppingALayoutIsRolledBack` (Task 8), `DiviThemeBuilderTest::testWithoutEditOthersPostsDiviIsNeverCalled`, `::testDiviIsOnlyCalledWithTheRightAndWithoutWpDie` (Task 4). 5. **Globale Module aus Seiten erzeugen und ersetzen:** Redakteur ohne `unfiltered_html` macht einen Knoten mit Code-Modul/eigenem CSS global; Ziele zum Ersetzen sind nicht inhaltsgleich, veraltet (`tree_rev`), nicht bearbeitbar oder ein Bibliothekseintrag; die Seite scheitert nach dem Anlegen des Eintrags. Erwartet: Ablehnung ohne Schreiben bzw. neuer Eintrag wieder gelöscht → Tests `LibraryMakeGlobalAbilityTest::testEditorCannotMakeACodeModuleGlobal`, `::testFailedPageSaveDeletesTheNewEntry` (Task 14), `::testTargetsMustBeIdenticalEditableAndCurrent` (Task 15). --- ## File Structure ``` wp-lit-mcp.php, src/Plugin.php (geändert) Version 0.5.0; Gates und Verdrahtung Theme Builder readme.txt (geändert) Theme Builder, find-similar, make-global, Changelog 0.5.0 docs/guides/ theme-builder.md (neu) Thema „theme-builder“ inkl. Ablauf „custom-post-type-vorlage“ overview.md, divi-library.md, divi-placeholders.md, workflows.md (geändert) docs/superpowers/specs/2026-09-25-wp-lit-mcp-design.md (geändert) §4.3, §4.5, §5.5, §5.6, §5.7, §9, §12 docs/superpowers/notes/2026-09-27-etappe-3-entscheidungen-entwurf.md (geändert) Abschnitt 7 „Umsetzung 3b“ docs/superpowers/notes/2026-09-27-etappe-3b-abnahme.md (neu) manuelle Abnahme src/ Guides/InstructionsBuilder.php (geändert) Theme-Builder-Regel Integrations/WordPress/WordPressIntegration.php (geändert) P13-Gate an den Render-Auszug Integrations/WordPress/Render/PageRenderSummaryAbility.php (geändert) P13 Integrations/Divi/ DiviIntegration.php (geändert) TB-Zugang, themeBuilder(), libraryIdsVisible() DiviCompatibility.php (geändert) TB-Prüfungen BlockTree/Outline.php (geändert) P13: globale IDs ausblendbar BlockTree/Fingerprint.php (neu) Struktur- und Inhalts-Fingerabdruck Compose/PostIdParser.php (neu) P10: eine ID-Regel Compose/ValueSanitizer.php (geändert) linkTargetType() Compose/DynamicContentRules.php, VariableRef.php, Placeholders.php (geändert) P10, nameOf() Compose/StructureRules.php (geändert) TB-Bereiche (appearance.themeBuilderLayouts) Schema/ModuleCatalog.php (geändert) themeBuilderAreas() Pages/ WpValueSanitizer.php (geändert) linkTargetType() DiviPage.php, DiviPageLoader.php (geändert) TB-Layouts, libraryIdsVisible() SaveScope.php (geändert) ThemeBuilderLayout DiviComposition.php (geändert) forPost(…, $area) DiviWriter.php, DiviWriteAbility.php (geändert) TB-Layouts, ähnliche Abschnitte, P13 DiviOutlineAbility.php, DiviNodeGetAbility.php (geändert) P13 DiviContentComposer.php (geändert) nutzt LayoutBuilder LayoutBuilder.php (neu) Abschnitte → leeres Dokument DynamicFields.php (neu) textartige Felder je Inhaltstyp (aus DiviDynamicSourcesAbility) DiviDynamicSourcesAbility.php (geändert) nutzt DynamicFields Library/ LibraryAccess.php (geändert) revealsIds() NewLibraryEntry.php (neu) gemeinsamer Anlegeweg (library-save, make-global) LibrarySaveAbility.php (geändert) nutzt NewLibraryEntry SimilarSections.php (neu) Warnung §5.5 LibraryFindSimilarAbility.php (neu) lit/library-find-similar LibraryMakeGlobalAbility.php (neu) lit/library-make-global LibraryIntegration.php (geändert) zwei neue Tools ThemeBuilder/ DiviThemeBuilder.php (neu) Adapter Template.php (neu) Vorlage als Wert ThemeBuilderAccess.php (neu) Rechte, Gates, Entwurfssperre TemplateConditions.php (neu) Bedingungen prüfen und benennen ThemeBuilderEdits.php (neu) sichtbare Layouts: Probelauf/confirm, Feldwarnungen TemplateBuild.php (neu) Anlegen alles oder nichts ThemeBuilderIntegration.php (neu) Gruppe „Divi Theme Builder“ TbListAbility.php, TbCreateTemplateAbility.php, TbAssignAbility.php, TbActivateAbility.php (neu) tests/ Support/ThemeBuilderFixtures.php (neu) Posts, Vorlagen, Store, Zugang Support/ComposeFixture.php, DiviWriterFixture.php (geändert) Fixtures/divi/metadata-sample.json (neu erzeugt) mit divi/post-content und appearance Fixtures/divi/stage3b/ (neu) Builder-Fixtures mit Pods, README.md Integration/divi-export-stage3-fixtures.php (geändert) Ordner/Präfix als Argument, Pods-Personen Integration/divi-metadata-fixture.php (geändert) post-content, appearance Integration/pods-fixture.php (geändert) Aktion „persons“ Integration/tb-smoke.sh (neu) Ende-zu-Ende 3b Unit/… (neu/geändert je Task) ``` --- ### Task 1: Echte Theme-Builder-Fixtures mit Pods (braucht Pods) Die 3a-Fixtures (`stage3/`) haben einen Body mit Dynamic Content, aber ohne Pods und ohne Inhaltstyp-Bedingung. Für 3b fehlen: eine Vorlage für einen Pods-Inhaltstyp, ein Body-Layout, dessen Pods-Felder **im echten Dynamic-Content-Dialog** gewählt wurden (so sehen wir, welche Form Divi schreibt: `custom_meta_` oder `post_meta_key` mit `meta_key`), die Nutzung der globalen Kopfzeile der Standardvorlage und zwei Befunde zum TB-Editor. Erzeugt wie in 3a: Divis eigene Speicherwege in einem headless Chrome (DevTools-Protokoll, als Administrator angemeldet); kein Plugin-Code schreibt. **Files:** - Modify: `tests/Integration/pods-fixture.php` (Aktion `persons`) - Modify: `tests/Integration/divi-export-stage3-fixtures.php` (Ordner und Titelpräfix als Argumente, Post-Typ `lit_person`) - Create: `tests/Fixtures/divi/stage3b/README.md` und die exportierten `*.json`/`*.html` - Test: `tests/Unit/Integrations/Divi/BlockTree/Stage3bFixturesTest.php` **Interfaces:** - Consumes: Divi 5.2.1 Theme Builder (`wp-admin/admin.php?page=et_theme_builder`), Visual Builder, Pods-Pod `lit_person` (Felder `role` Text, `level` Auswahl, `mentor` Beziehung). - Produces: `tests/Fixtures/divi/stage3b/` mit den Rollen `tb-template--person`, `tb-body--person-body`, `lit_person--erika-muster`, `lit_person--max-beispiel`, `manifest.json`, `theme-builder.json`, `render--person-erika.txt`; README-Befunde 10 (alter TB-Editor überschreibt die Liste?), 11 (wann entsteht der TB-Entwurf?), 12 (Form der Pods-Felder). Admin-Labels im Body: „Person Titel“ (Überschrift, Beitragstitel), „Person Funktion“ (Text, Pods `role`), „Person Stufe“ (Text, Pods `level`), „Person Mentor“ (Text, Pods `mentor`), „Person Beitragsinhalt“ (`divi/post-content`). - [ ] **Step 1: Pods-Freigabe einholen und prüfen** Der Controller fragt den Benutzer: „Für die Theme-Builder-Fixtures muss Pods in DDEV aktiv sein. Darf ich es mit `ddev wp plugin activate pods` aktivieren?“ Erst nach Zustimmung: Run: `cd /home/janniclas/Projekte/WordPress && ddev wp plugin activate pods && ddev wp plugin is-active pods && echo aktiv` Expected: `aktiv` - [ ] **Step 2: Den Fixture-Test schreiben** `tests/Unit/Integrations/Divi/BlockTree/Stage3bFixturesTest.php`: ```php */ public static function files(): array { $files = glob(dirname(__DIR__, 4) . '/Fixtures/divi/stage3b/*.html') ?: []; $cases = []; foreach ($files as $file) { $cases[basename($file)] = ['stage3b/' . basename($file)]; } return $cases; } public function testTheFixturesExist(): void { self::assertArrayHasKey('tb-body--person-body.html', self::files()); self::assertFileExists(dirname(__DIR__, 4) . '/Fixtures/divi/stage3b/tb-template--person.json'); } /** @dataProvider files */ public function testParsesAndSerializesByteIdentically(string $name): void { $content = DiviFixtures::markup($name); self::assertSame($content, Serializer::document((new Parser())->parse($content))); } /** Finding 12: the Pods field "role" as Divi's dialog writes it (custom_meta_role or post_meta_key + meta_key). */ public function testBodyLayoutShowsThePodsFieldRole(): void { $body = DiviFixtures::markup('stage3b/tb-body--person-body.html'); self::assertTrue( str_contains($body, 'custom_meta_role') || str_contains($body, 'meta_key":"role"'), 'the body layout references the Pods field role' ); self::assertStringContainsString('wp:divi/post-content', $body); } public function testTheTemplateIsDisabledAndMeantForPersons(): void { $template = json_decode(DiviFixtures::markup('stage3b/tb-template--person.json'), true); self::assertSame(['0'], $template['meta']['_et_enabled']); self::assertSame(['singular:post_type:lit_person:all'], $template['meta']['_et_use_on']); self::assertSame(['1061'], $template['meta']['_et_header_layout_id'], 'global header of the default template'); } } ``` - [ ] **Step 3: Test läuft rot** Run: `vendor/bin/phpunit --filter Stage3bFixturesTest` Expected: FAIL – `testTheFixturesExist` („Failed asserting that an array has the key 'tb-body--person-body.html'“). - [ ] **Step 4: Pods-Fixture um Personen erweitern** In `tests/Integration/pods-fixture.php` die Kopfzeile der Nutzung ergänzen: ```php * wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php persons ``` und direkt nach dem `delete`-Zweig (vor `if (! $api->load_pod(['name' => 'lit_person'])) {`) einfügen: ```php if ($action === 'persons') { if (! $api->load_pod(['name' => 'lit_person'])) { fwrite(STDERR, "Pod lit_person missing – run the create action first\n"); exit(1); } $erika = (int) pods('lit_person')->add([ 'post_title' => '[Fixture 3b] Erika Muster', 'post_status' => 'publish', 'role' => 'Geschäftsführung', 'level' => 'senior', ]); $max = (int) pods('lit_person')->add([ 'post_title' => '[Fixture 3b] Max Beispiel', 'post_status' => 'publish', 'role' => 'Entwicklung', 'level' => 'junior', 'mentor' => $erika, ]); echo "persons {$erika} {$max}\n"; return; } ``` - [ ] **Step 5: Export für beliebige Ordner und Präfixe** In `tests/Integration/divi-export-stage3-fixtures.php`: 1. Kopfkommentar, Nutzung ergänzen: ```php * wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php stage3b "[Fixture 3b]" * Arguments: target folder below tests/Fixtures/divi (default stage3) and title prefix (default "[Fixture]"). ``` 2. Die beiden ersten Variablen ersetzen: ```php $lit_stage = preg_match('/^[a-z0-9]+$/D', (string) ($args[0] ?? '')) === 1 ? (string) $args[0] : 'stage3'; $lit_prefix = (string) ($args[1] ?? '[Fixture]') . ' '; $lit_dir = dirname(__DIR__) . '/Fixtures/divi/' . $lit_stage; ``` 3. In `$lit_role` die Präfix-Entfernung allgemein machen und Personen benennen: ```php $slug = sanitize_title(preg_replace('/^\[Fixture[^\]]*\]\s*/', '', $post->post_title)); ``` und im Präfix-Array ergänzen: `'lit_person' => 'lit_person',`. 4. `$lit_types` um `'lit_person'` erweitern. 5. Die Fehlermeldung ohne Treffer: ```php WP_CLI::error(sprintf( 'No "%s" posts found – create them first (see tests/Fixtures/divi/%s/README.md).', $lit_prefix, $lit_stage )); ``` Run: `cd /home/janniclas/Projekte/WordPress && ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php && git -C wp-content/plugins/WP-LIT-MCP status --short tests/Fixtures/divi/stage3` Expected: `Success: 12 posts exported …` und **keine** Änderung unter `stage3/` (der Standardaufruf erzeugt byte-gleich dieselben Dateien). Zeigt `git status` doch Änderungen, sie mit `git checkout -- tests/Fixtures/divi/stage3` zurücknehmen, die Abweichung (Datei, Feld) im README von `stage3b/` als Befund festhalten und dem Controller melden – die 3a-Fixtures bleiben unverändert. - [ ] **Step 6: Pods-Daten anlegen** Run: ```bash cd /home/janniclas/Projekte/WordPress ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php create ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/pods-fixture.php persons ddev wp eval 'echo et_builder_enabled_for_post_type("lit_person") ? "divi an" : "divi aus";' ``` Expected: `created`, `persons `; IDs im README notieren. Zeigt die letzte Zeile „divi aus“: in Divi → Theme-Optionen → Builder → „Beitragstyp-Integration“ den Builder für „Personen“ einschalten (UI im headless Chrome) und erneut prüfen. - [ ] **Step 7: Vorlage und Body im echten Theme Builder bauen** Im headless Chrome, angemeldet als Administrator, genau wie 3a Abschnitt 3 (README `stage3/`), mit den Nonces aus `et_theme_builder_bundle.config.nonces`: 1. TB-Editor öffnen (`wp-admin/admin.php?page=et_theme_builder`). Über die Oberfläche „Neue Vorlage hinzufügen“ → Bedingung „Personen → Alle Personen“ (`singular:post_type:lit_person:all`). Kopfzeile: „Globale Kopfzeile verwenden“ (zeigt auf 1061); Inhalt: „Benutzerdefinierten Inhalt erstellen“; Fußzeile: nichts. Vorlage mit dem Schalter **deaktivieren**, Titel „[Fixture 3b] Person“, „Änderungen speichern“. 2. Den neuen Body im Visual Builder öffnen (Link aus `et_theme_builder_api_get_layout_url`, ohne `app_window=1`). Einen Abschnitt mit einer Zeile (1 Spalte) anlegen und **über die echten Einstellungsdialoge** (nicht `setContent`) einfügen: - Überschrift, Admin-Label „Person Titel“, Text über „Dynamischen Inhalt verwenden“ → „Beitragstitel“; - Text „Person Funktion“ → Dynamic Content, in der Liste das Pods-Feld `role` wählen (steht es nicht in der Liste: „Benutzerdefiniertes Feld“ → Schlüssel `role` eintragen und das im README vermerken); - Text „Person Stufe“ → Pods-Feld `level`; - Text „Person Mentor“ → Pods-Feld `mentor`; - Modul „Beitragsinhalt“, Admin-Label „Person Beitragsinhalt“. Titel über „Seiteneinstellungen → Titel“: „[Fixture 3b] Person Body“; speichern („Veröffentlichen“). 3. Befund 11: vor dem Speichern in Schritt 1 und nach einer ersten ungespeicherten Änderung jeweils `ddev wp post list --post_type=et_theme_builder --post_status=auto-draft --format=ids` ausführen und festhalten, ab wann ein TB-Entwurf existiert. - [ ] **Step 8: Darstellung prüfen (Vorlage kurz aktiv)** 1. Im TB-Editor „[Fixture 3b] Person“ aktivieren und speichern. 2. `curl -s -H "Host: wordpress.ddev.site" "http://localhost/?p="` im Web-Container (`ddev exec`), die Texte der vier Module aus dem HTML ziehen und als `tests/Fixtures/divi/stage3b/render--person-erika.txt` speichern (je Zeile „Admin-Label: sichtbarer Text“; für den Mentor erwarten wir eine Beitrags-ID oder nichts – F26). 3. Im TB-Editor die Vorlage wieder **deaktivieren** und speichern. Prüfen: `ddev wp post meta get _et_enabled` → `0`. - [ ] **Step 9: Befund 10 – überschreibt ein alter TB-Editor die Liste?** 1. Tab A: TB-Editor öffnen und geladen lassen. 2. Im Terminal eine zusätzliche, deaktivierte Vorlage „[Fixture 3b] Stale-Test“ genau wie `DiviThemeBuilder::attach()` anhängen: ```bash ddev wp eval ' $tb = et_theme_builder_get_theme_builder_post_id(true, false); $id = wp_insert_post(["post_type" => "et_template", "post_status" => "publish", "post_title" => "[Fixture 3b] Stale-Test"]); update_post_meta($id, "_et_enabled", "0"); $existing = get_post_meta($tb, "_et_template", false); update_option("et_tb_templates_backup_" . $tb, $existing); add_post_meta($tb, "_et_template", $id); delete_option("et_tb_templates_backup_" . $tb); echo $id;' ``` 3. In Tab A eine harmlose Änderung machen (Titel einer Fixture-Vorlage ändern und zurück) und speichern. 4. Festhalten: `ddev wp post meta get _et_template --format=json` (enthält Stale-Test noch?) und `ddev wp post meta get _et_theme_builder_marked_as_unused`. Danach `ddev wp post delete --force` und prüfen, dass `_et_template` des TB-Posts die ID nicht mehr enthält (sonst mit `delete_post_meta` entfernen). - [ ] **Step 10: Exportieren und README schreiben** Run: `cd /home/janniclas/Projekte/WordPress && ddev wp eval-file wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-export-stage3-fixtures.php stage3b "[Fixture 3b]"` Expected: `Success: 4 posts exported to …/stage3b` (Vorlage, Body, zwei Personen; die Standardvorlage 1065 liegt bereits in `stage3/`). `tests/Fixtures/divi/stage3b/README.md` im Aufbau von `stage3/README.md`: Dateien und Rollen (Tabelle mit IDs), Entstehung (Schritte 6–9 mit den Divi-Dateistellen wie dort), „Was echt ist und was nachgebildet wurde“ (nachgebildet ist nur die Stale-Test-Zuordnung aus Schritt 9), **Befund 10** (Liste nach dem Speichern eines alten Editors: bleibt/entfernt, Markierung ja/nein), **Befund 11** (TB-Entwurf entsteht bei … / gar nicht), **Befund 12** (Form des Pods-Felds `role` im Body: Name und `settings`, Ausgabe von `level` – Wert oder Beschriftung – und `mentor` laut `render--person-erika.txt`), Wirkung auf die Live-Site (Vorlage deaktiviert, Stale-Test gelöscht), Neu exportieren, Aufräumen (Befehle mit den IDs). - [ ] **Step 11: Test läuft grün** Run: `vendor/bin/phpunit --filter Stage3bFixturesTest` Expected: PASS (5 Tests + je `.html` ein Roundtrip). - [ ] **Step 12: Commit** ```bash git add tests/Integration/pods-fixture.php tests/Integration/divi-export-stage3-fixtures.php tests/Fixtures/divi/stage3b tests/Unit/Integrations/Divi/BlockTree/Stage3bFixturesTest.php ``` Commit wie in den Global Constraints mit `test: Theme Builder fixtures with Pods fields from the real builder (stage3b)`. --- ### Task 2: P10 – gleiche Zielregeln für rohe `post_link_url_*` Ruling P10: `{{link:}}` prüft das Ziel über `LinkTargets` (existiert, nicht Papierkorb/`auto-draft`/`inherit`, `read_post`, Typ); die Vollform `post_link_url_` mit `settings.post_id` prüfte bisher nur `read_post`. Künftig gilt für beide dieselbe Regel, und die ID wird an einer Stelle geparst. **Files:** - Create: `src/Integrations/Divi/Compose/PostIdParser.php` - Modify: `src/Integrations/Divi/Compose/ValueSanitizer.php`, `src/Integrations/Divi/Compose/DynamicContentRules.php` (`cleanSetting`, `selectPostSetting`), `src/Integrations/Divi/Compose/VariableRef.php` (Aufruf in `contentSettings`), `src/Integrations/Divi/Compose/Placeholders.php` (`link`), `src/Integrations/Divi/Pages/WpValueSanitizer.php` - Modify: `tests/Support/ComposeFixture.php`, `tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php` (innerer Sanitizer) - Test: `tests/Unit/Integrations/Divi/Compose/PostIdParserTest.php`, `tests/Unit/Integrations/Divi/Compose/VariableRefTest.php`, `tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php` **Interfaces:** - Consumes: `WpLinkTargets::postTypeOf(int): ?string` (3a). - Produces: `PostIdParser::parse(mixed $value): ?int`; `ValueSanitizer::linkTargetType(int $id): ?string`; `DynamicContentRules::cleanSetting(string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer, string $option = ''): mixed`. - [ ] **Step 1: Failing tests** `tests/Unit/Integrations/Divi/Compose/PostIdParserTest.php`: ```php ['post_id' => 'select_post'], 'post_link_url_post' => ['post_id' => 'select_post'], ]); $sanitizer = ComposeFixture::sanitizer(); self::assertSame( self::content('post_link_url_post', '{"post_id":"840"}'), $ref->canonical(self::content('post_link_url_post', '{"post_id":"840"}'), $sanitizer, true) ); $refused = [ 'a post is no page' => self::content('post_link_url_page', '{"post_id":"840"}'), 'no link target' => self::content('post_link_url_page', '{"post_id":"666"}'), 'overflowing id' => self::content('post_link_url_page', '{"post_id":"99999999999999999999"}'), 'padded id' => self::content('post_link_url_page', '{"post_id":"0840"}'), ]; foreach ($refused as $case => $value) { try { $ref->canonical($value, $sanitizer, true); self::fail($case . ' was accepted'); } catch (InvalidArgumentException $e) { self::assertStringContainsString('Beitrags-ID', $e->getMessage(), $case); } } } ``` (`use InvalidArgumentException;` steht bereits in der Datei; sonst ergänzen.) In `tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php`: ```php /** Ruling P10: the full form uses the {{link:}} targets – attachments, trash and unreadable entries are none. */ public function testLinkTargetTypeFollowsTheLinkTargets(): void { Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => match ($id) { 12 => new WP_Post(['ID' => 12, 'post_type' => 'page', 'post_status' => 'draft']), 13 => new WP_Post(['ID' => 13, 'post_type' => 'attachment', 'post_status' => 'inherit']), 14 => new WP_Post(['ID' => 14, 'post_type' => 'page', 'post_status' => 'trash']), 16 => new WP_Post(['ID' => 16, 'post_type' => 'page', 'post_status' => 'private']), default => null, }); Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 16); $sanitizer = new WpValueSanitizer(); self::assertSame('page', $sanitizer->linkTargetType(12)); self::assertNull($sanitizer->linkTargetType(13)); self::assertNull($sanitizer->linkTargetType(14)); self::assertNull($sanitizer->linkTargetType(15)); self::assertNull($sanitizer->linkTargetType(16)); } ``` (`use WP_Post;` und `use Brain\Monkey\Functions;` ergänzen, falls nicht vorhanden.) - [ ] **Step 2: Tests laufen rot** Run: `vendor/bin/phpunit --filter 'PostIdParserTest|VariableRefTest|WpValueSanitizerTest'` Expected: FAIL – „Class "LIT\MCP\Integrations\Divi\Compose\PostIdParser" not found“ und „Call to undefined method … linkTargetType()“. - [ ] **Step 3: PostIdParser** `src/Integrations/Divi/Compose/PostIdParser.php`: ```php }}, * post_link_url_* settings.post_id and Theme Builder conditions. A positive int, or its plain * decimal digits without sign, padding or spaces; nothing beyond PHP_INT_MAX (FILTER_VALIDATE_INT * would otherwise saturate to another entry). Pure PHP. */ final class PostIdParser { public static function parse(mixed $value): ?int { if (is_int($value)) { return $value > 0 ? $value : null; } if (! is_string($value) || preg_match('/^[1-9][0-9]{0,18}$/D', $value) !== 1) { return null; } $id = filter_var($value, FILTER_VALIDATE_INT); return is_int($id) ? $id : null; } } ``` - [ ] **Step 4: Sanitizer-Schnittstelle und WordPress-Umsetzung** In `src/Integrations/Divi/Compose/ValueSanitizer.php` nach `canReadPost()`: ```php /** * Ruling P10: the post type of entry $id when it is a link target – exists, lives (no trash, * auto-draft or revision/attachment status), readable – like {{link:}} (LinkTargets); else null. */ public function linkTargetType(int $id): ?string; ``` In `src/Integrations/Divi/Pages/WpValueSanitizer.php` nach `canReadPost()`: ```php public function linkTargetType(int $id): ?string { return (new WpLinkTargets())->postTypeOf($id); } ``` In `tests/Support/ComposeFixture.php` im anonymen Sanitizer nach `canReadPost()`: ```php /** Ruling P10: 840 is a post, 666 no link target, every other ID a page. */ public function linkTargetType(int $id): ?string { return match ($id) { 666 => null, 840 => 'post', default => 'page', }; } ``` In `tests/Unit/Integrations/Divi/Compose/StoredNodeGuardTest.php` im inneren Sanitizer nach `canReadPost()`: ```php public function linkTargetType(int $id): ?string { return $this->inner->linkTargetType($id); } ``` - [ ] **Step 5: select_post nach Link-Regel prüfen** In `src/Integrations/Divi/Compose/DynamicContentRules.php`: 1. Konstante ergänzen (bei den anderen Konstanten): ```php /** Divi's internal link options: post_link_url_ (F27). */ public const LINK_PREFIX = 'post_link_url_'; ``` 2. Signatur und Aufruf in `cleanSetting()`: ```php public static function cleanSetting( string $key, mixed $value, string $fieldType, ValueSanitizer $sanitizer, string $option = '' ): mixed { ``` und im `match`: ```php 'select_post' => self::selectPostSetting($key, $value, $sanitizer, $option), ``` 3. `selectPostSetting()` vollständig ersetzen: ```php /** * Ruling S37/P10: a numeric post id (PostIdParser). In an internal link post_link_url_ the * id must name a link target of exactly that type (LinkTargets rules, like {{link:}}); in any * other option a post the user may read. Returned as sent (int or digit string). * * @throws InvalidArgumentException */ private static function selectPostSetting( string $key, mixed $value, ValueSanitizer $sanitizer, string $option ): mixed { $id = PostIdParser::parse($value); $linkType = str_starts_with($option, self::LINK_PREFIX) ? substr($option, strlen(self::LINK_PREFIX)) : null; $valid = $id !== null && ($linkType === null ? $sanitizer->canReadPost($id) : $sanitizer->linkTargetType($id) === $linkType); if (! $valid) { throw new InvalidArgumentException(sprintf( 'Einstellung "%s" im Divi-Verweis: Beitrags-ID eines lesbaren Eintrags erwartet%s.', $key, $linkType === null ? '' : sprintf(' (Typ „%s“, nicht im Papierkorb, kein Anhang)', $linkType) )); } return $value; } ``` In `src/Integrations/Divi/Compose/VariableRef.php` in `contentSettings()` die Zeile mit `cleanSetting` ersetzen: ```php $clean[$key] = DynamicContentRules::cleanSetting($key, $value, $fields[$key], $sanitizer, $name); ``` In `src/Integrations/Divi/Compose/Placeholders.php` in `link()` die zwei Zeilen zur ID ersetzen: ```php $id = PostIdParser::parse($name); if ($id === null) { ``` (der folgende `throw` bleibt; `is_int($id)` entfällt). - [ ] **Step 6: Tests laufen grün, Gesamtlauf** Run: `vendor/bin/phpunit --filter 'PostIdParserTest|VariableRefTest|WpValueSanitizerTest|PlaceholdersTest|StoredNodeGuardTest|ShortformWriterTest' && composer test && composer lint` Expected: PASS; Gesamtlauf grün (1901 + neue Tests), Lint ohne Befund. - [ ] **Step 7: Commit** ```bash git add src/Integrations/Divi/Compose/PostIdParser.php src/Integrations/Divi/Compose/ValueSanitizer.php src/Integrations/Divi/Compose/DynamicContentRules.php src/Integrations/Divi/Compose/VariableRef.php src/Integrations/Divi/Compose/Placeholders.php src/Integrations/Divi/Pages/WpValueSanitizer.php tests/Support/ComposeFixture.php tests/Unit/Integrations/Divi/Compose tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php ``` Commit wie in den Global Constraints mit `fix: full-form internal links follow the {{link:}} target rules (ruling P10)`. --- ### Task 3: P13 – Bibliotheks-IDs vollständig ausblenden Ruling P13: Ohne Bibliothekszugang (Gruppe „Divi-Bibliothek“ deaktiviert oder kein Divi-Recht `divi_library`) nennen `lit-divi-outline`, `lit-divi-node-get`, die Outline jeder Divi-Schreibantwort und `lit-page-render-summary` keine Bibliotheks-IDs mehr. Globale Instanzen bleiben als „nur lesbar, globales Modul“ erkennbar. **Files:** - Modify: `src/Integrations/Divi/Library/LibraryAccess.php` (`revealsIds()`) - Modify: `src/Integrations/Divi/Pages/DiviPageLoader.php` (`libraryIdsVisible()`) - Modify: `src/Integrations/Divi/BlockTree/Outline.php` (Konstruktor `$globalIds`) - Modify: `src/Integrations/Divi/Pages/DiviOutlineAbility.php`, `DiviNodeGetAbility.php`, `DiviWriter.php` (Outline der Antwort) - Modify: `src/Integrations/Divi/DiviIntegration.php` (`libraryIdsVisible()`), `src/Integrations/WordPress/WordPressIntegration.php`, `src/Integrations/WordPress/Render/PageRenderSummaryAbility.php`, `src/Plugin.php` - Test: `tests/Unit/Integrations/Divi/Library/LibraryAccessTest.php`, `tests/Unit/Integrations/Divi/BlockTree/OutlineTest.php`, `tests/Unit/Integrations/Divi/Pages/DiviReadAbilitiesTest.php`, `tests/Unit/Integrations/WordPress/Render/PageRenderSummaryAbilityTest.php` **Interfaces:** - Consumes: `LibraryAccess::canList()`, das `$visible`-Gate (P9). - Produces: `LibraryAccess::revealsIds(): bool`; `DiviPageLoader::libraryIdsVisible(): bool`; `Outline::__construct(array $presetNames = [], bool $globalIds = true)`; `DiviIntegration::libraryIdsVisible(): bool`; `PageRenderSummaryAbility::__construct(ContentSchema $schema, PageRenderer $renderer, ?Closure $globalIdsVisible = null)`; `WordPressIntegration::__construct(ContentSchema, ChangeLog, ?ContentComposer $divi = null, ?Closure $globalIdsVisible = null)`. - [ ] **Step 1: Failing tests** `LibraryAccessTest` ergänzen: ```php /** Ruling P13: library IDs are named only while the library is visible and the user has its right. */ public function testRevealsIdsOnlyWithTheVisibleLibraryAndItsRight(): void { LibraryFixtures::given([]); self::assertTrue(LibraryFixtures::access()->revealsIds()); self::assertFalse(LibraryFixtures::access(['divi_library'])->revealsIds()); $hidden = new LibraryAccess(LibraryFixtures::store(), LibraryFixtures::roles(), null, static fn (): bool => false); self::assertFalse($hidden->revealsIds()); LibraryFixtures::given([], ['edit_posts']); self::assertFalse(LibraryFixtures::access()->revealsIds()); } ``` `OutlineTest` ergänzen: ```php /** Ruling P13: without library access the outline says "global", but not which entry. */ public function testGlobalIdsCanBeHidden(): void { $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); $outline = new Outline([], false); $rows = $outline->rows(NodeIndex::build((new Parser())->parse($content))); $byId = array_column($rows, null, 'id'); self::assertNull($byId['g0']['global_id']); self::assertSame('section', $byId['g0']['type']); self::assertStringNotContainsString('1058', $outline->text($rows)); self::assertStringContainsString('g0 section "Global Abschnitt" (nur lesbar)', $outline->text($rows)); } ``` `DiviReadAbilitiesTest`: den bestehenden Test `testNodeGetNamesTheLibraryEntryOfAGlobalModule` auf den Bibliothekszugang umstellen und einen ausgeblendeten Fall ergänzen: ```php public function testNodeGetNamesTheLibraryEntryOfAGlobalModule(): void { DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); $ability = new DiviNodeGetAbility(LibraryFixtures::loader(), $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::assertArrayHasKey('globalModule', $global['attrs']); self::assertFalse($global['editable']); self::assertNull($plain['global']); } /** Ruling P13: without the library right node-get and outline name no library ID. */ public function testWithoutLibraryAccessNoLibraryIdIsNamed(): void { DiviFixtures::givenPost(DiviFixtures::markup('stage3/page--globale-instanzen.html')); $loader = LibraryFixtures::loader(LibraryFixtures::access(['divi_library'])); $global = (new DiviNodeGetAbility($loader, $this->design()))->execute(['post_id' => 7, 'id' => 'g0']); $outline = (new DiviOutlineAbility($loader, $this->design()))->execute(['post_id' => 7]); self::assertNull($global['global']['id']); self::assertStringContainsString('Divi-Bibliothek', $global['global']['hint']); self::assertArrayNotHasKey('globalModule', $global['attrs']); self::assertStringNotContainsString('1058', (string) json_encode($global)); self::assertStringNotContainsString('1058', (string) json_encode($outline)); self::assertStringNotContainsString('1059', (string) json_encode($outline)); } ``` `PageRenderSummaryAbilityTest` ergänzen: ```php /** Ruling P13: without library access the note counts global modules instead of naming them. */ public function testWithoutLibraryAccessGlobalModulesAreOnlyCounted(): void { $content = DiviFixtures::markup('stage3/page--globale-instanzen.html'); $post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_status' => 'draft', 'post_content' => $content]); Functions\when('get_post')->justReturn($post); Functions\when('current_user_can')->justReturn(true); Functions\when('get_permalink')->justReturn('https://kunde.test/?page_id=7'); $renderer = Mockery::mock(PageRenderer::class); $renderer->allows('render')->andReturn('

Text

'); $ability = new PageRenderSummaryAbility(SchemaFixture::managed(), $renderer, static fn (): bool => false); $notes = $ability->execute(['post_id' => 7])['notes']; $joined = implode("\n", $notes); self::assertStringContainsString('2 globale Module sind im Auszug nicht enthalten', $joined); self::assertStringNotContainsString('1058', $joined); self::assertStringNotContainsString('1059', $joined); } ``` - [ ] **Step 2: Tests laufen rot** Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|OutlineTest|DiviReadAbilitiesTest|PageRenderSummaryAbilityTest'` Expected: FAIL – „Call to undefined method … revealsIds()“, `global_id` ist `'1058'` statt `null`, `PageRenderSummaryAbility` nennt die IDs. - [ ] **Step 3: Zugang und Loader** `LibraryAccess` nach `canList()`: ```php /** * Ruling P13: whether library IDs may be named to this connection at all – the library group * is not disabled (P9) and the user has the library right (canList). Otherwise outlines, * node-get and the render summary say "global module" without the entry's ID. */ public function revealsIds(): bool { return ($this->visible === null || ($this->visible)()) && $this->canList() === null; } ``` `DiviPageLoader` nach `library()`: ```php /** Ruling P13: whether the Divi tools may name library IDs (no library access = never). */ public function libraryIdsVisible(): bool { return $this->library?->revealsIds() ?? false; } ``` - [ ] **Step 4: Outline, Outline-Tool, Node-Get, Schreibantwort** `Outline`: Konstruktor und `row()`: ```php /** * @param array $presetNames Preset ID => name. * @param bool $globalIds Ruling P13: false leaves out the library IDs of global instances. */ public function __construct(private array $presetNames = [], private bool $globalIds = true) { } ``` und in `row()` den letzten Eintrag: ```php 'global_id' => $this->globalIds ? ($global['id'] ?? null) : null, ``` `DiviOutlineAbility::execute()`: `new Outline($this->design->read()->presetNames())` ersetzen durch ```php $outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible()); ``` `DiviWriter::outline()`: ebenso ```php $outline = new Outline($this->design->read()->presetNames(), $this->loader->libraryIdsVisible()); ``` `DiviNodeGetAbility::execute()`: den Block ab `$global = NodeSummary::globalRef($block);` bis vor `return [` ersetzen: ```php $attrs = AttrMap::flatten($block->attrs()); $global = NodeSummary::globalRef($block); if ($global !== null && $this->loader->libraryIdsVisible()) { $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'] ); } elseif ($global !== null) { // Ruling P13: which entry is shown stays unknown without library access. $global = [ 'id' => null, 'block_name' => $global['block_name'], 'hint' => 'Globales Modul aus der Divi-Bibliothek – nur lesbar; dieses Konto hat keinen Zugang zur ' . 'Divi-Bibliothek.', ]; unset($attrs['globalModule'], $attrs['globalParent']); } ``` und im Rückgabe-Array `'attrs' => JsonMap::of($attrs),`. - [ ] **Step 5: Render-Auszug und Verdrahtung** `PageRenderSummaryAbility`: ```php /** * @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether global module IDs may be named * (library access of this connection); null = always. */ public function __construct( private ContentSchema $schema, private PageRenderer $renderer, private ?Closure $globalIdsVisible = null ) { } ``` In `execute()` die `foreach`-Schleife über `globalModuleIds()` ersetzen: ```php $globalIds = self::globalModuleIds((string) $post->post_content); if ($globalIds !== [] && $this->globalIdsVisible !== null && ! ($this->globalIdsVisible)()) { $notes[] = sprintf( '%d %s im Auszug nicht enthalten (beim Aufruf über die Schnittstelle rendert Divi globale Module ' . 'nicht; die Divi-Bibliothek ist für dieses Konto nicht zugänglich).', count($globalIds), count($globalIds) === 1 ? 'globales Modul ist' : 'globale Module sind' ); } else { foreach (array_slice($globalIds, 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 ); } } ``` (`use Closure;` ergänzen.) `WordPressIntegration`: Konstruktor um `private ?Closure $globalIdsVisible = null` erweitern (`use Closure;`) und in `abilities()`: ```php new PageRenderSummaryAbility($this->schema, new PageRenderer(), $this->globalIdsVisible), ``` `DiviIntegration` ergänzen: ```php /** Ruling P13: whether this connection may see library IDs (library group visible, library right). */ public function libraryIdsVisible(): bool { return $this->access->revealsIds(); } ``` `Plugin`: `new WordPressIntegration($schema, $changeLog, $divi->contentComposer($diviWritable)),` ersetzen durch ```php new WordPressIntegration( $schema, $changeLog, $divi->contentComposer($diviWritable), $divi->libraryIdsVisible(...) ), ``` - [ ] **Step 6: Weitere Stellen mit Bibliotheks-IDs prüfen** Run: `grep -rn "globalModule\|global_id\|\[global " src --include=*.php | grep -v "BlockTree/\|Compose/\|Library/"` Expected: nur die in diesem Task geänderten Stellen und `DiviTextSearch::GLOBAL_REASON` (ohne ID). Nennt eine weitere Stelle eine Bibliotheks-ID gegenüber Benutzern ohne Bibliothekszugang, dort `libraryIdsVisible()` ebenso anwenden. - [ ] **Step 7: Tests grün, Gesamtlauf** Run: `vendor/bin/phpunit --filter 'LibraryAccessTest|OutlineTest|DiviReadAbilitiesTest|PageRenderSummaryAbilityTest|DiviWriterTest' && composer test && composer lint` Expected: PASS. Schlägt ein bestehender Test fehl, weil er mit `new DiviPageLoader(DiviFixtures::schema())` (ohne Bibliothekszugang) eine `global_id` oder `[global …]` erwartet, dort `LibraryFixtures::loader()` verwenden – ohne Bibliothekszugang ist das Ausblenden jetzt gewollt. - [ ] **Step 8: Commit** ```bash git add src/Integrations/Divi/Library/LibraryAccess.php src/Integrations/Divi/Pages src/Integrations/Divi/BlockTree/Outline.php src/Integrations/Divi/DiviIntegration.php src/Integrations/WordPress src/Plugin.php tests/Unit ``` Commit wie in den Global Constraints mit `fix: library IDs stay hidden without library access (ruling P13)`. --- ### Task 4: Theme-Builder-Adapter, Vorlage als Wert, Rechte je Objekt **Files:** - Create: `src/Integrations/Divi/ThemeBuilder/DiviThemeBuilder.php`, `Template.php`, `ThemeBuilderAccess.php` - Modify: `src/Integrations/Divi/DiviCompatibility.php` - Create: `tests/Support/ThemeBuilderFixtures.php` - Test: `tests/Unit/Integrations/Divi/ThemeBuilder/DiviThemeBuilderTest.php`, `TemplateTest.php`, `ThemeBuilderAccessTest.php`; `tests/Unit/Integrations/Divi/DiviCompatibilityTest.php` **Interfaces:** - Consumes: `DiviRoles::allows()/refusal()`, `PostGuard::id()/notFound()`, `Errors`, `Rev::of()`, `LibraryFixtures::roles()/given()`. - Produces: - `DiviThemeBuilder` (nicht final): Konstanten `TEMPLATE = 'et_template'`, `LAYOUT_TYPES = ['header' => 'et_header_layout', 'body' => 'et_body_layout', 'footer' => 'et_footer_layout']`, `TEMPLATE_LIST`, `USE_ON`, `EXCLUDE_FROM`, `ENABLED`, `UNUSED`, `BACKUP_OPTION`, `FUNCTIONS`; `static areaOf(string $postType): ?string`; `missing(): list`; `liveId(bool $create = false): int`; `draftId(): int`; `templateIds(int $themeBuilderId): list`; `template(int $id, bool $attached): ?Template`; `liveTemplates(): list