*/ private const DEFERRED_PIECES = [ 'organization', 'website', 'webpage', 'breadcrumb', 'article' ]; /** * Node the audit reads the site owner from: `@id` suffixes, then types. * * @var array */ private const OWNER_SUFFIXES = [ '#organization', '#person' ]; /** * Types that stand for the site owner, when the `@id` is not ours. * * @var array */ private const OWNER_TYPES = [ 'Organization', 'Person' ]; /** * Types that stand for a piece of writing. * * @var array */ private const ARTICLE_TYPES = [ 'Article', 'BlogPosting', 'NewsArticle', 'TechArticle', 'ScholarlyArticle', 'Report' ]; /** * Machine name, matching the settings section the module reads. * * @return string Module slug. */ public function slug(): string { return 'schema'; } /** * Human readable, translated module name. * * @return string Module title. */ public function title(): string { return __( 'Schema (JSON-LD)', 'acid-aeo' ); } /** * Hooks the module into WordPress. * * @return void */ public function register(): void { add_action( 'wp_head', [ $this, 'render' ], 5 ); add_action( 'acid_aeo_settings_updated', [ $this, 'on_settings_updated' ] ); } /** * Prints the graph for the current request. * * A 404, a feed, an embed and a preview each describe something that is not * a public page of this site, so none of them gets a graph. * * @return void */ public function render(): void { if ( is_404() || is_feed() || is_embed() || is_preview() ) { return; } /** * Filters the facts the JSON-LD graph is built from. * * @param array $ctx Keys `site`, `page` and `pieces`. */ $ctx = apply_filters( 'acid_aeo_schema_context', self::context() ); $graph = ( new SchemaGraphBuilder() )->build( is_array( $ctx ) ? $ctx : [] ); /** * Filters the finished JSON-LD graph. * * @param array $graph `@context` and `@graph`, or an empty array. */ $graph = apply_filters( 'acid_aeo_schema_graph', $graph ); if ( ! is_array( $graph ) || [] === $graph ) { return; } $json = wp_json_encode( $graph, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_HEX_TAG ); if ( ! is_string( $json ) ) { return; } // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- JSON encoded with JSON_HEX_TAG, which escapes every `<` and `>`; any HTML escaping would corrupt the document. echo '' . "\n"; } /** * Audit checks contributed by this module. * * @return array Checks. */ public function checks(): array { return [ $this->home_check(), $this->post_check(), $this->person_check(), ]; } /** * Whether the home page introduces the site as one thing with a name. * * @return Check The finding. */ private function home_check(): Check { $id = 'schema_home'; $label = __( 'Home page JSON-LD', 'acid-aeo' ); $weight = Check::WEIGHT_CRITICAL; $url = home_url( '/' ); $problem = Checks::unreadable( $id, $this->slug(), $label, $url, $weight ); if ( null !== $problem ) { return $problem; } $html = (string) Checks::fetch( $url )['body']; $deferred = $this->deferred( $id, $label, $html, $weight ); if ( null !== $deferred ) { return $deferred; } $owner = $this->node( $this->graph( $html ), self::OWNER_SUFFIXES, self::OWNER_TYPES ); if ( null === $owner ) { return Check::fail( $id, $this->slug(), $label, __( 'The home page carries no JSON-LD graph naming the site owner, so an answer engine has nothing to attribute an answer to.', 'acid-aeo' ), $weight ); } if ( '' === trim( (string) ( $owner['name'] ?? '' ) ) || '' === trim( (string) ( $owner['url'] ?? '' ) ) ) { return Check::fail( $id, $this->slug(), $label, __( 'The home page graph describes the site owner but leaves out the name or the URL, which are the two fields every consumer reads first.', 'acid-aeo' ), $weight ); } return Check::pass( $id, $this->slug(), $label, __( 'The home page introduces the site with a named organization or person node.', 'acid-aeo' ), $weight ); } /** * Whether the newest post says who wrote it and when. * * @return Check The finding. */ private function post_check(): Check { $id = 'schema_post'; $label = __( 'Article JSON-LD', 'acid-aeo' ); $weight = Check::WEIGHT_IMPORTANT; $post_id = ContentData::latest_post_id( [ 'post' ] ); $url = $post_id > 0 ? (string) get_permalink( $post_id ) : ''; if ( '' === $url ) { return Check::skip( $id, $this->slug(), $label, __( 'There are no published posts to look at yet.', 'acid-aeo' ), $weight ); } $problem = Checks::unreadable( $id, $this->slug(), $label, $url, $weight ); if ( null !== $problem ) { return $problem; } $html = (string) Checks::fetch( $url )['body']; $deferred = $this->deferred( $id, $label, $html, $weight ); if ( null !== $deferred ) { return $deferred; } $article = $this->node( $this->graph( $html ), [ '#article' ], self::ARTICLE_TYPES ); if ( null === $article ) { return Check::fail( $id, $this->slug(), $label, __( 'The newest post carries no article node, so an assistant quoting it cannot say who wrote it or when.', 'acid-aeo' ), $weight ); } $missing = []; foreach ( [ 'author', 'datePublished', 'dateModified' ] as $field ) { if ( empty( $article[ $field ] ) ) { $missing[] = $field; } } if ( [] !== $missing ) { return Check::fail( $id, $this->slug(), $label, sprintf( /* translators: %s: comma separated list of schema.org property names. */ __( 'The article node of the newest post is missing: %s.', 'acid-aeo' ), implode( ', ', $missing ) ), $weight ); } if ( empty( $article['image'] ) ) { return Check::warn( $id, $this->slug(), $label, __( 'The newest post has an article node with an author and dates, but no image. Set a featured image so the post can be shown as a rich result.', 'acid-aeo' ), $weight ); } return Check::pass( $id, $this->slug(), $label, __( 'The newest post names its author, its dates and its image.', 'acid-aeo' ), $weight ); } /** * Whether the people writing here have a profile worth quoting. * * @return Check The finding. */ private function person_check(): Check { $id = 'schema_person_complete'; $label = __( 'Author profiles are complete', 'acid-aeo' ); $weight = Check::WEIGHT_NICE; $authors = Checks::recent_authors( 10 ); if ( [] === $authors ) { return Check::skip( $id, $this->slug(), $label, __( 'There are no published posts to read authors from yet.', 'acid-aeo' ), $weight ); } $missing = 0; foreach ( $authors as $author_id ) { if ( '' === trim( (string) get_user_meta( $author_id, UserFields::JOB_TITLE, true ) ) ) { ++$missing; } } if ( 0 === $missing ) { return Check::pass( $id, $this->slug(), $label, __( 'Every recent author has a job title, so each person node says why they would know.', 'acid-aeo' ), $weight ); } return Check::warn( $id, $this->slug(), $label, sprintf( /* translators: 1: number of authors without a job title, 2: number of recent authors. */ __( '%1$d of %2$d recent authors have no job title, so their person nodes say who wrote the page but not why they would know.', 'acid-aeo' ), $missing, count( $authors ) ), $weight ); } /** * The finding for a page whose schema belongs to another plugin. * * When deferring, this module deliberately does not print the organization * or the article node, so their absence from its own graph is the intended * outcome rather than a failure — as long as the SEO plugin did print * something. * * @param string $id Check id. * @param string $label Translated check name. * @param string $html Page body. * @param int $weight Weight of the check. * @return Check|null The finding, or null when this module owns the graph. */ private function deferred( string $id, string $label, string $html, int $weight ): ?Check { $seo = Environment::seo_plugin(); if ( null === $seo || ! self::defers_to_seo() || ! preg_match( '#]*application/ld\+json#i', $html ) ) { return null; } return Check::skip( $id, $this->slug(), $label, sprintf( /* translators: %s: SEO plugin name. */ __( 'Handled by %s', 'acid-aeo' ), (string) $seo['name'] ), $weight ); } /** * The `@graph` of the JSON-LD this module printed on a page. * * @param string $html Page body. * @return array Graph nodes, empty when the page carries none. */ private function graph( string $html ): array { if ( ! preg_match( '#]*class="acid-aeo-schema"[^>]*>(.*?)#is', $html, $matches ) ) { return []; } $decoded = json_decode( (string) $matches[1], true ); if ( ! is_array( $decoded ) ) { return []; } if ( is_array( $decoded['@graph'] ?? null ) ) { return $decoded['@graph']; } return [ $decoded ]; } /** * The first node of a graph matching an `@id` suffix or a type. * * The `@id` is checked first because this plugin mints its own, and a * subtype (`LocalBusiness`, `BlogPosting`) would not match the base type. * * @param array $graph Graph nodes. * @param array $suffixes `@id` endings that identify the node. * @param array $types schema.org types that identify the node. * @return array|null The node, or null when the graph has none. */ private function node( array $graph, array $suffixes, array $types ): ?array { foreach ( $graph as $node ) { if ( ! is_array( $node ) ) { continue; } $node_id = (string) ( $node['@id'] ?? '' ); foreach ( $suffixes as $suffix ) { if ( '' !== $node_id && substr( $node_id, -strlen( $suffix ) ) === $suffix ) { return $node; } } $node_types = is_array( $node['@type'] ?? null ) ? $node['@type'] : [ $node['@type'] ?? '' ]; foreach ( $node_types as $node_type ) { if ( in_array( (string) $node_type, $types, true ) ) { return $node; } } } return null; } /** * Everything the graph builder needs for this request. * * @return array Keys `site`, `page` and `pieces`. */ public static function context(): array { return [ 'site' => SiteData::identity(), 'page' => PageData::current(), 'pieces' => self::pieces(), ]; } /** * Which schema pieces this request may print. * * @return array Piece name => whether it is printed. */ public static function pieces(): array { $pieces = Options::get( 'schema.pieces', [] ); $pieces = is_array( $pieces ) ? $pieces : []; if ( ! self::defers_to_seo() ) { return $pieces; } foreach ( self::DEFERRED_PIECES as $piece ) { $pieces[ $piece ] = false; } return $pieces; } /** * Whether another plugin is trusted with the page level pieces. * * Asks `Environment::seo_plugin()` rather than the full `detect()`: it is * the same answer, read from the constants those plugins define, without * the post counts and file probes `detect()` gathers for the audit — none * of which belong on a front-end page view. * * @return bool True when an SEO plugin is active and deferring is on. */ public static function defers_to_seo(): bool { if ( ! (bool) Options::get( 'schema.defer_to_seo', true ) ) { return false; } return null !== Environment::seo_plugin(); } }