diff --git a/docs/bin/manifest.json b/docs/bin/manifest.json index 92c49785..e37ffd5a 100644 --- a/docs/bin/manifest.json +++ b/docs/bin/manifest.json @@ -239,6 +239,11 @@ "parent": "features", "markdown_source": "https://github.com/wordpress/secure-custom-fields/blob/trunk/docs/features/field/index.md" }, + "features/post-content-placeholders": { + "slug": "post-content-placeholders", + "parent": "features", + "markdown_source": "https://github.com/wordpress/secure-custom-fields/blob/trunk/docs/features/post-content-placeholders.md" + }, "features/post-types": { "slug": "post-types", "parent": "features", diff --git a/docs/features/index.md b/docs/features/index.md index 04b471b9..3833b777 100644 --- a/docs/features/index.md +++ b/docs/features/index.md @@ -6,6 +6,7 @@ This section details all features available in Secure Custom Fields. - [Post Types](post-types) - Create and manage custom post types - [Fields](fields) - Available field types and their usage +- [Post Content Placeholders](post-content-placeholders) - Reuse supported field values inside Gutenberg paragraph and heading blocks - [API](api) - Programmatic access and integration ## Feature Categories diff --git a/docs/features/post-content-placeholders.md b/docs/features/post-content-placeholders.md new file mode 100644 index 00000000..a1ab0a30 --- /dev/null +++ b/docs/features/post-content-placeholders.md @@ -0,0 +1,137 @@ +# Post Content Placeholders + +Post content placeholders let you reuse supported Secure Custom Fields values inside Gutenberg post content without copying the same text into multiple places. + +## Placeholder syntax + +Use the exact placeholder format: + +```text +[[field_name]] +``` + +Example: + +```text +Welcome to [[movie_title]] +``` + +Only exact placeholders are replaced. The field name may contain letters, numbers, underscores, and hyphens. + +## Where placeholders work in v1 + +Version 1 is intentionally narrow. + +Placeholders are replaced only when all of the following are true: + +- The content is rendered on the frontend +- The current view is a singular post for the current post being rendered +- The placeholder is inside that post's main content +- The placeholder appears in a Gutenberg Paragraph block (`core/paragraph`) or Heading block (`core/heading`) + +Placeholders are not replaced in unsupported contexts such as: + +- Excerpts +- REST API content responses +- Feeds +- Admin screens +- Live editor substitution while typing +- Widgets, comments, term content, user content, or option pages + +If a placeholder is used anywhere outside this narrow frontend block scope, the raw placeholder text may remain visible. + +## Field requirements + +A field must be explicitly allowed before its value can be used in a placeholder. + +Enable the field setting: + +- **Allow Access to Value in Editor UI** (`allow_in_bindings`) + +If that setting is off, or the field has not been explicitly enabled for bindings exposure, the placeholder outputs an empty string on the frontend. + +This opt-in requirement helps prevent placeholders from exposing field values that were not intended for public display. + +## Supported field types + +Post content placeholders support a limited set of field types in v1: + +- Text +- Textarea +- Number +- Range +- Email +- URL +- Select, when it resolves to a single value +- Radio +- Button Group +- Date Picker +- Date Time Picker +- Time Picker +- WYSIWYG + +Unsupported, structured, or non-scalar field values are not rendered. This includes cases such as repeaters, groups, galleries, files, images, relationships, or select fields configured to return multiple values. + +## Output behavior + +- Matching supported placeholders are replaced at render time +- Saved `post_content` stays exactly as authored +- Missing fields render as an empty string +- Empty field values render as an empty string +- Unsupported fields render as an empty string +- Supported fields that return non-scalar values also render as an empty string +- Malformed placeholders stay unchanged so you can spot the original text + +Silent empty output is the expected v1 behavior for missing, empty, unsupported, or not-exposed fields. + +Examples that stay literal: + +```text +[[movie title]] +[[movie_title] +[movie_title] +[[movie_title|upper]] +``` + +## HTML and sanitization + +Placeholder output is sanitized before it is inserted into rendered content. + +Plain text values are output safely. HTML-bearing values, such as WYSIWYG content, are limited to inline-safe HTML. + +Inline formatting like these may be preserved: + +- `` +- `` +- `` +- `` +- `
` + +Block-level or layout HTML is not supported in placeholders. Markup such as paragraphs, headings, lists, tables, embeds, forms, iframes, and scripts is stripped before output. + +Because of this, WYSIWYG fields work best for short inline content rather than full rich content blocks. If a field value depends on structural HTML for its meaning or layout, placeholders are not a good fit in v1. + +## Deactivation behavior + +Placeholders do not change the content stored in the database. + +If Secure Custom Fields is deactivated, placeholder replacement stops and the original raw placeholder text, such as `[[movie_title]]`, remains visible in the post content. + +## Example workflow + +1. Create a supported post field such as `movie_title` +2. Enable **Allow Access to Value in Editor UI** for that field +3. Add a value to the field on a post +4. Insert `[[movie_title]]` into a Paragraph or Heading block +5. Save the post and view it on the frontend + +## Notes for testing + +To verify the feature: + +1. Save the post with a supported placeholder in a Paragraph or Heading block. +2. View the frontend singular post. +3. Confirm the placeholder is replaced there. +4. Reopen the post in Gutenberg and confirm the editor still stores the raw placeholder text, such as `[[field_name]]`. + +Malformed placeholders should remain visible as entered, and unsupported or not-exposed fields should render nothing on the frontend. \ No newline at end of file diff --git a/includes/class-scf-post-content-placeholders.php b/includes/class-scf-post-content-placeholders.php new file mode 100644 index 00000000..1b60413a --- /dev/null +++ b/includes/class-scf-post-content-placeholders.php @@ -0,0 +1,326 @@ +> + */ + protected $value_cache = array(); + + /** + * Constructor. + */ + public function __construct() { + add_filter( 'render_block', array( $this, 'filter_render_block' ), 10, 2 ); + } + + /** + * Replaces supported placeholders in supported block output. + * + * @param string $block_content The block content about to be rendered. + * @param array $block The parsed block data. + * @return string + */ + public function filter_render_block( $block_content, $block ) { + $post_id = $this->get_supported_post_id( $block_content, $block ); + + if ( ! $post_id ) { + return $block_content; + } + + $placeholder_names = $this->extract_placeholder_names( $block_content ); + + if ( empty( $placeholder_names ) ) { + return $block_content; + } + + $replacements = array(); + + foreach ( $placeholder_names as $placeholder_name ) { + $replacements[ $placeholder_name ] = $this->get_placeholder_value( $placeholder_name, $post_id ); + } + + $rendered_content = preg_replace_callback( + self::PLACEHOLDER_REGEX, + function ( $matches ) use ( $replacements ) { + $placeholder_name = $matches[1]; + + if ( ! array_key_exists( $placeholder_name, $replacements ) ) { + return $matches[0]; + } + + return $replacements[ $placeholder_name ]; + }, + $block_content + ); + + return is_string( $rendered_content ) ? $rendered_content : $block_content; + } + + /** + * Returns the current post ID when the render context is supported. + * + * @param string $block_content The rendered block HTML. + * @param array $block The parsed block data. + * @return int + */ + protected function get_supported_post_id( $block_content, $block ) { + if ( is_admin() ) { + return 0; + } + + if ( function_exists( 'wp_is_json_request' ) && wp_is_json_request() ) { + return 0; + } + + if ( is_feed() || ! is_singular() ) { + return 0; + } + + if ( ! doing_filter( 'the_content' ) || ! in_the_loop() || ! is_main_query() ) { + return 0; + } + + if ( ! is_array( $block ) || empty( $block['blockName'] ) || ! in_array( $block['blockName'], $this->get_supported_block_names(), true ) ) { + return 0; + } + + if ( false === strpos( $block_content, '[[' ) ) { + return 0; + } + + $post_id = get_the_ID(); + + return $post_id ? (int) $post_id : 0; + } + + /** + * Returns the supported block names. + * + * @return string[] + */ + protected function get_supported_block_names() { + return array( + 'core/paragraph', + 'core/heading', + ); + } + + /** + * Extracts unique placeholder names from a block-content string. + * + * @param string $block_content The rendered block HTML. + * @return string[] + */ + protected function extract_placeholder_names( $block_content ) { + $matches = array(); + + if ( ! preg_match_all( self::PLACEHOLDER_REGEX, $block_content, $matches ) ) { + return array(); + } + + return array_values( array_unique( $matches[1] ) ); + } + + /** + * Returns the resolved placeholder value for a field on a post. + * + * @param string $placeholder_name The placeholder field name. + * @param int $post_id The current post ID. + * @return string + */ + protected function get_placeholder_value( $placeholder_name, $post_id ) { + if ( isset( $this->value_cache[ $post_id ] ) && array_key_exists( $placeholder_name, $this->value_cache[ $post_id ] ) ) { + return $this->value_cache[ $post_id ][ $placeholder_name ]; + } + + $value = ''; + + $access_already_prevented = apply_filters( 'acf/prevent_access_to_unknown_fields', false ); + $filter_applied = false; + + if ( ! $access_already_prevented ) { + $filter_applied = true; + add_filter( 'acf/prevent_access_to_unknown_fields', '__return_true' ); + } + + try { + $field = get_field_object( $placeholder_name, $post_id, true, true, false ); + } finally { + if ( $filter_applied ) { + remove_filter( 'acf/prevent_access_to_unknown_fields', '__return_true' ); + } + } + + if ( $this->is_supported_field( $field ) ) { + $value = $this->prepare_field_value( $field ); + } + + if ( ! isset( $this->value_cache[ $post_id ] ) ) { + $this->value_cache[ $post_id ] = array(); + } + + $this->value_cache[ $post_id ][ $placeholder_name ] = $value; + + return $value; + } + + /** + * Returns true when the field can be rendered via placeholders. + * + * @param mixed $field The field object returned by SCF. + * @return bool + */ + protected function is_supported_field( $field ) { + if ( ! is_array( $field ) || empty( $field['type'] ) ) { + return false; + } + + if ( ! in_array( $field['type'], $this->get_supported_field_types(), true ) ) { + return false; + } + + if ( ! array_key_exists( 'allow_in_bindings', $field ) || ! $field['allow_in_bindings'] ) { + return false; + } + + /** + * Filters whether a field is eligible for post-content placeholder output. + * + * @param bool $is_allowed Whether the field is allowed. + * @param array $field The field object. + */ + return (bool) apply_filters( 'scf/post_content_placeholders/is_field_allowed', true, $field ); + } + + /** + * Returns the supported field types. + * + * @return string[] + */ + protected function get_supported_field_types() { + $supported_field_types = array( + 'text', + 'textarea', + 'number', + 'range', + 'email', + 'url', + 'select', + 'radio', + 'button_group', + 'date_picker', + 'date_time_picker', + 'time_picker', + 'wysiwyg', + ); + + /** + * Filters the placeholder-supported field types. + * + * @param string[] $supported_field_types The supported field types. + */ + return apply_filters( 'scf/post_content_placeholders/supported_field_types', $supported_field_types ); + } + + /** + * Prepares a field value for inline placeholder output. + * + * @param array $field The field object. + * @return string + */ + protected function prepare_field_value( $field ) { + if ( ! array_key_exists( 'value', $field ) || ! is_scalar( $field['value'] ) ) { + return ''; + } + + $prepared_value = (string) $field['value']; + + if ( '' === trim( $prepared_value ) ) { + return ''; + } + + /** + * Filters the field value before placeholder sanitization. + * + * @param string $prepared_value The scalar field value cast to a string. + * @param array $field The field object. + */ + $prepared_value = apply_filters( 'scf/post_content_placeholders/prepared_value', $prepared_value, $field ); + + if ( ! is_scalar( $prepared_value ) ) { + return ''; + } + + $prepared_value = (string) $prepared_value; + + if ( '' === trim( $prepared_value ) ) { + return ''; + } + + $sanitized_value = wp_kses( $prepared_value, $this->get_inline_allowed_html() ); + + if ( '' === trim( $sanitized_value ) ) { + return ''; + } + + return $sanitized_value; + } + + /** + * Returns the inline-only HTML allowed for placeholders. + * + * @return array> + */ + protected function get_inline_allowed_html() { + $allowed_html = array( + 'a' => array( + 'href' => true, + 'target' => true, + 'rel' => true, + 'title' => true, + ), + 'abbr' => array( + 'title' => true, + ), + 'b' => array(), + 'br' => array(), + 'cite' => array(), + 'code' => array(), + 'del' => array(), + 'em' => array(), + 'i' => array(), + 'mark' => array(), + 'small' => array(), + 'strong' => array(), + 'sub' => array(), + 'sup' => array(), + ); + + /** + * Filters the inline HTML allowed for placeholder output. + * + * @param array> $allowed_html The allowed HTML map. + */ + return apply_filters( 'scf/post_content_placeholders/inline_allowed_html', $allowed_html ); + } +} diff --git a/secure-custom-fields.php b/secure-custom-fields.php index d9934e47..b2cfe673 100644 --- a/secure-custom-fields.php +++ b/secure-custom-fields.php @@ -190,6 +190,7 @@ public function initialize() { acf_include( 'includes/class-acf-options-page.php' ); acf_include( 'includes/class-acf-site-health.php' ); acf_include( 'includes/class-scf-json-schema-validator.php' ); + acf_include( 'includes/class-scf-post-content-placeholders.php' ); acf_include( 'includes/class-scf-schema-builder.php' ); acf_include( 'includes/abilities/class-scf-abilities-integration.php' ); acf_include( 'includes/fields/class-acf-field.php' ); @@ -473,6 +474,8 @@ public function init() { new ACF\Blocks\Bindings(); } + new SCF_Post_Content_Placeholders(); + /** * Fires after ACF is completely "initialized". * diff --git a/tests/e2e/post-content-placeholders.spec.ts b/tests/e2e/post-content-placeholders.spec.ts new file mode 100644 index 00000000..66d1e9b8 --- /dev/null +++ b/tests/e2e/post-content-placeholders.spec.ts @@ -0,0 +1,204 @@ +/** + * E2E tests for post-content placeholders. + */ +const { test, expect } = require( './fixtures' ); +const { + PLUGIN_SLUG, + deleteFieldGroups, + waitForMetaBoxes, + toggleFieldSetting, +} = require( './field-helpers' ); + +const FIELD_GROUPS = { + movieTitle: 'Placeholder Movie Title', + secretNote: 'Placeholder Secret Note', + bodyHtml: 'Placeholder Body HTML', +}; + +test.describe( 'Post Content Placeholders', () => { + test.beforeAll( async ( { requestUtils } ) => { + await requestUtils.activatePlugin( PLUGIN_SLUG ); + } ); + + test.afterAll( async ( { requestUtils } ) => { + await requestUtils.deactivatePlugin( PLUGIN_SLUG ); + await requestUtils.deleteAllPosts(); + } ); + + test.beforeEach( async ( { page, admin } ) => { + await deleteFieldGroups( page, admin ); + } ); + + test( 'renders supported placeholders on the frontend while preserving raw editor content', async ( { + page, + admin, + editor, + requestUtils, + } ) => { + await createPlaceholderFieldGroup( page, admin, { + groupTitle: FIELD_GROUPS.movieTitle, + fieldLabel: 'Movie Title', + fieldType: 'text', + enableBindings: true, + } ); + + await createPlaceholderFieldGroup( page, admin, { + groupTitle: FIELD_GROUPS.secretNote, + fieldLabel: 'Secret Note', + fieldType: 'text', + enableBindings: false, + } ); + + await createPlaceholderFieldGroup( page, admin, { + groupTitle: FIELD_GROUPS.bodyHtml, + fieldLabel: 'Body HTML', + fieldType: 'wysiwyg', + enableBindings: true, + } ); + + const content = [ + '', + '

[[movie_title]]

', + '', + '', + '

Now showing [[movie_title]] [[secret_note]] [[body_html]] [[movie title]]

', + '', + ].join( '\n\n' ); + + const post = await requestUtils.createPost( { + title: 'Placeholder Frontend Post', + status: 'publish', + content, + showWelcomeGuide: false, + } ); + + await admin.editPost( post.id ); + await waitForMetaBoxes( page ); + + const movieTitleField = page.locator( + '.acf-field[data-name="movie_title"] input[type="text"]' + ); + const secretNoteField = page.locator( + '.acf-field[data-name="secret_note"] input[type="text"]' + ); + + await movieTitleField.waitFor( { state: 'visible', timeout: 30000 } ); + await secretNoteField.waitFor( { state: 'visible', timeout: 30000 } ); + + await movieTitleField.fill( 'The Matrix' ); + await secretNoteField.fill( 'Classified' ); + + const textTabButton = page.locator( + '.acf-field[data-name="body_html"] .wp-switch-editor.switch-html' + ); + await textTabButton.click(); + await page.waitForTimeout( 150 ); + await page + .locator( '.acf-field[data-name="body_html"] textarea.wp-editor-area' ) + .fill( '

Bold Span

' ); + + await savePostChanges( page ); + + await admin.editPost( post.id ); + const canvas = await editor.canvas; + await expect( + canvas.locator( '[data-type="core/heading"]' ).first() + ).toContainText( '[[movie_title]]' ); + await expect( + canvas.locator( '[data-type="core/paragraph"]' ).first() + ).toContainText( '[[movie_title]]' ); + await expect( + canvas.locator( '[data-type="core/paragraph"]' ).first() + ).toContainText( '[[movie title]]' ); + + await page.goto( post.link ); + + await expect( page.locator( 'h2.wp-block-heading' ) ).toContainText( + 'The Matrix' + ); + + const paragraph = page.locator( 'p' ).filter( { + hasText: 'Now showing', + } ); + await expect( paragraph ).toContainText( 'Now showing The Matrix' ); + await expect( paragraph ).toContainText( '[[movie title]]' ); + await expect( paragraph ).not.toContainText( 'Classified' ); + await expect( paragraph.locator( 'strong' ) ).toHaveText( 'Bold' ); + expect( await paragraph.locator( 'span' ).count() ).toBe( 0 ); + + const restPost = await requestUtils.rest( { + path: `/wp/v2/posts/${ post.id }`, + } ); + expect( restPost.content.rendered ).toContain( '[[movie_title]]' ); + expect( restPost.content.rendered ).toContain( '[[movie title]]' ); + + await requestUtils.deactivatePlugin( PLUGIN_SLUG ); + await page.goto( post.link ); + await expect( page.locator( 'h2.wp-block-heading' ) ).toContainText( + '[[movie_title]]' + ); + await expect( paragraph ).toContainText( '[[movie_title]]' ); + + await requestUtils.activatePlugin( PLUGIN_SLUG ); + } ); +} ); + +/** + * Create a field group containing a single placeholder field. + * + * @param {import('@playwright/test').Page} page Playwright page object. + * @param {Object} admin Admin utilities. + * @param {Object} options Field group options. + */ +async function createPlaceholderFieldGroup( page, admin, options ) { + const { groupTitle, fieldLabel, fieldType, enableBindings } = options; + + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); + await page.locator( 'a.acf-btn:has-text("Add New")' ).click(); + + await page.waitForSelector( '#title' ); + await page.fill( '#title', groupTitle ); + + await page + .locator( 'a.acf-btn-secondary.add-field' ) + .filter( { hasText: 'Add Field' } ) + .first() + .click(); + await page.waitForTimeout( 500 ); + + const fieldObject = page.locator( '.acf-field-object' ).last(); + const fieldLabelInput = fieldObject.locator( 'input.field-label' ); + const fieldTypeSelect = fieldObject.locator( 'select.field-type' ); + + await fieldLabelInput.waitFor( { state: 'visible', timeout: 30000 } ); + await fieldLabelInput.fill( fieldLabel ); + await fieldTypeSelect.selectOption( fieldType ); + + if ( enableBindings ) { + await toggleFieldSetting( page, '.acf-field-setting-allow_in_bindings', true ); + } + + await page + .locator( 'button.acf-btn.acf-publish[type="submit"]' ) + .click(); + await expect( page.locator( '.updated.notice' ) ).toBeVisible(); + + await admin.visitAdminPage( 'edit.php', 'post_type=acf-field-group' ); + await expect( page.locator( `tr:has-text("${ groupTitle }")` ) ).toBeVisible(); +} + +/** + * Save post changes from the block editor. + * + * @param {import('@playwright/test').Page} page Playwright page object. + */ +async function savePostChanges( page ) { + const saveButton = page.getByRole( 'button', { + name: /^(Save|Save draft|Update)$/, + } ); + + await saveButton.first().click(); + await expect( page.locator( '.components-snackbar, .editor-post-saved-state' ) ).toContainText( + /saved|updated/i + ); +} diff --git a/tests/php/includes/test-scf-post-content-placeholders.php b/tests/php/includes/test-scf-post-content-placeholders.php new file mode 100644 index 00000000..5a5f83fc --- /dev/null +++ b/tests/php/includes/test-scf-post-content-placeholders.php @@ -0,0 +1,370 @@ + + */ + private $field_keys; + + /** + * Sets up test fixtures. + */ + public function setUp(): void { + parent::setUp(); + + acf_reset_local(); + acf_get_store( 'values' )->reset(); + + $this->service = new class() extends SCF_Post_Content_Placeholders { + /** + * The post ID to use for supported-context rendering. + * + * @var int + */ + private $supported_post_id = 0; + + /** + * Sets the post ID used for supported test rendering. + * + * @param int $post_id The post ID. + * @return void + */ + public function set_supported_post_id( $post_id ) { + $this->supported_post_id = (int) $post_id; + } + + /** + * Returns the configured post ID only for supported block content. + * + * @param string $block_content The rendered block HTML. + * @param array $block The parsed block data. + * @return int + */ + protected function get_supported_post_id( $block_content, $block ) { + if ( ! is_array( $block ) || empty( $block['blockName'] ) || ! in_array( $block['blockName'], $this->get_supported_block_names(), true ) ) { + return 0; + } + + if ( false === strpos( $block_content, '[[' ) ) { + return 0; + } + + return $this->supported_post_id; + } + }; + $this->post_id = wp_insert_post( + array( + 'post_type' => 'post', + 'post_title' => 'Placeholder Test Post', + 'post_status' => 'publish', + 'post_content' => '', + ) + ); + + $this->field_keys = array( + 'movie_title' => 'field_movie_title', + 'release_year' => 'field_release_year', + 'secret_note' => 'field_secret_note', + 'legacy_title' => 'field_legacy_title', + 'body_html' => 'field_body_html', + 'multi_choice' => 'field_multi_choice', + 'hero_image' => 'field_hero_image', + ); + + acf_add_local_field_group( + array( + 'key' => 'group_post_content_placeholders', + 'title' => 'Post Content Placeholders', + 'location' => array( + array( + array( + 'param' => 'post_type', + 'operator' => '==', + 'value' => 'post', + ), + ), + ), + 'fields' => array( + array( + 'key' => $this->field_keys['movie_title'], + 'label' => 'Movie Title', + 'name' => 'movie_title', + 'type' => 'text', + 'allow_in_bindings' => 1, + ), + array( + 'key' => $this->field_keys['release_year'], + 'label' => 'Release Year', + 'name' => 'release_year', + 'type' => 'number', + 'allow_in_bindings' => 1, + ), + array( + 'key' => $this->field_keys['secret_note'], + 'label' => 'Secret Note', + 'name' => 'secret_note', + 'type' => 'text', + 'allow_in_bindings' => 0, + ), + array( + 'key' => $this->field_keys['legacy_title'], + 'label' => 'Legacy Title', + 'name' => 'legacy_title', + 'type' => 'text', + ), + array( + 'key' => $this->field_keys['body_html'], + 'label' => 'Body HTML', + 'name' => 'body_html', + 'type' => 'wysiwyg', + 'media_upload' => 0, + 'allow_in_bindings' => 1, + ), + array( + 'key' => $this->field_keys['multi_choice'], + 'label' => 'Multi Choice', + 'name' => 'multi_choice', + 'type' => 'select', + 'multiple' => 1, + 'choices' => array( + 'one' => 'One', + 'two' => 'Two', + ), + 'allow_in_bindings' => 1, + ), + array( + 'key' => $this->field_keys['hero_image'], + 'label' => 'Hero Image', + 'name' => 'hero_image', + 'type' => 'image', + 'allow_in_bindings' => 1, + ), + ), + ) + ); + } + + /** + * Tears down test fixtures. + */ + public function tearDown(): void { + remove_filter( 'render_block', array( $this->service, 'filter_render_block' ), 10 ); + acf_reset_local(); + acf_get_store( 'values' )->reset(); + + if ( $this->post_id ) { + wp_delete_post( $this->post_id, true ); + } + + parent::tearDown(); + } + + /** + * Tests placeholder replacement in paragraph and heading blocks. + */ + public function test_replaces_supported_placeholders_in_paragraph_and_heading_blocks() { + update_field( $this->field_keys['movie_title'], 'The Matrix', $this->post_id ); + update_field( $this->field_keys['release_year'], 1999, $this->post_id ); + + $rendered = $this->render_post_content( + implode( + "\n\n", + array( + '', + '

[[movie_title]]

', + '', + '', + '

[[movie_title]] ([[release_year]]) [[movie_title]]

', + '', + ) + ) + ); + + $this->assertStringContainsString( '

The Matrix

', $rendered ); + $this->assertStringContainsString( '

The Matrix (1999) The Matrix

', $rendered ); + } + + /** + * Tests malformed placeholders remain unchanged. + */ + public function test_malformed_placeholders_remain_unchanged() { + $rendered = $this->render_post_content( + implode( + "\n\n", + array( + '', + '

[[movie title]] [[movie_title] [movie_title] [[movie_title|upper]]

', + '', + ) + ) + ); + + $this->assertStringContainsString( '[[movie title]] [[movie_title] [movie_title] [[movie_title|upper]]', $rendered ); + } + + /** + * Tests missing placeholders render empty output. + */ + public function test_missing_placeholder_renders_empty_output() { + $rendered = $this->render_post_content( + implode( + "\n\n", + array( + '', + '

Before [[missing_key]] after

', + '', + ) + ) + ); + + $this->assertStringContainsString( '

Before after

', $rendered ); + } + + /** + * Tests placeholders with denied or unsupported fields render empty output. + */ + public function test_denied_or_unsupported_placeholders_render_empty_output() { + update_field( $this->field_keys['secret_note'], 'Classified', $this->post_id ); + update_field( $this->field_keys['legacy_title'], 'Legacy Value', $this->post_id ); + update_field( $this->field_keys['multi_choice'], array( 'one', 'two' ), $this->post_id ); + update_field( $this->field_keys['hero_image'], 123, $this->post_id ); + + $rendered = $this->render_post_content( + implode( + "\n\n", + array( + '', + '

[[secret_note]][[legacy_title]][[multi_choice]][[hero_image]]

', + '', + ) + ) + ); + + $this->assertStringContainsString( '

', $rendered ); + $this->assertStringNotContainsString( 'Classified', $rendered ); + $this->assertStringNotContainsString( 'Legacy Value', $rendered ); + $this->assertStringNotContainsString( 'Array', $rendered ); + } + + /** + * Tests allowed inline HTML is preserved and disallowed HTML is stripped. + */ + public function test_sanitizes_wysiwyg_output_to_inline_allowed_html() { + update_field( $this->field_keys['body_html'], '

Bold Span

', $this->post_id ); + + $rendered = $this->render_post_content( + implode( + "\n\n", + array( + '', + '

[[body_html]]

', + '', + ) + ) + ); + + $this->assertStringContainsString( '

Bold Span

', $rendered ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( 'class="bad"', $rendered ); + } + + /** + * Tests unsupported blocks are left untouched. + */ + public function test_unsupported_blocks_are_left_untouched() { + update_field( $this->field_keys['movie_title'], 'The Matrix', $this->post_id ); + + $rendered = $this->render_post_content( + implode( + "\n\n", + array( + '', + '
  • [[movie_title]]
', + '', + ) + ) + ); + + $this->assertStringContainsString( '[[movie_title]]', $rendered ); + } + + /** + * Tests unsupported runtime contexts are left untouched. + */ + public function test_unsupported_runtime_context_is_left_untouched() { + $block_content = '

[[movie_title]]

'; + $block = array( + 'blockName' => 'core/paragraph', + ); + + $this->assertSame( $block_content, $this->service->filter_render_block( $block_content, $block ) ); + } + + /** + * Tests saved post content remains unchanged after rendering. + */ + public function test_rendering_does_not_mutate_saved_post_content() { + update_field( $this->field_keys['movie_title'], 'The Matrix', $this->post_id ); + + $content = implode( + "\n\n", + array( + '', + '

[[movie_title]]

', + '', + ) + ); + + $this->render_post_content( $content ); + + $this->assertSame( $content, get_post_field( 'post_content', $this->post_id ) ); + } + + /** + * Renders post content through the supported frontend pipeline. + * + * @param string $content The content to render. + * @return string + */ + private function render_post_content( $content ) { + wp_update_post( + array( + 'ID' => $this->post_id, + 'post_content' => $content, + ) + ); + + $this->service->set_supported_post_id( $this->post_id ); + $rendered = apply_filters( 'the_content', get_post_field( 'post_content', $this->post_id ) ); + $this->service->set_supported_post_id( 0 ); + + return $rendered; + } +}