settings = $settings; } /** * Hook wp_head if schema is enabled and no SEO plugin owns it. */ public function register() { if ( ! $this->settings->enabled( 'enable_schema' ) ) { return; } add_action( 'wp_head', array( $this, 'output' ), 1 ); } /** * Whether a schema/SEO plugin that manages structured data + descriptions is active. * The raw detection, with NO Agentimus filter applied — so other features (e.g. the * {@see Description} meta-tag fallback) can reuse the exact same "is an SEO plugin * present?" signal and layer their own defer filter on top. * * The detection table itself lives in {@see SeoContext::suites()}; this method * stays as the public face existing callers (and third parties) already use. * * @return bool */ public static function seo_plugin_present() { return SeoContext::plugin_present(); } /** * Whether another plugin is already emitting schema we'd duplicate. * * @return bool */ public function seo_plugin_active() { /** * Filter whether Agentimus should stand down on schema. * * @param bool $active Whether to defer. */ return (bool) apply_filters( 'agentimus_defer_schema', self::seo_plugin_present() ); } /** * Print the JSON-LD graph in the head. */ public function output() { if ( $this->seo_plugin_active() ) { return; } // Per-post structured data only on a singular view of a covered type; the // site-level identity nodes stand on every front-end request. Services ride // the front page (which may itself be a singular page — both sets then apply, // exactly as before). $post = is_singular( Content::post_types() ) ? get_post() : null; $front = is_front_page(); // On a site with a persistent object cache, memo the rendered JSON per (post // revision + settings/site-identity) signature, so a crawl doesn't re-run // do_blocks + a DOM parse + topic derivation on every hit. Skipped without a // persistent cache — there, per-URL transients would just churn wp_options; the // FAQ size ceiling bounds the per-request cost instead. The signature self- // invalidates: a post edit bumps its modified time, a settings or site-identity // change bumps the fingerprint, so no explicit flush is needed. $key = $this->cache_key( $post, $front ); $json = ( '' !== $key ) ? Cache::get( $key ) : false; if ( ! is_string( $json ) ) { // This runs on wp_head for every human-facing page, and the document build calls // do_blocks(), a DOMDocument parse (Faq::extract) and four third-party filters // (agentimus_schema_for_post/_graph, agentimus_faq_pairs, agentimus_topic_links). // A throwing block callback or filter must cost the page its JSON-LD, never 500 // it — the same contract Markdown::post() honours on the agent-facing side. // Worse here: Description::buffer_start() holds the in an output buffer // around this call, so an uncaught Throwable would discard the whole head too. try { $doc = $this->build_document( $post, $front ); $json = ( null === $doc ) ? '' : (string) wp_json_encode( $doc, JSON_UNESCAPED_UNICODE ); } catch ( \Throwable $e ) { return; // Print nothing. Deliberately NOT cached — a transient filter error // must not memoize an empty graph for the life of the cache entry. } if ( '' !== $key ) { Cache::set( $key, $json, 12 * HOUR_IN_SECONDS ); } } if ( '' === $json ) { return; // Empty graph (or an encode failure) — nothing to print. } // Do NOT use JSON_UNESCAPED_SLASHES here — this JSON sits inside an HTML // " from breaking out of the tag. (The // .well-known/*.json files, served as application/json, can unescape them.) echo "\n\n"; } /** * A self-invalidating cache key for the rendered JSON, or '' to skip caching. Only * returns a key when a PERSISTENT object cache is present — otherwise per-URL * transients would accumulate in wp_options with no bound. The key folds in the * post's revision (its modified time) and a fingerprint of the settings + core site * identity, so any change that affects the graph produces a fresh key. * * It also folds in the cache epoch ({@see MarkdownCache::EPOCH_OPTION}), which the * `agentimus_cache_flushed` action rolls on every flush — including `edited_term`. * The graph's breadcrumb and keyword nodes derive from a post's TERMS, and a term * rename changes none of the other inputs (not post_modified_gmt), so without the * epoch a category rename would serve a stale graph until the entry aged out. * * @param \WP_Post|null $post Post being described, or null for the site graph. * @param bool $front Whether this is the front page. * @return string Cache key, or '' to skip caching. */ private function cache_key( $post, $front ) { if ( ! function_exists( 'wp_using_ext_object_cache' ) || ! wp_using_ext_object_cache() ) { return ''; } $post_sig = $post ? ( (int) $post->ID . ':' . (string) $post->post_modified_gmt ) : 'site'; $fingerprint = md5( (string) wp_json_encode( $this->settings->all() ) . '|' . get_bloginfo( 'name' ) . '|' . get_bloginfo( 'description' ) . '|' . get_locale() . '|' . (string) get_option( MarkdownCache::EPOCH_OPTION, '' ) ); return 'agentimus_schema_' . md5( $post_sig . '|' . ( $front ? '1' : '0' ) . '|' . $fingerprint ); } /** * Assemble the JSON-LD document — the `{ @context, @graph }` wrapper — for one * target. Pure construction with no emission guards: the front-end path * ({@see output()}) applies the enable/defer checks before calling this, while * the admin preview ({@see Rest}) wants the graph regardless, so it can show the * owner exactly what WOULD ship. The per-post privacy guard (published, not * password-protected) is intrinsic to the data, so it lives here — except that * $preview relaxes the publish-status half of it, so the admin preview can show a * not-yet-published post's would-be node ("here's what ships when you publish"). * The password guard is NEVER relaxed: that body stays gated even once published, * so a full node would misrepresent what actually ships. * * @param \WP_Post|null $post Post to describe, or null for the * site-level (front-page) graph. * @param bool|null $include_offerings Whether to add the Service and project * (SoftwareApplication) nodes; defaults to * true for the site graph, false for a post. * @param bool $preview Admin preview: also emit the per-post node * for an unpublished (draft/pending/future/ * private) post. Front-end callers omit this. * @return array|null The document, or null when the graph is empty. */ public function build_document( $post = null, $include_offerings = null, $preview = false ) { if ( null === $include_offerings ) { $include_offerings = ( null === $post ); } $graph = array( $this->website_node(), $this->entity_node() ); // Services and projects belong to the entity, so they sit alongside the site // identity — not repeated on every post. if ( $include_offerings ) { foreach ( array_merge( $this->service_nodes(), $this->project_nodes() ) as $offering ) { $graph[] = $offering; } } // Per-post structured data, but never for content the HTML site itself // gates: a password-protected post (whose body Q&A would otherwise leak // into the public FAQPage node) or a non-published status. Mirrors the // guard in Markdown::post(); the site-level identity nodes above stand. // $preview relaxes only the publish-status half — the admin preview shows an // unpublished post's would-be node — but a password-protected post is still // excluded, since its body never ships as schema even once published. if ( $post && '' === (string) $post->post_password && ( 'publish' === $post->post_status || $preview ) ) { $node = $this->article_node( $post ); /** * Filter the per-post schema node. An add-on (e.g. WooCommerce) can * return a Product/Event/… node here, or null to omit it. * * @param array|null $node Schema node. * @param \WP_Post $post Post. */ $node = apply_filters( 'agentimus_schema_for_post', $node, $post ); // is_array(), not just ! empty(): a scalar return (string/number) passes // ! empty() but would corrupt the @graph / break json_encode downstream. if ( is_array( $node ) && ! empty( $node ) ) { $graph[] = $node; } $graph[] = $this->breadcrumb_node( $post ); // A FAQPage when the content clearly is one — agents lift the Q&A. $faq = $this->faq_node( $post ); if ( $faq ) { $graph[] = $faq; } // A VideoObject per player on the page — the only node that can hold a // transcript, and the only way what a video SAYS becomes structured // data rather than a blank rectangle. foreach ( $this->video_nodes( $post ) as $video ) { $graph[] = $video; } } $graph = array_values( array_filter( $graph ) ); /** * Filter the JSON-LD @graph nodes before output. * * @param array $graph Schema nodes. */ $filtered = apply_filters( 'agentimus_schema_graph', $graph ); // Fall back to the valid graph if a filter hands back a non-array, then keep // only array nodes — the @graph that reaches json_encode is always well-formed. $graph = array_values( array_filter( is_array( $filtered ) ? $filtered : $graph, 'is_array' ) ); if ( empty( $graph ) ) { return null; } return array( '@context' => 'https://schema.org', '@graph' => $graph, ); } /** * The WebSite node (enables the sitelinks search box). * * @return array */ private function website_node() { return array( '@type' => 'WebSite', '@id' => home_url( '/#website' ), 'url' => home_url( '/' ), 'name' => $this->clean( get_bloginfo( 'name' ) ), 'description' => $this->clean( get_bloginfo( 'description' ) ), 'inLanguage' => get_bloginfo( 'language' ), 'potentialAction' => array( '@type' => 'SearchAction', 'target' => array( '@type' => 'EntryPoint', 'urlTemplate' => home_url( '/?s={search_term_string}' ), ), 'query-input' => 'required name=search_term_string', ), ); } /** * Decode HTML entities and strip tags so a value reads as clean plain text in the * JSON-LD. get_bloginfo() can return entity-encoded text (e.g. "&"), which is * wrong inside a JSON string value. * * @param string $value Possibly entity-encoded text. * @return string */ private function clean( $value ) { return trim( html_entity_decode( wp_strip_all_tags( (string) $value ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ) ); } /** * The site entity — Person, or an Organization (sub)type, from identity settings. * * @return array */ private function entity_node() { // 'Person' is the human case; any other configured value is a schema.org // Organization (sub)type — Organization, LocalBusiness, Store, … — emitted // as-is (validated against entity_types() on save). $stored = (string) $this->settings->identity( 'entity_type', 'Person' ); $type = '' !== $stored ? $stored : 'Person'; $name = $this->settings->display_name(); $about = (string) $this->settings->identity( 'about' ); $not = (string) $this->settings->identity( 'not_description' ); $aud = (string) $this->settings->identity( 'audience' ); $same_as = array_values( array_filter( (array) $this->settings->identity( 'same_as', array() ) ) ); $knows = array_values( array_filter( (array) $this->settings->identity( 'expertise', array() ) ) ); $node = array( '@type' => $type, '@id' => home_url( '/#identity' ), 'name' => '' !== $name ? $name : get_bloginfo( 'name' ), 'url' => home_url( '/' ), ); if ( '' !== $about ) { $node['description'] = $about; } // A negative description disambiguates the entity so agents don't miscategorize it. if ( '' !== $not ) { $node['disambiguatingDescription'] = $not; } if ( '' !== $aud ) { $node['audience'] = array( '@type' => 'Audience', 'audienceType' => $aud ); } if ( ! empty( $knows ) ) { $node['knowsAbout'] = $knows; } if ( ! empty( $same_as ) ) { $node['sameAs'] = $same_as; } if ( 'Person' === $type ) { $role = (string) $this->settings->identity( 'role' ); if ( '' !== $role ) { $node['jobTitle'] = $role; } } // A photo (Person) / logo (Organization) — engines show it in the entity and // knowledge cards they build. `logo` is the property Google reads on an org. $image = $this->entity_image(); if ( '' !== $image ) { $node[ 'Person' === $type ? 'image' : 'logo' ] = $image; } return $node; } /** * The site entity's image — a Person's photo or an Organization's logo. Defaults * to the WordPress Site Icon (512px), overridable via `agentimus_entity_image` so * a site can point at a dedicated logo file. Public so the Readiness "Entity image" * check grades exactly what the entity node emits (never drifts from it). * * @return string Image URL, or '' when none is available. */ public function entity_image() { $url = function_exists( 'get_site_icon_url' ) ? (string) get_site_icon_url( 512 ) : ''; /** * Filter the site entity's image/logo URL. * * @param string $url Default — the Site Icon at 512px, or '' when unset. * @param Settings $settings Settings store. */ $url = (string) apply_filters( 'agentimus_entity_image', $url, $this->settings ); return '' !== $url ? esc_url_raw( $url ) : ''; } /** * `Service` nodes from the owner-declared services list (opt-in; empty unless * the site actually offers services — we never guess them from content). Each * is linked back to the site entity as its provider, so an agent can answer * "what can I hire this site for?". * * @return array[] Zero or more Service nodes. */ private function service_nodes() { $services = (array) $this->settings->identity( 'services', array() ); $nodes = array(); foreach ( $services as $svc ) { if ( ! is_array( $svc ) ) { continue; } $name = isset( $svc['name'] ) ? $this->clean( $svc['name'] ) : ''; if ( '' === $name ) { continue; // A nameless service is not a service. } $node = array( '@type' => 'Service', 'name' => $name, 'provider' => array( '@id' => home_url( '/#identity' ) ), ); $desc = isset( $svc['description'] ) ? $this->clean( $svc['description'] ) : ''; if ( '' !== $desc ) { $node['description'] = $desc; } $url = isset( $svc['url'] ) ? esc_url_raw( (string) $svc['url'] ) : ''; if ( '' !== $url ) { $node['url'] = $url; } $nodes[] = $node; } return $nodes; } /** * `SoftwareApplication` nodes from the owner-declared projects list (opt-in; * empty unless the owner made software and listed it — never guessed from * content). Each names the site entity as its author, so an agent can answer * "who made this?" and "what has this person built?" from one graph. The * repository goes in `sameAs`: it is the same software, seen from its source. * * @return array[] Zero or more SoftwareApplication nodes. */ private function project_nodes() { $projects = (array) $this->settings->identity( 'projects', array() ); $nodes = array(); foreach ( $projects as $project ) { if ( ! is_array( $project ) ) { continue; } $name = isset( $project['name'] ) ? $this->clean( $project['name'] ) : ''; if ( '' === $name ) { continue; // A nameless project is not a project. } $node = array( '@type' => 'SoftwareApplication', '@id' => home_url( '/#project-' . sanitize_title( $name ) ), 'name' => $name, 'author' => array( '@id' => home_url( '/#identity' ) ), ); $desc = isset( $project['description'] ) ? $this->clean( $project['description'] ) : ''; if ( '' !== $desc ) { $node['description'] = $desc; } $version = isset( $project['version'] ) ? $this->clean( $project['version'] ) : ''; if ( '' !== $version ) { $node['softwareVersion'] = $version; } $links = array( 'url' => 'url', 'download' => 'downloadUrl', 'repo' => 'sameAs', ); foreach ( $links as $field => $property ) { $link = isset( $project[ $field ] ) ? esc_url_raw( (string) $project[ $field ] ) : ''; if ( '' !== $link ) { $node[ $property ] = $link; } } $nodes[] = $node; } return $nodes; } /** * A `FAQPage` node when the post clearly is one. We require at least two Q&A * pairs — a single question is prose, a list is an FAQ — so we never emit * guessed/spammy FAQ markup. The detected pairs are filterable. * * @param \WP_Post $post Post. * @return array|null */ private function faq_node( $post ) { $content = (string) $post->post_content; // Size ceiling: do_blocks + a DOM parse of a very large (page-builder) body runs // on every front-end view, so skip FAQ detection above a byte cap — such a post // is rarely a clean FAQ anyway. Filterable; 0 disables the ceiling. $max = (int) apply_filters( 'agentimus_faq_max_bytes', 256 * 1024 ); if ( $max > 0 && strlen( $content ) > $max ) { return null; } // Render blocks (so the Details block becomes
) without // firing the full the_content chain — avoids third-party render side effects. $html = function_exists( 'do_blocks' ) ? do_blocks( $content ) : $content; $pairs = Faq::extract( $html ); /** * Filter the FAQ pairs detected for a post — supply or refine them. * * @param array $pairs `[ ['q'=>…,'a'=>…], … ]`. * @param \WP_Post $post The post. */ $pairs = (array) apply_filters( 'agentimus_faq_pairs', $pairs, $post ); $entities = array(); foreach ( $pairs as $pair ) { $q = isset( $pair['q'] ) ? $this->clean( $pair['q'] ) : ''; $a = isset( $pair['a'] ) ? $this->clean( $pair['a'] ) : ''; if ( '' === $q || '' === $a ) { continue; } $entities[] = array( '@type' => 'Question', 'name' => $q, 'acceptedAnswer' => array( '@type' => 'Answer', 'text' => $a, ), ); } if ( count( $entities ) < 2 ) { return null; } $url = get_permalink( $post ); return array( '@type' => 'FAQPage', '@id' => $url . '#faq', 'url' => $url, 'mainEntity' => $entities, ); } /** * A `VideoObject` (or `AudioObject`) per playable item on the page. * * The point of these nodes is `description`: an assistant that can tell what a * video or a podcast is about can say so, and cite the page for it. There is no * other structured place to put that. Everything else here is the supporting * cast schema.org expects around it. * * Fields are emitted ONLY where they are true. A `description` is the owner's * own line about THAT item, never the page's description borrowed to fill a * gap. `thumbnailUrl` comes from the featured image or not at all — we don't * construct a provider's thumbnail URL and call it the media's own. * `uploadDate` is the post's publish date, which is exactly what schema.org * asks for ("when this media object was uploaded to this site"), not a guess at * when the video was filmed. * * @param \WP_Post $post Post. * @return array */ private function video_nodes( $post ) { $items = MediaContext::items_for( $post ); if ( empty( $items ) ) { return array(); } // Speak only where we have something to add. // // A node without a description is `name` + `url` + `uploadDate` — exactly // what a video plugin already emits, better, with a real thumbnail. Saying // it again in a second node duplicates their work to contribute nothing, // and duplicate structured data for one video is the failure this whole // file is careful about. The description is the field nobody else has. $items = array_values( array_filter( $items, static function ( $item ) { return '' !== $item['context']; } ) ); if ( empty( $items ) ) { return array(); } /** * Filter whether to stand down on media schema for this post. * * Defaults to whether something else on the page already describes its * media in JSON-LD — detected by the symptom, never by a list of plugin * names. Emitters that write in `wp_head` or `wp_footer` run after this * does and cannot be seen, so a plugin that knows it emits its own can say * so here. * * @param bool $defer Whether to emit nothing. * @param \WP_Post $post The post. */ if ( apply_filters( 'agentimus_defer_video_schema', MediaContext::foreign_video_schema( $post ), $post ) ) { return array(); } /** * Filter the most media nodes one page may emit. A guard against a * video-wall page turning its @graph into a catalogue, not a judgement * about how many videos a page should have. * * @param int $max Maximum nodes. */ $limit = (int) apply_filters( 'agentimus_video_max_nodes', 10 ); $items = array_slice( $items, 0, max( 1, $limit ) ); $url = get_permalink( $post ); $thumb = get_the_post_thumbnail_url( $post, 'full' ); $transcript = MediaContext::on_page_transcript( function_exists( 'do_blocks' ) ? do_blocks( (string) $post->post_content ) : (string) $post->post_content ); $nodes = array(); foreach ( $items as $i => $item ) { $node = array( '@type' => 'audio' === $item['kind'] ? 'AudioObject' : 'VideoObject', // The first keeps the bare "#video" fragment it has always had, so // nothing already pointing at it breaks. '@id' => $url . '#video' . ( $i > 0 ? '-' . ( $i + 1 ) : '' ), 'name' => $this->clean( $this->media_name( $item, $post ) ), 'uploadDate' => get_the_date( DATE_W3C, $post ), ); // A player URL and a media-file URL are different properties, and calling // one the other misdescribes what is at the end of the link. $node[ $item['embed'] ? 'embedUrl' : 'contentUrl' ] = $item['url']; // The owner's line about THIS item — the reason these nodes exist. Absent // rather than borrowed: a page-level description pasted onto six players // would tell an assistant that all six are the same thing. if ( '' !== $item['context'] ) { $node['description'] = $this->clean( $item['context'] ); } // This video's own still, when the author set one. Otherwise the post's // featured image — but ONLY on a page with a single item, where it is a // fair stand-in. Repeating it across five nodes said all five looked the // same, which was simply untrue; an absent thumbnailUrl is honest, a // wrong one is not. $still = (string) $item['poster']; // Core extracts cover art from an uploaded video and hangs it on the // attachment (wp-admin/includes/image.php), so a media-library video can // already have a real still even with no poster set. if ( '' === $still && ! $item['embed'] ) { $attachment = function_exists( 'attachment_url_to_postid' ) ? (int) attachment_url_to_postid( $item['url'] ) : 0; if ( $attachment ) { $still = (string) get_the_post_thumbnail_url( $attachment, 'full' ); } } if ( '' !== $still ) { $node['thumbnailUrl'] = $still; } elseif ( $thumb && 1 === count( $items ) ) { $node['thumbnailUrl'] = $thumb; } // A transcript already on the page, from whatever tool put it there — but // ONLY when the page has a single item. With several, one transcript // cannot say which of them it belongs to. if ( '' !== $transcript && 1 === count( $items ) ) { $node['transcript'] = $this->clean( $transcript ); } /** * Filter one media node (add duration, a real uploadDate from a provider * API, a per-item transcript…), or return null to omit it. * * @param array|null $node The node. * @param \WP_Post $post The post. * @param array $item The detected item: { kind, url, name, embed, key, context }. * @param int $index Its position on the page, from 0. */ $node = apply_filters( 'agentimus_video_node', $node, $post, $item, $i ); if ( is_array( $node ) && ! empty( $node ) ) { $nodes[] = $node; } } return $nodes; } /** * A name for one media item. * * The provider's own title first. Failing that the owner's line about THIS * item, shortened — a short descriptive phrase is a fair name, and it is at * least about the right video. The post's title is the last resort, and it is * only honest when the page holds a single item: on a page with five, naming * every node after the post states five times that each one is the post. * * @param array $item The detected item. * @param \WP_Post $post The post. * @return string */ private function media_name( $item, $post ) { if ( '' !== $item['name'] ) { return $item['name']; } if ( '' !== $item['context'] ) { // First sentence, capped — a name, not a paragraph. $name = trim( (string) preg_replace( '/\s+/', ' ', $item['context'] ) ); if ( preg_match( '/^(.{10,110}?[.!?])\s/u', $name . ' ', $m ) ) { $name = $m[1]; } return mb_strlen( $name ) > 110 ? trim( mb_substr( $name, 0, 109 ) ) . '…' : $name; } return get_the_title( $post ); } /** * Default per-post node. The schema @type is resolved from a filterable * post-type → type map (post → BlogPosting, page → WebPage, else Article), * so any content type gets a sensible default an add-on can override. * * @param \WP_Post $post Post. * @return array */ private function article_node( $post ) { /** * Filter the post-type → schema @type map. * * @param array $map Map of post_type => schema type. */ $map = apply_filters( 'agentimus_schema_type_map', array( 'post' => 'BlogPosting', 'page' => 'WebPage', ) ); $type = isset( $map[ $post->post_type ] ) ? $map[ $post->post_type ] : 'Article'; $url = get_permalink( $post ); $node = array( '@type' => $type, '@id' => $url . '#' . strtolower( $type ), 'url' => $url, 'headline' => $this->clean( get_the_title( $post ) ), 'datePublished' => get_the_date( DATE_W3C, $post ), 'dateModified' => get_the_modified_date( DATE_W3C, $post ), 'author' => array( '@id' => home_url( '/#identity' ) ), 'publisher' => array( '@id' => home_url( '/#identity' ) ), 'mainEntityOfPage' => $url, ); // The featured image, when the author set one. Google lists `image` as a // recommended Article property, and the featured image is the one picture // the author chose to represent this post — absent otherwise, never a // stand-in pulled from the body or the site identity. $image = get_the_post_thumbnail_url( $post, 'full' ); if ( $image ) { $node['image'] = $image; } // Per-page AI topics → schema.org `keywords` (flat terms) plus `about` // DefinedTerm entities. Each `about` node can carry `sameAs` links (Wikidata, // Wikipedia, an official site) that disambiguate the exact entity for an // assistant — supplied via the `agentimus_topic_links` filter, never a // front-end lookup. Emitted only when topics exist; inherits this method's // password/publish guard. $topics = Topics::for_post( $post ); if ( ! empty( $topics ) ) { $node['keywords'] = $topics; $about = $this->about_nodes( $topics, $post ); if ( ! empty( $about ) ) { $node['about'] = $about; } } // A one-line AI description → schema.org `description`. The single source of truth // ({@see Description::for_post()}) resolves the editor's value or the excerpt // fallback; emitted only when non-empty, and self-gated on the feature toggle. $desc = Description::for_post( $post ); if ( '' !== $desc ) { $node['description'] = $this->clean( $desc ); } // schema.org SpeakableSpecification on article-shaped content: the headline // and the lead paragraph are what a voice agent should read aloud. We don't // render the theme's HTML, so the selector list covers the classic theme // contract (.entry-title/.entry-content), block themes (wp-block-post-*), // and an h1.page-title headline — a selector that matches nothing is inert // for consumers, and a theme with its own classes adjusts via the filter // (an empty list suppresses the node). Pages stay quiet: WebPage chrome // (contact forms, link hubs) isn't read-aloud material. if ( 'WebPage' !== $type ) { /** * Filter the speakable CSS selectors for a post. * * @param string[] $selectors CSS selectors for read-aloud content. * @param \WP_Post $post The post. */ $speakable = (array) apply_filters( 'agentimus_speakable_selectors', array( '.entry-title', 'h1.page-title', '.wp-block-post-title', '.entry-content > p:first-of-type', '.wp-block-post-content > p:first-of-type' ), $post ); if ( ! empty( $speakable ) ) { $node['speakable'] = array( '@type' => 'SpeakableSpecification', 'cssSelector' => array_values( $speakable ), ); } } return $node; } /** * Build the `about` DefinedTerm nodes for a post's topics. Each is the topic as * a named entity; when an owner/add-on supplies authoritative reference URLs for * it (see {@see topic_links()}), they are attached as `sameAs` so an assistant can * resolve the exact thing — e.g. "Mercury" the planet vs the element. * * @param string[] $topics Resolved topics. * @param \WP_Post $post Post. * @return array[] */ private function about_nodes( array $topics, $post ) { $nodes = array(); foreach ( $topics as $topic ) { $term = array( '@type' => 'DefinedTerm', 'name' => $topic, ); $links = $this->topic_links( $topic, $post ); if ( ! empty( $links ) ) { $term['sameAs'] = 1 === count( $links ) ? $links[0] : $links; } $nodes[] = $term; } return $nodes; } /** * Authoritative reference URLs for one topic, emitted as `sameAs`. Core supplies * NONE — no front-end network calls and no risky auto-matching (a wrong Wikidata * guess is worse than none). An owner or add-on maps topics to Wikidata/Wikipedia * URLs via the filter; the result is sanitised and de-duplicated. * * @param string $topic Topic text. * @param \WP_Post $post Post. * @return string[] */ private function topic_links( $topic, $post ) { /** * Filter authoritative reference URLs (Wikidata, Wikipedia, an official site) * for a topic — emitted as schema.org `sameAs` on its `about` DefinedTerm. * * @param string[] $urls Reference URLs (default none). * @param string $topic The topic text. * @param \WP_Post $post The post. */ $urls = (array) apply_filters( 'agentimus_topic_links', array(), (string) $topic, $post ); $out = array(); foreach ( $urls as $url ) { $url = esc_url_raw( (string) $url ); if ( '' !== $url && ! in_array( $url, $out, true ) ) { $out[] = $url; } } return $out; } /** * BreadcrumbList for a single post (Home → Category → Post). Takes the post * explicitly so it can be built outside the loop — the admin preview describes * an arbitrary post that is not the current query. * * @param \WP_Post $post Post. * @return array */ private function breadcrumb_node( $post ) { $items = array( array( '@type' => 'ListItem', 'position' => 1, 'name' => __( 'Home', 'agentimus' ), 'item' => home_url( '/' ), ), ); $cats = get_the_category( $post->ID ); $pos = 2; if ( ! empty( $cats ) ) { $cat = $cats[0]; $items[] = array( '@type' => 'ListItem', 'position' => $pos++, 'name' => $this->clean( $cat->name ), 'item' => get_category_link( $cat ), ); } $items[] = array( '@type' => 'ListItem', 'position' => $pos, 'name' => $this->clean( get_the_title( $post ) ), 'item' => get_permalink( $post ), ); return array( '@type' => 'BreadcrumbList', 'itemListElement' => $items, ); } }