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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 5 additions & 4 deletions projects/packages/seo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
42 changes: 37 additions & 5 deletions projects/packages/seo/_inc/app.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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( {
Expand Down Expand Up @@ -58,18 +84,24 @@ const App: FC = () => {
<Tabs.List variant="minimal">
<Tabs.Tab value="overview">{ __( 'Overview', 'jetpack-seo' ) }</Tabs.Tab>
<Tabs.Tab value="settings">{ __( 'Settings', 'jetpack-seo' ) }</Tabs.Tab>
<Tabs.Tab value="content">{ __( 'Content', 'jetpack-seo' ) }</Tabs.Tab>
</Tabs.List>
</div>
<Tabs.Panel value="overview" focusable={ false }>
<div className="jetpack-seo-page-content">
<OverviewScreen />
<OverviewScreen coverage={ coverage } />
</div>
</Tabs.Panel>
<Tabs.Panel value="settings" focusable={ false }>
<div className="jetpack-seo-page-content">
<SettingsScreen form={ settingsForm } />
</div>
</Tabs.Panel>
<Tabs.Panel value="content" focusable={ false }>
<div className="jetpack-seo-page-content">
<ContentScreen onSaved={ onContentSaved } />
</div>
</Tabs.Panel>
</Tabs.Root>
<NoticesList />
</AdminPage>
Expand Down
52 changes: 52 additions & 0 deletions projects/packages/seo/_inc/data/content-types.ts
Original file line number Diff line number Diff line change
@@ -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;
}
7 changes: 7 additions & 0 deletions projects/packages/seo/_inc/data/overview-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
};
Expand Down
119 changes: 119 additions & 0 deletions projects/packages/seo/_inc/data/use-seo-posts.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
Loading
Loading