Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Add a site-level Organization node and output schema as a multi-node @graph.
163 changes: 163 additions & 0 deletions projects/packages/seo/src/class-organization-schema-node.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
<?php
/**
* Site-level Organization Schema.org node builder.
*
* Builds the Organization JSON-LD node that represents the site as a publishing
* entity. Most properties come straight from existing site identity — Site Title,
* site URL, Site Logo / Site Icon, and Tagline — so the node is useful with zero
* configuration. Properties with no WordPress source (social profiles `sameAs`,
* `email`) come from the schema settings, passed in via `$settings`; they are
* simply omitted until configured, so an unconfigured site still emits a valid
* node.
*
* The `$settings` argument is the seam the "Organization / Business info" settings
* UI plugs into later: it may carry admin overrides for the site-identity fields
* plus the `sameAs` / `email` values WordPress can't supply. Today the graph passes
* an empty array, so the node is built purely from site identity.
*
* @package automattic/jetpack-seo-package
*/

namespace Automattic\Jetpack\SEO;

/**
* Builds the site-level Organization node.
*/
class Organization_Schema_Node {

/**
* Build the Organization node, or null when the site has no name to identify
* it (an Organization entity with no name is not useful, so we emit nothing
* rather than something invalid).
*
* @param array $settings Optional schema settings: `name`, `description`,
* `sameAs` (array of URLs), `email`. Empty values fall
* back to site identity (or are omitted entirely).
* @return array|null
*/
public static function build( array $settings = array() ) {
$name = self::text( $settings['name'] ?? '' );
if ( '' === $name ) {
$name = self::text( get_bloginfo( 'name' ) );
}
if ( '' === $name ) {
return null;
}

$node = array(
'@type' => '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<int, string>
*/
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 ) );
}
}
162 changes: 162 additions & 0 deletions projects/packages/seo/src/class-post-schema-node.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
<?php
/**
* Per-post Schema.org node builder.
*
* Builds the page-level JSON-LD node for the queried singular post: Article (the
* default for standard 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. Returns an array node or null
* when the post should not emit structured data, so the graph can skip it.
*
* @package automattic/jetpack-seo-package
*/

namespace Automattic\Jetpack\SEO;

use Jetpack_SEO_Posts;
use WP_Post;

/**
* Builds the Article / FAQPage node for a single post.
*/
class Post_Schema_Node {

/**
* 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;

/**
* Build the JSON-LD node for the queried post, or null when none applies.
*
* @param WP_Post|null $post The queried post.
* @return array|null
*/
public static function build( $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; 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 <summary> (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,
);
}
}
Loading
Loading