diff --git a/src/Integrations/Divi/Compose/LinkTargets.php b/src/Integrations/Divi/Compose/LinkTargets.php new file mode 100644 index 0000000..4b6dea2 --- /dev/null +++ b/src/Integrations/Divi/Compose/LinkTargets.php @@ -0,0 +1,16 @@ +}} 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; +} diff --git a/src/Integrations/Divi/Compose/Placeholders.php b/src/Integrations/Divi/Compose/Placeholders.php index 34dec92..c7439b4 100644 --- a/src/Integrations/Divi/Compose/Placeholders.php +++ b/src/Integrations/Divi/Compose/Placeholders.php @@ -12,10 +12,10 @@ defined('ABSPATH') || exit; /** * Translates the placeholders of spec §5.4 into Divi's $variable(…)$ syntax: * {{color:primary||gcid-…}}, {{var:|gvid-…}}, {{font:heading|body}}, - * {{post:title|…}}, {{field:}}. 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:}}, {{link:}} (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:}} → Divi's own internal link post_link_url_ 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) { diff --git a/src/Integrations/Divi/Compose/VariableRef.php b/src/Integrations/Divi/Compose/VariableRef.php index 1fe416c..2d1ee19 100644 --- a/src/Integrations/Divi/Compose/VariableRef.php +++ b/src/Integrations/Divi/Compose/VariableRef.php @@ -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_ * (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_ ({{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) { diff --git a/src/Integrations/Divi/Pages/DiviComposition.php b/src/Integrations/Divi/Pages/DiviComposition.php index df4ea27..f67c2e3 100644 --- a/src/Integrations/Divi/Pages/DiviComposition.php +++ b/src/Integrations/Divi/Pages/DiviComposition.php @@ -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). */ diff --git a/src/Integrations/Divi/Pages/WpLinkTargets.php b/src/Integrations/Divi/Pages/WpLinkTargets.php new file mode 100644 index 0000000..3db9db8 --- /dev/null +++ b/src/Integrations/Divi/Pages/WpLinkTargets.php @@ -0,0 +1,30 @@ +}} 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; + } +} diff --git a/tests/Support/ComposeFixture.php b/tests/Support/ComposeFixture.php index aec6f11..a8abb15 100644 --- a/tests/Support/ComposeFixture.php +++ b/tests/Support/ComposeFixture.php @@ -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. */ diff --git a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php index c25688e..9f1eb8c 100644 --- a/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php +++ b/tests/Unit/Integrations/Divi/Compose/PlaceholdersTest.php @@ -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 */ + /** + * 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 + */ 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}}'); + } } diff --git a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php index 4ca6441..158a868 100644 --- a/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php +++ b/tests/Unit/Integrations/Divi/Compose/ShortformWriterTest.php @@ -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_ 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":"x"}')], '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); + } +} diff --git a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php index 770d110..c2d86de 100644 --- a/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php +++ b/tests/Unit/Integrations/Divi/Compose/VariableRefTest.php @@ -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_) 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()); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php index a565513..d4a656f 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviCompositionTest.php @@ -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(); diff --git a/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php b/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php new file mode 100644 index 0000000..d62ef23 --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php @@ -0,0 +1,37 @@ + 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'); + } +}