Files
WP-LIT-MCP/src/Integrations/Divi/Design/DiviCustomCssUpdateAbility.php
T
Jan-Niclas LoosenandClaude Sonnet 5.5 ac333a164c feat(css): globales CSS bearbeiten (lit/divi-custom-css-get und -update)
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>
2026-11-05 16:36:16 +01:00

200 lines
7.2 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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);
}
}