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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

12 changes: 6 additions & 6 deletions projects/packages/seo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,20 @@

The visibility command center for WordPress sites in the agentic web — a unified wp-admin screen that consolidates SEO, sitemaps, AI discoverability, and site verification settings across all site types (self-hosted, Atomic/WoW, Simple).

This package is being built up across a stacked series of PRs (see #48154 for the split plan). This foundation ships the package scaffold, the admin page, and the Overview screen's Site visibility card; the Settings, Content, and AI tabs and the remaining Overview cards land in follow-up PRs.
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):

## What this foundation provides
- **Overview** — a dashboard with **Site visibility** and **Site verification** cards, each deep-linking into the matching Settings section.
- **Settings** — a tab to configure search-engine indexing, the XML sitemap, post title structure, the front-page description, and site verification codes.

- A standalone wp-admin page registered at `admin.php?page=jetpack-seo`, gated behind the `rsm_jetpack_seo` feature flag (off by default during roll-out) and, when on, the `seo-tools` module being active.
- The **Overview** screen with a single **Site visibility** card (search engines allowed, sitemap active, SEO tools active).
The Per-post SEO (Content) and AI (llms.txt / AI crawlers) tabs land in follow-up PRs.

## 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:** the page is an ES-module bundle. Routing uses [`@wordpress/route`](https://www.npmjs.com/package/@wordpress/route); each route is a `routes/<name>/{route,stage}.tsx` pair. `_inc/app.tsx` wraps the routes in the `AdminPage` chrome from `@automattic/jetpack-components`. UI uses `@wordpress/components`, `@wordpress/ui`, and `@wordpress/icons`.
- **Data:** read-only initial state is bootstrapped server-side onto `window.JetpackScriptData.seo` via the `jetpack_admin_js_script_data` filter (`Initializer::inject_script_data()`) and read synchronously on the client through `@automattic/jetpack-script-data` (`_inc/data/get-overview.ts`). wp-build pages load as ES modules, so `wp_localize_script` can't bootstrap them — the script-data layer is the supported channel. There is no REST controller in this foundation.
- **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).

## Development

Expand Down
91 changes: 68 additions & 23 deletions projects/packages/seo/_inc/app.tsx
Original file line number Diff line number Diff line change
@@ -1,35 +1,80 @@
import { AdminPage, ThemeProvider } from '@automattic/jetpack-components';
import { useCallback } from '@wordpress/element';
import { __ } from '@wordpress/i18n';
import { useNavigate, useSearch } from '@wordpress/route';
import { Tabs } from '@wordpress/ui';
import { useSettingsForm } from './data/use-settings';
import NoticesList from './notices-list';
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';

/**
* Root of the Jetpack SEO admin app.
*
* `@wordpress/build` mounts this as the route's `stage`. It renders the shared
* `AdminPage` chrome (header + footer) and the Overview screen. The screen
* reads its data synchronously from the page bootstrap (`window.JetpackScriptData`),
* so there's no router or async provider to set up here yet — tabs arrive in
* later PRs.
* Root of the Jetpack SEO admin app, mounted by `@wordpress/build` as the
* route's `stage`. Renders the shared `AdminPage` chrome and an Overview /
* Settings tab pair driven by `?tab=`. The Settings form state lives here
* (above the tab panels) so unsaved edits survive switching tabs.
*
* @return The Jetpack SEO admin page.
*/
const App: FC = () => (
<ThemeProvider>
<AdminPage
title="SEO"
subTitle={ __(
'Visibility tools for your site — sitemaps, canonical URLs, and search-engine settings, in one place.',
'jetpack-seo'
) }
showFooter
>
<div className="jetpack-seo-page-content">
<OverviewScreen />
</div>
</AdminPage>
</ThemeProvider>
);
const App: FC = () => {
const search = useSearch( { from: '/' as unknown as never, strict: false } ) as StageSearch;
const activeTab: SeoTab = search.tab === 'settings' ? 'settings' : 'overview';
const navigate = useNavigate();
const settingsForm = useSettingsForm();

const onTabChange = useCallback(
( next: string | null ) => {
if ( next !== 'overview' && next !== 'settings' ) {
return;
}
navigate( {
// Default tab keeps a clean URL (no `?tab=overview`).
search: ( prev: Record< string, unknown > ) => ( {
...prev,
tab: next === 'overview' ? undefined : next,
} ),
} as unknown as Parameters< typeof navigate >[ 0 ] );
},
[ navigate ]
);

return (
<ThemeProvider>
<AdminPage
title="SEO"
subTitle={ __(
'Visibility tools for your site — sitemaps, search-engine settings, and more, in one place.',
'jetpack-seo'
) }
showFooter
>
<Tabs.Root value={ activeTab } onValueChange={ onTabChange }>
<div className="jp-admin-page-tabs jp-admin-page-tabs--minimal">
<Tabs.List variant="minimal">
<Tabs.Tab value="overview">{ __( 'Overview', 'jetpack-seo' ) }</Tabs.Tab>
<Tabs.Tab value="settings">{ __( 'Settings', 'jetpack-seo' ) }</Tabs.Tab>
</Tabs.List>
</div>
<Tabs.Panel value="overview" focusable={ false }>
<div className="jetpack-seo-page-content">
<OverviewScreen />
</div>
</Tabs.Panel>
<Tabs.Panel value="settings" focusable={ false }>
<div className="jetpack-seo-page-content">
<SettingsScreen form={ settingsForm } />
</div>
</Tabs.Panel>
</Tabs.Root>
<NoticesList />
</AdminPage>
</ThemeProvider>
);
};

