check_user_override( $post_id ); if ( $override ) { return $override; } // Level 2 — WordPress core / post type checks. $core = $this->check_core( $post_id, $post ); if ( $core ) { return $core; } // Level 3 — URL slug patterns. $url = $this->check_url_patterns( $post_id ); if ( $url ) { return $url; } // Level 4 — content signals. $content = $this->check_content_signals( $post ); if ( $content ) { return $content; } // Level 5 — fallback. return 'generic_page'; } // ------------------------------------------------------------------------- // Level 1: User override // ------------------------------------------------------------------------- /** * Check for a manually-set page type override in post meta. * * @param int $post_id Post ID. * @return string|null Valid page type or null. */ private function check_user_override( $post_id ) { $value = get_post_meta( $post_id, self::OVERRIDE_META_KEY, true ); if ( $value && in_array( $value, self::$page_types, true ) ) { return $value; } return null; } // ------------------------------------------------------------------------- // Level 2: WordPress core / post type signals // ------------------------------------------------------------------------- /** * Classify using WordPress core logic and post type. * * Works in both front-end (using is_* conditionals) and admin * (using post data and options directly). * * @param int $post_id Post ID. * @param WP_Post $post Post object. * @return string|null Page type or null. */ private function check_core( $post_id, $post ) { // Front-end context — use WP conditionals where available. if ( ! is_admin() ) { if ( is_front_page() ) { return 'homepage'; } if ( is_singular( 'post' ) ) { return 'blog_post'; } if ( is_home() || is_archive() ) { return 'blog_archive'; } if ( is_singular( 'product' ) ) { return 'product_page'; } } // Admin / generation context — derive from post data. if ( 'post' === $post->post_type ) { return 'blog_post'; } if ( 'product' === $post->post_type ) { return 'product_page'; } // Event CPTs (The Events Calendar, EventON, WP Events Manager). if ( in_array( $post->post_type, array( 'tribe_events', 'em_event', 'mec-events', 'mp-event' ), true ) ) { return 'event_page'; } // Course CPTs (LearnDash, LifterLMS, Tutor LMS, LearnPress). if ( in_array( $post->post_type, array( 'sfwd-courses', 'lp_course', 'tutor_course', 'course' ), true ) ) { return 'course_page'; } // Podcast CPTs (Seriously Simple Podcasting, PowerPress, Castos). if ( in_array( $post->post_type, array( 'podcast', 'castos_podcast', 'powerpress' ), true ) ) { return 'podcast_episode'; } // Portfolio CPTs (Jetpack, custom portfolio plugins). if ( in_array( $post->post_type, array( 'jetpack-portfolio', 'portfolio', 'work', 'project' ), true ) ) { return 'portfolio_item'; } // Is this post set as the static front page? if ( 'page' === $post->post_type && 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_on_front' ) === $post_id ) { return 'homepage'; } // Is this the blog posts index page? if ( 'page' === $post->post_type && 'page' === get_option( 'show_on_front' ) && (int) get_option( 'page_for_posts' ) === $post_id ) { return 'blog_archive'; } return null; } // ------------------------------------------------------------------------- // Level 3: URL slug patterns // ------------------------------------------------------------------------- /** * Map URL slug patterns to page types via regex. * * Operates on the URL path portion of the permalink so it is * domain-independent. * * @param int $post_id Post ID. * @return string|null Page type or null. */ private function check_url_patterns( $post_id ) { $permalink = get_permalink( $post_id ); if ( ! $permalink ) { return null; } $path = wp_parse_url( $permalink, PHP_URL_PATH ); if ( ! $path ) { return null; } $path = strtolower( rtrim( $path, '/' ) ); // Order matters — more specific patterns first. $patterns = array( // Multi-location: /locations/city-name/ or /location/city-name/ '#/locations?/[^/]+/?$#' => 'location_page', // Service detail: /services/service-name/ '#/services?/[^/]+/?$#' => 'service_page', // Service hub: /services/ or /our-services/ '#/(our-?)?services?/?$#' => 'service_hub', // About '#/(about|about-us|who-we-are)/?$#' => 'about_page', // Contact '#/(contact|contact-us|get-in-touch)/?$#' => 'contact_page', // FAQ '#/(faq|faqs|frequently-asked-questions)/?$#' => 'dedicated_faq_page', // Legal '#/(privacy|privacy-policy|terms|terms-of-service|terms-and-conditions|cookies|cookie-policy|disclaimer|legal)/?$#' => 'legal_page', // Events '#/events?/[^/]+/?$#' => 'event_page', // Courses '#/courses?/[^/]+/?$#' => 'course_page', '#/lessons?/[^/]+/?$#' => 'course_page', // Podcast episodes '#/episodes?/[^/]+/?$#' => 'podcast_episode', '#/podcast/[^/]+/?$#' => 'podcast_episode', // Portfolio items '#/(portfolio|work|projects?)/[^/]+/?$#' => 'portfolio_item', // Software product/features/pricing (sub-pages only, not the root) '#/(features?|pricing)/?$#' => 'software_product_page', ); foreach ( $patterns as $regex => $type ) { if ( preg_match( $regex, $path ) ) { return $type; } } return null; } // ------------------------------------------------------------------------- // Level 4: Content signals // ------------------------------------------------------------------------- /** * Infer page type from content structure. * * Checks: * - Q&A heading ratio ≥ 60% → dedicated_faq_page * - ≥ 3 linked service blocks → service_hub * - NAP block + contact form + map embed → contact_page * - Single H1 + service-flavoured content → service_page * * @param WP_Post $post Post object. * @return string|null Page type or null. */ private function check_content_signals( $post ) { $content = $post->post_content; if ( empty( $content ) ) { return null; } // FAQ ratio: count H2/H3 ending in "?" vs total H2/H3. preg_match_all( '/]*>(.*?)<\/h[23]>/is', $content, $headings ); $total_h23 = count( $headings[1] ); $question_h23 = 0; if ( $total_h23 > 0 ) { foreach ( $headings[1] as $text ) { if ( substr( rtrim( wp_strip_all_tags( $text ) ), -1 ) === '?' ) { $question_h23++; } } if ( ( $question_h23 / $total_h23 ) >= 0.6 ) { return 'dedicated_faq_page'; } } // Service hub: ≥3 internal links that look like service pages. preg_match_all( '/]*href=["\']([^"\']+)["\'][^>]*>(.*?)<\/a>/is', $content, $links ); $service_links = 0; $site_url = home_url(); if ( ! empty( $links[1] ) ) { foreach ( $links[1] as $href ) { if ( strpos( $href, $site_url ) === 0 || strpos( $href, '/' ) === 0 ) { $link_path = strtolower( wp_parse_url( $href, PHP_URL_PATH ) ?? '' ); // Any internal sub-page link under a services-type path. if ( preg_match( '#/services?/#', $link_path ) ) { $service_links++; } } } } if ( $service_links >= 3 ) { return 'service_hub'; } // Contact page: has a contact-form shortcode/block AND phone/address indicators. $has_form = ( false !== stripos( $content, 'contact-form' ) || false !== stripos( $content, 'wpforms' ) || false !== stripos( $content, 'gravity_form' ) || false !== stripos( $content, 'cf7' ) ); $has_map = ( false !== stripos( $content, 'google.com/maps' ) || false !== stripos( $content, 'maps.google' ) ); $has_address = (bool) preg_match( '/\d{1,5}\s+\w[\w\s,\.]+(?:st|street|ave|avenue|blvd|boulevard|rd|road|dr|drive|ln|lane|way|court|ct|pl|place)/i', wp_strip_all_tags( $content ) ); if ( $has_form && ( $has_map || $has_address ) ) { return 'contact_page'; } // Service page: single H1 + service/pricing/CTA language. $service_keywords = array( 'service', 'contractor', 'repair', 'install', 'replace', 'estimate', 'quote', 'hire' ); $text = strtolower( wp_strip_all_tags( $content ) ); $keyword_matches = 0; foreach ( $service_keywords as $kw ) { if ( false !== strpos( $text, $kw ) ) { $keyword_matches++; } } if ( $keyword_matches >= 3 ) { return 'service_page'; } // Recipe: ingredients + cooking instructions signals. $recipe_keywords = array( 'ingredients', 'instructions', 'prep time', 'cook time', 'servings', 'recipe', 'tablespoon', 'teaspoon', 'preheat' ); $recipe_matches = 0; foreach ( $recipe_keywords as $kw ) { if ( false !== strpos( $text, $kw ) ) { $recipe_matches++; } } if ( $recipe_matches >= 4 ) { return 'recipe_page'; } // Podcast episode: audio embed + episode language. $has_audio = ( false !== stripos( $content, 'powerpress' ) || false !== stripos( $content, 'simplecast' ) || false !== stripos( $content, 'buzzsprout' ) || false !== stripos( $content, 'podbean' ) || preg_match( '/]+>/i', $content ) ); $podcast_keywords = array( 'episode', 'listen', 'transcript', 'show notes', 'subscribe' ); $podcast_matches = 0; foreach ( $podcast_keywords as $kw ) { if ( false !== strpos( $text, $kw ) ) { $podcast_matches++; } } if ( $has_audio && $podcast_matches >= 2 ) { return 'podcast_episode'; } // Course page: curriculum/enrollment language. $course_keywords = array( 'enroll', 'curriculum', 'lesson', 'module', 'course', 'certificate', 'syllabus', 'instructor' ); $course_matches = 0; foreach ( $course_keywords as $kw ) { if ( false !== strpos( $text, $kw ) ) { $course_matches++; } } if ( $course_matches >= 4 ) { return 'course_page'; } return null; } // ------------------------------------------------------------------------- // Utility // ------------------------------------------------------------------------- /** * Save a manual page-type override to post meta. * * Called from the meta box save. * * @param int $post_id Post ID. * @param string $type One of the page type slugs, or empty to clear. * @return void */ public function save_override( $post_id, $type ) { if ( empty( $type ) || ! in_array( $type, self::$page_types, true ) ) { delete_post_meta( $post_id, self::OVERRIDE_META_KEY ); } else { update_post_meta( $post_id, self::OVERRIDE_META_KEY, $type ); } } }