WordPress „Zusätzliches CSS“ (in Divi „Eigenes CSS“) über die Design-Pipeline: Probelauf mit Zeilen-Diff, design_rev, Bestätigung, Snapshot, Rollback und Wiederherstellen (Bereich custom_css). Der Guard lehnt </style, <script, expression(, javascript: und neue @import ab und warnt bei fremden Hosts; nur Neues zählt, Bestehendes blockiert keine Änderung. Erfordert edit_css und die Divi-Rolle Theme-Anpassung. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
200 lines
7.2 KiB
PHP
200 lines
7.2 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Design;
|
||
|
||
use LIT\MCP\Abilities\Ability;
|
||
use LIT\MCP\Abilities\Annotations;
|
||
use LIT\MCP\Abilities\Errors;
|
||
use LIT\MCP\Integrations\Divi\Release\DiviFeature;
|
||
use LIT\MCP\Integrations\Divi\Release\RequiresDiviFeatures;
|
||
use WP_Error;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* lit/divi-custom-css-update: changes the site-wide custom CSS through the change pipeline (CustomCssArea): exactly one
|
||
* of css (replaces everything, "" empties it), append (adds to the end) or replace {old, new} (old must occur exactly
|
||
* once). The text passes CustomCssGuard – refusals store nothing, warnings show in the dry run. The dry run names the
|
||
* line diff, the reach (the whole website) and the design_rev; confirm needs that design_rev. A snapshot is taken
|
||
* first (lit/divi-design-restore).
|
||
*/
|
||
final class DiviCustomCssUpdateAbility implements Ability, RequiresDiviFeatures
|
||
{
|
||
private const MODES = ['css', 'append', 'replace'];
|
||
|
||
public function __construct(
|
||
private CustomCssStore $store,
|
||
private ThemeOptionsAdapter $options,
|
||
private DesignChange $change,
|
||
private CustomCssGuard $guard
|
||
) {
|
||
}
|
||
|
||
public function name(): string
|
||
{
|
||
return 'lit/divi-custom-css-update';
|
||
}
|
||
|
||
public function label(): string
|
||
{
|
||
return 'Eigenes CSS ändern';
|
||
}
|
||
|
||
public function description(): string
|
||
{
|
||
return 'Ändert das globale CSS der Website (WordPress „Zusätzliches CSS“, in Divi „Eigenes CSS“) des aktiven '
|
||
. 'Themes. Genau eines von: css (ersetzt alles, "" leert), append (hängt an) oder replace {old, new} '
|
||
. '(old muss genau einmal vorkommen). Erst lit/divi-custom-css-get lesen. Presets und Variablen haben '
|
||
. 'Vorrang; CSS nur, wenn kein natives Modul oder keine Einstellung passt. Wirkt auf die gesamte '
|
||
. 'Website: zuerst Probelauf (diff, Warnungen, design_rev), nach Rückfrage beim Nutzer confirm: true mit '
|
||
. 'design_rev. Vorher wird ein Snapshot angelegt (lit/divi-design-restore). Abgelehnt werden '
|
||
. '</style, <script, expression(, javascript: und neue @import-Anweisungen.';
|
||
}
|
||
|
||
public function inputSchema(): array
|
||
{
|
||
return [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'css' => ['type' => 'string', 'description' => 'Neues vollständiges CSS; "" leert es.'],
|
||
'append' => ['type' => 'string', 'description' => 'CSS, das ans Ende angehängt wird.'],
|
||
'replace' => [
|
||
'type' => 'object',
|
||
'properties' => [
|
||
'old' => ['type' => 'string', 'minLength' => 1, 'description' => 'Genau einmal im CSS.'],
|
||
'new' => ['type' => 'string'],
|
||
],
|
||
'required' => ['old', 'new'],
|
||
'additionalProperties' => false,
|
||
],
|
||
] + DesignChange::inputProperties(),
|
||
'additionalProperties' => false,
|
||
];
|
||
}
|
||
|
||
public function outputSchema(): ?array
|
||
{
|
||
return DesignChange::outputSchema();
|
||
}
|
||
|
||
/** It overwrites the stored text, and a client treating it as additive would run it without asking. */
|
||
public function annotations(): array
|
||
{
|
||
return Annotations::destructive(true);
|
||
}
|
||
|
||
public function isDirect(): bool
|
||
{
|
||
return false;
|
||
}
|
||
|
||
public function diviFeatures(array $input): array
|
||
{
|
||
return [DiviFeature::ThemeOptionRead, DiviFeature::ThemeOptionWrite];
|
||
}
|
||
|
||
public function permission(array $input): bool|WP_Error
|
||
{
|
||
return DesignRights::customCss($this->options) ?? true;
|
||
}
|
||
|
||
public function execute(array $input): mixed
|
||
{
|
||
$refusal = DesignRights::customCss($this->options);
|
||
if ($refusal !== null) {
|
||
return $refusal;
|
||
}
|
||
$given = array_values(array_filter(
|
||
self::MODES,
|
||
static fn (string $mode): bool => array_key_exists($mode, $input)
|
||
));
|
||
if (count($given) !== 1) {
|
||
return Errors::invalidInput(
|
||
'Genau eines von css, append oder replace angeben; nichts wurde gespeichert.',
|
||
['css' => 'css, append oder replace – nicht keins und nicht mehrere']
|
||
);
|
||
}
|
||
|
||
return $this->change->run(
|
||
$this->name(),
|
||
new CustomCssArea($this->store),
|
||
$input,
|
||
fn (array $raw): DesignPlan|WP_Error => $this->plan($raw, $given[0], $input[$given[0]])
|
||
);
|
||
}
|
||
|
||
/** @param array<array-key, mixed> $raw */
|
||
private function plan(array $raw, string $mode, mixed $value): DesignPlan|WP_Error
|
||
{
|
||
$before = (string) ($raw['css'] ?? '');
|
||
$after = $this->applied($before, $mode, $value);
|
||
if ($after instanceof WP_Error) {
|
||
return $after;
|
||
}
|
||
|
||
$verdict = $this->guard->check($after, $before);
|
||
if ($verdict['errors'] !== []) {
|
||
return Errors::invalidInput(
|
||
'Das CSS wird nicht gespeichert: ' . implode('; ', $verdict['errors']) . '.',
|
||
[$mode => implode('; ', $verdict['errors'])]
|
||
);
|
||
}
|
||
|
||
$warnings = [CustomCssArea::SITE_WIDE];
|
||
if ($after === '' && $before !== '') {
|
||
$warnings[] = 'Das gesamte eigene CSS der Website wird geleert.';
|
||
}
|
||
|
||
return new DesignPlan(
|
||
['stylesheet' => (string) ($raw['stylesheet'] ?? ''), 'css' => $after],
|
||
CustomCssDiff::between($before, $after),
|
||
CustomCssDiff::summary($before, $after),
|
||
null,
|
||
array_merge($warnings, $verdict['warnings'])
|
||
);
|
||
}
|
||
|
||
private function applied(string $before, string $mode, mixed $value): string|WP_Error
|
||
{
|
||
if ($mode === 'replace') {
|
||
return $this->replaced($before, $value);
|
||
}
|
||
if (! is_string($value)) {
|
||
return Errors::invalidInput(sprintf('%s muss Text sein.', $mode), [$mode => 'Text']);
|
||
}
|
||
if ($mode === 'css') {
|
||
return $value;
|
||
}
|
||
|
||
// append: the new rules start on a line of their own.
|
||
return $before === '' || str_ends_with($before, "\n") ? $before . $value : $before . "\n" . $value;
|
||
}
|
||
|
||
private function replaced(string $before, mixed $value): string|WP_Error
|
||
{
|
||
$old = is_array($value) ? ($value['old'] ?? null) : null;
|
||
$new = is_array($value) ? ($value['new'] ?? null) : null;
|
||
if (! is_string($old) || $old === '' || ! is_string($new)) {
|
||
return Errors::invalidInput('replace braucht old (nicht leer) und new.', ['replace' => '{old, new}']);
|
||
}
|
||
|
||
$count = substr_count($before, $old);
|
||
if ($count === 0) {
|
||
return Errors::invalidInput(
|
||
'replace.old kommt im CSS nicht vor; nichts wurde gespeichert.',
|
||
['replace.old' => 'Text genau wie im CSS']
|
||
);
|
||
}
|
||
if ($count > 1) {
|
||
return Errors::invalidInput(
|
||
sprintf('replace.old kommt %d-mal vor und muss eindeutig sein; nichts wurde gespeichert.', $count),
|
||
['replace.old' => 'mehr Umgebung angeben, bis er einmal vorkommt']
|
||
);
|
||
}
|
||
|
||
return str_replace($old, $new, $before);
|
||
}
|
||
}
|