diff --git a/docs/guides/overview.md b/docs/guides/overview.md index 2cec51e..29aad7e 100644 --- a/docs/guides/overview.md +++ b/docs/guides/overview.md @@ -36,7 +36,7 @@ Status (`lit/post-unpublish`, `lit/post-schedule`, `lit/post-trash`), Kopien (`l `lit/post-replace-with-copy`), Revisionen (`lit/revisions-list`, `lit/revision-restore`), Menüs (`lit/menu-list`, `lit/menu-get`, `lit/menu-create`, `lit/menu-set-items`, `lit/menu-assign-location`), Einstellungen (`lit/wp-settings-get`, `lit/wp-settings-update`), Medien (`lit/media-get`, `lit/media-upload`, -`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). +`lit/media-update`, `lit/media-delete`), Divi (`lit/divi-duplicate-node`, `lit/divi-replace-text`, `lit/divi-node-copy`, `lit/divi-dynamic-sources`), Divi-Bibliothek (`lit/library-list`, `lit/library-save`). Die `lit-divi-…`-Werkzeuge gibt es nur, wenn der Divi-5-Builder aktiv ist. diff --git a/src/Integrations/Divi/DiviIntegration.php b/src/Integrations/Divi/DiviIntegration.php index b3493e1..d94676e 100644 --- a/src/Integrations/Divi/DiviIntegration.php +++ b/src/Integrations/Divi/DiviIntegration.php @@ -19,6 +19,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviComposition; use LIT\MCP\Integrations\Divi\Pages\DiviContentComposer; use LIT\MCP\Integrations\Divi\Pages\DiviDeleteAbility; use LIT\MCP\Integrations\Divi\Pages\DiviDuplicateNodeAbility; +use LIT\MCP\Integrations\Divi\Pages\DiviDynamicSourcesAbility; use LIT\MCP\Integrations\Divi\Pages\DiviInsertAbility; use LIT\MCP\Integrations\Divi\Pages\DiviMoveAbility; use LIT\MCP\Integrations\Divi\Pages\DiviNodeCopyAbility; @@ -29,6 +30,7 @@ use LIT\MCP\Integrations\Divi\Pages\DiviReplaceTextAbility; use LIT\MCP\Integrations\Divi\Pages\DiviSaver; use LIT\MCP\Integrations\Divi\Pages\DiviUpdateAbility; use LIT\MCP\Integrations\Divi\Pages\DiviWriter; +use LIT\MCP\Integrations\Divi\Pages\DynamicContentMap; use LIT\MCP\Integrations\Divi\Schema\DiviCatalogAbility; use LIT\MCP\Integrations\Divi\Schema\DiviModules; use LIT\MCP\Integrations\Integration; @@ -118,6 +120,7 @@ final class DiviIntegration implements Integration new DiviDuplicateNodeAbility($writer), new DiviReplaceTextAbility($this->schema, $loader, $writer), new DiviNodeCopyAbility($writer, $loader, $this->diviWritable), + new DiviDynamicSourcesAbility($this->schema, new DynamicContentMap(), $this->access->roles()), ]; } diff --git a/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php new file mode 100644 index 0000000..056109e --- /dev/null +++ b/src/Integrations/Divi/Pages/DiviDynamicSourcesAbility.php @@ -0,0 +1,294 @@ +}}. + * + * Answer F4: only text-like fields are offered; relation/file fields show IDs in Divi (F26), Pods + * table storage is not post meta, protected keys never (S12), unknown types not (ruling P9) – each + * left out with its reason. Hidden fields (security audit M4) are neither offered nor named. The + * field list needs the Divi role option read_dynamic_content_custom_fields (E3, F25). + */ +final class DiviDynamicSourcesAbility implements Ability +{ + /** Field types Divi can show as text (arrays of strings are joined with ", ", F26). */ + private const TEXT_TYPES = ['string', 'text', 'number', 'integer', 'date', 'datetime', 'time', 'choice']; + + private const ID_TYPES = ['relation', 'file']; + + /** Candidates the default sample lookup checks for edit_post. */ + private const SAMPLE_CANDIDATES = 10; + + /** @var Closure(string): int */ + private Closure $samplePost; + + /** @var Closure(string): ?string */ + private Closure $podStorage; + + /** + * @param (Closure(string): int)|null $samplePost Post type → an entry to ask Divi with (0 = none). + * @param (Closure(string): ?string)|null $podStorage Post type → Pods storage ("meta", "table"); + * null without a pod. + */ + public function __construct( + private ContentSchema $schema, + private DynamicContentMap $map, + private DiviRoles $roles, + ?Closure $samplePost = null, + ?Closure $podStorage = null + ) { + $this->samplePost = $samplePost ?? self::defaultSample(...); + $this->podStorage = $podStorage ?? self::defaultStorage(...); + } + + public function name(): string + { + return 'lit/divi-dynamic-sources'; + } + + public function label(): string + { + return 'Dynamic-Content-Quellen'; + } + + public function description(): string + { + return 'Listet die Dynamic-Content-Quellen, die Divi für einen Inhaltstyp anbietet (Beitragsdaten, Links, ' + . 'Website-Daten …) mit dem Platzhalter dazu ({{post:…}}, {{link:}}) bzw. dem Namen für die Vollform, ' + . 'und die Felder des Typs, die sich als {{field:}} eignen. Nur textartige Felder; Beziehungs-, ' + . 'Datei-, Tabellen- und unbekannte Felder stehen mit Grund unter omitted.'; + } + + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => ['post_type' => ['type' => 'string', 'description' => 'z. B. "page" oder "person".']], + 'required' => ['post_type'], + 'additionalProperties' => false, + ]; + } + + public function outputSchema(): ?array + { + $entry = static fn (array $properties): array => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => array_fill_keys($properties, ['type' => 'string']), + 'required' => $properties, + ], + ]; + + return [ + 'type' => 'object', + 'properties' => [ + 'post_type' => ['type' => 'string'], + 'sample_post_id' => ['type' => 'integer'], + 'sources' => [ + 'type' => 'array', + 'items' => [ + 'type' => 'object', + 'properties' => [ + 'name' => ['type' => 'string'], + 'label' => ['type' => 'string'], + 'group' => ['type' => 'string'], + 'placeholder' => ['type' => ['string', 'null']], + 'settings' => ['type' => 'object'], + ], + 'required' => ['name', 'label', 'group', 'placeholder', 'settings'], + ], + ], + 'fields' => $entry(['key', 'label', 'type', 'source', 'placeholder']), + 'omitted' => $entry(['key', 'label', 'reason']), + 'notes' => ['type' => 'array', 'items' => ['type' => 'string']], + ], + 'required' => ['post_type', 'sample_post_id', 'sources', 'fields', 'omitted', 'notes'], + ]; + } + + public function annotations(): array + { + return Annotations::read(); + } + + public function isDirect(): bool + { + return false; + } + + public function permission(array $input): bool|WP_Error + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + + return $type instanceof WP_Error ? $type : true; + } + + public function execute(array $input): mixed + { + $type = PostGuard::postType($input['post_type'] ?? null, $this->schema, 'edit_posts'); + if ($type instanceof WP_Error) { + return $type; + } + + $postType = (string) $input['post_type']; + $sample = ($this->samplePost)($postType); + $notes = [ + 'Quellen ohne Platzhalter in der Vollform als $variable({"type":"content","value":{"name":"",' + . '"settings":{…}}})$ mit den genannten Einstellungen schreiben.', + ]; + + $options = $this->map->options($sample); + if ($options === null) { + $notes[] = 'Divis Quellenliste ist nicht verfügbar; gezeigt werden die Quellen der Platzhalter {{post:…}}.'; + $options = []; + foreach (DynamicContentRules::DEFAULT_DYNAMIC_CONTENT as $name => $settings) { + $options[$name] = ['label' => $name, 'group' => '', 'settings' => $settings]; + } + } + + $sources = []; + foreach ($options as $name => $option) { + $sources[] = [ + 'name' => (string) $name, + 'label' => $option['label'], + 'group' => $option['group'], + 'placeholder' => self::placeholder((string) $name), + 'settings' => JsonMap::of($option['settings']), + ]; + } + + $fields = []; + $omitted = []; + if ($this->roles->allows('read_dynamic_content_custom_fields')) { + [$fields, $omitted] = $this->fields($postType); + } else { + $notes[] = 'Felder werden nicht gelistet: die Divi-Rolle erlaubt „Dynamic Content: benutzerdefinierte ' + . 'Felder lesen“ nicht (Divi → Rollen-Editor).'; + } + + return [ + 'post_type' => $postType, + 'sample_post_id' => $sample, + 'sources' => $sources, + 'fields' => $fields, + 'omitted' => $omitted, + 'notes' => $notes, + ]; + } + + private static function placeholder(string $name): ?string + { + $post = array_search($name, Placeholders::POST, true); + if ($post !== false) { + return '{{post:' . $post . '}}'; + } + + return str_starts_with($name, 'post_link_url_') ? '{{link:}}' : null; + } + + /** + * @return array{0: list>, 1: list>} Offered and omitted fields. + */ + private function fields(string $postType): array + { + $storage = ($this->podStorage)($postType); + $fields = []; + $omitted = []; + foreach ($this->schema->fields($postType) as $field) { + if ($field->status === FieldDefinition::HIDDEN) { + continue; + } + + $entry = ['key' => $field->key, 'label' => $field->label]; + $reason = self::omission($field, $storage); + if ($reason !== null) { + $omitted[] = $entry + ['reason' => $reason]; + continue; + } + + $fields[] = $entry + [ + 'type' => $field->type, + 'source' => $field->source, + 'placeholder' => '{{field:' . $field->key . '}}', + ]; + } + + return [$fields, $omitted]; + } + + private static function omission(FieldDefinition $field, ?string $storage): ?string + { + return match (true) { + ! DynamicContentRules::isMetaKey($field->key) + => 'geschütztes oder ungültiges Meta-Feld (beginnt mit „_“)', + $field->source === 'pods' && $storage === 'table' + => 'Pods mit Tabellen-Speicherung – Divi liest nur Post-Meta', + in_array($field->type, self::ID_TYPES, true) + => 'Beziehungs- oder Dateifeld – Divi zeigt davon nur IDs', + $field->type === 'unknown' + => 'Feldtyp unbekannt – Divi zeigt womöglich keinen Text', + ! in_array($field->type, self::TEXT_TYPES, true) + => sprintf('Feldtyp „%s“ ist nicht textartig', $field->type), + default => null, + }; + } + + /** The most recently modified entry of the type the current user may edit, else 0. */ + private static function defaultSample(string $postType): int + { + $ids = get_posts([ + 'post_type' => $postType, + 'post_status' => ['publish', 'draft', 'pending', 'private', 'future'], + 'posts_per_page' => self::SAMPLE_CANDIDATES, + 'orderby' => 'modified', + 'order' => 'DESC', + 'fields' => 'ids', + 'no_found_rows' => true, + ]); + + foreach (is_array($ids) ? $ids : [] as $id) { + if (current_user_can('edit_post', (int) $id)) { + return (int) $id; + } + } + + return 0; + } + + private static function defaultStorage(string $postType): ?string + { + if (! function_exists('pods_api')) { + return null; + } + + try { + $pod = pods_api()->load_pod(['name' => $postType]); + } catch (Throwable) { + return null; + } + + return is_object($pod) && method_exists($pod, 'get_storage') ? (string) $pod->get_storage() : null; + } +} diff --git a/src/Integrations/Divi/Pages/DynamicContentMap.php b/src/Integrations/Divi/Pages/DynamicContentMap.php index 941ef6c..e2457e8 100644 --- a/src/Integrations/Divi/Pages/DynamicContentMap.php +++ b/src/Integrations/Divi/Pages/DynamicContentMap.php @@ -43,6 +43,51 @@ class DynamicContentMap /** @return array>|null Null = use DynamicContentRules' default map. */ public function forPost(int $postId): ?array + { + $raw = $this->raw($postId); + + return $raw === null ? null : self::fromOptions($raw); + } + + /** + * The options for lit/divi-dynamic-sources: name => label, group and setting types (the same + * names and types as forPost(), loop_* left out). custom_meta_ options a filter registers + * for a key DynamicContentRules refuses (protected "_" keys, S12/S30) are left out as well, so + * the listing never names them. Only label, group and setting types leave Divi's registry – + * no select options or defaults (they list meta keys). Null when the registry is unavailable. + * + * @return array}>|null + */ + public function options(int $postId): ?array + { + $raw = $this->raw($postId); + if ($raw === null) { + return null; + } + + $options = []; + foreach (self::fromOptions($raw) as $name => $settings) { + if (str_starts_with($name, 'custom_meta_') && ! DynamicContentRules::isMetaKey($name)) { + continue; + } + + $option = is_array($raw[$name] ?? null) ? $raw[$name] : []; + $options[$name] = [ + 'label' => is_string($option['label'] ?? null) ? $option['label'] : $name, + 'group' => is_string($option['group'] ?? null) ? $option['group'] : '', + 'settings' => $settings, + ]; + } + + return $options; + } + + /** + * Divi's registry for the post; any filter error is logged (message only) and yields null. + * + * @return array|null + */ + private function raw(int $postId): ?array { try { $options = $this->registry !== null ? ($this->registry)($postId) : self::divi($postId); @@ -52,7 +97,7 @@ class DynamicContentMap return null; } - return is_array($options) && $options !== [] ? self::fromOptions($options) : null; + return is_array($options) && $options !== [] ? $options : null; } /** diff --git a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php index e5701b5..1d62b49 100644 --- a/tests/Unit/Integrations/Divi/DiviIntegrationTest.php +++ b/tests/Unit/Integrations/Divi/DiviIntegrationTest.php @@ -30,6 +30,7 @@ final class DiviIntegrationTest extends TestCase 'lit/divi-duplicate-node', 'lit/divi-replace-text', 'lit/divi-node-copy', + 'lit/divi-dynamic-sources', ]; public function testProvidesTheDiviAbilitiesOnlyWithDivi5(): void diff --git a/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php new file mode 100644 index 0000000..b6726da --- /dev/null +++ b/tests/Unit/Integrations/Divi/Pages/DiviDynamicSourcesAbilityTest.php @@ -0,0 +1,163 @@ +justReturn( + (object) ['name' => 'page', 'cap' => (object) ['edit_posts' => 'edit_pages']] + ); + Functions\when('current_user_can')->justReturn(true); + } + + private function schema(): ContentSchema + { + $schema = Mockery::mock(ContentSchema::class); + $schema->allows('isManaged')->andReturn(true); + $schema->allows('fields')->with('page')->andReturn([ + 'telefon' => new FieldDefinition('telefon', 'Telefon', 'string', 'meta', FieldDefinition::WRITABLE), + 'notiz' => new FieldDefinition('notiz', 'Notiz', 'text', 'pods', FieldDefinition::WRITABLE), + 'foto' => new FieldDefinition('foto', 'Foto', 'file', 'pods', FieldDefinition::WRITABLE), + 'projekte' => new FieldDefinition('projekte', 'Projekte', 'relation', 'pods', FieldDefinition::WRITABLE), + '_intern' => new FieldDefinition('_intern', '_intern', 'string', 'meta', FieldDefinition::WRITABLE), + 'geheim' => new FieldDefinition('geheim', 'Geheim', 'string', 'pods', FieldDefinition::HIDDEN), + 'gefunden' => new FieldDefinition('gefunden', 'gefunden', 'unknown', 'sample', FieldDefinition::UNKNOWN), + 'aktiv' => new FieldDefinition('aktiv', 'Aktiv', 'boolean', 'pods', FieldDefinition::WRITABLE), + 'kennwort' => new FieldDefinition('kennwort', 'Kennwort', 'password', 'pods', FieldDefinition::WRITABLE), + ]); + + return $schema; + } + + /** + * @param list $deniedRoles + * @param array|null $registry + */ + private function ability( + array $deniedRoles = [], + ?string $storage = 'meta', + ?array $registry = null + ): DiviDynamicSourcesAbility { + $registry ??= DiviFixtures::dynamicContentRegistry(); + + return new DiviDynamicSourcesAbility( + $this->schema(), + new DynamicContentMap(static fn (int $id): array => $registry), + LibraryFixtures::roles($deniedRoles), + static fn (string $postType): int => 831, + static fn (string $postType): ?string => $storage + ); + } + + public function testListsDiviSourcesWithTheirPlaceholders(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + $sources = array_column($result['sources'], null, 'name'); + self::assertSame(831, $result['sample_post_id']); + self::assertSame('{{post:title}}', $sources['post_title']['placeholder']); + self::assertSame('{{link:}}', $sources['post_link_url_page']['placeholder']); + self::assertNull($sources['site_title']['placeholder']); + self::assertArrayNotHasKey('loop_post_title', $sources); + self::assertEquals(['post_id' => 'select_post'], (array) $sources['post_link_url_page']['settings']); + self::assertFalse($this->ability()->isDirect()); + self::assertTrue($this->ability()->annotations()['readonly']); + self::assertSame('lit/divi-dynamic-sources', $this->ability()->name()); + } + + public function testOffersTextLikeFieldsAndNamesTheOthers(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + + self::assertSame(['telefon', 'notiz'], array_column($result['fields'], 'key')); + self::assertSame('{{field:notiz}}', $result['fields'][1]['placeholder']); + $omitted = array_column($result['omitted'], 'reason', 'key'); + self::assertSame(['foto', 'projekte', '_intern', 'gefunden', 'aktiv', 'kennwort'], array_keys($omitted)); + self::assertStringContainsString('nur IDs', $omitted['foto']); + self::assertStringContainsString('nur IDs', $omitted['projekte']); + self::assertStringContainsString('„_“', $omitted['_intern']); + // Ruling P9: unknown field types are not offered. + self::assertStringContainsString('unbekannt', $omitted['gefunden']); + self::assertStringContainsString('nicht textartig', $omitted['aktiv']); + self::assertStringContainsString('nicht textartig', $omitted['kennwort']); + } + + public function testProtectedAndHiddenFieldsLeakNothing(): void + { + $result = $this->ability()->execute(['post_type' => 'page']); + $json = (string) json_encode($result); + + // Hidden fields are neither offered nor named (security audit M4). + self::assertStringNotContainsString('geheim', $json); + self::assertStringNotContainsString('Geheim', $json); + // The protected key is named (so the client knows why) but never offered with a placeholder. + self::assertStringNotContainsString('{{field:_intern}}', $json); + self::assertSame(['key', 'label', 'reason'], array_keys($result['omitted'][2])); + } + + public function testSourcesNeverNameProtectedMetaOptions(): void + { + $registry = DiviFixtures::dynamicContentRegistry() + [ + 'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []], + ]; + + $result = $this->ability([], 'meta', $registry)->execute(['post_type' => 'page']); + + self::assertStringNotContainsString('_edit_lock', (string) json_encode($result)); + } + + public function testPodsTableStorageIsNotReadableByDivi(): void + { + $result = $this->ability([], 'table')->execute(['post_type' => 'page']); + + self::assertSame(['telefon'], array_column($result['fields'], 'key')); + self::assertStringContainsString('Tabellen', array_column($result['omitted'], 'reason', 'key')['notiz']); + } + + public function testFieldListNeedsTheDiviRole(): void + { + $result = $this->ability(['read_dynamic_content_custom_fields'])->execute(['post_type' => 'page']); + + self::assertSame([], $result['fields']); + self::assertSame([], $result['omitted']); + self::assertStringContainsString('Rollen-Editor', implode(' ', $result['notes'])); + } + + public function testWithoutDivisRegistryThePlaceholderSourcesAreShown(): void + { + $result = $this->ability([], 'meta', [])->execute(['post_type' => 'page']); + + self::assertContains('post_title', array_column($result['sources'], 'name')); + self::assertStringContainsString('nicht verfügbar', implode(' ', $result['notes'])); + } + + public function testUnknownPostTypeIsNotFound(): void + { + Functions\when('get_post_type_object')->justReturn(null); + + self::assertToolError($this->ability()->permission(['post_type' => 'nix']), 'not_found'); + self::assertToolError($this->ability()->execute(['post_type' => 'nix']), 'not_found'); + } + + public function testNeedsTheTypesEditCapability(): void + { + Functions\when('current_user_can')->alias(static fn (string $cap): bool => $cap !== 'edit_pages'); + + self::assertToolError($this->ability()->permission(['post_type' => 'page']), 'forbidden'); + } +} diff --git a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php index 3136006..0c26f7a 100644 --- a/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php +++ b/tests/Unit/Integrations/Divi/Pages/DynamicContentMapTest.php @@ -143,6 +143,73 @@ final class DynamicContentMapTest extends TestCase self::assertNull((new DynamicContentMap())->forPost(7)); } + public function testOptionsCarryLabelGroupAndSettingTypesWithoutLoops(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $options = $map->options(831); + + self::assertSame(['post_id' => 'select_post'], $options['post_link_url_page']['settings']); + self::assertSame('post_meta_key', $options['post_meta_key']['label']); + self::assertSame('Standard', $options['post_meta_key']['group']); + self::assertArrayNotHasKey('loop_post_title', $options); + self::assertNull((new DynamicContentMap(static fn (int $id): array => []))->options(831)); + } + + public function testOptionsUseTheSameNamesAndTypesAsTheAllowMap(): void + { + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + + $options = $map->options(831); + + self::assertSame(array_keys((array) $map->forPost(831)), array_keys((array) $options)); + $settings = array_map(static fn (array $option): array => $option['settings'], (array) $options); + self::assertSame($map->forPost(831), $settings); + // Placeholder options the registry lacks: name as label, no group. + self::assertSame(['label' => 'post_tags', 'group' => ''], array_slice($options['post_tags'], 0, 2)); + } + + public function testOptionsNeverNameProtectedMetaKeys(): void + { + // A third-party filter may register per-key options (D4 style custom_meta_). + $registry = DiviFixtures::dynamicContentRegistry() + [ + 'custom_meta__edit_lock' => ['label' => '_edit_lock', 'group' => 'Custom', 'fields' => []], + 'custom_meta_custom_meta__secret' => ['label' => 'x', 'group' => 'Custom', 'fields' => []], + 'custom_meta_telefon' => ['label' => 'Telefon', 'group' => 'Custom', 'fields' => []], + ]; + $map = new DynamicContentMap(static fn (int $id): array => $registry); + + $options = (array) $map->options(831); + + self::assertArrayNotHasKey('custom_meta__edit_lock', $options); + self::assertArrayNotHasKey('custom_meta_custom_meta__secret', $options); + self::assertSame('Telefon', $options['custom_meta_telefon']['label']); + // The allow map itself is unchanged: VariableRef refuses those names by its own rules (S12/S30). + self::assertArrayHasKey('custom_meta__edit_lock', (array) $map->forPost(831)); + } + + public function testTheAllowMapIsUnchangedByTheRawLookupRefactor(): void + { + // Snapshot of forPost() for the live-like registry, taken before forPost() moved onto raw() + // (Task 15): the security allow map must stay exactly the same (rulings S22/S29/S30/S37). + $before = '{"post_title":{"before":"richtext","after":"richtext"},"post_excerpt":{"before":"richtext",' + . '"after":"richtext","words":"text","read_more_label":"text"},"post_date":{"before":"richtext",' + . '"after":"richtext","date_format":"select","custom_date_format":"text"},"post_author":{"before":' + . '"richtext","after":"richtext","name_format":"select","link":"yes_no_button"},"site_title":{"before":' + . '"richtext","after":"richtext"},"post_link_url":[],"post_link_url_page":{"post_id":"select_post"},' + . '"post_featured_image":[],"post_meta_key":{"before":"richtext","after":"richtext","select_meta_key":' + . '"select","meta_key":"text","enable_html":"yes_no_button"},"broken_fields":{"a":"","b":""},' + . '"no_fields":[],"post_modified_date":{"before":"richtext","after":"richtext","date_format":"select",' + . '"custom_date_format":"text"},"post_categories":{"before":"richtext","after":"richtext"},' + . '"post_tags":{"before":"richtext","after":"richtext"}}'; + + $map = new DynamicContentMap(static fn (int $id): array => DiviFixtures::dynamicContentRegistry()); + $after = $map->forPost(831); + + self::assertSame(json_decode($before, true), $after); + self::assertSame($before, json_encode($after)); + } + private static function assertRejected(callable $call, string $fragment): void { try {