export default App;
65 changes: 65 additions & 0 deletions projects/packages/seo/_inc/data/build-payload.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Pure helpers that turn the Settings form's local state into the changed-fields
// payloads for the two REST endpoints the SEO Settings tab writes to. Kept free
// of React/WordPress runtime imports so the diffing behavior can be unit-tested
// in isolation (see `test/build-payload.test.ts`).

import { VERIFICATION_KEYS } from './verification-services';
import type { SettingsResponse } from './settings-types';

/**
* Build the changed-fields payload for the Jetpack settings endpoint
* (`/jetpack/v4/settings`) — everything except search-engine visibility, which
* is a WordPress core option handled separately. Only changed fields are
* included so an unchanged save never re-toggles the sitemaps module. The
* endpoint owns validation/sanitization for every key here.
*
* @param baseline - The last-saved server state.
* @param local - The current form state.
* @return The changed-fields payload for `/jetpack/v4/settings`.
*/
export function buildJetpackPayload(
baseline: SettingsResponse,
local: SettingsResponse
): Record< string, unknown > {
const payload: Record< string, unknown > = {};

if ( local.sitemap_active !== baseline.sitemap_active ) {
payload.sitemaps = local.sitemap_active;
}
if ( JSON.stringify( local.title_formats ) !== JSON.stringify( baseline.title_formats ) ) {
payload.advanced_seo_title_formats = local.title_formats;
}
if ( local.front_page_description !== baseline.front_page_description ) {
payload.advanced_seo_front_page_description = local.front_page_description;
}
VERIFICATION_KEYS.forEach( key => {
if ( local.verification[ key ] !== baseline.verification[ key ] ) {
payload[ key ] = local.verification[ key ];
}
} );

return payload;
}

/**
* Build the changed-fields payload for WordPress core settings
* (`/wp/v2/settings`). Search-engine visibility maps to the core `blog_public`
* option (1 = allow indexing, 0 = discourage); the Jetpack settings endpoint
* rejects it, so it round-trips through core REST instead.
*
* @param baseline - The last-saved server state.
* @param local - The current form state.
* @return The changed-fields payload for `/wp/v2/settings`, or `{}` if unchanged.
*/
export function buildCorePayload(
baseline: SettingsResponse,
local: SettingsResponse
): Record< string, unknown > {
const payload: Record< string, unknown > = {};

if ( local.search_engines_visible !== baseline.search_engines_visible ) {
payload.blog_public = local.search_engines_visible ? 1 : 0;
}

return payload;
}
10 changes: 9 additions & 1 deletion projects/packages/seo/_inc/data/overview-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,19 @@ export interface SiteVisibility {
sitemap_active: boolean;
sitemap_url: string;
seo_tools_active: boolean;
front_page_description: string;
}

export interface SiteVerification {
google: boolean;
bing: boolean;
pinterest: boolean;
yandex: boolean;
facebook: boolean;
}

