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
@@ -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());
}
}