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;
}
}
+17 -1
View File
@@ -6,6 +6,7 @@ namespace LIT\MCP\Tests\Support;
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;
@@ -128,9 +129,24 @@ final class ComposeFixture
};
}
/** Link targets of the tests: 839 is a page, 840 a post; everything else is missing, trashed or unreadable. */
public static function links(): LinkTargets
{
return new class implements LinkTargets {
public function postTypeOf(int $id): ?string
{
return match ($id) {
839 => 'page',
840 => 'post',
default => null,
};
}
};
}
public static function placeholders(): Placeholders
{
return new Placeholders(DiviFixtures::designSystem());
return new Placeholders(DiviFixtures::designSystem(), self::links());
}
/** The strict $variable(…)$ checker for the sample design system and the default map. */
@@ -6,13 +6,21 @@ namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Design\DesignSystem;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
final class PlaceholdersTest extends TestCase
{
/** @return array<string, array{string, string}> */
/**
* Rows with a third value true go into a URL field (ruling P4: only the link row – URL mode
* refuses colour references by design).
*
* @return array<string, array{0: string, 1: string, 2?: bool}>
*/
public static function placeholders(): array
{
return [
@@ -31,20 +39,30 @@ final class PlaceholdersTest extends TestCase
'{{field:telefon_mobil}}',
'$variable({"type":"content","value":{"name":"custom_meta_telefon_mobil","settings":{"before":"","after":""}}})$',
],
'internal link' => [
'{{link:839}}',
'$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$',
true,
],
'plain text' => ['Hallo Welt', 'Hallo Welt'],
];
}
/** @dataProvider placeholders */
public function testResolvesToDiviVariables(string $input, string $expected): void
public function testResolvesToDiviVariables(string $input, string $expected, bool $url = false): void
{
$placeholders = ComposeFixture::placeholders();
$resolved = $placeholders->resolve($input);
self::assertSame($expected, $resolved);
if ($expected !== $input) {
// What placeholders produce passes the strict checker unchanged (ruling S1/S25).
self::assertSame($resolved, ComposeFixture::references()->canonical($resolved, ComposeFixture::sanitizer()));
// What placeholders produce passes the strict checker unchanged (ruling S1/S25); the live
// registry map knows post_link_url_page, the URL mode allows it.
$references = new VariableRef(
DiviFixtures::designSystem(),
DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())
);
self::assertSame($resolved, $references->canonical($resolved, ComposeFixture::sanitizer(), $url));
}
}
@@ -60,6 +78,11 @@ final class PlaceholdersTest extends TestCase
'bad field key' => ['{{field:a b}}', 'Ungültiger Feldschlüssel'],
'protected field key' => ['{{field:_edit_lock}}', 'Ungültiger Feldschlüssel'],
'embedded' => ['Seite: {{post:title}}', 'allein im Wert'],
'link without id' => ['{{link:start}}', 'Ungültige Beitrags-ID'],
'link to a missing post' => ['{{link:999}}', 'Eintrag 999'],
'link without link targets' => ['{{link:0}}', 'Ungültige Beitrags-ID'],
'link with a leading zero' => ['{{link:0839}}', 'Ungültige Beitrags-ID'],
'link beyond PHP_INT_MAX' => ['{{link:9999999999999999999}}', 'Ungültige Beitrags-ID'],
];
}
@@ -137,4 +160,12 @@ final class PlaceholdersTest extends TestCase
ComposeFixture::placeholders()->resolveDeep(['color' => '{{color:primary}}', 'size' => '20px'])
);
}
public function testLinksNeedLinkTargets(): void
{
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('Eintrag 839');
(new Placeholders(DiviFixtures::designSystem()))->resolve('{{link:839}}');
}
}
@@ -5,6 +5,11 @@ declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Compose;
use InvalidArgumentException;
use LIT\MCP\Integrations\Divi\Compose\Placeholders;
use LIT\MCP\Integrations\Divi\Compose\ShortformWriter;
use LIT\MCP\Integrations\Divi\Compose\ValueCleaner;
use LIT\MCP\Integrations\Divi\Compose\VariableRef;
use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap;
use LIT\MCP\Tests\Support\ComposeFixture;
use LIT\MCP\Tests\Support\DiviFixtures;
use PHPUnit\Framework\TestCase;
@@ -352,5 +357,73 @@ final class ShortformWriterTest extends TestCase
self::assertStringContainsString('gcid-a1b2', $color['attrs']['divider']['advanced']['line']['desktop']['value']['color']);
}
}
/** E13a: {{link:ID}} becomes Divi's post_link_url_<type> in URL fields – and only there. */
public function testInternalLinkOnlyInUrlFields(): void
{
$writer = self::linkWriter();
$button = $writer->apply('button', [], ['text' => 'Kontakt', 'url' => '{{link:839}}'], 'n');
self::assertSame([], $button['errors']);
self::assertSame(
'$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"839"}}})$',
$button['attrs']['button']['innerContent']['desktop']['value']['linkUrl']
);
$text = $writer->apply('button', [], ['text' => '{{link:839}}'], 'n');
self::assertStringContainsString('nur in Link- und URL-Feldern', $text['errors']['n.text']);
// A post type the registry offers no link option for (fixture: page only): unknown name.
$post = $writer->apply('button', [], ['url' => '{{link:840}}'], 'n');
self::assertStringContainsString('post_link_url_post', $post['errors']['n.url']);
}
/**
* The full form goes through the same choke point: URL fields only, no before/after, and the
* target must be readable (select_post, ruling S37 / preflight §4) – no path around ValueCleaner.
*/
public function testInternalLinkFullFormIsCheckedLikeThePlaceholder(): void
{
$writer = self::linkWriter();
$link = static fn (string $settings): string =>
'$variable({"type":"content","value":{"name":"post_link_url_page","settings":' . $settings . '}})$';
$valid = $writer->apply('button', [], ['url' => $link('{"post_id":839}')], 'n');
self::assertSame([], $valid['errors']);
self::assertSame($link('{"post_id":839}'), $valid['attrs']['button']['innerContent']['desktop']['value']['linkUrl']);
$unreadable = $writer->apply('button', [], ['url' => $link('{"post_id":"666"}')], 'n');
self::assertStringContainsString('lesbaren Eintrags', $unreadable['errors']['n.url']);
$slug = $writer->apply('button', [], ['url' => $link('{"post_id":"kontakt"}')], 'n');
self::assertStringContainsString('lesbaren Eintrags', $slug['errors']['n.url']);
$extra = $writer->apply('button', [], ['url' => $link('{"post_id":"839","before":"<b>x</b>"}')], 'n');
self::assertArrayHasKey('n.url', $extra['errors']);
self::assertSame([], $extra['attrs']);
$inText = $writer->apply('heading', [], ['text' => $link('{"post_id":"839"}')], 'n');
self::assertStringContainsString('nur in Link- und URL-Feldern', $inText['errors']['n.text']);
// A control-split placeholder is the placeholder (S35) and resolves the same way.
$split = $writer->apply('button', [], ['url' => '{' . chr(0) . '{link:839}}'], 'n');
self::assertSame([], $split['errors']);
self::assertSame(
$link('{"post_id":"839"}'),
$split['attrs']['button']['innerContent']['desktop']['value']['linkUrl']
);
}
/** ShortformWriter with the live registry map (knows post_link_url_page) and the test link targets. */
private static function linkWriter(): ShortformWriter
{
$design = DiviFixtures::designSystem();
$cleaner = new ValueCleaner(
DiviFixtures::catalog(),
new VariableRef($design, DynamicContentMap::fromOptions(DiviFixtures::dynamicContentRegistry())),
ComposeFixture::sanitizer()
);
return new ShortformWriter(new Placeholders($design, ComposeFixture::links()), ComposeFixture::media(), $cleaner);
}
}
@@ -421,11 +421,11 @@ final class VariableRefTest extends TestCase
);
self::assertSame(
self::content('post_link_url_page', '{"post_id":"12"}'),
$ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer)
$ref->canonical(self::content('post_link_url_page', '{"post_id":"12"}'), $sanitizer, true)
);
self::assertSame(
self::content('post_link_url_page', '{"post_id":12}'),
$ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer)
$ref->canonical(self::content('post_link_url_page', '{"post_id":12}'), $sanitizer, true)
);
$rejected = [
@@ -440,12 +440,25 @@ final class VariableRefTest extends TestCase
];
foreach ($rejected as $case => [$value, $fragment]) {
try {
$ref->canonical($value, $sanitizer);
// URL mode: internal links (post_link_url_*) are allowed only there (E13a).
$ref->canonical($value, $sanitizer, true);
self::fail('Accepted: ' . $case);
} catch (InvalidArgumentException $e) {
self::assertStringContainsString($fragment, $e->getMessage(), $case);
}
}
}
}
/** E13a: an internal link (post_link_url_<type>) is a URL – refused in every other field. */
public function testInternalLinksOnlyInUrlFields(): void
{
$ref = self::ref(['post_link_url_page' => ['post_id' => 'select_post']]);
$link = self::content('post_link_url_page', '{"post_id":"12"}');
self::assertSame($link, $ref->canonical($link, ComposeFixture::sanitizer(), true));
$this->expectException(InvalidArgumentException::class);
$this->expectExceptionMessage('nur in Link- und URL-Feldern');
$ref->canonical($link, ComposeFixture::sanitizer());
}
}
@@ -37,7 +37,14 @@ final class DiviCompositionTest extends TestCase
return DiviFixtures::dynamicContentRegistry();
});
return new DiviComposition($modules, $design, ComposeFixture::sanitizer(), ComposeFixture::media(), $registry);
return new DiviComposition(
$modules,
$design,
ComposeFixture::sanitizer(),
ComposeFixture::media(),
$registry,
ComposeFixture::links()
);
}
private static function siteTitle(): string
@@ -74,6 +81,24 @@ final class DiviCompositionTest extends TestCase
self::assertSame($composition->references(), $composition->references());
}
/** E13a: the composition hands its link targets to Placeholders; the result still passes ValueCleaner. */
public function testInternalLinksUseTheLinkTargets(): void
{
$composer = $this->composition()->forPost(7)->composer();
$column = Block::create('divi/column');
$page = $composer->compose([['type' => 'button', 'text' => 'Kontakt', 'url' => '{{link:839}}']], $column, 'nodes');
self::assertSame([], $page['errors']);
self::assertStringContainsString(
'post_link_url_page',
json_encode($page['blocks'][0]->attrs(), JSON_THROW_ON_ERROR)
);
$missing = $composer->compose([['type' => 'button', 'url' => '{{link:999}}']], $column, 'nodes');
self::assertStringContainsString('Eintrag 999', $missing['errors']['nodes[0].url']);
}
public function testWithoutAPostTheDefaultMapApplies(): void
{
$composition = $this->composition();
@@ -0,0 +1,37 @@
<?php
declare(strict_types=1);
namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages;
use Brain\Monkey\Functions;
use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets;
use LIT\MCP\Tests\Unit\TestCase;
use WP_Post;
final class WpLinkTargetsTest extends TestCase
{
public function testOnlyReadableLivingPostsCanBeLinked(): void
{
$posts = [
839 => new WP_Post(['ID' => 839, 'post_type' => 'page', 'post_status' => 'draft']),
841 => new WP_Post(['ID' => 841, 'post_type' => 'page', 'post_status' => 'trash']),
842 => new WP_Post(['ID' => 842, 'post_type' => 'page', 'post_status' => 'private']),
843 => new WP_Post(['ID' => 843, 'post_type' => 'revision', 'post_status' => 'inherit']),
844 => new WP_Post(['ID' => 844, 'post_type' => 'page', 'post_status' => 'auto-draft']),
];
Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null);
Functions\when('current_user_can')->alias(
static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842
);
$links = new WpLinkTargets();
self::assertSame('page', $links->postTypeOf(839));
self::assertNull($links->postTypeOf(841), 'trashed');
self::assertNull($links->postTypeOf(842), 'not readable');
self::assertNull($links->postTypeOf(843), 'revision');
self::assertNull($links->postTypeOf(844), 'auto-draft');
self::assertNull($links->postTypeOf(900), 'missing');
}
}