export interface OverviewResponse {
site_visibility: SiteVisibility;
site_verification: SiteVerification;
plan: {
seo_enabled_for_site: boolean;
};
Expand Down
24 changes: 24 additions & 0 deletions projects/packages/seo/_inc/data/settings-types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// Shape of the editable Settings state the server bootstraps onto
// `window.JetpackScriptData.seo.settings` (see `Initializer::get_settings_data()`).
// Writes go through the existing `/jetpack/v4/settings` REST endpoint.

export interface TitleFormatToken {
type: 'string' | 'token';
value: string;
}

export interface SettingsResponse {
front_page_description: string;
title_formats: Record< string, TitleFormatToken[] >;
verification: {
google: string;
bing: string;
pinterest: string;
yandex: string;
facebook: string;
};
search_engines_visible: boolean;
sitemap_active: boolean;
}

export type VerificationKey = keyof SettingsResponse[ 'verification' ];
113 changes: 113 additions & 0 deletions projects/packages/seo/_inc/data/test/build-payload.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
/**
* @jest-environment node
*/
import { buildCorePayload, buildJetpackPayload } from '../build-payload';
import type { SettingsResponse } from '../settings-types';

const makeSettings = ( overrides: Partial< SettingsResponse > = {} ): SettingsResponse => ( {
front_page_description: '',
title_formats: { posts: [ { type: 'token', value: 'site_name' } ] },
verification: { google: '', bing: '', pinterest: '', yandex: '', facebook: '' },
search_engines_visible: true,
sitemap_active: false,
...overrides,
} );

describe( 'buildJetpackPayload', () => {
it( 'returns an empty payload when nothing changed', () => {
const baseline = makeSettings();
expect( buildJetpackPayload( baseline, makeSettings() ) ).toEqual( {} );
} );

it( 'includes only the sitemaps key when the sitemap toggle changed', () => {
const baseline = makeSettings( { sitemap_active: false } );
const local = makeSettings( { sitemap_active: true } );
expect( buildJetpackPayload( baseline, local ) ).toEqual( { sitemaps: true } );
} );

it( 'maps a front-page description change to advanced_seo_front_page_description', () => {
const baseline = makeSettings( { front_page_description: '' } );
const local = makeSettings( { front_page_description: 'Hello.' } );
expect( buildJetpackPayload( baseline, local ) ).toEqual( {
advanced_seo_front_page_description: 'Hello.',
} );
} );

it( 'maps a title-format change to advanced_seo_title_formats', () => {
const baseline = makeSettings( { title_formats: { posts: [] } } );
const local = makeSettings( {
title_formats: { posts: [ { type: 'token', value: 'post_title' } ] },
} );
expect( buildJetpackPayload( baseline, local ) ).toEqual( {
advanced_seo_title_formats: { posts: [ { type: 'token', value: 'post_title' } ] },
} );
} );

it( 'does not emit title_formats when the array is deeply equal', () => {
const formats = { posts: [ { type: 'token' as const, value: 'site_name' } ] };
const baseline = makeSettings( { title_formats: formats } );
// A different object reference with identical contents must not be a diff.
const local = makeSettings( {
title_formats: { posts: [ { type: 'token', value: 'site_name' } ] },
} );
expect( buildJetpackPayload( baseline, local ) ).toEqual( {} );
} );

it( 'includes only the changed verification keys', () => {
const baseline = makeSettings();
const local = makeSettings( {
verification: { google: 'g-code', bing: '', pinterest: '', yandex: 'y-code', facebook: '' },
} );
expect( buildJetpackPayload( baseline, local ) ).toEqual( {
google: 'g-code',
yandex: 'y-code',
} );
} );

it( 'combines every changed Jetpack field in one payload', () => {
const baseline = makeSettings();
const local = makeSettings( {
sitemap_active: true,
front_page_description: 'Desc',
verification: { google: 'g', bing: '', pinterest: '', yandex: '', facebook: '' },
} );
expect( buildJetpackPayload( baseline, local ) ).toEqual( {
sitemaps: true,
advanced_seo_front_page_description: 'Desc',
google: 'g',
} );
} );

it( 'ignores search-engine visibility (that is a core option)', () => {
const baseline = makeSettings( { search_engines_visible: true } );
const local = makeSettings( { search_engines_visible: false } );
expect( buildJetpackPayload( baseline, local ) ).toEqual( {} );
} );
} );

describe( 'buildCorePayload', () => {
it( 'returns an empty payload when visibility is unchanged', () => {
const baseline = makeSettings( { search_engines_visible: true } );
expect(
buildCorePayload( baseline, makeSettings( { search_engines_visible: true } ) )
).toEqual( {} );
} );

it( 'maps allow-indexing to blog_public = 1', () => {
const baseline = makeSettings( { search_engines_visible: false } );
const local = makeSettings( { search_engines_visible: true } );
expect( buildCorePayload( baseline, local ) ).toEqual( { blog_public: 1 } );
} );

it( 'maps discourage-indexing to blog_public = 0', () => {
const baseline = makeSettings( { search_engines_visible: true } );
const local = makeSettings( { search_engines_visible: false } );
expect( buildCorePayload( baseline, local ) ).toEqual( { blog_public: 0 } );
} );

it( 'ignores Jetpack-only fields', () => {
const baseline = makeSettings();
const local = makeSettings( { sitemap_active: true, front_page_description: 'x' } );
expect( buildCorePayload( baseline, local ) ).toEqual( {} );
} );
} );
Loading
Loading