Type -> Object cascade. Deliberately a pure reporter * of today's real behavior (WP defaults + what the plugin currently does), * not a new decision-maker: nothing in the plugin consumes this yet * (frontend output, sitemap, and JSON-LD stay exactly as they are in R1). * R2-R4 wire real consumers to it and add genuine per-type/per-object * override UI (Search Appearance, Schema); this class is the rails that * work rides on, not the vehicle itself. * * Coordinator decision (R1 plan sign-off): the "index"/"follow" fields DO * honor the already-stored-but-currently-inert _abds_seo_noindex / * _abds_seo_nofollow post meta (written by competitor-plugin migration, * never read back out anywhere else in the plugin today) — reporting the * real stored intent is more correct than ignoring it, and it changes no * output in R1 since nothing consumes this class yet. This means: once R2 * wires a real consumer (e.g. frontend meta-robots output) to this engine, * long-migrated noindex values will start taking effect on the live site * for the first time — a deliberate activation, not a surprise, and it * must be called out explicitly in that release's own report. * * R2 (F2.3): the cascade gains a real Type layer. "Type" here is a * generalized `type_key`: a real post-type slug, a taxonomy slug prefixed * `tax:` (avoids colliding with a post type of the same name), or one of * the reserved double-underscore pseudo-keys for archive/system contexts * that have no registered slug of their own (`__author_archive`, * `__date_archive`, `__search`, `__404`) — see resolve_current_request(). * Archive/System contexts fold into this same Type layer rather than a * separate 4th cascade bucket: they answer the exact same question ("what * is the policy for this kind of thing"), just with a naturally empty * Object layer (no per-object meta applies to a date archive). * * Split into a pure cascade (resolve_from_raw(), WP-free, unit-tested like * ABDS_SEO_Meta_Builder) and thin WP-aware gatherers (resolve(), accepts a * WP_Post, a post ID, a WP_Term, or a URL; resolve_current_request() reads * the current main query) that feed it — same split this codebase already * uses between ABDS_SEO_Meta_Builder and ABDS_SEO_Frontend_Output. * * @package ABDS_SEO */ if ( ! defined( 'ABSPATH' ) ) { exit; } /** * Resolves the current effective SEO policy for a URL, object, or request. */ class ABDS_SEO_Policy_Engine { const PSEUDO_AUTHOR_ARCHIVE = '__author_archive'; const PSEUDO_DATE_ARCHIVE = '__date_archive'; const PSEUDO_SEARCH = '__search'; const PSEUDO_404 = '__404'; /** * WP-aware entrypoint: accepts a WP_Post, a post ID, a WP_Term, or a * URL, gathers today's actual Global/Type/Object values via read-only * WP calls, and returns the resolved policy. A URL that doesn't resolve * to a post (e.g. one wp_delete_post() has already removed) still gets * a real answer, just Global-only (no post-level meta or per-post * canonical override applies to something that isn't a post). * * @param WP_Post|WP_Term|int|string $url_or_object A post object/ID, a term object, or a URL string. * @return array{index: bool, follow: bool, canonical: string, in_sitemap: bool, schema_type: string} */ public static function resolve( $url_or_object ): array { if ( $url_or_object instanceof WP_Term ) { return self::resolve_term( $url_or_object ); } $post = self::resolve_post( $url_or_object ); $type_settings = self::type_settings_for_key( $post instanceof WP_Post ? $post->post_type : '' ); $raw = array( 'blog_public' => self::blog_public(), 'type_settings' => $type_settings, 'type_key' => $post instanceof WP_Post ? $post->post_type : '', 'global_default_robots' => self::global_default_robots(), 'noindex_meta' => $post instanceof WP_Post && '1' === (string) get_post_meta( $post->ID, '_abds_seo_noindex', true ), 'nofollow_meta' => $post instanceof WP_Post && '1' === (string) get_post_meta( $post->ID, '_abds_seo_nofollow', true ), 'seo_canonical' => $post instanceof WP_Post ? (string) get_post_meta( $post->ID, '_abds_seo_canonical', true ) : '', 'wp_canonical' => $post instanceof WP_Post ? (string) wp_get_canonical_url( $post ) : '', ); return self::resolve_from_raw( $raw ); } /** * WP-aware entrypoint for a taxonomy term: same Type layer as * resolve(), keyed `tax:{taxonomy}`. Terms carry no per-object SEO meta * today, so the Object layer is naturally empty — the cascade still * resolves correctly (Global/Type only), same as resolve_post() * returning null does for a non-post URL. * * @param WP_Term $term Term object. * @return array{index: bool, follow: bool, canonical: string, in_sitemap: bool, schema_type: string} */ protected static function resolve_term( WP_Term $term ): array { $raw = array( 'blog_public' => self::blog_public(), 'type_settings' => self::type_settings_for_key( 'tax:' . $term->taxonomy ), 'global_default_robots' => self::global_default_robots(), 'wp_canonical' => (string) get_term_link( $term ), ); return self::resolve_from_raw( $raw ); } /** * WP-aware entrypoint for the current main query: singular posts and * taxonomy terms delegate to resolve()/resolve_term() (identical * result to calling them directly with the queried object); author/ * date/search/404 archives resolve via their reserved pseudo-keys, * since they have no post/term object of their own to key off of. * Consumers: F2.5's frontend meta-robots output, F2.7's robots.txt * checks. * * @return array{index: bool, follow: bool, canonical: string, in_sitemap: bool, schema_type: string} */ public static function resolve_current_request(): array { if ( is_singular() ) { return self::resolve( get_queried_object_id() ); } $queried_term = get_queried_object(); if ( ( is_category() || is_tag() || is_tax() ) && $queried_term instanceof WP_Term ) { return self::resolve_term( $queried_term ); } if ( is_author() ) { $pseudo_key = self::PSEUDO_AUTHOR_ARCHIVE; } elseif ( is_date() ) { $pseudo_key = self::PSEUDO_DATE_ARCHIVE; } elseif ( is_search() ) { $pseudo_key = self::PSEUDO_SEARCH; } elseif ( is_404() ) { $pseudo_key = self::PSEUDO_404; } else { // The blog homepage (posts index), post-type archives, and any // other request type this method doesn't explicitly recognize // all fall through to a plain Global-only resolve (empty Type // layer — type_settings_for_key('') never matches a real // post-type/taxonomy/pseudo-key, so the cascade just applies // Global). Deliberately NOT the 404 pseudo-key: defaulting an // unrecognized-but-real page to noindex is far more dangerous // (silently deindexing live content, as the blog homepage // itself did before this fix) than defaulting it to the same // indexable-by-default behavior most pages already get. $pseudo_key = ''; } $raw = array( 'blog_public' => self::blog_public(), 'type_settings' => self::type_settings_for_key( $pseudo_key ), 'global_default_robots' => self::global_default_robots(), ); return self::resolve_from_raw( $raw ); } /** * The actual Global -> Type -> Object cascade, given already-gathered * raw values. Pure: no WP calls, unit-tested directly (mirrors * ABDS_SEO_Meta_Builder's pattern). * * Every key of $raw is optional and defaults to the least-restrictive/ * empty value so a partial array still resolves: blog_public (bool), * type_settings (array{index?: ?bool, follow?: ?bool, in_sitemap?: ?bool, schema_type?: string}), * global_default_robots (string, one of the 4 "index,follow"-style * combinations), noindex_meta (bool), nofollow_meta (bool), * seo_canonical (string), wp_canonical (string). * * Layer precedence for index/follow: Global's blog_public is the one * site-wide override nothing below it can undo. Type's index/follow * (null/absent = inherit) overrides Global's own default_robots * choice. Object's noindex/nofollow meta can only ever force OFF — * there is no "force index" object-level signal, matching the R1 * migrated data this reads (a competitor plugin's export never * expresses "force index", only "this was marked noindex/nofollow"). * * @param array $raw Raw gathered values, see above for the shape. * @return array{index: bool, follow: bool, canonical: string, in_sitemap: bool, schema_type: string} */ public static function resolve_from_raw( array $raw ): array { $blog_public = (bool) ( $raw['blog_public'] ?? true ); $type_settings = (array) ( $raw['type_settings'] ?? array() ); $default_robots = (string) ( $raw['global_default_robots'] ?? '' ); $noindex_meta = (bool) ( $raw['noindex_meta'] ?? false ); $nofollow_meta = (bool) ( $raw['nofollow_meta'] ?? false ); $seo_canonical = trim( (string) ( $raw['seo_canonical'] ?? '' ) ); $wp_canonical = (string) ( $raw['wp_canonical'] ?? '' ); $global_index_default = ! str_contains( $default_robots, 'noindex' ); $global_follow_default = ! str_contains( $default_robots, 'nofollow' ); $type_index = array_key_exists( 'index', $type_settings ) ? $type_settings['index'] : null; $type_follow = array_key_exists( 'follow', $type_settings ) ? $type_settings['follow'] : null; $type_in_sitemap = array_key_exists( 'in_sitemap', $type_settings ) ? $type_settings['in_sitemap'] : null; $index = $blog_public && ( $type_index ?? $global_index_default ) && ! $noindex_meta; $follow = ( $type_follow ?? $global_follow_default ) && ! $nofollow_meta; $canonical = '' !== $seo_canonical ? $seo_canonical : $wp_canonical; // A Type can additionally exclude itself from the sitemap even // while staying indexable (e.g. "indexed but not worth listing"); // it can never rejoin the sitemap once Global/Object noindex it. $in_sitemap = $index && ( $type_in_sitemap ?? true ); return array( 'index' => $index, 'follow' => $follow, 'canonical' => $canonical, 'in_sitemap' => $in_sitemap, 'schema_type' => self::resolve_schema_type( $type_settings, (string) ( $raw['type_key'] ?? '' ) ), ); } /** * Type layer for schema_type: the stored per-type value when it is one of * ABDS_SEO_Schema_Validator::PAGE_TYPES, otherwise the default for that * content type (WebPage for `page`, Article for the rest -- both for an * unset type and as the fallback for an unknown stored value). The graph * (ABDS_SEO_Schema_Graph) prints exactly this type. An unknown stored value is * not silently accepted: the audit reports it (see * stored_schema_type_for_post_type()). * * @param array $type_settings This type_key's settings sub-array. * @param string $type_key Post-type slug ('' for terms and archives). * @return string */ protected static function resolve_schema_type( array $type_settings, string $type_key = '' ): string { return ABDS_SEO_Schema_Validator::resolve_page_type( (string) ( $type_settings['schema_type'] ?? '' ), $type_key )['type']; } /** * The raw stored schema_type of a post type, exactly as saved ("" when * never set). Lets the audit tell "never set" from "set to something we * cannot print" -- resolve() reports only the effective type. * * @param string $post_type Post type slug. * @return string */ public static function stored_schema_type_for_post_type( string $post_type ): string { return trim( (string) ( self::type_settings_for_key( $post_type )['schema_type'] ?? '' ) ); } /** * Looks up the stored settings sub-array for one type_key, merging the * three storage buckets (type_settings for post types, taxonomy_settings * for `tax:`-prefixed taxonomies, archive_settings for the reserved * pseudo-keys) into the single Type-layer namespace resolve_from_raw() * expects. `__404` is never user-configurable (see class docblock) — * always reported as noindex regardless of what's stored. * * @param string $type_key Post-type slug, `tax:{taxonomy}`, or a reserved pseudo-key. * @return array */ protected static function type_settings_for_key( string $type_key ): array { if ( self::PSEUDO_404 === $type_key ) { return array( 'index' => false ); } $settings = get_option( 'abds_seo_settings', array() ); if ( str_starts_with( $type_key, 'tax:' ) ) { $taxonomy = substr( $type_key, 4 ); return (array) ( $settings['taxonomy_settings'][ $taxonomy ] ?? array() ); } if ( self::PSEUDO_AUTHOR_ARCHIVE === $type_key ) { return self::author_archive_settings( $settings ); } if ( in_array( $type_key, array( self::PSEUDO_DATE_ARCHIVE, self::PSEUDO_SEARCH ), true ) ) { $archive_key = str_replace( '__', '', $type_key ); return (array) ( $settings['archive_settings'][ $archive_key ] ?? array() ); } return (array) ( $settings['type_settings'][ $type_key ] ?? array() ); } /** * The author archive's `index` has a real dynamic default (F2.4): while * the site owner hasn't explicitly chosen Index/Noindex (stored `index` * absent or null), it follows the conventional "single-author site → * the /author/ archive duplicates the homepage feed, noindex it" * recommendation, computed from ABDS_SEO_Author_Count. An explicit * stored true/false always wins over the recommendation, same as any * other Type-level override. * * @param array $settings Full abds_seo_settings option value. * @return array */ protected static function author_archive_settings( array $settings ): array { $stored = (array) ( $settings['archive_settings']['author_archive'] ?? array() ); if ( ! array_key_exists( 'index', $stored ) || null === $stored['index'] ) { $stored['index'] = 1 !== ABDS_SEO_Author_Count::published_author_count(); } return $stored; } /** * WP core's own "discourage search engines" setting — the one * site-wide override nothing else in the cascade can undo. * * @return bool */ protected static function blog_public(): bool { return '0' !== (string) get_option( 'blog_public' ); } /** * The Global tab's own default_robots choice (F2.2), the fallback the * Type layer inherits from when it has no explicit override. * * @return string */ protected static function global_default_robots(): string { $settings = get_option( 'abds_seo_settings', array() ); return (string) ( $settings['global']['default_robots'] ?? 'index,follow' ); } /** * Resolves the input to a WP_Post when possible: passes a WP_Post * through, looks a post ID up, or resolves a URL via WP core's own * url_to_postid(). Returns null for anything that doesn't resolve to a * post (non-post URL, deleted post, malformed input) — resolve() treats * that as "Global layer only", not an error. * * @param WP_Post|int|string $url_or_object A post object/ID, or a URL string. * @return WP_Post|null */ protected static function resolve_post( $url_or_object ): ?WP_Post { if ( $url_or_object instanceof WP_Post ) { return $url_or_object; } if ( is_int( $url_or_object ) ) { $post = get_post( $url_or_object ); return $post instanceof WP_Post ? $post : null; } if ( is_string( $url_or_object ) && '' !== trim( $url_or_object ) ) { $post_id = url_to_postid( $url_or_object ); return $post_id > 0 ? self::resolve_post( $post_id ) : null; } return null; } }