resolve -> map -> render. * * @package FeedWeaver */ namespace FeedWeaver\Feed; use FeedWeaver\Config\Feed_Config; use FeedWeaver\Http\Endpoint; use FeedWeaver\Mapping\Date_Formatter; use FeedWeaver\Mapping\Field_Resolver; use FeedWeaver\Mapping\Mapping_Engine; use FeedWeaver\Mapping\Meta_Policy; use FeedWeaver\Mapping\Namespace_Validator; use FeedWeaver\Mapping\Source_Catalog; use FeedWeaver\Mapping\Text_Sanitizer; use FeedWeaver\Query\Content_Query; use FeedWeaver\Query\Eligibility; use FeedWeaver\Render\Json_Renderer; use FeedWeaver\Render\Renderer; use FeedWeaver\Render\Rss_Profile; use FeedWeaver\Render\Rss_Renderer; use FeedWeaver\Render\Xml_Validator; use FeedWeaver\Render\Xml_Renderer; defined( 'ABSPATH' ) || exit; /** * Single implementation used by both the public endpoint and the admin * preview, so the preview can never drift from production output. */ class Feed_Builder { const GENERATION_OPTION = 'feedweaver_cache_generation'; const DEFAULT_TTL = 300; /** Transient name prefix for cached feed bodies (see Meta_Discovery::CACHE_PREFIX for the length reasoning). */ const CACHE_PREFIX = 'feedweaver_cache_'; const MAX_CACHE_BYTES = 2097152; /** @var Content_Query */ private $query; /** @var Field_Resolver */ private $resolver; /** * @param Content_Query|null $query Content query. * @param Field_Resolver|null $resolver Field resolver. */ public function __construct( ?Content_Query $query = null, ?Field_Resolver $resolver = null ) { $this->query = $query ? $query : new Content_Query(); $this->resolver = $resolver ? $resolver : new Field_Resolver(); } /** * Full validation: the pure configuration rules plus WordPress-aware checks. * * @param Feed_Config $config Configuration. * @return string[] Error messages; empty when valid. */ public function validate( Feed_Config $config ): array { $errors = $config->validate(); foreach ( $config->post_types as $post_type ) { if ( ! Eligibility::is_post_type_eligible( $post_type ) ) { /* translators: %s: content type name. */ $errors[] = sprintf( __( 'The content type "%s" is not publicly available.', 'feedweaver' ), $post_type ); } } foreach ( array_keys( $config->tax_filters ) as $taxonomy ) { if ( ! Eligibility::is_taxonomy_eligible( $taxonomy ) ) { /* translators: %s: taxonomy name. */ $errors[] = sprintf( __( 'The taxonomy "%s" cannot be used as a filter.', 'feedweaver' ), $taxonomy ); } } foreach ( $config->mappings as $mapping ) { if ( Source_Catalog::is_taxonomy( $mapping['source'] ) && ! Eligibility::is_taxonomy_eligible( Source_Catalog::taxonomy_name( $mapping['source'] ) ) ) { /* translators: %s: taxonomy name. */ $errors[] = sprintf( __( 'The taxonomy "%s" cannot be published.', 'feedweaver' ), (string) Source_Catalog::taxonomy_name( $mapping['source'] ) ); } if ( Source_Catalog::is_meta( $mapping['source'] ) ) { $key = (string) Source_Catalog::meta_key( $mapping['source'] ); // Respect keys that WordPress or another plugin has marked as protected. if ( Meta_Policy::is_allowed( $key ) && is_protected_meta( $key, 'post' ) ) { /* translators: %s: custom field key. */ $errors[] = sprintf( __( 'The custom field "%s" is protected and cannot be published.', 'feedweaver' ), $key ); } } } return array_values( array_unique( $errors ) ); } /** * Validation for saving in the admin: everything validate() checks plus that the * chosen terms exist. (Not run for public requests: it costs a query per taxonomy. * At request time a term that no longer exists simply matches nothing.) * * @param Feed_Config $config Configuration. * @return string[] Error messages; empty when valid. */ public function validate_for_save( Feed_Config $config ): array { $errors = $this->validate( $config ); foreach ( $config->tax_filters as $taxonomy => $term_ids ) { if ( ! Eligibility::is_taxonomy_eligible( $taxonomy ) ) { continue; } $existing = get_terms( array( 'taxonomy' => $taxonomy, 'include' => $term_ids, 'hide_empty' => false, 'fields' => 'ids', 'number' => count( $term_ids ), ) ); if ( is_wp_error( $existing ) || count( $existing ) !== count( $term_ids ) ) { /* translators: %s: taxonomy name. */ $errors[] = sprintf( __( 'A term chosen for "%s" no longer exists. Remove it from the filter.', 'feedweaver' ), $taxonomy ); } } return array_values( array_unique( $errors ) ); } /** * Builds a feed. * * @param Feed_Config $config Configuration. * @param bool $use_cache Read and write the transient cache (public feeds only). * @param bool $pretty Indent output (preview). * @param int $show Render only this many items (preview); 0 renders all. The counts in the * result's diagnostics always cover every item the feed would contain. * @return Feed_Result */ public function build( Feed_Config $config, bool $use_cache = false, bool $pretty = false, int $show = 0 ): Feed_Result { $errors = $this->validate( $config ); if ( $errors ) { return Feed_Result::failure( $errors ); } // Never share a build made for a signed-in visitor: plugins may alter output per user. $use_cache = $use_cache && ! is_user_logged_in(); $ttl = $use_cache ? (int) apply_filters( 'feedweaver_cache_ttl', self::DEFAULT_TTL, $config ) : 0; $cache_key = $ttl > 0 ? $this->cache_key( $config ) : ''; if ( '' !== $cache_key ) { $cached = get_transient( $cache_key ); if ( is_array( $cached ) && isset( $cached['body'], $cached['content_type'], $cached['count'] ) ) { $result = new Feed_Result(); $result->ok = true; $result->body = (string) $cached['body']; $result->content_type = (string) $cached['content_type']; $result->count = (int) $cached['count']; $result->from_cache = true; return $result; } } $posts = $this->query->fetch( $config ); if ( is_wp_error( $posts ) ) { return Feed_Result::failure( array( $posts->get_error_message() ) ); } try { $is_rss = Feed_Config::FORMAT_RSS === $config->format; $mappings = $config->mappings; $attributes = $config->attributes; if ( $is_rss ) { $effective = Rss_Profile::effective( $config ); $mappings = $effective['mappings']; $attributes = $effective['attributes']; } // RSS always uses RFC 822 dates and never writes empty elements; both are RSS rules, not options. $engine = new Mapping_Engine( $mappings, $is_rss || 'omit' === $config->empty_values, $attributes, $config->repeat_names ); $date_format = $is_rss ? Date_Formatter::RFC2822 : $config->date_format; $renderer = $this->renderer( $config, $pretty ); $sources = $engine->required_sources(); $items = array(); $omitted = array(); $adjusted = array(); foreach ( $posts as $post ) { $item = $engine->map( $this->resolver->resolve( $post, $sources, $date_format, $config->date_custom ) ); if ( $is_rss ) { if ( null !== $config->enclosure && ! isset( $item['enclosure'] ) ) { $adjusted['enclosure_incomplete'][] = (int) $post->ID; } $inspected = Rss_Profile::inspect_item( $item ); foreach ( $inspected['notes'] as $note ) { if ( 'no_content' === $note ) { $omitted[ $note ][] = (int) $post->ID; } else { $adjusted[ $note ][] = (int) $post->ID; } } $item = $inspected['item']; if ( null === $item ) { continue; // No title and no description: not a valid RSS item. } } $items[] = $item; }//end foreach $shown = $show > 0 ? array_slice( $items, 0, $show ) : $items; $body = $renderer->render( $shown ); if ( $config->is_xml_family() ) { // An independent parse of what is about to be published. Malformed output is never served. $problems = $is_rss ? Xml_Validator::validate_rss( $body ) : Xml_Validator::validate_xml( $body ); if ( $problems ) { return Feed_Result::failure( array( __( 'The feed could not be generated because the XML would not be valid. Check element names, attributes and namespaces.', 'feedweaver' ) ) ); } } } catch ( \Throwable $e ) { if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { error_log( 'FeedWeaver: feed build failed (' . get_class( $e ) . ').' ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- opt-in debug log; class name only, no message or paths. } return Feed_Result::failure( array( __( 'The feed could not be generated. Check the field mappings and names.', 'feedweaver' ) ) ); }//end try $result = new Feed_Result(); $result->ok = true; $result->body = $body; $result->content_type = $renderer->content_type(); $result->count = count( $shown ); $result->diagnostics = array( 'matched' => count( $posts ), 'included' => count( $items ), 'omitted' => $omitted, 'adjusted' => $adjusted, ); if ( '' !== $cache_key && strlen( $body ) <= self::MAX_CACHE_BYTES ) { set_transient( $cache_key, array( 'body' => $body, 'content_type' => $result->content_type, 'count' => $result->count, ), $ttl ); } return $result; } /** * Invalidates all cached feeds by changing the cache generation. */ public static function bump_generation(): void { update_option( self::GENERATION_OPTION, (string) wp_rand( 1, 2147483647 ), true ); } /** * Cache key: changes when the configuration or content generation changes. * * @param Feed_Config $config Configuration. * @return string */ private function cache_key( Feed_Config $config ): string { $generation = (string) get_option( self::GENERATION_OPTION, '0' ); // Permalinks and the home URL change the "permalink" source values. $site = home_url() . '|' . get_option( 'permalink_structure' ); if ( Feed_Config::FORMAT_RSS === $config->format ) { // The channel defaults come from the site's title, tagline and language. $site .= '|' . md5( (string) wp_json_encode( Rss_Profile::channel( $config, $this->site_defaults() ) ) ); } // The access mode is not part of the fingerprint (it does not change the content), but it does // decide whether an RSS self link is written, so it separates cache entries here. return self::CACHE_PREFIX . md5( $config->slug . '|' . $config->fingerprint() . '|' . $config->access . '|' . $generation . '|' . $site . '|' . FEEDWEAVER_VERSION ); } /** * Chooses the renderer. * * @param Feed_Config $config Configuration. * @param bool $pretty Indent. * @return Renderer */ private function renderer( Feed_Config $config, bool $pretty ): Renderer { if ( Feed_Config::FORMAT_JSON === $config->format ) { return new Json_Renderer( $config->root_name, $pretty ); } $namespaces = Namespace_Validator::map( $config->namespaces ); if ( Feed_Config::FORMAT_RSS === $config->format ) { return new Rss_Renderer( Rss_Profile::channel( $config, $this->site_defaults() ), $namespaces, $pretty, $this->self_url( $config ) ); } return new Xml_Renderer( $config->root_name, $config->item_name, $pretty, $namespaces ); } /** * The address written as the RSS self link: the feed's plain URL, for public feeds only. * * Protected feeds get none. A URL that did not carry the credential would not work for readers * that use a URL access key, and some readers replace the address they were given with the self * link, which would silently break those subscriptions; a URL that did carry the credential * would publish the secret. Neither is acceptable, so it is omitted (and documented). * * @param Feed_Config $config Configuration. * @return string */ private function self_url( Feed_Config $config ): string { if ( Feed_Config::ACCESS_PROTECTED === $config->access || 1 !== preg_match( '/^[a-z0-9]+(-[a-z0-9]+)*$/D', $config->slug ) ) { return ''; } return Endpoint::url( $config->slug ); } /** * The site's own values, used where an RSS channel setting is left blank. * * @return array{name:string,url:string,description:string,language:string} */ public function site_defaults(): array { return array( 'name' => Text_Sanitizer::to_plain( (string) get_bloginfo( 'name' ) ), 'url' => (string) home_url( '/' ), 'description' => Text_Sanitizer::to_plain( (string) get_bloginfo( 'description' ) ), 'language' => (string) get_bloginfo( 'language' ), ); } }