diff --git a/projects/packages/seo/changelog/add-seo-schema-organization-graph b/projects/packages/seo/changelog/add-seo-schema-organization-graph new file mode 100644 index 000000000000..00195ce4ce6a --- /dev/null +++ b/projects/packages/seo/changelog/add-seo-schema-organization-graph @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a site-level Organization node and output schema as a multi-node @graph. diff --git a/projects/packages/seo/src/class-organization-schema-node.php b/projects/packages/seo/src/class-organization-schema-node.php new file mode 100644 index 000000000000..5c23e8f4780d --- /dev/null +++ b/projects/packages/seo/src/class-organization-schema-node.php @@ -0,0 +1,163 @@ + 'Organization', + '@id' => Schema_Node_Ids::organization(), + 'name' => $name, + 'url' => home_url( '/' ), + ); + + $description = self::text( $settings['description'] ?? '' ); + if ( '' === $description ) { + $description = self::text( get_bloginfo( 'description' ) ); + } + if ( '' !== $description ) { + $node['description'] = $description; + } + + $logo = self::logo(); + if ( null !== $logo ) { + $node['logo'] = $logo; + } + + $same_as = self::url_list( $settings['sameAs'] ?? array() ); + if ( ! empty( $same_as ) ) { + $node['sameAs'] = $same_as; + } + + $email = isset( $settings['email'] ) ? sanitize_email( (string) $settings['email'] ) : ''; + if ( '' !== $email ) { + // Only from explicit settings — never auto-filled from admin_email. + $node['email'] = $email; + } + + return $node; + } + + /** + * The site logo as an ImageObject: the Site Logo (Customizer) when set, + * otherwise the Site Icon. Null when the site has neither. + * + * @return array|null + */ + private static function logo() { + $custom_logo_id = get_theme_mod( 'custom_logo' ); + if ( $custom_logo_id ) { + $src = wp_get_attachment_image_src( $custom_logo_id, 'full' ); + if ( is_array( $src ) && ! empty( $src[0] ) ) { + $image = array( + '@type' => 'ImageObject', + 'url' => $src[0], + ); + if ( ! empty( $src[1] ) && ! empty( $src[2] ) ) { + $image['width'] = (int) $src[1]; + $image['height'] = (int) $src[2]; + } + return $image; + } + } + + $icon_url = get_site_icon_url(); + if ( $icon_url ) { + return array( + '@type' => 'ImageObject', + 'url' => $icon_url, + ); + } + + return null; + } + + /** + * Normalize a scalar setting/site value to trimmed plain text. + * + * @param mixed $value Raw value. + * @return string + */ + private static function text( $value ) { + if ( ! is_string( $value ) ) { + return ''; + } + return trim( wp_strip_all_tags( $value ) ); + } + + /** + * Normalize a list of profile URLs (`sameAs`): keep only non-empty, valid URLs. + * + * @param mixed $value Raw value (expected to be an array of URLs). + * @return array + */ + private static function url_list( $value ) { + if ( ! is_array( $value ) ) { + return array(); + } + + $urls = array(); + foreach ( $value as $url ) { + if ( ! is_string( $url ) ) { + continue; + } + + $url = trim( $url ); + if ( '' === $url ) { + continue; + } + + $validated = wp_http_validate_url( $url ); + if ( false === $validated ) { + continue; + } + + $clean = esc_url_raw( $validated, array( 'http', 'https' ) ); + if ( '' !== $clean ) { + $urls[] = $clean; + } + } + + return array_values( array_unique( $urls ) ); + } +} diff --git a/projects/packages/seo/src/class-post-schema-node.php b/projects/packages/seo/src/class-post-schema-node.php new file mode 100644 index 000000000000..9145a440d5ff --- /dev/null +++ b/projects/packages/seo/src/class-post-schema-node.php @@ -0,0 +1,162 @@ +post_status ) { + return null; + } + + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; Schema_Builder::emit() guards on class_exists. + $override = Jetpack_SEO_Posts::get_post_schema_type( $post ); + $type = '' !== $override ? $override : self::default_schema_for_post( $post ); + + switch ( $type ) { + case 'faq': + return self::build_faq( $post ); + case 'article': + return self::build_article( $post ); + default: + return null; + } + } + + /** + * Default Schema type for a post when the user has not set an override: + * Article for standard posts, none for pages, attachments, or custom types. + * + * @param WP_Post $post The post. + * @return string + */ + private static function default_schema_for_post( WP_Post $post ) { + // Only standard posts get Article schema by default; everything else + // (pages, attachments, custom post types) requires an explicit override. + return 'post' === $post->post_type ? 'article' : ''; + } + + /** + * Article JSON-LD. + * + * @param WP_Post $post The post. + * @return array + */ + private static function build_article( WP_Post $post ) { + $node = array( + '@type' => 'Article', + 'headline' => wp_strip_all_tags( get_the_title( $post ) ), + 'datePublished' => get_post_time( 'c', true, $post ), + 'dateModified' => get_post_modified_time( 'c', true, $post ), + 'mainEntityOfPage' => array( + '@type' => 'WebPage', + '@id' => get_permalink( $post ), + ), + 'author' => array( + '@type' => 'Person', + 'name' => get_the_author_meta( 'display_name', (int) $post->post_author ), + ), + ); + + $image = get_the_post_thumbnail_url( $post, 'full' ); + if ( $image ) { + $node['image'] = $image; + } + + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; Schema_Builder::emit() guards on class_exists. + $description = Jetpack_SEO_Posts::get_post_description( $post ); + if ( $description ) { + // Cap it: get_post_description() falls back to full post_content, which + // would otherwise dump the whole body into the markup. + $node['description'] = wp_trim_words( wp_strip_all_tags( $description ), self::DESCRIPTION_MAX_WORDS, '' ); + } + + return $node; + } + + /** + * FAQPage JSON-LD, parsed from `core/details` blocks (summary = question, + * rendered content = answer). Returns null when the post has none, so we + * never emit an empty/invalid FAQPage. + * + * @param WP_Post $post The post. + * @return array|null + */ + private static function build_faq( WP_Post $post ) { + if ( ! function_exists( 'parse_blocks' ) ) { + return null; + } + + $items = array(); + foreach ( parse_blocks( $post->post_content ) as $block ) { + if ( 'core/details' !== ( $block['blockName'] ?? '' ) ) { + continue; + } + $question = trim( (string) ( $block['attrs']['summary'] ?? '' ) ); + + // Render only the inner blocks for the answer. Rendering the whole + // core/details block would re-include the (the question). + $answer_html = ''; + foreach ( $block['innerBlocks'] ?? array() as $inner_block ) { + $answer_html .= render_block( $inner_block ); + } + $answer = trim( wp_strip_all_tags( $answer_html ) ); + if ( '' === $question || '' === $answer ) { + continue; + } + $items[] = array( + '@type' => 'Question', + 'name' => $question, + 'acceptedAnswer' => array( + '@type' => 'Answer', + 'text' => $answer, + ), + ); + } + + if ( empty( $items ) ) { + return null; + } + + return array( + '@type' => 'FAQPage', + 'mainEntity' => $items, + ); + } +} diff --git a/projects/packages/seo/src/class-schema-builder.php b/projects/packages/seo/src/class-schema-builder.php index 1065fdbecfcd..1703f1f47075 100644 --- a/projects/packages/seo/src/class-schema-builder.php +++ b/projects/packages/seo/src/class-schema-builder.php @@ -2,36 +2,29 @@ /** * JSON-LD Schema.org markup emitter. * - * Emits per-post structured data into the document ``: Article (the - * default for posts) and FAQPage (when the post uses `core/details` blocks). - * The type follows the per-post `jetpack_seo_schema_type` override when set, - * otherwise a sensible default by post type. Emission is gated on + * Serializes a Schema.org `@graph` document into the document `` for the + * current singular request. The graph stitches together the page node (Article, + * or FAQPage when the post uses `core/details` blocks) built by + * {@see Post_Schema_Node}; site-level nodes (Organization, WebSite, …) join the + * same graph and cross-reference the page node by `@id`. Emission is gated on * `Jetpack_SEO_Utils::is_enabled_jetpack_seo()`. * - * Organization / LocalBusiness (site-level) and HowTo are intentionally out of - * scope here — they need backing config / structured input. See JETPACK-1701 - * (Expanded schema markup project). + * This class owns only the gating and serialization; the individual nodes and + * their stable `@id`s live in their own builders ({@see Post_Schema_Node}, + * {@see Schema_Node_Ids}) and are assembled by {@see Schema_Graph}. * * @package automattic/jetpack-seo-package */ namespace Automattic\Jetpack\SEO; -use Jetpack_SEO_Posts; use Jetpack_SEO_Utils; -use WP_Post; /** - * Emits Schema.org JSON-LD into the document head. + * Emits a Schema.org JSON-LD `@graph` into the document head. */ class Schema_Builder { - /** - * Max words kept for a schema `description`, so a long post body doesn't - * dump its full content into the markup. - */ - const DESCRIPTION_MAX_WORDS = 55; - /** * Wire the front-end emitter. * @@ -42,167 +35,79 @@ public static function init() { } /** - * Build and echo the JSON-LD block for the current singular request. + * Build and echo the JSON-LD `@graph` block for the current singular request. * * @return void */ public static function emit() { // Both plugin classes must be loaded — they're not guaranteed in every - // context, and build_for_post() calls Jetpack_SEO_Posts directly. + // context, and the post node builder calls Jetpack_SEO_Posts directly. // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack; guarded by the class_exists check on the same line. if ( ! class_exists( 'Jetpack_SEO_Utils' ) || ! class_exists( 'Jetpack_SEO_Posts' ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) { return; } + // Site-level nodes still ride along on the singular request's graph, so a + // page that emits no page node (and therefore no graph) emits nothing — + // preserving the pre-graph behavior on archives, the home page, and 404s. if ( ! is_singular() ) { return; } - $node = self::build_for_post( get_queried_object() ); - if ( ! $node ) { + $document = self::build_document( get_queried_object() ); + if ( null === $document ) { return; } - $doc = array( '@context' => 'https://schema.org' ) + $node; - printf( '', // Default flags escape forward slashes — important inside " in the data can't break out of the block. - wp_json_encode( $doc, JSON_UNESCAPED_UNICODE ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped + wp_json_encode( $document, JSON_UNESCAPED_UNICODE ) // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ); } /** - * Build the JSON-LD node for the queried post. + * Assemble the `@graph` document for the queried singular object. * - * @param WP_Post|null $post The queried post. - * @return array|null - */ - private static function build_for_post( $post ) { - if ( ! ( $post instanceof WP_Post ) ) { - return null; - } - - // Only emit structured data for published content. Previews, drafts, and - // private posts are viewable by logged-in users (and may be edge-cached), - // so we must not output JSON-LD for anything that isn't publicly published. - if ( 'publish' !== $post->post_status ) { - return null; - } - - // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; emit() guards on class_exists. - $override = Jetpack_SEO_Posts::get_post_schema_type( $post ); - $type = '' !== $override ? $override : self::default_schema_for_post( $post ); - - switch ( $type ) { - case 'faq': - return self::build_faq( $post ); - case 'article': - return self::build_article( $post ); - default: - return null; - } - } - - /** - * Default Schema type for a post when the user has not set an override: - * Article for standard posts, none for pages, attachments, or custom types. - * - * @param WP_Post $post The post. - * @return string - */ - private static function default_schema_for_post( WP_Post $post ) { - // Only standard posts get Article schema by default; everything else - // (pages, attachments, custom post types) requires an explicit override. - return 'post' === $post->post_type ? 'article' : ''; - } - - /** - * Article JSON-LD. + * Returns null when the post yields no page node, so the caller emits nothing + * rather than an empty graph. Site-level nodes are only added alongside a page + * node; standalone sitewide emission (home page, archives) is out of scope. * - * @param WP_Post $post The post. - * @return array - */ - private static function build_article( WP_Post $post ) { - $node = array( - '@type' => 'Article', - 'headline' => wp_strip_all_tags( get_the_title( $post ) ), - 'datePublished' => get_post_time( 'c', true, $post ), - 'dateModified' => get_post_modified_time( 'c', true, $post ), - 'mainEntityOfPage' => array( - '@type' => 'WebPage', - '@id' => get_permalink( $post ), - ), - 'author' => array( - '@type' => 'Person', - 'name' => get_the_author_meta( 'display_name', (int) $post->post_author ), - ), - ); - - $image = get_the_post_thumbnail_url( $post, 'full' ); - if ( $image ) { - $node['image'] = $image; - } - - // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; emit() guards on class_exists. - $description = Jetpack_SEO_Posts::get_post_description( $post ); - if ( $description ) { - // Cap it: get_post_description() falls back to full post_content, which - // would otherwise dump the whole body into the markup. - $node['description'] = wp_trim_words( wp_strip_all_tags( $description ), self::DESCRIPTION_MAX_WORDS, '' ); - } - - return $node; - } - - /** - * FAQPage JSON-LD, parsed from `core/details` blocks (summary = question, - * rendered content = answer). Returns null when the post has none, so we - * never emit an empty/invalid FAQPage. + * Cross-node references (e.g. the Article `publisher`) are wired here rather + * than inside the individual node builders, which stay self-contained and + * unaware of each other. * - * @param WP_Post $post The post. + * @param mixed $queried_object The queried object (expected to be a WP_Post). * @return array|null */ - private static function build_faq( WP_Post $post ) { - if ( ! function_exists( 'parse_blocks' ) ) { + private static function build_document( $queried_object ) { + $post_node = Post_Schema_Node::build( $queried_object ); + if ( null === $post_node ) { return null; } - $items = array(); - foreach ( parse_blocks( $post->post_content ) as $block ) { - if ( 'core/details' !== ( $block['blockName'] ?? '' ) ) { - continue; - } - $question = trim( (string) ( $block['attrs']['summary'] ?? '' ) ); - - // Render only the inner blocks for the answer. Rendering the whole - // core/details block would re-include the (the question). - $answer_html = ''; - foreach ( $block['innerBlocks'] ?? array() as $inner_block ) { - $answer_html .= render_block( $inner_block ); + $graph = new Schema_Graph(); + + // Site-level entities come first, then the page node references them by @id. + // Organization is built from site identity alone here. The persisted schema + // settings — social profiles (`sameAs`) and any `name`/`logo`/`email` + // overrides — are injected through Organization_Schema_Node::build( $settings ) + // once the schema settings server lands; see the `$settings` seam on that + // builder. Until then the argument is intentionally empty, so the output + // matches the current site identity and nothing is configurable yet. + $organization = Organization_Schema_Node::build(); + if ( null !== $organization ) { + $graph->add( $organization ); + + // Only the Article node carries a publisher; FAQPage does not. + if ( 'Article' === ( $post_node['@type'] ?? '' ) ) { + $post_node['publisher'] = array( '@id' => Schema_Node_Ids::organization() ); } - $answer = trim( wp_strip_all_tags( $answer_html ) ); - if ( '' === $question || '' === $answer ) { - continue; - } - $items[] = array( - '@type' => 'Question', - 'name' => $question, - 'acceptedAnswer' => array( - '@type' => 'Answer', - 'text' => $answer, - ), - ); } - if ( empty( $items ) ) { - return null; - } + $graph->add( $post_node ); - return array( - '@type' => 'FAQPage', - 'mainEntity' => $items, - ); + return $graph->to_document(); } } diff --git a/projects/packages/seo/src/class-schema-graph.php b/projects/packages/seo/src/class-schema-graph.php new file mode 100644 index 000000000000..d92e8be5afc7 --- /dev/null +++ b/projects/packages/seo/src/class-schema-graph.php @@ -0,0 +1,68 @@ + + */ + private $nodes = array(); + + /** + * Add a node to the graph. Null/empty nodes are ignored, so callers can pass + * a builder result straight through without guarding it first. + * + * @param array|null $node A JSON-LD node, or null/empty to skip. + * @return static The graph, for chaining. + */ + public function add( $node ) { + if ( is_array( $node ) && ! empty( $node ) ) { + $this->nodes[] = $node; + } + return $this; + } + + /** + * Whether the graph has no nodes to emit. + * + * @return bool + */ + public function is_empty() { + return empty( $this->nodes ); + } + + /** + * Render the full JSON-LD document, or null when there is nothing to emit so + * the caller can skip output entirely rather than print an empty graph. + * + * @return array|null + */ + public function to_document() { + if ( $this->is_empty() ) { + return null; + } + + return array( + '@context' => 'https://schema.org', + '@graph' => array_values( $this->nodes ), + ); + } +} diff --git a/projects/packages/seo/src/class-schema-node-ids.php b/projects/packages/seo/src/class-schema-node-ids.php new file mode 100644 index 000000000000..d61eaac1b384 --- /dev/null +++ b/projects/packages/seo/src/class-schema-node-ids.php @@ -0,0 +1,45 @@ +set_site_identity( '' ); + $this->assertNull( Organization_Schema_Node::build() ); + } + + /** + * The node is built from site identity alone, with a stable `@id` matching the + * shared Organization id used for cross-references. + */ + public function test_builds_from_site_identity() { + $this->set_site_identity( 'Acme Co', 'We make things', 'https://acme.test/' ); + + $node = Organization_Schema_Node::build(); + + $this->assertIsArray( $node ); + $this->assertSame( 'Organization', $node['@type'] ); + $this->assertSame( Schema_Node_Ids::organization(), $node['@id'] ); + $this->assertSame( 'https://acme.test/#organization', $node['@id'] ); + $this->assertSame( 'Acme Co', $node['name'] ); + $this->assertSame( 'https://acme.test/', $node['url'] ); + $this->assertSame( 'We make things', $node['description'] ); + } + + /** + * With no tagline, the `description` is omitted rather than emitted empty. + */ + public function test_description_omitted_when_tagline_empty() { + $this->set_site_identity( 'Acme Co', '' ); + $node = Organization_Schema_Node::build(); + $this->assertArrayNotHasKey( 'description', $node ); + } + + /** + * The Site Icon provides the logo ImageObject when no Site Logo is set. + */ + public function test_logo_falls_back_to_site_icon() { + $this->set_site_identity( 'Acme Co' ); + add_filter( + 'get_site_icon_url', + static function () { + return 'https://acme.test/icon.png'; + } + ); + + $node = Organization_Schema_Node::build(); + + $this->assertSame( 'ImageObject', $node['logo']['@type'] ); + $this->assertSame( 'https://acme.test/icon.png', $node['logo']['url'] ); + } + + /** + * The Site Logo (Customizer `custom_logo`) is preferred over the Site Icon and + * carries its dimensions when available. + */ + public function test_logo_prefers_custom_logo_with_dimensions() { + $this->set_site_identity( 'Acme Co' ); + set_theme_mod( 'custom_logo', 42 ); + add_filter( + 'wp_get_attachment_image_src', + static function () { + return array( 'https://acme.test/logo.png', 120, 60 ); + } + ); + // A Site Icon also exists, but the Site Logo must win. + add_filter( + 'get_site_icon_url', + static function () { + return 'https://acme.test/icon.png'; + } + ); + + $node = Organization_Schema_Node::build(); + + $this->assertSame( 'https://acme.test/logo.png', $node['logo']['url'] ); + $this->assertSame( 120, $node['logo']['width'] ); + $this->assertSame( 60, $node['logo']['height'] ); + } + + /** + * `sameAs` comes only from settings (WordPress has no site-level source). Empty + * and invalid entries are dropped and duplicates removed. + */ + public function test_same_as_from_settings_is_sanitized() { + $this->set_site_identity( 'Acme Co' ); + + $node = Organization_Schema_Node::build( + array( + 'sameAs' => array( + 'https://example.test/twitter', + '', + '/relative-profile', + 'not a url', + 'javascript:alert(1)', + 'mailto:hello@acme.test', + 'https://example.test/twitter', + 'https://example.test/facebook', + ), + ) + ); + + $this->assertSame( + array( 'https://example.test/twitter', 'https://example.test/facebook' ), + $node['sameAs'] + ); + } + + /** + * Without configured social profiles, `sameAs` is omitted entirely. + */ + public function test_same_as_omitted_when_unconfigured() { + $this->set_site_identity( 'Acme Co' ); + $this->assertArrayNotHasKey( 'sameAs', Organization_Schema_Node::build() ); + } + + /** + * `email` is included only when provided in settings (never auto-filled), and + * is sanitized. + */ + public function test_email_only_from_settings() { + $this->set_site_identity( 'Acme Co' ); + + $this->assertArrayNotHasKey( 'email', Organization_Schema_Node::build() ); + + $node = Organization_Schema_Node::build( array( 'email' => 'hello@acme.test' ) ); + $this->assertSame( 'hello@acme.test', $node['email'] ); + } + + /** + * Settings override the site-identity defaults for `name` and `description`. + */ + public function test_settings_override_site_identity() { + $this->set_site_identity( 'Acme Co', 'Default tagline' ); + + $node = Organization_Schema_Node::build( + array( + 'name' => 'Acme Corporation', + 'description' => 'Custom description', + ) + ); + + $this->assertSame( 'Acme Corporation', $node['name'] ); + $this->assertSame( 'Custom description', $node['description'] ); + } + + /** + * A malformed `sameAs` (not an array) is ignored rather than emitted. + */ + public function test_non_array_same_as_is_ignored() { + $this->set_site_identity( 'Acme Co' ); + $node = Organization_Schema_Node::build( array( 'sameAs' => 'https://twitter.com/acme' ) ); + $this->assertArrayNotHasKey( 'sameAs', $node ); + } + + /** + * A malformed (non-string) `name` setting falls back to the site title rather + * than producing an invalid node. + */ + public function test_non_string_name_falls_back_to_site_title() { + $this->set_site_identity( 'Acme Co' ); + $node = Organization_Schema_Node::build( array( 'name' => array( 'unexpected' ) ) ); + $this->assertSame( 'Acme Co', $node['name'] ); + } +} diff --git a/projects/packages/seo/tests/php/PostSchemaNodeTest.php b/projects/packages/seo/tests/php/PostSchemaNodeTest.php new file mode 100644 index 000000000000..eb9acd71a970 --- /dev/null +++ b/projects/packages/seo/tests/php/PostSchemaNodeTest.php @@ -0,0 +1,169 @@ + 1, + 'post_type' => 'post', + 'post_status' => 'publish', + 'post_title' => 'Test post', + 'post_content' => '', + 'post_date' => '2026-01-01 00:00:00', + 'post_date_gmt' => '2026-01-01 00:00:00', + 'post_author' => 0, + ), + $fields + ) + ); + } + + /** + * Invoke a private static Post_Schema_Node method by reflection. + * + * @param string $name Method name. + * @param mixed ...$args Arguments. + * @return mixed + */ + private function invoke( string $name, ...$args ) { + $method = new ReflectionMethod( Post_Schema_Node::class, $name ); + // setAccessible() is required to invoke a private method on PHP < 8.1, and a + // deprecated no-op from 8.1 on — call it only where it's actually needed. + if ( PHP_VERSION_ID < 80100 ) { + $method->setAccessible( true ); + } + return $method->invoke( null, ...$args ); + } + + /** + * The default schema type is Article only for standard posts; pages, + * attachments, and custom post types get no default (require an override). + */ + public function test_default_schema_is_article_only_for_standard_posts() { + $this->assertSame( 'article', $this->invoke( 'default_schema_for_post', $this->make_post( array( 'post_type' => 'post' ) ) ) ); + $this->assertSame( '', $this->invoke( 'default_schema_for_post', $this->make_post( array( 'post_type' => 'page' ) ) ) ); + $this->assertSame( '', $this->invoke( 'default_schema_for_post', $this->make_post( array( 'post_type' => 'attachment' ) ) ) ); + $this->assertSame( '', $this->invoke( 'default_schema_for_post', $this->make_post( array( 'post_type' => 'product' ) ) ) ); + } + + /** + * No node is built for unpublished content (previews, drafts, private, etc.), + * even when a logged-in user can view it. + */ + public function test_no_node_for_unpublished_posts() { + foreach ( array( 'draft', 'private', 'pending', 'future', 'auto-draft' ) as $status ) { + $this->assertNull( + Post_Schema_Node::build( $this->make_post( array( 'post_status' => $status ) ) ), + "Expected no node for status: {$status}" + ); + } + } + + /** + * A non-WP_Post (e.g. a non-singular queried object) yields no node. + */ + public function test_no_node_for_non_post() { + $this->assertNull( Post_Schema_Node::build( null ) ); + } + + /** + * A published standard post with no override produces an Article node. + */ + public function test_published_post_builds_article_by_default() { + $node = Post_Schema_Node::build( + $this->make_post( + array( + 'post_type' => 'post', + 'post_title' => 'Hello world', + ) + ) + ); + + $this->assertIsArray( $node ); + $this->assertSame( 'Article', $node['@type'] ); + $this->assertArrayHasKey( 'headline', $node ); + $this->assertArrayHasKey( 'datePublished', $node ); + $this->assertArrayHasKey( 'mainEntityOfPage', $node ); + } + + /** + * A page with no override produces no node (pages have no default schema). + */ + public function test_published_page_has_no_default_schema() { + $this->assertNull( + Post_Schema_Node::build( $this->make_post( array( 'post_type' => 'page' ) ) ) + ); + } + + /** + * FAQPage answers are built from a `core/details` block's inner blocks only, + * so the question (the ``) is not duplicated into the answer text. + */ + public function test_faq_answer_excludes_the_question() { + \Jetpack_SEO_Posts::$schema_type = 'faq'; + + $content = ''; + $content .= '
What is SEO?'; + $content .= '

Search engine optimization.

'; + $content .= '
'; + + $node = Post_Schema_Node::build( $this->make_post( array( 'post_content' => $content ) ) ); + + $this->assertIsArray( $node ); + $this->assertSame( 'FAQPage', $node['@type'] ); + $this->assertCount( 1, $node['mainEntity'] ); + + $item = $node['mainEntity'][0]; + $this->assertSame( 'Question', $item['@type'] ); + $this->assertSame( 'What is SEO?', $item['name'] ); + $this->assertSame( 'Search engine optimization.', $item['acceptedAnswer']['text'] ); + $this->assertStringNotContainsString( 'What is SEO?', $item['acceptedAnswer']['text'] ); + } + + /** + * A "faq" override with no `core/details` blocks yields no node, rather than + * an empty/invalid FAQPage. + */ + public function test_faq_without_details_blocks_is_null() { + \Jetpack_SEO_Posts::$schema_type = 'faq'; + $node = Post_Schema_Node::build( + $this->make_post( array( 'post_content' => '

No FAQ here.

' ) ) + ); + $this->assertNull( $node ); + } +} diff --git a/projects/packages/seo/tests/php/SchemaBuilderTest.php b/projects/packages/seo/tests/php/SchemaBuilderTest.php index f7cb2c6bfb3f..37fe1f600441 100644 --- a/projects/packages/seo/tests/php/SchemaBuilderTest.php +++ b/projects/packages/seo/tests/php/SchemaBuilderTest.php @@ -2,6 +2,10 @@ /** * Tests for the Jetpack SEO Schema_Builder. * + * These exercise the emitted `#s', $html, $matches ), + 'emit() output is not a single application/ld+json script block.' + ); + return json_decode( $matches[1], true ); } /** - * The default schema type is Article only for standard posts; pages, - * attachments, and custom post types get no default (require an override). + * Find the first node of a given `@type` in a `@graph` document, or null. + * + * Looking nodes up by type (rather than position) keeps these assertions + * stable as more site-level nodes join the graph. + * + * @param array $document Decoded `@graph` document. + * @param string $type The `@type` to find. + * @return array|null */ - public function test_default_schema_is_article_only_for_standard_posts() { - $this->assertSame( 'article', $this->invoke( 'default_schema_for_post', $this->make_post( array( 'post_type' => 'post' ) ) ) ); - $this->assertSame( '', $this->invoke( 'default_schema_for_post', $this->make_post( array( 'post_type' => 'page' ) ) ) ); - $this->assertSame( '', $this->invoke( 'default_schema_for_post', $this->make_post( array( 'post_type' => 'attachment' ) ) ) ); - $this->assertSame( '', $this->invoke( 'default_schema_for_post', $this->make_post( array( 'post_type' => 'product' ) ) ) ); + private function node_of_type( array $document, string $type ) { + foreach ( $document['@graph'] as $node ) { + if ( is_array( $node ) && ( $node['@type'] ?? '' ) === $type ) { + return $node; + } + } + return null; } /** - * No structured data is emitted for unpublished content (previews, drafts, - * private, etc.), even when a logged-in user can view it. + * Nothing is emitted when the SEO feature is disabled. */ - public function test_no_schema_for_unpublished_posts() { - foreach ( array( 'draft', 'private', 'pending', 'future', 'auto-draft' ) as $status ) { - $this->assertNull( - $this->invoke( 'build_for_post', $this->make_post( array( 'post_status' => $status ) ) ), - "Expected no schema for status: {$status}" - ); - } + public function test_emits_nothing_when_feature_disabled() { + \Jetpack_SEO_Utils::$enabled = false; + $this->assertNull( $this->emit_document( $this->make_post() ) ); } /** - * A non-WP_Post (e.g. a non-singular queried object) yields no node. + * Nothing is emitted on non-singular requests (home, archives, 404). Site-level + * nodes ride along on the singular request's graph; they are not emitted on + * their own yet. */ - public function test_no_schema_for_non_post() { - $this->assertNull( $this->invoke( 'build_for_post', null ) ); + public function test_emits_nothing_on_non_singular() { + $this->assertNull( $this->emit_document( null ) ); } /** - * A published standard post with no override produces an Article node. + * Nothing is emitted for unpublished content, even on a singular request. */ - public function test_published_post_builds_article_by_default() { - $node = $this->invoke( - 'build_for_post', - $this->make_post( - array( - 'post_type' => 'post', - 'post_title' => 'Hello world', - ) - ) - ); + public function test_emits_nothing_for_unpublished() { + $this->assertNull( $this->emit_document( $this->make_post( array( 'post_status' => 'draft' ) ) ) ); + } - $this->assertIsArray( $node ); - $this->assertSame( 'Article', $node['@type'] ); - $this->assertArrayHasKey( 'headline', $node ); - $this->assertArrayHasKey( 'datePublished', $node ); - $this->assertArrayHasKey( 'mainEntityOfPage', $node ); + /** + * A page with no schema override yields no page node, so the request emits + * nothing at all (no standalone site-level graph). + */ + public function test_emits_nothing_for_page_without_override() { + $this->assertNull( $this->emit_document( $this->make_post( array( 'post_type' => 'page' ) ) ) ); } /** - * A page with no override produces no node (pages have no default schema). + * A published standard post emits a `@graph` document containing an Article + * node. Title/permalink values resolve through DB lookups the dbless test + * environment can't satisfy, so this asserts document shape, not those values. */ - public function test_published_page_has_no_default_schema() { - $this->assertNull( - $this->invoke( 'build_for_post', $this->make_post( array( 'post_type' => 'page' ) ) ) - ); + public function test_emits_graph_with_article_for_published_post() { + $doc = $this->emit_document( $this->make_post( array( 'post_title' => 'Hello world' ) ) ); + + $this->assertIsArray( $doc ); + $this->assertSame( 'https://schema.org', $doc['@context'] ); + $this->assertIsArray( $doc['@graph'] ); + $this->assertArrayNotHasKey( '@type', $doc, 'The document is a @graph, not a single top-level node.' ); + + $article = $this->node_of_type( $doc, 'Article' ); + $this->assertIsArray( $article, 'Expected an Article node in the graph.' ); + $this->assertArrayHasKey( 'headline', $article ); + $this->assertArrayHasKey( 'datePublished', $article ); + $this->assertArrayHasKey( 'mainEntityOfPage', $article ); } /** - * FAQPage answers are built from a `core/details` block's inner blocks only, - * so the question (the ``) is not duplicated into the answer text. + * A "faq" override emits a `@graph` document containing a FAQPage node. */ - public function test_faq_answer_excludes_the_question() { + public function test_emits_graph_with_faqpage_for_faq_override() { \Jetpack_SEO_Posts::$schema_type = 'faq'; $content = ''; @@ -144,29 +200,51 @@ public function test_faq_answer_excludes_the_question() { $content .= '

Search engine optimization.

'; $content .= ''; - $node = $this->invoke( 'build_for_post', $this->make_post( array( 'post_content' => $content ) ) ); + $doc = $this->emit_document( $this->make_post( array( 'post_content' => $content ) ) ); + + $this->assertIsArray( $doc ); + $faq = $this->node_of_type( $doc, 'FAQPage' ); + $this->assertIsArray( $faq, 'Expected a FAQPage node in the graph.' ); + $this->assertSame( 'What is SEO?', $faq['mainEntity'][0]['name'] ); + } + + /** + * The graph leads with the site-level Organization node, and the Article + * references it as `publisher` by `@id`. + */ + public function test_graph_leads_with_organization_referenced_as_article_publisher() { + $this->set_site_name( 'Acme Co' ); - $this->assertIsArray( $node ); - $this->assertSame( 'FAQPage', $node['@type'] ); - $this->assertCount( 1, $node['mainEntity'] ); + $doc = $this->emit_document( $this->make_post() ); - $item = $node['mainEntity'][0]; - $this->assertSame( 'Question', $item['@type'] ); - $this->assertSame( 'What is SEO?', $item['name'] ); - $this->assertSame( 'Search engine optimization.', $item['acceptedAnswer']['text'] ); - $this->assertStringNotContainsString( 'What is SEO?', $item['acceptedAnswer']['text'] ); + $this->assertSame( 'Organization', $doc['@graph'][0]['@type'], 'Site-level nodes come first.' ); + + $organization = $this->node_of_type( $doc, 'Organization' ); + $this->assertIsArray( $organization, 'Expected an Organization node in the graph.' ); + $this->assertSame( 'Acme Co', $organization['name'] ); + + $article = $this->node_of_type( $doc, 'Article' ); + $this->assertSame( $organization['@id'], $article['publisher']['@id'] ); } /** - * A "faq" override with no `core/details` blocks yields no node, rather than - * an empty/invalid FAQPage. + * A FAQPage joins the graph alongside the Organization, but carries no + * `publisher` (only Article references a publisher). */ - public function test_faq_without_details_blocks_is_null() { + public function test_faqpage_has_no_publisher_but_graph_has_organization() { + $this->set_site_name( 'Acme Co' ); \Jetpack_SEO_Posts::$schema_type = 'faq'; - $node = $this->invoke( - 'build_for_post', - $this->make_post( array( 'post_content' => '

No FAQ here.

' ) ) - ); - $this->assertNull( $node ); + + $content = ''; + $content .= '
What is SEO?'; + $content .= '

Search engine optimization.

'; + $content .= '
'; + + $doc = $this->emit_document( $this->make_post( array( 'post_content' => $content ) ) ); + + $this->assertIsArray( $this->node_of_type( $doc, 'Organization' ) ); + + $faq = $this->node_of_type( $doc, 'FAQPage' ); + $this->assertArrayNotHasKey( 'publisher', $faq ); } } diff --git a/projects/packages/seo/tests/php/SchemaGraphTest.php b/projects/packages/seo/tests/php/SchemaGraphTest.php new file mode 100644 index 000000000000..ab157869600f --- /dev/null +++ b/projects/packages/seo/tests/php/SchemaGraphTest.php @@ -0,0 +1,56 @@ +assertTrue( $graph->is_empty() ); + $this->assertNull( $graph->to_document() ); + } + + /** + * Null and empty nodes are ignored, so a builder result can be passed straight + * through without the caller guarding it. + */ + public function test_null_and_empty_nodes_are_skipped() { + $graph = new Schema_Graph(); + $graph->add( null )->add( array() ); + $this->assertTrue( $graph->is_empty() ); + $this->assertNull( $graph->to_document() ); + } + + /** + * The document wraps the collected nodes in a schema.org `@graph`, preserving + * insertion order and serializing the graph as a JSON array (a list). + */ + public function test_document_wraps_nodes_in_graph_in_order() { + $organization = array( '@type' => 'Organization' ); + $article = array( '@type' => 'Article' ); + + $document = ( new Schema_Graph() ) + ->add( $organization ) + ->add( $article ) + ->to_document(); + + $this->assertSame( 'https://schema.org', $document['@context'] ); + $this->assertSame( array( $organization, $article ), $document['@graph'] ); + $this->assertSame( array( 0, 1 ), array_keys( $document['@graph'] ), 'The @graph must be a sequential list.' ); + } +} diff --git a/projects/packages/seo/tests/php/SchemaNodeIdsTest.php b/projects/packages/seo/tests/php/SchemaNodeIdsTest.php new file mode 100644 index 000000000000..735724d4f056 --- /dev/null +++ b/projects/packages/seo/tests/php/SchemaNodeIdsTest.php @@ -0,0 +1,58 @@ +assertSame( 'https://example.test/#organization', Schema_Node_Ids::organization() ); + } + + /** + * The id is stable across calls — it must not vary per request, or the cross-node + * `@id` references (e.g. the Article publisher) would not resolve. + */ + public function test_organization_id_is_stable() { + add_filter( + 'home_url', + static function () { + return 'https://example.test/'; + } + ); + + $this->assertSame( Schema_Node_Ids::organization(), Schema_Node_Ids::organization() ); + } +}