From 9a90573809ab1908d7d6cd251f38c23ce12039a6 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 12:49:34 -0500 Subject: [PATCH 01/11] Jetpack SEO: per-post schema type meta + front-end JSON-LD (Article/FAQ) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Jetpack_SEO_Posts: register jetpack_seo_schema_type meta (show_in_rest with an enum schema so core REST rejects unknown types — must-fix #12), plus get_post_schema_type() and a single factual get_post_seo_coverage() helper (presence/state only, no scoring) shared by the columns + Overview card. Per-post types scoped to none/Article/FAQ (LocalBusiness/Organization/HowTo deferred to the Expanded Schema project, JETPACK-1701). - Schema_Builder (package): front-end JSON-LD on wp_head for Article + FAQ (FAQ parsed from core/details; emits nothing if absent). Must-fix #8 (drop JSON_UNESCAPED_SLASHES) and #9 (cap Article description with wp_trim_words). - Initializer: wire Schema_Builder::init() in (front-end emission). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../packages/seo/src/class-initializer.php | 4 + .../packages/seo/src/class-schema-builder.php | 187 ++++++++++++++++++ .../seo-tools/class-jetpack-seo-posts.php | 89 ++++++++- 3 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 projects/packages/seo/src/class-schema-builder.php diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index f3512a3e55fb..14707b3a2e6f 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -102,6 +102,10 @@ public static function init() { return; } + // Front-end JSON-LD schema (Article / FAQ). Self-hooks `wp_head`, so it + // only emits on front-end requests. + Schema_Builder::init(); + // Priority 1: load the wp-build bundle (and define its render function) // before `add_menu_item()` runs at the default priority and needs it. add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); diff --git a/projects/packages/seo/src/class-schema-builder.php b/projects/packages/seo/src/class-schema-builder.php new file mode 100644 index 000000000000..ec13540e579a --- /dev/null +++ b/projects/packages/seo/src/class-schema-builder.php @@ -0,0 +1,187 @@ +`: 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 + * `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). + * + * @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. + */ +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. + * + * @return void + */ + public static function init() { + add_action( 'wp_head', array( __CLASS__, 'emit' ), 5 ); + } + + /** + * Build and echo the JSON-LD block for the current singular request. + * + * @return void + */ + public static function emit() { + if ( ! class_exists( 'Jetpack_SEO_Utils' ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) { + return; + } + + if ( ! is_singular() ) { + return; + } + + $node = self::build_for_post( get_queried_object() ); + if ( ! $node ) { + 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 + ); + } + + /** + * Build the JSON-LD node for the queried post. + * + * @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; + } + + $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 hasn't set an override: + * Article for posts, none for pages. + * + * @param WP_Post $post The post. + * @return string + */ + private static function default_schema_for_post( WP_Post $post ) { + return 'page' === $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', $post->post_author ), + ), + ); + + $image = get_the_post_thumbnail_url( $post, 'full' ); + if ( $image ) { + $node['image'] = $image; + } + + $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'] ?? '' ) ); + $answer = trim( wp_strip_all_tags( render_block( $block ) ) ); + 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/plugins/jetpack/modules/seo-tools/class-jetpack-seo-posts.php b/projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-posts.php index 33bfecda772e..1c22474052b0 100644 --- a/projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-posts.php +++ b/projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-posts.php @@ -15,12 +15,22 @@ class Jetpack_SEO_Posts { const DESCRIPTION_META_KEY = 'advanced_seo_description'; const HTML_TITLE_META_KEY = 'jetpack_seo_html_title'; const NOINDEX_META_KEY = 'jetpack_seo_noindex'; + const SCHEMA_TYPE_META_KEY = 'jetpack_seo_schema_type'; const POST_META_KEYS_ARRAY = array( self::DESCRIPTION_META_KEY, self::HTML_TITLE_META_KEY, self::NOINDEX_META_KEY, + self::SCHEMA_TYPE_META_KEY, ); + /** + * Allowed Schema.org types that can be stored in the per-post schema-type + * meta. Empty string means "no override" — Schema_Builder picks a sensible + * default for the post. Single source of truth for the meta enum, the + * block-editor panel options, and Schema_Builder. + */ + const ALLOWED_SCHEMA_TYPES = array( '', 'article', 'faq' ); + /** * Build meta description for post SEO. * @@ -140,9 +150,11 @@ public static function exclude_noindex_posts_from_jetpack_sitemap( $skip, $post } /** - * Registers the following meta keys for use in the REST API: + * Registers the SEO post meta keys for use in the REST API: * - self::DESCRIPTION_META_KEY * - self::HTML_TITLE_META_KEY + * - self::NOINDEX_META_KEY + * - self::SCHEMA_TYPE_META_KEY */ public static function register_post_meta() { $description_args = array( @@ -175,8 +187,83 @@ public static function register_post_meta() { ), ); + $schema_type_args = array( + 'type' => 'string', + 'description' => __( 'Schema.org type to emit as JSON-LD for this post.', 'jetpack' ), + 'single' => true, + 'default' => '', + 'sanitize_callback' => array( __CLASS__, 'sanitize_schema_type' ), + 'show_in_rest' => array( + 'name' => self::SCHEMA_TYPE_META_KEY, + // Enum so core REST rejects an unknown schema type with a proper + // rest_invalid_param error; the sanitize_callback is the + // defense-in-depth fallback for non-REST writes. + 'schema' => array( + 'type' => 'string', + 'enum' => self::ALLOWED_SCHEMA_TYPES, + ), + ), + ); + register_meta( 'post', self::DESCRIPTION_META_KEY, $description_args ); register_meta( 'post', self::HTML_TITLE_META_KEY, $html_title_args ); register_meta( 'post', self::NOINDEX_META_KEY, $noindex_args ); + register_meta( 'post', self::SCHEMA_TYPE_META_KEY, $schema_type_args ); + } + + /** + * Sanitize a schema type to the allowed list. Unknown values become '' + * (no override) rather than erroring, so a non-REST write can't store junk. + * + * @param string $value The submitted value. + * @return string A value from self::ALLOWED_SCHEMA_TYPES. + */ + public static function sanitize_schema_type( $value ) { + $value = is_string( $value ) ? sanitize_key( $value ) : ''; + return in_array( $value, self::ALLOWED_SCHEMA_TYPES, true ) ? $value : ''; + } + + /** + * Get the per-post schema-type override, if any. + * + * @param WP_Post|int|null $post Post or post ID. + * @return string A value from self::ALLOWED_SCHEMA_TYPES ('' = no override). + */ + public static function get_post_schema_type( $post = null ) { + $post = get_post( $post ); + if ( ! ( $post instanceof WP_Post ) ) { + return ''; + } + return self::sanitize_schema_type( (string) get_post_meta( $post->ID, self::SCHEMA_TYPE_META_KEY, true ) ); + } + + /** + * Factual per-post SEO field coverage — presence/state only, never a score. + * + * Single source of truth shared by the Content tab, the edit.php columns, + * and the Overview coverage card so the three never drift. Reports whether + * each field has been *set*, independent of whether SEO tools are currently + * active (this is an authoring/audit view, not front-end emission). + * + * @param WP_Post|int|null $post Post or post ID. + * @return array{has_custom_title:bool,has_description:bool,has_schema_type:bool,noindex:bool} + */ + public static function get_post_seo_coverage( $post = null ) { + $post = get_post( $post ); + if ( ! ( $post instanceof WP_Post ) ) { + return array( + 'has_custom_title' => false, + 'has_description' => false, + 'has_schema_type' => false, + 'noindex' => false, + ); + } + + return array( + 'has_custom_title' => '' !== (string) get_post_meta( $post->ID, self::HTML_TITLE_META_KEY, true ), + 'has_description' => '' !== (string) get_post_meta( $post->ID, self::DESCRIPTION_META_KEY, true ), + 'has_schema_type' => '' !== self::get_post_schema_type( $post ), + 'noindex' => (bool) get_post_meta( $post->ID, self::NOINDEX_META_KEY, true ), + ); } } From 2e5c5fb2a697f9b1f4c49834b8502d1533250ebd Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 13:00:09 -0500 Subject: [PATCH 02/11] Jetpack SEO: factual edit.php columns + block-editor schema-type panel - Admin columns (class-jetpack-seo-admin-columns.php, wired in seo-tools.php): factual Schema / Meta description / Search columns on public post lists, via the shared Jetpack_SEO_Posts::get_post_seo_coverage() helper. No traffic-light scoring and no React-island preview (display-only). - Block editor: new schema-panel.js (Default/Article/FAQ SelectControl) reusing the existing withSeoHelper HOC; wired SeoSchemaPanel into the existing SEO panel's three placements (sidebar, document settings, pre-publish). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../jetpack/extensions/plugins/seo/index.js | 12 ++ .../extensions/plugins/seo/schema-panel.js | 26 ++++ .../plugins/jetpack/modules/seo-tools.php | 3 + .../class-jetpack-seo-admin-columns.php | 140 ++++++++++++++++++ 4 files changed, 181 insertions(+) create mode 100644 projects/plugins/jetpack/extensions/plugins/seo/schema-panel.js create mode 100644 projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-admin-columns.php diff --git a/projects/plugins/jetpack/extensions/plugins/seo/index.js b/projects/plugins/jetpack/extensions/plugins/seo/index.js index 8faa44fc0309..c1de64cfa2d3 100644 --- a/projects/plugins/jetpack/extensions/plugins/seo/index.js +++ b/projects/plugins/jetpack/extensions/plugins/seo/index.js @@ -34,6 +34,7 @@ import { SeoSkeletonLoader } from './components/skeleton-loader'; import UpsellNotice from './components/upsell'; import SeoDescriptionPanel from './description-panel'; import SeoNoindexPanel from './noindex-panel'; +import SeoSchemaPanel from './schema-panel'; import { showSeoSection } from './show-seo-section'; import SeoTitlePanel from './title-panel'; import './editor.scss'; @@ -192,6 +193,11 @@ const Seo = () => { > + + + @@ -216,6 +222,9 @@ const Seo = () => { + + + @@ -238,6 +247,9 @@ const Seo = () => { + + + diff --git a/projects/plugins/jetpack/extensions/plugins/seo/schema-panel.js b/projects/plugins/jetpack/extensions/plugins/seo/schema-panel.js new file mode 100644 index 000000000000..4007966a8f4d --- /dev/null +++ b/projects/plugins/jetpack/extensions/plugins/seo/schema-panel.js @@ -0,0 +1,26 @@ +import { SelectControl } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { withSeoHelper } from './with-seo-helper'; + +const SCHEMA_OPTIONS = [ + { label: __( 'Default', 'jetpack' ), value: '' }, + { label: __( 'Article', 'jetpack' ), value: 'article' }, + { label: __( 'FAQ', 'jetpack' ), value: 'faq' }, +]; + +const SeoSchemaPanel = ( { metaValue, updateMetaValue } ) => ( + +); + +export default withSeoHelper( 'jetpack_seo_schema_type' )( SeoSchemaPanel ); diff --git a/projects/plugins/jetpack/modules/seo-tools.php b/projects/plugins/jetpack/modules/seo-tools.php index 765fff1191b5..a83b5c7d1a41 100644 --- a/projects/plugins/jetpack/modules/seo-tools.php +++ b/projects/plugins/jetpack/modules/seo-tools.php @@ -49,4 +49,7 @@ if ( ! apply_filters( 'jetpack_disable_seo_tools', false ) ) { require_once __DIR__ . '/seo-tools/class-jetpack-seo.php'; new Jetpack_SEO(); + + require_once __DIR__ . '/seo-tools/class-jetpack-seo-admin-columns.php'; + Jetpack_SEO_Admin_Columns::init(); } diff --git a/projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-admin-columns.php b/projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-admin-columns.php new file mode 100644 index 000000000000..92868815bfee --- /dev/null +++ b/projects/plugins/jetpack/modules/seo-tools/class-jetpack-seo-admin-columns.php @@ -0,0 +1,140 @@ + true, + 'show_ui' => true, + 'show_in_rest' => true, + ), + 'names' + ); + unset( $post_types['attachment'] ); + + foreach ( $post_types as $post_type ) { + add_filter( "manage_{$post_type}_posts_columns", array( __CLASS__, 'add_columns' ) ); + add_action( "manage_{$post_type}_posts_custom_column", array( __CLASS__, 'render_column' ), 10, 2 ); + } + } + + /** + * Insert the SEO columns just after the title column. + * + * @param array $columns Existing columns keyed by column name. + * @return array + */ + public static function add_columns( $columns ) { + $new = array(); + foreach ( $columns as $key => $label ) { + $new[ $key ] = $label; + if ( 'title' === $key ) { + $new['jetpack_seo_schema'] = __( 'Schema', 'jetpack' ); + $new['jetpack_seo_description'] = __( 'Meta description', 'jetpack' ); + $new['jetpack_seo_search'] = __( 'Search', 'jetpack' ); + } + } + return $new; + } + + /** + * Render a single cell — factual state only. + * + * @param string $column Column identifier. + * @param int $post_id Current row post ID. + * @return void + */ + public static function render_column( $column, $post_id ) { + $columns = array( 'jetpack_seo_schema', 'jetpack_seo_description', 'jetpack_seo_search' ); + if ( ! in_array( $column, $columns, true ) ) { + return; + } + + $coverage = Jetpack_SEO_Posts::get_post_seo_coverage( $post_id ); + + switch ( $column ) { + case 'jetpack_seo_schema': + $schema = Jetpack_SEO_Posts::get_post_schema_type( $post_id ); + echo esc_html( '' !== $schema ? self::schema_type_label( $schema ) : '—' ); + break; + + case 'jetpack_seo_description': + echo $coverage['has_description'] + ? esc_html__( 'Set', 'jetpack' ) + : '' . esc_html__( 'Not set', 'jetpack' ) . ''; + break; + + case 'jetpack_seo_search': + echo $coverage['noindex'] + ? esc_html__( 'Hidden', 'jetpack' ) + : '' . esc_html__( 'Visible', 'jetpack' ) . ''; + break; + } + } + + /** + * Display label for an allowed schema type. + * + * @param string $schema Schema type slug. + * @return string + */ + private static function schema_type_label( $schema ) { + switch ( $schema ) { + case 'article': + return __( 'Article', 'jetpack' ); + case 'faq': + return __( 'FAQ', 'jetpack' ); + default: + return ucfirst( $schema ); + } + } + + /** + * Minimal column-width styling on edit.php only (no color-coding — + * these columns report state, not a grade). + * + * @param string $hook_suffix Current admin hook suffix. + * @return void + */ + public static function enqueue_assets( $hook_suffix ) { + if ( 'edit.php' !== $hook_suffix ) { + return; + } + wp_register_style( 'jetpack-seo-admin-columns', false, array(), JETPACK__VERSION ); + wp_add_inline_style( + 'jetpack-seo-admin-columns', + '.column-jetpack_seo_schema,.column-jetpack_seo_description,.column-jetpack_seo_search{width:9em}' . + '.jetpack-seo-col-muted{color:#787c82}' + ); + wp_enqueue_style( 'jetpack-seo-admin-columns' ); + } +} From e6fa7b210105a6b482649087a61df39884a07b10 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 13:42:08 -0500 Subject: [PATCH 03/11] Jetpack SEO: Content DataViews tab + Content SEO coverage card - Content tab (?tab=content): DataViews list of posts/pages backed by core /wp/v2/posts + registered SEO meta (no package REST controller). Factual columns (schema type, meta-description set, search visibility) + an Edit SEO modal that writes core post meta, with a live SERP preview. Adds @wordpress/dataviews, @wordpress/core-data, @wordpress/html-entities. - Content SEO coverage card on the Overview: factual per-metric DonutMeter rings (custom description, schema type) + literal counts, noindex as a plain count. No composite score / grading. Backed by a content_coverage aggregate in get_overview_data() via script-data (count helpers in the Initializer). Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 9 + projects/packages/seo/_inc/app.tsx | 14 +- .../packages/seo/_inc/data/content-types.ts | 44 ++++ .../packages/seo/_inc/data/overview-types.ts | 8 + .../packages/seo/_inc/data/use-seo-posts.ts | 124 +++++++++ .../_inc/screens/content/edit-seo-modal.tsx | 217 ++++++++++++++++ .../seo/_inc/screens/content/index.tsx | 239 ++++++++++++++++++ .../seo/_inc/screens/content/serp-preview.tsx | 66 +++++ .../seo/_inc/screens/content/style.scss | 56 ++++ .../overview/content-coverage-card.tsx | 108 ++++++++ .../seo/_inc/screens/overview/index.tsx | 11 + .../seo/_inc/screens/overview/style.scss | 31 +++ projects/packages/seo/package.json | 3 + .../packages/seo/routes/index/package.json | 3 + .../packages/seo/src/class-initializer.php | 62 +++++ 15 files changed, 992 insertions(+), 3 deletions(-) create mode 100644 projects/packages/seo/_inc/data/content-types.ts create mode 100644 projects/packages/seo/_inc/data/use-seo-posts.ts create mode 100644 projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx create mode 100644 projects/packages/seo/_inc/screens/content/index.tsx create mode 100644 projects/packages/seo/_inc/screens/content/serp-preview.tsx create mode 100644 projects/packages/seo/_inc/screens/content/style.scss create mode 100644 projects/packages/seo/_inc/screens/overview/content-coverage-card.tsx diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8e31de68860..e9ac31000ea6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4543,12 +4543,21 @@ importers: '@wordpress/components': specifier: 33.1.0 version: 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@wordpress/core-data': + specifier: 7.46.0 + version: 7.46.0(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/data': specifier: 10.46.0 version: 10.46.0(react@18.3.1) + '@wordpress/dataviews': + specifier: 14.3.0 + version: 14.3.0(@types/react@18.3.28)(react@18.3.1) '@wordpress/element': specifier: 6.46.0 version: 6.46.0 + '@wordpress/html-entities': + specifier: 4.46.0 + version: 4.46.0 '@wordpress/i18n': specifier: 6.19.0 version: 6.19.0 diff --git a/projects/packages/seo/_inc/app.tsx b/projects/packages/seo/_inc/app.tsx index a4cb0ef27db6..e01671505463 100644 --- a/projects/packages/seo/_inc/app.tsx +++ b/projects/packages/seo/_inc/app.tsx @@ -5,13 +5,14 @@ import { useNavigate, useSearch } from '@wordpress/route'; import { Tabs } from '@wordpress/ui'; import { useSettingsForm } from './data/use-settings'; import NoticesList from './notices-list'; +import ContentScreen from './screens/content'; import OverviewScreen from './screens/overview'; import SettingsScreen from './screens/settings'; import './admin-page-layout.scss'; import type { FC } from 'react'; type StageSearch = Record< string, unknown > & { tab?: string }; -type SeoTab = 'overview' | 'settings'; +type SeoTab = 'overview' | 'settings' | 'content'; /** * Root of the Jetpack SEO admin app, mounted by `@wordpress/build` as the @@ -23,13 +24,14 @@ type SeoTab = 'overview' | 'settings'; */ const App: FC = () => { const search = useSearch( { from: '/' as unknown as never, strict: false } ) as StageSearch; - const activeTab: SeoTab = search.tab === 'settings' ? 'settings' : 'overview'; + const activeTab: SeoTab = + search.tab === 'settings' || search.tab === 'content' ? search.tab : 'overview'; const navigate = useNavigate(); const settingsForm = useSettingsForm(); const onTabChange = useCallback( ( next: string | null ) => { - if ( next !== 'overview' && next !== 'settings' ) { + if ( next !== 'overview' && next !== 'settings' && next !== 'content' ) { return; } navigate( { @@ -58,6 +60,7 @@ const App: FC = () => { { __( 'Overview', 'jetpack-seo' ) } { __( 'Settings', 'jetpack-seo' ) } + { __( 'Content', 'jetpack-seo' ) } @@ -70,6 +73,11 @@ const App: FC = () => { + +
+ +
+
diff --git a/projects/packages/seo/_inc/data/content-types.ts b/projects/packages/seo/_inc/data/content-types.ts new file mode 100644 index 000000000000..0a090f3dbb16 --- /dev/null +++ b/projects/packages/seo/_inc/data/content-types.ts @@ -0,0 +1,44 @@ +// Types for the Content tab, which lists posts/pages backed by WordPress core +// REST (`/wp/v2/posts`, `/wp/v2/pages`) plus the SEO post meta registered in +// `class-jetpack-seo-posts.php`. There is no package REST controller — every +// field here comes from a core REST record's `meta` object. + +// The allowed per-post Schema.org override. Mirrors +// `Jetpack_SEO_Posts::ALLOWED_SCHEMA_TYPES` ('' = no override / default). +export type SchemaType = '' | 'article' | 'faq'; + +// The SEO post meta as it arrives in a core REST record's `meta` object. +export interface SeoPostMeta { + advanced_seo_description: string; + jetpack_seo_html_title: string; + jetpack_seo_noindex: boolean; + jetpack_seo_schema_type: SchemaType; +} + +// The post type the Content tab is currently listing. Switches the core +// endpoint between `/wp/v2/posts` and `/wp/v2/pages`. +export type ContentPostType = 'post' | 'page'; + +// A single row in the Content DataViews table. Factual flags only — the table +// reports the *state* of each SEO field, never a score or quality judgement. +export interface ContentRow { + id: number; + title: string; + link: string; + editLink: string; + type: string; + status: string; + // The four editable SEO meta fields, plus derived presence flags. + customTitle: string; + description: string; + schemaType: SchemaType; + noindex: boolean; + hasCustomTitle: boolean; + hasDescription: boolean; +} + +// An option for the post-type filter (Posts / Pages). +export interface PostTypeOption { + value: ContentPostType; + label: string; +} diff --git a/projects/packages/seo/_inc/data/overview-types.ts b/projects/packages/seo/_inc/data/overview-types.ts index 72a8272e4dda..f2efab2fc9f5 100644 --- a/projects/packages/seo/_inc/data/overview-types.ts +++ b/projects/packages/seo/_inc/data/overview-types.ts @@ -17,9 +17,17 @@ export interface SiteVerification { facebook: boolean; } +export interface ContentCoverage { + total: number; + with_description: number; + with_schema: number; + noindexed: number; +} + export interface OverviewResponse { site_visibility: SiteVisibility; site_verification: SiteVerification; + content_coverage: ContentCoverage; plan: { seo_enabled_for_site: boolean; }; diff --git a/projects/packages/seo/_inc/data/use-seo-posts.ts b/projects/packages/seo/_inc/data/use-seo-posts.ts new file mode 100644 index 000000000000..da48b8b8bb06 --- /dev/null +++ b/projects/packages/seo/_inc/data/use-seo-posts.ts @@ -0,0 +1,124 @@ +import { useEntityRecords } from '@wordpress/core-data'; +import { useMemo } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import type { ContentPostType, ContentRow, SchemaType, SeoPostMeta } from './content-types'; + +// Only request the columns the Content tab renders, plus the SEO meta. Core +// REST returns `meta` as an object keyed by the registered meta names. +const POST_FIELDS = [ 'id', 'title', 'link', 'type', 'status', 'meta' ].join( ',' ); + +// The shape of a core REST post/page record, narrowed to what we read. +interface SeoPostRecord { + id: number; + title?: { rendered?: string }; + link?: string; + type?: string; + status?: string; + meta?: Partial< SeoPostMeta >; +} + +export interface UseSeoPostsArgs { + postType: ContentPostType; + page: number; + perPage: number; + search?: string; + orderby?: string; + order?: 'asc' | 'desc'; +} + +export interface UseSeoPostsReturn { + items: ContentRow[]; + totalItems: number; + totalPages: number; + isLoading: boolean; +} + +/** + * Coerce a stored schema-type meta value to the allowed union. Anything + * unexpected falls back to '' (no override), matching the server-side + * sanitize in `Jetpack_SEO_Posts::sanitize_schema_type`. + * + * @param value - The raw `jetpack_seo_schema_type` meta value. + * @return A valid {@link SchemaType}. + */ +function toSchemaType( value: unknown ): SchemaType { + return value === 'article' || value === 'faq' ? value : ''; +} + +/** + * Map a raw core REST post/page record to a Content table row, deriving the + * factual SEO-field flags from its `meta`. Presence/state only — never a score. + * + * @param record - A core REST post/page record. + * @return The corresponding {@link ContentRow}. + */ +function toContentRow( record: SeoPostRecord ): ContentRow { + const meta = record.meta ?? {}; + const customTitle = meta.jetpack_seo_html_title ?? ''; + const description = meta.advanced_seo_description ?? ''; + + return { + id: record.id, + title: decodeEntities( record.title?.rendered ?? '' ), + link: record.link ?? '', + // Core REST doesn't expose the wp-admin edit URL on the post resource, + // so derive it from the post ID (the canonical Gutenberg editor path). + editLink: `post.php?post=${ record.id }&action=edit`, + type: record.type ?? '', + status: record.status ?? '', + customTitle, + description, + schemaType: toSchemaType( meta.jetpack_seo_schema_type ), + noindex: !! meta.jetpack_seo_noindex, + hasCustomTitle: customTitle !== '', + hasDescription: description !== '', + }; +} + +/** + * Fetch the Content tab's post/page list from WordPress core REST, with + * server-side pagination, search, and title sorting driven by DataViews view + * state. Wraps `useEntityRecords( 'postType', postType, query )`; the SEO meta + * comes back inside each record's `meta` object via the registered + * `show_in_rest` post meta (no custom endpoint). + * + * @param args - The selected post type plus DataViews paging/search/sort state. + * @return The mapped rows plus core-data's pagination + loading state. + */ +export default function useSeoPosts( args: UseSeoPostsArgs ): UseSeoPostsReturn { + const { postType, page, perPage, search, orderby, order } = args; + + const query = useMemo( () => { + const queryArgs: Record< string, unknown > = { + context: 'edit', + _fields: POST_FIELDS, + page, + per_page: perPage, + orderby: orderby || 'title', + order: order || 'asc', + // Authoring/audit view: include drafts and other non-published + // statuses the current user can see, not just published content. + status: [ 'publish', 'future', 'draft', 'pending', 'private' ], + }; + if ( search ) { + queryArgs.search = search; + } + return queryArgs; + }, [ page, perPage, search, orderby, order ] ); + + const { + records: rawRecords, + hasResolved, + totalItems, + totalPages, + } = useEntityRecords< SeoPostRecord >( 'postType', postType, query ); + + const items = useMemo( () => ( rawRecords || [] ).map( toContentRow ), [ rawRecords ] ); + + return { + items, + totalItems: totalItems ?? 0, + totalPages: totalPages ?? 0, + isLoading: ! hasResolved, + }; +} diff --git a/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx b/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx new file mode 100644 index 000000000000..f97d2411e0b0 --- /dev/null +++ b/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx @@ -0,0 +1,217 @@ +/* eslint-disable react/jsx-no-bind */ + +import { + Button, + Modal, + SelectControl, + TextControl, + TextareaControl, + ToggleControl, +} from '@wordpress/components'; +import { store as coreStore, useEntityRecord } from '@wordpress/core-data'; +import { useDispatch } from '@wordpress/data'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import SerpPreview from './serp-preview'; +import type { + ContentPostType, + ContentRow, + SchemaType, + SeoPostMeta, +} from '../../data/content-types'; +import type { FC } from 'react'; + +// Single snackbar id reused across a save so "Saving…" is replaced in place by +// "SEO updated." (or an error) — mirrors the Settings page's two-stage toast. +const SAVE_NOTICE_ID = 'jetpack-seo-content-save'; + +// Pre-resolved schema-type options so the production minifier can't fold an +// adjacent `cond ? __(A) : __(B)` into `__(cond ? A : B)`, which breaks i18n +// extraction. See feedback_i18n_ternary_minifier_fold. +const SCHEMA_OPTIONS: Array< { value: SchemaType; label: string } > = [ + { value: '', label: __( 'Default', 'jetpack-seo' ) }, + { value: 'article', label: __( 'Article', 'jetpack-seo' ) }, + { value: 'faq', label: __( 'FAQ', 'jetpack-seo' ) }, +]; + +interface Props { + // The row that opened the modal (table-loaded values are the initial state). + row: ContentRow; + // The core endpoint to save through: 'post' or 'page'. + postType: ContentPostType; + onClose: () => void; +} + +// The editable subset of SEO meta the modal owns. +type EditableMeta = Pick< + SeoPostMeta, + | 'advanced_seo_description' + | 'jetpack_seo_html_title' + | 'jetpack_seo_noindex' + | 'jetpack_seo_schema_type' +>; + +/** + * Edit one post's SEO fields. Loads the live record via core-data + * (`useEntityRecord`) and saves the post's `meta` through + * `editEntityRecord` → `saveEditedEntityRecord( 'postType', type, id )`. + * No custom endpoint. The SERP preview updates live as fields change. + * + * @param props - Component props. + * @param props.row - The row that opened the modal (initial field values). + * @param props.postType - The core endpoint to save through ('post' | 'page'). + * @param props.onClose - Called to dismiss the modal. + * @return The edit-SEO modal. + */ +const EditSeoModal: FC< Props > = ( { row, postType, onClose } ) => { + const { record, isResolving } = useEntityRecord( 'postType', postType, row.id ); + const { editEntityRecord, saveEditedEntityRecord } = useDispatch( coreStore ); + const { createInfoNotice, createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + const [ isSaving, setIsSaving ] = useState( false ); + + // Local form state, seeded from the table row so fields are populated before + // the live record resolves, then reconciled once core-data returns `meta`. + const [ local, setLocal ] = useState< EditableMeta >( () => ( { + advanced_seo_description: row.description, + jetpack_seo_html_title: row.customTitle, + jetpack_seo_noindex: row.noindex, + jetpack_seo_schema_type: row.schemaType, + } ) ); + + const recordMeta = ( record as { meta?: Partial< SeoPostMeta > } | undefined )?.meta; + useEffect( () => { + if ( ! recordMeta ) { + return; + } + setLocal( { + advanced_seo_description: recordMeta.advanced_seo_description ?? '', + jetpack_seo_html_title: recordMeta.jetpack_seo_html_title ?? '', + jetpack_seo_noindex: !! recordMeta.jetpack_seo_noindex, + jetpack_seo_schema_type: + recordMeta.jetpack_seo_schema_type === 'article' || + recordMeta.jetpack_seo_schema_type === 'faq' + ? recordMeta.jetpack_seo_schema_type + : '', + } ); + }, [ recordMeta ] ); + + const setField = useCallback( + ( patch: Partial< EditableMeta > ) => setLocal( state => ( { ...state, ...patch } ) ), + [] + ); + + const onSave = useCallback( async () => { + setIsSaving( true ); + createInfoNotice( __( 'Saving…', 'jetpack-seo' ), { + id: SAVE_NOTICE_ID, + type: 'snackbar', + isDismissible: false, + } ); + try { + // Stage the meta edit, then persist it. core-data merges `meta`, so we + // only send the four SEO keys, leaving any other post meta untouched. + editEntityRecord( 'postType', postType, row.id, { meta: local } ); + await saveEditedEntityRecord( 'postType', postType, row.id ); + createSuccessNotice( __( 'SEO updated.', 'jetpack-seo' ), { + id: SAVE_NOTICE_ID, + type: 'snackbar', + } ); + onClose(); + } catch ( error ) { + createErrorNotice( + ( error as { message?: string } )?.message ?? + __( 'Could not save. Please try again.', 'jetpack-seo' ), + { id: SAVE_NOTICE_ID, type: 'snackbar' } + ); + } finally { + setIsSaving( false ); + } + }, [ + createErrorNotice, + createInfoNotice, + createSuccessNotice, + editEntityRecord, + local, + onClose, + postType, + row.id, + saveEditedEntityRecord, + ] ); + + const postTitle = useMemo( () => { + const rendered = ( record as { title?: { rendered?: string } } | undefined )?.title?.rendered; + return rendered ? decodeEntities( rendered ) : row.title; + }, [ record, row.title ] ); + + const permalink = ( record as { link?: string } | undefined )?.link ?? row.link; + + return ( + +
+ setField( { jetpack_seo_html_title: next } ) } + disabled={ isResolving } + __next40pxDefaultSize + __nextHasNoMarginBottom + /> + setField( { advanced_seo_description: next } ) } + rows={ 3 } + disabled={ isResolving } + __nextHasNoMarginBottom + /> + setField( { jetpack_seo_schema_type: next as SchemaType } ) } + disabled={ isResolving } + __next40pxDefaultSize + __nextHasNoMarginBottom + /> + setField( { jetpack_seo_noindex: next } ) } + disabled={ isResolving } + __nextHasNoMarginBottom + /> + +
+
+ + +
+
+ ); +}; + +export default EditSeoModal; diff --git a/projects/packages/seo/_inc/screens/content/index.tsx b/projects/packages/seo/_inc/screens/content/index.tsx new file mode 100644 index 000000000000..19af098f82a4 --- /dev/null +++ b/projects/packages/seo/_inc/screens/content/index.tsx @@ -0,0 +1,239 @@ +import { DataViews } from '@wordpress/dataviews'; +import { useCallback, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Badge, Link } from '@wordpress/ui'; +import useSeoPosts from '../../data/use-seo-posts'; +import EditSeoModal from './edit-seo-modal'; +import './style.scss'; +import type { ContentPostType, ContentRow } from '../../data/content-types'; +import type { Action, Field, Operator, View } from '@wordpress/dataviews'; +import type { FC } from 'react'; + +// Filter field ids that don't map to a server query param. Post type switches +// the core endpoint; schema/description are post-meta the core list can't query +// server-side, so they filter the already-loaded page client-side. +const POST_TYPE_FIELD = 'postType'; +const SCHEMA_FIELD = 'schemaType'; +const DESCRIPTION_FIELD = 'description'; + +// Pre-resolved labels so the production minifier can't fold an adjacent +// `cond ? __(A) : __(B)` into `__(cond ? A : B)`, which breaks i18n +// extraction. See feedback_i18n_ternary_minifier_fold. +const articleLabel = __( 'Article', 'jetpack-seo' ); +const faqLabel = __( 'FAQ', 'jetpack-seo' ); +const setLabel = __( 'Set', 'jetpack-seo' ); +const notSetLabel = __( 'Not set', 'jetpack-seo' ); +const visibleLabel = __( 'Visible', 'jetpack-seo' ); +const hiddenLabel = __( 'Hidden', 'jetpack-seo' ); +const noTitleLabel = __( '(no title)', 'jetpack-seo' ); + +const DEFAULT_VIEW: View = { + type: 'table', + perPage: 20, + page: 1, + search: '', + sort: { field: 'title', direction: 'asc' }, + titleField: 'title', + fields: [ 'schema', 'metaDescription', 'searchVisibility' ], + filters: [], +}; + +const defaultLayouts = { table: {} }; + +/** + * Map a schema-type value to its display label. `—` when no override is set. + * + * @param schemaType - The post's schema-type meta value. + * @return The label to render in the Schema column. + */ +function schemaLabel( schemaType: ContentRow[ 'schemaType' ] ): string { + if ( schemaType === 'article' ) { + return articleLabel; + } + if ( schemaType === 'faq' ) { + return faqLabel; + } + return '—'; +} + +/** + * Content tab: a DataViews list of posts/pages backed by WordPress core REST, + * reporting the factual *state* of each post's SEO fields (never a score). + * Pagination, search, and title sorting are server-side via core-data; the + * post-type filter switches the core endpoint; schema and meta-description + * filters narrow the loaded page client-side (core can't query post meta). + * + * @return The Content tab content. + */ +const ContentScreen: FC = () => { + const [ view, setView ] = useState< View >( DEFAULT_VIEW ); + const [ editing, setEditing ] = useState< ContentRow | null >( null ); + + // The post-type filter lives in view.filters so it shares the DataViews + // filter UI; default to posts. + const postType: ContentPostType = useMemo( () => { + const value = view.filters?.find( filter => filter.field === POST_TYPE_FIELD )?.value; + return value === 'page' ? 'page' : 'post'; + }, [ view.filters ] ); + + const { items, totalItems, totalPages, isLoading } = useSeoPosts( { + postType, + page: view.page || 1, + perPage: view.perPage || 20, + search: view.search, + // Title is the only sortable column; only its direction varies. + orderby: 'title', + order: view.sort?.direction === 'desc' ? 'desc' : 'asc', + } ); + + // Client-side narrowing for the two post-meta filters core REST can't query. + const data = useMemo( () => { + const schemaValue = view.filters?.find( filter => filter.field === SCHEMA_FIELD )?.value; + const descriptionValue = view.filters?.find( filter => filter.field === DESCRIPTION_FIELD ) + ?.value; + + return items.filter( item => { + if ( schemaValue !== undefined && schemaValue !== '' && item.schemaType !== schemaValue ) { + // Schema filter value 'default' targets the no-override rows. + if ( ! ( schemaValue === 'default' && item.schemaType === '' ) ) { + return false; + } + } + if ( descriptionValue === 'set' && ! item.hasDescription ) { + return false; + } + if ( descriptionValue === 'not_set' && item.hasDescription ) { + return false; + } + return true; + } ); + }, [ items, view.filters ] ); + + const fields: Field< ContentRow >[] = useMemo( + () => [ + { + id: 'title', + label: __( 'Title', 'jetpack-seo' ), + enableHiding: false, + getValue: ( { item } ) => item.title, + render: ( { item } ) => { item.title || noTitleLabel }, + }, + { + id: POST_TYPE_FIELD, + label: __( 'Type', 'jetpack-seo' ), + elements: [ + { value: 'post', label: __( 'Posts', 'jetpack-seo' ) }, + { value: 'page', label: __( 'Pages', 'jetpack-seo' ) }, + ], + filterBy: { operators: [ 'is' ] as Operator[], isPrimary: true }, + enableSorting: false, + enableHiding: false, + render: () => null, + getValue: () => null, + }, + { + id: 'schema', + label: __( 'Schema', 'jetpack-seo' ), + enableSorting: false, + getValue: ( { item } ) => item.schemaType, + render: ( { item } ) => schemaLabel( item.schemaType ), + }, + { + id: SCHEMA_FIELD, + label: __( 'Schema type', 'jetpack-seo' ), + elements: [ + { value: 'default', label: __( 'Default', 'jetpack-seo' ) }, + { value: 'article', label: articleLabel }, + { value: 'faq', label: faqLabel }, + ], + filterBy: { operators: [ 'is' ] as Operator[] }, + enableSorting: false, + enableHiding: false, + render: () => null, + getValue: () => null, + }, + { + id: 'metaDescription', + label: __( 'Meta description', 'jetpack-seo' ), + enableSorting: false, + getValue: ( { item } ) => ( item.hasDescription ? 'set' : 'not_set' ), + render: ( { item } ) => ( + + { item.hasDescription ? setLabel : notSetLabel } + + ), + }, + { + id: DESCRIPTION_FIELD, + label: __( 'Meta description set', 'jetpack-seo' ), + elements: [ + { value: 'set', label: setLabel }, + { value: 'not_set', label: notSetLabel }, + ], + filterBy: { operators: [ 'is' ] as Operator[] }, + enableSorting: false, + enableHiding: false, + render: () => null, + getValue: () => null, + }, + { + id: 'searchVisibility', + label: __( 'Search', 'jetpack-seo' ), + enableSorting: false, + getValue: ( { item } ) => ( item.noindex ? 'hidden' : 'visible' ), + render: ( { item } ) => ( + + { item.noindex ? hiddenLabel : visibleLabel } + + ), + }, + ], + [] + ); + + const actions: Action< ContentRow >[] = useMemo( + () => [ + { + id: 'edit-seo', + label: __( 'Edit SEO', 'jetpack-seo' ), + isPrimary: true, + supportsBulk: false, + callback: ( rows: ContentRow[] ) => { + const [ row ] = rows; + if ( row ) { + setEditing( row ); + } + }, + }, + ], + [] + ); + + const paginationInfo = useMemo( + () => ( { totalItems, totalPages } ), + [ totalItems, totalPages ] + ); + + const onChangeView = useCallback( ( next: View ) => setView( next ), [] ); + const getItemId = useCallback( ( item: ContentRow ) => String( item.id ), [] ); + const closeModal = useCallback( () => setEditing( null ), [] ); + + return ( +
+ [] } + view={ view } + onChangeView={ onChangeView } + paginationInfo={ paginationInfo } + isLoading={ isLoading } + getItemId={ getItemId as ( item: unknown ) => string } + defaultLayouts={ defaultLayouts } + actions={ actions as Action< unknown >[] } + /> + { editing && } +
+ ); +}; + +export default ContentScreen; diff --git a/projects/packages/seo/_inc/screens/content/serp-preview.tsx b/projects/packages/seo/_inc/screens/content/serp-preview.tsx new file mode 100644 index 000000000000..8e9ac8d50b76 --- /dev/null +++ b/projects/packages/seo/_inc/screens/content/serp-preview.tsx @@ -0,0 +1,66 @@ +import { __ } from '@wordpress/i18n'; +import type { FC } from 'react'; + +interface Props { + // The post's permalink — used to render the breadcrumb/URL line. + link: string; + // The post title, used as the headline fallback when no custom SEO title. + postTitle: string; + // The custom SEO title (`jetpack_seo_html_title`), if set. + customTitle: string; + // The meta description (`advanced_seo_description`), if set. + description: string; +} + +/** + * Turn a permalink into a Google-style breadcrumb line, e.g. + * `example.com › blog › my-post`. Falls back to the raw link on a parse error. + * + * @param link - The post permalink. + * @return The breadcrumb string. + */ +function toBreadcrumb( link: string ): string { + try { + const url = new URL( link ); + const segments = url.pathname.split( '/' ).filter( Boolean ); + return [ url.hostname, ...segments ].join( ' › ' ); + } catch { + return link; + } +} + +/** + * A small, hand-rolled Google search-result snippet: breadcrumb/URL line, blue + * title, gray description. Updates live as the edit modal's fields change. The + * title falls back to the post title when no custom SEO title is set; the + * description is shown only when set. Intentionally not `@automattic/social-previews`. + * + * @param props - Component props. + * @param props.link - The post permalink (breadcrumb/URL line). + * @param props.postTitle - The post title, used as the headline fallback. + * @param props.customTitle - The custom SEO title, if set. + * @param props.description - The meta description, if set. + * @return The SERP preview snippet. + */ +const SerpPreview: FC< Props > = ( { link, postTitle, customTitle, description } ) => { + const title = customTitle || postTitle; + + return ( +
+
+ { __( 'Search engine preview', 'jetpack-seo' ) } +
+
+
{ toBreadcrumb( link ) }
+
+ { title || __( '(no title)', 'jetpack-seo' ) } +
+ { description && ( +
{ description }
+ ) } +
+
+ ); +}; + +export default SerpPreview; diff --git a/projects/packages/seo/_inc/screens/content/style.scss b/projects/packages/seo/_inc/screens/content/style.scss new file mode 100644 index 000000000000..fac631f2edc6 --- /dev/null +++ b/projects/packages/seo/_inc/screens/content/style.scss @@ -0,0 +1,56 @@ +// Content tab: full-width DataViews list (wider than the Settings form column) +// to give the table room to breathe, matching the Forms dashboard. +.jetpack-seo-content { + max-inline-size: 1128px; + margin-inline: auto; +} + +// Edit-SEO modal body: stack the fields and the live SERP preview. +.jetpack-seo-content__modal-body { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-lg, 16px); +} + +.jetpack-seo-content__modal-actions { + display: flex; + justify-content: flex-end; + gap: var(--wpds-dimension-gap-sm, 8px); + margin-top: var(--wpds-dimension-gap-lg, 16px); +} + +// Google-style search-result snippet shown live in the edit modal. +.jetpack-seo-serp-preview { + padding: var(--wpds-dimension-padding-md, 12px); + background: var(--wpds-color-bg-surface-neutral, #f6f7f7); + border-radius: var(--wpds-border-radius-md, 4px); +} + +.jetpack-seo-serp-preview__label { + margin-block-end: var(--wpds-dimension-gap-sm, 8px); + color: var(--wpds-color-fg-content-neutral-weak, #757575); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.jetpack-seo-serp-preview__url { + color: var(--wpds-color-fg-content-neutral-weak, #4d5156); + font-size: 12px; + line-height: 1.3; +} + +.jetpack-seo-serp-preview__title { + color: #1a0dab; + font-size: 18px; + line-height: 1.3; + margin-block-start: var(--wpds-dimension-gap-xs, 4px); +} + +.jetpack-seo-serp-preview__description { + color: var(--wpds-color-fg-content-neutral-weak, #4d5156); + font-size: 13px; + line-height: 1.4; + margin-block-start: var(--wpds-dimension-gap-xs, 4px); +} diff --git a/projects/packages/seo/_inc/screens/overview/content-coverage-card.tsx b/projects/packages/seo/_inc/screens/overview/content-coverage-card.tsx new file mode 100644 index 000000000000..a499041fd9a7 --- /dev/null +++ b/projects/packages/seo/_inc/screens/overview/content-coverage-card.tsx @@ -0,0 +1,108 @@ +import { DonutMeter } from '@automattic/jetpack-components'; +import { Button } from '@wordpress/components'; +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Card } from '@wordpress/ui'; +import type { ContentCoverage } from '../../data/overview-types'; +import type { FC } from 'react'; + +interface Props { + data: ContentCoverage; + onManage: () => void; +} + +interface RingProps { + label: string; + segment: number; + total: number; +} + +/** + * One factual coverage ring: a proportion (segment of total) plus the literal + * count beneath it. Deliberately a single neutral fill colour and never + * adaptive — fuller is not "better", it's just how many posts have the field + * set. The admin decides what matters. + * + * @param props - Component props. + * @param props.label - Localized label for the metric. + * @param props.segment - Number of posts with the field set. + * @param props.total - Total published posts/pages. + * @return A labelled coverage ring. + */ +const CoverageRing: FC< RingProps > = ( { label, segment, total } ) => ( +
+ +
+ { sprintf( + /* translators: %1$d: posts with the field set, %2$d: total published posts. */ + __( '%1$d / %2$d', 'jetpack-seo' ), + segment, + total + ) } +
+
{ label }
+
+); + +const ContentCoverageCard: FC< Props > = ( { data, onManage } ) => { + const { total, with_description, with_schema, noindexed } = data; + + return ( + + + { __( 'Content SEO', 'jetpack-seo' ) } + + + { total === 0 ? ( +

{ __( 'No published posts or pages yet.', 'jetpack-seo' ) }

+ ) : ( + <> +
+ + +
+ { noindexed > 0 && ( +

+ { sprintf( + /* translators: %d: number of published posts/pages hidden from search engines. */ + _n( + '%d post hidden from search engines', + '%d posts hidden from search engines', + noindexed, + 'jetpack-seo' + ), + noindexed + ) } +

+ ) } + + ) } +
+ +
+
+
+ ); +}; + +export default ContentCoverageCard; diff --git a/projects/packages/seo/_inc/screens/overview/index.tsx b/projects/packages/seo/_inc/screens/overview/index.tsx index 91311989b897..42c6063cb2d3 100644 --- a/projects/packages/seo/_inc/screens/overview/index.tsx +++ b/projects/packages/seo/_inc/screens/overview/index.tsx @@ -5,6 +5,7 @@ import { __ } from '@wordpress/i18n'; import { useNavigate } from '@wordpress/route'; import { Notice } from '@wordpress/ui'; import getOverview from '../../data/get-overview'; +import ContentCoverageCard from './content-coverage-card'; import SiteVerificationCard from './site-verification-card'; import SiteVisibilityCard from './site-visibility-card'; import './style.scss'; @@ -28,6 +29,15 @@ const OverviewScreen: FC = () => { [ navigate ] ); + // Deep-link to the Content tab. + const goToContent = useCallback( + () => + navigate( { + search: ( prev: Record< string, unknown > ) => ( { ...prev, tab: 'content' } ), + } as unknown as Parameters< typeof navigate >[ 0 ] ), + [ navigate ] + ); + if ( ! data ) { return ( @@ -57,6 +67,7 @@ const OverviewScreen: FC = () => { data={ data.site_verification } onManage={ () => goToSection( 'verification' ) } /> + ); diff --git a/projects/packages/seo/_inc/screens/overview/style.scss b/projects/packages/seo/_inc/screens/overview/style.scss index f8620e5899ea..185ee21ae77e 100644 --- a/projects/packages/seo/_inc/screens/overview/style.scss +++ b/projects/packages/seo/_inc/screens/overview/style.scss @@ -44,3 +44,34 @@ margin-top: auto; padding-top: var(--wpds-dimension-gap-md, 12px); } + +// Content SEO card: factual coverage rings, side by side. +.jetpack-seo-overview__coverage-rings { + display: flex; + flex-wrap: wrap; + gap: var(--wpds-dimension-gap-lg, 16px); +} + +.jetpack-seo-overview__coverage-ring { + display: flex; + flex: 1 1 0; + flex-direction: column; + align-items: center; + text-align: center; + gap: var(--wpds-dimension-gap-xs, 4px); +} + +.jetpack-seo-overview__coverage-count { + font-weight: 600; +} + +.jetpack-seo-overview__coverage-label { + color: var(--wpds-color-fg-content-neutral-weak, #787c82); + font-size: 12px; +} + +.jetpack-seo-overview__coverage-note { + margin: var(--wpds-dimension-gap-md, 12px) 0 0; + color: var(--wpds-color-fg-content-neutral-weak, #787c82); + font-size: 12px; +} diff --git a/projects/packages/seo/package.json b/projects/packages/seo/package.json index 34d76bfb20cf..5149fe0e78cf 100644 --- a/projects/packages/seo/package.json +++ b/projects/packages/seo/package.json @@ -42,8 +42,11 @@ "@automattic/jetpack-wp-build-polyfills": "workspace:*", "@wordpress/api-fetch": "7.46.0", "@wordpress/components": "33.1.0", + "@wordpress/core-data": "7.46.0", "@wordpress/data": "10.46.0", + "@wordpress/dataviews": "14.3.0", "@wordpress/element": "6.46.0", + "@wordpress/html-entities": "4.46.0", "@wordpress/i18n": "6.19.0", "@wordpress/icons": "13.1.0", "@wordpress/notices": "5.46.0", diff --git a/projects/packages/seo/routes/index/package.json b/projects/packages/seo/routes/index/package.json index b54894f9a3ec..eb1b93bc6407 100644 --- a/projects/packages/seo/routes/index/package.json +++ b/projects/packages/seo/routes/index/package.json @@ -8,8 +8,11 @@ "@types/react": "18.3.28", "@wordpress/api-fetch": "7.46.0", "@wordpress/components": "33.1.0", + "@wordpress/core-data": "7.46.0", "@wordpress/data": "10.46.0", + "@wordpress/dataviews": "14.3.0", "@wordpress/element": "6.46.0", + "@wordpress/html-entities": "4.46.0", "@wordpress/i18n": "6.19.0", "@wordpress/icons": "13.1.0", "@wordpress/notices": "5.46.0", diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index 14707b3a2e6f..bc3ba91800c6 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -300,12 +300,74 @@ public static function get_overview_data() { 'yandex' => ! empty( $codes['yandex'] ), 'facebook' => ! empty( $codes['facebook'] ), ), + 'content_coverage' => self::get_content_coverage(), 'plan' => array( 'seo_enabled_for_site' => $seo_enabled, ), ); } + /** + * Factual content-coverage counts for the Overview card: how many published + * posts/pages have each SEO field set. State, not a score — the card shows + * proportions + raw counts and lets the admin decide what matters. + * + * @return array{total:int,with_description:int,with_schema:int,noindexed:int} + */ + private static function get_content_coverage() { + $post_types = array( 'post', 'page' ); + + $total = 0; + foreach ( $post_types as $post_type ) { + $counts = wp_count_posts( $post_type ); + $total += isset( $counts->publish ) ? (int) $counts->publish : 0; + } + + return array( + 'total' => $total, + 'with_description' => self::count_published_with_meta( $post_types, Jetpack_SEO_Posts::DESCRIPTION_META_KEY ), + 'with_schema' => self::count_published_with_meta( $post_types, Jetpack_SEO_Posts::SCHEMA_TYPE_META_KEY ), + 'noindexed' => self::count_published_with_meta( $post_types, Jetpack_SEO_Posts::NOINDEX_META_KEY, '1' ), + ); + } + + /** + * Count published posts/pages whose meta is set. With no `$value`, counts a + * non-empty string meta; with a `$value`, counts an exact match. + * + * @param string[] $post_types Post types to count across. + * @param string $meta_key Meta key to test. + * @param string|null $value Exact value to match, or null for "non-empty". + * @return int + */ + private static function count_published_with_meta( $post_types, $meta_key, $value = null ) { + $clause = null === $value + ? array( + 'key' => $meta_key, + 'value' => '', + 'compare' => '!=', + ) + : array( + 'key' => $meta_key, + 'value' => $value, + ); + + $query = new WP_Query( + array( + 'post_type' => $post_types, + 'post_status' => 'publish', + 'posts_per_page' => 1, + 'fields' => 'ids', + 'update_post_meta_cache' => false, + 'update_post_term_cache' => false, + // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- Overview snapshot; one count query per metric on the SEO page only. + 'meta_query' => array( $clause ), + ) + ); + + return (int) $query->found_posts; + } + /** * Expose the core `blog_public` option to the REST settings endpoint. * From da04334b4ca8344becc61eb3826f13f626188852 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 13:43:35 -0500 Subject: [PATCH 04/11] Jetpack SEO: changelog for the Content tab PR Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/packages/seo/changelog/add-content-tab | 4 ++++ .../jetpack/changelog/add-seo-schema-type-and-content-columns | 4 ++++ 2 files changed, 8 insertions(+) create mode 100644 projects/packages/seo/changelog/add-content-tab create mode 100644 projects/plugins/jetpack/changelog/add-seo-schema-type-and-content-columns diff --git a/projects/packages/seo/changelog/add-content-tab b/projects/packages/seo/changelog/add-content-tab new file mode 100644 index 000000000000..4849b828b4c0 --- /dev/null +++ b/projects/packages/seo/changelog/add-content-tab @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a Content tab (a DataViews list of posts/pages backed by core REST, with per-post SEO editing and a SERP preview), a Content SEO coverage card on the Overview, and front-end JSON-LD schema (Article / FAQ). diff --git a/projects/plugins/jetpack/changelog/add-seo-schema-type-and-content-columns b/projects/plugins/jetpack/changelog/add-seo-schema-type-and-content-columns new file mode 100644 index 000000000000..ff367ceffcd4 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-seo-schema-type-and-content-columns @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +SEO: add a per-post schema type (jetpack_seo_schema_type meta + a block-editor Schema type control) and factual SEO columns (schema, meta description, search visibility) on post-list tables. From 6b63b6d87a134ed0a7392ec906d827980021fc0f Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 14:41:29 -0500 Subject: [PATCH 05/11] =?UTF-8?q?Jetpack=20SEO:=20fix=20critical=20error?= =?UTF-8?q?=20on=20the=20SEO=20page=20=E2=80=94=20undeclared=20Jetpack=5FS?= =?UTF-8?q?EO=5FPosts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit get_content_coverage() referenced Jetpack_SEO_Posts:: constants, but the package Initializer has no `use Jetpack_SEO_Posts;` import (only Jetpack_SEO_Utils), so in this namespace it resolved to a non-existent Automattic\\Jetpack\\SEO\\Jetpack_SEO_Posts and fataled on every SEO-page load. php -l/phpcs can't see it; phan would have (the guarded Utils calls carry @phan-suppress; mine didn't). Fix: mirror the three meta keys as local package constants (they're stable strings) so coverage counting doesn't depend on the plugin class at all. Also hardened Schema_Builder::emit() to guard on Jetpack_SEO_Posts + added the matching @phan-suppress annotations. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../packages/seo/src/class-initializer.php | 18 +++++++++++++++--- .../packages/seo/src/class-schema-builder.php | 7 ++++++- 2 files changed, 21 insertions(+), 4 deletions(-) diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index bc3ba91800c6..dd16352de413 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -69,6 +69,18 @@ class Initializer { */ const SCRIPT_DATA_KEY = 'seo'; + /** + * Post-meta keys mirrored from `Jetpack_SEO_Posts` (in plugins/jetpack). + * Duplicated here as literals on purpose: that plugin class is NOT reliably + * loaded in this package's admin context (the `Jetpack_SEO_Utils` + * `class_exists` guard in `get_overview_data()` is there for the same + * reason), so referencing its constants would fatal. Content-coverage + * counting only needs the key strings, which are stable. + */ + const META_DESCRIPTION = 'advanced_seo_description'; + const META_SCHEMA_TYPE = 'jetpack_seo_schema_type'; + const META_NOINDEX = 'jetpack_seo_noindex'; + /** * Whether the package has been initialized. * @@ -325,9 +337,9 @@ private static function get_content_coverage() { return array( 'total' => $total, - 'with_description' => self::count_published_with_meta( $post_types, Jetpack_SEO_Posts::DESCRIPTION_META_KEY ), - 'with_schema' => self::count_published_with_meta( $post_types, Jetpack_SEO_Posts::SCHEMA_TYPE_META_KEY ), - 'noindexed' => self::count_published_with_meta( $post_types, Jetpack_SEO_Posts::NOINDEX_META_KEY, '1' ), + 'with_description' => self::count_published_with_meta( $post_types, self::META_DESCRIPTION ), + 'with_schema' => self::count_published_with_meta( $post_types, self::META_SCHEMA_TYPE ), + 'noindexed' => self::count_published_with_meta( $post_types, self::META_NOINDEX, '1' ), ); } diff --git a/projects/packages/seo/src/class-schema-builder.php b/projects/packages/seo/src/class-schema-builder.php index ec13540e579a..375fee36c5d8 100644 --- a/projects/packages/seo/src/class-schema-builder.php +++ b/projects/packages/seo/src/class-schema-builder.php @@ -47,7 +47,10 @@ public static function init() { * @return void */ public static function emit() { - if ( ! class_exists( 'Jetpack_SEO_Utils' ) || ! Jetpack_SEO_Utils::is_enabled_jetpack_seo() ) { + // Both plugin classes must be loaded — they're not guaranteed in every + // context, and build_for_post() 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; } @@ -81,6 +84,7 @@ private static function build_for_post( $post ) { 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 ); @@ -132,6 +136,7 @@ private static function build_article( WP_Post $post ) { $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 From 62c20d9016cdbec9167899cfb93db47eea103513 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 15:17:37 -0500 Subject: [PATCH 06/11] Jetpack SEO: qualify \WP_Query in count_published_with_meta() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same namespace-resolution fatal as the previous fix, one line further down the same call chain: `new WP_Query` in this namespaced file (no use import, no leading backslash) resolved to Automattic\\Jetpack\\SEO\\WP_Query and fataled the SEO page. Qualified to \WP_Query. Audited every class reference in the package's namespaced files — all others are use-imported or same-namespace. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/packages/seo/src/class-initializer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index dd16352de413..8c6bda15ebae 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -364,7 +364,7 @@ private static function count_published_with_meta( $post_types, $meta_key, $valu 'value' => $value, ); - $query = new WP_Query( + $query = new \WP_Query( array( 'post_type' => $post_types, 'post_status' => 'publish', From c385898a4b19b018f7acf4bfecad7f63a53587a0 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 16:26:11 -0500 Subject: [PATCH 07/11] Jetpack SEO: Overview/Content refinements from JN testing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Overview layout: move the Content SEO card out of the 2-up grid into a full-width row beneath Site visibility + Site verification. 2. Content edits reflect on the Overview card without a reload: coverage counts lifted to the app root, nudged optimistically (per-metric delta) when a post's SEO is saved on the Content tab. 3. Edit SEO row action now has a pencil icon (more discoverable than a label- only hover action). 4. Drop the 'hidden from search' count from the coverage card; add a Search visibility filter (Visible/Hidden) to the Content tab instead — most content is visible by default, so it's a filter, not a headline stat. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/packages/seo/_inc/app.tsx | 30 +++++++++++-- .../packages/seo/_inc/data/content-types.ts | 8 ++++ .../packages/seo/_inc/data/overview-types.ts | 1 - .../_inc/screens/content/edit-seo-modal.tsx | 16 ++++++- .../seo/_inc/screens/content/index.tsx | 44 +++++++++++++++++-- .../overview/content-coverage-card.tsx | 44 ++++++------------- .../seo/_inc/screens/overview/index.tsx | 13 +++++- .../seo/_inc/screens/overview/style.scss | 8 ++-- .../packages/seo/src/class-initializer.php | 4 +- 9 files changed, 121 insertions(+), 47 deletions(-) diff --git a/projects/packages/seo/_inc/app.tsx b/projects/packages/seo/_inc/app.tsx index e01671505463..77fd9db88a83 100644 --- a/projects/packages/seo/_inc/app.tsx +++ b/projects/packages/seo/_inc/app.tsx @@ -1,14 +1,17 @@ import { AdminPage, ThemeProvider } from '@automattic/jetpack-components'; -import { useCallback } from '@wordpress/element'; +import { useCallback, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { useNavigate, useSearch } from '@wordpress/route'; import { Tabs } from '@wordpress/ui'; +import getOverview from './data/get-overview'; import { useSettingsForm } from './data/use-settings'; import NoticesList from './notices-list'; import ContentScreen from './screens/content'; import OverviewScreen from './screens/overview'; import SettingsScreen from './screens/settings'; import './admin-page-layout.scss'; +import type { CoverageDelta } from './data/content-types'; +import type { ContentCoverage } from './data/overview-types'; import type { FC } from 'react'; type StageSearch = Record< string, unknown > & { tab?: string }; @@ -29,6 +32,27 @@ const App: FC = () => { const navigate = useNavigate(); const settingsForm = useSettingsForm(); + // Coverage counts live here (above the tabs) so a Content-tab edit reflects + // on the Overview card immediately on tab switch, without a reload. Seeded + // from the server bootstrap; nudged optimistically when a post's SEO saves. + const [ coverage, setCoverage ] = useState< ContentCoverage | null >( + () => getOverview()?.content_coverage ?? null + ); + + const onContentSaved = useCallback( + ( delta: CoverageDelta ) => + setCoverage( current => + current + ? { + ...current, + with_description: current.with_description + delta.description, + with_schema: current.with_schema + delta.schema, + } + : current + ), + [] + ); + const onTabChange = useCallback( ( next: string | null ) => { if ( next !== 'overview' && next !== 'settings' && next !== 'content' ) { @@ -65,7 +89,7 @@ const App: FC = () => {
- +
@@ -75,7 +99,7 @@ const App: FC = () => {
- +
diff --git a/projects/packages/seo/_inc/data/content-types.ts b/projects/packages/seo/_inc/data/content-types.ts index 0a090f3dbb16..aa3c70495a44 100644 --- a/projects/packages/seo/_inc/data/content-types.ts +++ b/projects/packages/seo/_inc/data/content-types.ts @@ -42,3 +42,11 @@ export interface PostTypeOption { value: ContentPostType; label: string; } + +// Optimistic adjustment applied to the Overview coverage counts when a post's +// SEO is saved on the Content tab: +1 / -1 / 0 per metric depending on whether +// the field became set or unset. +export interface CoverageDelta { + description: number; + schema: number; +} diff --git a/projects/packages/seo/_inc/data/overview-types.ts b/projects/packages/seo/_inc/data/overview-types.ts index f2efab2fc9f5..5c93972fd08b 100644 --- a/projects/packages/seo/_inc/data/overview-types.ts +++ b/projects/packages/seo/_inc/data/overview-types.ts @@ -21,7 +21,6 @@ export interface ContentCoverage { total: number; with_description: number; with_schema: number; - noindexed: number; } export interface OverviewResponse { diff --git a/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx b/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx index f97d2411e0b0..c25bb4780879 100644 --- a/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx +++ b/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx @@ -18,6 +18,7 @@ import SerpPreview from './serp-preview'; import type { ContentPostType, ContentRow, + CoverageDelta, SchemaType, SeoPostMeta, } from '../../data/content-types'; @@ -42,6 +43,9 @@ interface Props { // The core endpoint to save through: 'post' or 'page'. postType: ContentPostType; onClose: () => void; + // Called after a successful save with the coverage delta to apply to the + // Overview card (so it updates without a page reload). + onSaved: ( delta: CoverageDelta ) => void; } // The editable subset of SEO meta the modal owns. @@ -63,9 +67,10 @@ type EditableMeta = Pick< * @param props.row - The row that opened the modal (initial field values). * @param props.postType - The core endpoint to save through ('post' | 'page'). * @param props.onClose - Called to dismiss the modal. + * @param props.onSaved - Called after a successful save with the coverage delta. * @return The edit-SEO modal. */ -const EditSeoModal: FC< Props > = ( { row, postType, onClose } ) => { +const EditSeoModal: FC< Props > = ( { row, postType, onClose, onSaved } ) => { const { record, isResolving } = useEntityRecord( 'postType', postType, row.id ); const { editEntityRecord, saveEditedEntityRecord } = useDispatch( coreStore ); const { createInfoNotice, createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); @@ -118,6 +123,12 @@ const EditSeoModal: FC< Props > = ( { row, postType, onClose } ) => { id: SAVE_NOTICE_ID, type: 'snackbar', } ); + // Nudge the Overview coverage card to reflect this edit without a reload: + // +1/-1/0 per metric, comparing the row's prior state to what we saved. + onSaved( { + description: Number( local.advanced_seo_description !== '' ) - Number( row.hasDescription ), + schema: Number( local.jetpack_seo_schema_type !== '' ) - Number( row.schemaType !== '' ), + } ); onClose(); } catch ( error ) { createErrorNotice( @@ -135,8 +146,11 @@ const EditSeoModal: FC< Props > = ( { row, postType, onClose } ) => { editEntityRecord, local, onClose, + onSaved, postType, + row.hasDescription, row.id, + row.schemaType, saveEditedEntityRecord, ] ); diff --git a/projects/packages/seo/_inc/screens/content/index.tsx b/projects/packages/seo/_inc/screens/content/index.tsx index 19af098f82a4..9bce22ee234e 100644 --- a/projects/packages/seo/_inc/screens/content/index.tsx +++ b/projects/packages/seo/_inc/screens/content/index.tsx @@ -1,11 +1,12 @@ import { DataViews } from '@wordpress/dataviews'; import { useCallback, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { pencil } from '@wordpress/icons'; import { Badge, Link } from '@wordpress/ui'; import useSeoPosts from '../../data/use-seo-posts'; import EditSeoModal from './edit-seo-modal'; import './style.scss'; -import type { ContentPostType, ContentRow } from '../../data/content-types'; +import type { ContentPostType, ContentRow, CoverageDelta } from '../../data/content-types'; import type { Action, Field, Operator, View } from '@wordpress/dataviews'; import type { FC } from 'react'; @@ -15,6 +16,9 @@ import type { FC } from 'react'; const POST_TYPE_FIELD = 'postType'; const SCHEMA_FIELD = 'schemaType'; const DESCRIPTION_FIELD = 'description'; +// Filter-only field id for search visibility; the displayed column is +// 'searchVisibility'. Filtered client-side on the loaded page like the others. +const SEARCH_FIELD = 'searchFilter'; // Pre-resolved labels so the production minifier can't fold an adjacent // `cond ? __(A) : __(B)` into `__(cond ? A : B)`, which breaks i18n @@ -65,7 +69,13 @@ function schemaLabel( schemaType: ContentRow[ 'schemaType' ] ): string { * * @return The Content tab content. */ -const ContentScreen: FC = () => { +interface Props { + // Called after a successful per-post SEO save, with the coverage delta to + // apply to the Overview card (so it reflects the edit without a reload). + onSaved: ( delta: CoverageDelta ) => void; +} + +const ContentScreen: FC< Props > = ( { onSaved } ) => { const [ view, setView ] = useState< View >( DEFAULT_VIEW ); const [ editing, setEditing ] = useState< ContentRow | null >( null ); @@ -91,6 +101,7 @@ const ContentScreen: FC = () => { const schemaValue = view.filters?.find( filter => filter.field === SCHEMA_FIELD )?.value; const descriptionValue = view.filters?.find( filter => filter.field === DESCRIPTION_FIELD ) ?.value; + const searchValue = view.filters?.find( filter => filter.field === SEARCH_FIELD )?.value; return items.filter( item => { if ( schemaValue !== undefined && schemaValue !== '' && item.schemaType !== schemaValue ) { @@ -105,6 +116,12 @@ const ContentScreen: FC = () => { if ( descriptionValue === 'not_set' && item.hasDescription ) { return false; } + if ( searchValue === 'visible' && item.noindex ) { + return false; + } + if ( searchValue === 'hidden' && ! item.noindex ) { + return false; + } return true; } ); }, [ items, view.filters ] ); @@ -187,6 +204,19 @@ const ContentScreen: FC = () => { ), }, + { + id: SEARCH_FIELD, + label: __( 'Search visibility', 'jetpack-seo' ), + elements: [ + { value: 'visible', label: visibleLabel }, + { value: 'hidden', label: hiddenLabel }, + ], + filterBy: { operators: [ 'is' ] as Operator[] }, + enableSorting: false, + enableHiding: false, + render: () => null, + getValue: () => null, + }, ], [] ); @@ -196,6 +226,7 @@ const ContentScreen: FC = () => { { id: 'edit-seo', label: __( 'Edit SEO', 'jetpack-seo' ), + icon: pencil, isPrimary: true, supportsBulk: false, callback: ( rows: ContentRow[] ) => { @@ -231,7 +262,14 @@ const ContentScreen: FC = () => { defaultLayouts={ defaultLayouts } actions={ actions as Action< unknown >[] } /> - { editing && } + { editing && ( + + ) } ); }; diff --git a/projects/packages/seo/_inc/screens/overview/content-coverage-card.tsx b/projects/packages/seo/_inc/screens/overview/content-coverage-card.tsx index a499041fd9a7..8a4bfcdcac47 100644 --- a/projects/packages/seo/_inc/screens/overview/content-coverage-card.tsx +++ b/projects/packages/seo/_inc/screens/overview/content-coverage-card.tsx @@ -1,6 +1,6 @@ import { DonutMeter } from '@automattic/jetpack-components'; import { Button } from '@wordpress/components'; -import { __, _n, sprintf } from '@wordpress/i18n'; +import { __, sprintf } from '@wordpress/i18n'; import { Card } from '@wordpress/ui'; import type { ContentCoverage } from '../../data/overview-types'; import type { FC } from 'react'; @@ -55,7 +55,7 @@ const CoverageRing: FC< RingProps > = ( { label, segment, total } ) => ( ); const ContentCoverageCard: FC< Props > = ( { data, onManage } ) => { - const { total, with_description, with_schema, noindexed } = data; + const { total, with_description, with_schema } = data; return ( @@ -66,34 +66,18 @@ const ContentCoverageCard: FC< Props > = ( { data, onManage } ) => { { total === 0 ? (

{ __( 'No published posts or pages yet.', 'jetpack-seo' ) }

) : ( - <> -
- - -
- { noindexed > 0 && ( -

- { sprintf( - /* translators: %d: number of published posts/pages hidden from search engines. */ - _n( - '%d post hidden from search engines', - '%d posts hidden from search engines', - noindexed, - 'jetpack-seo' - ), - noindexed - ) } -

- ) } - +
+ + +
) }
+
+
); diff --git a/projects/packages/seo/_inc/screens/overview/style.scss b/projects/packages/seo/_inc/screens/overview/style.scss index 185ee21ae77e..9c51f88efc7a 100644 --- a/projects/packages/seo/_inc/screens/overview/style.scss +++ b/projects/packages/seo/_inc/screens/overview/style.scss @@ -70,8 +70,8 @@ font-size: 12px; } -.jetpack-seo-overview__coverage-note { - margin: var(--wpds-dimension-gap-md, 12px) 0 0; - color: var(--wpds-color-fg-content-neutral-weak, #787c82); - font-size: 12px; +// The Content SEO card sits below the two status cards, spanning the full +// width of its own row. Margin matches the grid gap above it. +.jetpack-seo-overview__content-card { + margin-top: var(--wpds-dimension-gap-lg, 16px); } diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index 8c6bda15ebae..1ed694123fd5 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -79,7 +79,6 @@ class Initializer { */ const META_DESCRIPTION = 'advanced_seo_description'; const META_SCHEMA_TYPE = 'jetpack_seo_schema_type'; - const META_NOINDEX = 'jetpack_seo_noindex'; /** * Whether the package has been initialized. @@ -324,7 +323,7 @@ public static function get_overview_data() { * posts/pages have each SEO field set. State, not a score — the card shows * proportions + raw counts and lets the admin decide what matters. * - * @return array{total:int,with_description:int,with_schema:int,noindexed:int} + * @return array{total:int,with_description:int,with_schema:int} */ private static function get_content_coverage() { $post_types = array( 'post', 'page' ); @@ -339,7 +338,6 @@ private static function get_content_coverage() { 'total' => $total, 'with_description' => self::count_published_with_meta( $post_types, self::META_DESCRIPTION ), 'with_schema' => self::count_published_with_meta( $post_types, self::META_SCHEMA_TYPE ), - 'noindexed' => self::count_published_with_meta( $post_types, self::META_NOINDEX, '1' ), ); } From 6325bcea4f16aa679647064788d7c0e145c80abc Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 18:36:58 -0500 Subject: [PATCH 08/11] Jetpack SEO: fix CI failures + client-side DataViews refactor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - changelog: Type 'added' → 'enhancement' (Changelogger validity) - class-schema-builder.php: cast post_author to int for get_the_author_meta() (Phan PhanTypeMismatchArgument) - use-seo-posts.ts: fetch merged posts+pages client-side (up to 100 each); expose draft/pending/private statuses; drop pagination args - content/index.tsx: use filterSortAndPaginate for client-side filter/sort/paginate; fix ButtonTrigger icon (wrap pencil in ); restore getValue for type/schema/description/visibility filter fields Co-Authored-By: Claude Sonnet 4.6 --- .../packages/seo/_inc/data/use-seo-posts.ts | 95 +++++++------- .../seo/_inc/screens/content/index.tsx | 121 ++++++++---------- .../packages/seo/changelog/add-content-tab | 2 +- .../packages/seo/src/class-schema-builder.php | 2 +- 4 files changed, 101 insertions(+), 119 deletions(-) diff --git a/projects/packages/seo/_inc/data/use-seo-posts.ts b/projects/packages/seo/_inc/data/use-seo-posts.ts index da48b8b8bb06..32f45c088642 100644 --- a/projects/packages/seo/_inc/data/use-seo-posts.ts +++ b/projects/packages/seo/_inc/data/use-seo-posts.ts @@ -1,12 +1,22 @@ import { useEntityRecords } from '@wordpress/core-data'; import { useMemo } from '@wordpress/element'; import { decodeEntities } from '@wordpress/html-entities'; -import type { ContentPostType, ContentRow, SchemaType, SeoPostMeta } from './content-types'; +import type { ContentRow, SchemaType, SeoPostMeta } from './content-types'; // Only request the columns the Content tab renders, plus the SEO meta. Core // REST returns `meta` as an object keyed by the registered meta names. const POST_FIELDS = [ 'id', 'title', 'link', 'type', 'status', 'meta' ].join( ',' ); +// Authoring/audit view: include drafts and other non-published statuses the +// current user can see, not just published content. +const STATUSES = [ 'publish', 'future', 'draft', 'pending', 'private' ]; + +// Core REST caps `per_page` at 100. We request the max for each type and merge +// posts + pages client-side. NOTE: a site with more than 100 posts (or 100 +// pages) won't show the overflow on the Content tab yet — acceptable for now; +// a future iteration can page/virtualize the merged set. +const PER_PAGE = 100; + // The shape of a core REST post/page record, narrowed to what we read. interface SeoPostRecord { id: number; @@ -17,19 +27,8 @@ interface SeoPostRecord { meta?: Partial< SeoPostMeta >; } -export interface UseSeoPostsArgs { - postType: ContentPostType; - page: number; - perPage: number; - search?: string; - orderby?: string; - order?: 'asc' | 'desc'; -} - export interface UseSeoPostsReturn { items: ContentRow[]; - totalItems: number; - totalPages: number; isLoading: boolean; } @@ -75,50 +74,46 @@ function toContentRow( record: SeoPostRecord ): ContentRow { }; } +// A single fixed query shared by both post types, so DataViews can filter, +// sort and paginate the merged set entirely client-side. +const QUERY = { + context: 'edit', + _fields: POST_FIELDS, + per_page: PER_PAGE, + status: STATUSES, +}; + /** - * Fetch the Content tab's post/page list from WordPress core REST, with - * server-side pagination, search, and title sorting driven by DataViews view - * state. Wraps `useEntityRecords( 'postType', postType, query )`; the SEO meta - * comes back inside each record's `meta` object via the registered - * `show_in_rest` post meta (no custom endpoint). + * Fetch the Content tab's posts *and* pages from WordPress core REST and merge + * them into a single list. Each type is fetched once (up to {@link PER_PAGE} + * records) and mapped to a {@link ContentRow}; filtering, sorting and + * pagination happen client-side in the Content screen via + * `filterSortAndPaginate`. The SEO meta comes back inside each record's `meta` + * object via the registered `show_in_rest` post meta (no custom endpoint). * - * @param args - The selected post type plus DataViews paging/search/sort state. - * @return The mapped rows plus core-data's pagination + loading state. + * @return The merged, mapped rows plus a combined loading state. */ -export default function useSeoPosts( args: UseSeoPostsArgs ): UseSeoPostsReturn { - const { postType, page, perPage, search, orderby, order } = args; - - const query = useMemo( () => { - const queryArgs: Record< string, unknown > = { - context: 'edit', - _fields: POST_FIELDS, - page, - per_page: perPage, - orderby: orderby || 'title', - order: order || 'asc', - // Authoring/audit view: include drafts and other non-published - // statuses the current user can see, not just published content. - status: [ 'publish', 'future', 'draft', 'pending', 'private' ], - }; - if ( search ) { - queryArgs.search = search; - } - return queryArgs; - }, [ page, perPage, search, orderby, order ] ); - - const { - records: rawRecords, - hasResolved, - totalItems, - totalPages, - } = useEntityRecords< SeoPostRecord >( 'postType', postType, query ); +export default function useSeoPosts(): UseSeoPostsReturn { + const { records: postRecords, hasResolved: postsResolved } = useEntityRecords< SeoPostRecord >( + 'postType', + 'post', + QUERY + ); + const { records: pageRecords, hasResolved: pagesResolved } = useEntityRecords< SeoPostRecord >( + 'postType', + 'page', + QUERY + ); - const items = useMemo( () => ( rawRecords || [] ).map( toContentRow ), [ rawRecords ] ); + const items = useMemo( + () => [ ...( postRecords || [] ), ...( pageRecords || [] ) ].map( toContentRow ), + [ postRecords, pageRecords ] + ); return { items, - totalItems: totalItems ?? 0, - totalPages: totalPages ?? 0, - isLoading: ! hasResolved, + // Show the loading state until *both* queries have resolved, so the + // table doesn't flash a posts-only list before pages arrive. + isLoading: ! postsResolved || ! pagesResolved, }; } diff --git a/projects/packages/seo/_inc/screens/content/index.tsx b/projects/packages/seo/_inc/screens/content/index.tsx index 9bce22ee234e..317e1b88820d 100644 --- a/projects/packages/seo/_inc/screens/content/index.tsx +++ b/projects/packages/seo/_inc/screens/content/index.tsx @@ -1,4 +1,5 @@ -import { DataViews } from '@wordpress/dataviews'; +import { Icon } from '@wordpress/components'; +import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews'; import { useCallback, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { pencil } from '@wordpress/icons'; @@ -10,16 +11,23 @@ import type { ContentPostType, ContentRow, CoverageDelta } from '../../data/cont import type { Action, Field, Operator, View } from '@wordpress/dataviews'; import type { FC } from 'react'; -// Filter field ids that don't map to a server query param. Post type switches -// the core endpoint; schema/description are post-meta the core list can't query -// server-side, so they filter the already-loaded page client-side. +// Filter field ids. Every filter runs client-side over the merged posts+pages +// set via `filterSortAndPaginate`, matching each row through the field's +// `getValue`. Post type filters on the record's `type`; schema / description / +// search-visibility filter on the derived SEO-meta flags. const POST_TYPE_FIELD = 'postType'; const SCHEMA_FIELD = 'schemaType'; const DESCRIPTION_FIELD = 'description'; // Filter-only field id for search visibility; the displayed column is -// 'searchVisibility'. Filtered client-side on the loaded page like the others. +// 'searchVisibility'. const SEARCH_FIELD = 'searchFilter'; +// Schema filter sentinel for the no-override rows. The schema column's raw +// value for those rows is '' (empty meta); the filter element uses this value +// instead so `filterSortAndPaginate` can match it (it skips empty-string +// filter values), and SCHEMA_FIELD's getValue maps '' → this. +const SCHEMA_DEFAULT = 'default'; + // Pre-resolved labels so the production minifier can't fold an adjacent // `cond ? __(A) : __(B)` into `__(cond ? A : B)`, which breaks i18n // extraction. See feedback_i18n_ternary_minifier_fold. @@ -30,6 +38,7 @@ const notSetLabel = __( 'Not set', 'jetpack-seo' ); const visibleLabel = __( 'Visible', 'jetpack-seo' ); const hiddenLabel = __( 'Hidden', 'jetpack-seo' ); const noTitleLabel = __( '(no title)', 'jetpack-seo' ); +const editSeoLabel = __( 'Edit SEO', 'jetpack-seo' ); const DEFAULT_VIEW: View = { type: 'table', @@ -39,6 +48,7 @@ const DEFAULT_VIEW: View = { sort: { field: 'title', direction: 'asc' }, titleField: 'title', fields: [ 'schema', 'metaDescription', 'searchVisibility' ], + // No post-type filter by default, so both posts and pages show. filters: [], }; @@ -61,11 +71,11 @@ function schemaLabel( schemaType: ContentRow[ 'schemaType' ] ): string { } /** - * Content tab: a DataViews list of posts/pages backed by WordPress core REST, - * reporting the factual *state* of each post's SEO fields (never a score). - * Pagination, search, and title sorting are server-side via core-data; the - * post-type filter switches the core endpoint; schema and meta-description - * filters narrow the loaded page client-side (core can't query post meta). + * Content tab: a DataViews list of posts *and* pages backed by WordPress core + * REST, reporting the factual *state* of each post's SEO fields (never a + * score). The hook fetches both types and merges them; filtering (including the + * post-type filter), sorting and pagination all run client-side over the merged + * set via `filterSortAndPaginate`. * * @return The Content tab content. */ @@ -79,52 +89,7 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { const [ view, setView ] = useState< View >( DEFAULT_VIEW ); const [ editing, setEditing ] = useState< ContentRow | null >( null ); - // The post-type filter lives in view.filters so it shares the DataViews - // filter UI; default to posts. - const postType: ContentPostType = useMemo( () => { - const value = view.filters?.find( filter => filter.field === POST_TYPE_FIELD )?.value; - return value === 'page' ? 'page' : 'post'; - }, [ view.filters ] ); - - const { items, totalItems, totalPages, isLoading } = useSeoPosts( { - postType, - page: view.page || 1, - perPage: view.perPage || 20, - search: view.search, - // Title is the only sortable column; only its direction varies. - orderby: 'title', - order: view.sort?.direction === 'desc' ? 'desc' : 'asc', - } ); - - // Client-side narrowing for the two post-meta filters core REST can't query. - const data = useMemo( () => { - const schemaValue = view.filters?.find( filter => filter.field === SCHEMA_FIELD )?.value; - const descriptionValue = view.filters?.find( filter => filter.field === DESCRIPTION_FIELD ) - ?.value; - const searchValue = view.filters?.find( filter => filter.field === SEARCH_FIELD )?.value; - - return items.filter( item => { - if ( schemaValue !== undefined && schemaValue !== '' && item.schemaType !== schemaValue ) { - // Schema filter value 'default' targets the no-override rows. - if ( ! ( schemaValue === 'default' && item.schemaType === '' ) ) { - return false; - } - } - if ( descriptionValue === 'set' && ! item.hasDescription ) { - return false; - } - if ( descriptionValue === 'not_set' && item.hasDescription ) { - return false; - } - if ( searchValue === 'visible' && item.noindex ) { - return false; - } - if ( searchValue === 'hidden' && ! item.noindex ) { - return false; - } - return true; - } ); - }, [ items, view.filters ] ); + const { items, isLoading } = useSeoPosts(); const fields: Field< ContentRow >[] = useMemo( () => [ @@ -145,8 +110,11 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { filterBy: { operators: [ 'is' ] as Operator[], isPrimary: true }, enableSorting: false, enableHiding: false, + // Filter-only field; not shown as a column. Core REST records + // expose `type` as 'post' | 'page', matching the elements, so + // `filterSortAndPaginate` narrows the merged set. render: () => null, - getValue: () => null, + getValue: ( { item } ) => item.type, }, { id: 'schema', @@ -159,7 +127,7 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { id: SCHEMA_FIELD, label: __( 'Schema type', 'jetpack-seo' ), elements: [ - { value: 'default', label: __( 'Default', 'jetpack-seo' ) }, + { value: SCHEMA_DEFAULT, label: __( 'Default', 'jetpack-seo' ) }, { value: 'article', label: articleLabel }, { value: 'faq', label: faqLabel }, ], @@ -167,7 +135,9 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { enableSorting: false, enableHiding: false, render: () => null, - getValue: () => null, + // Map the no-override value ('') to the filter's sentinel so it + // matches the "Default" element. + getValue: ( { item } ) => ( item.schemaType === '' ? SCHEMA_DEFAULT : item.schemaType ), }, { id: 'metaDescription', @@ -191,7 +161,7 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { enableSorting: false, enableHiding: false, render: () => null, - getValue: () => null, + getValue: ( { item } ) => ( item.hasDescription ? 'set' : 'not_set' ), }, { id: 'searchVisibility', @@ -215,7 +185,7 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { enableSorting: false, enableHiding: false, render: () => null, - getValue: () => null, + getValue: ( { item } ) => ( item.noindex ? 'hidden' : 'visible' ), }, ], [] @@ -225,8 +195,21 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { () => [ { id: 'edit-seo', - label: __( 'Edit SEO', 'jetpack-seo' ), - icon: pencil, + // DataViews 14.3.0's inline primary-action button (ButtonTrigger) + // renders only the action's `label` and ignores the `icon` prop, + // so a raw `icon: pencil` never paints. Wrap the icon in + // per the established Jetpack convention (forms / activity-log), + // and surface it through the label — which ButtonTrigger renders + // as the button's children — so the pencil actually shows. The + // trailing text keeps the action accessible. `label` is typed as + // string-returning, so the node is cast at the array boundary. + label: ( () => ( + <> + + { editSeoLabel } + + ) ) as unknown as () => string, + icon: , isPrimary: true, supportsBulk: false, callback: ( rows: ContentRow[] ) => { @@ -240,9 +223,11 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { [] ); - const paginationInfo = useMemo( - () => ( { totalItems, totalPages } ), - [ totalItems, totalPages ] + // Client-side filter, sort and paginate the merged posts+pages set. Returns + // the page slice plus the pagination totals DataViews needs. + const { data, paginationInfo } = useMemo( + () => filterSortAndPaginate( items, view, fields ), + [ items, view, fields ] ); const onChangeView = useCallback( ( next: View ) => setView( next ), [] ); @@ -265,7 +250,9 @@ const ContentScreen: FC< Props > = ( { onSaved } ) => { { editing && ( diff --git a/projects/packages/seo/changelog/add-content-tab b/projects/packages/seo/changelog/add-content-tab index 4849b828b4c0..786efed7d6ef 100644 --- a/projects/packages/seo/changelog/add-content-tab +++ b/projects/packages/seo/changelog/add-content-tab @@ -1,4 +1,4 @@ Significance: minor -Type: added +Type: enhancement Add a Content tab (a DataViews list of posts/pages backed by core REST, with per-post SEO editing and a SERP preview), a Content SEO coverage card on the Overview, and front-end JSON-LD schema (Article / FAQ). diff --git a/projects/packages/seo/src/class-schema-builder.php b/projects/packages/seo/src/class-schema-builder.php index 375fee36c5d8..6ac708da7c9b 100644 --- a/projects/packages/seo/src/class-schema-builder.php +++ b/projects/packages/seo/src/class-schema-builder.php @@ -127,7 +127,7 @@ private static function build_article( WP_Post $post ) { ), 'author' => array( '@type' => 'Person', - 'name' => get_the_author_meta( 'display_name', $post->post_author ), + 'name' => get_the_author_meta( 'display_name', (int) $post->post_author ), ), ); From b59e8bbe867a1eef4160a9e748627b397903b0fb Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 19:11:57 -0500 Subject: [PATCH 09/11] =?UTF-8?q?Jetpack=20SEO:=20Content=20tab=20?= =?UTF-8?q?=E2=80=94=20published-only=20+=20always-visible=20edit=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restrict Content tab to published posts only (drop draft/pending/future/private). Unpublished content has no SEO impact, and showing it created a count mismatch with the Overview coverage card. - Replace the hover-only DataViews primary action with a rendered 'editAction' column: an EditButton component (useCallback-stable onClick) that is always visible, so users don't need to hover to discover the edit affordance. Co-Authored-By: Claude Sonnet 4.6 --- .../packages/seo/_inc/data/use-seo-posts.ts | 6 +-- .../seo/_inc/screens/content/index.tsx | 53 +++++++------------ 2 files changed, 22 insertions(+), 37 deletions(-) diff --git a/projects/packages/seo/_inc/data/use-seo-posts.ts b/projects/packages/seo/_inc/data/use-seo-posts.ts index 32f45c088642..db69ff49666b 100644 --- a/projects/packages/seo/_inc/data/use-seo-posts.ts +++ b/projects/packages/seo/_inc/data/use-seo-posts.ts @@ -7,9 +7,9 @@ import type { ContentRow, SchemaType, SeoPostMeta } from './content-types'; // REST returns `meta` as an object keyed by the registered meta names. const POST_FIELDS = [ 'id', 'title', 'link', 'type', 'status', 'meta' ].join( ',' ); -// Authoring/audit view: include drafts and other non-published statuses the -// current user can see, not just published content. -const STATUSES = [ 'publish', 'future', 'draft', 'pending', 'private' ]; +// Only published content is indexed by search engines, so only published posts +// are relevant to SEO. Drafts and scheduled posts are excluded. +const STATUSES = [ 'publish' ]; // Core REST caps `per_page` at 100. We request the max for each type and merge // posts + pages client-side. NOTE: a site with more than 100 posts (or 100 diff --git a/projects/packages/seo/_inc/screens/content/index.tsx b/projects/packages/seo/_inc/screens/content/index.tsx index 317e1b88820d..d1f5a72048c2 100644 --- a/projects/packages/seo/_inc/screens/content/index.tsx +++ b/projects/packages/seo/_inc/screens/content/index.tsx @@ -1,4 +1,4 @@ -import { Icon } from '@wordpress/components'; +import { Button } from '@wordpress/components'; import { DataViews, filterSortAndPaginate } from '@wordpress/dataviews'; import { useCallback, useMemo, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; @@ -8,7 +8,7 @@ import useSeoPosts from '../../data/use-seo-posts'; import EditSeoModal from './edit-seo-modal'; import './style.scss'; import type { ContentPostType, ContentRow, CoverageDelta } from '../../data/content-types'; -import type { Action, Field, Operator, View } from '@wordpress/dataviews'; +import type { Field, Operator, View } from '@wordpress/dataviews'; import type { FC } from 'react'; // Filter field ids. Every filter runs client-side over the merged posts+pages @@ -47,13 +47,23 @@ const DEFAULT_VIEW: View = { search: '', sort: { field: 'title', direction: 'asc' }, titleField: 'title', - fields: [ 'schema', 'metaDescription', 'searchVisibility' ], + fields: [ 'schema', 'metaDescription', 'searchVisibility', 'editAction' ], // No post-type filter by default, so both posts and pages show. filters: [], }; const defaultLayouts = { table: {} }; +interface EditButtonProps { + item: ContentRow; + onEdit: ( item: ContentRow ) => void; +} + +const EditButton: FC< EditButtonProps > = ( { item, onEdit } ) => { + const handleClick = useCallback( () => onEdit( item ), [ item, onEdit ] ); + return