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:
@@ -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');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user