feat: {{link:ID}} placeholder via Divi's post_link_url dynamic content

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-27 00:32:59 +02:00
co-authored by Claude Opus 5.5
parent 404f0f34b9
commit 788943e809
11 changed files with 304 additions and 21 deletions
@@ -0,0 +1,16 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Compose;
defined('ABSPATH') || exit;
/**
* Entries {{link:<ID>}} may point to (E13a). Pure interface – WpLinkTargets asks WordPress.
*/
interface LinkTargets
{
/** Post type of entry $id when it may be linked (exists, not trashed, readable); null otherwise. */
public function postTypeOf(int $id): ?string;
}
+39 -7
View File
@@ -12,10 +12,10 @@ 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>}}. 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.
* {{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
{
@@ -37,9 +37,10 @@ final class Placeholders
'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 const PATTERN = '/^\{\{\s*(color|var|font|post|field|link)\s*:\s*([^{}]+?)\s*\}\}$/';
public function __construct(private DesignSystem $design)
/** @param LinkTargets|null $links Entries {{link:…}} may point to; without them every link fails. */
public function __construct(private DesignSystem $design, private ?LinkTargets $links = null)
{
}
@@ -54,7 +55,7 @@ final class Placeholders
if (str_contains($value, '{{')) {
throw new InvalidArgumentException(
'Platzhalter müssen allein im Wert stehen, z. B. "{{color:primary}}" (bekannt: color, var, font, '
. 'post, field).'
. 'post, field, link).'
);
}
@@ -68,6 +69,7 @@ final class Placeholders
'var' => $this->variable($name),
'font' => $this->font($name),
'post' => $this->post($name),
'link' => $this->link($name),
default => $this->field($name),
};
}
@@ -152,6 +154,36 @@ final class Placeholders
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) {
@@ -26,7 +26,8 @@ defined('ABSPATH') || exit;
* settings hue/saturation/lightness/opacity, numbers only.
* - type "content": DynamicContentRules – the dynamic content map (ruling S22), custom_meta_<key>
* (S12/S29/S30), non-color global variables and theme fonts (S27); it also cleans each content
* setting. "enable_html" is always rejected here.
* setting. "enable_html" is always rejected here. Internal links post_link_url_<type> ({{link:…}},
* E13a) only in URL fields.
* A dynamic content map of the wrong shape fails in the constructor with InvalidArgumentException.
*/
final class VariableRef
@@ -232,6 +233,13 @@ final class VariableRef
*/
private function contentSettings(string $name, array $settings, ValueSanitizer $sanitizer, bool $url): array
{
if (! $url && str_starts_with($name, 'post_link_url_')) {
throw new InvalidArgumentException(sprintf(
'"%s" (Link auf einen Eintrag, {{link:…}}) nur in Link- und URL-Feldern.',
$name
));
}
$fields = $this->content->fieldsFor($name);
$clean = [];
foreach ($settings as $key => $value) {
@@ -6,6 +6,7 @@ namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Compose\AttrWriter;
use LIT\MCP\Integrations\Divi\Compose\Composer;
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
use LIT\MCP\Integrations\Divi\Compose\MediaResolver;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
@@ -45,7 +46,8 @@ class DiviComposition
private DesignSystemReader $design,
private ValueSanitizer $sanitizer = new WpValueSanitizer(),
private MediaResolver $media = new WpMediaResolver(),
private DynamicContentMap $dynamicContent = new DynamicContentMap()
private DynamicContentMap $dynamicContent = new DynamicContentMap(),
private LinkTargets $links = new WpLinkTargets()
) {
}
@@ -83,7 +85,7 @@ class DiviComposition
*/
public function placeholders(): Placeholders
{
return $this->placeholders ??= new Placeholders($this->design->read());
return $this->placeholders ??= new Placeholders($this->design->read(), $this->links);
}
/** The one choke point of this composition (ValueCleaner, rulings S1–S3, S7, S13, S28, S32). */
@@ -0,0 +1,30 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Integrations\Divi\Pages;
use LIT\MCP\Integrations\Divi\Compose\LinkTargets;
use WP_Post;
defined('ABSPATH') || exit;
/**
* {{link:<ID>}} targets from WordPress: the entry exists, lives (no trash, auto-draft or
* revision) and the current user may read it (read_post, like select_post settings, S37).
* Missing and unreadable entries give the same null (no existence oracle).
*/
final class WpLinkTargets implements LinkTargets
{
private const DEAD = ['trash', 'auto-draft', 'inherit'];
public function postTypeOf(int $id): ?string
{
$post = get_post($id);
if (! $post instanceof WP_Post || in_array((string) $post->post_status, self::DEAD, true)) {
return null;
}
return current_user_can('read_post', $id) ? (string) $post->post_type : null;
}
}