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:
2026-09-26 14:56:47 +02:00
co-authored by Claude Opus 5.5
parent ea1fd607b2
commit 7762b76305
20 changed files with 1716 additions and 2 deletions
@@ -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());
}
}
+33
View File
@@ -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));
}
}
}
}
+157
View File
@@ -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));
}
}
+224
View File
@@ -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');
}
}