feat: Divi placeholders, strict $variable references and full-form attribute writer
VariableRef parses, validates and re-serializes every $variable(...)$ value (S1/S2/S12); AttrWriter routes strings by catalog kind incl. per-subName composite kinds and key-name URL fallback (S3/S3a), rejects odd value keys (S13) and Divi HTML/attribute fields without unfiltered_html (S7); ambiguous design-system labels fail with their ids (S13a); R2 finding recorded (S5). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||||
|
||||
/**
|
||||
* Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax:
|
||||
* {{color:primary|<Name>|gcid-…}}, {{var:<Name>|gvid-…}}, {{font:heading|body}},
|
||||
* {{post:title|…}}, {{field:<meta_key>}}. A placeholder must be the whole value (D9); unknown
|
||||
* names fail with the list of existing ones, labels two entries share fail with their IDs
|
||||
* (ruling S13a). The result is still checked by VariableRef wherever it is written. Pure PHP.
|
||||
*/
|
||||
final class Placeholders
|
||||
{
|
||||
/** {{post:<key>}} => Divi dynamic content option. */
|
||||
public const POST = [
|
||||
'title' => 'post_title',
|
||||
'excerpt' => 'post_excerpt',
|
||||
'date' => 'post_date',
|
||||
'modified' => 'post_modified_date',
|
||||
'author' => 'post_author',
|
||||
'url' => 'post_link_url',
|
||||
'featured_image' => 'post_featured_image',
|
||||
'categories' => 'post_categories',
|
||||
'tags' => 'post_tags',
|
||||
];
|
||||
|
||||
/** Dynamic content that is text and takes before/after settings. */
|
||||
private const TEXT_SETTINGS = [
|
||||
'post_title', 'post_excerpt', 'post_date', 'post_modified_date', 'post_author', 'post_categories', 'post_tags',
|
||||
];
|
||||
|
||||
private const PATTERN = '/^\{\{\s*(color|var|font|post|field)\s*:\s*([^{}]+?)\s*\}\}$/';
|
||||
|
||||
private VariableRef $references;
|
||||
|
||||
public function __construct(private DesignSystem $design)
|
||||
{
|
||||
$this->references = new VariableRef($design);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether $value has the shape of one Divi variable reference ($variable({…})$), e.g. from an
|
||||
* earlier read. A shape check only – never a reason to store a value unchecked: that is
|
||||
* VariableRef::canonical() (ruling S1).
|
||||
*/
|
||||
public static function isVariable(string $value): bool
|
||||
{
|
||||
return preg_match('/^\$variable\((\{.*\})\)\$$/s', $value, $match) === 1
|
||||
&& ! str_contains($match[1], ')$')
|
||||
&& is_array(json_decode($match[1], true));
|
||||
}
|
||||
|
||||
/** The strict checker of $variable(…)$ values against this design system (ruling S1). */
|
||||
public function references(): VariableRef
|
||||
{
|
||||
return $this->references;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolves $value when it is a placeholder; other strings are returned unchanged.
|
||||
*
|
||||
* @throws InvalidArgumentException With a German message for the client.
|
||||
*/
|
||||
public function resolve(string $value): string
|
||||
{
|
||||
if (preg_match(self::PATTERN, trim($value), $match) !== 1) {
|
||||
if (str_contains($value, '{{')) {
|
||||
throw new InvalidArgumentException(
|
||||
'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
|
||||
. 'post, field).'
|
||||
);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
[, $kind, $name] = $match;
|
||||
|
||||
return match ($kind) {
|
||||
'color' => $this->color($name),
|
||||
'var' => $this->variable($name),
|
||||
'font' => $this->font($name),
|
||||
'post' => $this->post($name),
|
||||
default => $this->field($name),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies resolve() to every string inside $value (full-form values are nested).
|
||||
*
|
||||
* @throws InvalidArgumentException
|
||||
*/
|
||||
public function resolveDeep(mixed $value): mixed
|
||||
{
|
||||
if (is_string($value)) {
|
||||
return $this->resolve($value);
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
return array_map([$this, 'resolveDeep'], $value);
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
private function color(string $name): string
|
||||
{
|
||||
$colors = $this->design->colors();
|
||||
$ids = array_column($colors, 'id');
|
||||
$alias = DesignSystem::COLOR_ALIASES[mb_strtolower($name)] ?? null;
|
||||
$id = match (true) {
|
||||
in_array($name, $ids, true) => $name,
|
||||
$alias !== null && in_array($alias, $ids, true) => $alias,
|
||||
default => self::byLabel($colors, $name, 'globale Farbe'),
|
||||
};
|
||||
|
||||
if ($id === null) {
|
||||
$known = array_merge(
|
||||
array_keys(array_intersect(DesignSystem::COLOR_ALIASES, $ids)),
|
||||
array_column($colors, 'label')
|
||||
);
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Unbekannte globale Farbe "%s". Vorhanden: %s.',
|
||||
$name,
|
||||
implode(', ', array_unique($known))
|
||||
));
|
||||
}
|
||||
|
||||
return VariableRef::serialize('color', $id, []);
|
||||
}
|
||||
|
||||
private function variable(string $name): string
|
||||
{
|
||||
$variables = $this->design->variables();
|
||||
$id = in_array($name, array_column($variables, 'id'), true)
|
||||
? $name
|
||||
: self::byLabel($variables, $name, 'globale Variable');
|
||||
if ($id === null) {
|
||||
$labels = array_column($variables, 'label');
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Unbekannte globale Variable "%s". Vorhanden: %s.',
|
||||
$name,
|
||||
$labels === [] ? '(keine)' : implode(', ', $labels)
|
||||
));
|
||||
}
|
||||
|
||||
$type = 'content';
|
||||
foreach ($variables as $variable) {
|
||||
if ($variable['id'] === $id && $variable['type'] === 'colors') {
|
||||
$type = 'color';
|
||||
}
|
||||
}
|
||||
|
||||
return VariableRef::serialize($type, $id, []);
|
||||
}
|
||||
|
||||
/**
|
||||
* ID of the one entry labelled $name (case-insensitive); null when none is.
|
||||
*
|
||||
* @param list<array{id: string, label: string}> $entries
|
||||
* @throws InvalidArgumentException When several entries share the label (ruling S13a).
|
||||
*/
|
||||
private static function byLabel(array $entries, string $name, string $what): ?string
|
||||
{
|
||||
$key = mb_strtolower($name);
|
||||
$ids = [];
|
||||
foreach ($entries as $entry) {
|
||||
if (mb_strtolower($entry['label']) === $key) {
|
||||
$ids[] = $entry['id'];
|
||||
}
|
||||
}
|
||||
|
||||
if (count($ids) > 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Name der %s "%s" ist mehrdeutig; bitte die ID angeben: %s.',
|
||||
$what,
|
||||
$name,
|
||||
implode(', ', $ids)
|
||||
));
|
||||
}
|
||||
|
||||
return $ids[0] ?? null;
|
||||
}
|
||||
|
||||
private function font(string $name): string
|
||||
{
|
||||
$id = DesignSystem::FONT_VARIABLES[strtolower($name)] ?? null;
|
||||
if ($id === null) {
|
||||
throw new InvalidArgumentException(
|
||||
sprintf('Unbekannte Theme-Schrift "%s". Vorhanden: heading, body.', $name)
|
||||
);
|
||||
}
|
||||
|
||||
return VariableRef::serialize('content', $id, []);
|
||||
}
|
||||
|
||||
private function post(string $name): string
|
||||
{
|
||||
$option = self::POST[strtolower($name)] ?? null;
|
||||
if ($option === null) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Unbekannter Beitragswert "%s". Vorhanden: %s.',
|
||||
$name,
|
||||
implode(', ', array_keys(self::POST))
|
||||
));
|
||||
}
|
||||
|
||||
$settings = in_array($option, self::TEXT_SETTINGS, true) ? ['before' => '', 'after' => ''] : [];
|
||||
|
||||
return VariableRef::serialize('content', $option, $settings);
|
||||
}
|
||||
|
||||
private function field(string $name): string
|
||||
{
|
||||
if (preg_match(VariableRef::META_KEY, $name) !== 1) {
|
||||
throw new InvalidArgumentException(sprintf(
|
||||
'Ungültiger Feldschlüssel "%s" (erlaubt: Buchstaben, Ziffern, "_" und "-", nicht mit "_" am Anfang).',
|
||||
$name
|
||||
));
|
||||
}
|
||||
|
||||
return VariableRef::serialize('content', 'custom_meta_' . $name, ['before' => '', 'after' => '']);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user