Skip to content

Commit 34c235e

Browse files
authored
feat(rss): allow RSS feed customization with new extra tags filters (#4231)
1 parent f275328 commit 34c235e

File tree

3 files changed

+328
-7
lines changed

3 files changed

+328
-7
lines changed

includes/optional-modules/class-rss.php

Lines changed: 83 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1130,7 +1130,16 @@ public static function add_extra_tags() {
11301130
$post = get_post();
11311131

11321132
if ( $settings['use_image_tags'] ) {
1133-
$thumbnail_url = get_the_post_thumbnail_url( $post, RSS_Add_Image::RSS_IMAGE_SIZE );
1133+
/**
1134+
* Filter the image size used for RSS feed images in <image> tags.
1135+
*
1136+
* @param string $size The image size slug. Default is `Newspack\RSS_Add_Image::RSS_IMAGE_SIZE`.
1137+
* @param array $settings The feed settings array.
1138+
* @param WP_Post $post The current post object.
1139+
*/
1140+
$image_size = apply_filters( 'newspack_rss_image_size', RSS_Add_Image::RSS_IMAGE_SIZE, $settings, $post );
1141+
$thumbnail_url = get_the_post_thumbnail_url( $post, $image_size );
1142+
11341143
if ( $thumbnail_url ) :
11351144
?>
11361145
<image><?php echo esc_url( $thumbnail_url ); ?></image>
@@ -1151,28 +1160,95 @@ public static function add_extra_tags() {
11511160
$tags = ( ! is_array( $tags ) ) ? [] : $tags;
11521161
$all_terms = array_merge( $cats, $tags );
11531162
$terms_string = implode( ',', wp_list_pluck( $all_terms, 'name' ) );
1154-
?>
1155-
<tags><?php echo esc_html( $terms_string ); ?></tags>
1156-
<?php
1163+
1164+
/**
1165+
* Filter the tags output format for RSS feeds.
1166+
*
1167+
* Return false to skip the default <tags> wrapper and output custom format.
1168+
* Return a string to replace the default comma-separated format.
1169+
* Allowed tags: <tag> (no attributes).
1170+
*
1171+
* @param string|false $output Default comma-separated escaped string, or false to skip wrapper.
1172+
* @param array $all_terms Array of WP_Term objects (categories and tags merged).
1173+
* @param array $settings The feed settings array.
1174+
* @param WP_Post $post The current post object.
1175+
*/
1176+
$tags_output = apply_filters( 'newspack_rss_tags_output', esc_html( $terms_string ), $all_terms, $settings, $post );
1177+
1178+
if ( false !== $tags_output ) {
1179+
?>
1180+
<tags><?php echo wp_kses( $tags_output, [ 'tag' => [] ] ); ?></tags>
1181+
<?php
1182+
}
11571183
}
11581184

11591185
if ( $settings['use_media_tags'] ) {
11601186
$thumbnail_id = get_post_thumbnail_id();
11611187
if ( $thumbnail_id ) {
1162-
$thumbnail_data = wp_get_attachment_image_src( $thumbnail_id, RSS_Add_Image::RSS_IMAGE_SIZE );
1188+
/** This filter is documented above in the use_image_tags block */
1189+
$image_size = apply_filters( 'newspack_rss_image_size', RSS_Add_Image::RSS_IMAGE_SIZE, $settings, $post );
1190+
$thumbnail_data = wp_get_attachment_image_src( $thumbnail_id, $image_size );
1191+
11631192
if ( $thumbnail_data ) {
11641193
$caption = get_the_post_thumbnail_caption();
1194+
/**
1195+
* Filter the media content URL for RSS feeds.
1196+
*
1197+
* Allows URL transformations (e.g., wp_specialchars_decode for mobile apps).
1198+
*
1199+
* @param string $url The thumbnail URL from wp_get_attachment_image_src.
1200+
* @param int $thumbnail_id The attachment post ID.
1201+
* @param array $settings The feed settings array.
1202+
* @param WP_Post $post The current post object.
1203+
*/
1204+
$media_url = apply_filters( 'newspack_rss_media_content_url', $thumbnail_data[0], $thumbnail_id, $settings, $post );
11651205
?>
1166-
<media:content type="<?php echo esc_attr( get_post_mime_type( $thumbnail_id ) ); ?>" url="<?php echo esc_url( $thumbnail_data[0] ); ?>">
1206+
<media:content type="<?php echo esc_attr( get_post_mime_type( $thumbnail_id ) ); ?>" url="<?php echo esc_url( $media_url ); ?>">
11671207
<?php if ( ! empty( $caption ) ) : ?>
11681208
<media:description><?php echo esc_html( $caption ); ?></media:description>
11691209
<?php endif; ?>
1170-
<media:thumbnail url="<?php echo esc_url( $thumbnail_data[0] ); ?>" width="<?php echo esc_attr( $thumbnail_data[1] ); ?>" height="<?php echo esc_attr( $thumbnail_data[2] ); ?>" />
1210+
<media:thumbnail url="<?php echo esc_url( $media_url ); ?>" width="<?php echo esc_attr( $thumbnail_data[1] ); ?>" height="<?php echo esc_attr( $thumbnail_data[2] ); ?>" />
11711211
</media:content>
11721212
<?php
1213+
/**
1214+
* Fires after the media:content element to allow adding extra media elements.
1215+
*
1216+
* Use this to add custom media elements like <media:credit>, <media:copyright>, etc.
1217+
*
1218+
* Example:
1219+
* $credit = get_post_meta( $thumbnail_id, '_media_credit', true );
1220+
* if ( $credit ) {
1221+
* echo '<media:credit><![CDATA[' . esc_html( $credit ) . ']]></media:credit>';
1222+
* }
1223+
*
1224+
* @param int $thumbnail_id The attachment post ID.
1225+
* @param array|false $thumbnail_data Array with [url, width, height] or false.
1226+
* @param string $caption The image caption from get_the_post_thumbnail_caption().
1227+
* @param array $settings The feed settings array.
1228+
* @param WP_Post $post The current post object.
1229+
*/
1230+
do_action( 'newspack_rss_after_media_content', $thumbnail_id, $thumbnail_data, $caption, $settings, $post );
11731231
}
11741232
}
11751233
}
1234+
1235+
/**
1236+
* Fires after all standard RSS extra tags have been output.
1237+
*
1238+
* Use this hook to add completely custom tags to feed items (e.g., for mobile app
1239+
* integrations like Pugpig, or other third-party services).
1240+
*
1241+
* Example:
1242+
* add_action( 'newspack_rss_after_extra_tags', function( $settings, $post ) {
1243+
* if ( my_is_special_feed() ) {
1244+
* echo '<custom_field>' . esc_html( get_post_meta( $post->ID, 'custom', true ) ) . '</custom_field>';
1245+
* }
1246+
* }, 10, 2 );
1247+
*
1248+
* @param array $settings The feed settings array from get_feed_settings().
1249+
* @param WP_Post $post The current post object.
1250+
*/
1251+
do_action( 'newspack_rss_after_extra_tags', $settings, $post );
11761252
}
11771253

11781254
/**

tests/mocks/filter-input-mock.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
<?php
2+
/**
3+
* Compatibility shim for filter_input() in PHPUnit tests.
4+
*
5+
* @package Newspack\Tests
6+
*/
7+
8+
namespace Newspack;
9+
10+
if ( ! function_exists( __NAMESPACE__ . '\\filter_input' ) ) {
11+
/**
12+
* Provides access to $_GET during PHPUnit runs where filter_input() is not populated.
13+
*
14+
* @param int $type One of INPUT_* constants.
15+
* @param string $variable_name Variable name.
16+
* @param int $filter Filter ID. Default: FILTER_DEFAULT.
17+
* @param array|int $options Filter options (defaults to 0, matching PHP's signature).
18+
* @return mixed Sanitized value or null.
19+
*/
20+
function filter_input( $type, $variable_name, $filter = FILTER_DEFAULT, $options = 0 ) {
21+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
22+
if ( INPUT_GET === $type && array_key_exists( $variable_name, $_GET ) ) {
23+
// phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
24+
$value = $_GET[ $variable_name ];
25+
26+
return \filter_var( $value, $filter, $options );
27+
}
28+
29+
return \filter_input( $type, $variable_name, $filter, $options );
30+
}
31+
}

tests/unit-tests/rss.php

Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,9 @@
77

88
use Newspack\RSS;
99
use Newspack\Optional_Modules;
10+
use Newspack\RSS_Add_Image;
1011

12+
require_once __DIR__ . '/../mocks/filter-input-mock.php';
1113
/**
1214
* Tests the RSS core functionality.
1315
*/
@@ -123,6 +125,37 @@ private function set_test_settings( $settings ) {
123125
$this->current_test_settings = $settings;
124126
}
125127

128+
/**
129+
* Render the extra RSS tags for a post with the provided settings overrides.
130+
*
131+
* @param WP_Post $post Post object.
132+
* @param array $settings Settings overrides for the feed.
133+
* @return string Captured markup from RSS::add_extra_tags().
134+
*/
135+
private function render_extra_tags_for_post( $post, $settings ) {
136+
$_GET['partner-feed'] = 'test-rss-feed';
137+
$this->set_test_settings(
138+
array_merge(
139+
[
140+
'num_items_in_feed' => 10,
141+
],
142+
$settings
143+
)
144+
);
145+
146+
$GLOBALS['post'] = $post;
147+
setup_postdata( $post );
148+
149+
ob_start();
150+
RSS::add_extra_tags();
151+
$output = ob_get_clean();
152+
153+
wp_reset_postdata();
154+
unset( $_GET['partner-feed'] );
155+
156+
return $output;
157+
}
158+
126159
/**
127160
* Test default taxonomy_filters_relation setting.
128161
*/
@@ -1001,4 +1034,185 @@ public function test_rss_complex_taxonomy_filtering() {
10011034
$this->assertEquals( 1, $query->found_posts, 'Should find exactly one post' );
10021035
$this->assertEquals( $post1, $query->posts[0]->ID, 'Should return the post with all three taxonomy terms' );
10031036
}
1037+
1038+
/**
1039+
* Test that RSS::add_extra_tags() applies the newspack_rss_image_size filter for <image> markup.
1040+
*/
1041+
public function test_add_extra_tags_applies_image_size_filter() {
1042+
$post_id = $this->factory()->post->create();
1043+
$attachment_id = $this->factory()->attachment->create_object( 'image.jpg', $post_id, [ 'post_mime_type' => 'image/jpeg' ] );
1044+
set_post_thumbnail( $post_id, $attachment_id );
1045+
1046+
$post = get_post( $post_id );
1047+
1048+
$filtered = false;
1049+
$size_used = null;
1050+
1051+
$image_size_filter = function ( $size, $settings, $filter_post ) use ( &$filtered ) {
1052+
$filtered = true;
1053+
$this->assertEquals( RSS_Add_Image::RSS_IMAGE_SIZE, $size, 'Expected image size filter to be applied for media tags.' );
1054+
$this->assertIsArray( $settings, 'Expected settings to be an array.' );
1055+
$this->assertInstanceOf( 'WP_Post', $filter_post, 'Expected filter post to be a WP_Post object.' );
1056+
return 'custom-rss-size';
1057+
};
1058+
1059+
$image_src_tracker = function ( $image, $attachment_id_param, $size ) use ( &$size_used, $attachment_id ) {
1060+
if ( $attachment_id === $attachment_id_param && null === $size_used ) {
1061+
$size_used = $size;
1062+
}
1063+
return $image;
1064+
};
1065+
1066+
add_filter( 'newspack_rss_image_size', $image_size_filter, 10, 3 );
1067+
add_filter( 'wp_get_attachment_image_src', $image_src_tracker, 10, 3 );
1068+
1069+
$output = $this->render_extra_tags_for_post(
1070+
$post,
1071+
[
1072+
'use_image_tags' => true,
1073+
]
1074+
);
1075+
1076+
remove_filter( 'newspack_rss_image_size', $image_size_filter, 10 );
1077+
remove_filter( 'wp_get_attachment_image_src', $image_src_tracker, 10 );
1078+
1079+
$this->assertTrue( $filtered, 'Expected newspack_rss_image_size filter to run.' );
1080+
$this->assertSame( 'custom-rss-size', $size_used, 'Expected image size filter value to be used.' );
1081+
$this->assertStringContainsString( '<image>', $output, 'Expected image tag markup in output.' );
1082+
}
1083+
1084+
/**
1085+
* Test that RSS::add_extra_tags() allows replacing <tags> markup via newspack_rss_tags_output.
1086+
*/
1087+
public function test_add_extra_tags_supports_custom_tags_output() {
1088+
$category_id = $this->factory()->term->create(
1089+
[
1090+
'taxonomy' => 'category',
1091+
'name' => 'TestCat',
1092+
]
1093+
);
1094+
$tag_id = $this->factory()->term->create(
1095+
[
1096+
'taxonomy' => 'post_tag',
1097+
'name' => 'TestTag',
1098+
]
1099+
);
1100+
1101+
$post_id = $this->factory()->post->create(
1102+
[
1103+
'post_category' => [ $category_id ],
1104+
]
1105+
);
1106+
wp_set_object_terms( $post_id, [ $tag_id ], 'post_tag' );
1107+
$post = get_post( $post_id );
1108+
1109+
$filtered = false;
1110+
$tags_filter = function ( $output, $all_terms, $settings, $filter_post ) use ( &$filtered ) {
1111+
$filtered = true;
1112+
$this->assertIsString( $output, 'Expected output to be a string.' );
1113+
$this->assertIsArray( $all_terms, 'Expected all terms to be an array.' );
1114+
$this->assertIsArray( $settings, 'Expected settings to be an array.' );
1115+
$this->assertInstanceOf( 'WP_Post', $filter_post, 'Expected filter post to be a WP_Post object.' );
1116+
1117+
$nested = '';
1118+
foreach ( $all_terms as $term ) {
1119+
$nested .= '<tag>' . esc_html( $term->name ) . '</tag>';
1120+
}
1121+
return $nested;
1122+
};
1123+
1124+
add_filter( 'newspack_rss_tags_output', $tags_filter, 10, 4 );
1125+
1126+
$output = $this->render_extra_tags_for_post(
1127+
$post,
1128+
[
1129+
'use_tags_tags' => true,
1130+
]
1131+
);
1132+
$normalized_output = preg_replace( '/\s+/', '', $output );
1133+
1134+
remove_filter( 'newspack_rss_tags_output', $tags_filter, 10 );
1135+
1136+
$this->assertTrue( $filtered, 'Expected newspack_rss_tags_output filter to run.' );
1137+
$this->assertStringContainsString( '<tags><tag>TestCat</tag><tag>TestTag</tag></tags>', $normalized_output, 'Expected tags output to be normalized.' );
1138+
}
1139+
1140+
/**
1141+
* Test that RSS::add_extra_tags() applies media filters and fires the related actions.
1142+
*/
1143+
public function test_add_extra_tags_applies_media_filters_and_actions() {
1144+
$post_id = $this->factory()->post->create();
1145+
$attachment_id = $this->factory()->attachment->create_object( 'image.jpg', $post_id, [ 'post_mime_type' => 'image/jpeg' ] );
1146+
set_post_thumbnail( $post_id, $attachment_id );
1147+
wp_update_post(
1148+
[
1149+
'ID' => $attachment_id,
1150+
'post_excerpt' => 'Media caption',
1151+
]
1152+
);
1153+
1154+
$post = get_post( $post_id );
1155+
1156+
$image_size_filtered = false;
1157+
$size_used = null;
1158+
$media_url_filtered = false;
1159+
$media_url = 'https://example.com/media-filtered.jpg';
1160+
$media_action = new \MockAction();
1161+
$after_action = new \MockAction();
1162+
1163+
$image_size_filter = function ( $size, $settings, $filter_post ) use ( &$image_size_filtered ) {
1164+
$image_size_filtered = true;
1165+
$this->assertIsArray( $settings, 'Expected settings to be an array.' );
1166+
$this->assertInstanceOf( 'WP_Post', $filter_post, 'Expected filter post to be a WP_Post object.' );
1167+
return 'media-custom-size';
1168+
};
1169+
1170+
$image_src_tracker = function ( $image, $attachment_id_param, $size ) use ( &$size_used, $attachment_id ) {
1171+
if ( $attachment_id === $attachment_id_param && null === $size_used ) {
1172+
$size_used = $size;
1173+
}
1174+
return $image;
1175+
};
1176+
1177+
$media_url_filter = function ( $url, $thumbnail_id, $settings, $filter_post ) use ( &$media_url_filtered, $media_url, $attachment_id ) {
1178+
$media_url_filtered = true;
1179+
$this->assertEquals( $attachment_id, $thumbnail_id, 'Expected attachment ID to match.' );
1180+
$this->assertIsArray( $settings, 'Expected settings to be an array.' );
1181+
$this->assertInstanceOf( 'WP_Post', $filter_post, 'Expected filter post to be a WP_Post object.' );
1182+
return $media_url;
1183+
};
1184+
1185+
add_filter( 'newspack_rss_image_size', $image_size_filter, 10, 3 );
1186+
add_filter( 'wp_get_attachment_image_src', $image_src_tracker, 10, 3 );
1187+
add_filter( 'newspack_rss_media_content_url', $media_url_filter, 10, 4 );
1188+
1189+
add_action( 'newspack_rss_after_media_content', [ $media_action, 'action' ], 10, 5 );
1190+
add_action( 'newspack_rss_after_extra_tags', [ $after_action, 'action' ], 10, 2 );
1191+
1192+
$output = $this->render_extra_tags_for_post(
1193+
$post,
1194+
[
1195+
'use_media_tags' => true,
1196+
]
1197+
);
1198+
1199+
remove_filter( 'newspack_rss_image_size', $image_size_filter, 10 );
1200+
remove_filter( 'wp_get_attachment_image_src', $image_src_tracker, 10 );
1201+
remove_filter( 'newspack_rss_media_content_url', $media_url_filter, 10 );
1202+
remove_action( 'newspack_rss_after_media_content', [ $media_action, 'action' ], 10 );
1203+
remove_action( 'newspack_rss_after_extra_tags', [ $after_action, 'action' ], 10 );
1204+
1205+
$this->assertTrue( $image_size_filtered, 'Expected image size filter to be applied for media tags.' );
1206+
$this->assertSame( 'media-custom-size', $size_used, 'Expected size used to be the custom size.' );
1207+
$this->assertTrue( $media_url_filtered, 'Expected media content URL filter to run.' );
1208+
$this->assertSame( 1, $media_action->get_call_count(), 'Expected after media content action to fire once.' );
1209+
$media_args = $media_action->get_args()[0];
1210+
$this->assertEquals( $attachment_id, $media_args[0], 'Expected attachment ID to match.' );
1211+
$this->assertIsArray( $media_args[1], 'Expected media args to be an array.' );
1212+
$this->assertIsString( $media_args[2], 'Expected media args to be a string.' );
1213+
$this->assertIsArray( $media_args[3], 'Expected media args to be an array.' );
1214+
$this->assertInstanceOf( 'WP_Post', $media_args[4], 'Expected media args to be a WP_Post object.' );
1215+
$this->assertSame( 1, $after_action->get_call_count(), 'Expected after extra tags action to fire once.' );
1216+
$this->assertStringContainsString( 'url="' . esc_url( $media_url ) . '"', $output, 'Expected media URL to be in output.' );
1217+
}
10041218
}

0 commit comments

Comments
 (0)