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 (
+
+
+ );
+};
+
+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 (
+