feat: Divi save pipeline – tree_rev lock, kses-safe save, revision, flags, cache, ToolResult
DiviSaver/DiviWriter/DiviComposition/DiviChange, WpValueSanitizer (S4), WpMediaResolver, DiviFlags (S11), DynamicContentMap from Divi's live registry (S22), S31 wiring, S35 control strip at apply() entry. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -58,6 +58,8 @@ final class AttrWriter
|
|||||||
*/
|
*/
|
||||||
public function apply(string $module, array $attrs, array $changes, string $field): array
|
public function apply(string $module, array $attrs, array $changes, string $field): array
|
||||||
{
|
{
|
||||||
|
// Ruling S35: controls go before placeholder resolution ("{\0{post:title}}" is a placeholder).
|
||||||
|
$changes = VariableRef::withoutControlsDeep($changes);
|
||||||
$errors = [];
|
$errors = [];
|
||||||
$invalid = [];
|
$invalid = [];
|
||||||
foreach ($changes as $path => $map) {
|
foreach ($changes as $path => $map) {
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ final class ShortformWriter
|
|||||||
*/
|
*/
|
||||||
public function apply(string $type, array $attrs, array $values, string $field): array
|
public function apply(string $type, array $attrs, array $values, string $field): array
|
||||||
{
|
{
|
||||||
|
// Ruling S35: controls go before placeholder resolution and trim ("{\0{post:title}}").
|
||||||
|
$values = VariableRef::withoutControlsDeep($values);
|
||||||
$fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []);
|
$fields = $this->modules === null ? ShortformMap::fields($type) : ($this->modules[$type] ?? []);
|
||||||
$errors = [];
|
$errors = [];
|
||||||
foreach ($values as $name => $value) {
|
foreach ($values as $name => $value) {
|
||||||
|
|||||||
@@ -146,6 +146,21 @@ final class VariableRef
|
|||||||
return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
|
return (string) preg_replace('/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* withoutControls() on every string inside $value (keys stay: odd keys are rejected). Ruling
|
||||||
|
* S35: AttrWriter::apply() and ShortformWriter::apply() run it once on their raw input, before
|
||||||
|
* placeholder resolution and trim – so "{\0{post:title}}" is the placeholder, checked as one,
|
||||||
|
* and never literal text that slipped past the placeholder check.
|
||||||
|
*/
|
||||||
|
public static function withoutControlsDeep(mixed $value): mixed
|
||||||
|
{
|
||||||
|
if (is_string($value)) {
|
||||||
|
return self::withoutControls($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($value) ? array_map([self::class, 'withoutControlsDeep'], $value) : $value;
|
||||||
|
}
|
||||||
|
|
||||||
/** @param array<string, mixed>|stdClass $settings */
|
/** @param array<string, mixed>|stdClass $settings */
|
||||||
public static function serialize(string $type, string $name, array|stdClass $settings): string
|
public static function serialize(string $type, string $name, array|stdClass $settings): string
|
||||||
{
|
{
|
||||||
@@ -383,7 +398,8 @@ final class VariableRef
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …)
|
// Ruling S29: meta key settings (post_meta_key's meta_key/select_meta_key, loop_meta_key …)
|
||||||
// name a meta key – never a protected one.
|
// name a meta key – never a protected one. Matched by the key SUFFIX "meta_key" (S29/S30):
|
||||||
|
// re-check Divi's dynamic content options for new meta key settings on every Divi version bump.
|
||||||
if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) {
|
if (str_ends_with($key, 'meta_key') && $clean !== '' && ! self::isMetaKey((string) $clean)) {
|
||||||
throw new InvalidArgumentException(sprintf(
|
throw new InvalidArgumentException(sprintf(
|
||||||
'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", '
|
'Einstellung "%s" im Divi-Verweis: ungültiger Meta-Schlüssel (Buchstaben, Ziffern, "_" und "-", '
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* What a Divi write did to the tree: summary, changed nodes (for ToolResult::changed – IDs of
|
||||||
|
* surviving nodes are resolved after the change), touched nodes (for the quality warnings)
|
||||||
|
* and warnings of its own.
|
||||||
|
*/
|
||||||
|
final class DiviChange
|
||||||
|
{
|
||||||
|
/** @var list<array{block: ?Block, id: ?string, type: string, action: string, label: string}> */
|
||||||
|
private array $changed = [];
|
||||||
|
|
||||||
|
/** @var list<Block> */
|
||||||
|
private array $touched = [];
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $warnings = [];
|
||||||
|
|
||||||
|
public function __construct(public readonly string $summary)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A node that still exists after the change: "inserted", "updated", "moved" or "duplicated".
|
||||||
|
* Every action but "moved" counts for the quality warnings.
|
||||||
|
*/
|
||||||
|
public function changed(Block $block, string $action): self
|
||||||
|
{
|
||||||
|
$this->changed[] = [
|
||||||
|
'block' => $block,
|
||||||
|
'id' => null,
|
||||||
|
'type' => $block->name(),
|
||||||
|
'action' => $action,
|
||||||
|
'label' => NodeSummary::label($block),
|
||||||
|
];
|
||||||
|
if ($action !== 'moved') {
|
||||||
|
$this->touched[] = $block;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A node that is gone; $idBefore is its ID before the change. */
|
||||||
|
public function removed(string $idBefore, Block $block): self
|
||||||
|
{
|
||||||
|
$this->changed[] = [
|
||||||
|
'block' => null,
|
||||||
|
'id' => $idBefore,
|
||||||
|
'type' => $block->name(),
|
||||||
|
'action' => 'deleted',
|
||||||
|
'label' => NodeSummary::label($block),
|
||||||
|
];
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warning(string $warning): self
|
||||||
|
{
|
||||||
|
$this->warnings[] = $warning;
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<array{block: ?Block, id: ?string, type: string, action: string, label: string}> */
|
||||||
|
public function entries(): array
|
||||||
|
{
|
||||||
|
return $this->changed;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<Block> */
|
||||||
|
public function touched(): array
|
||||||
|
{
|
||||||
|
return $this->touched;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return list<string> */
|
||||||
|
public function warnings(): array
|
||||||
|
{
|
||||||
|
return $this->warnings;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\Composer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\StructureRules;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\Warnings;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wires the pure Compose classes with the live catalog, design system, Divi's dynamic content
|
||||||
|
* registry and the WordPress sanitizer/media lookup – once per request and entry. Ruling S31:
|
||||||
|
* ONE Placeholders instance (carrying the live dynamic content map of the entry, S22) is shared
|
||||||
|
* by AttrWriter and ShortformWriter, and the same AttrWriter serves ShortformWriter and Composer.
|
||||||
|
*
|
||||||
|
* forPost() binds the composition to the entry being written (DiviWriter::composition() does it
|
||||||
|
* for the abilities); unbound, VariableRef's default map applies. Not final so abilities can be
|
||||||
|
* tested with a mock.
|
||||||
|
*/
|
||||||
|
class DiviComposition
|
||||||
|
{
|
||||||
|
private ?int $postId = null;
|
||||||
|
private ?Placeholders $placeholders = null;
|
||||||
|
private ?AttrWriter $attrWriter = null;
|
||||||
|
private ?Composer $composer = null;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private DiviModules $modules,
|
||||||
|
private DesignSystemReader $design,
|
||||||
|
private ValueSanitizer $sanitizer = new WpValueSanitizer(),
|
||||||
|
private MediaResolver $media = new WpMediaResolver(),
|
||||||
|
private DynamicContentMap $dynamicContent = new DynamicContentMap()
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A fresh composition for entry $postId (own Placeholders with that entry's live map). */
|
||||||
|
public function forPost(int $postId): static
|
||||||
|
{
|
||||||
|
$bound = clone $this;
|
||||||
|
$bound->postId = $postId;
|
||||||
|
$bound->placeholders = null;
|
||||||
|
$bound->attrWriter = null;
|
||||||
|
$bound->composer = null;
|
||||||
|
|
||||||
|
return $bound;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The one Placeholders instance of this composition (ruling S31). */
|
||||||
|
public function placeholders(): Placeholders
|
||||||
|
{
|
||||||
|
return $this->placeholders ??= new Placeholders(
|
||||||
|
$this->design->read(),
|
||||||
|
$this->postId === null ? null : $this->dynamicContent->forPost($this->postId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function attrWriter(): AttrWriter
|
||||||
|
{
|
||||||
|
return $this->attrWriter ??= new AttrWriter($this->modules->catalog(), $this->placeholders(), $this->sanitizer);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function composer(): Composer
|
||||||
|
{
|
||||||
|
return $this->composer ??= new Composer(
|
||||||
|
$this->modules->catalog(),
|
||||||
|
$this->rules(),
|
||||||
|
new ShortformWriter($this->placeholders(), $this->media, $this->attrWriter()),
|
||||||
|
$this->attrWriter(),
|
||||||
|
$this->design->read(),
|
||||||
|
$this->modules->builderVersion()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function rules(): StructureRules
|
||||||
|
{
|
||||||
|
return new StructureRules($this->modules->catalog());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function editor(): TreeEditor
|
||||||
|
{
|
||||||
|
return new TreeEditor($this->rules());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function warnings(): Warnings
|
||||||
|
{
|
||||||
|
return new Warnings($this->design->read());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Marks an entry as a Divi 5 builder layout, like the Visual Builder's own save does
|
||||||
|
* (Divi 5.2.1 SyncToServerController.php:382-384: _et_pb_use_divi_5 = "on", legacy
|
||||||
|
* _et_pb_use_builder = "on"). Ruling S11: update_post_meta() returns false for an unchanged value too, so success is
|
||||||
|
* checked by reading the flags back. Used by DiviSaver (inside FollowUp) and the Divi content
|
||||||
|
* creation.
|
||||||
|
*/
|
||||||
|
final class DiviFlags
|
||||||
|
{
|
||||||
|
public const FLAGS = ['_et_pb_use_divi_5' => 'on', '_et_pb_use_builder' => 'on'];
|
||||||
|
|
||||||
|
/** @throws RuntimeException When a flag does not read back as set. */
|
||||||
|
public static function set(int $postId): void
|
||||||
|
{
|
||||||
|
foreach (self::FLAGS as $key => $value) {
|
||||||
|
update_post_meta($postId, $key, $value);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (self::FLAGS as $key => $value) {
|
||||||
|
if (get_post_meta($postId, $key, true) !== $value) {
|
||||||
|
throw new RuntimeException(sprintf('Divi flag %s of post %d is not "%s".', $key, $postId, $value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\FollowUp;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostGuard;
|
||||||
|
use LIT\MCP\Integrations\WordPress\RevisionPoint;
|
||||||
|
use RuntimeException;
|
||||||
|
use Throwable;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save pipeline of spec §5.6, steps 3–5: restore point, wp_update_post() with wp_slash() (the
|
||||||
|
* status stays), Divi 5 flags (DiviFlags), Divi's static CSS cache. A failure after saving is
|
||||||
|
* only a warning (FollowUp); a filter that changed the stored content is reported as well.
|
||||||
|
*
|
||||||
|
* kses (spec D7): for users without unfiltered_html WordPress' kses would run
|
||||||
|
* filter_block_content(), which re-parses and re-serializes EVERY block – untouched content
|
||||||
|
* would no longer be byte-identical (spec §1.3, §5.2) and global modules could break. kses'
|
||||||
|
* filters (kses_remove_filters()) are therefore switched off for exactly this call and restored
|
||||||
|
* in finally (kses_init_filters()) – only when they were active. Every value the Divi tools
|
||||||
|
* write was sanitized and every $variable(…)$ re-serialized by the plugin before (AttrWriter,
|
||||||
|
* VariableRef, WpValueSanitizer).
|
||||||
|
*
|
||||||
|
* Divi's own save filters are NOT kses and are never removed (ruling S7, defense in depth):
|
||||||
|
* Security::initialize() (Divi 5.2.1 Security.php:74-81) registers on wp_insert_post_data
|
||||||
|
* AttributeSecurity (priority 5, always), HtmlSecurity (priority 6, by capability) and
|
||||||
|
* DynamicContentFixes (priority 10: strips enable_html for users without unfiltered_html). The first two re-serialize
|
||||||
|
* the whole page when it holds custom attributes or htmlBefore/htmlAfter/elementType, so on such
|
||||||
|
* pages the bytes change for every user – reported as CHANGED_ON_SAVE. Finding R2 / rulings
|
||||||
|
* S5, S7: docs/superpowers/notes/2026-09-26-etappe-2-entscheidungen.md.
|
||||||
|
*
|
||||||
|
* If saving throws, the previous content is written back (WriteFailed: rolled back or not).
|
||||||
|
* Not final so abilities can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class DiviSaver
|
||||||
|
{
|
||||||
|
public const REASON = 'Die Divi-Seite konnte nicht gespeichert werden.';
|
||||||
|
|
||||||
|
public const CHANGED_ON_SAVE = 'WordPress oder ein Plugin hat den Inhalt beim Speichern verändert; tree_rev '
|
||||||
|
. 'beschreibt den gespeicherten Stand – vor weiteren Änderungen lit-divi-outline lesen.';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{revision_id: ?int, warnings: list<string>}
|
||||||
|
* @throws WriteFailed
|
||||||
|
*/
|
||||||
|
public function save(WP_Post $post, string $content): array
|
||||||
|
{
|
||||||
|
$postId = (int) $post->ID;
|
||||||
|
$revisionId = RevisionPoint::capture($postId);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$result = $this->update($postId, $content);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$this->restore($postId, (string) $post->post_content, $revisionId, $e);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($result instanceof WP_Error || $result === 0) {
|
||||||
|
$message = $result instanceof WP_Error ? $result->get_error_message() : 'returned 0';
|
||||||
|
throw new WriteFailed(
|
||||||
|
self::REASON,
|
||||||
|
true,
|
||||||
|
$postId,
|
||||||
|
$revisionId,
|
||||||
|
null,
|
||||||
|
sprintf('wp_update_post(%d) refused: %s', $postId, $message),
|
||||||
|
rolledBackState: self::keeping($revisionId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$warnings = [];
|
||||||
|
FollowUp::run(
|
||||||
|
static fn () => DiviFlags::set($postId),
|
||||||
|
'divi builder flags',
|
||||||
|
'Die Divi-Kennzeichen der Seite konnten nicht gesetzt werden; der Inhalt ist gespeichert – die Seite '
|
||||||
|
. 'einmal im Divi-Builder öffnen und speichern.',
|
||||||
|
$warnings
|
||||||
|
);
|
||||||
|
FollowUp::run(
|
||||||
|
static function () use ($postId): void {
|
||||||
|
if (class_exists('ET_Core_PageResource')) {
|
||||||
|
\ET_Core_PageResource::remove_static_resources($postId, 'all', true);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'divi static css',
|
||||||
|
'Der CSS-Cache von Divi konnte nicht geleert werden; die Änderung ist gespeichert, erscheint aber evtl. '
|
||||||
|
. 'erst nach „Statische CSS-Dateien leeren“ (Divi → Theme-Optionen → Builder).',
|
||||||
|
$warnings
|
||||||
|
);
|
||||||
|
|
||||||
|
$stored = get_post($postId);
|
||||||
|
if ($stored instanceof WP_Post && (string) $stored->post_content !== $content) {
|
||||||
|
$warnings[] = self::CHANGED_ON_SAVE;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['revision_id' => $revisionId, 'warnings' => $warnings];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** wp_update_post() with kses' filters off for users without unfiltered_html (D7). */
|
||||||
|
private function update(int $postId, string $content): int|WP_Error
|
||||||
|
{
|
||||||
|
$kses = ! current_user_can('unfiltered_html')
|
||||||
|
&& has_filter('content_save_pre', 'wp_filter_post_kses') !== false;
|
||||||
|
if ($kses) {
|
||||||
|
kses_remove_filters();
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return wp_update_post(wp_slash(['ID' => $postId, 'post_content' => $content]), true);
|
||||||
|
} finally {
|
||||||
|
if ($kses) {
|
||||||
|
kses_init_filters();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @throws WriteFailed Always. */
|
||||||
|
private function restore(int $postId, string $before, ?int $revisionId, Throwable $cause): never
|
||||||
|
{
|
||||||
|
$detail = sprintf('Saving Divi content of post %d failed: %s', $postId, $cause->getMessage());
|
||||||
|
try {
|
||||||
|
$current = get_post($postId);
|
||||||
|
if ($current instanceof WP_Post && PostGuard::missedSchedule($current)) {
|
||||||
|
throw new RuntimeException('restoring would publish a missed schedule');
|
||||||
|
}
|
||||||
|
|
||||||
|
$restored = $this->update($postId, $before);
|
||||||
|
if ($restored instanceof WP_Error || $restored === 0) {
|
||||||
|
throw new RuntimeException('previous content could not be written back');
|
||||||
|
}
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$detail .= '; rollback: ' . $e->getMessage();
|
||||||
|
throw new WriteFailed(self::REASON, false, $postId, $revisionId, $cause, $detail);
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new WriteFailed(
|
||||||
|
self::REASON,
|
||||||
|
true,
|
||||||
|
$postId,
|
||||||
|
$revisionId,
|
||||||
|
$cause,
|
||||||
|
$detail,
|
||||||
|
rolledBackState: self::keeping($revisionId)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** write_failed must not claim "nothing was saved" while the restore-point revision stays. */
|
||||||
|
private static function keeping(?int $revisionId): ?string
|
||||||
|
{
|
||||||
|
return $revisionId === null ? null : Errors::notSavedKeeping(sprintf('Revision %d', $revisionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Abilities\Errors;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Abilities\ToolResult;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeSummary;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Outline;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Serializer;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\WordPress\PostChangeResult;
|
||||||
|
use WP_Error;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The shared frame of every Divi write (spec §5.6): load with rights and the tree_rev lock
|
||||||
|
* (ruling P16; a mismatch is rev_mismatch and nothing is written), let the ability change the
|
||||||
|
* tree – only when its whole input validated (all or nothing) –, serialize, save via DiviSaver,
|
||||||
|
* then lit_mcp_after_save, change log and the ToolResult with changed nodes, affected outline,
|
||||||
|
* quality warnings (spec §5.5), revision_id, the new rev and tree_rev. Not final so abilities
|
||||||
|
* can be tested with a mock.
|
||||||
|
*/
|
||||||
|
class DiviWriter
|
||||||
|
{
|
||||||
|
public const OUTLINE_TRUNCATED = 'Die Übersicht der geänderten Abschnitte ist gekürzt; vollständig mit '
|
||||||
|
. 'lit-divi-outline lesen.';
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private DiviPageLoader $loader,
|
||||||
|
private DiviSaver $saver,
|
||||||
|
private ChangeLog $log,
|
||||||
|
private DesignSystemReader $design,
|
||||||
|
private DiviComposition $composition
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<string, mixed>> post_id and tree_rev of every Divi write. */
|
||||||
|
public static function inputProperties(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'post_id' => ['type' => 'integer', 'minimum' => 1],
|
||||||
|
'tree_rev' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'tree_rev aus lit-divi-outline bzw. der letzten Divi-Änderung (Pflicht).',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<string, mixed>> Target position of insert and move. */
|
||||||
|
public static function positionProperties(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'parent' => [
|
||||||
|
'type' => 'string',
|
||||||
|
'description' => 'Pfad-ID des Elternknotens (z. B. Spalte "s0.r0.c1"); ohne parent/before/after: '
|
||||||
|
. 'Ende der Seite.',
|
||||||
|
],
|
||||||
|
'before' => ['type' => 'string', 'description' => 'Pfad-ID: vor diesem Knoten einfügen.'],
|
||||||
|
'after' => ['type' => 'string', 'description' => 'Pfad-ID: nach diesem Knoten einfügen.'],
|
||||||
|
'index' => ['type' => 'integer', 'minimum' => 0, 'description' => 'Position unter parent (0 = zuerst).'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The page to change, after rights, type, layout and the tree_rev lock were checked.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
*/
|
||||||
|
public function page(array $input): DiviPage|WP_Error
|
||||||
|
{
|
||||||
|
$page = $this->loader->load($input['post_id'] ?? null, true);
|
||||||
|
if ($page instanceof WP_Error) {
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
$problem = $this->loader->writable($page);
|
||||||
|
if ($problem !== null) {
|
||||||
|
return $problem;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! is_string($input['tree_rev'] ?? null) || $input['tree_rev'] !== $page->treeRev()) {
|
||||||
|
return Errors::revMismatch(
|
||||||
|
'Die Divi-Seite wurde seit dem letzten Lesen geändert (tree_rev passt nicht); nichts wurde '
|
||||||
|
. 'geschrieben.',
|
||||||
|
DiviPageLoader::OUTLINE_HINT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $page;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The Compose classes for $page: bound to its entry, so placeholders and $variable(…)$
|
||||||
|
* references are checked against Divi's dynamic content options for it (ruling S22).
|
||||||
|
*/
|
||||||
|
public function composition(DiviPage $page): DiviComposition
|
||||||
|
{
|
||||||
|
return $this->composition->forPost((int) $page->post->ID);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Section label around $block, for automatic admin labels ("" when none). */
|
||||||
|
public static function sectionLabel(NodeIndex $index, ?Block $block): string
|
||||||
|
{
|
||||||
|
for ($node = $block; $node !== null; $node = self::parentBlock($index, $node)) {
|
||||||
|
if ($node->name() === 'divi/section') {
|
||||||
|
return NodeSummary::label($node);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Saves the changed tree of $page and returns the ToolResult array. Call it only when the
|
||||||
|
* whole input validated – every error before means nothing is written.
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $input
|
||||||
|
* @return array<string, mixed>
|
||||||
|
* @throws WriteFailed
|
||||||
|
*/
|
||||||
|
public function save(string $ability, DiviPage $page, DiviChange $change, array $input): array
|
||||||
|
{
|
||||||
|
$post = $page->post;
|
||||||
|
$content = Serializer::document($page->document);
|
||||||
|
if ($content === (string) $post->post_content) {
|
||||||
|
return ToolResult::ok('Keine Änderung – die Seite ist bereits so.')
|
||||||
|
->rev(Rev::ofPost($post))
|
||||||
|
->with('tree_rev', $page->treeRev())
|
||||||
|
->warnings($change->warnings())
|
||||||
|
->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
$index = NodeIndex::build($page->document);
|
||||||
|
$quality = $this->composition->warnings()->collect($index, $change->touched());
|
||||||
|
$warnings = array_merge($change->warnings(), $quality);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$saved = $this->saver->save($post, $content);
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
if (! $e->rolledBack) {
|
||||||
|
$this->log->recordPartial(
|
||||||
|
$ability,
|
||||||
|
(string) $post->post_type,
|
||||||
|
(string) $post->ID,
|
||||||
|
sprintf('Divi "%s": Änderung nur teilweise zurückgenommen', $post->post_title),
|
||||||
|
$e->revisionId
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
$outline = $this->outline($index, $change);
|
||||||
|
if ($outline['truncated']) {
|
||||||
|
$warnings[] = self::OUTLINE_TRUNCATED;
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = (new PostChangeResult(
|
||||||
|
$ability,
|
||||||
|
$this->log,
|
||||||
|
(int) $post->ID,
|
||||||
|
'die Änderung',
|
||||||
|
array_merge($warnings, $saved['warnings'])
|
||||||
|
))->afterSave($input);
|
||||||
|
$updated = $result->reread($post) ?? $post;
|
||||||
|
$summary = sprintf('Divi "%s": %s', $post->post_title, $change->summary);
|
||||||
|
$result->log((string) $post->post_type, $summary, $saved['revision_id']);
|
||||||
|
|
||||||
|
$status = sprintf('%s (Status unverändert: %s).', $change->summary, $updated->post_status);
|
||||||
|
$tool = $result->result($status, $updated)
|
||||||
|
->revisionId($saved['revision_id'])
|
||||||
|
->outline($outline['text'])
|
||||||
|
->with('tree_rev', Rev::of((string) $updated->post_content))
|
||||||
|
->urls((string) get_preview_post_link($updated), $page->editUrl());
|
||||||
|
foreach ($change->entries() as $entry) {
|
||||||
|
$id = $entry['id'] ?? ($entry['block'] !== null ? $index->idOf($entry['block']) : null);
|
||||||
|
$tool->changed($id ?? '?', $entry['type'], $entry['action'], $entry['label']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tool->toArray();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Outline of the sections that contain changed nodes (breadth limit of ruling S18).
|
||||||
|
*
|
||||||
|
* @return array{text: ?string, truncated: bool}
|
||||||
|
*/
|
||||||
|
private function outline(NodeIndex $index, DiviChange $change): array
|
||||||
|
{
|
||||||
|
$sections = [];
|
||||||
|
foreach ($change->entries() as $entry) {
|
||||||
|
$id = $entry['block'] !== null ? $index->idOf($entry['block']) : null;
|
||||||
|
if ($id !== null) {
|
||||||
|
$sections[explode('.', $id)[0]] = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($sections === []) {
|
||||||
|
return ['text' => null, 'truncated' => false];
|
||||||
|
}
|
||||||
|
|
||||||
|
$outline = new Outline($this->design->read()->presetNames());
|
||||||
|
$texts = [];
|
||||||
|
$truncated = false;
|
||||||
|
foreach (array_keys($sections) as $sectionId) {
|
||||||
|
$texts[] = $outline->text($outline->rows($index, (string) $sectionId));
|
||||||
|
$truncated = $truncated || $outline->wasTruncated();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['text' => implode("\n", $texts), 'truncated' => $truncated];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function parentBlock(NodeIndex $index, Block $block): ?Block
|
||||||
|
{
|
||||||
|
$parent = $index->parentOf($block);
|
||||||
|
|
||||||
|
return $parent instanceof Block && $parent->name() !== 'divi/placeholder' ? $parent : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Closure;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
|
use Throwable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VariableRef's dynamic content allow map (ruling S22) from Divi's live registry
|
||||||
|
* DynamicContentOptions::get_options($postId, 'edit') – the options the Visual Builder offers
|
||||||
|
* for this entry (filter divi_module_dynamic_content_options; the list depends on the post:
|
||||||
|
* post_categories/post_tags only for types with those taxonomies). Shape:
|
||||||
|
* option name => [setting key => field type], e.g. post_date => [before => richtext, …,
|
||||||
|
* date_format => select]. VariableRef routes each setting by that type and keeps its fixed
|
||||||
|
* rules whatever the map says (enable_html refused, meta keys S29/S30, no markers S28).
|
||||||
|
*
|
||||||
|
* - loop_* options never enter the map: Divi offers them only inside loop-enabled modules,
|
||||||
|
* which the tools do not track – VariableRef refuses them ("nur innerhalb von Divi-Schleifen").
|
||||||
|
* - The map REPLACES VariableRef's default, so every Placeholders::POST option is added when
|
||||||
|
* the registry lacks it, with the settings the placeholders write (VariableRef's default).
|
||||||
|
* - Registry unavailable (no Divi, empty, error): null – VariableRef's default map applies.
|
||||||
|
*
|
||||||
|
* Not final so abilities can be tested with a fake registry.
|
||||||
|
*/
|
||||||
|
class DynamicContentMap
|
||||||
|
{
|
||||||
|
private const REGISTRY = 'ET\Builder\Packages\Module\Layout\Components\DynamicContent\DynamicContentOptions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param (Closure(int): mixed)|null $registry Registry lookup by post ID (tests); null = Divi's.
|
||||||
|
*/
|
||||||
|
public function __construct(private ?Closure $registry = null)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @return array<string, array<string, string>>|null Null = use VariableRef's default map. */
|
||||||
|
public function forPost(int $postId): ?array
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId);
|
||||||
|
} catch (Throwable $e) {
|
||||||
|
$message = sprintf('[wp-lit-mcp] divi dynamic content options of post %d failed: %s', $postId, $e);
|
||||||
|
error_log($message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return is_array($options) && $options !== [] ? self::fromOptions($options) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<array-key, mixed> $options get_options() result: name => [fields => [key => [type => …]]].
|
||||||
|
* @return array<string, array<string, string>>
|
||||||
|
*/
|
||||||
|
public static function fromOptions(array $options): array
|
||||||
|
{
|
||||||
|
$map = [];
|
||||||
|
foreach ($options as $name => $option) {
|
||||||
|
if (! is_string($name) || $name === '' || str_starts_with($name, 'loop_') || ! is_array($option)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$fields = [];
|
||||||
|
foreach (is_array($option['fields'] ?? null) ? $option['fields'] : [] as $key => $field) {
|
||||||
|
if (is_string($key) && $key !== '') {
|
||||||
|
$fields[$key] = self::type($key, $field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$map[$name] = $fields;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Placeholders::POST as $option) {
|
||||||
|
$map[$option] = ($map[$option] ?? []) + (VariableRef::DEFAULT_DYNAMIC_CONTENT[$option] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $map;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Field type of one setting. before/after are "richtext" whatever Divi declares ("text"): Divi
|
||||||
|
* outputs them as HTML (DynamicContentElements::get_wrapper_element, wp_kses by the author's
|
||||||
|
* unfiltered_html), and sanitize_text_field() would trim their meaningful spaces ("Von ").
|
||||||
|
* html() is kses for editors, the S28 marker checks hold either way. No readable type: '' –
|
||||||
|
* VariableRef's strictest branch (a scalar without markup).
|
||||||
|
*/
|
||||||
|
private static function type(string $key, mixed $field): string
|
||||||
|
{
|
||||||
|
$type = is_array($field) && is_string($field['type'] ?? null) ? $field['type'] : '';
|
||||||
|
|
||||||
|
return ($key === 'before' || $key === 'after') && $type === 'text' ? 'richtext' : $type;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function divi(int $postId): mixed
|
||||||
|
{
|
||||||
|
if (! class_exists(self::REGISTRY)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return call_user_func([self::REGISTRY, 'get_options'], $postId, 'edit');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Media library images for short-form fields (image_id, portrait_id, image_ids): only images
|
||||||
|
* the current user may read. URL and alt text are cleaned by ShortformWriter like client input.
|
||||||
|
*/
|
||||||
|
final class WpMediaResolver implements MediaResolver
|
||||||
|
{
|
||||||
|
public function image(int $id): ?array
|
||||||
|
{
|
||||||
|
if ($id <= 0 || ! wp_attachment_is_image($id) || ! current_user_can('read_post', $id)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = wp_get_attachment_url($id);
|
||||||
|
if (! is_string($url) || $url === '') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return ['url' => $url, 'alt' => (string) get_post_meta($id, '_wp_attachment_image_alt', true)];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* WordPress sanitizing of Divi values (spec §5.6/§10). Because DiviSaver bypasses kses for the
|
||||||
|
* page as a whole (kses would re-serialize every untouched block, D7), every value this plugin
|
||||||
|
* writes goes through here instead – always via AttrWriter::cleanValue(), which also checks
|
||||||
|
* $variable(…)$ references before and after these sanitizers.
|
||||||
|
*
|
||||||
|
* Rich text and code depend on unfiltered_html like in core (ruling S4): administrators keep
|
||||||
|
* their embeds, forms and scripts; everybody else gets wp_kses_post.
|
||||||
|
*/
|
||||||
|
final class WpValueSanitizer implements ValueSanitizer
|
||||||
|
{
|
||||||
|
public function text(string $value): string
|
||||||
|
{
|
||||||
|
return sanitize_text_field($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function html(string $value): string
|
||||||
|
{
|
||||||
|
return $this->allowsUnfilteredHtml() ? $value : wp_kses_post($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function code(string $value): string
|
||||||
|
{
|
||||||
|
return $this->allowsUnfilteredHtml() ? $value : wp_kses_post($value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function url(string $value): string
|
||||||
|
{
|
||||||
|
return esc_url_raw(trim($value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function allowsUnfilteredHtml(): bool
|
||||||
|
{
|
||||||
|
return current_user_can('unfiltered_html');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -469,4 +469,46 @@ final class AttrWriterTest extends TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution – a
|
||||||
|
* control-split placeholder is the placeholder (resolved and checked), never literal text
|
||||||
|
* that slipped past the placeholder check. Also deep inside values.
|
||||||
|
*/
|
||||||
|
public function testControlCharactersGoBeforePlaceholderResolution(): void
|
||||||
|
{
|
||||||
|
$writer = ComposeFixture::attrWriter();
|
||||||
|
$reference = '$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$';
|
||||||
|
|
||||||
|
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
|
||||||
|
$result = $writer->apply(
|
||||||
|
'divi/heading',
|
||||||
|
[],
|
||||||
|
['title.innerContent' => ['desktop' => '{' . $control . '{post:title}}']],
|
||||||
|
'attrs'
|
||||||
|
);
|
||||||
|
self::assertSame([], $result['errors'], (string) ord($control));
|
||||||
|
self::assertSame($reference, $result['attrs']['title']['innerContent']['desktop']['value']);
|
||||||
|
|
||||||
|
// Embedded: refused like "{{post:title}}" inside text.
|
||||||
|
$embedded = $writer->apply(
|
||||||
|
'divi/heading',
|
||||||
|
[],
|
||||||
|
['title.innerContent' => ['desktop' => 'Hallo {' . $control . '{post:title}}']],
|
||||||
|
'attrs'
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('allein im Wert', $embedded['errors']['attrs.title.innerContent']);
|
||||||
|
self::assertSame([], $embedded['attrs']);
|
||||||
|
|
||||||
|
$deep = $writer->apply(
|
||||||
|
'divi/button',
|
||||||
|
[],
|
||||||
|
['button.innerContent' => ['desktop' => ['text' => "{{post:ti" . $control . "tle}}"]]],
|
||||||
|
'attrs'
|
||||||
|
);
|
||||||
|
self::assertSame([], $deep['errors']);
|
||||||
|
self::assertSame($reference, $deep['attrs']['button']['innerContent']['desktop']['value']['text']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -193,7 +193,6 @@ final class ShortformWriterTest extends TestCase
|
|||||||
'url: line-break octet marker' => ['url', '$variable%0a(y)$'],
|
'url: line-break octet marker' => ['url', '$variable%0a(y)$'],
|
||||||
'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'],
|
'url: nested line-break octet marker' => ['url', '$variable%0%0aa(y)$'],
|
||||||
'color: tag-split marker' => ['color', '$variable<x>(' . substr($color, 10)],
|
'color: tag-split marker' => ['color', '$variable<x>(' . substr($color, 10)],
|
||||||
'color: NUL-split marker' => ['color', '$variable' . chr(0) . '(' . substr($color, 10)],
|
|
||||||
'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)],
|
'color: octet-split marker' => ['color', '$variable%41(' . substr($color, 10)],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -318,4 +317,38 @@ final class ShortformWriterTest extends TestCase
|
|||||||
self::assertSame([], $admin['errors']);
|
self::assertSame([], $admin['errors']);
|
||||||
self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']);
|
self::assertSame('Hallo', $admin['attrs']['module']['advanced']['html']['desktop']['value']['htmlBefore']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ruling S35: controls go once at the entry of apply(), before placeholder resolution and
|
||||||
|
* trim – "{\0{post:title}}" is the placeholder, checked like any other.
|
||||||
|
*/
|
||||||
|
public function testControlCharactersGoBeforePlaceholderResolution(): void
|
||||||
|
{
|
||||||
|
$writer = ComposeFixture::shortform();
|
||||||
|
|
||||||
|
foreach ([chr(0), chr(1), chr(11), chr(127)] as $control) {
|
||||||
|
$result = $writer->apply('heading', [], ['text' => '{' . $control . '{post:title}}'], 'n');
|
||||||
|
self::assertSame([], $result['errors'], (string) ord($control));
|
||||||
|
self::assertSame(
|
||||||
|
'$variable({"type":"content","value":{"name":"post_title","settings":{"before":"","after":""}}})$',
|
||||||
|
$result['attrs']['title']['innerContent']['desktop']['value']
|
||||||
|
);
|
||||||
|
|
||||||
|
$embedded = $writer->apply('heading', [], ['text' => 'Hallo {' . $control . '{post:title}}'], 'n');
|
||||||
|
self::assertStringContainsString('allein im Wert', $embedded['errors']['n.text']);
|
||||||
|
self::assertSame([], $embedded['attrs']);
|
||||||
|
|
||||||
|
// A control-split whole reference IS the reference (S32a/S35): validated and re-serialized.
|
||||||
|
$reference = '$variable({"type":"color","value":{"name":"gcid-a1b2","settings":{}}})$';
|
||||||
|
$split = $writer->apply('divider', [], ['color' => '$variable' . $control . substr($reference, 9)], 'n');
|
||||||
|
self::assertSame([], $split['errors']);
|
||||||
|
self::assertSame($reference, $split['attrs']['divider']['advanced']['line']['desktop']['value']['color']);
|
||||||
|
|
||||||
|
// Colors: the placeholder resolves after the strip (and trim), then the color check.
|
||||||
|
$color = $writer->apply('divider', [], ['color' => ' {{color:Nacht' . $control . 'blau}} '], 'n');
|
||||||
|
self::assertSame([], $color['errors']);
|
||||||
|
self::assertStringContainsString('gcid-a1b2', $color['attrs']['divider']['advanced']['line']['desktop']['value']['color']);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Block;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\Document;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\NodeIndex;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\TreeEditor;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
|
||||||
|
final class DiviCompositionTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
private array $registryCalls = [];
|
||||||
|
|
||||||
|
private function composition(): DiviComposition
|
||||||
|
{
|
||||||
|
$modules = Mockery::mock(DiviModules::class);
|
||||||
|
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
|
||||||
|
$modules->allows('builderVersion')->andReturn('5.2.1');
|
||||||
|
$design = Mockery::mock(DesignSystemReader::class);
|
||||||
|
$design->allows('read')->andReturn(DesignSystemTest::sample());
|
||||||
|
$registry = new DynamicContentMap(function (int $postId): array {
|
||||||
|
$this->registryCalls[] = $postId;
|
||||||
|
|
||||||
|
return DynamicContentMapTest::registry();
|
||||||
|
});
|
||||||
|
|
||||||
|
return new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function siteTitle(): string
|
||||||
|
{
|
||||||
|
return VariableRef::serialize('content', 'site_title', ['before' => '', 'after' => '']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testOneLiveMapIsSharedByTheFullAndTheShortForm(): void
|
||||||
|
{
|
||||||
|
$composition = $this->composition()->forPost(7);
|
||||||
|
|
||||||
|
// Full form (AttrWriter).
|
||||||
|
$full = $composition->attrWriter()->apply(
|
||||||
|
'divi/text',
|
||||||
|
[],
|
||||||
|
['content.innerContent' => ['desktop' => self::siteTitle()]],
|
||||||
|
'attrs'
|
||||||
|
);
|
||||||
|
self::assertSame([], $full['errors']);
|
||||||
|
self::assertSame(self::siteTitle(), AttrMap::desktop($full['attrs'], 'content.innerContent'));
|
||||||
|
|
||||||
|
// Short form through the Composer (ShortformWriter) – same map, same AttrWriter.
|
||||||
|
$short = $composition->composer()->compose(
|
||||||
|
[['type' => 'button', 'text' => self::siteTitle()]],
|
||||||
|
Block::create('divi/column'),
|
||||||
|
'nodes'
|
||||||
|
);
|
||||||
|
self::assertSame([], $short['errors']);
|
||||||
|
self::assertStringContainsString('site_title', json_encode($short['blocks'][0]->attrs(), JSON_THROW_ON_ERROR));
|
||||||
|
|
||||||
|
self::assertSame([7], $this->registryCalls, 'the registry is read once per post');
|
||||||
|
self::assertSame($composition->attrWriter(), $composition->attrWriter());
|
||||||
|
self::assertSame($composition->placeholders(), $composition->placeholders());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testWithoutAPostTheDefaultMapApplies(): void
|
||||||
|
{
|
||||||
|
$composition = $this->composition();
|
||||||
|
|
||||||
|
$result = $composition->attrWriter()->apply(
|
||||||
|
'divi/text',
|
||||||
|
[],
|
||||||
|
['content.innerContent' => ['desktop' => self::siteTitle()]],
|
||||||
|
'attrs'
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertStringContainsString('Unbekannter Name "site_title"', $result['errors']['attrs.content.innerContent']);
|
||||||
|
self::assertSame([], $this->registryCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testForPostIsAFreshCompositionPerPost(): void
|
||||||
|
{
|
||||||
|
$base = $this->composition();
|
||||||
|
$seven = $base->forPost(7);
|
||||||
|
$eight = $base->forPost(8);
|
||||||
|
|
||||||
|
self::assertNotSame($base, $seven);
|
||||||
|
self::assertNotSame($seven->placeholders(), $eight->placeholders());
|
||||||
|
self::assertNotSame($base->placeholders(), $seven->placeholders());
|
||||||
|
self::assertSame([7, 8], $this->registryCalls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRulesEditorAndWarnings(): void
|
||||||
|
{
|
||||||
|
$composition = $this->composition();
|
||||||
|
|
||||||
|
self::assertNull($composition->rules()->violation(Block::create('divi/column'), 'divi/button'));
|
||||||
|
self::assertNotNull($composition->rules()->violation(null, 'divi/button'));
|
||||||
|
self::assertSame([], $composition->warnings()->collect(NodeIndex::build(new Document()), []));
|
||||||
|
self::assertInstanceOf(TreeEditor::class, $composition->editor());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviFlags;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class DiviFlagsTest extends TestCase
|
||||||
|
{
|
||||||
|
public function testSetsBothFlagsAndAcceptsAnUnchangedValue(): void
|
||||||
|
{
|
||||||
|
$written = [];
|
||||||
|
// update_post_meta() is false when the value is already "on" – no failure (ruling S11).
|
||||||
|
Functions\when('update_post_meta')->alias(static function (int $id, string $key, string $value) use (&$written): bool {
|
||||||
|
$written[] = "$id:$key=$value";
|
||||||
|
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
Functions\when('get_post_meta')->justReturn('on');
|
||||||
|
|
||||||
|
DiviFlags::set(7);
|
||||||
|
|
||||||
|
self::assertSame(['7:_et_pb_use_divi_5=on', '7:_et_pb_use_builder=on'], $written);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testThrowsWhenAFlagDoesNotReadBack(): void
|
||||||
|
{
|
||||||
|
Functions\when('update_post_meta')->justReturn(true);
|
||||||
|
Functions\when('get_post_meta')->alias(
|
||||||
|
static fn (int $id, string $key): string => $key === '_et_pb_use_builder' ? 'off' : 'on'
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->expectException(RuntimeException::class);
|
||||||
|
$this->expectExceptionMessage('_et_pb_use_builder');
|
||||||
|
DiviFlags::set(7);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
use WP_Error;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class DiviSaverTest extends TestCase
|
||||||
|
{
|
||||||
|
private WP_Post $post;
|
||||||
|
|
||||||
|
/** @var list<string> */
|
||||||
|
private array $calls = [];
|
||||||
|
|
||||||
|
/** @var array<string, string> */
|
||||||
|
private array $meta = [];
|
||||||
|
|
||||||
|
private string $log = '';
|
||||||
|
|
||||||
|
private string|false $previousLog = false;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->post = new WP_Post(['ID' => 7, 'post_type' => 'page', 'post_content' => 'ALT']);
|
||||||
|
Functions\when('wp_save_post_revision')->justReturn(811);
|
||||||
|
Functions\when('wp_get_post_revisions')->justReturn([811 => new WP_Post(['ID' => 811])]);
|
||||||
|
Functions\when('update_post_meta')->alias(function (int $id, string $key, string $value): bool {
|
||||||
|
$this->calls[] = "meta:$key=$value";
|
||||||
|
$this->meta[$key] = $value;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
Functions\when('get_post_meta')->alias(fn (int $id, string $key): string => $this->meta[$key] ?? '');
|
||||||
|
Functions\when('kses_remove_filters')->alias(function (): void {
|
||||||
|
$this->calls[] = 'kses off';
|
||||||
|
});
|
||||||
|
Functions\when('kses_init_filters')->alias(function (): void {
|
||||||
|
$this->calls[] = 'kses on';
|
||||||
|
});
|
||||||
|
// FollowUp logs failures with error_log() (a PHP internal): keep the test output clean.
|
||||||
|
$this->log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
|
||||||
|
$this->previousLog = ini_set('error_log', $this->log);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
ini_set('error_log', (string) $this->previousLog);
|
||||||
|
@unlink($this->log);
|
||||||
|
parent::tearDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavesWithoutKsesForFilteredUsersAndSetsDiviFlags(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
Functions\when('has_filter')->justReturn(10);
|
||||||
|
Functions\expect('wp_update_post')->once()->with(['ID' => 7, 'post_content' => 'NEU'], true)
|
||||||
|
->andReturnUsing(function (): int {
|
||||||
|
$this->calls[] = 'update';
|
||||||
|
|
||||||
|
return 7;
|
||||||
|
});
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
|
||||||
|
|
||||||
|
$saved = (new DiviSaver())->save($this->post, 'NEU');
|
||||||
|
|
||||||
|
self::assertSame(['revision_id' => 811, 'warnings' => []], $saved);
|
||||||
|
self::assertSame(
|
||||||
|
['kses off', 'update', 'kses on', 'meta:_et_pb_use_divi_5=on', 'meta:_et_pb_use_builder=on'],
|
||||||
|
$this->calls
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKeepsKsesUntouchedForUnfilteredUsersAndWarnsWhenAFilterChangedTheContent(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('wp_update_post')->justReturn(7);
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU, gefiltert']));
|
||||||
|
|
||||||
|
$saved = (new DiviSaver())->save($this->post, 'NEU');
|
||||||
|
|
||||||
|
self::assertNotContains('kses off', $this->calls);
|
||||||
|
self::assertSame([DiviSaver::CHANGED_ON_SAVE], $saved['warnings']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testKsesStaysUntouchedWhenItIsNotActive(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
Functions\when('has_filter')->justReturn(false);
|
||||||
|
Functions\when('wp_update_post')->justReturn(7);
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
|
||||||
|
|
||||||
|
(new DiviSaver())->save($this->post, 'NEU');
|
||||||
|
|
||||||
|
self::assertNotContains('kses off', $this->calls);
|
||||||
|
self::assertNotContains('kses on', $this->calls);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRefusedUpdateIsACleanFailure(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('wp_update_post')->justReturn(new WP_Error('db_update_error', 'DB kaputt'));
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DiviSaver())->save($this->post, 'NEU');
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
self::assertSame(811, $e->revisionId);
|
||||||
|
self::assertStringContainsString('Revision 811 bleibt erhalten', $e->toError()->get_error_message());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAThrowingHookRestoresThePreviousContentAndKsesComesBack(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(false);
|
||||||
|
Functions\when('has_filter')->justReturn(10);
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft']));
|
||||||
|
$written = [];
|
||||||
|
Functions\when('wp_update_post')->alias(function (array $data) use (&$written): int {
|
||||||
|
$written[] = $data['post_content'];
|
||||||
|
$this->calls[] = 'update';
|
||||||
|
if ($data['post_content'] === 'NEU') {
|
||||||
|
throw new RuntimeException('save_post hook failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 7;
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DiviSaver())->save($this->post, 'NEU');
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertTrue($e->rolledBack);
|
||||||
|
self::assertSame(['NEU', 'ALT'], $written);
|
||||||
|
// The kses bypass is restored in finally – after the failed save and after the restore.
|
||||||
|
self::assertSame(['kses off', 'update', 'kses on', 'kses off', 'update', 'kses on'], $this->calls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailedRestoreIsAPartialWrite(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_status' => 'draft']));
|
||||||
|
Functions\when('wp_update_post')->alias(static function (): int {
|
||||||
|
throw new RuntimeException('database gone');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DiviSaver())->save($this->post, 'NEU');
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertSame('lit_partial_write', $e->toError()->get_error_code());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRestoreNeverPublishesAMissedSchedule(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post([
|
||||||
|
'ID' => 7,
|
||||||
|
'post_status' => 'future',
|
||||||
|
'post_date_gmt' => '2020-01-01 00:00:00',
|
||||||
|
]));
|
||||||
|
$written = [];
|
||||||
|
Functions\when('wp_update_post')->alias(static function (array $data) use (&$written): int {
|
||||||
|
$written[] = $data['post_content'];
|
||||||
|
throw new RuntimeException('save_post hook failed');
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
(new DiviSaver())->save($this->post, 'NEU');
|
||||||
|
self::fail('WriteFailed expected');
|
||||||
|
} catch (WriteFailed $e) {
|
||||||
|
self::assertFalse($e->rolledBack);
|
||||||
|
self::assertSame(['NEU'], $written);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFlagFailureIsOnlyAWarning(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
Functions\when('wp_update_post')->justReturn(7);
|
||||||
|
Functions\when('get_post')->justReturn(new WP_Post(['ID' => 7, 'post_content' => 'NEU']));
|
||||||
|
Functions\when('get_post_meta')->justReturn('');
|
||||||
|
|
||||||
|
$saved = (new DiviSaver())->save($this->post, 'NEU');
|
||||||
|
|
||||||
|
self::assertSame(811, $saved['revision_id']);
|
||||||
|
self::assertCount(1, $saved['warnings']);
|
||||||
|
self::assertStringContainsString('Divi-Kennzeichen', $saved['warnings'][0]);
|
||||||
|
self::assertStringContainsString('divi builder flags failed', (string) file_get_contents($this->log));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Abilities\Rev;
|
||||||
|
use LIT\MCP\Abilities\WriteFailed;
|
||||||
|
use LIT\MCP\ChangeLog\ChangeLog;
|
||||||
|
use LIT\MCP\Integrations\Divi\BlockTree\AttrMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Design\DesignSystemReader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviChange;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviComposition;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPage;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviPageLoader;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviSaver;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DiviWriter;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
|
use LIT\MCP\Integrations\Divi\Schema\DiviModules;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Schema\ModuleCatalogTest;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use Mockery;
|
||||||
|
use WP_Post;
|
||||||
|
|
||||||
|
final class DiviWriterTest extends TestCase
|
||||||
|
{
|
||||||
|
/** @var list<int> */
|
||||||
|
private array $registryCalls = [];
|
||||||
|
|
||||||
|
private function writer(DiviSaver $saver, ChangeLog $log): DiviWriter
|
||||||
|
{
|
||||||
|
$modules = Mockery::mock(DiviModules::class);
|
||||||
|
$modules->allows('catalog')->andReturn(ModuleCatalogTest::sampleCatalog());
|
||||||
|
$modules->allows('builderVersion')->andReturn('5.2.1');
|
||||||
|
$design = Mockery::mock(DesignSystemReader::class);
|
||||||
|
$design->allows('read')->andReturn(DesignSystemTest::sample());
|
||||||
|
$registry = new DynamicContentMap(function (int $postId): array {
|
||||||
|
$this->registryCalls[] = $postId;
|
||||||
|
|
||||||
|
return DynamicContentMapTest::registry();
|
||||||
|
});
|
||||||
|
|
||||||
|
return new DiviWriter(
|
||||||
|
new DiviPageLoader(DiviPageLoaderTest::schema()),
|
||||||
|
$saver,
|
||||||
|
$log,
|
||||||
|
$design,
|
||||||
|
new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPageChecksTheTreeRevLock(): void
|
||||||
|
{
|
||||||
|
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
|
||||||
|
DiviPageLoaderTest::givenPost($content);
|
||||||
|
$writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class));
|
||||||
|
|
||||||
|
self::assertInstanceOf(DiviPage::class, $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]));
|
||||||
|
self::assertSame('lit_rev_mismatch', $writer->page(['post_id' => 7])->get_error_code());
|
||||||
|
$stale = $writer->page(['post_id' => 7, 'tree_rev' => Rev::ofPost(DiviPageLoaderTest::givenPost($content))]);
|
||||||
|
self::assertSame('lit_rev_mismatch', $stale->get_error_code(), 'the post rev is no tree_rev');
|
||||||
|
self::assertStringContainsString('nichts wurde', $stale->get_error_message());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPageRefusesWhatIsNotWritable(): void
|
||||||
|
{
|
||||||
|
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
|
||||||
|
DiviPageLoaderTest::givenPost($content);
|
||||||
|
$writer = new DiviWriter(
|
||||||
|
new DiviPageLoader(DiviPageLoaderTest::schema(false)),
|
||||||
|
Mockery::mock(DiviSaver::class),
|
||||||
|
Mockery::mock(ChangeLog::class),
|
||||||
|
Mockery::mock(DesignSystemReader::class),
|
||||||
|
Mockery::mock(DiviComposition::class)
|
||||||
|
);
|
||||||
|
|
||||||
|
self::assertSame('lit_unsupported', $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)])->get_error_code());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSectionLabelOfANode(): void
|
||||||
|
{
|
||||||
|
DiviPageLoaderTest::givenPost(DiviPageLoaderTest::fixture('synthetic-basic.html'));
|
||||||
|
$page = (new DiviPageLoader(DiviPageLoaderTest::schema()))->load(7, true);
|
||||||
|
|
||||||
|
self::assertSame('Hero', DiviWriter::sectionLabel($page->index, $page->index->find('s0.r0.c1')));
|
||||||
|
self::assertSame('', DiviWriter::sectionLabel($page->index, null));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCompositionIsBoundToThePage(): void
|
||||||
|
{
|
||||||
|
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
|
||||||
|
DiviPageLoaderTest::givenPost($content);
|
||||||
|
$writer = $this->writer(Mockery::mock(DiviSaver::class), Mockery::mock(ChangeLog::class));
|
||||||
|
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
|
||||||
|
|
||||||
|
$writer->composition($page)->placeholders();
|
||||||
|
|
||||||
|
self::assertSame([7], $this->registryCalls, 'the live dynamic content map of post 7 (ruling S22)');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnchangedTreeIsNotSaved(): void
|
||||||
|
{
|
||||||
|
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
|
||||||
|
DiviPageLoaderTest::givenPost($content);
|
||||||
|
$saver = Mockery::mock(DiviSaver::class);
|
||||||
|
$saver->expects('save')->never();
|
||||||
|
$writer = $this->writer($saver, Mockery::mock(ChangeLog::class));
|
||||||
|
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
|
||||||
|
|
||||||
|
$result = $writer->save('lit/divi-update', $page, new DiviChange('nichts'), []);
|
||||||
|
|
||||||
|
self::assertSame(Rev::of($content), $result['tree_rev']);
|
||||||
|
self::assertStringStartsWith('Keine Änderung', $result['summary']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testSavesAndReportsChangedNodesOutlineAndTheNewTreeRev(): void
|
||||||
|
{
|
||||||
|
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
|
||||||
|
DiviPageLoaderTest::givenPost($content);
|
||||||
|
$stored = null;
|
||||||
|
$saver = Mockery::mock(DiviSaver::class);
|
||||||
|
$saver->expects('save')->andReturnUsing(static function (WP_Post $post, string $new) use (&$stored): array {
|
||||||
|
$stored = $new;
|
||||||
|
DiviPageLoaderTest::givenPost($new);
|
||||||
|
|
||||||
|
return ['revision_id' => 811, 'warnings' => ['vom Speichern']];
|
||||||
|
});
|
||||||
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
|
$log->expects('record')->with('lit/divi-update', 'page', '7', 'Divi "Start": Button geändert', 811)
|
||||||
|
->andReturn(55);
|
||||||
|
$input = ['post_id' => 7, 'tree_rev' => Rev::of($content)];
|
||||||
|
Functions\expect('do_action')->once()->with('lit_mcp_after_save', 7, 'lit/divi-update', $input);
|
||||||
|
Functions\when('get_preview_post_link')->justReturn('https://kunde.test/?page_id=7&preview=true');
|
||||||
|
$writer = $this->writer($saver, $log);
|
||||||
|
$page = $writer->page($input);
|
||||||
|
$button = $page->index->find('s0.r0.c1.m2');
|
||||||
|
$removed = $page->index->find('s1');
|
||||||
|
$button->setAttrs(AttrMap::set($button->attrs(), 'button.innerContent.desktop.value.text', 'Neu'));
|
||||||
|
$page->document->children()[0]->removeChild($removed);
|
||||||
|
$change = (new DiviChange('Button geändert'))
|
||||||
|
->changed($button, 'updated')
|
||||||
|
->removed('s1', $removed)
|
||||||
|
->warning('eigene Warnung');
|
||||||
|
|
||||||
|
$result = $writer->save('lit/divi-update', $page, $change, $input);
|
||||||
|
|
||||||
|
self::assertIsString($stored);
|
||||||
|
self::assertStringNotContainsString('wp:divi/blurb', $stored);
|
||||||
|
self::assertSame(Rev::of($stored), $result['tree_rev']);
|
||||||
|
self::assertSame(811, $result['revision_id']);
|
||||||
|
self::assertSame(55, $result['log_id']);
|
||||||
|
self::assertSame('https://kunde.test/?page_id=7&et_fb=1', $result['edit_url']);
|
||||||
|
self::assertSame('https://kunde.test/?page_id=7&preview=true', $result['preview_url']);
|
||||||
|
self::assertSame('Button geändert (Status unverändert: draft).', $result['summary']);
|
||||||
|
self::assertSame(
|
||||||
|
[['s0.r0.c1.m2', 'divi/button', 'updated'], ['s1', 'divi/section', 'deleted']],
|
||||||
|
array_map(static fn (array $c): array => [$c['id'], $c['type'], $c['action']], $result['changed'])
|
||||||
|
);
|
||||||
|
self::assertStringContainsString('Hero', (string) $result['outline']);
|
||||||
|
self::assertStringNotContainsString('Leistungen', (string) $result['outline'], 'only affected sections');
|
||||||
|
self::assertContains('eigene Warnung', $result['warnings']);
|
||||||
|
self::assertContains('vom Speichern', $result['warnings']);
|
||||||
|
self::assertSame(Rev::ofPost(DiviPageLoaderTest::givenPost($stored)), $result['rev']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFailedRollbackIsLoggedAsPartial(): void
|
||||||
|
{
|
||||||
|
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
|
||||||
|
DiviPageLoaderTest::givenPost($content);
|
||||||
|
$saver = Mockery::mock(DiviSaver::class);
|
||||||
|
$saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, false, 7, 811));
|
||||||
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
|
$log->expects('recordPartial')->with('lit/divi-delete', 'page', '7', 'Divi "Start": Änderung nur teilweise zurückgenommen', 811);
|
||||||
|
$writer = $this->writer($saver, $log);
|
||||||
|
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
|
||||||
|
$page->document->children()[0]->removeChild($page->index->find('s1'));
|
||||||
|
|
||||||
|
$this->expectException(WriteFailed::class);
|
||||||
|
$writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCleanFailureIsNotLogged(): void
|
||||||
|
{
|
||||||
|
$content = DiviPageLoaderTest::fixture('synthetic-basic.html');
|
||||||
|
DiviPageLoaderTest::givenPost($content);
|
||||||
|
$saver = Mockery::mock(DiviSaver::class);
|
||||||
|
$saver->allows('save')->andThrow(new WriteFailed(DiviSaver::REASON, true, 7, 811));
|
||||||
|
$log = Mockery::mock(ChangeLog::class);
|
||||||
|
$log->expects('recordPartial')->never();
|
||||||
|
$log->expects('record')->never();
|
||||||
|
$writer = $this->writer($saver, $log);
|
||||||
|
$page = $writer->page(['post_id' => 7, 'tree_rev' => Rev::of($content)]);
|
||||||
|
$page->document->children()[0]->removeChild($page->index->find('s1'));
|
||||||
|
|
||||||
|
$this->expectException(WriteFailed::class);
|
||||||
|
$writer->save('lit/divi-delete', $page, new DiviChange('s1 gelöscht'), []);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use InvalidArgumentException;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
|
||||||
|
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Compose\ComposeFixture;
|
||||||
|
use LIT\MCP\Tests\Unit\Integrations\Divi\Design\DesignSystemTest;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
final class DynamicContentMapTest extends TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Shape of DynamicContentOptions::get_options($pageId, 'edit') in Divi 5.2.1 (excerpt of a
|
||||||
|
* live dump for a page: no post_categories/post_tags – pages have no such taxonomies).
|
||||||
|
*
|
||||||
|
* @return array<string, array<string, mixed>>
|
||||||
|
*/
|
||||||
|
public static function registry(): array
|
||||||
|
{
|
||||||
|
$text = static fn (): array => ['label' => 'x', 'type' => 'text', 'default' => ''];
|
||||||
|
$option = static fn (string $id, string $group, array $fields): array => [
|
||||||
|
'id' => $id, 'label' => $id, 'type' => 'text', 'custom' => false, 'group' => $group, 'fields' => $fields,
|
||||||
|
];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'post_title' => $option('post_title', 'Default', ['before' => $text(), 'after' => $text()]),
|
||||||
|
'post_excerpt' => $option('post_excerpt', 'Default', [
|
||||||
|
'before' => $text(), 'after' => $text(), 'words' => $text(), 'read_more_label' => $text(),
|
||||||
|
]),
|
||||||
|
'post_date' => $option('post_date', 'Default', [
|
||||||
|
'before' => $text(),
|
||||||
|
'after' => $text(),
|
||||||
|
'date_format' => ['label' => 'x', 'type' => 'select', 'default' => 'default'],
|
||||||
|
'custom_date_format' => $text(),
|
||||||
|
]),
|
||||||
|
'post_author' => $option('post_author', 'Default', [
|
||||||
|
'before' => $text(),
|
||||||
|
'after' => $text(),
|
||||||
|
'name_format' => ['type' => 'select'],
|
||||||
|
'link' => ['type' => 'yes_no_button'],
|
||||||
|
]),
|
||||||
|
'site_title' => $option('site_title', 'Default', ['before' => $text(), 'after' => $text()]),
|
||||||
|
'post_link_url' => $option('post_link_url', 'Default', []),
|
||||||
|
'post_link_url_page' => $option('post_link_url_page', 'Default', ['post_id' => ['type' => 'select_post']]),
|
||||||
|
'post_featured_image' => $option('post_featured_image', 'Default', []),
|
||||||
|
'post_meta_key' => $option('post_meta_key', 'Standard', [
|
||||||
|
'before' => $text(),
|
||||||
|
'after' => $text(),
|
||||||
|
'select_meta_key' => ['type' => 'select'],
|
||||||
|
'meta_key' => $text(),
|
||||||
|
'enable_html' => ['type' => 'yes_no_button'],
|
||||||
|
]),
|
||||||
|
'loop_post_title' => $option('loop_post_title', 'Loop', [
|
||||||
|
'before' => $text(), 'after' => $text(), 'loop_position' => $text(),
|
||||||
|
]),
|
||||||
|
'loop_post_meta_key_manual_custom_field' => $option('loop_post_meta_key_manual_custom_field', 'Loop', [
|
||||||
|
'loop_meta_key' => $text(),
|
||||||
|
]),
|
||||||
|
// Odd entries a third-party filter could add.
|
||||||
|
'broken_fields' => $option('broken_fields', 'Default', ['a' => 'not-an-array', 'b' => ['type' => 3]]),
|
||||||
|
'no_fields' => ['id' => 'no_fields', 'type' => 'text'],
|
||||||
|
7 => $option('7', 'Default', []),
|
||||||
|
'not_an_array' => 'x',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFlattensTheRegistryToOptionNameAndFieldTypes(): void
|
||||||
|
{
|
||||||
|
$map = DynamicContentMap::fromOptions(self::registry());
|
||||||
|
|
||||||
|
// before/after: rich text (Divi outputs them as HTML; text() would trim "Von ").
|
||||||
|
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_title']);
|
||||||
|
self::assertSame(
|
||||||
|
['before' => 'richtext', 'after' => 'richtext', 'date_format' => 'select', 'custom_date_format' => 'text'],
|
||||||
|
$map['post_date']
|
||||||
|
);
|
||||||
|
self::assertSame(['post_id' => 'select_post'], $map['post_link_url_page']);
|
||||||
|
self::assertSame([], $map['post_link_url']);
|
||||||
|
self::assertSame('select', $map['post_meta_key']['select_meta_key']);
|
||||||
|
// Fields without a readable type become '' – VariableRef's strictest branch (scalar, no markup).
|
||||||
|
self::assertSame(['a' => '', 'b' => ''], $map['broken_fields']);
|
||||||
|
self::assertSame([], $map['no_fields']);
|
||||||
|
self::assertArrayNotHasKey('not_an_array', $map);
|
||||||
|
self::assertArrayNotHasKey(7, $map);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLoopOptionsNeverEnterTheMap(): void
|
||||||
|
{
|
||||||
|
$map = DynamicContentMap::fromOptions(self::registry());
|
||||||
|
|
||||||
|
self::assertSame([], array_filter(array_keys($map), static fn (string $name): bool => str_starts_with($name, 'loop_')));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testPlaceholderOptionsAreAlwaysThere(): void
|
||||||
|
{
|
||||||
|
// The map replaces VariableRef's default: every {{post:…}} placeholder must stay usable
|
||||||
|
// with the settings it writes (before/after), also where the registry lacks the option.
|
||||||
|
$map = DynamicContentMap::fromOptions(self::registry());
|
||||||
|
|
||||||
|
foreach (Placeholders::POST as $option) {
|
||||||
|
self::assertArrayHasKey($option, $map, $option);
|
||||||
|
foreach (array_keys(VariableRef::DEFAULT_DYNAMIC_CONTENT[$option]) as $setting) {
|
||||||
|
self::assertArrayHasKey($setting, $map[$option], "$option.$setting");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Supplemented with the default settings.
|
||||||
|
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['post_categories']);
|
||||||
|
// Live fields win.
|
||||||
|
self::assertSame('select', $map['post_date']['date_format']);
|
||||||
|
self::assertSame('text', $map['post_excerpt']['words']);
|
||||||
|
|
||||||
|
$placeholders = new Placeholders(DesignSystemTest::sample(), $map);
|
||||||
|
foreach (array_keys(Placeholders::POST) as $key) {
|
||||||
|
$reference = $placeholders->resolve('{{post:' . $key . '}}');
|
||||||
|
self::assertSame(
|
||||||
|
$reference,
|
||||||
|
$placeholders->references()->canonical($reference, ComposeFixture::sanitizer()),
|
||||||
|
$key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheMapWorksInVariableRef(): void
|
||||||
|
{
|
||||||
|
$references = new VariableRef(DesignSystemTest::sample(), DynamicContentMap::fromOptions(self::registry()));
|
||||||
|
$sanitizer = ComposeFixture::sanitizer();
|
||||||
|
$ref = static fn (string $name, array $settings): string => VariableRef::serialize('content', $name, $settings);
|
||||||
|
|
||||||
|
// An option only the live registry knows.
|
||||||
|
$site = $ref('site_title', ['before' => 'Willkommen bei ', 'after' => '']);
|
||||||
|
self::assertSame($site, $references->canonical($site, $sanitizer));
|
||||||
|
// before/after are cleaned as rich text (kses for editors) and re-serialized; spaces stay.
|
||||||
|
self::assertSame(
|
||||||
|
$ref('post_title', ['before' => 'Neu: ', 'after' => '']),
|
||||||
|
$references->canonical($ref('post_title', ['before' => '<script>x()</script>Neu: ', 'after' => '']), $sanitizer)
|
||||||
|
);
|
||||||
|
// Other text fields go through text().
|
||||||
|
self::assertSame(
|
||||||
|
$ref('post_excerpt', ['words' => '20']),
|
||||||
|
$references->canonical($ref('post_excerpt', ['words' => '<b>20</b>']), $sanitizer)
|
||||||
|
);
|
||||||
|
// Meta keys still obey S29/S30, also through the live map.
|
||||||
|
$meta = $ref('post_meta_key', ['select_meta_key' => 'custom_meta__wp_page_template']);
|
||||||
|
self::assertRejected(static fn () => $references->canonical($meta, $sanitizer), 'Meta-Schlüssel');
|
||||||
|
$ok = $ref('post_meta_key', ['meta_key' => 'telefon']);
|
||||||
|
self::assertSame($ok, $references->canonical($ok, $sanitizer));
|
||||||
|
// enable_html stays forbidden even though the registry lists it.
|
||||||
|
$html = $ref('post_meta_key', ['enable_html' => 'on']);
|
||||||
|
self::assertRejected(static fn () => $references->canonical($html, $sanitizer), 'enable_html');
|
||||||
|
// Loop content: Divi offers it only inside loops – never through the tools.
|
||||||
|
$loop = $ref('loop_post_title', []);
|
||||||
|
self::assertRejected(static fn () => $references->canonical($loop, $sanitizer), 'Schleifen');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLiveRegistryForThePost(): void
|
||||||
|
{
|
||||||
|
$seen = [];
|
||||||
|
$map = (new DynamicContentMap(static function (int $postId) use (&$seen): array {
|
||||||
|
$seen[] = $postId;
|
||||||
|
|
||||||
|
return DynamicContentMapTest::registry();
|
||||||
|
}))->forPost(42);
|
||||||
|
|
||||||
|
self::assertSame([42], $seen);
|
||||||
|
self::assertSame(['before' => 'richtext', 'after' => 'richtext'], $map['site_title'] ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testUnavailableRegistryFallsBackToTheDefault(): void
|
||||||
|
{
|
||||||
|
self::assertNull((new DynamicContentMap(static fn (): array => []))->forPost(7), 'empty (recursion guard)');
|
||||||
|
self::assertNull((new DynamicContentMap(static fn (): string => 'x'))->forPost(7), 'no array');
|
||||||
|
|
||||||
|
$log = (string) tempnam(sys_get_temp_dir(), 'lit-log');
|
||||||
|
$previous = ini_set('error_log', $log);
|
||||||
|
try {
|
||||||
|
$map = (new DynamicContentMap(static function (): array {
|
||||||
|
throw new RuntimeException('filter exploded');
|
||||||
|
}))->forPost(7);
|
||||||
|
} finally {
|
||||||
|
ini_set('error_log', (string) $previous);
|
||||||
|
}
|
||||||
|
self::assertNull($map);
|
||||||
|
self::assertStringContainsString('filter exploded', (string) file_get_contents($log));
|
||||||
|
unlink($log);
|
||||||
|
|
||||||
|
// Without Divi (no DynamicContentOptions class, as in these unit tests).
|
||||||
|
self::assertNull((new DynamicContentMap())->forPost(7));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static function assertRejected(callable $call, string $fragment): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$call();
|
||||||
|
self::fail('InvalidArgumentException expected');
|
||||||
|
} catch (InvalidArgumentException $e) {
|
||||||
|
self::assertStringContainsString($fragment, $e->getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\WpMediaResolver;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
final class WpMediaResolverTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Functions\when('wp_attachment_is_image')->alias(static fn (int $id): bool => $id !== 5);
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 6);
|
||||||
|
Functions\when('wp_get_attachment_url')->alias(static fn (int $id): string|false => $id === 8 ? false : "https://kunde.test/$id.jpg");
|
||||||
|
Functions\when('get_post_meta')->justReturn('Alt-Text');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReadableImagesOnly(): void
|
||||||
|
{
|
||||||
|
$media = new WpMediaResolver();
|
||||||
|
|
||||||
|
self::assertSame(['url' => 'https://kunde.test/33.jpg', 'alt' => 'Alt-Text'], $media->image(33));
|
||||||
|
self::assertNull($media->image(0), 'no id');
|
||||||
|
self::assertNull($media->image(5), 'no image');
|
||||||
|
self::assertNull($media->image(6), 'not readable');
|
||||||
|
self::assertNull($media->image(8), 'no url');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
|
||||||
|
|
||||||
|
use Brain\Monkey\Functions;
|
||||||
|
use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer;
|
||||||
|
use LIT\MCP\Tests\Unit\TestCase;
|
||||||
|
|
||||||
|
final class WpValueSanitizerTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
Functions\when('wp_kses_post')->alias(static fn (string $value): string => 'kses(' . $value . ')');
|
||||||
|
Functions\when('esc_url_raw')->alias(static fn (string $value): string => 'url(' . $value . ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditorsGetKsesForRichTextAndCode(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'unfiltered_html');
|
||||||
|
$sanitizer = new WpValueSanitizer();
|
||||||
|
|
||||||
|
self::assertSame('kses(<iframe></iframe>)', $sanitizer->html('<iframe></iframe>'));
|
||||||
|
self::assertSame('kses(<script>x</script>)', $sanitizer->code('<script>x</script>'));
|
||||||
|
self::assertFalse($sanitizer->allowsUnfilteredHtml());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testAdministratorsKeepRichTextAndCodeUnfiltered(): void
|
||||||
|
{
|
||||||
|
// Ruling S4: html() depends on unfiltered_html like code() – admin embeds survive updates.
|
||||||
|
Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap === 'unfiltered_html');
|
||||||
|
$sanitizer = new WpValueSanitizer();
|
||||||
|
|
||||||
|
self::assertSame('<iframe src="https://x.test"></iframe>', $sanitizer->html('<iframe src="https://x.test"></iframe>'));
|
||||||
|
self::assertSame('<script>x</script>', $sanitizer->code('<script>x</script>'));
|
||||||
|
self::assertTrue($sanitizer->allowsUnfilteredHtml());
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTextAndUrlDoNotDependOnTheRole(): void
|
||||||
|
{
|
||||||
|
Functions\when('current_user_can')->justReturn(true);
|
||||||
|
$sanitizer = new WpValueSanitizer();
|
||||||
|
|
||||||
|
self::assertSame('Hallo', $sanitizer->text('<b>Hallo</b>'));
|
||||||
|
self::assertSame('url(https://x.test/a b)', $sanitizer->url(' https://x.test/a b '));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user