docs: Divi guides shortform/placeholders, hints name the write tools; test: Divi write end-to-end; version 0.3.0
- Hints (PostPresenter::DIVI_NOTE, outline, catalog) and server instructions (tree_rev rule) name the Divi write tools; guides divi-shortform and divi-placeholders, additions to divi-structure, divi-attrs, overview (tool groups, 'nur lesen'), workflows, content-types. - D7 wording and the S7 exception in readme, guides and spec 1.3/5.2; readme 0.3.0. - ToolNamingTest: every referenced guide topic exists; overview lists exactly the topics. - tests/Integration/divi-write-smoke.sh: all write tools via OAuth, editor without unfiltered_html with benign probes (S6), Divi's own save filters on the new fixture synthetic-divi-filters.html (S7); admin-render.php accepts the new wrap class. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01At3V6SjwbMe6eMzVGW3Hs7
This commit is contained in:
@@ -23,14 +23,16 @@ Werkzeugnamen: `lit-…` sind direkt aufrufbare Tools; `lit/…` sind Abilities,
|
|||||||
Trefferliste. Geliefert werden nur Einträge, die der verbundene Nutzer selbst bearbeiten darf (fremde
|
Trefferliste. Geliefert werden nur Einträge, die der verbundene Nutzer selbst bearbeiten darf (fremde
|
||||||
Entwürfe/private Inhalte ohne die passende Berechtigung erscheinen nicht).
|
Entwürfe/private Inhalte ohne die passende Berechtigung erscheinen nicht).
|
||||||
- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`.
|
- `lit-content-get`: alle Daten eines Eintrags inkl. Feldern, Begriffen, Beitragsbild und `rev`.
|
||||||
Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline` (siehe `divi-structure`), geändert
|
Bei Divi-Seiten fehlt `content`; den Aufbau liest `lit-divi-outline`, geändert wird er mit den
|
||||||
wird er vorerst im Divi-Builder (Titel, Auszug, Felder und Begriffe gehen weiter über
|
Divi-Werkzeugen (siehe `divi-structure`); Titel, Auszug, Felder und Begriffe gehen weiter über
|
||||||
`lit-content-update`).
|
`lit-content-update`.
|
||||||
|
|
||||||
## Anlegen und Ändern
|
## Anlegen und Ändern
|
||||||
|
|
||||||
- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler
|
- `lit-content-create` legt immer einen **Entwurf** an. Felder werden gegen das Schema geprüft; ein Fehler
|
||||||
bricht den ganzen Aufruf ab (`fields.<schlüssel>` im Fehler nennt die Ursache).
|
bricht den ganzen Aufruf ab (`fields.<schlüssel>` im Fehler nennt die Ursache). Divi-Seiten gleich mit
|
||||||
|
Inhalt: `divi` = Liste von Abschnitten in Kurzform (siehe `divi-shortform`) statt `content`; die Antwort
|
||||||
|
enthält dann die `tree_rev` für weitere Divi-Änderungen.
|
||||||
- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist
|
- `lit-content-update` ändert nur die übergebenen Werte, der Status bleibt. `rev` aus `lit-content-get` ist
|
||||||
**Pflicht** – hat jemand parallel gespeichert (Inhalt, Titel, Auszug, Status oder irgendetwas anderes am
|
**Pflicht** – hat jemand parallel gespeichert (Inhalt, Titel, Auszug, Status oder irgendetwas anderes am
|
||||||
Eintrag), kommt `rev_mismatch`: neu lesen, Änderung auf dem aktuellen Stand wiederholen.
|
Eintrag), kommt `rev_mismatch`: neu lesen, Änderung auf dem aktuellen Stand wiederholen.
|
||||||
@@ -59,8 +61,8 @@ Werkzeugnamen: `lit-…` sind direkt aufrufbare Tools; `lit/…` sind Abilities,
|
|||||||
## Sicher überarbeiten
|
## Sicher überarbeiten
|
||||||
|
|
||||||
1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`.
|
1. `lit/post-duplicate` → Entwurfskopie; die Antwort enthält die `rev` der Kopie für `lit-content-update`.
|
||||||
2. Kopie mit `lit-content-update` (Divi-Inhalte vorerst im Divi-Builder) bearbeiten und dem Nutzer die
|
2. Kopie mit `lit-content-update` bzw. den Divi-Werkzeugen bearbeiten und dem Nutzer die Vorschau zeigen;
|
||||||
Vorschau zeigen; `lit-page-render-summary` fasst den gerenderten Inhalt zusammen.
|
`lit-page-render-summary` fasst den gerenderten Inhalt zusammen.
|
||||||
3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben).
|
3. `lit/post-replace-with-copy` übernimmt die Kopie ins Original (ID, URL, Status und Menüeinträge bleiben).
|
||||||
Voraussetzungen: die Kopie muss noch Entwurf oder ausstehend sein, `rev` des Originals aus
|
Voraussetzungen: die Kopie muss noch Entwurf oder ausstehend sein, `rev` des Originals aus
|
||||||
`lit-content-get` ist Pflicht (sie deckt Titel, Inhalt, Auszug und Status ab – Felder/Meta, die direkt am
|
`lit-content-get` ist Pflicht (sie deckt Titel, Inhalt, Auszug und Status ab – Felder/Meta, die direkt am
|
||||||
|
|||||||
@@ -18,6 +18,31 @@ Der Wert eines Pfads ist eine Zuordnung **Breakpoint → Zustand → Wert**:
|
|||||||
Breakpoints: `desktop`, `tablet`, `phone` (je nach Website auch `tabletWide`, `phoneWide`, `widescreen`,
|
Breakpoints: `desktop`, `tablet`, `phone` (je nach Website auch `tabletWide`, `phoneWide`, `widescreen`,
|
||||||
`ultraWide`); Zustände: `value`, `hover`, `sticky`.
|
`ultraWide`); Zustände: `value`, `hover`, `sticky`.
|
||||||
|
|
||||||
|
## Setzen
|
||||||
|
|
||||||
|
In `attrs` (bei `lit-divi-insert` je Knoten, bei `lit-divi-update` je Änderung) steht Pfad → Wert je Breakpoint.
|
||||||
|
Kurz: `{"desktop": "#1a1a1a", "phone": "…"}` bzw. bei Objektwerten `{"desktop": {"size": "28px"}}`; Hover und
|
||||||
|
Sticky: `{"hover": {…}}` bzw. `{"desktop": {"value": {…}, "hover": {…}}}`. Nur die genannten Breakpoints
|
||||||
|
werden ersetzt, der Rest bleibt; `null` entfernt einen Pfad, Breakpoint oder Zustand:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "id": "s0.r0.c1.m0",
|
||||||
|
"attrs": { "title.decoration.font.font": { "desktop": { "color": "{{color:heading}}" },
|
||||||
|
"phone": { "size": "28px" } },
|
||||||
|
"module.decoration.spacing": null } }
|
||||||
|
```
|
||||||
|
|
||||||
|
Ein Objektwert ersetzt den bisherigen Wert dieses Breakpoints ganz (Unterschlüssel werden nicht
|
||||||
|
zusammengeführt): z. B. bei `module.decoration.sizing` einer Spalte `flexType` immer mitschicken (siehe
|
||||||
|
`divi-shortform`).
|
||||||
|
|
||||||
|
Unbekannte Pfade → Fehler `invalid_attr_path` mit Vorschlägen unter `fields`. Texte werden bereinigt
|
||||||
|
(HTML nur, wo Divi Rich-Text erwartet; Links ohne `javascript:`). `$variable`-Verweise werden geparst,
|
||||||
|
geprüft und vom Plugin neu erzeugt (siehe `divi-placeholders`). Eigenes HTML, CSS und HTML-Attribute
|
||||||
|
(`…decoration.attributes`, `…advanced.html`, `…css…`) nur mit dem Recht `unfiltered_html`, sonst „nur im
|
||||||
|
Divi-Builder“. Unberührte Teile der Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten
|
||||||
|
mit eigenen HTML-/Attribut-Feldern beim Speichern (Warnung in der Antwort, neue `tree_rev` gilt).
|
||||||
|
|
||||||
## Pfade finden
|
## Pfade finden
|
||||||
|
|
||||||
- `lit-divi-catalog` ohne `module` listet alle Module dieser Website; mit `module` (z. B. `"heading"`) alle
|
- `lit-divi-catalog` ohne `module` listet alle Module dieser Website; mit `module` (z. B. `"heading"`) alle
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
# Platzhalter für Divi-Inhalte
|
||||||
|
|
||||||
|
Platzhalter stehen **allein** im Wert (nicht mitten im Text) und werden beim Speichern in Divis eigene
|
||||||
|
Verweise übersetzt. So bleibt die Seite mit dem Designsystem verbunden: ändert sich die Primärfarbe, ändert
|
||||||
|
sich auch der Button.
|
||||||
|
|
||||||
|
| Platzhalter | Bedeutung |
|
||||||
|
|---|---|
|
||||||
|
| `{{color:primary}}`, `secondary`, `heading`, `body`, `link` | die fünf Theme-Farben |
|
||||||
|
| `{{color:<Name>}}` | eigene globale Farbe (Name wie in `lit-divi-design-system`) |
|
||||||
|
| `{{var:<Name>}}` | globale Variable (Zahl, Text, Bild, Link) |
|
||||||
|
| `{{font:heading}}`, `{{font:body}}` | Theme-Schrift für Überschriften bzw. Fließtext |
|
||||||
|
| `{{post:title}}`, `excerpt`, `date`, `modified`, `author`, `url`, `featured_image`, `categories`, `tags` | dynamischer Inhalt des angezeigten Beitrags |
|
||||||
|
| `{{field:<meta_key>}}` | dynamischer Inhalt aus einem Feld (Pods/Meta), z. B. `{{field:telefon}}` |
|
||||||
|
| `"preset": "<Name>"` | Modul-Preset (kein Platzhalter, sondern eigenes Feld) |
|
||||||
|
|
||||||
|
Beispiele:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "button", "text": "Anrufen", "url": "{{field:telefon_link}}",
|
||||||
|
"attrs": { "button.decoration.background": { "desktop": { "color": "{{color:primary}}" },
|
||||||
|
"hover": { "color": "{{color:Nachtblau}}" } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "heading", "text": "{{post:title}}", "level": "h1" }
|
||||||
|
```
|
||||||
|
|
||||||
|
Unbekannte Namen lehnt das Werkzeug ab und nennt die vorhandenen; ist ein Name doppelt vergeben, statt des
|
||||||
|
Namens die ID aus `lit-divi-design-system` angeben (z. B. `{{color:gcid-…}}`). Felder, deren Schlüssel mit `_`
|
||||||
|
beginnt (geschützte Meta-Daten), sind nicht erlaubt.
|
||||||
|
|
||||||
|
In Link-Feldern (`url`, `button_url` …) nimmt das Werkzeug jeden dynamischen Inhalt an, auch `{{post:title}}`;
|
||||||
|
sinnvoll sind dort nur Werte, die eine Adresse ergeben (`{{post:url}}`, ein Feld mit einer URL, eine
|
||||||
|
Link-Variable).
|
||||||
|
|
||||||
|
## `$variable`-Werte
|
||||||
|
|
||||||
|
In `lit-divi-node-get` erscheinen die übersetzten Verweise als `$variable({…})$`. Diese Werte unverändert
|
||||||
|
lassen oder wieder durch einen Platzhalter ersetzen. `$variable`-Verweise werden geparst, geprüft und vom
|
||||||
|
Plugin neu erzeugt: erlaubt sind nur ganze Werte mit bekannten Farben, Variablen, Schriften und
|
||||||
|
dynamischen Quellen; Zusatztexte davor/danach (`before`/`after`) werden bereinigt; alles andere (z. B. ein
|
||||||
|
Verweis mitten im Text, eigenes HTML-Ausgeben von Feldern) lehnt das Werkzeug mit `validation_failed` ab.
|
||||||
|
|
||||||
|
Unberührte Teile der Seite bleiben dabei byte-identisch. Ausnahme: Divi selbst normalisiert Seiten mit
|
||||||
|
eigenen HTML-/Attribut-Feldern beim Speichern; die Antwort warnt dann, dass der Inhalt beim Speichern
|
||||||
|
verändert wurde, und die neue `tree_rev` gilt.
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Divi-Kurzform
|
||||||
|
|
||||||
|
Neue Divi-Inhalte beschreibt man als JSON-Knoten. `lit-divi-insert` (in `nodes`) und `lit-content-create` (in
|
||||||
|
`divi`) nehmen dieselbe Form. Jeder Knoten hat einen `type`; dazu Kurzform-Felder, optional `label`
|
||||||
|
(Admin-Label), `preset` (Name eines Modul-Presets aus `lit-divi-design-system`) und `attrs` (Vollform, siehe
|
||||||
|
`divi-attrs`). Kurz- und Vollform lassen sich kombinieren, aber nicht für denselben Pfad (dann Fehler
|
||||||
|
`validation_failed` mit dem Pfad).
|
||||||
|
|
||||||
|
## Aufbau
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "type": "section", "label": "Hero", "background_color": "{{color:primary}}",
|
||||||
|
"rows": [
|
||||||
|
{ "columns": "1_3,2_3",
|
||||||
|
"modules": [
|
||||||
|
[ { "type": "image", "image_id": 33 } ],
|
||||||
|
[ { "type": "heading", "text": "Willkommen", "level": "h1" },
|
||||||
|
{ "type": "text", "content": "<p>Seit 1987 in Köln.</p>" },
|
||||||
|
{ "type": "button", "text": "Termin buchen", "url": "/kontakt", "preset": "CTA primär" } ]
|
||||||
|
] }
|
||||||
|
] }
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Abschnitt** (`section`): `rows` (Zeilen) oder – für eine einspaltige Zeile – direkt `modules`;
|
||||||
|
`section_type: "fullwidth"` für Vollbreite-Abschnitte (dann nur `fullwidth-…`-Module in `modules`);
|
||||||
|
`background_color`. Spezialabschnitte (specialty) nur im Divi-Builder bauen.
|
||||||
|
- **Zeile** (`row`): `columns` = Breiten als Brüche, die zusammen 1 ergeben (`"4_4"`, `"1_2,1_2"`,
|
||||||
|
`"1_3,2_3"`, `"1_4,1_4,1_2"`, auch Fünftel); `modules` = je Spalte eine Liste (bei einer Spalte reicht eine
|
||||||
|
einfache Liste).
|
||||||
|
- **Module** mit Kind-Modulen (Akkordeon, Tabs, Slider, Kontaktformular, Karte, Social-Media-Follow,
|
||||||
|
Icon-Liste) bekommen ihre Einträge in `items`; der Eintragstyp ergibt sich von selbst.
|
||||||
|
- `label` fehlt → Admin-Label „<Abschnitt> – <Modul>“, z. B. „Hero – Button“. Abschnitten deshalb ein Label
|
||||||
|
geben.
|
||||||
|
- `heading` ohne `level` wird `h2` (eine Seite hat meist schon eine `h1` im Titel).
|
||||||
|
|
||||||
|
## Kurzform-Felder
|
||||||
|
|
||||||
|
| Modul | Felder |
|
||||||
|
|---|---|
|
||||||
|
| `text` | `content` (HTML) |
|
||||||
|
| `heading` | `text`, `level` (`h1`–`h6`) |
|
||||||
|
| `button` | `text`, `url`, `new_window` (true/false) |
|
||||||
|
| `image` | `image_id` (Mediathek, setzt URL/ID/Alt), `src`, `alt`, `title`, `url` |
|
||||||
|
| `blurb` | `title`, `url`, `content`, `image_id`, `src`, `alt`, `level` |
|
||||||
|
| `cta` | `title`, `content`, `button_text`, `button_url` |
|
||||||
|
| `icon` | `url`, `title` |
|
||||||
|
| `divider` | `show`, `color` |
|
||||||
|
| `testimonial` | `author`, `job_title`, `company`, `company_url`, `content`, `portrait_id`, `portrait_url` |
|
||||||
|
| `team-member` | `name`, `position`, `content`, `image_id`, `image_url` |
|
||||||
|
| `accordion` → `accordion-item` | Eintrag: `title`, `content`, `open` |
|
||||||
|
| `tabs` → `tab` | Eintrag: `title`, `content` |
|
||||||
|
| `toggle` | `title`, `content`, `open` |
|
||||||
|
| `gallery` | `image_ids` (Liste von Anhang-IDs) |
|
||||||
|
| `slider` → `slide` | Eintrag: `title`, `content`, `button_text`, `button_url`, `image_id`, `src`, `alt` |
|
||||||
|
| `contact-form` → `contact-field` | Formular: `title`, `email`, `success_message`, `submit_text`; Feld: `title`, `id`, `type` (`input`, `email`, `text`, `checkbox`, `radio`, `select`), `required` |
|
||||||
|
| `map` → `map-pin` | Karte: `address`, `lat`, `lng`, `zoom`; Pin: `title`, `content`, `address`, `lat`, `lng` |
|
||||||
|
| `video` | `src`, `thumbnail` |
|
||||||
|
| `number-counter` | `number`, `title`, `percent` |
|
||||||
|
| `social-media-follow` → `social-media-follow-network` | Eintrag: `network` (z. B. `facebook`), `url`, `label` |
|
||||||
|
| `icon-list` → `icon-list-item` | Eintrag: `text` |
|
||||||
|
| `code` | `content` – nur, wenn kein natives Modul passt (Warnung) |
|
||||||
|
|
||||||
|
Alle anderen Module (siehe `lit-divi-catalog`) gehen über `attrs`. Die Kurzform-Felder eines Moduls samt Pfad
|
||||||
|
zeigt `lit-divi-catalog` mit `module`.
|
||||||
|
|
||||||
|
Zwei Felder heißen wie Knoten-Schlüssel und lassen sich deshalb beim Einfügen **nicht** in Kurzform setzen:
|
||||||
|
`type` des Kontaktformular-Felds (`type` ist dort der Modultyp) und `label` des Social-Media-Netzwerks (`label`
|
||||||
|
ist dort das Admin-Label). Beim Einfügen über `attrs` (`fieldItem.advanced.type` bzw.
|
||||||
|
`socialNetwork.innerContent` mit `label`) setzen, danach auch mit `lit-divi-update` über `set`
|
||||||
|
(`{"set": {"type": "email"}}`).
|
||||||
|
|
||||||
|
## Einfügen, ändern, verschieben, löschen
|
||||||
|
|
||||||
|
- `lit-divi-insert`: `nodes` + Position – `parent` (Pfad-ID, z. B. Spalte `s0.r0.c1`; optional `index`),
|
||||||
|
`before` oder `after`; ohne Position ans Seitenende. Oben auf der Seite sind nur Abschnitte erlaubt, in
|
||||||
|
Abschnitten Zeilen, in Spalten Module.
|
||||||
|
- `lit-divi-update`: `changes` = `[{"id": "s0.r0.c1.m2", "set": {"text": "Jetzt buchen"}, "preset": "CTA
|
||||||
|
primär", "label": "Hero – Button"}]` – mehrere Knoten in einem Aufruf; `null` entfernt einen Wert. Denselben
|
||||||
|
Pfad nicht zugleich über `set` und `attrs` ändern (dann zwei Änderungen senden).
|
||||||
|
- `lit-divi-move`: `id` + Position (auch in andere Zeilen oder Abschnitte).
|
||||||
|
- `lit-divi-delete`: `ids`, erst nach Rückfrage mit `confirm: true`.
|
||||||
|
- `lit/divi-duplicate-node`: Kopie direkt hinter dem Original.
|
||||||
|
- Jede Antwort enthält die neue `tree_rev` (für die nächste Änderung), `changed` mit den neuen Pfad-IDs,
|
||||||
|
den betroffenen Abschnitt als `outline`, `warnings` (Qualität, siehe unten) und `revision_id`.
|
||||||
|
|
||||||
|
Fehler: `validation_failed` bzw. `invalid_attr_path` mit `fields` (Eingabepfad → Problem, z. B.
|
||||||
|
`nodes[0].rows[0].columns`); dann wurde nichts gespeichert.
|
||||||
|
|
||||||
|
## Spaltenbreiten ändern
|
||||||
|
|
||||||
|
Die Breite einer Spalte steht in `module.decoration.sizing` (Desktop: `flexType`, z. B. `8_24` = 1/3). Wer
|
||||||
|
diesen Desktop-Wert über `attrs` ersetzt, ersetzt das ganze Objekt: `flexType` immer mitschicken, sonst fällt
|
||||||
|
die Breite weg. In einer Zeile mit festen Breiten alle Spalten zusammen ändern (Summe 1). Die Angabe
|
||||||
|
`flexColumnStructure` der Zeile (Spaltenvorlage im Divi-Builder) passt das Werkzeug nicht an; nach einer
|
||||||
|
Breitenänderung zeigt der Builder dort evtl. noch die alte Vorlage; gespeichert sind die neuen Breiten (bei
|
||||||
|
Bedarf die Vorlage im Builder neu wählen).
|
||||||
|
|
||||||
|
## Sicherheit und Speichern
|
||||||
|
|
||||||
|
Alle Werte werden vor dem Speichern bereinigt: Texte ohne HTML, Rich-Text ohne `unfiltered_html` nur mit dem
|
||||||
|
HTML, das WordPress in Beiträgen erlaubt, Links ohne `javascript:`.
|
||||||
|
`$variable`-Verweise werden geparst, geprüft und vom Plugin neu erzeugt. Eigenes HTML, CSS und HTML-Attribute eines Moduls (`…decoration.attributes`, `…advanced.html`,
|
||||||
|
`…css…`) ändert nur, wer das Recht `unfiltered_html` hat – sonst „nur im Divi-Builder“. Unberührte Teile der
|
||||||
|
Seite bleiben byte-identisch; Ausnahme: Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern
|
||||||
|
beim Speichern (dann Warnung, dass der Inhalt beim Speichern verändert wurde – mit der neuen `tree_rev`
|
||||||
|
weiterarbeiten).
|
||||||
|
|
||||||
|
## Warnungen
|
||||||
|
|
||||||
|
Neue oder geänderte Knoten werden geprüft: feste Farben/Schriften, obwohl eine passende globale existiert;
|
||||||
|
Bilder ohne Alt-Text; leere Module; Sprünge in der Überschriftenhierarchie; Code-Modul. Warnungen dem Nutzer
|
||||||
|
nennen und möglichst gleich beheben.
|
||||||
@@ -22,9 +22,24 @@ Spalten (`column`), Spalten Module (`text`, `heading`, `button`, …). Manche Mo
|
|||||||
des letzten Lesevorgangs.
|
des letzten Lesevorgangs.
|
||||||
- `rev` ist davon getrennt: der Stand des ganzen Eintrags (Titel, Auszug, Status …) für `lit-content-update`.
|
- `rev` ist davon getrennt: der Stand des ganzen Eintrags (Titel, Auszug, Status …) für `lit-content-update`.
|
||||||
|
|
||||||
|
## Ändern
|
||||||
|
|
||||||
|
Neue Inhalte und Änderungen: siehe `divi-shortform` (Kurzform, `lit-divi-insert`, `lit-divi-update`,
|
||||||
|
`lit-divi-move`, `lit-divi-delete`), `divi-attrs` (Vollform) und `divi-placeholders`. Ablauf:
|
||||||
|
|
||||||
|
1. `lit-divi-outline` lesen (liefert `tree_rev`), `lit-divi-design-system` für Farben, Variablen, Presets.
|
||||||
|
2. Änderung mit dieser `tree_rev` senden. Die Antwort enthält die neue `tree_rev` und die neuen Pfad-IDs –
|
||||||
|
damit direkt weiterarbeiten, ohne erneut zu lesen.
|
||||||
|
3. `rev_mismatch`: jemand hat die Seite inzwischen geändert (z. B. im Divi-Builder) → neu lesen.
|
||||||
|
4. Zum Schluss `lit-page-render-summary` prüfen und dem Nutzer die Vorschau (`preview_url`) bzw. den
|
||||||
|
Divi-Builder (`edit_url`) nennen. Der Status bleibt unverändert; veröffentlichte Seiten werden live
|
||||||
|
geändert (vorher entsteht eine Revision, `revision_id`).
|
||||||
|
|
||||||
## Was nicht bearbeitet wird
|
## Was nicht bearbeitet wird
|
||||||
|
|
||||||
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) ändern die Divi-Werkzeuge nicht.
|
- Knoten mit „nur lesbar“ (globale Module `g…`, fremde Blöcke `x…`) lassen sich nur als Ganzes verschieben,
|
||||||
|
duplizieren oder löschen; ihr Inhalt wird im Divi-Builder bearbeitet (globale Module folgen später).
|
||||||
|
`lit/divi-replace-text` ändert sie nicht, sondern nennt sie in `skipped_nodes`.
|
||||||
- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder
|
- Divi-4-Inhalt (Shortcodes `[et_pb_…]`) wird nicht gelesen oder geändert: Seite einmal im Divi-Builder
|
||||||
öffnen und speichern, Divi 5 wandelt sie dabei um.
|
öffnen und speichern, Divi 5 wandelt sie dabei um.
|
||||||
- Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und
|
- Ist das Markup beschädigt, lehnen die Werkzeuge ab (`unsupported`); die Seite im Divi-Builder öffnen und
|
||||||
|
|||||||
@@ -23,8 +23,12 @@ Direkt sichtbar: `lit-guide`, `lit-site-status`, `lit-content-types`, `lit-conte
|
|||||||
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
|
`lit-divi-outline`, `lit-divi-node-get`, `lit-divi-catalog`, `lit-divi-design-system`, `lit-divi-insert`,
|
||||||
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`.
|
`lit-divi-update`, `lit-divi-move`, `lit-divi-delete`.
|
||||||
|
|
||||||
Welche Tools direkt sichtbar sind, lässt sich unter Einstellungen → LIT MCP → Tools ändern (außer bei
|
Die Tools sind nach Integration gruppiert (Grundlagen, WordPress, Medien, Divi). Jede Gruppe hat unter
|
||||||
`lit-guide`, das immer direkt bleibt).
|
Einstellungen → LIT MCP → Tools eine eigene Einstellung (direkt/indirekt/deaktiviert), die ihre Tools übernehmen
|
||||||
|
(„wie Gruppe“). Eine Einstellung am einzelnen Tool geht vor; die Gruppe zeigt dann „gemischt“. „nur lesen“
|
||||||
|
schaltet alle schreibenden Tools der Gruppe ab – auch solche mit eigener Einstellung. `lit-guide` bleibt immer
|
||||||
|
direkt; `lit-media-preview`/`lit-media-download` bleiben bei Gruppe „indirekt“ direkt. Ab mehr als 25 direkten
|
||||||
|
Tools warnt die Seite (Kontextkosten in claude.ai). Änderungen gelten ab der nächsten Verbindung.
|
||||||
|
|
||||||
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und
|
Über `mcp-adapter-discover-abilities` / `mcp-adapter-execute-ability` (Ability-Namen `lit/…`; in Guides und
|
||||||
Hinweisen immer so geschrieben):
|
Hinweisen immer so geschrieben):
|
||||||
@@ -37,12 +41,13 @@ Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/me
|
|||||||
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
|
Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist.
|
||||||
|
|
||||||
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
|
Weitere Themen: `lit-guide` mit `topic` = `content-types`, `workflows`, `media`, `divi-structure`,
|
||||||
`divi-attrs` oder `divi-design-system`.
|
`divi-shortform`, `divi-attrs`, `divi-placeholders` oder `divi-design-system`.
|
||||||
|
|
||||||
## Rückmeldungen
|
## Rückmeldungen
|
||||||
|
|
||||||
- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei
|
- Erfolg: `ok`, `summary`, `changed`, `warnings`, `rev`, `revision_id` (Revision mit dem Stand vorher, nur bei
|
||||||
Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs.
|
Tools, die Inhalt überschreiben, sonst `null`), `log_id`, URLs. Divi-Werkzeuge zusätzlich `tree_rev` (Stand
|
||||||
|
des Seiteninhalts, Pflicht für die nächste Divi-Änderung) und `outline` (betroffener Abschnitt).
|
||||||
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
|
- Fehler: JSON mit `code` (z. B. `validation_failed`, `rev_mismatch`, `write_failed`, `partial_write`),
|
||||||
`message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
|
`message`, `hint` (und ggf. `fields`). Dem `hint` folgen.
|
||||||
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
|
- `confirm_required`: Nutzer fragen, dann mit `confirm: true` wiederholen.
|
||||||
|
|||||||
@@ -11,6 +11,25 @@
|
|||||||
5. Menü: `lit/menu-create` (optional mit `location` aus `lit/menu-list`), Einträge mit `lit/menu-set-items`.
|
5. Menü: `lit/menu-create` (optional mit `location` aus `lit/menu-list`), Einträge mit `lit/menu-set-items`.
|
||||||
6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte.
|
6. Veröffentlichen erst, wenn der Nutzer es ausdrücklich möchte.
|
||||||
|
|
||||||
|
## Divi-Seite bauen
|
||||||
|
|
||||||
|
1. `lit-divi-design-system` lesen (Farben, Schriften, Presets) und `lit-divi-catalog` für die Module.
|
||||||
|
2. Seite als Entwurf mit Inhalt anlegen: `lit-content-create` mit `divi` (Abschnitte in Kurzform, siehe
|
||||||
|
`divi-shortform`) – oder eine leere Seite anlegen und Abschnitte mit `lit-divi-insert` einfügen.
|
||||||
|
3. Weitere Abschnitte/Module mit `lit-divi-insert`, Anpassungen mit `lit-divi-update`; immer die `tree_rev`
|
||||||
|
der letzten Antwort mitgeben.
|
||||||
|
4. `lit-page-render-summary` prüfen, `warnings` beheben, dem Nutzer `preview_url` und `edit_url` (Divi-Builder)
|
||||||
|
nennen. Veröffentlichen nur auf Wunsch (`lit-post-publish`).
|
||||||
|
|
||||||
|
## Text auf vielen Seiten ersetzen
|
||||||
|
|
||||||
|
`lit/divi-replace-text` zuerst mit `dry_run: true` (Standard): zeigt Treffer je Seite mit `tree_rev`. Nach
|
||||||
|
Rückfrage mit `dry_run: false`, `targets` (post_id + tree_rev aus dem Probelauf) und `confirm: true`
|
||||||
|
ausführen; jede Seite bekommt eine eigene Revision, `results` meldet das Ergebnis je Seite. Ersetzt wird nur
|
||||||
|
sichtbarer Text (keine Links, Stile, Code oder dynamischen Inhalte); in reinen Textfeldern (Titel,
|
||||||
|
Button-Texte) werden Leerzeichen am Anfang und Ende der Ersetzung entfernt. Globale Module werden nicht
|
||||||
|
geändert, sondern in `skipped_nodes` genannt – dem Nutzer sagen, dass er sie in der Divi-Bibliothek ändern muss.
|
||||||
|
|
||||||
## Menüs pflegen
|
## Menüs pflegen
|
||||||
|
|
||||||
- `lit/menu-get` liefert den Baum und `menu_rev` (Stand des Menüs). `lit/menu-set-items` verlangt diese
|
- `lit/menu-get` liefert den Baum und `menu_rev` (Stand des Menüs). `lit/menu-set-items` verlangt diese
|
||||||
|
|||||||
@@ -0,0 +1,112 @@
|
|||||||
|
# Etappe 2b – Manuelle Abnahme (Divi schreiben)
|
||||||
|
|
||||||
|
Vorbereitet von Task 10 (Hinweise, Guides, Version 0.3.0, Ende-zu-Ende-Test). Automatisch geprüft sind
|
||||||
|
bereits:
|
||||||
|
|
||||||
|
- alle Unit-Tests (`composer test`) und Lint (`composer lint`);
|
||||||
|
- die fünf Integrationsskripte `divi-write-smoke.sh`, `divi-read-smoke.sh`, `mcp-smoke.sh`,
|
||||||
|
`wp-basis-smoke.sh` und `media-admin-smoke.sh` (alle in `tests/Integration/`).
|
||||||
|
|
||||||
|
`divi-write-smoke.sh` prüft über den OAuth-Endpunkt:
|
||||||
|
|
||||||
|
- Einfügen, Ändern, Verschieben, Löschen und Duplizieren;
|
||||||
|
- Ersetzen mit Probelauf;
|
||||||
|
- `lit-content-create` mit Divi-Inhalt;
|
||||||
|
- den ersten Abschnitt auf einer leeren Seite;
|
||||||
|
- einen Redakteur ohne `unfiltered_html`: unberührte Bytes bleiben gleich, und es wird weder Skript,
|
||||||
|
Event-Handler, `javascript:` noch ein eingeschleustes Attribut gespeichert oder gerendert;
|
||||||
|
- Divis eigene Speicherfilter (Seite mit eigenen HTML-Attributen).
|
||||||
|
|
||||||
|
Details stehen im Report von Task 10 (`.superpowers/sdd/2026-09-26-etappe-2b-divi-schreiben/task-10-report.md`).
|
||||||
|
|
||||||
|
Die folgenden Punkte lassen sich nicht automatisch prüfen, weil sie den echten Divi Visual Builder und den
|
||||||
|
claude.ai-Connector brauchen. Jan-Niclas prüft sie selbst (Spec §1.3 Nr. 2, §13 „Bearbeitbarkeit im
|
||||||
|
Visual Builder“). Abweichungen als kurze Notiz zurückmelden; sie werden Folgeaufgaben (z. B. anders
|
||||||
|
gespeicherte Spaltenbreiten, D8).
|
||||||
|
|
||||||
|
## 1. Visual Builder: von Claude gebaute Seite
|
||||||
|
|
||||||
|
- [ ] Dieser Auftrag in claude.ai:
|
||||||
|
„Lege eine Entwurfsseite ‚Abnahme Divi‘ an mit Hero (Überschrift, Text, Button mit Preset), einer
|
||||||
|
Zeile 1/3 + 2/3 mit Bild und Blurb, einem Akkordeon und einem Kontaktformular.“
|
||||||
|
Erwartet:
|
||||||
|
- Claude liest vorher `lit-divi-design-system` und `lit-divi-catalog`;
|
||||||
|
- dann `lit-content-create` mit `divi` (oder eine leere Seite plus `lit-divi-insert`);
|
||||||
|
- die Antwort nennt `preview_url` und `edit_url`.
|
||||||
|
- [ ] Die Seite im Divi Visual Builder öffnen (`edit_url`) und prüfen:
|
||||||
|
- [ ] Alle Module erscheinen als **native Module**, nicht als Code-Modul.
|
||||||
|
- [ ] Die Admin-Labels folgen dem Muster „<Abschnitt> – <Modul>“, z. B. „Hero – Button“.
|
||||||
|
- [ ] Jedes Modul lässt sich anklicken, ändern und speichern, ohne Fehlermeldung des Builders.
|
||||||
|
- [ ] Die Spaltenbreiten stimmen (1/3 + 2/3), auch in der Spalteneinstellung des Builders.
|
||||||
|
- [ ] Farben aus `{{color:…}}` sind als **globale Farbe** verknüpft: der Farbwähler zeigt die globale
|
||||||
|
Farbe, nicht einen festen Hex-Wert.
|
||||||
|
- [ ] Das Button-Preset ist im Builder als Preset ausgewählt.
|
||||||
|
- [ ] Akkordeon-Einträge und Kontaktformular-Felder sind einzeln bearbeitbar.
|
||||||
|
- [ ] Eine zweite Seite nur mit `lit-divi-insert` aufbauen lassen: leere Seite anlegen, dann
|
||||||
|
„Füge einen Abschnitt ‚Start‘ mit Überschrift und Text ein“. Im Builder öffnen: der Abschnitt
|
||||||
|
erscheint normal und ist bearbeitbar (Ruling S9: Hülle `divi/placeholder` wie beim Builder).
|
||||||
|
|
||||||
|
## 2. Visual Builder: von Claude geänderte Seite
|
||||||
|
|
||||||
|
- [ ] Eine bestehende, im Builder gebaute Seite kopieren (`lit/post-duplicate`) und an der Kopie per
|
||||||
|
claude.ai einen Button-Text ändern, ein Modul verschieben und einen Abschnitt duplizieren lassen.
|
||||||
|
- [ ] Die Kopie im Builder öffnen und prüfen:
|
||||||
|
- [ ] Alle unveränderten Module sehen aus wie vorher (Einstellungen, Presets, globale Farben).
|
||||||
|
- [ ] Die geänderten Module sind bearbeitbar, und die Änderung ist sichtbar.
|
||||||
|
- [ ] Die Kopie eines Abschnitts heißt „<Label> (Kopie)“.
|
||||||
|
|
||||||
|
## 3. Zurück zu Claude nach einer Builder-Änderung
|
||||||
|
|
||||||
|
- [ ] Die Seite aus Schritt 1 im Builder ändern und speichern.
|
||||||
|
- [ ] Danach per claude.ai eine weitere Änderung verlangen. Erwartet:
|
||||||
|
- [ ] `rev_mismatch` erscheint nur, wenn Claude eine alte `tree_rev` benutzt.
|
||||||
|
- [ ] Nach erneutem Lesen (`lit-divi-outline`) gelingt die Änderung.
|
||||||
|
- [ ] Die im Builder gemachten Einstellungen bleiben erhalten.
|
||||||
|
|
||||||
|
## 4. Rückgängig
|
||||||
|
|
||||||
|
- [ ] Einen Abschnitt löschen lassen. Claude fragt nach (`confirm_required`) und löscht erst nach der
|
||||||
|
Bestätigung.
|
||||||
|
- [ ] Den Abschnitt über `lit/revision-restore` mit der gemeldeten `revision_id` zurückholen und im Builder
|
||||||
|
prüfen, dass er wieder vollständig da ist.
|
||||||
|
|
||||||
|
## 5. Text auf mehreren Seiten ersetzen
|
||||||
|
|
||||||
|
- [ ] „Ersetze auf allen Seiten ‚Termin‘ durch ‚Beratungstermin‘“. Erwartet:
|
||||||
|
- [ ] Claude zeigt erst den Probelauf (Treffer je Seite) und fragt nach.
|
||||||
|
- [ ] Erst danach ersetzt es.
|
||||||
|
- [ ] Im Protokoll-Tab (Einstellungen → LIT MCP → Protokoll) steht je geänderter Seite ein Eintrag mit
|
||||||
|
eigener Revision.
|
||||||
|
- [ ] Liegt ein Treffer in einem **globalen Modul**, ändert Claude ihn nicht, sondern nennt ihn
|
||||||
|
(`skipped_nodes`, „in der Divi-Bibliothek bearbeiten“).
|
||||||
|
|
||||||
|
## 6. Redakteur ohne `unfiltered_html`
|
||||||
|
|
||||||
|
- [ ] Mit einem Redakteurs-Konto verbinden; unter Einstellungen → LIT MCP → Zugriff muss die Rolle
|
||||||
|
freigegeben sein.
|
||||||
|
- [ ] Auf einer Seite eines Administrators einen Text ändern lassen.
|
||||||
|
- [ ] Die Seite im Builder öffnen. Erwartet:
|
||||||
|
- [ ] Eingebettete Inhalte des Administrators (z. B. iframe, eigenes HTML im Code-Modul) sind
|
||||||
|
unverändert.
|
||||||
|
- [ ] Die Änderung des Redakteurs ist da.
|
||||||
|
- [ ] Den Redakteur eigenes HTML/CSS/Attribute eines Moduls setzen lassen, z. B. „füge dem Button das
|
||||||
|
HTML-Attribut data-x hinzu“. Erwartet: Ablehnung mit „nur im Divi-Builder änderbar“.
|
||||||
|
|
||||||
|
## 7. Tool-Gruppen und „nur lesen“
|
||||||
|
|
||||||
|
- [ ] Unter Einstellungen → LIT MCP → Tools die Gruppe Divi auf „nur lesen“ stellen.
|
||||||
|
- [ ] Neu verbinden. Die Divi-Schreibwerkzeuge (`lit-divi-insert` usw.) fehlen; die Lesewerkzeuge sind
|
||||||
|
noch da.
|
||||||
|
- [ ] Die Einstellung zurücksetzen.
|
||||||
|
|
||||||
|
## 8. Builder-Fixtures aus 2a (falls noch offen; Ruling S20)
|
||||||
|
|
||||||
|
- [ ] Seiten mit **globalem Modul**, Preset, Variablen und Dynamic Content exportieren:
|
||||||
|
`tests/Integration/divi-export-page.php` (Anleitung in `2026-09-26-etappe-2a-abnahme.md` §1).
|
||||||
|
`composer test` bleibt grün.
|
||||||
|
- [ ] Auf einer Kopie einer solchen Seite `lit-divi-update` an einem normalen Modul ausführen. Das
|
||||||
|
globale Modul bleibt byte-identisch: Export vorher und nachher vergleichen.
|
||||||
|
- [ ] Hat die Seite Module mit eigenen HTML-Attributen oder einem eigenen HTML-Element (Erweitert →
|
||||||
|
Attribute/HTML), meldet die Antwort evtl. „Inhalt beim Speichern verändert“. Das ist dokumentiert
|
||||||
|
(Ruling S7: Divi normalisiert solche Seiten selbst). Prüfen, dass die Seite im Builder danach
|
||||||
|
einwandfrei ist.
|
||||||
@@ -18,7 +18,7 @@ Ein WordPress-Plugin, das auf jeder Kundenseite einen MCP-Server bereitstellt, m
|
|||||||
|
|
||||||
1. Ein claude.ai-Connector verbindet sich per OAuth mit einer Kundenseite; nur freigegebene Rollen können Tools nutzen.
|
1. Ein claude.ai-Connector verbindet sich per OAuth mit einer Kundenseite; nur freigegebene Rollen können Tools nutzen.
|
||||||
2. Von Claude erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe).
|
2. Von Claude erzeugte oder geänderte Divi-Inhalte sind im Visual Builder vollständig bearbeitbar (manuell verifiziert pro Etappe).
|
||||||
3. Unangetastete Inhalte bleiben byte-identisch erhalten.
|
3. Unangetastete Inhalte bleiben byte-identisch erhalten. Dokumentierte Ausnahme (Ruling S7): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`AttributeSecurity`/`HtmlSecurity` auf `wp_insert_post_data`, laufen für jeden Benutzer und werden nie entfernt); die Antwort meldet das als Warnung „Inhalt beim Speichern verändert“ (`CHANGED_ON_SAVE`), die Seite bleibt gültig (E2E `divi-write-smoke.sh` Abschnitt 8).
|
||||||
4. Eine neue Website (Seitenstruktur, Menü, Einstellungen, Designsystem aus einem CD) lässt sich im Dialog aufsetzen.
|
4. Eine neue Website (Seitenstruktur, Menü, Einstellungen, Designsystem aus einem CD) lässt sich im Dialog aufsetzen.
|
||||||
5. Jede Änderung ist nachvollziehbar (Protokoll) und umkehrbar (Revision bzw. Snapshot).
|
5. Jede Änderung ist nachvollziehbar (Protokoll) und umkehrbar (Revision bzw. Snapshot).
|
||||||
|
|
||||||
@@ -171,7 +171,7 @@ Seiten, Theme-Builder-Layouts (`et_header_layout`, `et_body_layout`, `et_footer_
|
|||||||
|
|
||||||
- Parst `post_content` in einen Baum (auf Basis von `parse_blocks`, mit Sonderbehandlung für `divi/global-layout` und `globalModule`, die `parse_blocks` laut Divi-Code nicht korrekt verarbeitet).
|
- Parst `post_content` in einen Baum (auf Basis von `parse_blocks`, mit Sonderbehandlung für `divi/global-layout` und `globalModule`, die `parse_blocks` laut Divi-Code nicht korrekt verarbeitet).
|
||||||
- Serialisiert den Baum zurück; unveränderte Knoten werden aus ihrem Original-Markup übernommen.
|
- Serialisiert den Baum zurück; unveränderte Knoten werden aus ihrem Original-Markup übernommen.
|
||||||
- **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt.
|
- **Invariante:** Parse → Serialize ohne Änderung ergibt byte-identischen Inhalt. Fremde/unbekannte Blöcke bleiben unberührt. Ausnahme beim Speichern (nicht im BlockTree): Divi selbst normalisiert Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (`module.decoration.attributes`, `module.advanced.html` mit `htmlBefore`/`htmlAfter`/`elementType`) – Divi parst und serialisiert dann die ganze Seite neu; gemeldet als `CHANGED_ON_SAVE` (Ruling S7).
|
||||||
- Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar).
|
- Reines PHP ohne WordPress-Abhängigkeit (vollständig unit-testbar).
|
||||||
- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`).
|
- Attributwerte mit `$variable(...)$` werden beim Serialisieren wie von Divi erwartet escaped (`"` → `"`).
|
||||||
- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert.
|
- Umsetzung (D3): eigener Tokenizer mit dem Delimiter-Muster von `WP_Block_Parser` statt `parse_blocks()` – ohne WordPress testbar, jedes Byte zwischen den Delimitern bleibt erhalten. Knoten mit `globalModule`, `divi/global-layout` oder nicht dekodierbaren Attributen sind undurchsichtig: sie werden nicht betreten, nur als Ganzes verschoben, dupliziert oder gelöscht (Bearbeitung ab Etappe 3). Unbalanciertes Markup wird abgelehnt, nie repariert.
|
||||||
|
|||||||
+35
-1
@@ -4,7 +4,7 @@ Tags: mcp, claude, divi, pods, ai
|
|||||||
Requires at least: 6.9
|
Requires at least: 6.9
|
||||||
Tested up to: 7.0
|
Tested up to: 7.0
|
||||||
Requires PHP: 8.1
|
Requires PHP: 8.1
|
||||||
Stable tag: 0.2.0
|
Stable tag: 0.3.0
|
||||||
License: GPL-2.0-or-later
|
License: GPL-2.0-or-later
|
||||||
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
License URI: https://www.gnu.org/licenses/gpl-2.0.html
|
||||||
|
|
||||||
@@ -39,6 +39,17 @@ Einstellungen: Einstellungen → LIT MCP (Verbindung, Zugriff, Tools, Protokoll,
|
|||||||
* Eigene Daten: Optionen lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
|
* Eigene Daten: Optionen lit_mcp_allowed_roles, lit_mcp_exposure, lit_mcp_limits, lit_mcp_db_version,
|
||||||
Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup,
|
Tabelle {prefix}lit_mcp_log (nur Verweise, keine Inhalte), täglicher Cron-Job lit_mcp_log_cleanup,
|
||||||
Transient lit_mcp_endpoint_check.
|
Transient lit_mcp_endpoint_check.
|
||||||
|
* Divi-Änderungen über LIT MCP speichern den Seiteninhalt für Benutzer ohne die Berechtigung
|
||||||
|
unfiltered_html ohne die kses-Filterung von WordPress, weil diese sonst jeden unveränderten Divi-Block neu
|
||||||
|
schreiben würde. Alle Werte, die LIT MCP dabei schreibt, werden vorher bereinigt (wp_kses_post,
|
||||||
|
sanitize_text_field, esc_url_raw); $variable-Verweise werden geparst, geprüft und vom Plugin neu erzeugt.
|
||||||
|
Inhalte des Code-Moduls bleiben nur mit unfiltered_html ungefiltert; eigenes HTML, CSS und HTML-Attribute
|
||||||
|
von Modulen schreibt LIT MCP nur für Benutzer mit unfiltered_html. Divis eigene Speicherfilter bleiben
|
||||||
|
aktiv.
|
||||||
|
* Unberührte Teile einer Divi-Seite bleiben beim Speichern byte-identisch. Ausnahme: Divi selbst normalisiert
|
||||||
|
Seiten mit eigenen HTML-/Attribut-Feldern beim Speichern (die Antwort warnt dann, dass der Inhalt beim
|
||||||
|
Speichern verändert wurde).
|
||||||
|
* Nach jeder Divi-Änderung wird der statische CSS-Cache von Divi für die Seite geleert.
|
||||||
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
|
* Uploads über LIT MCP: nur Bilder (jpg, png, gif, webp, avif), PDF, MP4, MP3; Größenlimit einstellbar
|
||||||
(Standard 5 MB, höchstens 32 MB; Base64-Upload und Download höchstens 10 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden
|
(Standard 5 MB, höchstens 32 MB; Base64-Upload und Download höchstens 10 MB); Import per URL nur von öffentlich erreichbaren Adressen (interne/lokale Adressen werden
|
||||||
abgelehnt).
|
abgelehnt).
|
||||||
@@ -57,6 +68,8 @@ Es gibt keine allgemeine "Rückgängig"-Funktion; was sich wie zurücksetzen lä
|
|||||||
|
|
||||||
* Inhalte (Titel, Text, Auszug): über WordPress-Revisionen (Werkzeuge lit/revisions-list,
|
* Inhalte (Titel, Text, Auszug): über WordPress-Revisionen (Werkzeuge lit/revisions-list,
|
||||||
lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten.
|
lit/revision-restore). Felder und Begriffe sind in Revisionen nicht enthalten.
|
||||||
|
* Divi-Seiten: jede Divi-Änderung legt vorher eine Revision an (revision_id in der Antwort); zurück über
|
||||||
|
lit/revision-restore. Beim seitenübergreifenden Ersetzen erhält jede geänderte Seite eine eigene Revision.
|
||||||
* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision
|
* Original durch eine Kopie ersetzen (lit/post-replace-with-copy): vorher legt das Werkzeug eine Revision
|
||||||
und eine vollständige Sicherung des bisherigen Originals im Papierkorb an (backup_id in der Antwort) –
|
und eine vollständige Sicherung des bisherigen Originals im Papierkorb an (backup_id in der Antwort) –
|
||||||
von dort in WordPress wiederherstellbar.
|
von dort in WordPress wiederherstellbar.
|
||||||
@@ -93,8 +106,29 @@ Protokoll gelten pro Website. Super-Admins haben (wie jede Capability in WordPre
|
|||||||
unabhängig von den freigegebenen Rollen. Der Standard-MCP-Server von mcp-adapter wird ebenfalls website-weit
|
unabhängig von den freigegebenen Rollen. Der Standard-MCP-Server von mcp-adapter wird ebenfalls website-weit
|
||||||
abgeschaltet.
|
abgeschaltet.
|
||||||
|
|
||||||
|
== Frequently Asked Questions ==
|
||||||
|
|
||||||
|
= Kann ich Tools abschalten oder nur lesend freigeben? =
|
||||||
|
|
||||||
|
Ja, unter Einstellungen → LIT MCP → Tools einzeln oder je Gruppe; „nur lesen“ schaltet alle schreibenden
|
||||||
|
Tools einer Gruppe auf einmal ab.
|
||||||
|
|
||||||
|
= Bleiben mit Claude bearbeitete Divi-Seiten im Visual Builder bearbeitbar? =
|
||||||
|
|
||||||
|
Ja. LIT MCP erzeugt native Divi-5-Module mit Admin-Labels und verknüpft Farben, Variablen und Presets mit dem
|
||||||
|
Designsystem. Nach einer Änderung im Builder liest Claude die Seite neu, bevor es weiter ändert.
|
||||||
|
|
||||||
== Changelog ==
|
== Changelog ==
|
||||||
|
|
||||||
|
= 0.3.0 =
|
||||||
|
* Divi-Seiten: Seitenbaum mit Pfad-IDs und tree_rev, Knoten in Voll- und Kurzform, Modulkatalog,
|
||||||
|
Designsystem (Farben, Variablen, Schriften, Presets), gerenderte Zusammenfassung; Abschnitte, Zeilen und
|
||||||
|
Module einfügen, ändern, verschieben, duplizieren und löschen (Kurzform, Vollform, Platzhalter wie
|
||||||
|
{{color:primary}}), Text seitenübergreifend ersetzen mit Probelauf, Qualitätswarnungen, neue Seiten
|
||||||
|
direkt mit Divi-Inhalt.
|
||||||
|
* Tools nach Gruppen einstellbar (direkt/indirekt/deaktiviert, „nur lesen“ je Gruppe); überarbeitete
|
||||||
|
Einstellungsseite.
|
||||||
|
|
||||||
= 0.2.0 =
|
= 0.2.0 =
|
||||||
* WordPress-Basis: Schema-Erkennung inkl. Pods, Inhalte lesen/anlegen/ändern, Status, Kopie und
|
* WordPress-Basis: Schema-Erkennung inkl. Pods, Inhalte lesen/anlegen/ändern, Status, Kopie und
|
||||||
Austausch, Revisionen, Menüs, WordPress-Einstellungen, Mediathek (Suche, Vorschau, Metadaten,
|
Austausch, Revisionen, Menüs, WordPress-Einstellungen, Mediathek (Suche, Vorschau, Metadaten,
|
||||||
|
|||||||
@@ -53,8 +53,12 @@ final class InstructionsBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
$divi = str_contains(', ' . $active . ',', ', divi,')
|
$divi = str_contains(', ' . $active . ',', ', divi,')
|
||||||
? ['- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide '
|
? [
|
||||||
. 'topic "divi-structure".']
|
'- Divi-Seiten: vor Änderungen lit-divi-outline und lit-divi-design-system lesen; Aufbau: lit-guide '
|
||||||
|
. 'topic "divi-structure".',
|
||||||
|
'- Divi-Änderungen verlangen die tree_rev aus lit-divi-outline bzw. der letzten Divi-Antwort '
|
||||||
|
. '(nicht rev).',
|
||||||
|
]
|
||||||
: [];
|
: [];
|
||||||
|
|
||||||
return implode("\n", array_merge($lines, [
|
return implode("\n", array_merge($lines, [
|
||||||
|
|||||||
@@ -177,13 +177,14 @@ final class DiviOutlineAbility implements Ability
|
|||||||
$hints = [];
|
$hints = [];
|
||||||
if ($rows === []) {
|
if ($rows === []) {
|
||||||
$hints[] = $page->isDiviLayout()
|
$hints[] = $page->isDiviLayout()
|
||||||
? 'Die Seite hat noch keinen Divi-Inhalt.'
|
? 'Die Seite hat noch keinen Divi-Inhalt: mit lit-divi-insert (ohne parent) den ersten Abschnitt '
|
||||||
|
. 'einfügen.'
|
||||||
: 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.';
|
: 'Der Inhalt ist kein Divi-Layout (Block-Editor oder klassisch); lit-content-get zeigt ihn.';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (in_array(false, array_column($rows, 'editable'), true)) {
|
if (in_array(false, array_column($rows, 'editable'), true)) {
|
||||||
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) ändern die Divi-Werkzeuge nicht; '
|
$hints[] = 'Knoten mit "nur lesbar" (globale Module, fremde Blöcke) lassen sich nur als Ganzes '
|
||||||
. 'ihren Inhalt nur im Divi-Builder bearbeiten.';
|
. 'verschieben, duplizieren oder löschen; ihren Inhalt im Divi-Builder bearbeiten.';
|
||||||
}
|
}
|
||||||
|
|
||||||
return $hints;
|
return $hints;
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ final class DiviCatalogAbility implements Ability
|
|||||||
public function execute(array $input): mixed
|
public function execute(array $input): mixed
|
||||||
{
|
{
|
||||||
$catalog = $this->modules->catalog();
|
$catalog = $this->modules->catalog();
|
||||||
$notes = [self::CODE_NOTE, 'Details zur Vollform: lit-guide mit topic "divi-attrs".'];
|
$notes = [self::CODE_NOTE, 'Details und Beispiele: lit-guide mit topic "divi-shortform" bzw. "divi-attrs".'];
|
||||||
|
|
||||||
if (! is_string($input['module'] ?? null) || $input['module'] === '') {
|
if (! is_string($input['module'] ?? null) || $input['module'] === '') {
|
||||||
$modules = [];
|
$modules = [];
|
||||||
|
|||||||
@@ -25,8 +25,9 @@ use WP_Post;
|
|||||||
class PostPresenter
|
class PostPresenter
|
||||||
{
|
{
|
||||||
/** How Divi content is read and changed with the Divi tools. */
|
/** How Divi content is read and changed with the Divi tools. */
|
||||||
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen, einzelne Knoten mit '
|
public const DIVI_NOTE = 'Den Aufbau mit lit/divi-outline (direkt: lit-divi-outline) lesen und mit '
|
||||||
. 'lit/divi-node-get (direkt: lit-divi-node-get); ändern vorerst im Divi-Builder.';
|
. 'lit/divi-insert, lit/divi-update, lit/divi-move bzw. lit/divi-delete (direkt: lit-divi-insert, '
|
||||||
|
. 'lit-divi-update, lit-divi-move, lit-divi-delete) ändern.';
|
||||||
|
|
||||||
/** Divi content on a site where the Divi 5 builder is not loaded (no Divi tools registered). */
|
/** Divi content on a site where the Divi 5 builder is not loaded (no Divi tools registered). */
|
||||||
public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine '
|
public const DIVI_INACTIVE_NOTE = 'Der Divi-5-Builder ist auf dieser Website nicht aktiv, daher gibt es keine '
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ use LIT\MCP\Server\VisibilityFilter;
|
|||||||
*/
|
*/
|
||||||
final class Plugin
|
final class Plugin
|
||||||
{
|
{
|
||||||
public const VERSION = '0.2.0';
|
public const VERSION = '0.3.0';
|
||||||
|
|
||||||
private static string $pluginFile = '';
|
private static string $pluginFile = '';
|
||||||
private static ?SettingsPage $settingsPage = null;
|
private static ?SettingsPage $settingsPage = null;
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
<!-- wp:divi/placeholder --><!-- wp:divi/section {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/row {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Zeile"}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/column {"module":{"decoration":{"sizing":{"desktop":{"value":{"flexType":"24_24"}}}}},"builderVersion":"5.2.1"} --><!-- wp:divi/text {"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Artikel"}}},"advanced":{"html":{"elementType":{"desktop":{"value":"article"}}}},"decoration":{"attributes":{"desktop":{"value":{"attributes":[{"id":"lit-attr-1","name":"data-track","value":"hero","targetElement":""}]}}}}},"content":{"innerContent":{"desktop":{"value":"<p>Text mit eigenem Element und Attribut.</p>"}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/text --><!-- wp:divi/heading {"title":{"innerContent":{"desktop":{"value":"Eigene Felder bleiben"}},"decoration":{"font":{"font":{"desktop":{"value":{"headingLevel":"h2"}}}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Überschrift"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/heading --><!-- wp:divi/button {"button":{"innerContent":{"desktop":{"value":{"text":"Kontakt","linkUrl":"/kontakt"}}}},"module":{"meta":{"adminLabel":{"desktop":{"value":"Eigene Felder – Button"}}}},"builderVersion":"5.2.1"} --><!-- /wp:divi/button --><!-- /wp:divi/column --><!-- /wp:divi/row --><!-- /wp:divi/section --><!-- /wp:divi/placeholder -->
|
||||||
@@ -41,6 +41,6 @@ foreach ($lit_page->tabSlugs() as $lit_slug) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
$lit_ok = str_contains($lit_html, 'nav-tab-active') && str_contains($lit_html, 'class="wrap"');
|
$lit_ok = str_contains($lit_html, 'nav-tab-active') && preg_match('/class="wrap[ "]/', $lit_html) === 1;
|
||||||
echo $lit_slug . ($lit_ok ? ' ok ' : ' FAIL ') . strlen($lit_html) . "\n";
|
echo $lit_slug . ($lit_ok ? ' ok ' : ' FAIL ') . strlen($lit_html) . "\n";
|
||||||
}
|
}
|
||||||
|
|||||||
Executable
+364
@@ -0,0 +1,364 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# End-to-end checks of the Etappe-2b Divi write tools through the OAuth-protected endpoint.
|
||||||
|
# Runs INSIDE the DDEV web container (Divi 5 must be the active theme):
|
||||||
|
# cd /home/janniclas/Projekte/WordPress && timeout 900 ddev exec bash wp-content/plugins/WP-LIT-MCP/tests/Integration/divi-write-smoke.sh
|
||||||
|
# Creates draft pages, a temporary role/user without unfiltered_html, and removes everything again
|
||||||
|
# (pages with their revisions, change-log rows of the temporary user, role, user, app passwords).
|
||||||
|
# Security probes (section 7, ruling S6) use benign markers only: a data-probe attribute and the
|
||||||
|
# literal text alert(1); the check is that no tag, event handler, javascript: URL or data-probe
|
||||||
|
# attribute reaches the stored bytes, the render summary or the rendered page.
|
||||||
|
set -euo pipefail
|
||||||
|
cd /var/www/html
|
||||||
|
# shellcheck source=lib.sh
|
||||||
|
source wp-content/plugins/WP-LIT-MCP/tests/Integration/lib.sh
|
||||||
|
|
||||||
|
ADMIN_LOGIN=$(wp user list --role=administrator --field=user_login | head -1)
|
||||||
|
EDITOR_LOGIN=lit-smoke-editor
|
||||||
|
FIXTURES=$PLUGIN/tests/Fixtures/divi
|
||||||
|
ORIGINAL_ROLES=$(wp option get lit_mcp_allowed_roles --format=json 2>/dev/null || echo '["administrator"]')
|
||||||
|
CREATED_POSTS=()
|
||||||
|
|
||||||
|
cleanup() {
|
||||||
|
for id in "${CREATED_POSTS[@]}"; do
|
||||||
|
wp post delete "$id" --force >/dev/null 2>&1 || true
|
||||||
|
done
|
||||||
|
cleanup_app_passwords_for "$ADMIN_LOGIN"
|
||||||
|
cleanup_app_passwords_for "$EDITOR_LOGIN"
|
||||||
|
# Change-log rows of this run: every row of the temporary user and every row about a test page.
|
||||||
|
LIT_LOGIN="$EDITOR_LOGIN" LIT_IDS="${CREATED_POSTS[*]}" wp eval '
|
||||||
|
global $wpdb;
|
||||||
|
$table = $wpdb->prefix . "lit_mcp_log";
|
||||||
|
$u = get_user_by("login", getenv("LIT_LOGIN"));
|
||||||
|
if ($u) { $wpdb->delete($table, ["user_id" => $u->ID]); }
|
||||||
|
foreach (array_filter(explode(" ", (string) getenv("LIT_IDS"))) as $id) {
|
||||||
|
$wpdb->delete($table, ["object_type" => "page", "object_id" => $id]);
|
||||||
|
}' >/dev/null 2>&1 || true
|
||||||
|
wp user delete "$EDITOR_LOGIN" --yes >/dev/null 2>&1 || true
|
||||||
|
wp role delete lit_smoke_editor >/dev/null 2>&1 || true
|
||||||
|
wp option update lit_mcp_allowed_roles "$ORIGINAL_ROLES" --format=json >/dev/null 2>&1 || true
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
create_page() { # $1 = fixture file ("" = empty page), $2 = title -> prints the ID
|
||||||
|
LIT_FILE="${1:+$FIXTURES/$1}" LIT_TITLE="$2" wp --user="$ADMIN_LOGIN" eval '
|
||||||
|
$file = (string) getenv("LIT_FILE");
|
||||||
|
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
|
||||||
|
"post_content" => $file === "" ? "" : wp_slash((string) file_get_contents($file))]);
|
||||||
|
update_post_meta($id, "_et_pb_use_builder", "on");
|
||||||
|
update_post_meta($id, "_et_pb_use_divi_5", "on");
|
||||||
|
echo $id;'
|
||||||
|
}
|
||||||
|
|
||||||
|
content_of() { # $1 = post ID
|
||||||
|
wp post get "$1" --field=post_content
|
||||||
|
}
|
||||||
|
|
||||||
|
# Whether the stored content still contains the exact bytes of the fixture section "Leistungen".
|
||||||
|
keeps_leistungen() { # $1 = post ID
|
||||||
|
LIT_ID="$1" LIT_FILE="$FIXTURES/converted-landing.html" wp eval '
|
||||||
|
$fixture = (string) file_get_contents(getenv("LIT_FILE"));
|
||||||
|
$start = strpos($fixture, "<!-- wp:divi/section {\"module\":{\"meta\":{\"adminLabel\":{\"desktop\":{\"value\":\"Leistungen\"");
|
||||||
|
$section = substr($fixture, $start, strrpos($fixture, "<!-- /wp:divi/placeholder -->") - $start);
|
||||||
|
echo str_contains((string) get_post((int) getenv("LIT_ID"))->post_content, $section) ? "yes" : "no";'
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prints every dangerous finding (one per line) in the stored content and in the rendered
|
||||||
|
# content area of a post: script tags, event-handler or data-probe attributes, javascript: URLs.
|
||||||
|
# Stored: every string value of every block attribute (decoded by parse_blocks, so "\u003c" is
|
||||||
|
# "<") and the markup between the blocks; rendered: the HTML of the content area.
|
||||||
|
probe_findings() { # $1 = post ID
|
||||||
|
LIT_ID="$1" wp --user="$ADMIN_LOGIN" eval '
|
||||||
|
$post = get_post((int) getenv("LIT_ID"));
|
||||||
|
$stored = [];
|
||||||
|
$walk = static function ($value) use (&$walk, &$stored): void {
|
||||||
|
if (is_array($value)) {
|
||||||
|
array_walk($value, static fn ($v) => $walk($v));
|
||||||
|
} elseif (is_string($value)) {
|
||||||
|
$stored[] = $value;
|
||||||
|
// $variable({...})$ references: their settings (before/after) are JSON inside the string.
|
||||||
|
if (preg_match("/^\\\$variable\\((.*)\\)\\\$$/s", $value, $ref) === 1) {
|
||||||
|
$walk(json_decode($ref[1], true));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$blocks = static function (array $list) use (&$blocks, $walk, &$stored): void {
|
||||||
|
foreach ($list as $block) {
|
||||||
|
$walk($block["attrs"] ?? []);
|
||||||
|
$stored[] = (string) ($block["innerHTML"] ?? "");
|
||||||
|
$blocks($block["innerBlocks"] ?? []);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
$blocks(parse_blocks((string) $post->post_content));
|
||||||
|
$sources = [
|
||||||
|
"stored" => $stored,
|
||||||
|
"rendered" => [(new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render($post)],
|
||||||
|
];
|
||||||
|
$rules = [
|
||||||
|
"script tag" => "/<\s*script/i",
|
||||||
|
"event handler" => "/<[^>]*\son[a-z]+\s*=/i",
|
||||||
|
"data-probe attribute" => "/<[^>]*\sdata-probe\b/i",
|
||||||
|
"javascript: URL" => "/javascript\s*:/i",
|
||||||
|
];
|
||||||
|
foreach ($sources as $where => $strings) {
|
||||||
|
foreach ($rules as $what => $pattern) {
|
||||||
|
foreach ($strings as $html) {
|
||||||
|
if (preg_match($pattern, $html) === 1) {
|
||||||
|
echo "$where: $what in " . substr($html, 0, 120) . "\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}'
|
||||||
|
}
|
||||||
|
|
||||||
|
assert_clean() { # $1 = post ID, $2 = context
|
||||||
|
local findings
|
||||||
|
findings=$(probe_findings "$1")
|
||||||
|
[ -z "$findings" ] || fail "$2: $findings"
|
||||||
|
}
|
||||||
|
|
||||||
|
[ "$(wp eval 'echo get_template();')" = "Divi" ] || fail "Divi must be the active theme"
|
||||||
|
PAGE_ID=$(create_page converted-landing.html "LIT Divi Schreiben")
|
||||||
|
CREATED_POSTS+=("$PAGE_ID")
|
||||||
|
assert_clean "$PAGE_ID" "fixture baseline"
|
||||||
|
# The checker itself must see probes in attribute strings and in $variable settings.
|
||||||
|
PROBE_ID=$(LIT_TITLE="LIT Divi Probe" wp --user="$ADMIN_LOGIN" eval '
|
||||||
|
$v = "\$variable(" . wp_json_encode(["type" => "content", "value" => ["name" => "post_title",
|
||||||
|
"settings" => ["before" => "<script>alert(1)</script>", "after" => ""]]]) . ")$";
|
||||||
|
$text = ["content" => ["innerContent" => ["desktop" => ["value" => "<p><b data-probe=\"1\" onclick=\"alert(1)\">x</b></p>"]]]];
|
||||||
|
$head = ["title" => ["innerContent" => ["desktop" => ["value" => $v]]]];
|
||||||
|
$id = wp_insert_post(["post_type" => "page", "post_status" => "draft", "post_title" => getenv("LIT_TITLE"),
|
||||||
|
"post_content" => wp_slash(serialize_block(["blockName" => "divi/text", "attrs" => $text, "innerBlocks" => [],
|
||||||
|
"innerHTML" => "", "innerContent" => []]) . serialize_block(["blockName" => "divi/heading", "attrs" => $head,
|
||||||
|
"innerBlocks" => [], "innerHTML" => "", "innerContent" => []]))]);
|
||||||
|
echo $id;')
|
||||||
|
CREATED_POSTS+=("$PROBE_ID")
|
||||||
|
PROBE_SELFTEST=$(probe_findings "$PROBE_ID")
|
||||||
|
for finding in "stored: script tag" "stored: event handler" "stored: data-probe attribute"; do
|
||||||
|
echo "$PROBE_SELFTEST" | grep -q "$finding" || fail "probe checker misses \"$finding\": $PROBE_SELFTEST"
|
||||||
|
done
|
||||||
|
wp post delete "$PROBE_ID" --force >/dev/null
|
||||||
|
connect "$ADMIN_LOGIN"
|
||||||
|
|
||||||
|
# 1. Direct write tools with correct annotations
|
||||||
|
LIST=$(rpc tools/list '{}')
|
||||||
|
for t in lit-divi-insert lit-divi-update lit-divi-move lit-divi-delete; do
|
||||||
|
echo "$LIST" | jq -e --arg t "$t" '.result.tools[] | select(.name == $t)' >/dev/null || fail "tools/list lacks $t"
|
||||||
|
done
|
||||||
|
echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-delete") | .annotations.destructiveHint == true' >/dev/null \
|
||||||
|
|| fail "lit-divi-delete lacks destructiveHint"
|
||||||
|
if echo "$LIST" | jq -e '.result.tools[] | select(.name == "lit-divi-replace-text" or .name == "lit-divi-duplicate-node")' >/dev/null; then
|
||||||
|
fail "lit-divi-replace-text and lit-divi-duplicate-node must be indirect"
|
||||||
|
fi
|
||||||
|
GUIDE=$(tool lit-guide '{"topic":"divi-shortform"}')
|
||||||
|
echo "$GUIDE" | jq -e '.content | contains("lit-divi-insert")' >/dev/null || fail "guide divi-shortform: $GUIDE"
|
||||||
|
echo "$INIT_RESPONSE" | grep -q 'tree_rev' || fail "server instructions lack the tree_rev rule"
|
||||||
|
pass "tools/list: Divi write tools direct, duplicate/replace-text indirect; guide and instructions"
|
||||||
|
|
||||||
|
# 2. Insert a whole section in short form with placeholders
|
||||||
|
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')
|
||||||
|
INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1",
|
||||||
|
nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [
|
||||||
|
[{type: "heading", text: "{{post:title}}", level: "h2"}],
|
||||||
|
[{type: "text", content: "<p>Hallo <strong>Welt</strong></p>"},
|
||||||
|
{type: "button", text: "Mehr erfahren", url: "javascript:alert(1)", label: "Neu – CTA"}]]}]}]}')")
|
||||||
|
[ "$(error_code "$INSERT")" = "validation_failed" ] || fail "javascript: URL accepted: $INSERT"
|
||||||
|
[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "refused insert wrote something"
|
||||||
|
INSERT=$(tool lit-divi-insert "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev, before: "s1",
|
||||||
|
nodes: [{type: "section", label: "Neu", background_color: "{{color:primary}}", rows: [{columns: "1_3,2_3", modules: [
|
||||||
|
[{type: "heading", text: "{{post:title}}", level: "h2"}],
|
||||||
|
[{type: "text", content: "<p>Hallo <strong>Welt</strong></p>"},
|
||||||
|
{type: "button", text: "Mehr erfahren", url: "/mehr", label: "Neu – CTA"}]]}]}]}')")
|
||||||
|
echo "$INSERT" | jq -e '.ok == true and .changed[0].id == "s1" and .revision_id != null and .log_id != null' >/dev/null \
|
||||||
|
|| fail "divi-insert: $INSERT"
|
||||||
|
TREE_REV=$(echo "$INSERT" | jq -r '.tree_rev')
|
||||||
|
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "insert changed the bytes of an untouched section"
|
||||||
|
SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}")
|
||||||
|
echo "$SUMMARY" | jq -e '.headings[] | select(.level == 2 and .text == "LIT Divi Schreiben")' >/dev/null \
|
||||||
|
|| fail "{{post:title}} not rendered: $SUMMARY"
|
||||||
|
pass "lit-divi-insert: javascript: URL refused, section with placeholders, untouched bytes kept, rendered"
|
||||||
|
|
||||||
|
# 3. Lock and all-or-nothing
|
||||||
|
STALE=$(tool lit-divi-update "{\"post_id\":$PAGE_ID,\"tree_rev\":\"000000000000\",\"changes\":[{\"id\":\"s0\",\"label\":\"x\"}]}")
|
||||||
|
[ "$(error_code "$STALE")" = "rev_mismatch" ] || fail "stale tree_rev accepted: $STALE"
|
||||||
|
BAD=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt"}}, {id: "s1.r0.c0.m0", attrs: {"title.decoration.fnt": {desktop: "x"}}}]}')")
|
||||||
|
[ "$(error_code "$BAD")" = "invalid_attr_path" ] || fail "invalid path accepted: $BAD"
|
||||||
|
[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "failed update wrote something"
|
||||||
|
pass "tree_rev lock and invalid_attr_path leave the page unchanged"
|
||||||
|
|
||||||
|
# 4. Update, move, delete
|
||||||
|
UPDATE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s1.r0.c1.m1", set: {text: "Jetzt anfragen"}, attrs: {"button.decoration.background": {desktop: {color: "{{color:secondary}}"}}}}]}')")
|
||||||
|
echo "$UPDATE" | jq -e '.ok == true and .changed[0].action == "updated"' >/dev/null || fail "divi-update: $UPDATE"
|
||||||
|
TREE_REV=$(echo "$UPDATE" | jq -r '.tree_rev')
|
||||||
|
MOVE=$(tool lit-divi-move "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1.r0.c1.m1\",\"parent\":\"s1.r0.c0\"}")
|
||||||
|
echo "$MOVE" | jq -e '.changed[0].id == "s1.r0.c0.m1"' >/dev/null || fail "divi-move: $MOVE"
|
||||||
|
TREE_REV=$(echo "$MOVE" | jq -r '.tree_rev')
|
||||||
|
ASK=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"]}")
|
||||||
|
[ "$(error_code "$ASK")" = "confirm_required" ] || fail "delete without confirm: $ASK"
|
||||||
|
DELETE=$(tool lit-divi-delete "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"ids\":[\"s1.r0.c0.m1\"],\"confirm\":true}")
|
||||||
|
echo "$DELETE" | jq -e '.changed[0].action == "deleted"' >/dev/null || fail "divi-delete: $DELETE"
|
||||||
|
TREE_REV=$(echo "$DELETE" | jq -r '.tree_rev')
|
||||||
|
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "update/move/delete changed an untouched section"
|
||||||
|
pass "lit-divi-update, lit-divi-move, lit-divi-delete"
|
||||||
|
|
||||||
|
# 5. Duplicate and replace text (indirect)
|
||||||
|
DUP=$(ability lit/divi-duplicate-node "{\"post_id\":$PAGE_ID,\"tree_rev\":\"$TREE_REV\",\"id\":\"s1\"}")
|
||||||
|
echo "$DUP" | jq -e '.changed[0].id == "s2" and (.changed[0].label | endswith("(Kopie)"))' >/dev/null || fail "duplicate: $DUP"
|
||||||
|
DRY=$(ability lit/divi-replace-text '{"search":"Köln","replace":"Bonn","limit":100}')
|
||||||
|
TARGET=$(echo "$DRY" | jq -c --argjson id "$PAGE_ID" '[.matches[] | select(.post_id == $id) | {post_id, tree_rev}]')
|
||||||
|
[ "$TARGET" != "[]" ] || fail "dry run found nothing: $DRY"
|
||||||
|
if content_of "$PAGE_ID" | grep -q 'Bonn'; then fail "dry run changed content"; fi
|
||||||
|
RUN=$(ability lit/divi-replace-text "$(jq -cn --argjson t "$TARGET" '{search: "Köln", replace: "Bonn", dry_run: false, confirm: true, targets: $t}')")
|
||||||
|
echo "$RUN" | jq -e '.results[0].ok == true and .results[0].count >= 1' >/dev/null || fail "replace-text: $RUN"
|
||||||
|
content_of "$PAGE_ID" | grep -q 'Bonn' || fail "replace-text wrote nothing"
|
||||||
|
pass "lit/divi-duplicate-node and lit/divi-replace-text (dry run, confirmed run)"
|
||||||
|
|
||||||
|
# 6. New Divi page in one call; first section on an empty Divi page (ruling S9)
|
||||||
|
CREATED=$(tool lit-content-create '{"post_type":"page","title":"LIT Divi Neu","divi":[{"type":"section","label":"Start","modules":[{"type":"heading","text":"Neu hier"},{"type":"text","content":"<p>Text</p>"}]}]}')
|
||||||
|
NEW_ID=$(echo "$CREATED" | jq -r '.changed[0].id // empty')
|
||||||
|
[ -n "$NEW_ID" ] || fail "content-create with divi: $CREATED"
|
||||||
|
CREATED_POSTS+=("$NEW_ID")
|
||||||
|
echo "$CREATED" | jq -e '.tree_rev != null' >/dev/null || fail "content-create lacks tree_rev: $CREATED"
|
||||||
|
[ "$(wp post meta get "$NEW_ID" _et_pb_use_divi_5)" = "on" ] || fail "Divi flag missing"
|
||||||
|
[ "$(wp post get "$NEW_ID" --field=post_status)" = "draft" ] || fail "new Divi page is not a draft"
|
||||||
|
tool lit-page-render-summary "{\"post_id\":$NEW_ID}" | jq -e '.headings[0].text == "Neu hier"' >/dev/null || fail "new page not rendered"
|
||||||
|
EMPTY_ID=$(create_page "" "LIT Divi Leer")
|
||||||
|
CREATED_POSTS+=("$EMPTY_ID")
|
||||||
|
EMPTY=$(tool lit-divi-outline "{\"post_id\":$EMPTY_ID}")
|
||||||
|
echo "$EMPTY" | jq -e '.hints[0] | contains("lit-divi-insert")' >/dev/null || fail "empty-page hint: $EMPTY"
|
||||||
|
FIRST=$(tool lit-divi-insert "$(jq -cn --argjson id "$EMPTY_ID" --arg rev "$(echo "$EMPTY" | jq -r '.tree_rev')" '{post_id: $id, tree_rev: $rev,
|
||||||
|
nodes: [{type: "section", label: "Start", modules: [{type: "heading", text: "Erster Abschnitt"}]}]}')")
|
||||||
|
echo "$FIRST" | jq -e '.changed[0].id == "s0"' >/dev/null || fail "insert into empty page: $FIRST"
|
||||||
|
content_of "$EMPTY_ID" | grep -q '^<!-- wp:divi/placeholder --><!-- wp:divi/section ' || fail "first section not wrapped in divi/placeholder"
|
||||||
|
pass "lit-content-create with Divi short form; first section of an empty page wrapped like the builder"
|
||||||
|
|
||||||
|
# 7. A user without unfiltered_html on the admin-authored page (rulings D7, S6): untouched bytes
|
||||||
|
# identical, nothing dangerous stored or rendered.
|
||||||
|
wp role create lit_smoke_editor "LIT Smoke Editor" --clone=editor >/dev/null
|
||||||
|
wp cap remove lit_smoke_editor unfiltered_html >/dev/null
|
||||||
|
wp option update lit_mcp_allowed_roles '["administrator","lit_smoke_editor"]' --format=json >/dev/null
|
||||||
|
wp user create "$EDITOR_LOGIN" lit-smoke-editor@example.invalid --role=lit_smoke_editor >/dev/null
|
||||||
|
[ "$(LIT_LOGIN="$EDITOR_LOGIN" wp eval 'echo user_can(get_user_by("login", getenv("LIT_LOGIN")), "unfiltered_html") ? "yes" : "no";')" = "no" ] \
|
||||||
|
|| fail "the editor must not have unfiltered_html"
|
||||||
|
connect "$EDITOR_LOGIN"
|
||||||
|
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')
|
||||||
|
|
||||||
|
# 7a. Rich text with markup: kses for the editor
|
||||||
|
EDIT=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s0.r0.c0.m1", set: {content: "<p>Neu vom Redakteur</p><script>alert(1)</script><img src=\"x\" onerror=\"alert(1)\"><a href=\"javascript:alert(1)\">Link</a>"}}]}')")
|
||||||
|
echo "$EDIT" | jq -e '.ok == true' >/dev/null || fail "editor update: $EDIT"
|
||||||
|
TREE_REV=$(echo "$EDIT" | jq -r '.tree_rev')
|
||||||
|
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "kses re-serialized untouched blocks of an editor save"
|
||||||
|
assert_clean "$PAGE_ID" "7a rich text"
|
||||||
|
|
||||||
|
# 7b. Whole-value $variable with markup in before: re-serialized with cleaned settings, or refused.
|
||||||
|
# before/after take the HTML wp_kses_post allows (data-* attributes included), so the data-probe
|
||||||
|
# marker is only used in text fields (7c/7d), where no attribute may ever appear.
|
||||||
|
VAR='$variable({"type":"content","value":{"name":"post_title","settings":{"before":"<script>alert(1)</script><img src=\"x\" onerror=\"alert(1)\"><a href=\"javascript:alert(1)\">x</a>","after":"<svg onload=\"alert(1)\"></svg>"}}})$'
|
||||||
|
WHOLE=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" --arg v "$VAR" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s0.r0.c0.m0", set: {text: $v}}, {id: "s0.r0.c0.m1", set: {content: $v}}]}')")
|
||||||
|
if echo "$WHOLE" | jq -e '.ok == true' >/dev/null; then
|
||||||
|
TREE_REV=$(echo "$WHOLE" | jq -r '.tree_rev')
|
||||||
|
else
|
||||||
|
[ "$(error_code "$WHOLE")" = "validation_failed" ] || fail "whole-value variable: $WHOLE"
|
||||||
|
fi
|
||||||
|
assert_clean "$PAGE_ID" "7b whole-value \$variable (${WHOLE:0:60})"
|
||||||
|
outcome() { echo "$1" | jq -r 'if .ok == true then "saved (cleaned)" else "refused: " + ((.__error // "") | (try fromjson catch {}) | (.code // "?")) end'; }
|
||||||
|
echo "info - 7b whole-value \$variable with markup in before/after: $(outcome "$WHOLE")"
|
||||||
|
|
||||||
|
# 7c. Embedded $variable inside rich text: refused, nothing written
|
||||||
|
PROBE_VAR='$variable({"type":"content","value":{"name":"post_title","settings":{"before":"\"><b data-probe=1>","after":""}}})$'
|
||||||
|
EMBEDDED=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" --arg v "<p>Hallo $PROBE_VAR Welt</p>" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s0.r0.c0.m1", set: {content: $v}}]}')")
|
||||||
|
[ "$(error_code "$EMBEDDED")" = "validation_failed" ] || fail "embedded variable accepted: $EMBEDDED"
|
||||||
|
[ "$(tool lit-divi-outline "{\"post_id\":$PAGE_ID}" | jq -r '.tree_rev')" = "$TREE_REV" ] || fail "refused embedded variable wrote something"
|
||||||
|
|
||||||
|
# 7d. Text field with tag-split payloads, and a javascript: background image
|
||||||
|
SPLIT=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s0.r0.c0.m0", set: {text: "<scr<script>ipt>alert(1)</scr</script>ipt> \"><<span data-probe=1>span data-probe=1>Titel"}},
|
||||||
|
{id: "s0.r0.c0.m2", set: {text: "<<b>b data-probe=1>Knopf</b>"}}]}')")
|
||||||
|
if echo "$SPLIT" | jq -e '.ok == true' >/dev/null; then
|
||||||
|
TREE_REV=$(echo "$SPLIT" | jq -r '.tree_rev')
|
||||||
|
else
|
||||||
|
[ "$(error_code "$SPLIT")" = "validation_failed" ] || fail "tag-split text: $SPLIT"
|
||||||
|
fi
|
||||||
|
BG=$(tool lit-divi-update "$(jq -cn --argjson id "$PAGE_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s0", attrs: {"module.decoration.background": {desktop: {image: {url: "javascript:alert(1)"}}}}}]}')")
|
||||||
|
if echo "$BG" | jq -e '.ok == true' >/dev/null; then
|
||||||
|
TREE_REV=$(echo "$BG" | jq -r '.tree_rev')
|
||||||
|
else
|
||||||
|
[ "$(error_code "$BG")" = "validation_failed" ] || [ "$(error_code "$BG")" = "invalid_attr_path" ] || fail "javascript: background: $BG"
|
||||||
|
fi
|
||||||
|
assert_clean "$PAGE_ID" "7d tag-split text / javascript: background"
|
||||||
|
echo "info - 7d tag-split text: $(outcome "$SPLIT"); javascript: background image: $(outcome "$BG")"
|
||||||
|
SUMMARY=$(tool lit-page-render-summary "{\"post_id\":$PAGE_ID}")
|
||||||
|
# The summary reports visible text (a probe shows up there only as harmless literal text, e.g. an
|
||||||
|
# entity-escaped "<script>" from sanitize_text_field); its link and image targets must be clean.
|
||||||
|
echo "$SUMMARY" | jq -e '[.links[].href, .images[].src] | map(select(. != null and test("^\\s*(javascript|data|vbscript)\\s*:"; "i"))) | length == 0' >/dev/null \
|
||||||
|
|| fail "render summary shows a dangerous link or image: $SUMMARY"
|
||||||
|
[ "$(keeps_leistungen "$PAGE_ID")" = "yes" ] || fail "editor saves changed an untouched section"
|
||||||
|
pass "user without unfiltered_html: untouched bytes identical; no script, handler, javascript: or data-probe stored or rendered"
|
||||||
|
|
||||||
|
# 8. Divi's own save filters (ruling S7). On a page with custom attributes or elementType Divi's
|
||||||
|
# AttributeSecurity/HtmlSecurity (wp_insert_post_data, every user, never removed) parse and
|
||||||
|
# re-serialize the WHOLE page with serialize_blocks() on every save.
|
||||||
|
# 8a: the page as Divi itself stored it (already in serialize_blocks() form): our write changes
|
||||||
|
# only the edited node, no warning.
|
||||||
|
# 8b: the same page in another valid byte form (empty blocks as <!-- wp:x --><!-- /wp:x -->,
|
||||||
|
# e.g. imported without Divi's filters): Divi normalizes untouched blocks too – the bytes
|
||||||
|
# change beyond the edit, the answer carries CHANGED_ON_SAVE, tree_rev is the stored state,
|
||||||
|
# custom fields survive (Divi 5.2.1 renders the custom attribute; the text module's wrapper
|
||||||
|
# ignores elementType), the page renders, and the next write is byte-stable again.
|
||||||
|
connect "$ADMIN_LOGIN"
|
||||||
|
FILTER_ID=$(create_page synthetic-divi-filters.html "LIT Divi Filter")
|
||||||
|
CREATED_POSTS+=("$FILTER_ID")
|
||||||
|
node_bytes() { # $1 = post ID, $2 = admin label -> the stored opener of that block
|
||||||
|
LIT_ID="$1" LIT_LABEL="$2" wp eval '
|
||||||
|
$c = (string) get_post((int) getenv("LIT_ID"))->post_content;
|
||||||
|
$needle = "\"value\":\"" . getenv("LIT_LABEL") . "\"";
|
||||||
|
$at = strpos($c, $needle);
|
||||||
|
$start = strrpos(substr($c, 0, $at), "<!-- wp:");
|
||||||
|
echo substr($c, $start, strpos($c, "-->", $at) + 3 - $start);'
|
||||||
|
}
|
||||||
|
ARTICLE_BEFORE=$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")
|
||||||
|
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}" | jq -r '.tree_rev')
|
||||||
|
CANONICAL=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s0.r0.c0.m2", set: {text: "Schreiben"}}]}')")
|
||||||
|
echo "$CANONICAL" | jq -e '.ok == true and (.warnings | any(contains("beim Speichern verändert")) | not)' >/dev/null \
|
||||||
|
|| fail "8a: write on a Divi-stored page: $CANONICAL"
|
||||||
|
[ "$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")" = "$ARTICLE_BEFORE" ] || fail "8a: untouched block changed"
|
||||||
|
pass "Divi save filters, page as Divi stored it: only the edited node changes, no warning"
|
||||||
|
|
||||||
|
LIT_ID="$FILTER_ID" LIT_FILE="$FIXTURES/synthetic-divi-filters.html" wp eval '
|
||||||
|
global $wpdb;
|
||||||
|
$id = (int) getenv("LIT_ID");
|
||||||
|
$wpdb->update($wpdb->posts, ["post_content" => (string) file_get_contents(getenv("LIT_FILE"))], ["ID" => $id]);
|
||||||
|
clean_post_cache($id);'
|
||||||
|
ARTICLE_BEFORE=$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")
|
||||||
|
TREE_REV=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}" | jq -r '.tree_rev')
|
||||||
|
FILTERED=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$TREE_REV" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s0.r0.c0.m1", set: {text: "Eigene Felder bleiben erhalten"}}]}')")
|
||||||
|
echo "$FILTERED" | jq -e '.ok == true' >/dev/null || fail "8b: update on a page with custom fields: $FILTERED"
|
||||||
|
echo "$FILTERED" | jq -e '.warnings | any(contains("beim Speichern verändert"))' >/dev/null \
|
||||||
|
|| fail "8b: CHANGED_ON_SAVE missing (Divi no longer normalizes – update the S7 documentation): $FILTERED"
|
||||||
|
[ "$(node_bytes "$FILTER_ID" "Eigene Felder – Artikel")" != "$ARTICLE_BEFORE" ] \
|
||||||
|
|| fail "8b: untouched block kept its bytes (Divi no longer normalizes – update the S7 documentation)"
|
||||||
|
OUTLINE=$(tool lit-divi-outline "{\"post_id\":$FILTER_ID}")
|
||||||
|
[ "$(echo "$OUTLINE" | jq -r '.tree_rev')" = "$(echo "$FILTERED" | jq -r '.tree_rev')" ] \
|
||||||
|
|| fail "8b: tree_rev of the answer is not the stored state: $FILTERED"
|
||||||
|
NODE=$(tool lit-divi-node-get "{\"post_id\":$FILTER_ID,\"id\":\"s0.r0.c0.m0\"}")
|
||||||
|
echo "$NODE" | jq -e 'tostring | contains("article") and contains("data-track")' >/dev/null \
|
||||||
|
|| fail "8b: elementType or custom attribute lost: $NODE"
|
||||||
|
RENDERED=$(LIT_ID="$FILTER_ID" wp --user="$ADMIN_LOGIN" eval \
|
||||||
|
'echo (new LIT\MCP\Integrations\WordPress\Render\PageRenderer())->render(get_post((int) getenv("LIT_ID")));')
|
||||||
|
echo "$RENDERED" | grep -q 'data-track="hero"' || fail "8b: custom attribute not rendered"
|
||||||
|
echo "$RENDERED" | grep -q 'Eigene Felder bleiben erhalten' || fail "8b: update not rendered"
|
||||||
|
AGAIN=$(tool lit-divi-update "$(jq -cn --argjson id "$FILTER_ID" --arg rev "$(echo "$OUTLINE" | jq -r '.tree_rev')" '{post_id: $id, tree_rev: $rev,
|
||||||
|
changes: [{id: "s0.r0.c0.m2", set: {text: "Nochmal"}}]}')")
|
||||||
|
echo "$AGAIN" | jq -e '.ok == true and (.warnings | any(contains("beim Speichern verändert")) | not)' >/dev/null \
|
||||||
|
|| fail "8b: follow-up write with the new tree_rev: $AGAIN"
|
||||||
|
pass "Divi save filters, other byte form: normalized with CHANGED_ON_SAVE, tree_rev = stored state, page valid, then stable"
|
||||||
|
|
||||||
|
echo "ALL CHECKS PASSED"
|
||||||
@@ -57,7 +57,9 @@ final class InstructionsBuilderTest extends TestCase
|
|||||||
$without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();
|
$without = (new InstructionsBuilder(new IntegrationRegistry($core)))->build();
|
||||||
|
|
||||||
self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with);
|
self::assertStringContainsString('lit-divi-outline und lit-divi-design-system lesen', $with);
|
||||||
|
self::assertStringContainsString('tree_rev', $with);
|
||||||
self::assertStringNotContainsString('lit-divi-outline und', $without);
|
self::assertStringNotContainsString('lit-divi-outline und', $without);
|
||||||
|
self::assertStringNotContainsString('tree_rev', $without);
|
||||||
self::assertLessThan(2000, strlen($with));
|
self::assertLessThan(2000, strlen($with));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,40 @@ final class ToolNamingTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Every guide topic named in a description, hint or the overview exists as docs/guides/<topic>.md. */
|
||||||
|
public function testReferencedGuideTopicsExist(): void
|
||||||
|
{
|
||||||
|
$root = dirname(__DIR__, 3);
|
||||||
|
$referenced = [];
|
||||||
|
foreach (self::texts() as $path => $text) {
|
||||||
|
if (str_ends_with($path, '.php')) {
|
||||||
|
preg_match_all('/topic \\\\?"([a-z0-9-]+)\\\\?"/', $text, $matches);
|
||||||
|
$referenced = array_merge($referenced, $matches[1]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self::assertContains('divi-shortform', $referenced, 'lit/divi-insert and lit/content-create name divi-shortform');
|
||||||
|
foreach (array_unique($referenced) as $topic) {
|
||||||
|
self::assertFileExists($root . '/docs/guides/' . $topic . '.md', 'guide topic "' . $topic . '"');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOverviewListsExactlyTheOtherGuideTopics(): void
|
||||||
|
{
|
||||||
|
$root = dirname(__DIR__, 3);
|
||||||
|
$overview = (string) file_get_contents($root . '/docs/guides/overview.md');
|
||||||
|
preg_match('/Weitere Themen:(.*?)\n\n/s', $overview, $section);
|
||||||
|
preg_match_all('/`([a-z0-9-]+)`/', $section[1] ?? '', $listed);
|
||||||
|
$listedTopics = array_values(array_diff($listed[1], ['topic', 'lit-guide']));
|
||||||
|
sort($listedTopics);
|
||||||
|
|
||||||
|
$files = array_map(static fn (string $f): string => basename($f, '.md'), glob($root . '/docs/guides/*.md') ?: []);
|
||||||
|
$files = array_values(array_diff($files, ['overview']));
|
||||||
|
sort($files);
|
||||||
|
|
||||||
|
self::assertSame($files, $listedTopics);
|
||||||
|
}
|
||||||
|
|
||||||
public function testOverviewListsExactlyTheDirectTools(): void
|
public function testOverviewListsExactlyTheDirectTools(): void
|
||||||
{
|
{
|
||||||
$overview = (string) file_get_contents(dirname(__DIR__, 3) . '/docs/guides/overview.md');
|
$overview = (string) file_get_contents(dirname(__DIR__, 3) . '/docs/guides/overview.md');
|
||||||
|
|||||||
@@ -84,11 +84,12 @@ final class DiviReadAbilitiesTest extends TestCase
|
|||||||
{
|
{
|
||||||
DiviPageLoaderTest::givenPost('');
|
DiviPageLoaderTest::givenPost('');
|
||||||
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
|
$ability = new DiviOutlineAbility(new DiviPageLoader(DiviPageLoaderTest::schema()), $this->design());
|
||||||
self::assertSame(['Die Seite hat noch keinen Divi-Inhalt.'], $ability->execute(['post_id' => 7])['hints']);
|
self::assertStringContainsString('lit-divi-insert', $ability->execute(['post_id' => 7])['hints'][0]);
|
||||||
|
|
||||||
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html'));
|
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-mixed.html'));
|
||||||
$hints = $ability->execute(['post_id' => 7])['hints'];
|
$hints = $ability->execute(['post_id' => 7])['hints'];
|
||||||
self::assertStringContainsString('nur lesbar', $hints[0]);
|
self::assertStringContainsString('nur lesbar', $hints[0]);
|
||||||
|
self::assertStringContainsString('nur als Ganzes', $hints[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testNodeGetReturnsFullFormShortformAndPresets(): void
|
public function testNodeGetReturnsFullFormShortformAndPresets(): void
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ final class DiviCatalogAbilityTest extends TestCase
|
|||||||
));
|
));
|
||||||
self::assertFalse($header[0]['shortform']);
|
self::assertFalse($header[0]['shortform']);
|
||||||
self::assertSame(DiviCatalogAbility::CODE_NOTE, $result['notes'][0]);
|
self::assertSame(DiviCatalogAbility::CODE_NOTE, $result['notes'][0]);
|
||||||
|
self::assertStringContainsString('"divi-shortform"', $result['notes'][1]);
|
||||||
|
self::assertStringContainsString('"divi-attrs"', $result['notes'][1]);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function testDetailOfOneModule(): void
|
public function testDetailOfOneModule(): void
|
||||||
|
|||||||
@@ -97,6 +97,10 @@ final class PostPresenterTest extends TestCase
|
|||||||
{
|
{
|
||||||
self::assertStringContainsString('lit-divi-outline', PostPresenter::diviHint(true));
|
self::assertStringContainsString('lit-divi-outline', PostPresenter::diviHint(true));
|
||||||
self::assertStringContainsString('lit-content-update', PostPresenter::diviHint(true));
|
self::assertStringContainsString('lit-content-update', PostPresenter::diviHint(true));
|
||||||
|
foreach (['lit-divi-insert', 'lit-divi-update', 'lit-divi-move', 'lit-divi-delete'] as $tool) {
|
||||||
|
self::assertStringContainsString($tool, PostPresenter::diviHint(true));
|
||||||
|
}
|
||||||
|
self::assertStringNotContainsString('vorerst im Divi-Builder', PostPresenter::diviHint(true));
|
||||||
self::assertStringNotContainsString('lit-divi', PostPresenter::diviHint(false));
|
self::assertStringNotContainsString('lit-divi', PostPresenter::diviHint(false));
|
||||||
self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, PostPresenter::diviHint());
|
self::assertStringContainsString(PostPresenter::DIVI_INACTIVE_NOTE, PostPresenter::diviHint());
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -3,7 +3,7 @@
|
|||||||
/**
|
/**
|
||||||
* Plugin Name: WP-LIT-MCP
|
* Plugin Name: WP-LIT-MCP
|
||||||
* Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
|
* Description: MCP-Server für WordPress, Divi 5 und Pods – mit OAuth für claude.ai.
|
||||||
* Version: 0.2.0
|
* Version: 0.3.0
|
||||||
* Requires at least: 6.9
|
* Requires at least: 6.9
|
||||||
* Requires PHP: 8.1
|
* Requires PHP: 8.1
|
||||||
* Tested up to: 7.0
|
* Tested up to: 7.0
|
||||||
|
|||||||
Reference in New Issue
Block a user