From ab723346443bc2d9b1751fe3ad54f420ec6b0688 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Mon, 1 Jun 2026 18:48:38 -0500 Subject: [PATCH 1/6] Jetpack SEO: reconcile the Settings tab onto the wp-build foundation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-implements PR #49256's Settings tab on the rebuilt (wp-build + script-data) foundation. The old branch was built on the deleted architecture (webpack, react-router, a custom REST controller, the useSimple* hooks, CSS modules), so this ports the portable UI and rebuilds the rest to match the foundation. - Tabs: the page is now Overview | Settings, driven by `?tab=` via @wordpress/route + @wordpress/ui Tabs (Scan's pattern). Form state lives in the page root so unsaved edits survive tab switches. - Settings sections: Site visibility (search-engine indexing + XML sitemap), post title structure, front-page description, site verification. - Reads: bootstrapped onto window.JetpackScriptData.seo.settings via the existing script-data filter (no round-trip). - Writes: @wordpress/api-fetch POST to the existing /jetpack/v4/settings endpoint (which already validates/sanitizes every field) — no new package REST controller. Save surfaces @wordpress/notices snackbars; a beforeunload guard covers unsaved changes on full-page exit. - Sitemap toggle now drives the real `sitemaps` module, not the placeholder `jetpack_seo_sitemap_enabled` option. Deliberately NOT included (per plan): the seo-tools enable/disable toggle + Overview banner + always-visible menu + off-state dimming (deferred to a dedicated discoverability PR), and the Canonical URLs card (placeholder option with no backing feature in Jetpack — dropped). Co-Authored-By: Claude Opus 4.8 (1M context) --- pnpm-lock.yaml | 9 + projects/packages/seo/_inc/app.tsx | 91 +++++++--- .../packages/seo/_inc/data/settings-types.ts | 24 +++ .../packages/seo/_inc/data/use-settings.ts | 156 ++++++++++++++++++ projects/packages/seo/_inc/notices-list.tsx | 32 ++++ .../seo/_inc/screens/settings/index.tsx | 137 +++++++++++++++ .../seo/_inc/screens/settings/style.scss | 27 +++ .../settings/title-structure-field.tsx | 124 ++++++++++++++ .../screens/settings/verification-card.tsx | 76 +++++++++ .../packages/seo/changelog/add-settings-tab | 4 + projects/packages/seo/package.json | 3 + .../packages/seo/routes/index/package.json | 3 + .../packages/seo/src/class-initializer.php | 44 +++++ 13 files changed, 707 insertions(+), 23 deletions(-) create mode 100644 projects/packages/seo/_inc/data/settings-types.ts create mode 100644 projects/packages/seo/_inc/data/use-settings.ts create mode 100644 projects/packages/seo/_inc/notices-list.tsx create mode 100644 projects/packages/seo/_inc/screens/settings/index.tsx create mode 100644 projects/packages/seo/_inc/screens/settings/style.scss create mode 100644 projects/packages/seo/_inc/screens/settings/title-structure-field.tsx create mode 100644 projects/packages/seo/_inc/screens/settings/verification-card.tsx create mode 100644 projects/packages/seo/changelog/add-settings-tab diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b277262bf7fa..e8e31de68860 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4537,9 +4537,15 @@ importers: '@automattic/jetpack-wp-build-polyfills': specifier: workspace:* version: link:../wp-build-polyfills + '@wordpress/api-fetch': + specifier: 7.46.0 + version: 7.46.0 '@wordpress/components': specifier: 33.1.0 version: 33.1.0(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/element': specifier: 6.46.0 version: 6.46.0 @@ -4549,6 +4555,9 @@ importers: '@wordpress/icons': specifier: 13.1.0 version: 13.1.0(react@18.3.1) + '@wordpress/notices': + specifier: 5.46.0 + version: 5.46.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@wordpress/route': specifier: 0.12.0 version: 0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1) diff --git a/projects/packages/seo/_inc/app.tsx b/projects/packages/seo/_inc/app.tsx index c3392436eb54..a4cb0ef27db6 100644 --- a/projects/packages/seo/_inc/app.tsx +++ b/projects/packages/seo/_inc/app.tsx @@ -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 = () => ( - - -
- -
-
-
-); +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 ( + + + +
+ + { __( 'Overview', 'jetpack-seo' ) } + { __( 'Settings', 'jetpack-seo' ) } + +
+ +
+ +
+
+ +
+ +
+
+
+ +
+
+ ); +}; export default App; diff --git a/projects/packages/seo/_inc/data/settings-types.ts b/projects/packages/seo/_inc/data/settings-types.ts new file mode 100644 index 000000000000..e39233a25d97 --- /dev/null +++ b/projects/packages/seo/_inc/data/settings-types.ts @@ -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' ]; diff --git a/projects/packages/seo/_inc/data/use-settings.ts b/projects/packages/seo/_inc/data/use-settings.ts new file mode 100644 index 000000000000..66d744d249c9 --- /dev/null +++ b/projects/packages/seo/_inc/data/use-settings.ts @@ -0,0 +1,156 @@ +import { getScriptData } from '@automattic/jetpack-script-data'; +import apiFetch from '@wordpress/api-fetch'; +import { useDispatch } from '@wordpress/data'; +import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { store as noticesStore } from '@wordpress/notices'; +import type { SettingsResponse, VerificationKey } from './settings-types'; + +type SeoScriptData = { + seo?: { + settings?: SettingsResponse; + }; +}; + +/** + * Read the editable Settings state bootstrapped onto + * `window.JetpackScriptData.seo.settings` by the server. Synchronous — present + * on first paint, no request. Returns `null` if the bootstrap is missing. + * + * @return The settings, or `null` when unavailable. + */ +export function getSettings(): SettingsResponse | null { + const scriptData = getScriptData() as SeoScriptData | undefined; + return scriptData?.seo?.settings ?? null; +} + +const VERIFICATION_KEYS: readonly VerificationKey[] = [ + 'google', + 'bing', + 'pinterest', + 'yandex', + 'facebook', +]; + +/** + * Translate the form state into the partial payload the existing + * `/jetpack/v4/settings` endpoint expects, including ONLY changed fields so we + * never re-toggle the sitemaps module (or re-write options) on an unchanged + * save. 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 keyed for `/jetpack/v4/settings`. + */ +function buildPayload( + 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; + } + 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; +} + +export interface SettingsForm { + local: SettingsResponse | null; + isDirty: boolean; + isSaving: boolean; + save: () => void; + update: ( patch: Partial< SettingsResponse > ) => void; + setVerification: ( key: VerificationKey, value: string ) => void; +} + +/** + * Owns the Settings form: seeds local state from the page bootstrap, tracks + * dirtiness, saves the diff to `/jetpack/v4/settings`, surfaces snackbars, and + * guards full-page exit while unsaved. + * + * Called above the tab panels (in the page root) so unsaved edits survive + * switching to the Overview tab and back. + * + * @return The settings form controller. + */ +export function useSettingsForm(): SettingsForm { + const initial = useMemo( () => getSettings(), [] ); + const [ baseline, setBaseline ] = useState< SettingsResponse | null >( initial ); + const [ local, setLocal ] = useState< SettingsResponse | null >( initial ); + const [ isSaving, setIsSaving ] = useState( false ); + const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + + const isDirty = useMemo( + () => !! baseline && !! local && JSON.stringify( baseline ) !== JSON.stringify( local ), + [ baseline, local ] + ); + + const update = useCallback( + ( patch: Partial< SettingsResponse > ) => + setLocal( state => ( state ? { ...state, ...patch } : state ) ), + [] + ); + + const setVerification = useCallback( + ( key: VerificationKey, value: string ) => + setLocal( state => + state ? { ...state, verification: { ...state.verification, [ key ]: value } } : state + ), + [] + ); + + const save = useCallback( () => { + if ( ! local || ! baseline ) { + return; + } + const payload = buildPayload( baseline, local ); + if ( Object.keys( payload ).length === 0 ) { + return; + } + setIsSaving( true ); + apiFetch( { path: '/jetpack/v4/settings', method: 'POST', data: payload } ) + .then( () => { + setBaseline( local ); + createSuccessNotice( __( 'Settings saved.', 'jetpack-seo' ), { type: 'snackbar' } ); + } ) + .catch( ( error: { message?: string } ) => { + createErrorNotice( + error?.message ?? __( 'Could not save settings. Please try again.', 'jetpack-seo' ), + { type: 'snackbar' } + ); + } ) + .finally( () => setIsSaving( false ) ); + }, [ local, baseline, createSuccessNotice, createErrorNotice ] ); + + // Native confirm when leaving the page (reload, back, external link) with + // unsaved changes. In-app tab switches don't lose edits — the state lives + // above the panels — so no router-level blocker is needed. + useEffect( () => { + if ( ! isDirty ) { + return; + } + const handler = ( event: BeforeUnloadEvent ) => { + event.preventDefault(); + event.returnValue = ''; + }; + window.addEventListener( 'beforeunload', handler ); + return () => window.removeEventListener( 'beforeunload', handler ); + }, [ isDirty ] ); + + return { local, isDirty, isSaving, save, update, setVerification }; +} diff --git a/projects/packages/seo/_inc/notices-list.tsx b/projects/packages/seo/_inc/notices-list.tsx new file mode 100644 index 000000000000..54642b43cc73 --- /dev/null +++ b/projects/packages/seo/_inc/notices-list.tsx @@ -0,0 +1,32 @@ +import { SnackbarList } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { store as noticesStore } from '@wordpress/notices'; +import type { FC } from 'react'; + +const MAX_VISIBLE_NOTICES = 3; + +/** + * Floating snackbar layer for the SEO admin page. Subscribes to the core + * `notices` store and renders the trailing snackbars. Anywhere in the page can + * fire one via `useDispatch( noticesStore ).createSuccessNotice( …, { type: 'snackbar' } )`. + * + * @return The snackbar list. + */ +const NoticesList: FC = () => { + const notices = useSelect( select => select( noticesStore ).getNotices(), [] ); + const { removeNotice } = useDispatch( noticesStore ); + + const snackbarNotices = notices + .filter( ( { type } ) => type === 'snackbar' ) + .slice( -MAX_VISIBLE_NOTICES ); + + return ( + + ); +}; + +export default NoticesList; diff --git a/projects/packages/seo/_inc/screens/settings/index.tsx b/projects/packages/seo/_inc/screens/settings/index.tsx new file mode 100644 index 000000000000..093454c52a66 --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/index.tsx @@ -0,0 +1,137 @@ +/* eslint-disable react/jsx-no-bind */ + +import { Button, Notice, TextareaControl, ToggleControl } from '@wordpress/components'; +import { __, sprintf } from '@wordpress/i18n'; +import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; +import TitleStructureField from './title-structure-field'; +import VerificationCard from './verification-card'; +import './style.scss'; +import type { SettingsForm } from '../../data/use-settings'; +import type { FC } from 'react'; + +// Pre-resolved so the production minifier can't fold adjacent ternary `__()` +// calls (breaks i18n extraction). See feedback_i18n_ternary_minifier_fold. +const setLabel = __( 'Set', 'jetpack-seo' ); +const notSetLabel = __( 'Not set', 'jetpack-seo' ); + +interface Props { + form: SettingsForm; +} + +/** + * Consolidated Settings screen. State + persistence live in the `form` + * controller (passed from the page root so edits survive tab switches); + * this component is the presentation + Save affordance. + * + * @param props - Component props. + * @param props.form - The settings form controller from `useSettingsForm`. + * @return The Settings tab content. + */ +const SettingsScreen: FC< Props > = ( { form } ) => { + const { local, isDirty, isSaving, save, update, setVerification } = form; + + if ( ! local ) { + return ( + + { __( 'Unable to load settings.', 'jetpack-seo' ) } + + ); + } + + const postsTokens = local.title_formats.posts ?? []; + const visibilityEnabledCount = + ( local.search_engines_visible ? 1 : 0 ) + ( local.sitemap_active ? 1 : 0 ); + + return ( +
+
+ +
+ + + + + { __( 'Site visibility', 'jetpack-seo' ) } + + { sprintf( + /* translators: %1$d: number of enabled visibility settings, %2$d: total. */ + __( '%1$d of %2$d enabled', 'jetpack-seo' ), + visibilityEnabledCount, + 2 + ) } + + + + + + update( { search_engines_visible: next } ) } + disabled={ isSaving } + __nextHasNoMarginBottom + /> + update( { sitemap_active: next } ) } + disabled={ isSaving } + __nextHasNoMarginBottom + /> + + + + + update( { title_formats: { ...local.title_formats, posts: next } } ) } + disabled={ isSaving } + /> + + + + + { __( 'Front-page description', 'jetpack-seo' ) } + + { local.front_page_description ? setLabel : notSetLabel } + + + + + update( { front_page_description: next } ) } + rows={ 3 } + disabled={ isSaving } + __nextHasNoMarginBottom + /> + + + + +
+ ); +}; + +export default SettingsScreen; diff --git a/projects/packages/seo/_inc/screens/settings/style.scss b/projects/packages/seo/_inc/screens/settings/style.scss new file mode 100644 index 000000000000..f1c3d31361a2 --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/style.scss @@ -0,0 +1,27 @@ +.jetpack-seo-settings { + display: flex; + flex-direction: column; + gap: var(--wpds-dimension-gap-lg, 16px); + max-width: 660px; +} + +// Save action row, pinned to the top-right of the form. +.jetpack-seo-settings__actions { + display: flex; + justify-content: flex-end; +} + +// Live preview under the title-structure token editor. +.jetpack-seo-settings__preview { + margin-top: var(--wpds-dimension-gap-md, 12px); + padding: var(--wpds-dimension-padding-md, 12px); + background: var(--wpds-color-bg-surface-neutral, #f6f7f7); + border-radius: var(--wpds-border-radius-md, 4px); + font-size: 13px; +} + +.jetpack-seo-settings__verification-grid { + display: grid; + grid-template-columns: 1fr; + gap: var(--wpds-dimension-gap-md, 12px); +} diff --git a/projects/packages/seo/_inc/screens/settings/title-structure-field.tsx b/projects/packages/seo/_inc/screens/settings/title-structure-field.tsx new file mode 100644 index 000000000000..1357b02c0b74 --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/title-structure-field.tsx @@ -0,0 +1,124 @@ +/* eslint-disable jsdoc/require-returns, jsdoc/require-param */ + +/* eslint-disable react/jsx-no-bind */ + +import { FormTokenField } from '@wordpress/components'; +import { useMemo } from '@wordpress/element'; +import { __ } from '@wordpress/i18n'; +import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; +import './style.scss'; +import type { TitleFormatToken } from '../../data/settings-types'; +import type { FC } from 'react'; + +/** + * Canonical tokens supported for the `posts` page type. Mirrors the back-end + * list in Jetpack_SEO_Titles. Internal id stays snake_case for the REST + * payload; the UI shows a friendly bracketed label so users can distinguish + * token chips from literal string fragments (separators like " | "). + */ +const TOKEN_LABELS: Record< string, string > = { + site_name: __( 'Site name', 'jetpack-seo' ), + tagline: __( 'Tagline', 'jetpack-seo' ), + post_title: __( 'Post title', 'jetpack-seo' ), +}; + +const TOKEN_IDS = Object.keys( TOKEN_LABELS ); + +// Reverse map — "Site name" → "site_name" — to parse `[Site name]` back into +// the canonical id when the user picks a suggestion or pastes a label. +const LABEL_TO_TOKEN_ID: Record< string, string > = Object.fromEntries( + TOKEN_IDS.map( id => [ TOKEN_LABELS[ id ], id ] ) +); + +const toDisplay = ( token: TitleFormatToken ): string => + token.type === 'token' && TOKEN_LABELS[ token.value ] + ? `[${ TOKEN_LABELS[ token.value ] }]` + : token.value; + +const fromDisplay = ( display: string ): TitleFormatToken => { + const match = display.match( /^\[(.+)\]$/ ); + const inner = match?.[ 1 ]; + if ( inner && LABEL_TO_TOKEN_ID[ inner ] ) { + return { type: 'token', value: LABEL_TO_TOKEN_ID[ inner ] }; + } + return { type: 'string', value: display }; +}; + +// Pre-resolved 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 customizedLabel = __( 'Customized', 'jetpack-seo' ); +const defaultLabel = __( 'Default', 'jetpack-seo' ); + +interface Props { + tokens: TitleFormatToken[]; + onChange: ( next: TitleFormatToken[] ) => void; + disabled?: boolean; +} + +/** + * FormTokenField-powered post-title-structure editor. Tokens display as + * bracketed pretty labels (e.g. `[Site name]`) while raw string fragments + * like " | " coexist as distinct chips. + */ +const TitleStructureField: FC< Props > = ( { tokens, onChange, disabled } ) => { + const displayValues = useMemo( () => tokens.map( toDisplay ), [ tokens ] ); + const displaySuggestions = useMemo( + () => TOKEN_IDS.map( id => `[${ TOKEN_LABELS[ id ] }]` ), + [] + ); + + const preview = useMemo( + () => + tokens + .map( token => { + if ( token.type === 'string' ) { + return token.value; + } + switch ( token.value ) { + case 'site_name': + return __( 'Your site', 'jetpack-seo' ); + case 'tagline': + return __( 'Your tagline', 'jetpack-seo' ); + case 'post_title': + return __( 'Hello World', 'jetpack-seo' ); + default: + return token.value; + } + } ) + .join( '' ), + [ tokens ] + ); + + const hasCustomStructure = tokens.length > 0; + + return ( + + + + { __( 'Post title structure', 'jetpack-seo' ) } + + { hasCustomStructure ? customizedLabel : defaultLabel } + + + + + onChange( ( next as string[] ).map( fromDisplay ) ) } + disabled={ disabled } + __experimentalExpandOnFocus + __next40pxDefaultSize + __nextHasNoMarginBottom + /> +
+ { __( 'Preview', 'jetpack-seo' ) }: { preview } +
+
+
+ ); +}; + +export default TitleStructureField; diff --git a/projects/packages/seo/_inc/screens/settings/verification-card.tsx b/projects/packages/seo/_inc/screens/settings/verification-card.tsx new file mode 100644 index 000000000000..d60cd3d4e90a --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/verification-card.tsx @@ -0,0 +1,76 @@ +/* eslint-disable react/jsx-no-bind */ + +import { TextControl } from '@wordpress/components'; +import { __, sprintf, _n } from '@wordpress/i18n'; +import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; +import './style.scss'; +import type { SettingsResponse, VerificationKey } from '../../data/settings-types'; +import type { FC } from 'react'; + +interface Props { + value: SettingsResponse[ 'verification' ]; + onChange: ( key: VerificationKey, value: string ) => void; + disabled?: boolean; +} + +const services: Array< { key: VerificationKey; label: string; hint: string } > = [ + { + key: 'google', + label: 'Google', + hint: __( + 'Paste the `content` attribute from the Google Search Console meta tag.', + 'jetpack-seo' + ), + }, + { key: 'bing', label: 'Bing', hint: __( 'Bing Webmaster Tools meta tag.', 'jetpack-seo' ) }, + { key: 'pinterest', label: 'Pinterest', hint: __( 'Pinterest meta tag.', 'jetpack-seo' ) }, + { key: 'yandex', label: 'Yandex', hint: __( 'Yandex Webmaster meta tag.', 'jetpack-seo' ) }, + { + key: 'facebook', + label: 'Facebook', + hint: __( 'Facebook domain verification meta tag.', 'jetpack-seo' ), + }, +]; + +const notSetLabel = __( 'Not set', 'jetpack-seo' ); + +const VerificationCard: FC< Props > = ( { value, onChange, disabled } ) => { + const verifiedCount = services.filter( ( { key } ) => !! value[ key ] ).length; + + return ( + + + + { __( 'Site verification', 'jetpack-seo' ) } + 0 ? 'stable' : 'draft' }> + { verifiedCount > 0 + ? sprintf( + /* translators: %d: number of verification services configured */ + _n( '%d verified', '%d verified', verifiedCount, 'jetpack-seo' ), + verifiedCount + ) + : notSetLabel } + + + + +
+ { services.map( ( { key, label, hint } ) => ( + onChange( key, next ) } + help={ hint } + disabled={ disabled } + __next40pxDefaultSize + __nextHasNoMarginBottom + /> + ) ) } +
+
+
+ ); +}; + +export default VerificationCard; diff --git a/projects/packages/seo/changelog/add-settings-tab b/projects/packages/seo/changelog/add-settings-tab new file mode 100644 index 000000000000..729b5e0e5d7e --- /dev/null +++ b/projects/packages/seo/changelog/add-settings-tab @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Add a Settings tab to the SEO admin page: site visibility (search-engine indexing + XML sitemap), post title structure, front-page description, and site verification. Saves through the existing /jetpack/v4/settings endpoint. diff --git a/projects/packages/seo/package.json b/projects/packages/seo/package.json index bb316a6ab2bf..34d76bfb20cf 100644 --- a/projects/packages/seo/package.json +++ b/projects/packages/seo/package.json @@ -40,10 +40,13 @@ "@automattic/jetpack-components": "workspace:*", "@automattic/jetpack-script-data": "workspace:*", "@automattic/jetpack-wp-build-polyfills": "workspace:*", + "@wordpress/api-fetch": "7.46.0", "@wordpress/components": "33.1.0", + "@wordpress/data": "10.46.0", "@wordpress/element": "6.46.0", "@wordpress/i18n": "6.19.0", "@wordpress/icons": "13.1.0", + "@wordpress/notices": "5.46.0", "@wordpress/route": "0.12.0", "@wordpress/ui": "0.13.0", "@wordpress/url": "4.46.0", diff --git a/projects/packages/seo/routes/index/package.json b/projects/packages/seo/routes/index/package.json index 8c37b6ac5f81..b54894f9a3ec 100644 --- a/projects/packages/seo/routes/index/package.json +++ b/projects/packages/seo/routes/index/package.json @@ -6,10 +6,13 @@ "@automattic/jetpack-components": "workspace:*", "@automattic/jetpack-script-data": "workspace:*", "@types/react": "18.3.28", + "@wordpress/api-fetch": "7.46.0", "@wordpress/components": "33.1.0", + "@wordpress/data": "10.46.0", "@wordpress/element": "6.46.0", "@wordpress/i18n": "6.19.0", "@wordpress/icons": "13.1.0", + "@wordpress/notices": "5.46.0", "@wordpress/route": "0.12.0", "@wordpress/ui": "0.13.0", "@wordpress/url": "4.46.0", diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index d1f5625b7811..0a3b6a7d8d1c 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -14,6 +14,7 @@ use Automattic\Jetpack\Admin_UI\Admin_Menu; use Automattic\Jetpack\Modules; use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills; +use Jetpack_SEO_Titles; use Jetpack_SEO_Utils; /** @@ -214,6 +215,7 @@ public static function inject_script_data( $data ) { } $data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data(); + $data[ self::SCRIPT_DATA_KEY ]['settings'] = self::get_settings_data(); return $data; } @@ -283,4 +285,46 @@ public static function get_overview_data() { ), ); } + + /** + * Build the editable Settings state the Settings tab hydrates from. + * + * Read-only bootstrap only. Writes go through the existing + * `/jetpack/v4/settings` REST endpoint, which already validates and + * sanitizes each of these fields — this package registers no settings + * endpoint of its own. The reads here mirror the options/helpers that + * endpoint round-trips so the form hydrates without a request. + * + * @return array + */ + public static function get_settings_data() { + $modules = new Modules(); + + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Titles lives in plugins/jetpack and is guarded by class_exists. + $title_formats = class_exists( 'Jetpack_SEO_Titles' ) ? Jetpack_SEO_Titles::get_custom_title_formats() : array(); + // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists. + $front_page_desc = class_exists( 'Jetpack_SEO_Utils' ) ? Jetpack_SEO_Utils::get_front_page_meta_description() : ''; + + $codes = get_option( 'verification_services_codes', array() ); + if ( ! is_array( $codes ) ) { + $codes = array(); + } + + return array( + 'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1, + // The real `sitemaps` module is the source of truth (not a bespoke + // option). The Settings toggle drives it via `/jetpack/v4/settings`. + 'sitemap_active' => $modules->is_active( 'sitemaps' ), + // Cast to object so an empty format set serializes as `{}`, not `[]`. + 'title_formats' => (object) $title_formats, + 'front_page_description' => (string) $front_page_desc, + 'verification' => array( + 'google' => isset( $codes['google'] ) ? (string) $codes['google'] : '', + 'bing' => isset( $codes['bing'] ) ? (string) $codes['bing'] : '', + 'pinterest' => isset( $codes['pinterest'] ) ? (string) $codes['pinterest'] : '', + 'yandex' => isset( $codes['yandex'] ) ? (string) $codes['yandex'] : '', + 'facebook' => isset( $codes['facebook'] ) ? (string) $codes['facebook'] : '', + ), + ); + } } From b341008ea1502cc41d34b505870b922b74d694ff Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Mon, 1 Jun 2026 19:24:03 -0500 Subject: [PATCH 2/6] Jetpack SEO: save search-engine visibility via core /wp/v2/settings blog_public is a WordPress core option, not a Jetpack one, so POSTing it to /jetpack/v4/settings failed with "Invalid option: blog_public" (and the partial failure left the form out of sync on reload). Route blog_public through core's /wp/v2/settings instead, registering it with show_in_rest so the core settings controller round-trips it; the other four fields stay on /jetpack/v4/settings. Save now fans out to both endpoints and only commits the baseline when all succeed. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../packages/seo/_inc/data/use-settings.ts | 57 +++++++++++++++---- .../packages/seo/src/class-initializer.php | 28 +++++++++ 2 files changed, 73 insertions(+), 12 deletions(-) diff --git a/projects/packages/seo/_inc/data/use-settings.ts b/projects/packages/seo/_inc/data/use-settings.ts index 66d744d249c9..7d9b070657ed 100644 --- a/projects/packages/seo/_inc/data/use-settings.ts +++ b/projects/packages/seo/_inc/data/use-settings.ts @@ -33,24 +33,22 @@ const VERIFICATION_KEYS: readonly VerificationKey[] = [ ]; /** - * Translate the form state into the partial payload the existing - * `/jetpack/v4/settings` endpoint expects, including ONLY changed fields so we - * never re-toggle the sitemaps module (or re-write options) on an unchanged - * save. The endpoint owns validation/sanitization for every key here. + * 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 keyed for `/jetpack/v4/settings`. + * @return The changed-fields payload for `/jetpack/v4/settings`. */ -function buildPayload( +function buildJetpackPayload( 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; - } if ( local.sitemap_active !== baseline.sitemap_active ) { payload.sitemaps = local.sitemap_active; } @@ -69,6 +67,29 @@ function buildPayload( 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. + */ +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; +} + export interface SettingsForm { local: SettingsResponse | null; isDirty: boolean; @@ -118,12 +139,24 @@ export function useSettingsForm(): SettingsForm { if ( ! local || ! baseline ) { return; } - const payload = buildPayload( baseline, local ); - if ( Object.keys( payload ).length === 0 ) { + const jetpackPayload = buildJetpackPayload( baseline, local ); + const corePayload = buildCorePayload( baseline, local ); + + const requests: Array< Promise< unknown > > = []; + if ( Object.keys( jetpackPayload ).length > 0 ) { + requests.push( + apiFetch( { path: '/jetpack/v4/settings', method: 'POST', data: jetpackPayload } ) + ); + } + if ( Object.keys( corePayload ).length > 0 ) { + requests.push( apiFetch( { path: '/wp/v2/settings', method: 'POST', data: corePayload } ) ); + } + if ( requests.length === 0 ) { return; } + setIsSaving( true ); - apiFetch( { path: '/jetpack/v4/settings', method: 'POST', data: payload } ) + Promise.all( requests ) .then( () => { setBaseline( local ); createSuccessNotice( __( 'Settings saved.', 'jetpack-seo' ), { type: 'snackbar' } ); diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index 0a3b6a7d8d1c..c3a8e0dd93d3 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -107,6 +107,12 @@ public static function init() { add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 ); add_action( 'admin_menu', array( __CLASS__, 'add_menu_item' ), 10 ); + // Expose the core `blog_public` option to the REST settings endpoint so + // the Settings tab can save search-engine visibility via `/wp/v2/settings`. + // (The Jetpack settings endpoint only accepts Jetpack options.) Writes + // are still capability-gated by the core settings controller. + add_action( 'rest_api_init', array( __CLASS__, 'register_rest_settings' ) ); + /** * Fires after the Jetpack SEO package is initialized. * @@ -286,6 +292,28 @@ public static function get_overview_data() { ); } + /** + * Expose the core `blog_public` option to the REST settings endpoint. + * + * Search-engine visibility is a WordPress core option, not a Jetpack one, + * so the Settings tab saves it through `/wp/v2/settings` — which only + * round-trips settings registered with `show_in_rest`. The core settings + * controller enforces the `manage_options` capability on writes. + * + * @return void + */ + public static function register_rest_settings() { + register_setting( + 'reading', + 'blog_public', + array( + 'show_in_rest' => true, + 'type' => 'integer', + 'default' => 1, + ) + ); + } + /** * Build the editable Settings state the Settings tab hydrates from. * From a5efd7e597108d7fdd2c583820d47e83ef9ee3a2 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Mon, 1 Jun 2026 20:10:25 -0500 Subject: [PATCH 3/6] =?UTF-8?q?Jetpack=20SEO:=20settings-tab=20consistency?= =?UTF-8?q?=20=E2=80=94=20centering,=20auto-save,=20Overview=20verificatio?= =?UTF-8?q?n,=20sitemap=20links?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses four consistency gaps vs other Jetpack admin pages (found in JN review): - Center page content at 660px (the established JP settings-page width; matches Newsletter/Podcast), both tabs. - Replace the explicit Save button with auto-save: toggles save on change, text/token fields on blur, surfaced via a single "Updating settings…" → "Settings saved." snackbar (mirrors the Jetpack → Settings page; also how Podcast behaves). - Re-add the Site verification card to the Overview tab; both Overview cards now deep-link into the matching Settings section, scrolled to the section top (scroll-margin-top clears the sticky header/tabs so the title shows). - Restore the sitemap link: under the Settings sitemap toggle and on the Overview Site-visibility line (404-until-cron-generated caveat tracked in JETPACK-1694). --- .../packages/seo/_inc/data/overview-types.ts | 9 + .../packages/seo/_inc/data/settings-types.ts | 2 + .../packages/seo/_inc/data/use-settings.ts | 154 +++++++++-------- .../seo/_inc/screens/overview/index.tsx | 33 +++- .../overview/site-verification-card.tsx | 47 ++++++ .../screens/overview/site-visibility-card.tsx | 26 ++- .../seo/_inc/screens/overview/style.scss | 41 ++++- .../seo/_inc/screens/settings/index.tsx | 155 ++++++++++-------- .../seo/_inc/screens/settings/style.scss | 21 ++- .../screens/settings/verification-card.tsx | 5 +- .../packages/seo/src/class-initializer.php | 26 ++- 11 files changed, 366 insertions(+), 153 deletions(-) create mode 100644 projects/packages/seo/_inc/screens/overview/site-verification-card.tsx diff --git a/projects/packages/seo/_inc/data/overview-types.ts b/projects/packages/seo/_inc/data/overview-types.ts index 159a01e31ad0..2050e78b8fbb 100644 --- a/projects/packages/seo/_inc/data/overview-types.ts +++ b/projects/packages/seo/_inc/data/overview-types.ts @@ -10,8 +10,17 @@ export interface SiteVisibility { 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; }; diff --git a/projects/packages/seo/_inc/data/settings-types.ts b/projects/packages/seo/_inc/data/settings-types.ts index e39233a25d97..1f62609c4161 100644 --- a/projects/packages/seo/_inc/data/settings-types.ts +++ b/projects/packages/seo/_inc/data/settings-types.ts @@ -19,6 +19,8 @@ export interface SettingsResponse { }; search_engines_visible: boolean; sitemap_active: boolean; + sitemap_url: string; + news_sitemap_url: string; } export type VerificationKey = keyof SettingsResponse[ 'verification' ]; diff --git a/projects/packages/seo/_inc/data/use-settings.ts b/projects/packages/seo/_inc/data/use-settings.ts index 7d9b070657ed..af0368d1e2cd 100644 --- a/projects/packages/seo/_inc/data/use-settings.ts +++ b/projects/packages/seo/_inc/data/use-settings.ts @@ -1,11 +1,16 @@ import { getScriptData } from '@automattic/jetpack-script-data'; import apiFetch from '@wordpress/api-fetch'; import { useDispatch } from '@wordpress/data'; -import { useCallback, useEffect, useMemo, useState } from '@wordpress/element'; +import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; import type { SettingsResponse, VerificationKey } from './settings-types'; +// Single snackbar id reused across a save so "Updating settings…" is replaced +// in place by "Settings saved." (or an error) — mirrors the Jetpack → Settings +// page's two-stage toast. +const SAVE_NOTICE_ID = 'jetpack-seo-settings-save'; + type SeoScriptData = { seo?: { settings?: SettingsResponse; @@ -92,36 +97,89 @@ function buildCorePayload( export interface SettingsForm { local: SettingsResponse | null; - isDirty: boolean; isSaving: boolean; - save: () => void; - update: ( patch: Partial< SettingsResponse > ) => void; + /** Update local state only — for controlled typing; pair with `commit()` on blur. */ + setField: ( patch: Partial< SettingsResponse > ) => void; + /** Update a verification code locally — pair with `commit()` on blur. */ setVerification: ( key: VerificationKey, value: string ) => void; + /** Apply an optional patch and immediately save the changed fields. */ + commit: ( patch?: Partial< SettingsResponse > ) => void; } /** - * Owns the Settings form: seeds local state from the page bootstrap, tracks - * dirtiness, saves the diff to `/jetpack/v4/settings`, surfaces snackbars, and - * guards full-page exit while unsaved. + * Owns the Settings form: seeds local state from the page bootstrap and + * auto-saves changes — there's no explicit Save button. Toggles `commit()` on + * change; text/token fields `setField()` while editing and `commit()` on blur. + * Saves diff against the last-saved baseline (so an unchanged save is a no-op + * and the sitemaps module is never re-toggled needlessly), surfacing a single + * "Updating settings…"→"Settings saved." snackbar. * - * Called above the tab panels (in the page root) so unsaved edits survive - * switching to the Overview tab and back. + * Lives above the tab panels (in the page root) so state survives tab switches. * * @return The settings form controller. */ export function useSettingsForm(): SettingsForm { const initial = useMemo( () => getSettings(), [] ); - const [ baseline, setBaseline ] = useState< SettingsResponse | null >( initial ); const [ local, setLocal ] = useState< SettingsResponse | null >( initial ); const [ isSaving, setIsSaving ] = useState( false ); - const { createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + const { createInfoNotice, createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); - const isDirty = useMemo( - () => !! baseline && !! local && JSON.stringify( baseline ) !== JSON.stringify( local ), - [ baseline, local ] + // Refs so `commit()` reads the freshest values without stale closures and + // without re-creating the callback on every keystroke. + const baselineRef = useRef< SettingsResponse | null >( initial ); + const localRef = useRef< SettingsResponse | null >( initial ); + useEffect( () => { + localRef.current = local; + }, [ local ] ); + + const saveValues = useCallback( + ( values: SettingsResponse ) => { + const baseline = baselineRef.current; + if ( ! baseline ) { + return; + } + const jetpackPayload = buildJetpackPayload( baseline, values ); + const corePayload = buildCorePayload( baseline, values ); + + const requests: Array< Promise< unknown > > = []; + if ( Object.keys( jetpackPayload ).length > 0 ) { + requests.push( + apiFetch( { path: '/jetpack/v4/settings', method: 'POST', data: jetpackPayload } ) + ); + } + if ( Object.keys( corePayload ).length > 0 ) { + requests.push( apiFetch( { path: '/wp/v2/settings', method: 'POST', data: corePayload } ) ); + } + if ( requests.length === 0 ) { + return; + } + + setIsSaving( true ); + createInfoNotice( __( 'Updating settings…', 'jetpack-seo' ), { + id: SAVE_NOTICE_ID, + type: 'snackbar', + isDismissible: false, + } ); + Promise.all( requests ) + .then( () => { + baselineRef.current = values; + createSuccessNotice( __( 'Settings saved.', 'jetpack-seo' ), { + id: SAVE_NOTICE_ID, + type: 'snackbar', + } ); + } ) + .catch( ( error: { message?: string } ) => { + createErrorNotice( + error?.message ?? __( 'Could not save settings. Please try again.', 'jetpack-seo' ), + { id: SAVE_NOTICE_ID, type: 'snackbar' } + ); + } ) + .finally( () => setIsSaving( false ) ); + }, + [ createInfoNotice, createSuccessNotice, createErrorNotice ] ); - const update = useCallback( + const setField = useCallback( ( patch: Partial< SettingsResponse > ) => setLocal( state => ( state ? { ...state, ...patch } : state ) ), [] @@ -135,55 +193,21 @@ export function useSettingsForm(): SettingsForm { [] ); - const save = useCallback( () => { - if ( ! local || ! baseline ) { - return; - } - const jetpackPayload = buildJetpackPayload( baseline, local ); - const corePayload = buildCorePayload( baseline, local ); - - const requests: Array< Promise< unknown > > = []; - if ( Object.keys( jetpackPayload ).length > 0 ) { - requests.push( - apiFetch( { path: '/jetpack/v4/settings', method: 'POST', data: jetpackPayload } ) - ); - } - if ( Object.keys( corePayload ).length > 0 ) { - requests.push( apiFetch( { path: '/wp/v2/settings', method: 'POST', data: corePayload } ) ); - } - if ( requests.length === 0 ) { - return; - } - - setIsSaving( true ); - Promise.all( requests ) - .then( () => { - setBaseline( local ); - createSuccessNotice( __( 'Settings saved.', 'jetpack-seo' ), { type: 'snackbar' } ); - } ) - .catch( ( error: { message?: string } ) => { - createErrorNotice( - error?.message ?? __( 'Could not save settings. Please try again.', 'jetpack-seo' ), - { type: 'snackbar' } - ); - } ) - .finally( () => setIsSaving( false ) ); - }, [ local, baseline, createSuccessNotice, createErrorNotice ] ); + const commit = useCallback( + ( patch?: Partial< SettingsResponse > ) => { + const current = localRef.current; + if ( ! current ) { + return; + } + const next = patch ? { ...current, ...patch } : current; + if ( patch ) { + localRef.current = next; + setLocal( next ); + } + saveValues( next ); + }, + [ saveValues ] + ); - // Native confirm when leaving the page (reload, back, external link) with - // unsaved changes. In-app tab switches don't lose edits — the state lives - // above the panels — so no router-level blocker is needed. - useEffect( () => { - if ( ! isDirty ) { - return; - } - const handler = ( event: BeforeUnloadEvent ) => { - event.preventDefault(); - event.returnValue = ''; - }; - window.addEventListener( 'beforeunload', handler ); - return () => window.removeEventListener( 'beforeunload', handler ); - }, [ isDirty ] ); - - return { local, isDirty, isSaving, save, update, setVerification }; + return { local, isSaving, setField, setVerification, commit }; } diff --git a/projects/packages/seo/_inc/screens/overview/index.tsx b/projects/packages/seo/_inc/screens/overview/index.tsx index 87cbe8e05516..91311989b897 100644 --- a/projects/packages/seo/_inc/screens/overview/index.tsx +++ b/projects/packages/seo/_inc/screens/overview/index.tsx @@ -1,12 +1,32 @@ +/* eslint-disable react/jsx-no-bind */ + +import { useCallback } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; +import { useNavigate } from '@wordpress/route'; import { Notice } from '@wordpress/ui'; import getOverview from '../../data/get-overview'; +import SiteVerificationCard from './site-verification-card'; import SiteVisibilityCard from './site-visibility-card'; import './style.scss'; import type { FC } from 'react'; const OverviewScreen: FC = () => { const data = getOverview(); + const navigate = useNavigate(); + + // Deep-link to a Settings section: switch to the Settings tab and set + // `?focus=`, which the Settings screen reads to scroll the section to top. + const goToSection = useCallback( + ( section: 'visibility' | 'verification' ) => + navigate( { + search: ( prev: Record< string, unknown > ) => ( { + ...prev, + tab: 'settings', + focus: section, + } ), + } as unknown as Parameters< typeof navigate >[ 0 ] ), + [ navigate ] + ); if ( ! data ) { return ( @@ -17,7 +37,7 @@ const OverviewScreen: FC = () => { } return ( - <> +
{ ! data.plan.seo_enabled_for_site && ( @@ -29,9 +49,16 @@ const OverviewScreen: FC = () => { ) }
- + goToSection( 'visibility' ) } + /> + goToSection( 'verification' ) } + />
- +
); }; diff --git a/projects/packages/seo/_inc/screens/overview/site-verification-card.tsx b/projects/packages/seo/_inc/screens/overview/site-verification-card.tsx new file mode 100644 index 000000000000..3c0fd58ad05d --- /dev/null +++ b/projects/packages/seo/_inc/screens/overview/site-verification-card.tsx @@ -0,0 +1,47 @@ +import { Button } from '@wordpress/components'; +import { __ } from '@wordpress/i18n'; +import { Card } from '@wordpress/ui'; +import StatusDot from './status-dot'; +import type { SiteVerification } from '../../data/overview-types'; +import type { FC } from 'react'; + +interface Props { + data: SiteVerification; + onManage: () => void; +} + +const services: Array< { key: keyof SiteVerification; label: string } > = [ + { key: 'google', label: 'Google' }, + { key: 'bing', label: 'Bing' }, + { key: 'pinterest', label: 'Pinterest' }, + { key: 'yandex', label: 'Yandex' }, + { key: 'facebook', label: 'Facebook' }, +]; + +// Module-scope so the production minifier can't fold an adjacent ternary +// `__()` into `__(cond ? A : B)`. See feedback_i18n_ternary_minifier_fold. +const verifiedLabel = __( 'Verified', 'jetpack-seo' ); +const notSetLabel = __( 'Not set', 'jetpack-seo' ); + +const SiteVerificationCard: FC< Props > = ( { data, onManage } ) => ( + + + { __( 'Site verification', 'jetpack-seo' ) } + + + { services.map( ( { key, label } ) => ( +
+ + { data[ key ] ? verifiedLabel : notSetLabel } +
+ ) ) } +
+ +
+
+
+); + +export default SiteVerificationCard; diff --git a/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx b/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx index 2a2e95f12015..7d3fffe2b5de 100644 --- a/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx +++ b/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx @@ -1,11 +1,13 @@ +import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { Card, Stack } from '@wordpress/ui'; +import { Card, Link, Stack } from '@wordpress/ui'; import StatusDot from './status-dot'; import type { OverviewResponse } from '../../data/overview-types'; import type { FC } from 'react'; interface Props { data: OverviewResponse[ 'site_visibility' ]; + onManage: () => void; } // Labels resolved at module scope so the production minifier can't fold an @@ -18,7 +20,7 @@ const sitemapDisabledLabel = __( 'Sitemap disabled', 'jetpack-seo' ); const seoToolsActiveLabel = __( 'SEO tools active', 'jetpack-seo' ); const seoToolsInactiveLabel = __( 'SEO tools inactive', 'jetpack-seo' ); -const SiteVisibilityCard: FC< Props > = ( { data } ) => ( +const SiteVisibilityCard: FC< Props > = ( { data, onManage } ) => ( { __( 'Site visibility', 'jetpack-seo' ) } @@ -29,15 +31,27 @@ const SiteVisibilityCard: FC< Props > = ( { data } ) => ( status={ data.search_engines_visible ? 'ok' : 'err' } label={ data.search_engines_visible ? searchAllowedLabel : searchBlockedLabel } /> - + + + { data.sitemap_active && ( + + { __( 'View', 'jetpack-seo' ) } + + ) } + +
+ +
); diff --git a/projects/packages/seo/_inc/screens/overview/style.scss b/projects/packages/seo/_inc/screens/overview/style.scss index a7631003216e..2b4f56e39882 100644 --- a/projects/packages/seo/_inc/screens/overview/style.scss +++ b/projects/packages/seo/_inc/screens/overview/style.scss @@ -1,6 +1,43 @@ +// Centered fixed-width column to match the Settings tab and other Jetpack +// settings pages (660px is the established JP settings-page width). +.jetpack-seo-overview { + max-inline-size: 660px; + margin-inline: auto; +} + .jetpack-seo-overview__grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); - gap: var(--wpds-dimension-gap-lg); + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--wpds-dimension-gap-lg, 16px); align-items: stretch; } + +// Make each Overview card a flex column so its body stretches and equal-height +// cards line up across the row. Targets Card.Root's last child (Card.Content), +// since @wordpress/ui uses hashed (non-stable) class names. +.jetpack-seo-overview__grid > * { + display: flex; + flex-direction: column; + height: 100%; +} + +.jetpack-seo-overview__grid > * > *:last-child { + display: flex; + flex-direction: column; + flex: 1 1 auto; +} + +// A status row in the Site verification card: indicator + label on the left, +// "Verified"/"Not set" on the right. +.jetpack-seo-overview__stat-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--wpds-dimension-gap-xs, 4px) 0; +} + +// Card action row (Manage button), pinned to the bottom of the card. +.jetpack-seo-overview__card-footer { + margin-top: auto; + padding-top: var(--wpds-dimension-gap-md, 12px); +} diff --git a/projects/packages/seo/_inc/screens/settings/index.tsx b/projects/packages/seo/_inc/screens/settings/index.tsx index 093454c52a66..468d040690a7 100644 --- a/projects/packages/seo/_inc/screens/settings/index.tsx +++ b/projects/packages/seo/_inc/screens/settings/index.tsx @@ -1,8 +1,10 @@ /* eslint-disable react/jsx-no-bind */ -import { Button, Notice, TextareaControl, ToggleControl } from '@wordpress/components'; +import { Notice, TextareaControl, ToggleControl } from '@wordpress/components'; +import { useEffect } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; -import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; +import { useSearch } from '@wordpress/route'; +import { Badge, Card, CollapsibleCard, Link, Stack } from '@wordpress/ui'; import TitleStructureField from './title-structure-field'; import VerificationCard from './verification-card'; import './style.scss'; @@ -18,17 +20,35 @@ interface Props { form: SettingsForm; } +type SettingsSearch = Record< string, unknown > & { focus?: string }; + /** - * Consolidated Settings screen. State + persistence live in the `form` - * controller (passed from the page root so edits survive tab switches); - * this component is the presentation + Save affordance. + * Consolidated Settings screen. State + auto-save live in the `form` controller + * (passed from the page root so it survives tab switches); this component is + * the presentation. There's no Save button — toggles save on change, text and + * token fields save on blur. * * @param props - Component props. * @param props.form - The settings form controller from `useSettingsForm`. * @return The Settings tab content. */ const SettingsScreen: FC< Props > = ( { form } ) => { - const { local, isDirty, isSaving, save, update, setVerification } = form; + const { local, setField, setVerification, commit } = form; + + // Overview deep links (`?focus=visibility|verification`) scroll the matching + // section to its top. `scroll-margin-top` on the section (style.scss) clears + // the fixed header + sticky tabs so the section title stays visible. + const search = useSearch( { from: '/' as unknown as never, strict: false } ) as SettingsSearch; + const focus = search.focus; + useEffect( () => { + if ( focus !== 'visibility' && focus !== 'verification' ) { + return; + } + const frame = requestAnimationFrame( () => { + document.getElementById( focus )?.scrollIntoView( { block: 'start' } ); + } ); + return () => cancelAnimationFrame( frame ); + }, [ focus ] ); if ( ! local ) { return ( @@ -44,64 +64,63 @@ const SettingsScreen: FC< Props > = ( { form } ) => { return (
-
- +
+ + + + { __( 'Site visibility', 'jetpack-seo' ) } + + { sprintf( + /* translators: %1$d: number of enabled visibility settings, %2$d: total. */ + __( '%1$d of %2$d enabled', 'jetpack-seo' ), + visibilityEnabledCount, + 2 + ) } + + + + + + commit( { search_engines_visible: next } ) } + __nextHasNoMarginBottom + /> +
+ commit( { sitemap_active: next } ) } + __nextHasNoMarginBottom + /> + { local.sitemap_active && ( +
+ + { local.sitemap_url } + + + { local.news_sitemap_url } + +
+ ) } +
+
+
+
- - - - { __( 'Site visibility', 'jetpack-seo' ) } - - { sprintf( - /* translators: %1$d: number of enabled visibility settings, %2$d: total. */ - __( '%1$d of %2$d enabled', 'jetpack-seo' ), - visibilityEnabledCount, - 2 - ) } - - - - - - update( { search_engines_visible: next } ) } - disabled={ isSaving } - __nextHasNoMarginBottom - /> - update( { sitemap_active: next } ) } - disabled={ isSaving } - __nextHasNoMarginBottom - /> - - - - update( { title_formats: { ...local.title_formats, posts: next } } ) } - disabled={ isSaving } + onChange={ next => commit( { title_formats: { ...local.title_formats, posts: next } } ) } /> @@ -117,19 +136,21 @@ const SettingsScreen: FC< Props > = ( { form } ) => { update( { front_page_description: next } ) } + onChange={ next => setField( { front_page_description: next } ) } + onBlur={ () => commit() } rows={ 3 } - disabled={ isSaving } __nextHasNoMarginBottom /> - +
+ commit() } + /> +
); }; diff --git a/projects/packages/seo/_inc/screens/settings/style.scss b/projects/packages/seo/_inc/screens/settings/style.scss index f1c3d31361a2..99ce5571e4d1 100644 --- a/projects/packages/seo/_inc/screens/settings/style.scss +++ b/projects/packages/seo/_inc/screens/settings/style.scss @@ -1,14 +1,27 @@ +// Centered fixed-width column. 660px is the established Jetpack settings-page +// width (see Newsletter's settings/style.scss: "MSD and future JP settings +// pages are 660px wide"); there is no design token for it. .jetpack-seo-settings { display: flex; flex-direction: column; gap: var(--wpds-dimension-gap-lg, 16px); - max-width: 660px; + max-inline-size: 660px; + margin-inline: auto; } -// Save action row, pinned to the top-right of the form. -.jetpack-seo-settings__actions { +// Deep-link target wrapper. `scroll-margin-top` clears the fixed header + +// sticky tabs strip so the scrolled-to section's title stays visible. +.jetpack-seo-settings__section { + scroll-margin-top: 64px; +} + +// Sitemap URLs shown under the sitemap toggle when it's on. +.jetpack-seo-settings__sitemap-urls { display: flex; - justify-content: flex-end; + flex-direction: column; + gap: var(--wpds-dimension-gap-xs, 4px); + margin-top: var(--wpds-dimension-gap-sm, 8px); + font-size: 13px; } // Live preview under the title-structure token editor. diff --git a/projects/packages/seo/_inc/screens/settings/verification-card.tsx b/projects/packages/seo/_inc/screens/settings/verification-card.tsx index d60cd3d4e90a..79044c0f46e6 100644 --- a/projects/packages/seo/_inc/screens/settings/verification-card.tsx +++ b/projects/packages/seo/_inc/screens/settings/verification-card.tsx @@ -10,6 +10,8 @@ import type { FC } from 'react'; interface Props { value: SettingsResponse[ 'verification' ]; onChange: ( key: VerificationKey, value: string ) => void; + /** Save the current value — called on blur (auto-save, no Save button). */ + onCommit?: () => void; disabled?: boolean; } @@ -34,7 +36,7 @@ const services: Array< { key: VerificationKey; label: string; hint: string } > = const notSetLabel = __( 'Not set', 'jetpack-seo' ); -const VerificationCard: FC< Props > = ( { value, onChange, disabled } ) => { +const VerificationCard: FC< Props > = ( { value, onChange, onCommit, disabled } ) => { const verifiedCount = services.filter( ( { key } ) => !! value[ key ] ).length; return ( @@ -61,6 +63,7 @@ const VerificationCard: FC< Props > = ( { value, onChange, disabled } ) => { label={ label } value={ value[ key ] } onChange={ next => onChange( key, next ) } + onBlur={ onCommit } help={ hint } disabled={ disabled } __next40pxDefaultSize diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index c3a8e0dd93d3..533cc2588e54 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -275,18 +275,32 @@ public static function get_overview_data() { // @phan-suppress-next-line PhanUndeclaredClassMethod -- Same as above; only invoked when class_exists. $front_page_desc = $seo_enabled ? Jetpack_SEO_Utils::get_front_page_meta_description() : ''; + $codes = get_option( 'verification_services_codes', array() ); + if ( ! is_array( $codes ) ) { + $codes = array(); + } + return array( - 'site_visibility' => array( + 'site_visibility' => array( 'search_engines_visible' => (int) get_option( 'blog_public', 1 ) === 1, - 'sitemap_active' => (bool) get_option( 'jetpack_seo_sitemap_enabled', false ), - // Pre-wired for the sitemap "View" link, restored once real - // sitemaps-module detection lands (JETPACK-1694); no consumer yet. + // The real `sitemaps` module is the source of truth (the Settings + // toggle drives it via `/jetpack/v4/settings`). + 'sitemap_active' => $modules->is_active( 'sitemaps' ), 'sitemap_url' => home_url( '/sitemap.xml' ), 'seo_tools_active' => $modules->is_active( 'seo-tools' ), // Pre-wired for the Settings/Content follow-up tabs; no consumer yet. 'front_page_description' => (string) $front_page_desc, ), - 'plan' => array( + // Per-service booleans (a code is set or not) for the Overview's + // Site verification card. + 'site_verification' => array( + 'google' => ! empty( $codes['google'] ), + 'bing' => ! empty( $codes['bing'] ), + 'pinterest' => ! empty( $codes['pinterest'] ), + 'yandex' => ! empty( $codes['yandex'] ), + 'facebook' => ! empty( $codes['facebook'] ), + ), + 'plan' => array( 'seo_enabled_for_site' => $seo_enabled, ), ); @@ -343,6 +357,8 @@ public static function get_settings_data() { // The real `sitemaps` module is the source of truth (not a bespoke // option). The Settings toggle drives it via `/jetpack/v4/settings`. 'sitemap_active' => $modules->is_active( 'sitemaps' ), + 'sitemap_url' => home_url( '/sitemap.xml' ), + 'news_sitemap_url' => home_url( '/news-sitemap.xml' ), // Cast to object so an empty format set serializes as `{}`, not `[]`. 'title_formats' => (object) $title_formats, 'front_page_description' => (string) $front_page_desc, From f34cc73e454876fdb3ec41852d06b37b5a01c2f8 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Mon, 1 Jun 2026 20:48:22 -0500 Subject: [PATCH 4/6] Jetpack SEO: settings-tab polish from JN review - Overview content now uses a wider centered column (1128px, My Jetpack-like) so cards sit side-by-side comfortably; Settings stays the 660px form column. - Deep-linking to Site verification now also expands that card (was scrolling to a collapsed header). - Overview "Manage" buttons are bottom-right; the Site visibility button reads "Manage visibility". - Drop the sitemap URL links (both /sitemap.xml and the niche Google-News /news-sitemap.xml): Jetpack generates the sitemap on a background cron, so the URL can 404 until then. Surfacing a link only once the sitemap is actually generated is tracked in JETPACK-1694 (via Jetpack_Sitemap_Librarian). The sitemap toggle itself is unchanged. --- .../packages/seo/_inc/data/settings-types.ts | 2 - .../screens/overview/site-visibility-card.tsx | 19 +++----- .../seo/_inc/screens/overview/style.scss | 11 +++-- .../seo/_inc/screens/settings/index.tsx | 45 +++++++++---------- .../seo/_inc/screens/settings/style.scss | 9 ---- .../screens/settings/verification-card.tsx | 18 +++++++- .../packages/seo/src/class-initializer.php | 2 - 7 files changed, 51 insertions(+), 55 deletions(-) diff --git a/projects/packages/seo/_inc/data/settings-types.ts b/projects/packages/seo/_inc/data/settings-types.ts index 1f62609c4161..e39233a25d97 100644 --- a/projects/packages/seo/_inc/data/settings-types.ts +++ b/projects/packages/seo/_inc/data/settings-types.ts @@ -19,8 +19,6 @@ export interface SettingsResponse { }; search_engines_visible: boolean; sitemap_active: boolean; - sitemap_url: string; - news_sitemap_url: string; } export type VerificationKey = keyof SettingsResponse[ 'verification' ]; diff --git a/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx b/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx index 7d3fffe2b5de..a79a80c71a8b 100644 --- a/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx +++ b/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx @@ -1,6 +1,6 @@ import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; -import { Card, Link, Stack } from '@wordpress/ui'; +import { Card, Stack } from '@wordpress/ui'; import StatusDot from './status-dot'; import type { OverviewResponse } from '../../data/overview-types'; import type { FC } from 'react'; @@ -31,17 +31,10 @@ const SiteVisibilityCard: FC< Props > = ( { data, onManage } ) => ( status={ data.search_engines_visible ? 'ok' : 'err' } label={ data.search_engines_visible ? searchAllowedLabel : searchBlockedLabel } /> - - - { data.sitemap_active && ( - - { __( 'View', 'jetpack-seo' ) } - - ) } - + = ( { data, onManage } ) => (
diff --git a/projects/packages/seo/_inc/screens/overview/style.scss b/projects/packages/seo/_inc/screens/overview/style.scss index 2b4f56e39882..f8620e5899ea 100644 --- a/projects/packages/seo/_inc/screens/overview/style.scss +++ b/projects/packages/seo/_inc/screens/overview/style.scss @@ -1,7 +1,8 @@ -// Centered fixed-width column to match the Settings tab and other Jetpack -// settings pages (660px is the established JP settings-page width). +// Centered content column. Wider than the Settings tab (which is a single 660px +// form column) because the Overview lays cards out side-by-side — matches the +// My Jetpack page's content width. .jetpack-seo-overview { - max-inline-size: 660px; + max-inline-size: 1128px; margin-inline: auto; } @@ -36,8 +37,10 @@ padding: var(--wpds-dimension-gap-xs, 4px) 0; } -// Card action row (Manage button), pinned to the bottom of the card. +// Card action row (Manage button), pinned to the bottom-right of the card. .jetpack-seo-overview__card-footer { + display: flex; + justify-content: flex-end; margin-top: auto; padding-top: var(--wpds-dimension-gap-md, 12px); } diff --git a/projects/packages/seo/_inc/screens/settings/index.tsx b/projects/packages/seo/_inc/screens/settings/index.tsx index 468d040690a7..663834629fba 100644 --- a/projects/packages/seo/_inc/screens/settings/index.tsx +++ b/projects/packages/seo/_inc/screens/settings/index.tsx @@ -1,10 +1,10 @@ /* eslint-disable react/jsx-no-bind */ import { Notice, TextareaControl, ToggleControl } from '@wordpress/components'; -import { useEffect } from '@wordpress/element'; +import { useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { useSearch } from '@wordpress/route'; -import { Badge, Card, CollapsibleCard, Link, Stack } from '@wordpress/ui'; +import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; import TitleStructureField from './title-structure-field'; import VerificationCard from './verification-card'; import './style.scss'; @@ -50,6 +50,15 @@ const SettingsScreen: FC< Props > = ( { form } ) => { return () => cancelAnimationFrame( frame ); }, [ focus ] ); + // Expand the verification card when deep-linked to it, so the user lands on + // the open section rather than a collapsed header. + const [ verificationOpen, setVerificationOpen ] = useState( focus === 'verification' ); + useEffect( () => { + if ( focus === 'verification' ) { + setVerificationOpen( true ); + } + }, [ focus ] ); + if ( ! local ) { return ( @@ -91,28 +100,16 @@ const SettingsScreen: FC< Props > = ( { form } ) => { onChange={ next => commit( { search_engines_visible: next } ) } __nextHasNoMarginBottom /> -
- commit( { sitemap_active: next } ) } - __nextHasNoMarginBottom - /> - { local.sitemap_active && ( -
- - { local.sitemap_url } - - - { local.news_sitemap_url } - -
+ + checked={ local.sitemap_active } + onChange={ next => commit( { sitemap_active: next } ) } + __nextHasNoMarginBottom + /> @@ -149,6 +146,8 @@ const SettingsScreen: FC< Props > = ( { form } ) => { value={ local.verification } onChange={ setVerification } onCommit={ () => commit() } + open={ verificationOpen } + onOpenChange={ setVerificationOpen } />
diff --git a/projects/packages/seo/_inc/screens/settings/style.scss b/projects/packages/seo/_inc/screens/settings/style.scss index 99ce5571e4d1..131bdb611050 100644 --- a/projects/packages/seo/_inc/screens/settings/style.scss +++ b/projects/packages/seo/_inc/screens/settings/style.scss @@ -15,15 +15,6 @@ scroll-margin-top: 64px; } -// Sitemap URLs shown under the sitemap toggle when it's on. -.jetpack-seo-settings__sitemap-urls { - display: flex; - flex-direction: column; - gap: var(--wpds-dimension-gap-xs, 4px); - margin-top: var(--wpds-dimension-gap-sm, 8px); - font-size: 13px; -} - // Live preview under the title-structure token editor. .jetpack-seo-settings__preview { margin-top: var(--wpds-dimension-gap-md, 12px); diff --git a/projects/packages/seo/_inc/screens/settings/verification-card.tsx b/projects/packages/seo/_inc/screens/settings/verification-card.tsx index 79044c0f46e6..fa61226ce0e6 100644 --- a/projects/packages/seo/_inc/screens/settings/verification-card.tsx +++ b/projects/packages/seo/_inc/screens/settings/verification-card.tsx @@ -13,6 +13,9 @@ interface Props { /** Save the current value — called on blur (auto-save, no Save button). */ onCommit?: () => void; disabled?: boolean; + /** Controlled open state — lets a deep link expand the card. Uncontrolled (collapsed) when omitted. */ + open?: boolean; + onOpenChange?: ( open: boolean ) => void; } const services: Array< { key: VerificationKey; label: string; hint: string } > = [ @@ -36,11 +39,22 @@ const services: Array< { key: VerificationKey; label: string; hint: string } > = const notSetLabel = __( 'Not set', 'jetpack-seo' ); -const VerificationCard: FC< Props > = ( { value, onChange, onCommit, disabled } ) => { +const VerificationCard: FC< Props > = ( { + value, + onChange, + onCommit, + disabled, + open, + onOpenChange, +} ) => { const verifiedCount = services.filter( ( { key } ) => !! value[ key ] ).length; + // CollapsibleCard.Root takes either controlled (`open`/`onOpenChange`) or + // uncontrolled (`defaultOpen`) props — one at a time. + const collapsibleProps = open === undefined ? { defaultOpen: false } : { open, onOpenChange }; + return ( - + { __( 'Site verification', 'jetpack-seo' ) } diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index 533cc2588e54..039f7f758b07 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -357,8 +357,6 @@ public static function get_settings_data() { // The real `sitemaps` module is the source of truth (not a bespoke // option). The Settings toggle drives it via `/jetpack/v4/settings`. 'sitemap_active' => $modules->is_active( 'sitemaps' ), - 'sitemap_url' => home_url( '/sitemap.xml' ), - 'news_sitemap_url' => home_url( '/news-sitemap.xml' ), // Cast to object so an empty format set serializes as `{}`, not `[]`. 'title_formats' => (object) $title_formats, 'front_page_description' => (string) $front_page_desc, From a482cb0cdc92218d1c8c740f789bcd9dfb689971 Mon Sep 17 00:00:00 2001 From: Angela Blake Date: Tue, 2 Jun 2026 10:51:02 -0500 Subject: [PATCH 5/6] Jetpack SEO: tidy #49256 docs + drop dead Overview field MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Audit pass on the Settings PR's docs and code: - README: rewrite to present-tense house style (matches Newsletter/Forms), covering both the Overview and Settings tabs instead of the foundation-only 'this ships X; follow-ups add Y' framing. - get_overview_data() + SiteVisibility type: drop front_page_description — the Overview never consumed it and the Settings tab reads it from its own settings payload, so it was dead data. - changelog: note Settings also writes core /wp/v2/settings (blog_public), not only /jetpack/v4/settings. Co-Authored-By: Claude Opus 4.8 (1M context) --- projects/packages/seo/README.md | 12 ++++++------ projects/packages/seo/_inc/data/overview-types.ts | 1 - projects/packages/seo/changelog/add-settings-tab | 2 +- projects/packages/seo/src/class-initializer.php | 4 ---- 4 files changed, 7 insertions(+), 12 deletions(-) diff --git a/projects/packages/seo/README.md b/projects/packages/seo/README.md index f40e10df16a9..6cd85d5304f3 100644 --- a/projects/packages/seo/README.md +++ b/projects/packages/seo/README.md @@ -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//{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 diff --git a/projects/packages/seo/_inc/data/overview-types.ts b/projects/packages/seo/_inc/data/overview-types.ts index 2050e78b8fbb..72a8272e4dda 100644 --- a/projects/packages/seo/_inc/data/overview-types.ts +++ b/projects/packages/seo/_inc/data/overview-types.ts @@ -7,7 +7,6 @@ export interface SiteVisibility { sitemap_active: boolean; sitemap_url: string; seo_tools_active: boolean; - front_page_description: string; } export interface SiteVerification { diff --git a/projects/packages/seo/changelog/add-settings-tab b/projects/packages/seo/changelog/add-settings-tab index 729b5e0e5d7e..d0b56119d6e2 100644 --- a/projects/packages/seo/changelog/add-settings-tab +++ b/projects/packages/seo/changelog/add-settings-tab @@ -1,4 +1,4 @@ Significance: minor Type: added -Add a Settings tab to the SEO admin page: site visibility (search-engine indexing + XML sitemap), post title structure, front-page description, and site verification. Saves through the existing /jetpack/v4/settings endpoint. +Add a Settings tab to the SEO admin page: site visibility (search-engine indexing + XML sitemap), post title structure, front-page description, and site verification. Saves through the existing /jetpack/v4/settings and core /wp/v2/settings REST endpoints — no new package endpoint. diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php index 039f7f758b07..f3512a3e55fb 100644 --- a/projects/packages/seo/src/class-initializer.php +++ b/projects/packages/seo/src/class-initializer.php @@ -272,8 +272,6 @@ public static function get_overview_data() { $modules = new Modules(); // @phan-suppress-next-line PhanUndeclaredClassMethod -- Jetpack_SEO_Utils lives in plugins/jetpack and is guarded by class_exists. $seo_enabled = class_exists( 'Jetpack_SEO_Utils' ) && Jetpack_SEO_Utils::is_enabled_jetpack_seo(); - // @phan-suppress-next-line PhanUndeclaredClassMethod -- Same as above; only invoked when class_exists. - $front_page_desc = $seo_enabled ? Jetpack_SEO_Utils::get_front_page_meta_description() : ''; $codes = get_option( 'verification_services_codes', array() ); if ( ! is_array( $codes ) ) { @@ -288,8 +286,6 @@ public static function get_overview_data() { 'sitemap_active' => $modules->is_active( 'sitemaps' ), 'sitemap_url' => home_url( '/sitemap.xml' ), 'seo_tools_active' => $modules->is_active( 'seo-tools' ), - // Pre-wired for the Settings/Content follow-up tabs; no consumer yet. - 'front_page_description' => (string) $front_page_desc, ), // Per-service booleans (a code is set or not) for the Overview's // Site verification card. From a0349b38024fb86a8add3e755ee113ec23f00106 Mon Sep 17 00:00:00 2001 From: Douglas Date: Tue, 2 Jun 2026 18:46:55 -0300 Subject: [PATCH 6/6] Jetpack SEO: address settings-tab review feedback - Use @wordpress/ui Notice (Notice.Root/Description) on the Settings "unable to load" state, matching the Overview screen. - Disable the Settings controls while a save is in flight (wire the hook's isSaving), closing the auto-save overlap/baseline-desync window. - Hoist the verification-services list to data/verification-services.ts as the single source consumed by both cards and the save payload. - Extract the pure payload-diffing and title-token helpers into data/build-payload.ts and data/title-format-tokens.ts, and cover them with unit tests (19 cases). - Use double quotes (not backticks) in the Google verification hint. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../packages/seo/_inc/data/build-payload.ts | 65 ++++++++++ .../seo/_inc/data/test/build-payload.test.ts | 113 ++++++++++++++++++ .../data/test/title-format-tokens.test.ts | 46 +++++++ .../seo/_inc/data/title-format-tokens.ts | 56 +++++++++ .../packages/seo/_inc/data/use-settings.ts | 67 +---------- .../seo/_inc/data/verification-services.ts | 20 ++++ .../overview/site-verification-card.tsx | 11 +- .../seo/_inc/screens/settings/index.tsx | 17 ++- .../settings/title-structure-field.tsx | 35 +----- .../screens/settings/verification-card.tsx | 37 +++--- 10 files changed, 331 insertions(+), 136 deletions(-) create mode 100644 projects/packages/seo/_inc/data/build-payload.ts create mode 100644 projects/packages/seo/_inc/data/test/build-payload.test.ts create mode 100644 projects/packages/seo/_inc/data/test/title-format-tokens.test.ts create mode 100644 projects/packages/seo/_inc/data/title-format-tokens.ts create mode 100644 projects/packages/seo/_inc/data/verification-services.ts diff --git a/projects/packages/seo/_inc/data/build-payload.ts b/projects/packages/seo/_inc/data/build-payload.ts new file mode 100644 index 000000000000..cc4b4fe439a8 --- /dev/null +++ b/projects/packages/seo/_inc/data/build-payload.ts @@ -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; +} diff --git a/projects/packages/seo/_inc/data/test/build-payload.test.ts b/projects/packages/seo/_inc/data/test/build-payload.test.ts new file mode 100644 index 000000000000..c94d44acfd11 --- /dev/null +++ b/projects/packages/seo/_inc/data/test/build-payload.test.ts @@ -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( {} ); + } ); +} ); diff --git a/projects/packages/seo/_inc/data/test/title-format-tokens.test.ts b/projects/packages/seo/_inc/data/test/title-format-tokens.test.ts new file mode 100644 index 000000000000..312de2e3a5b3 --- /dev/null +++ b/projects/packages/seo/_inc/data/test/title-format-tokens.test.ts @@ -0,0 +1,46 @@ +/** + * @jest-environment node + */ +import { fromDisplay, toDisplay } from '../title-format-tokens'; +import type { TitleFormatToken } from '../settings-types'; + +describe( 'toDisplay', () => { + it( 'renders a known placeholder token as a bracketed label', () => { + expect( toDisplay( { type: 'token', value: 'site_name' } ) ).toBe( '[Site name]' ); + expect( toDisplay( { type: 'token', value: 'post_title' } ) ).toBe( '[Post title]' ); + } ); + + it( 'renders a literal string fragment verbatim', () => { + expect( toDisplay( { type: 'string', value: ' | ' } ) ).toBe( ' | ' ); + } ); + + it( 'falls back to the raw value for an unknown token id', () => { + expect( toDisplay( { type: 'token', value: 'mystery' } ) ).toBe( 'mystery' ); + } ); +} ); + +describe( 'fromDisplay', () => { + it( 'parses a known bracketed label back into its placeholder token', () => { + expect( fromDisplay( '[Site name]' ) ).toEqual( { type: 'token', value: 'site_name' } ); + expect( fromDisplay( '[Tagline]' ) ).toEqual( { type: 'token', value: 'tagline' } ); + } ); + + it( 'treats an unknown bracketed string as a literal fragment', () => { + expect( fromDisplay( '[Unknown]' ) ).toEqual( { type: 'string', value: '[Unknown]' } ); + } ); + + it( 'treats a plain separator as a literal fragment', () => { + expect( fromDisplay( ' | ' ) ).toEqual( { type: 'string', value: ' | ' } ); + } ); +} ); + +describe( 'round-trip', () => { + it( 'is stable for a mixed token/string structure', () => { + const tokens: TitleFormatToken[] = [ + { type: 'token', value: 'post_title' }, + { type: 'string', value: ' | ' }, + { type: 'token', value: 'site_name' }, + ]; + expect( tokens.map( toDisplay ).map( fromDisplay ) ).toEqual( tokens ); + } ); +} ); diff --git a/projects/packages/seo/_inc/data/title-format-tokens.ts b/projects/packages/seo/_inc/data/title-format-tokens.ts new file mode 100644 index 000000000000..1853145a75d8 --- /dev/null +++ b/projects/packages/seo/_inc/data/title-format-tokens.ts @@ -0,0 +1,56 @@ +// Token model for the post-title-structure editor. A title format is an ordered +// list of tokens — either a canonical placeholder (`site_name`) or a literal +// string fragment (a separator like " | "). The UI shows placeholders as +// bracketed pretty labels (`[Site name]`) so they're visually distinct from +// literal fragments; these helpers convert between the canonical model and that +// display form. Kept free of React/UI imports so the round-trip is unit-testable. + +import { __ } from '@wordpress/i18n'; +import type { TitleFormatToken } from './settings-types'; + +/** + * Canonical tokens supported for the `posts` page type. Mirrors the back-end + * list in Jetpack_SEO_Titles. Internal id stays snake_case for the REST + * payload; the UI shows a friendly bracketed label. + */ +export const TOKEN_LABELS: Record< string, string > = { + site_name: __( 'Site name', 'jetpack-seo' ), + tagline: __( 'Tagline', 'jetpack-seo' ), + post_title: __( 'Post title', 'jetpack-seo' ), +}; + +export const TOKEN_IDS = Object.keys( TOKEN_LABELS ); + +// Reverse map — "Site name" → "site_name" — to parse `[Site name]` back into +// the canonical id when the user picks a suggestion or pastes a label. +const LABEL_TO_TOKEN_ID: Record< string, string > = Object.fromEntries( + TOKEN_IDS.map( id => [ TOKEN_LABELS[ id ], id ] ) +); + +/** + * Render a token as its display string: bracketed pretty label for a known + * placeholder, raw value for a literal string fragment. + * + * @param token - The canonical token. + * @return The display string. + */ +export const toDisplay = ( token: TitleFormatToken ): string => + token.type === 'token' && TOKEN_LABELS[ token.value ] + ? `[${ TOKEN_LABELS[ token.value ] }]` + : token.value; + +/** + * Parse a display string back into a canonical token. `[Known label]` becomes + * the matching placeholder; anything else is a literal string fragment. + * + * @param display - The display string from the token field. + * @return The canonical token. + */ +export const fromDisplay = ( display: string ): TitleFormatToken => { + const match = display.match( /^\[(.+)\]$/ ); + const inner = match?.[ 1 ]; + if ( inner && LABEL_TO_TOKEN_ID[ inner ] ) { + return { type: 'token', value: LABEL_TO_TOKEN_ID[ inner ] }; + } + return { type: 'string', value: display }; +}; diff --git a/projects/packages/seo/_inc/data/use-settings.ts b/projects/packages/seo/_inc/data/use-settings.ts index af0368d1e2cd..10baae8f3b26 100644 --- a/projects/packages/seo/_inc/data/use-settings.ts +++ b/projects/packages/seo/_inc/data/use-settings.ts @@ -4,6 +4,7 @@ import { useDispatch } from '@wordpress/data'; import { useCallback, useEffect, useMemo, useRef, useState } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { store as noticesStore } from '@wordpress/notices'; +import { buildCorePayload, buildJetpackPayload } from './build-payload'; import type { SettingsResponse, VerificationKey } from './settings-types'; // Single snackbar id reused across a save so "Updating settings…" is replaced @@ -29,72 +30,6 @@ export function getSettings(): SettingsResponse | null { return scriptData?.seo?.settings ?? null; } -const VERIFICATION_KEYS: readonly VerificationKey[] = [ - 'google', - 'bing', - 'pinterest', - 'yandex', - 'facebook', -]; - -/** - * 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`. - */ -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. - */ -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; -} - export interface SettingsForm { local: SettingsResponse | null; isSaving: boolean; diff --git a/projects/packages/seo/_inc/data/verification-services.ts b/projects/packages/seo/_inc/data/verification-services.ts new file mode 100644 index 000000000000..ffd50ceb3ac9 --- /dev/null +++ b/projects/packages/seo/_inc/data/verification-services.ts @@ -0,0 +1,20 @@ +// Single source of truth for the search-engine/social verification services the +// SEO feature supports, in display order. Consumed by the Settings verification +// card, the Overview verification card, and the save-payload builder — so a +// service is added or removed in exactly one place. +// +// Labels are brand names and are intentionally not translated. + +import type { VerificationKey } from './settings-types'; + +export const VERIFICATION_SERVICES: ReadonlyArray< { key: VerificationKey; label: string } > = [ + { key: 'google', label: 'Google' }, + { key: 'bing', label: 'Bing' }, + { key: 'pinterest', label: 'Pinterest' }, + { key: 'yandex', label: 'Yandex' }, + { key: 'facebook', label: 'Facebook' }, +]; + +export const VERIFICATION_KEYS: readonly VerificationKey[] = VERIFICATION_SERVICES.map( + service => service.key +); diff --git a/projects/packages/seo/_inc/screens/overview/site-verification-card.tsx b/projects/packages/seo/_inc/screens/overview/site-verification-card.tsx index 3c0fd58ad05d..0aacd10c6391 100644 --- a/projects/packages/seo/_inc/screens/overview/site-verification-card.tsx +++ b/projects/packages/seo/_inc/screens/overview/site-verification-card.tsx @@ -1,6 +1,7 @@ import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { Card } from '@wordpress/ui'; +import { VERIFICATION_SERVICES } from '../../data/verification-services'; import StatusDot from './status-dot'; import type { SiteVerification } from '../../data/overview-types'; import type { FC } from 'react'; @@ -10,14 +11,6 @@ interface Props { onManage: () => void; } -const services: Array< { key: keyof SiteVerification; label: string } > = [ - { key: 'google', label: 'Google' }, - { key: 'bing', label: 'Bing' }, - { key: 'pinterest', label: 'Pinterest' }, - { key: 'yandex', label: 'Yandex' }, - { key: 'facebook', label: 'Facebook' }, -]; - // Module-scope so the production minifier can't fold an adjacent ternary // `__()` into `__(cond ? A : B)`. See feedback_i18n_ternary_minifier_fold. const verifiedLabel = __( 'Verified', 'jetpack-seo' ); @@ -29,7 +22,7 @@ const SiteVerificationCard: FC< Props > = ( { data, onManage } ) => ( { __( 'Site verification', 'jetpack-seo' ) } - { services.map( ( { key, label } ) => ( + { VERIFICATION_SERVICES.map( ( { key, label } ) => (
{ data[ key ] ? verifiedLabel : notSetLabel } diff --git a/projects/packages/seo/_inc/screens/settings/index.tsx b/projects/packages/seo/_inc/screens/settings/index.tsx index 663834629fba..0ce0e57ef513 100644 --- a/projects/packages/seo/_inc/screens/settings/index.tsx +++ b/projects/packages/seo/_inc/screens/settings/index.tsx @@ -1,10 +1,10 @@ /* eslint-disable react/jsx-no-bind */ -import { Notice, TextareaControl, ToggleControl } from '@wordpress/components'; +import { TextareaControl, ToggleControl } from '@wordpress/components'; import { useEffect, useState } from '@wordpress/element'; import { __, sprintf } from '@wordpress/i18n'; import { useSearch } from '@wordpress/route'; -import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; +import { Badge, Card, CollapsibleCard, Notice, Stack } from '@wordpress/ui'; import TitleStructureField from './title-structure-field'; import VerificationCard from './verification-card'; import './style.scss'; @@ -33,7 +33,7 @@ type SettingsSearch = Record< string, unknown > & { focus?: string }; * @return The Settings tab content. */ const SettingsScreen: FC< Props > = ( { form } ) => { - const { local, setField, setVerification, commit } = form; + const { local, isSaving, setField, setVerification, commit } = form; // Overview deep links (`?focus=visibility|verification`) scroll the matching // section to its top. `scroll-margin-top` on the section (style.scss) clears @@ -61,9 +61,9 @@ const SettingsScreen: FC< Props > = ( { form } ) => { if ( ! local ) { return ( - - { __( 'Unable to load settings.', 'jetpack-seo' ) } - + + { __( 'Unable to load settings.', 'jetpack-seo' ) } + ); } @@ -98,6 +98,7 @@ const SettingsScreen: FC< Props > = ( { form } ) => { ) } checked={ local.search_engines_visible } onChange={ next => commit( { search_engines_visible: next } ) } + disabled={ isSaving } __nextHasNoMarginBottom /> = ( { form } ) => { ) } checked={ local.sitemap_active } onChange={ next => commit( { sitemap_active: next } ) } + disabled={ isSaving } __nextHasNoMarginBottom /> @@ -118,6 +120,7 @@ const SettingsScreen: FC< Props > = ( { form } ) => { commit( { title_formats: { ...local.title_formats, posts: next } } ) } + disabled={ isSaving } /> @@ -136,6 +139,7 @@ const SettingsScreen: FC< Props > = ( { form } ) => { onChange={ next => setField( { front_page_description: next } ) } onBlur={ () => commit() } rows={ 3 } + disabled={ isSaving } __nextHasNoMarginBottom /> @@ -146,6 +150,7 @@ const SettingsScreen: FC< Props > = ( { form } ) => { value={ local.verification } onChange={ setVerification } onCommit={ () => commit() } + disabled={ isSaving } open={ verificationOpen } onOpenChange={ setVerificationOpen } /> diff --git a/projects/packages/seo/_inc/screens/settings/title-structure-field.tsx b/projects/packages/seo/_inc/screens/settings/title-structure-field.tsx index 1357b02c0b74..044f438bbd5b 100644 --- a/projects/packages/seo/_inc/screens/settings/title-structure-field.tsx +++ b/projects/packages/seo/_inc/screens/settings/title-structure-field.tsx @@ -6,44 +6,11 @@ import { FormTokenField } from '@wordpress/components'; import { useMemo } from '@wordpress/element'; import { __ } from '@wordpress/i18n'; import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; +import { TOKEN_IDS, TOKEN_LABELS, fromDisplay, toDisplay } from '../../data/title-format-tokens'; import './style.scss'; import type { TitleFormatToken } from '../../data/settings-types'; import type { FC } from 'react'; -/** - * Canonical tokens supported for the `posts` page type. Mirrors the back-end - * list in Jetpack_SEO_Titles. Internal id stays snake_case for the REST - * payload; the UI shows a friendly bracketed label so users can distinguish - * token chips from literal string fragments (separators like " | "). - */ -const TOKEN_LABELS: Record< string, string > = { - site_name: __( 'Site name', 'jetpack-seo' ), - tagline: __( 'Tagline', 'jetpack-seo' ), - post_title: __( 'Post title', 'jetpack-seo' ), -}; - -const TOKEN_IDS = Object.keys( TOKEN_LABELS ); - -// Reverse map — "Site name" → "site_name" — to parse `[Site name]` back into -// the canonical id when the user picks a suggestion or pastes a label. -const LABEL_TO_TOKEN_ID: Record< string, string > = Object.fromEntries( - TOKEN_IDS.map( id => [ TOKEN_LABELS[ id ], id ] ) -); - -const toDisplay = ( token: TitleFormatToken ): string => - token.type === 'token' && TOKEN_LABELS[ token.value ] - ? `[${ TOKEN_LABELS[ token.value ] }]` - : token.value; - -const fromDisplay = ( display: string ): TitleFormatToken => { - const match = display.match( /^\[(.+)\]$/ ); - const inner = match?.[ 1 ]; - if ( inner && LABEL_TO_TOKEN_ID[ inner ] ) { - return { type: 'token', value: LABEL_TO_TOKEN_ID[ inner ] }; - } - return { type: 'string', value: display }; -}; - // Pre-resolved 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. diff --git a/projects/packages/seo/_inc/screens/settings/verification-card.tsx b/projects/packages/seo/_inc/screens/settings/verification-card.tsx index fa61226ce0e6..5cc62964b7ae 100644 --- a/projects/packages/seo/_inc/screens/settings/verification-card.tsx +++ b/projects/packages/seo/_inc/screens/settings/verification-card.tsx @@ -3,6 +3,7 @@ import { TextControl } from '@wordpress/components'; import { __, sprintf, _n } from '@wordpress/i18n'; import { Badge, Card, CollapsibleCard, Stack } from '@wordpress/ui'; +import { VERIFICATION_SERVICES } from '../../data/verification-services'; import './style.scss'; import type { SettingsResponse, VerificationKey } from '../../data/settings-types'; import type { FC } from 'react'; @@ -18,24 +19,18 @@ interface Props { onOpenChange?: ( open: boolean ) => void; } -const services: Array< { key: VerificationKey; label: string; hint: string } > = [ - { - key: 'google', - label: 'Google', - hint: __( - 'Paste the `content` attribute from the Google Search Console meta tag.', - 'jetpack-seo' - ), - }, - { key: 'bing', label: 'Bing', hint: __( 'Bing Webmaster Tools meta tag.', 'jetpack-seo' ) }, - { key: 'pinterest', label: 'Pinterest', hint: __( 'Pinterest meta tag.', 'jetpack-seo' ) }, - { key: 'yandex', label: 'Yandex', hint: __( 'Yandex Webmaster meta tag.', 'jetpack-seo' ) }, - { - key: 'facebook', - label: 'Facebook', - hint: __( 'Facebook domain verification meta tag.', 'jetpack-seo' ), - }, -]; +// Per-service input hints, keyed by the shared service id. The service list and +// brand labels live in `data/verification-services` (single source of truth). +const HINTS: Record< VerificationKey, string > = { + google: __( + 'Paste the "content" attribute from the Google Search Console meta tag.', + 'jetpack-seo' + ), + bing: __( 'Bing Webmaster Tools meta tag.', 'jetpack-seo' ), + pinterest: __( 'Pinterest meta tag.', 'jetpack-seo' ), + yandex: __( 'Yandex Webmaster meta tag.', 'jetpack-seo' ), + facebook: __( 'Facebook domain verification meta tag.', 'jetpack-seo' ), +}; const notSetLabel = __( 'Not set', 'jetpack-seo' ); @@ -47,7 +42,7 @@ const VerificationCard: FC< Props > = ( { open, onOpenChange, } ) => { - const verifiedCount = services.filter( ( { key } ) => !! value[ key ] ).length; + const verifiedCount = VERIFICATION_SERVICES.filter( ( { key } ) => !! value[ key ] ).length; // CollapsibleCard.Root takes either controlled (`open`/`onOpenChange`) or // uncontrolled (`defaultOpen`) props — one at a time. @@ -71,14 +66,14 @@ const VerificationCard: FC< Props > = ( {
- { services.map( ( { key, label, hint } ) => ( + { VERIFICATION_SERVICES.map( ( { key, label } ) => ( onChange( key, next ) } onBlur={ onCommit } - help={ hint } + help={ HINTS[ key ] } disabled={ disabled } __next40pxDefaultSize __nextHasNoMarginBottom