, canonical: string, title: string, description: string, image: string, nodes: list>}|null */ private static $context = null; /** @var bool Current request is a detail base page with no entity (empty shell). */ private static $is_empty_shell = false; /** * Provider directory listing (/providers/) for the current request. * An empty title/description means the page's own SEO value is kept. * * @var array{page_id: int, url: string, title: string, description: string, items: list, clinic_id: string, clinic_node: array|null}|null */ private static $directory = null; /** Directory query parameters that only filter or re-sort the same list. */ private const FILTER_PARAMS = ['pd_search', 'pd_location', 'pd_specialty', 'pd_gender', 'pd_accepting', 'pd_sort']; public static function init(): void { // After Powr_Dir_Rewrites (priority 10) has issued redirects / 404s. add_action('template_redirect', [self::class, 'prepare'], 20); add_filter('aioseo_sitemap_posts', [self::class, 'aioseo_sitemap_entries'], 10, 2); add_action('init', [self::class, 'register_core_sitemap']); // Yoast / Rank Math replace the core sitemap: serve /pd-providers-sitemap.xml // and list it in their sitemap index. add_action('template_redirect', [self::class, 'serve_sitemap'], 0); add_filter('wpseo_sitemap_index', [self::class, 'sitemap_index_entry']); add_filter('rank_math/sitemap/index', [self::class, 'sitemap_index_entry'], 11); } /** * True when this class outputs the JSON-LD for the given entity, so the * shortcode must not print a second copy in the page body. */ public static function handles_entity(string $type, string $slug): bool { return self::$context !== null && self::$context['type'] === $type && self::$context['slug'] === $slug; } public static function aioseo_active(): bool { return function_exists('aioseo'); } /** * The SEO plugin that owns output: 'aioseo', 'yoast', 'rankmath', * or '' when none is active (the plugin then prints its own tags). */ public static function seo_plugin(): string { if (self::aioseo_active()) { return 'aioseo'; } if (defined('WPSEO_VERSION')) { return 'yoast'; } if (class_exists('RankMath')) { return 'rankmath'; } return ''; } public static function prepare(): void { try { if (is_404()) { return; } $resolved = self::resolve_detail_entity(); if ($resolved === null) { self::$is_empty_shell = self::detect_empty_shell(); if (self::$is_empty_shell) { self::register_noindex(); return; } // Filtered/re-sorted listings duplicate the directory itself. if (self::is_directory_page() && self::has_filter_params()) { self::register_noindex(); } self::prepare_directory(); return; } [$slug, $type, $page_id] = $resolved; $client = new Powr_Dir_Api_Client(); if (! $client->is_configured()) { return; } $entity = $type === 'provider' ? Powr_Dir_Shortcodes::load_provider($client, $slug) : Powr_Dir_Shortcodes::load_location($client, $slug); if (is_wp_error($entity) || ! is_array($entity) || $entity === []) { return; } $canonical = Powr_Dir_Rewrites::build_profile_url((string) get_permalink($page_id), $slug); if ($canonical === '#') { return; } // JSON-LD is Laravel-owned and printed only by Powr_Dir_Schema::emit_from_wp_head. // This context drives title / canonical / Open Graph — not schema graph nodes. self::$context = [ 'type' => $type, 'slug' => $slug, 'page_id' => $page_id, 'entity' => $entity, 'canonical' => $canonical, 'title' => self::build_title($entity, $type), 'description' => self::build_description($entity, $type), 'image' => self::real_image((string) ($entity['photo'] ?? '')), 'nodes' => [], ]; switch (self::seo_plugin()) { case 'aioseo': self::register_aioseo_hooks(); break; case 'yoast': self::register_yoast_hooks(); break; case 'rankmath': self::register_rankmath_hooks(); break; default: self::register_core_hooks(); } } catch (Throwable $e) { // Never break page rendering for SEO failures. self::$context = null; self::$directory = null; } } /* ------------------------------------------------------------------ * All in One SEO integration * ------------------------------------------------------------------ */ private static function register_aioseo_hooks(): void { add_filter('aioseo_title', [self::class, 'filter_title'], 20); add_filter('aioseo_description', [self::class, 'filter_description'], 20); add_filter('aioseo_canonical_url', [self::class, 'filter_canonical'], 20); add_filter('aioseo_facebook_tags', [self::class, 'aioseo_facebook_tags'], 20); add_filter('aioseo_twitter_tags', [self::class, 'aioseo_twitter_tags'], 20); add_filter('aioseo_breadcrumbs_trail', [self::class, 'aioseo_breadcrumbs_trail'], 20); add_filter('aioseo_schema_output', [self::class, 'aioseo_schema_output'], 20); } /** * @return string */ public static function filter_title($title) { return self::$context !== null ? self::$context['title'] : $title; } /** * @return string */ public static function filter_description($description) { return self::$context !== null ? self::$context['description'] : $description; } /** * @return string */ public static function filter_canonical($url) { return self::$context !== null ? self::$context['canonical'] : $url; } /** * @param array $meta * @return array */ public static function aioseo_facebook_tags($meta) { if (self::$context === null || ! is_array($meta)) { return $meta; } $c = self::$context; // Article tags describe the host WordPress page, not the profile. foreach (array_keys($meta) as $key) { if (strpos((string) $key, 'article:') === 0) { unset($meta[$key]); } } $meta['og:type'] = $c['type'] === 'provider' ? 'profile' : 'website'; $meta['og:title'] = $c['title']; $meta['og:description'] = $c['description']; $meta['og:url'] = $c['canonical']; if ($c['image'] !== '') { $meta['og:image'] = $c['image']; $meta['og:image:secure_url'] = is_ssl() ? $c['image'] : ''; $meta['og:image:alt'] = self::image_alt($c['entity'], $c['type']); // Dimensions belonged to the page's default image. unset($meta['og:image:width'], $meta['og:image:height']); } return $meta; } /** * @param array $meta * @return array */ public static function aioseo_twitter_tags($meta) { if (self::$context === null || ! is_array($meta)) { return $meta; } $c = self::$context; $meta['twitter:title'] = $c['title']; $meta['twitter:description'] = $c['description']; if ($c['image'] !== '') { $meta['twitter:image'] = $c['image']; $meta['twitter:image:alt'] = self::image_alt($c['entity'], $c['type']); } // AIOSEO adds reading-time labels for articles; not meaningful here. unset($meta['twitter:label1'], $meta['twitter:data1'], $meta['twitter:label2'], $meta['twitter:data2']); return $meta; } /** * Append the provider/location as the last crumb (drives both the visible * AIOSEO breadcrumbs and its BreadcrumbList schema). * * @param array> $trail * @return array> */ public static function aioseo_breadcrumbs_trail($trail) { if (self::$context === null || ! is_array($trail)) { return $trail; } $trail[] = [ 'label' => self::entity_display_name(self::$context['entity']), 'link' => self::$context['canonical'], 'type' => 'powr_' . self::$context['type'], 'subType' => null, 'reference' => null, ]; return $trail; } /** * Re-key AIOSEO's page-level nodes (WebPage, BreadcrumbList) from the host * page URL to the profile URL, and merge the provider/location nodes into * the same @graph. * * @param array $graphs * @return array */ public static function aioseo_schema_output($graphs) { return self::merge_profile_graph($graphs); } /** * Remap host SEO-plugin page nodes to the profile canonical URL. * Does not inject provider/location JSON-LD (Laravel schemaDocument only). * * @param array $graphs * @return array */ private static function merge_profile_graph($graphs) { if (self::$context === null || ! is_array($graphs)) { return $graphs; } $c = self::$context; $base = untrailingslashit((string) get_permalink($c['page_id'])); $id_map = []; $webpage_index = null; foreach ($graphs as $i => $node) { if (! is_array($node)) { continue; } $id = (string) ($node['@id'] ?? ''); $hash = strpos($id, '#'); $id_base = $hash === false ? $id : substr($id, 0, $hash); if ($id === '' || untrailingslashit($id_base) !== $base) { continue; } $new_id = $hash === false ? $c['canonical'] : $c['canonical'] . '#' . substr($id, $hash + 1); $id_map[$id] = $new_id; $graphs[$i]['@id'] = $new_id; if (isset($node['url'])) { $graphs[$i]['url'] = $c['canonical']; } if (self::is_web_page_node($node)) { $webpage_index = $i; } } $graphs = self::replace_ids($graphs, $id_map); if ($webpage_index !== null) { $graphs[$webpage_index]['name'] = $c['title']; $graphs[$webpage_index]['description'] = $c['description']; if ($c['type'] === 'provider') { $graphs[$webpage_index]['@type'] = self::retype_web_page($graphs[$webpage_index]['@type'] ?? '', 'ProfilePage'); } } return $graphs; } /** * A plain WebPage node becomes the more specific page type; other page * types (e.g. a custom AboutPage) are kept. * * @param mixed $type * @return mixed */ private static function retype_web_page($type, string $new_type) { if ($type === 'WebPage') { return $new_type; } if (is_array($type) && in_array('WebPage', $type, true)) { $type = array_values(array_unique(array_map(static function ($t) use ($new_type) { return $t === 'WebPage' ? $new_type : $t; }, $type))); return count($type) === 1 ? $type[0] : $type; } return $type; } /** * og:type for the current profile. * * @return string */ public static function filter_og_type($type) { if (self::$context === null) { return $type; } return self::$context['type'] === 'provider' ? 'profile' : 'website'; } /** * @return string */ public static function filter_image($image) { return self::$context !== null && self::$context['image'] !== '' ? self::$context['image'] : $image; } /* ------------------------------------------------------------------ * Yoast SEO integration * ------------------------------------------------------------------ */ private static function register_yoast_hooks(): void { add_filter('wpseo_title', [self::class, 'filter_title'], 20); add_filter('wpseo_metadesc', [self::class, 'filter_description'], 20); add_filter('wpseo_canonical', [self::class, 'filter_canonical'], 20); add_filter('wpseo_opengraph_url', [self::class, 'filter_canonical'], 20); add_filter('wpseo_opengraph_title', [self::class, 'filter_title'], 20); add_filter('wpseo_opengraph_desc', [self::class, 'filter_description'], 20); add_filter('wpseo_opengraph_type', [self::class, 'filter_og_type'], 20); add_action('wpseo_add_opengraph_images', [self::class, 'yoast_add_image'], 20); add_filter('wpseo_twitter_title', [self::class, 'filter_title'], 20); add_filter('wpseo_twitter_description', [self::class, 'filter_description'], 20); add_filter('wpseo_twitter_image', [self::class, 'filter_image'], 20); // Publish/modified dates describe the host WordPress page, not the profile. add_filter('wpseo_opengraph_show_publish_date', '__return_false', 20); add_filter('wpseo_schema_graph', [self::class, 'yoast_schema_graph'], 20); add_filter('wpseo_breadcrumb_links', [self::class, 'yoast_breadcrumb_links'], 20); } /** * @param object $container Yoast's WPSEO_OpenGraph_Image container. */ public static function yoast_add_image($container): void { if (self::$context === null || self::$context['image'] === '' || ! is_object($container) || ! method_exists($container, 'add_image_by_url')) { return; } $container->add_image_by_url(self::$context['image']); } /** * @param array $graph * @return array */ public static function yoast_schema_graph($graph) { return array_values((array) self::merge_profile_graph($graph)); } /** * @param array> $links * @return array> */ public static function yoast_breadcrumb_links($links) { if (self::$context === null || ! is_array($links)) { return $links; } $links[] = [ 'url' => self::$context['canonical'], 'text' => self::entity_display_name(self::$context['entity']), ]; return $links; } /* ------------------------------------------------------------------ * Rank Math integration * ------------------------------------------------------------------ */ private static function register_rankmath_hooks(): void { add_filter('rank_math/frontend/title', [self::class, 'filter_title'], 20); add_filter('rank_math/frontend/description', [self::class, 'filter_description'], 20); add_filter('rank_math/frontend/canonical', [self::class, 'filter_canonical'], 20); add_filter('rank_math/opengraph/type', [self::class, 'filter_og_type'], 20); add_filter('rank_math/opengraph/url', [self::class, 'filter_canonical'], 20); // Rank Math filters each tag as rank_math/opengraph/{network}/{tag}. $tags = [ 'facebook/og_title' => 'filter_title', 'facebook/og_description' => 'filter_description', 'facebook/og_url' => 'filter_canonical', 'facebook/og_type' => 'filter_og_type', 'facebook/image' => 'filter_image', 'twitter/twitter_title' => 'filter_title', 'twitter/twitter_description' => 'filter_description', 'twitter/image' => 'filter_image', ]; foreach ($tags as $tag => $callback) { add_filter('rank_math/opengraph/' . $tag, [self::class, $callback], 20); } add_filter('rank_math/json_ld', [self::class, 'rankmath_json_ld'], 99); add_filter('rank_math/frontend/breadcrumb/items', [self::class, 'rankmath_breadcrumb_items'], 20); } /** * @param array $data Rank Math graph, keyed by node name. * @return array */ public static function rankmath_json_ld($data) { return self::merge_profile_graph($data); } /** * @param array> $crumbs [label, url] pairs. * @return array> */ public static function rankmath_breadcrumb_items($crumbs) { if (self::$context === null || ! is_array($crumbs)) { return $crumbs; } $crumbs[] = [self::entity_display_name(self::$context['entity']), self::$context['canonical']]; return $crumbs; } /* ------------------------------------------------------------------ * Fallback when no SEO plugin is active * ------------------------------------------------------------------ */ private static function register_core_hooks(): void { add_filter('pre_get_document_title', [self::class, 'filter_title'], 20); add_filter('get_canonical_url', [self::class, 'core_canonical'], 20, 2); add_action('wp_head', [self::class, 'print_head_tags'], 1); // Sondy JSON-LD is emitted only by Powr_Dir_Schema::emit_from_wp_head. } /** * @param string $url * @param WP_Post $post * @return string */ public static function core_canonical($url, $post) { if (self::$context === null || ! $post instanceof WP_Post || (int) $post->ID !== self::$context['page_id']) { return $url; } return self::$context['canonical']; } public static function print_head_tags(): void { if (self::$context === null) { return; } $c = self::$context; $tags = [ ['name', 'description', $c['description']], ['property', 'og:type', $c['type'] === 'provider' ? 'profile' : 'website'], ['property', 'og:title', $c['title']], ['property', 'og:description', $c['description']], ['property', 'og:url', $c['canonical']], ['property', 'og:site_name', (string) get_bloginfo('name')], ['property', 'og:locale', (string) get_locale()], ['name', 'twitter:card', $c['image'] !== '' ? 'summary_large_image' : 'summary'], ['name', 'twitter:title', $c['title']], ['name', 'twitter:description', $c['description']], ]; if ($c['image'] !== '') { $alt = self::image_alt($c['entity'], $c['type']); $tags[] = ['property', 'og:image', $c['image']]; $tags[] = ['property', 'og:image:alt', $alt]; $tags[] = ['name', 'twitter:image', $c['image']]; $tags[] = ['name', 'twitter:image:alt', $alt]; } foreach ($tags as [$attr, $key, $value]) { if ($value === '') { continue; } printf( '' . "\n", esc_attr($attr), esc_attr($key), esc_attr($value) ); } } /** * @deprecated Sondy JSON-LD is printed only by Powr_Dir_Schema::emit_from_wp_head. */ public static function print_json_ld(): void { // Intentionally empty — do not reconstruct provider/location schema in WordPress. } /* ------------------------------------------------------------------ * Provider directory listing (/providers/) * ------------------------------------------------------------------ */ /** * Title, description and CollectionPage + ItemList schema for the * provider directory. Hooks into All in One SEO when active; prints its * own tags only when no SEO plugin is active (Yoast / Rank Math own the * directory page's output and are left alone). */ private static function prepare_directory(): void { if (! self::is_directory_page()) { return; } $seo_plugin = self::seo_plugin(); $client = new Powr_Dir_Api_Client(); if (! $client->is_configured()) { return; } $result = $client->get_providers(); if (is_wp_error($result)) { return; } $rows = array_values(array_filter((array) $result['data'], 'is_array')); $detail_page_id = (int) get_option('powr_detail_page_id', 0); $detail_url = $detail_page_id > 0 ? (string) get_permalink($detail_page_id) : ''; if ($rows === [] || $detail_url === '') { return; } $ordered = Powr_Dir_Directory_Html::sort_providers($rows, Powr_Dir_Shortcodes::provider_directory_is_simplified(count($rows))); $items = []; $first_names = []; $accepting = false; foreach ($ordered as $row) { $slug = sanitize_title((string) ($row['slug'] ?? '')); $url = $slug !== '' ? Powr_Dir_Rewrites::build_profile_url($detail_url, $slug) : '#'; $name = trim((string) ($row['name'] ?? '')); if ($url === '#' || $name === '') { continue; } $items[] = ['name' => self::entity_display_name($row), 'url' => $url]; $first_names[] = $name; if (! empty($row['newPatients']) || ! empty($row['acceptingNewPatients']) || ! empty($row['accepting'])) { $accepting = true; } } if ($items === []) { return; } $page_id = (int) get_queried_object_id(); $theme = Powr_Dir_Shortcodes::load_theme($client); $clinic_name = wp_strip_all_tags(trim((string) ($theme['name'] ?? ''))); if ($clinic_name === '') { $clinic_name = wp_strip_all_tags((string) get_bloginfo('name')); } $place = self::directory_place($client); [$has_title, $has_description] = self::custom_page_meta($seo_plugin, $page_id); $title = $has_title ? '' : self::build_directory_title($clinic_name, $place); $description = $has_description ? '' : self::build_directory_description($first_names, $clinic_name, $place, $accepting); /** * Optional clinic @id for SEO-plugin graph remapping only. * Organization JSON-LD itself comes from Laravel schemaDocument. */ $clinic_id = (string) apply_filters( 'powr_dir_clinic_schema_id', home_url('/#organization'), null ); self::$directory = [ 'page_id' => $page_id, 'url' => (string) get_permalink($page_id), 'title' => $title, 'description' => $description, 'items' => $items, 'clinic_id' => $clinic_id, 'clinic_node' => null, ]; switch ($seo_plugin) { case 'aioseo': add_filter('aioseo_title', [self::class, 'directory_title'], 20); add_filter('aioseo_description', [self::class, 'directory_description'], 20); add_filter('aioseo_facebook_tags', [self::class, 'directory_social_tags'], 20); add_filter('aioseo_twitter_tags', [self::class, 'directory_social_tags'], 20); add_filter('aioseo_schema_output', [self::class, 'directory_schema_output'], 20); break; case 'yoast': foreach (['wpseo_title', 'wpseo_opengraph_title', 'wpseo_twitter_title'] as $hook) { add_filter($hook, [self::class, 'directory_title'], 20); } foreach (['wpseo_metadesc', 'wpseo_opengraph_desc', 'wpseo_twitter_description'] as $hook) { add_filter($hook, [self::class, 'directory_description'], 20); } add_filter('wpseo_schema_graph', [self::class, 'directory_schema_list'], 20); break; case 'rankmath': foreach (['rank_math/frontend/title', 'rank_math/opengraph/facebook/og_title', 'rank_math/opengraph/twitter/twitter_title'] as $hook) { add_filter($hook, [self::class, 'directory_title'], 20); } foreach (['rank_math/frontend/description', 'rank_math/opengraph/facebook/og_description', 'rank_math/opengraph/twitter/twitter_description'] as $hook) { add_filter($hook, [self::class, 'directory_description'], 20); } add_filter('rank_math/json_ld', [self::class, 'directory_schema_output'], 99); break; default: add_filter('pre_get_document_title', [self::class, 'directory_title'], 20); add_action('wp_head', [self::class, 'print_directory_head'], 1); } } /** * Yoast expects a list, not the keyed array Rank Math uses. * * @param array $graph * @return array */ public static function directory_schema_list($graph) { return array_values((array) self::directory_schema_output($graph)); } /** * The page that lists providers: a page holding a directory shortcode, or * the provider page opened without a profile slug (page builders such as * Oxygen keep shortcodes outside post_content). */ private static function is_directory_page(): bool { if (! is_page() || sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_PROVIDER)) !== '') { return false; } $page_id = (int) get_queried_object_id(); $content = (string) get_post_field('post_content', $page_id); $detail_page_id = (int) get_option('powr_detail_page_id', 0); $is_directory = has_shortcode($content, 'powr_provider_directory') || has_shortcode($content, 'powr_providers') || ($page_id === $detail_page_id && $detail_page_id > 0); /** * Whether the current page is the provider directory listing. */ return (bool) apply_filters('powr_dir_is_provider_directory', $is_directory, $page_id); } private static function has_filter_params(): bool { foreach (self::FILTER_PARAMS as $key) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only check of listing filters. if (isset($_GET[$key]) && is_string($_GET[$key]) && sanitize_text_field(wp_unslash($_GET[$key])) !== '') { return true; } } return false; } /** * Whether the page has its own SEO title / description in the active SEO * plugin (staff edits win). If that cannot be read, the page's values are kept. * * @return array{0: bool, 1: bool} */ private static function custom_page_meta(string $seo_plugin, int $page_id): array { try { switch ($seo_plugin) { case 'aioseo': $post = get_post($page_id); $meta = $post instanceof WP_Post ? aioseo()->meta->metaData->getMetaData($post) : null; if (! is_object($meta)) { return [true, true]; } $title = (string) ($meta->title ?? ''); $description = (string) ($meta->description ?? ''); break; case 'yoast': if (! class_exists('WPSEO_Meta')) { return [true, true]; } $title = (string) WPSEO_Meta::get_value('title', $page_id); $description = (string) WPSEO_Meta::get_value('metadesc', $page_id); break; case 'rankmath': $title = (string) get_post_meta($page_id, 'rank_math_title', true); $description = (string) get_post_meta($page_id, 'rank_math_description', true); break; default: return [false, false]; } return [trim($title) !== '', trim($description) !== '']; } catch (Throwable $e) { return [true, true]; } } /** * "City, ST" when every location is in the same city, else "". */ private static function directory_place(Powr_Dir_Api_Client $client): string { $locations = $client->get_locations(); if (is_wp_error($locations)) { return ''; } $places = []; foreach ((array) $locations as $row) { if (! is_array($row)) { continue; } $place = trim(implode(', ', array_filter([ trim((string) ($row['city'] ?? '')), trim((string) ($row['state'] ?? '')), ]))); if ($place !== '') { $places[strtolower($place)] = $place; } } return count($places) === 1 ? (string) reset($places) : ''; } private static function build_directory_title(string $clinic, string $place): string { $where = implode(', ', array_filter([$clinic, $place])); $title = __('Our Providers', 'sondy') . ($where !== '' ? ' | ' . $where : ''); /** * Default of the provider directory (used when the page has no * custom SEO title). */ return (string) apply_filters('powr_dir_directory_seo_title', $title, $clinic, $place); } /** * @param list<string> $names */ private static function build_directory_description(array $names, string $clinic, string $place, bool $accepting): string { $count = count($names); $shown = wp_sprintf('%l', array_slice($names, 0, 3)); $sentence = $count > 3 /* translators: 1: number of providers, 2: list of provider names */ ? sprintf(__('Meet our %1$d providers, including %2$s,', 'sondy'), $count, $shown) /* translators: %s: list of provider names */ : sprintf(__('Meet %s', 'sondy'), $shown); if ($clinic !== '') { /* translators: %s: clinic name */ $sentence .= ' ' . sprintf(__('at %s', 'sondy'), $clinic); } if ($place !== '') { /* translators: %s: city, state */ $sentence .= ' ' . sprintf(__('in %s', 'sondy'), $place); } $sentences = [$sentence . '.']; if ($accepting) { $sentences[] = __('Accepting new patients.', 'sondy'); } $sentences[] = __('View profiles, specialties and locations, and book an appointment.', 'sondy'); $description = self::pack_sentences($sentences); /** * Default meta description of the provider directory (used when the * page has no custom SEO description). */ return (string) apply_filters('powr_dir_directory_seo_description', $description, $names, $clinic, $place); } /** * @return string */ public static function directory_title($title) { return self::$directory !== null && self::$directory['title'] !== '' ? self::$directory['title'] : $title; } /** * @return string */ public static function directory_description($description) { return self::$directory !== null && self::$directory['description'] !== '' ? self::$directory['description'] : $description; } /** * og:/twitter: title and description follow the generated values. * * @param array<string, mixed> $meta * @return array<string, mixed> */ public static function directory_social_tags($meta) { if (self::$directory === null || ! is_array($meta)) { return $meta; } $d = self::$directory; foreach (['og:', 'twitter:'] as $prefix) { if (! array_key_exists($prefix . 'title', $meta) && ! array_key_exists($prefix . 'description', $meta)) { continue; } if ($d['title'] !== '') { $meta[$prefix . 'title'] = $d['title']; } if ($d['description'] !== '') { $meta[$prefix . 'description'] = $d['description']; } } return $meta; } /** * Directory CollectionPage JSON-LD is Laravel-owned (API meta.schemaDocument). * Do not inject WordPress-built ItemList / Organization nodes into SEO plugins. * * @param array<int, mixed> $graphs * @return array<int, mixed> */ public static function directory_schema_output($graphs) { return $graphs; } /** * No SEO plugin: print the directory's description and share tags. * CollectionPage JSON-LD comes from Laravel via Powr_Dir_Schema::emit_from_wp_head. */ public static function print_directory_head(): void { if (self::$directory === null) { return; } $d = self::$directory; $title = $d['title'] !== '' ? $d['title'] : wp_get_document_title(); $tags = [ ['name', 'description', $d['description']], ['property', 'og:type', 'website'], ['property', 'og:title', $title], ['property', 'og:description', $d['description']], ['property', 'og:url', $d['url']], ['property', 'og:site_name', (string) get_bloginfo('name')], ['name', 'twitter:card', 'summary'], ['name', 'twitter:title', $title], ['name', 'twitter:description', $d['description']], ]; foreach ($tags as [$attr, $key, $value]) { if ($value === '') { continue; } printf('<meta %1$s="%2$s" content="%3$s" />' . "\n", esc_attr($attr), esc_attr($key), esc_attr($value)); } } /** * @return array<string, mixed> */ private static function directory_item_list(): array { $d = (array) self::$directory; $elements = []; foreach ((array) ($d['items'] ?? []) as $i => $item) { $elements[] = [ '@type' => 'ListItem', 'position' => $i + 1, 'url' => $item['url'], 'name' => $item['name'], ]; } return [ '@type' => 'ItemList', '@id' => (string) ($d['url'] ?? '') . '#itemlist', 'numberOfItems' => count($elements), 'itemListElement' => $elements, ]; } /* ------------------------------------------------------------------ * Robots: detail base page with no slug renders only an error box * ------------------------------------------------------------------ */ private static function register_noindex(): void { add_filter('aioseo_robots_meta', static function ($attributes) { if (! is_array($attributes)) { return $attributes; } $attributes['noindex'] = 'noindex'; return $attributes; }, 20); add_filter('wp_robots', static function (array $robots): array { $robots['noindex'] = true; $robots['follow'] = true; return $robots; }, 20); } /** * A detail page that only hosts a detail shortcode (no listing) and was * opened without a slug shows "No provider specified" - thin content. */ private static function detect_empty_shell(): bool { $checks = [ [(int) get_option('powr_detail_page_id', 0), 'powr_provider_detail', ['powr_providers', 'powr_provider_directory'], Powr_Dir_Rewrites::QUERY_VAR_PROVIDER], [(int) get_option('powr_location_listing_page_id', 0), 'powr_location_detail', ['powr_locations', 'powr_location_directory'], Powr_Dir_Rewrites::QUERY_VAR_LOCATION], ]; foreach ($checks as [$page_id, $detail_tag, $listing_tags, $query_var]) { if ($page_id <= 0 || ! is_page($page_id)) { continue; } return self::page_is_detail_only($page_id, $detail_tag, $listing_tags) && sanitize_title((string) get_query_var($query_var)) === ''; } return false; } /** * @param list<string> $listing_tags */ private static function page_is_detail_only(int $page_id, string $detail_tag, array $listing_tags): bool { $content = (string) get_post_field('post_content', $page_id); if (! has_shortcode($content, $detail_tag) || preg_match('/\[' . preg_quote($detail_tag, '/') . '\s[^\]]*slug=/', $content) === 1) { return false; } foreach ($listing_tags as $tag) { if (has_shortcode($content, $tag)) { return false; } } return true; } /* ------------------------------------------------------------------ * Sitemaps * ------------------------------------------------------------------ */ /** * Add every provider/location URL to AIOSEO's page sitemap (first chunk * only, so chunked sitemaps do not repeat them). * * @param array<int, array<string, mixed>> $entries * @param string $post_type * @return array<int, array<string, mixed>> */ public static function aioseo_sitemap_entries($entries, $post_type = '') { if ($post_type !== 'page' || ! is_array($entries) || ! self::aioseo_active()) { return $entries; } try { $sitemap = aioseo()->sitemap ?? null; if (is_object($sitemap) && isset($sitemap->pageNumber) && (int) $sitemap->pageNumber > 0) { return $entries; } $empty_shells = []; foreach (self::sitemap_detail_pages() as $page) { if ($page['empty_shell']) { $empty_shells[] = untrailingslashit($page['url']); } } if ($empty_shells !== []) { $entries = array_values(array_filter($entries, static function ($entry) use ($empty_shells): bool { return ! is_array($entry) || ! in_array(untrailingslashit((string) ($entry['loc'] ?? '')), $empty_shells, true); })); } foreach (self::sitemap_urls() as $url) { $entry = [ 'loc' => $url['loc'], 'lastmod' => $url['lastmod'], 'changefreq' => 'weekly', 'priority' => 0.7, ]; if ($url['image'] !== '') { $entry['images'] = [['image:loc' => $url['image']]]; } $entries[] = $entry; } } catch (Throwable $e) { return $entries; } return $entries; } /** * <sitemap> entry for /pd-providers-sitemap.xml in the Yoast / Rank Math index. * * @param string $xml * @return string */ public static function sitemap_index_entry($xml) { try { $urls = self::sitemap_urls(); } catch (Throwable $e) { return $xml; } if ($urls === []) { return $xml; } $lastmod = max(array_map(static function (array $url): string { return $url['lastmod']; }, $urls)); return (string) $xml . '<sitemap><loc>' . esc_url(home_url('/pd-providers-sitemap.xml')) . '</loc>' . '<lastmod>' . esc_html($lastmod) . '</lastmod></sitemap>' . "\n"; } /** * Output /pd-providers-sitemap.xml: every provider and location profile. */ public static function serve_sitemap(): void { if ((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_SITEMAP) !== '1') { return; } try { $urls = self::sitemap_urls(); } catch (Throwable $e) { $urls = []; } status_header(200); header('Content-Type: application/xml; charset=UTF-8'); header('X-Robots-Tag: noindex, follow', true); echo '<?xml version="1.0" encoding="UTF-8"?>' . "\n"; echo '<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">' . "\n"; foreach ($urls as $url) { echo '<url><loc>' . esc_url($url['loc']) . '</loc><lastmod>' . esc_html($url['lastmod']) . '</lastmod>'; if ($url['image'] !== '') { echo '<image:image><image:loc>' . esc_url($url['image']) . '</image:loc></image:image>'; } echo "</url>\n"; } echo '</urlset>'; exit; } public static function register_core_sitemap(): void { if (self::aioseo_active() || ! function_exists('wp_register_sitemap_provider') || ! class_exists('WP_Sitemaps_Provider')) { return; } require_once POWR_DIR_PATH . 'includes/class-sitemap-provider.php'; wp_register_sitemap_provider('sondy', new Powr_Dir_Sitemap_Provider()); } /** * @return list<array{loc: string, lastmod: string, image: string}> */ public static function sitemap_urls(): array { $client = new Powr_Dir_Api_Client(); if (! $client->is_configured()) { return []; } $urls = []; foreach (self::sitemap_detail_pages() as $page) { $rows = $page['type'] === 'provider' ? $client->get_providers() : $client->get_locations(); if (is_wp_error($rows)) { continue; } if ($page['type'] === 'provider') { $rows = $rows['data']; } foreach ((array) $rows as $row) { if (! is_array($row)) { continue; } $slug = sanitize_title((string) ($row['slug'] ?? '')); if ($slug === '') { continue; } $loc = Powr_Dir_Rewrites::build_profile_url($page['url'], $slug); if ($loc === '#') { continue; } $updated = strtotime((string) ($row['updatedAt'] ?? $row['updated_at'] ?? '')); $urls[] = [ 'loc' => $loc, 'lastmod' => gmdate('c', $updated !== false && $updated > 0 ? $updated : $page['modified']), 'image' => self::real_image((string) ($row['photo'] ?? '')), ]; } } return $urls; } /** * @return list<array{type: string, url: string, modified: int, empty_shell: bool}> */ private static function sitemap_detail_pages(): array { $pages = []; $config = [ 'provider' => [(int) get_option('powr_detail_page_id', 0), 'powr_provider_detail', ['powr_providers', 'powr_provider_directory']], 'location' => [(int) get_option('powr_location_listing_page_id', 0), 'powr_location_detail', ['powr_locations', 'powr_location_directory']], ]; foreach ($config as $type => [$page_id, $detail_tag, $listing_tags]) { if ($page_id <= 0 || get_post_status($page_id) !== 'publish') { continue; } $url = (string) get_permalink($page_id); if ($url === '') { continue; } $modified = strtotime((string) get_post_field('post_modified_gmt', $page_id) . ' UTC'); $pages[] = [ 'type' => $type, 'url' => $url, 'modified' => $modified !== false ? $modified : time(), 'empty_shell' => self::page_is_detail_only($page_id, $detail_tag, $listing_tags), ]; } return $pages; } /* ------------------------------------------------------------------ * Titles, descriptions and helpers * ------------------------------------------------------------------ */ /** * @param array<string, mixed> $e */ private static function build_title(array $e, string $type): string { $meta = trim((string) ($e['metaTitle'] ?? '')); if ($meta !== '' && ! self::is_bare_name($meta, $e)) { return $meta; } $parts = [self::entity_display_name($e)]; if ($type === 'provider') { $specialty = (string) ($e['specialties'][0] ?? ''); if ($specialty !== '') { $parts[] = $specialty; } } else { $city = self::location_city($e); if ($city !== '') { $parts[] = $city; } } $site = wp_strip_all_tags((string) get_bloginfo('name')); return implode(' - ', array_filter($parts)) . ($site !== '' ? ' | ' . $site : ''); } /** * @param array<string, mixed> $e */ private static function build_description(array $e, string $type): string { $meta = trim((string) ($e['metaDescription'] ?? '')); if ($meta !== '') { return self::clean_text($meta); } $name = self::entity_display_name($e); $sentences = []; if ($type === 'provider') { $specialties = array_slice((array) ($e['specialties'] ?? []), 0, 2); $first_location = is_array($e['locations'][0] ?? null) ? $e['locations'][0] : []; $where = trim((string) ($first_location['name'] ?? '')); $city = trim(implode(', ', array_filter([(string) ($first_location['city'] ?? ''), (string) ($first_location['state'] ?? '')]))); $sentence = $specialties !== [] /* translators: 1: provider name, 2: specialties */ ? sprintf(__('%1$s specializes in %2$s', 'sondy'), $name, implode(' & ', $specialties)) : $name; if ($where !== '') { /* translators: %s: location name */ $sentence .= ' ' . sprintf(__('at %s', 'sondy'), $where); } if ($city !== '') { /* translators: %s: city, state */ $sentence .= ' ' . sprintf(__('in %s', 'sondy'), $city); } $sentences[] = $sentence . '.'; if (! empty($e['accepting'])) { $sentences[] = __('Accepting new patients.', 'sondy'); } if (! empty($e['videoVisits'])) { $sentences[] = __('Telehealth visits available.', 'sondy'); } $sentences[] = __('View locations, insurance and hours, and book an appointment.', 'sondy'); } else { $address = implode(', ', array_filter(array_map('strval', (array) ($e['address'] ?? [])))); /* translators: 1: location name, 2: street address */ $sentences[] = $address !== '' ? sprintf(__('%1$s, %2$s.', 'sondy'), $name, $address) : $name . '.'; $services = array_slice((array) ($e['services'] ?? []), 0, 3); if ($services !== []) { /* translators: %s: list of services */ $sentences[] = sprintf(__('Services include %s.', 'sondy'), implode(', ', $services)); } $count = (int) ($e['providerCount'] ?? 0); if ($count > 0) { /* translators: %d: number of providers */ $sentences[] = sprintf(_n('%d provider at this location.', '%d providers at this location.', $count, 'sondy'), $count); } $sentences[] = __('Hours, directions, insurance and appointments.', 'sondy'); } return self::pack_sentences($sentences); } /** * Keep whole sentences; only cut mid-sentence when the first one alone is too long. * * @param list<string> $sentences */ private static function pack_sentences(array $sentences): string { $description = ''; foreach ($sentences as $sentence) { $candidate = trim($description . ' ' . self::clean_text($sentence)); if ($description !== '' && mb_strlen($candidate) > self::DESCRIPTION_MAX) { break; } $description = $candidate; } return self::truncate($description, self::DESCRIPTION_MAX); } /** * @param array<string, mixed> $e */ private static function entity_display_name(array $e): string { $name = trim((string) ($e['name'] ?? '')); $credentials = trim((string) ($e['credentials'] ?? '')); return $credentials !== '' && $name !== '' ? $name . ', ' . $credentials : $name; } /** * A Meta Title that is only the name (with or without credentials) is too * thin for a title tag; the generated title is used instead. * * @param array<string, mixed> $e */ private static function is_bare_name(string $title, array $e): bool { $normalize = static function (string $s): string { return strtolower(trim((string) preg_replace('/[\s,.]+/', ' ', $s))); }; $candidates = [ (string) ($e['name'] ?? ''), self::entity_display_name($e), ]; foreach ($candidates as $candidate) { if ($candidate !== '' && $normalize($title) === $normalize($candidate)) { return true; } } return false; } /** * City/state from the last address line, without the ZIP code. * * @param array<string, mixed> $e */ private static function location_city(array $e): string { $lines = array_values(array_filter(array_map('strval', (array) ($e['address'] ?? [])))); if (count($lines) < 2) { return ''; } return trim((string) preg_replace('/\s+\d{5}(?:-\d{4})?$/', '', (string) end($lines))); } /** * @param array<string, mixed> $e */ private static function image_alt(array $e, string $type): string { $name = self::entity_display_name($e); /* translators: %s: provider name */ return $type === 'provider' ? sprintf(__('Photo of %s', 'sondy'), $name) : $name; } /** * Drop bundled/remote placeholder images; they add nothing to previews. */ private static function real_image(string $url): string { $url = trim($url); if ($url === '' || strpos($url, 'placeholder') !== false || ! preg_match('#^https?://#i', $url)) { return ''; } return $url; } private static function clean_text(string $text): string { $text = html_entity_decode(wp_strip_all_tags($text), ENT_QUOTES, 'UTF-8'); return trim((string) preg_replace('/\s+/u', ' ', $text)); } private static function truncate(string $text, int $max): string { if (mb_strlen($text) <= $max) { return $text; } $cut = mb_substr($text, 0, $max - 1); // Byte offsets are safe here: a space is never inside a multibyte sequence. $space = strrpos($cut, ' '); if ($space !== false && $space > strlen($cut) / 2) { $cut = substr($cut, 0, $space); } return rtrim($cut, " ,.;:-") . '…'; } /** * @param array<string, mixed> $node */ private static function is_web_page_node(array $node): bool { $page_types = ['WebPage', 'ProfilePage', 'AboutPage', 'ItemPage', 'MedicalWebPage', 'CollectionPage', 'ContactPage', 'FAQPage', 'CheckoutPage', 'SearchResultsPage', 'RealEstateListing']; $types = (array) ($node['@type'] ?? []); return array_intersect($types, $page_types) !== []; } /** * Recursively rewrite {"@id": old} references to their new ids. * * @param mixed $data * @param array<string, string> $id_map * @return mixed */ private static function replace_ids($data, array $id_map) { if ($id_map === [] || ! is_array($data)) { return $data; } foreach ($data as $key => $value) { if ($key === '@id' && is_string($value) && isset($id_map[$value])) { $data[$key] = $id_map[$value]; } elseif (is_array($value)) { $data[$key] = self::replace_ids($value, $id_map); } } return $data; } /** * @return array{0: string, 1: 'provider'|'location', 2: int}|null */ private static function resolve_detail_entity(): ?array { $provider_page_id = (int) get_option('powr_detail_page_id', 0); $location_page_id = (int) get_option('powr_location_listing_page_id', 0); $provider_slug = sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_PROVIDER)); if ($provider_page_id > 0 && is_page($provider_page_id) && $provider_slug !== '') { return [$provider_slug, 'provider', $provider_page_id]; } $location_slug = sanitize_title((string) get_query_var(Powr_Dir_Rewrites::QUERY_VAR_LOCATION)); if ($location_page_id > 0 && is_page($location_page_id) && $location_slug !== '') { return [$location_slug, 'location', $location_page_id]; } return null; } }