diff --git a/src/Integrations/Divi/Pages/DiviTextSearch.php b/src/Integrations/Divi/Pages/DiviTextSearch.php index d4ad9a6..5a5df65 100644 --- a/src/Integrations/Divi/Pages/DiviTextSearch.php +++ b/src/Integrations/Divi/Pages/DiviTextSearch.php @@ -69,8 +69,9 @@ final class DiviTextSearch /** * Every hit of $page, computed without changing it. Without $globalIds (ruling P13: no library - * access) the library IDs a global instance stores are no text to find: an opaque node is - * matched without globalModule/globalParent, as if they were not stored. + * access) the library IDs a global instance stores are no text to find: a global node is + * matched without globalModule/globalParent, as if they were not stored; every other opaque + * node (e.g. undecodable attributes) is matched as stored, the same with or without access. * * @return array{count: int, samples: list>, * blocks: list}>, skipped_nodes: list} @@ -89,7 +90,9 @@ final class DiviTextSearch foreach ($page->index->entries() as $entry) { $block = $entry['block']; if ($block->isDivi() && $block->isOpaque()) { - $markup = $globalIds ? Serializer::block($block) : self::withoutLibraryIds($block); + $markup = $globalIds || ! $block->isGlobal() + ? Serializer::block($block) + : self::withoutLibraryIds($block); if ($replacer->mentions($markup)) { $skipped[] = ['id' => (string) $entry['id'], 'reason' => self::opaqueReason($block)]; } @@ -120,8 +123,9 @@ final class DiviTextSearch } /** - * Ruling P13: $block's markup with every globalModule/globalParent left out, at every depth – - * only for matching, never saved. + * Ruling P13: the markup of the global node $block (decodable attributes, as isGlobal() needs) + * with every globalModule/globalParent left out, at every depth – only for matching, never + * saved. Openers are rebuilt from the decoded attributes, so Divi's raw spelling is not kept. */ private static function withoutLibraryIds(Block $block): string { diff --git a/src/Integrations/Divi/Pages/WpLinkTargets.php b/src/Integrations/Divi/Pages/WpLinkTargets.php index fac4c6a..5be3764 100644 --- a/src/Integrations/Divi/Pages/WpLinkTargets.php +++ b/src/Integrations/Divi/Pages/WpLinkTargets.php @@ -4,32 +4,62 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Pages; +use Closure; use LIT\MCP\Integrations\Divi\Compose\LinkTargets; use WP_Post; defined('ABSPATH') || exit; /** - * {{link:}} and post_link_url_* targets from WordPress (ruling P10): the entry exists, lives - * (no trash, auto-draft or revision), is no attachment (whatever its status) 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). + * {{link:}} and post_link_url_* targets from WordPress (rulings P10, Q14): the entry exists, + * lives (no trash, auto-draft or revision), is no attachment (whatever its status), its type has a + * Divi link option post_link_url_ (never the library et_pb_layout or an internal "_" type) and + * the current user may read it (read_post, like select_post settings, S37). Missing, unreadable and + * unlinkable entries give the same null (no existence or type oracle). */ final class WpLinkTargets implements LinkTargets { private const DEAD = ['trash', 'auto-draft', 'inherit']; + /** Never link targets, whatever Divi lists: attachments and the Divi Library (ruling Q14). */ + private const NEVER = ['attachment', 'et_pb_layout']; + + /** @var Closure(string): bool */ + private Closure $linkable; + + /** + * @param (Closure(string): bool)|null $linkable Whether Divi offers post_link_url_ (tests); + * null = Divi's et_builder_get_public_post_types(), the list its link option uses. + */ + public function __construct(?Closure $linkable = null) + { + $this->linkable = $linkable ?? self::diviLinkable(...); + } + public function postTypeOf(int $id): ?string { $post = get_post($id); - if ( - ! $post instanceof WP_Post - || in_array((string) $post->post_status, self::DEAD, true) - || $post->post_type === 'attachment' - ) { + 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; + $type = (string) $post->post_type; + if (in_array($type, self::NEVER, true) || str_starts_with($type, '_') || ! ($this->linkable)($type)) { + return null; + } + + return current_user_can('read_post', $id) ? $type : null; + } + + /** Fail closed: without Divi's list no type has a link option. */ + private static function diviLinkable(string $type): bool + { + if (! function_exists('et_builder_get_public_post_types')) { + return false; + } + + $types = et_builder_get_public_post_types(); + + return is_array($types) && array_key_exists($type, $types); } } diff --git a/src/Integrations/Divi/Pages/WpValueSanitizer.php b/src/Integrations/Divi/Pages/WpValueSanitizer.php index 0b372b2..8bcafc6 100644 --- a/src/Integrations/Divi/Pages/WpValueSanitizer.php +++ b/src/Integrations/Divi/Pages/WpValueSanitizer.php @@ -4,6 +4,7 @@ declare(strict_types=1); namespace LIT\MCP\Integrations\Divi\Pages; +use LIT\MCP\Integrations\Divi\Compose\LinkTargets; use LIT\MCP\Integrations\Divi\Compose\ValueSanitizer; defined('ABSPATH') || exit; @@ -19,6 +20,11 @@ defined('ABSPATH') || exit; */ final class WpValueSanitizer implements ValueSanitizer { + /** @param LinkTargets $links The {{link:}} targets post_link_url_* follow too (ruling P10). */ + public function __construct(private LinkTargets $links = new WpLinkTargets()) + { + } + public function text(string $value): string { return sanitize_text_field($value); @@ -51,6 +57,6 @@ final class WpValueSanitizer implements ValueSanitizer public function linkTargetType(int $id): ?string { - return (new WpLinkTargets())->postTypeOf($id); + return $this->links->postTypeOf($id); } } diff --git a/src/Integrations/WordPress/WordPressIntegration.php b/src/Integrations/WordPress/WordPressIntegration.php index 0792e65..06317b0 100644 --- a/src/Integrations/WordPress/WordPressIntegration.php +++ b/src/Integrations/WordPress/WordPressIntegration.php @@ -49,8 +49,8 @@ defined('ABSPATH') || exit; */ final class WordPressIntegration implements Integration { - /** @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). */ /** + * @param ContentComposer|null $divi Divi short form for lit/content-create (DiviIntegration). * @param (Closure(): bool)|null $globalIdsVisible Ruling P13: whether lit-page-render-summary may name * global module IDs (the Divi integration's library access); null = always. */ diff --git a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php index 5f3c69d..a7f2981 100644 --- a/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DiviReplaceTextAbilityTest.php @@ -464,6 +464,22 @@ final class DiviReplaceTextAbilityTest extends TestCase self::assertSame([], $byId['skipped_nodes']); } + /** Ruling P13/Q14: only global subtrees lose their IDs – an undecodable node is matched as stored, with or without access. */ + public function testUndecodableNodesAreMatchedAlikeWithAndWithoutLibraryAccess(): void + { + $content = str_replace( + '', + '', + self::page([self::text('

Meier

')]) + ); + + $hidden = $this->ability($content)->execute(['search' => 'Müller', 'replace' => 'x']); + $shown = $this->ability($content, false, [7], true)->execute(['search' => 'Müller', 'replace' => 'x']); + + self::assertSame(['s0.r0.c0.m1'], array_column($hidden['skipped_nodes'], 'id')); + self::assertSame($shown['skipped_nodes'], $hidden['skipped_nodes']); + } + /** No effective change (search == replacement, cleaned replacement == match): nothing saved. */ public function testNoEffectiveChangeSavesNothing(): void { diff --git a/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php b/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php index bade31c..7bdcb6e 100644 --- a/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php +++ b/tests/Unit/Integrations/Divi/Pages/WpLinkTargetsTest.php @@ -5,7 +5,12 @@ declare(strict_types=1); namespace LIT\MCP\Tests\Unit\Integrations\Divi\Pages; use Brain\Monkey\Functions; +use InvalidArgumentException; +use LIT\MCP\Integrations\Divi\Compose\Placeholders; +use LIT\MCP\Integrations\Divi\Compose\VariableRef; use LIT\MCP\Integrations\Divi\Pages\WpLinkTargets; +use LIT\MCP\Integrations\Divi\Pages\WpValueSanitizer; +use LIT\MCP\Tests\Support\DiviFixtures; use LIT\MCP\Tests\Unit\TestCase; use WP_Post; @@ -28,7 +33,7 @@ final class WpLinkTargetsTest extends TestCase static fn (string $cap, int $id): bool => $cap === 'read_post' && $id !== 842 ); - $links = new WpLinkTargets(); + $links = new WpLinkTargets(self::linkable()); self::assertSame('page', $links->postTypeOf(839)); self::assertNull($links->postTypeOf(841), 'trashed'); @@ -40,4 +45,77 @@ final class WpLinkTargetsTest extends TestCase self::assertNull($links->postTypeOf(846), 'trashed attachment'); self::assertNull($links->postTypeOf(847), 'attachment with an own status (ruling P10: no attachment)'); } + + /** Post types Divi offers post_link_url_ for in these tests (et_builder_get_public_post_types). */ + private static function linkable(): \Closure + { + return static fn (string $type): bool => in_array($type, ['page', 'post', 'attachment', 'lit_person'], true); + } + + /** + * Ruling Q14: a type without a Divi link option (the library, internal "_" types, any other) is no + * link target – answered like a missing ID, so {{link:}} tells nobody which IDs are library entries. + */ + public function testTypesWithoutADiviLinkOptionAreNotFound(): void + { + $posts = [ + 1058 => new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout', 'post_status' => 'publish']), + 1070 => new WP_Post(['ID' => 1070, 'post_type' => '_internal', 'post_status' => 'publish']), + 1071 => new WP_Post(['ID' => 1071, 'post_type' => 'wp_template', 'post_status' => 'publish']), + 1072 => new WP_Post(['ID' => 1072, 'post_type' => 'lit_person', 'post_status' => 'publish']), + ]; + Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $posts[$id] ?? null); + Functions\when('current_user_can')->justReturn(true); + $everything = new WpLinkTargets(static fn (string $type): bool => true); + + $links = new WpLinkTargets(self::linkable()); + self::assertNull($links->postTypeOf(1058), 'library entry'); + self::assertNull($links->postTypeOf(1070), 'internal type'); + self::assertNull($links->postTypeOf(1071), 'type without link option'); + self::assertSame('lit_person', $links->postTypeOf(1072)); + self::assertNull($everything->postTypeOf(1058), 'the library never, whatever Divi lists'); + self::assertNull($everything->postTypeOf(1070), 'internal types never, whatever Divi lists'); + self::assertNull((new WpLinkTargets())->postTypeOf(1072), 'without Divi no type has a link option'); + } + + /** + * Ruling Q14: {{link:}} and the full form answer a library ID exactly like a missing ID – with and + * without the library right (read_post on the entry), so no guessed ID reveals a library entry. + */ + public function testALibraryIdIsAnsweredLikeAMissingId(): void + { + Functions\when('get_post')->alias(static fn (int $id): ?WP_Post => $id === 1058 + ? new WP_Post(['ID' => 1058, 'post_type' => 'et_pb_layout', 'post_status' => 'publish']) + : null); + $links = new WpLinkTargets(self::linkable()); + $placeholders = new Placeholders(DiviFixtures::designSystem(), $links); + $ref = new VariableRef(DiviFixtures::designSystem(), ['post_link_url_page' => ['post_id' => 'select_post']]); + $sanitizer = new WpValueSanitizer($links); + $answer = static function (callable $call, string $id): string { + try { + $call($id); + + return 'accepted'; + } catch (InvalidArgumentException $e) { + return str_replace($id, '', $e->getMessage()); + } + }; + $short = static fn (string $id): string => $placeholders->resolve('{{link:' . $id . '}}'); + $full = static fn (string $id): string => $ref->canonical( + '$variable({"type":"content","value":{"name":"post_link_url_page","settings":{"post_id":"' . $id . '"}}})$', + $sanitizer, + true + ); + + foreach (['with the library right' => true, 'without it' => false] as $case => $right) { + Functions\when('current_user_can')->justReturn($right); + foreach (['{{link:}}' => $short, 'post_link_url_page' => $full] as $form => $call) { + $library = $answer($call, '1058'); + self::assertNotSame('accepted', $library, $case . ' ' . $form); + self::assertSame($answer($call, '1066'), $library, $case . ' ' . $form); + self::assertStringNotContainsString('et_pb_layout', $library, $case . ' ' . $form); + } + } + } } + diff --git a/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php b/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php index d1013bb..776363e 100644 --- a/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php +++ b/tests/Unit/Integrations/Divi/Pages/WpValueSanitizerTest.php @@ -5,6 +5,7 @@ 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\Integrations\Divi\Pages\WpValueSanitizer; use LIT\MCP\Tests\Unit\TestCase; use WP_Post; @@ -72,7 +73,7 @@ final class WpValueSanitizerTest extends TestCase default => null, }); Functions\when('current_user_can')->alias(static fn (string $cap, int $id): bool => $id !== 16); - $sanitizer = new WpValueSanitizer(); + $sanitizer = new WpValueSanitizer(new WpLinkTargets(static fn (string $type): bool => $type !== 'wp_block')); self::assertSame('page', $sanitizer->linkTargetType(12)); self::assertSame('post', $sanitizer->linkTargetType(20));