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/README.md b/projects/packages/seo/README.md index 6cd85d5304f3..836d652c1283 100644 --- a/projects/packages/seo/README.md +++ b/projects/packages/seo/README.md @@ -4,18 +4,19 @@ The visibility command center for WordPress sites in the agentic web — a unifi This package is built up across a stacked series of PRs (see #48154 for the split plan). It currently provides, on a wp-admin page registered at `admin.php?page=jetpack-seo` (gated on the `seo-tools` module being active): -- **Overview** — a dashboard with **Site visibility** and **Site verification** cards, each deep-linking into the matching Settings section. +- **Overview** — a dashboard with **Site visibility**, **Site verification**, and **Content SEO** cards. The first two deep-link into the matching Settings sections; Content SEO shows factual coverage rings (custom description, schema type set) with literal counts. - **Settings** — a tab to configure search-engine indexing, the XML sitemap, post title structure, the front-page description, and site verification codes. +- **Content** — a DataViews list of published posts and pages showing each post's SEO state (schema type, meta description set, search visibility). Rows link to the Gutenberg editor and open an inline **Edit SEO** modal (custom title, description, schema type, noindex, SERP preview). Filters for post type, schema type, description state, and search visibility run client-side over the merged set. The Schema type control and SEO columns also appear in the block editor and `edit.php` post-list tables respectively. JSON-LD (Article / FAQ) is emitted on `wp_head`. -The Per-post SEO (Content) and AI (llms.txt / AI crawlers) tabs land in follow-up PRs. +The AI (llms.txt / AI crawlers) tab lands in a follow-up PR. ## Architecture Built as a [`@wordpress/build`](https://www.npmjs.com/package/@wordpress/build) (wp-build) dashboard, the pattern shared by recently-shipped Jetpack admin pages (Podcast, Scan, Forms, Newsletter): - **PHP:** `Automattic\Jetpack\SEO\Initializer` registers the admin menu via `Admin_Menu::add_menu()`, loads wp-build's generated bundle (`build/build.php` + `WP_Build_Polyfills::register()`), and bootstraps the app's initial state. Because the user-facing slug (`jetpack-seo`) differs from wp-build's page slug (`jetpack-seo-dashboard`), the screen id is aliased on `current_screen` so wp-build's auto-generated enqueue callback fires. -- **React:** an ES-module bundle. Routing uses [`@wordpress/route`](https://www.npmjs.com/package/@wordpress/route); the Overview and Settings tabs are `?tab=`-driven panels. `_inc/app.tsx` wraps them in the `AdminPage` chrome from `@automattic/jetpack-components`. UI uses `@wordpress/components`, `@wordpress/ui`, and `@wordpress/icons`. -- **Data:** read-only initial state for both tabs is bootstrapped server-side onto `window.JetpackScriptData.seo.{overview,settings}` via the `jetpack_admin_js_script_data` filter (`Initializer::inject_script_data()`) and read synchronously on the client through `@automattic/jetpack-script-data`. wp-build pages load as ES modules, so `wp_localize_script` can't bootstrap them — the script-data layer is the supported channel. The package registers **no REST controller of its own**: Settings writes reuse the existing `/jetpack/v4/settings` endpoint (and core `/wp/v2/settings` for the `blog_public` search-engine-visibility option). +- **React:** an ES-module bundle. Routing uses [`@wordpress/route`](https://www.npmjs.com/package/@wordpress/route); the Overview, Settings, and Content tabs are `?tab=`-driven panels. `_inc/app.tsx` wraps them in the `AdminPage` chrome from `@automattic/jetpack-components`. UI uses `@wordpress/components`, `@wordpress/ui`, and `@wordpress/icons`. +- **Data:** read-only initial state for the Overview and Settings tabs is bootstrapped server-side onto `window.JetpackScriptData.seo.{overview,settings}` via the `jetpack_admin_js_script_data` filter (`Initializer::inject_script_data()`) and read synchronously on the client through `@automattic/jetpack-script-data`. wp-build pages load as ES modules, so `wp_localize_script` can't bootstrap them — the script-data layer is the supported channel. The Content tab fetches posts and pages live from **core `/wp/v2/posts` and `/wp/v2/pages`** (no custom endpoint), with SEO meta returned via registered `show_in_rest` post meta. Writes in the Edit SEO modal go through the same core post endpoint. The package registers **no REST controller of its own**: Settings writes reuse `/jetpack/v4/settings` (and core `/wp/v2/settings` for `blog_public`). ## Development diff --git a/projects/packages/seo/_inc/app.tsx b/projects/packages/seo/_inc/app.tsx index a4cb0ef27db6..77fd9db88a83 100644 --- a/projects/packages/seo/_inc/app.tsx +++ b/projects/packages/seo/_inc/app.tsx @@ -1,17 +1,21 @@ 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 }; -type SeoTab = 'overview' | 'settings'; +type SeoTab = 'overview' | 'settings' | 'content'; /** * Root of the Jetpack SEO admin app, mounted by `@wordpress/build` as the @@ -23,13 +27,35 @@ 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(); + // 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' ) { + if ( next !== 'overview' && next !== 'settings' && next !== 'content' ) { return; } navigate( { @@ -58,11 +84,12 @@ const App: FC = () => { { __( 'Overview', 'jetpack-seo' ) } { __( 'Settings', 'jetpack-seo' ) } + { __( 'Content', 'jetpack-seo' ) }
- +
@@ -70,6 +97,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..aa3c70495a44 --- /dev/null +++ b/projects/packages/seo/_inc/data/content-types.ts @@ -0,0 +1,52 @@ +// 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; +} + +// 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 72a8272e4dda..5c93972fd08b 100644 --- a/projects/packages/seo/_inc/data/overview-types.ts +++ b/projects/packages/seo/_inc/data/overview-types.ts @@ -17,9 +17,16 @@ export interface SiteVerification { facebook: boolean; } +export interface ContentCoverage { + total: number; + with_description: number; + with_schema: 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..db69ff49666b --- /dev/null +++ b/projects/packages/seo/_inc/data/use-seo-posts.ts @@ -0,0 +1,119 @@ +import { useEntityRecords } from '@wordpress/core-data'; +import { useMemo } from '@wordpress/element'; +import { decodeEntities } from '@wordpress/html-entities'; +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( ',' ); + +// 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 +// 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; + title?: { rendered?: string }; + link?: string; + type?: string; + status?: string; + meta?: Partial< SeoPostMeta >; +} + +export interface UseSeoPostsReturn { + items: ContentRow[]; + 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 !== '', + }; +} + +// 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 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). + * + * @return The merged, mapped rows plus a combined loading state. + */ +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( + () => [ ...( postRecords || [] ), ...( pageRecords || [] ) ].map( toContentRow ), + [ postRecords, pageRecords ] + ); + + return { + items, + // 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/edit-seo-modal.tsx b/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx new file mode 100644 index 000000000000..c25bb4780879 --- /dev/null +++ b/projects/packages/seo/_inc/screens/content/edit-seo-modal.tsx @@ -0,0 +1,231 @@ +/* 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, + CoverageDelta, + 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; + // 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. +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. + * @param props.onSaved - Called after a successful save with the coverage delta. + * @return The edit-SEO modal. + */ +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 ); + 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', + } ); + // 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( + ( 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, + onSaved, + postType, + row.hasDescription, + row.id, + row.schemaType, + 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..d1f5a72048c2 --- /dev/null +++ b/projects/packages/seo/_inc/screens/content/index.tsx @@ -0,0 +1,249 @@ +import { Button } 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'; +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, CoverageDelta } from '../../data/content-types'; +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 +// 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'. +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. +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 editSeoLabel = __( 'Edit SEO', 'jetpack-seo' ); + +const DEFAULT_VIEW: View = { + type: 'table', + perPage: 20, + page: 1, + search: '', + sort: { field: 'title', direction: 'asc' }, + titleField: 'title', + 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 + + + + ); +}; + +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..f2b162a35d77 100644 --- a/projects/packages/seo/_inc/screens/overview/index.tsx +++ b/projects/packages/seo/_inc/screens/overview/index.tsx @@ -5,12 +5,20 @@ 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'; +import type { ContentCoverage } from '../../data/overview-types'; import type { FC } from 'react'; -const OverviewScreen: FC = () => { +interface Props { + // Live coverage counts, lifted to the app root so Content-tab edits reflect + // here on tab switch without a page reload. Falls back to the bootstrap. + coverage: ContentCoverage | null; +} + +const OverviewScreen: FC< Props > = ( { coverage } ) => { const data = getOverview(); const navigate = useNavigate(); @@ -28,6 +36,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 ( @@ -58,6 +75,9 @@ const OverviewScreen: FC = () => { 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..9c51f88efc7a 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; +} + +// 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/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/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 f3512a3e55fb..1ed694123fd5 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -69,6 +69,17 @@ 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'; + /** * Whether the package has been initialized. * @@ -102,6 +113,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 ); @@ -296,12 +311,73 @@ 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} + */ + 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, self::META_DESCRIPTION ), + 'with_schema' => self::count_published_with_meta( $post_types, self::META_SCHEMA_TYPE ), + ); + } + + /** + * 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. * 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..6ac708da7c9b --- /dev/null +++ b/projects/packages/seo/src/class-schema-builder.php @@ -0,0 +1,192 @@ +`: 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() { + // 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; + } + + 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; + } + + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; emit() guards on class_exists. + $override = Jetpack_SEO_Posts::get_post_schema_type( $post ); + $type = '' !== $override ? $override : self::default_schema_for_post( $post ); + + switch ( $type ) { + case 'faq': + return self::build_faq( $post ); + case 'article': + return self::build_article( $post ); + default: + return null; + } + } + + /** + * Default Schema type for a post when the user 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', (int) $post->post_author ), + ), + ); + + $image = get_the_post_thumbnail_url( $post, 'full' ); + if ( $image ) { + $node['image'] = $image; + } + + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Posts lives in plugins/jetpack; emit() guards on class_exists. + $description = Jetpack_SEO_Posts::get_post_description( $post ); + if ( $description ) { + // Cap it: get_post_description() falls back to full post_content, which + // would otherwise dump the whole body into the markup. + $node['description'] = wp_trim_words( wp_strip_all_tags( $description ), self::DESCRIPTION_MAX_WORDS, '' ); + } + + return $node; + } + + /** + * FAQPage JSON-LD, parsed from `core/details` blocks (summary = question, + * rendered content = answer). Returns null when the post has none, so we + * never emit an empty/invalid FAQPage. + * + * @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/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..5da7e1464268 --- /dev/null +++ b/projects/plugins/jetpack/changelog/add-seo-schema-type-and-content-columns @@ -0,0 +1,4 @@ +Significance: minor +Type: enhancement + +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. 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' ); + } +} 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 ), + ); } }