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/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/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/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/overview-types.ts b/projects/packages/seo/_inc/data/overview-types.ts index 159a01e31ad0..72a8272e4dda 100644 --- a/projects/packages/seo/_inc/data/overview-types.ts +++ b/projects/packages/seo/_inc/data/overview-types.ts @@ -7,11 +7,19 @@ export interface SiteVisibility { sitemap_active: boolean; sitemap_url: string; seo_tools_active: boolean; - front_page_description: string; +} + +export interface SiteVerification { + google: boolean; + bing: boolean; + pinterest: boolean; + yandex: boolean; + facebook: boolean; } export interface OverviewResponse { site_visibility: SiteVisibility; + site_verification: SiteVerification; plan: { seo_enabled_for_site: boolean; }; 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/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 new file mode 100644 index 000000000000..10baae8f3b26 --- /dev/null +++ b/projects/packages/seo/_inc/data/use-settings.ts @@ -0,0 +1,148 @@ +import { getScriptData } from '@automattic/jetpack-script-data'; +import apiFetch from '@wordpress/api-fetch'; +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 +// 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; + }; +}; + +/** + * 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; +} + +export interface SettingsForm { + local: SettingsResponse | null; + isSaving: boolean; + /** 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 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. + * + * 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 [ local, setLocal ] = useState< SettingsResponse | null >( initial ); + const [ isSaving, setIsSaving ] = useState( false ); + const { createInfoNotice, createSuccessNotice, createErrorNotice } = useDispatch( noticesStore ); + + // 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 setField = 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 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 ] + ); + + return { local, isSaving, setField, setVerification, commit }; +} 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/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/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..0aacd10c6391 --- /dev/null +++ b/projects/packages/seo/_inc/screens/overview/site-verification-card.tsx @@ -0,0 +1,40 @@ +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'; + +interface Props { + data: SiteVerification; + onManage: () => void; +} + +// 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' ) } + + + { VERIFICATION_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..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,3 +1,4 @@ +import { Button } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; import { Card, Stack } from '@wordpress/ui'; import StatusDot from './status-dot'; @@ -6,6 +7,7 @@ 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' ) } @@ -38,6 +40,11 @@ const SiteVisibilityCard: FC< Props > = ( { data } ) => ( label={ data.seo_tools_active ? seoToolsActiveLabel : seoToolsInactiveLabel } /> +
+ +
); diff --git a/projects/packages/seo/_inc/screens/overview/style.scss b/projects/packages/seo/_inc/screens/overview/style.scss index a7631003216e..f8620e5899ea 100644 --- a/projects/packages/seo/_inc/screens/overview/style.scss +++ b/projects/packages/seo/_inc/screens/overview/style.scss @@ -1,6 +1,46 @@ +// 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: 1128px; + 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-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 new file mode 100644 index 000000000000..0ce0e57ef513 --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/index.tsx @@ -0,0 +1,162 @@ +/* eslint-disable react/jsx-no-bind */ + +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, Notice, 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; +} + +type SettingsSearch = Record< string, unknown > & { focus?: string }; + +/** + * 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, 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 + // 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 ] ); + + // 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 ( + + { __( '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 + ) } + + + + + + commit( { search_engines_visible: next } ) } + disabled={ isSaving } + __nextHasNoMarginBottom + /> + commit( { sitemap_active: next } ) } + disabled={ isSaving } + __nextHasNoMarginBottom + /> + + + +
+ + commit( { title_formats: { ...local.title_formats, posts: next } } ) } + disabled={ isSaving } + /> + + + + + { __( 'Front-page description', 'jetpack-seo' ) } + + { local.front_page_description ? setLabel : notSetLabel } + + + + + setField( { front_page_description: next } ) } + onBlur={ () => commit() } + rows={ 3 } + disabled={ isSaving } + __nextHasNoMarginBottom + /> + + + +
+ commit() } + disabled={ isSaving } + open={ verificationOpen } + onOpenChange={ setVerificationOpen } + /> +
+
+ ); +}; + +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..131bdb611050 --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/style.scss @@ -0,0 +1,31 @@ +// 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-inline-size: 660px; + margin-inline: auto; +} + +// 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; +} + +// 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..044f438bbd5b --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/title-structure-field.tsx @@ -0,0 +1,91 @@ +/* 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 { 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'; + +// 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..5cc62964b7ae --- /dev/null +++ b/projects/packages/seo/_inc/screens/settings/verification-card.tsx @@ -0,0 +1,88 @@ +/* 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 { VERIFICATION_SERVICES } from '../../data/verification-services'; +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; + /** 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; +} + +// 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' ); + +const VerificationCard: FC< Props > = ( { + value, + onChange, + onCommit, + disabled, + open, + onOpenChange, +} ) => { + const verifiedCount = VERIFICATION_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' ) } + 0 ? 'stable' : 'draft' }> + { verifiedCount > 0 + ? sprintf( + /* translators: %d: number of verification services configured */ + _n( '%d verified', '%d verified', verifiedCount, 'jetpack-seo' ), + verifiedCount + ) + : notSetLabel } + + + + +
+ { VERIFICATION_SERVICES.map( ( { key, label } ) => ( + onChange( key, next ) } + onBlur={ onCommit } + help={ HINTS[ key ] } + 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..d0b56119d6e2 --- /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 and core /wp/v2/settings REST endpoints — no new package 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..f3512a3e55fb 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; /** @@ -106,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. * @@ -214,6 +221,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; } @@ -264,23 +272,97 @@ 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 ) ) { + $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, ), ); } + + /** + * 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. + * + * 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'] : '', + ), + ); + } }