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>
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
96fa58188b
commit
ac333a164c
@@ -0,0 +1,199 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user