Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
199 lines
7.0 KiB
PHP
199 lines
7.0 KiB
PHP
<?php
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace LIT\MCP\Integrations\Divi\Compose;
|
||
|
||
use InvalidArgumentException;
|
||
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
|
||
|
||
defined('ABSPATH') || exit;
|
||
|
||
/**
|
||
* 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>}}, {{link:<ID>}} (internal link, URL fields only – E13a).
|
||
* 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, DesignSystem::colorIdFor/variableFor).
|
||
* 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|link)\s*:\s*([^{}]+?)\s*\}\}$/';
|
||
|
||
/** @param LinkTargets|null $links Entries {{link:…}} may point to; without them every link fails. */
|
||
public function __construct(private DesignSystem $design, private ?LinkTargets $links = null)
|
||
{
|
||
}
|
||
|
||
/**
|
||
* 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, link).'
|
||
);
|
||
}
|
||
|
||
return $value;
|
||
}
|
||
|
||
[, $kind, $name] = $match;
|
||
|
||
return match ($kind) {
|
||
'color' => $this->color($name),
|
||
'var' => $this->variable($name),
|
||
'font' => $this->font($name),
|
||
'post' => $this->post($name),
|
||
'link' => $this->link($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
|
||
{
|
||
$id = $this->design->colorIdFor($name);
|
||
if ($id === null) {
|
||
$colors = $this->design->colors();
|
||
$known = array_merge(
|
||
array_keys(array_intersect(DesignSystem::COLOR_ALIASES, array_column($colors, 'id'))),
|
||
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
|
||
{
|
||
$id = $this->design->variableFor($name);
|
||
if ($id === null) {
|
||
$labels = array_column($this->design->variables(), 'label');
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Unbekannte globale Variable "%s". Vorhanden: %s.',
|
||
$name,
|
||
$labels === [] ? '(keine)' : implode(', ', $labels)
|
||
));
|
||
}
|
||
|
||
return VariableRef::serialize($this->design->isColorVariable($id) ? 'color' : 'content', $id, []);
|
||
}
|
||
|
||
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);
|
||
}
|
||
|
||
/**
|
||
* {{link:<ID>}} → Divi's own internal link post_link_url_<type> with settings.post_id (F27):
|
||
* rendered as get_permalink() at view time, so a draft target gets its pretty URL once published.
|
||
* Whether Divi offers that option (public post types only) VariableRef checks against the registry;
|
||
* that the value goes into a URL field and the target is readable (S37) the choke point checks again.
|
||
*
|
||
* @throws InvalidArgumentException
|
||
*/
|
||
private function link(string $name): string
|
||
{
|
||
// FILTER_VALIDATE_INT: no id beyond PHP_INT_MAX that would saturate to another entry.
|
||
$id = preg_match('/^[1-9][0-9]{0,18}$/D', $name) === 1 ? filter_var($name, FILTER_VALIDATE_INT) : false;
|
||
if (! is_int($id)) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Ungültige Beitrags-ID "%s" in {{link:…}} (positive Zahl erwartet, z. B. {{link:42}}).',
|
||
$name
|
||
));
|
||
}
|
||
|
||
$type = $this->links?->postTypeOf($id);
|
||
if ($type === null) {
|
||
throw new InvalidArgumentException(sprintf(
|
||
'Eintrag %s für {{link:…}} gibt es nicht, er liegt im Papierkorb oder ist nicht lesbar.',
|
||
$name
|
||
));
|
||
}
|
||
|
||
return VariableRef::serialize('content', 'post_link_url_' . $type, ['post_id' => $name]);
|
||
}
|
||
|
||
private function field(string $name): string
|
||
{
|
||
if (preg_match(DynamicContentRules::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' => '']);
|
||
}
|
||
}
|