From 0edb474d78a1c8bb2e33e3aa222305de32d78fcd Mon Sep 17 00:00:00 2001 From: Jan-Niclas Loosen Date: Sun, 27 Sep 2026 12:16:15 +0200 Subject: [PATCH] docs: Etappe 3b implementation plan (Theme Builder) Co-Authored-By: Claude Opus 5.5 (1M context) --- .../2026-09-27-etappe-3b-theme-builder.md | 8827 +++++++++++++++++ 1 file changed, 8827 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-27-etappe-3b-theme-builder.md diff --git a/docs/superpowers/plans/2026-09-27-etappe-3b-theme-builder.md b/docs/superpowers/plans/2026-09-27-etappe-3b-theme-builder.md new file mode 100644 index 0000000..52b3fc1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-27-etappe-3b-theme-builder.md @@ -0,0 +1,8827 @@ +# 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