diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e593660e50f2..b277262bf7fa 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -4520,6 +4520,112 @@ importers:
specifier: 6.0.1
version: 6.0.1(webpack@5.105.2)
+ projects/packages/seo:
+ dependencies:
+ '@automattic/babel-plugin-replace-textdomain':
+ specifier: workspace:*
+ version: link:../../js-packages/babel-plugin-replace-textdomain
+ '@automattic/jetpack-base-styles':
+ specifier: workspace:*
+ version: link:../../js-packages/base-styles
+ '@automattic/jetpack-components':
+ specifier: workspace:*
+ version: link:../../js-packages/components
+ '@automattic/jetpack-script-data':
+ specifier: workspace:*
+ version: link:../../js-packages/script-data
+ '@automattic/jetpack-wp-build-polyfills':
+ specifier: workspace:*
+ version: link:../wp-build-polyfills
+ '@wordpress/components':
+ specifier: 33.1.0
+ version: 33.1.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/element':
+ specifier: 6.46.0
+ version: 6.46.0
+ '@wordpress/i18n':
+ specifier: 6.19.0
+ version: 6.19.0
+ '@wordpress/icons':
+ specifier: 13.1.0
+ version: 13.1.0(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)
+ '@wordpress/ui':
+ specifier: 0.13.0
+ version: 0.13.0(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@wordpress/url':
+ specifier: 4.46.0
+ version: 4.46.0
+ clsx:
+ specifier: 2.1.1
+ version: 2.1.1
+ devDependencies:
+ '@babel/core':
+ specifier: 7.29.0
+ version: 7.29.0
+ '@babel/preset-env':
+ specifier: 7.29.2
+ version: 7.29.2(@babel/core@7.29.0)
+ '@testing-library/dom':
+ specifier: 10.4.1
+ version: 10.4.1
+ '@testing-library/jest-dom':
+ specifier: 6.9.1
+ version: 6.9.1
+ '@testing-library/react':
+ specifier: 16.3.2
+ version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ '@types/jest':
+ specifier: 30.0.0
+ version: 30.0.0
+ '@types/node':
+ specifier: ^24.12.0
+ version: 24.12.3
+ '@types/react':
+ specifier: 18.3.28
+ version: 18.3.28
+ '@types/react-dom':
+ specifier: 18.3.7
+ version: 18.3.7(@types/react@18.3.28)
+ '@typescript/native-preview':
+ specifier: 7.0.0-dev.20260225.1
+ version: 7.0.0-dev.20260225.1
+ '@wordpress/base-styles':
+ specifier: 8.0.0
+ version: 8.0.0
+ '@wordpress/browserslist-config':
+ specifier: 6.46.0
+ version: 6.46.0
+ '@wordpress/build':
+ specifier: 0.14.0
+ version: 0.14.0(@babel/core@7.29.0)(@wordpress/route@0.12.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(@wordpress/theme@0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1))(browserslist@4.28.2)
+ '@wordpress/theme':
+ specifier: 0.13.0
+ version: 0.13.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
+ browserslist:
+ specifier: ^4.24.0
+ version: 4.28.2
+ jest:
+ specifier: 30.4.2
+ version: 30.4.2(@types/node@24.12.3)
+ react:
+ specifier: 18.3.1
+ version: 18.3.1
+ react-dom:
+ specifier: 18.3.1
+ version: 18.3.1(react@18.3.1)
+ sass-embedded:
+ specifier: 1.97.3
+ version: 1.97.3
+ sass-loader:
+ specifier: 16.0.5
+ version: 16.0.5(sass-embedded@1.97.3)
+ typescript:
+ specifier: 5.9.3
+ version: 5.9.3
+
projects/packages/stats-admin: {}
projects/packages/transport-helper: {}
@@ -32231,6 +32337,12 @@ snapshots:
sass-embedded-win32-arm64: 1.97.3
sass-embedded-win32-x64: 1.97.3
+ sass-loader@16.0.5(sass-embedded@1.97.3):
+ dependencies:
+ neo-async: 2.6.2
+ optionalDependencies:
+ sass-embedded: 1.97.3
+
sass-loader@16.0.5(sass-embedded@1.97.3)(webpack@5.105.2):
dependencies:
neo-async: 2.6.2
diff --git a/projects/packages/seo/.gitattributes b/projects/packages/seo/.gitattributes
new file mode 100644
index 000000000000..e8047e5e6f6a
--- /dev/null
+++ b/projects/packages/seo/.gitattributes
@@ -0,0 +1,17 @@
+# Files to include in the mirror repo, but excluded via gitignore
+# Remember to end all directories with `/**` to properly tag every file.
+/build/** production-include
+
+# Files to exclude from the mirror repo, but included in the monorepo.
+# (see also entries in the monorepo root .gitattributes)
+# Remember to end all directories with `/**` to properly tag every file.
+global.d.ts production-exclude
+routes/** production-exclude
+_inc/**/*.scss production-exclude
+_inc/**/*.tsx production-exclude
+_inc/**/*.ts production-exclude
+_inc/**/*.jsx production-exclude
+src/**/*.scss production-exclude
+src/**/*.tsx production-exclude
+src/**/*.ts production-exclude
+src/**/*.jsx production-exclude
diff --git a/projects/packages/seo/.gitignore b/projects/packages/seo/.gitignore
new file mode 100644
index 000000000000..77b928272c4a
--- /dev/null
+++ b/projects/packages/seo/.gitignore
@@ -0,0 +1,5 @@
+vendor/
+node_modules/
+build/
+.cache/
+wordpress
diff --git a/projects/packages/seo/.phan/config.php b/projects/packages/seo/.phan/config.php
new file mode 100644
index 000000000000..334ac14a8d8c
--- /dev/null
+++ b/projects/packages/seo/.phan/config.php
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/projects/packages/seo/CHANGELOG.md b/projects/packages/seo/CHANGELOG.md
new file mode 100644
index 000000000000..03a962f457f6
--- /dev/null
+++ b/projects/packages/seo/CHANGELOG.md
@@ -0,0 +1,6 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
diff --git a/projects/packages/seo/README.md b/projects/packages/seo/README.md
new file mode 100644
index 000000000000..f40e10df16a9
--- /dev/null
+++ b/projects/packages/seo/README.md
@@ -0,0 +1,36 @@
+# Jetpack SEO
+
+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.
+
+## What this foundation provides
+
+- 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).
+
+## 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.
+
+## Development
+
+```bash
+# Build once (from the repo root)
+jetpack build packages/seo
+
+# Watch mode
+pnpm --filter='@automattic/jetpack-seo' run watch
+
+# Typecheck
+pnpm --filter='@automattic/jetpack-seo' run typecheck
+
+# Tests
+pnpm --filter='@automattic/jetpack-seo' run test
+```
+
+The built JS/CSS lives in `build/` and is included in the vendored Jetpack plugin distribution via `.gitattributes` (`/build/** production-include`).
diff --git a/projects/packages/seo/_inc/admin-page-layout.scss b/projects/packages/seo/_inc/admin-page-layout.scss
new file mode 100644
index 000000000000..260edd4da8ba
--- /dev/null
+++ b/projects/packages/seo/_inc/admin-page-layout.scss
@@ -0,0 +1,22 @@
+@use "@automattic/jetpack-base-styles/admin-page-layout" as *;
+
+// Apply the shared Jetpack admin-page layout so `` owns the page
+// chrome: fixed header, scrollable middle, pinned footer. We use the
+// `-wp-build` variant because this is a wp-build dashboard: it's a superset
+// of the base mixin that adds `` resets — a no-op until a tab
+// renders breadcrumbs, so it's forward-compatible for the follow-up tabs.
+// Both menu placements are targeted because Admin_Menu may register the page as
+// a top-level menu or as a Jetpack submenu depending on the environment.
+body.jetpack_page_jetpack-seo,
+body.toplevel_page_jetpack-seo {
+
+ @include jetpack-admin-page-layout-wp-build;
+}
+
+// `` renders content with `horizontalSpacing={0}` and no
+// `hasPadding`, so the content area gets no padding from admin-ui or the layout
+// mixin — only the header does (at `padding-inline: 2xl`). Pad our content to
+// align with the header and clear it (the consumer-side job Scan/Podcast own).
+.jetpack-seo-page-content {
+ padding: var(--wpds-dimension-padding-2xl);
+}
diff --git a/projects/packages/seo/_inc/app.tsx b/projects/packages/seo/_inc/app.tsx
new file mode 100644
index 000000000000..c3392436eb54
--- /dev/null
+++ b/projects/packages/seo/_inc/app.tsx
@@ -0,0 +1,35 @@
+import { AdminPage, ThemeProvider } from '@automattic/jetpack-components';
+import { __ } from '@wordpress/i18n';
+import OverviewScreen from './screens/overview';
+import './admin-page-layout.scss';
+import type { FC } from 'react';
+
+/**
+ * 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.
+ *
+ * @return The Jetpack SEO admin page.
+ */
+const App: FC = () => (
+
+
+
+
+
+
+
+);
+
+export default App;
diff --git a/projects/packages/seo/_inc/data/get-overview.ts b/projects/packages/seo/_inc/data/get-overview.ts
new file mode 100644
index 000000000000..39aefdd5c53f
--- /dev/null
+++ b/projects/packages/seo/_inc/data/get-overview.ts
@@ -0,0 +1,23 @@
+import { getScriptData } from '@automattic/jetpack-script-data';
+import type { OverviewResponse } from './overview-types';
+
+type SeoScriptData = {
+ seo?: {
+ overview?: OverviewResponse;
+ };
+};
+
+/**
+ * Read the aggregated Overview state.
+ *
+ * The server bootstraps it onto `window.JetpackScriptData.seo.overview` via the
+ * `jetpack_admin_js_script_data` filter (see `Initializer::inject_script_data()`),
+ * so it's on the page at first paint — the Overview reads it synchronously with
+ * no request and no loading state. Returns `null` if the bootstrap is missing.
+ *
+ * @return The Overview state, or `null` when unavailable.
+ */
+export default function getOverview(): OverviewResponse | null {
+ const scriptData = getScriptData() as SeoScriptData | undefined;
+ return scriptData?.seo?.overview ?? null;
+}
diff --git a/projects/packages/seo/_inc/data/overview-types.ts b/projects/packages/seo/_inc/data/overview-types.ts
new file mode 100644
index 000000000000..159a01e31ad0
--- /dev/null
+++ b/projects/packages/seo/_inc/data/overview-types.ts
@@ -0,0 +1,18 @@
+// Shape of the aggregated Overview state the server bootstraps onto
+// `window.JetpackScriptData.seo.overview` (see `Initializer::get_overview_data()`).
+// Plain TypeScript — the server owns the payload, so no runtime schema is needed.
+
+export interface SiteVisibility {
+ search_engines_visible: boolean;
+ sitemap_active: boolean;
+ sitemap_url: string;
+ seo_tools_active: boolean;
+ front_page_description: string;
+}
+
+export interface OverviewResponse {
+ site_visibility: SiteVisibility;
+ plan: {
+ seo_enabled_for_site: boolean;
+ };
+}
diff --git a/projects/packages/seo/_inc/screens/overview/index.tsx b/projects/packages/seo/_inc/screens/overview/index.tsx
new file mode 100644
index 000000000000..87cbe8e05516
--- /dev/null
+++ b/projects/packages/seo/_inc/screens/overview/index.tsx
@@ -0,0 +1,38 @@
+import { __ } from '@wordpress/i18n';
+import { Notice } from '@wordpress/ui';
+import getOverview from '../../data/get-overview';
+import SiteVisibilityCard from './site-visibility-card';
+import './style.scss';
+import type { FC } from 'react';
+
+const OverviewScreen: FC = () => {
+ const data = getOverview();
+
+ if ( ! data ) {
+ return (
+
+ { __( 'Unable to load overview.', 'jetpack-seo' ) }
+
+ );
+ }
+
+ return (
+ <>
+ { ! data.plan.seo_enabled_for_site && (
+
+
+ { __(
+ 'SEO tools are not enabled on this site. Some cards reflect the underlying WordPress options only.',
+ 'jetpack-seo'
+ ) }
+
+
+ ) }
+
+
+
+ >
+ );
+};
+
+export default OverviewScreen;
diff --git a/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx b/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx
new file mode 100644
index 000000000000..2a2e95f12015
--- /dev/null
+++ b/projects/packages/seo/_inc/screens/overview/site-visibility-card.tsx
@@ -0,0 +1,45 @@
+import { __ } from '@wordpress/i18n';
+import { Card, 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' ];
+}
+
+// Labels resolved at module scope so the production minifier can't fold an
+// adjacent `cond ? __(A) : __(B)` into `__(cond ? A : B)`, which would erase
+// the literals from i18n extraction. See feedback_i18n_ternary_minifier_fold.
+const searchAllowedLabel = __( 'Search engines allowed', 'jetpack-seo' );
+const searchBlockedLabel = __( 'Search engines blocked', 'jetpack-seo' );
+const sitemapActiveLabel = __( 'Sitemap active', 'jetpack-seo' );
+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 } ) => (
+
+
+ { __( 'Site visibility', 'jetpack-seo' ) }
+
+
+
+
+
+
+
+
+
+);
+
+export default SiteVisibilityCard;
diff --git a/projects/packages/seo/_inc/screens/overview/status-dot.scss b/projects/packages/seo/_inc/screens/overview/status-dot.scss
new file mode 100644
index 000000000000..000dcaccacd3
--- /dev/null
+++ b/projects/packages/seo/_inc/screens/overview/status-dot.scss
@@ -0,0 +1,23 @@
+.jetpack-seo-status-dot {
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border-radius: 50%;
+ margin-inline-end: var(--wpds-dimension-gap-sm);
+ vertical-align: middle;
+
+ // The `-weak` tokens are the vivid indicator colors; the plain
+ // `fg-content-*` tokens are the darkest text shades (near-black) and read
+ // as black on a status dot.
+ &.is-ok {
+ background: var(--wpds-color-fg-content-success-weak);
+ }
+
+ &.is-warn {
+ background: var(--wpds-color-fg-content-warning-weak);
+ }
+
+ &.is-err {
+ background: var(--wpds-color-fg-content-error-weak);
+ }
+}
diff --git a/projects/packages/seo/_inc/screens/overview/status-dot.tsx b/projects/packages/seo/_inc/screens/overview/status-dot.tsx
new file mode 100644
index 000000000000..087c042c31aa
--- /dev/null
+++ b/projects/packages/seo/_inc/screens/overview/status-dot.tsx
@@ -0,0 +1,24 @@
+import clsx from 'clsx';
+import './status-dot.scss';
+import type { FC } from 'react';
+
+interface Props {
+ status: 'ok' | 'warn' | 'err';
+ label?: string;
+}
+
+const StatusDot: FC< Props > = ( { status, label } ) => (
+
+
+ { label }
+
+);
+
+export default StatusDot;
diff --git a/projects/packages/seo/_inc/screens/overview/style.scss b/projects/packages/seo/_inc/screens/overview/style.scss
new file mode 100644
index 000000000000..a7631003216e
--- /dev/null
+++ b/projects/packages/seo/_inc/screens/overview/style.scss
@@ -0,0 +1,6 @@
+.jetpack-seo-overview__grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
+ gap: var(--wpds-dimension-gap-lg);
+ align-items: stretch;
+}
diff --git a/projects/packages/seo/changelog/.gitkeep b/projects/packages/seo/changelog/.gitkeep
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/projects/packages/seo/changelog/add-jetpack-seo-foundation b/projects/packages/seo/changelog/add-jetpack-seo-foundation
new file mode 100644
index 000000000000..f0a424ff71a4
--- /dev/null
+++ b/projects/packages/seo/changelog/add-jetpack-seo-foundation
@@ -0,0 +1,4 @@
+Significance: patch
+Type: added
+
+Initialize SEO package under feature flag
diff --git a/projects/packages/seo/composer.json b/projects/packages/seo/composer.json
new file mode 100644
index 000000000000..35c3b71cee44
--- /dev/null
+++ b/projects/packages/seo/composer.json
@@ -0,0 +1,77 @@
+{
+ "name": "automattic/jetpack-seo",
+ "description": "Jetpack SEO — the visibility command center for WordPress sites in the agentic web.",
+ "type": "jetpack-library",
+ "license": "GPL-2.0-or-later",
+ "require": {
+ "php": ">=7.2",
+ "automattic/jetpack-admin-ui": "@dev",
+ "automattic/jetpack-status": "@dev",
+ "automattic/jetpack-wp-build-polyfills": "@dev"
+ },
+ "require-dev": {
+ "yoast/phpunit-polyfills": "^4.0.0",
+ "automattic/jetpack-changelogger": "@dev",
+ "automattic/jetpack-test-environment": "@dev",
+ "automattic/phpunit-select-config": "@dev"
+ },
+ "suggest": {
+ "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package."
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "scripts": {
+ "phpunit": [
+ "phpunit-select-config phpunit.#.xml.dist --colors=always"
+ ],
+ "test-php": [
+ "@composer phpunit"
+ ],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "build-development": [
+ "pnpm run build"
+ ],
+ "build-production": [
+ "NODE_ENV=production pnpm run build"
+ ],
+ "watch": [
+ "Composer\\Config::disableProcessTimeout",
+ "pnpm run watch"
+ ]
+ },
+ "repositories": [
+ {
+ "type": "path",
+ "url": "../../packages/*",
+ "options": {
+ "monorepo": true
+ }
+ }
+ ],
+ "minimum-stability": "dev",
+ "prefer-stable": true,
+ "extra": {
+ "autotagger": true,
+ "mirror-repo": "Automattic/jetpack-seo",
+ "textdomain": "jetpack-seo",
+ "changelogger": {
+ "link-template": "https://github.com/Automattic/jetpack-seo/compare/${old}...${new}"
+ },
+ "branch-alias": {
+ "dev-trunk": "0.1.x-dev"
+ },
+ "version-constants": {
+ "::PACKAGE_VERSION": "src/class-initializer.php"
+ }
+ },
+ "config": {
+ "allow-plugins": {
+ "roots/wordpress-core-installer": true
+ }
+ }
+}
diff --git a/projects/packages/seo/global.d.ts b/projects/packages/seo/global.d.ts
new file mode 100644
index 000000000000..3bb09f27e95e
--- /dev/null
+++ b/projects/packages/seo/global.d.ts
@@ -0,0 +1,2 @@
+declare module '*.scss';
+declare module '*.svg';
diff --git a/projects/packages/seo/package.json b/projects/packages/seo/package.json
new file mode 100644
index 000000000000..bb316a6ab2bf
--- /dev/null
+++ b/projects/packages/seo/package.json
@@ -0,0 +1,84 @@
+{
+ "name": "@automattic/jetpack-seo",
+ "version": "0.1.0-alpha",
+ "private": true,
+ "description": "Jetpack SEO — the visibility command center for WordPress sites in the agentic web.",
+ "homepage": "https://github.com/Automattic/jetpack/tree/HEAD/projects/packages/seo/#readme",
+ "bugs": {
+ "url": "https://github.com/Automattic/jetpack/labels/[Package] SEO"
+ },
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/Automattic/jetpack.git",
+ "directory": "projects/packages/seo"
+ },
+ "license": "GPL-2.0-or-later",
+ "author": "Automattic",
+ "sideEffects": [
+ "*.css",
+ "*.scss"
+ ],
+ "scripts": {
+ "build": "pnpm run clean && pnpm run build:wp-build && pnpm run build:boot-asset",
+ "build:boot-asset": "provide-boot-asset-file",
+ "build:strip-unminified-prod": "strip-unminified-prod",
+ "build:wp-build": "wp-build",
+ "build-production": "NODE_ENV=production BABEL_ENV=production pnpm run build && pnpm run build:strip-unminified-prod && pnpm run validate",
+ "clean": "rm -rf build/",
+ "test": "NODE_OPTIONS=--experimental-vm-modules jest --config=tests/jest.config.js --passWithNoTests",
+ "test-coverage": "pnpm run test --coverage",
+ "typecheck": "tsgo --noEmit",
+ "validate": "pnpm exec validate-es --no-error-on-unmatched-pattern build/",
+ "watch": "wp-build --watch"
+ },
+ "browserslist": [
+ "extends @wordpress/browserslist-config"
+ ],
+ "dependencies": {
+ "@automattic/babel-plugin-replace-textdomain": "workspace:*",
+ "@automattic/jetpack-base-styles": "workspace:*",
+ "@automattic/jetpack-components": "workspace:*",
+ "@automattic/jetpack-script-data": "workspace:*",
+ "@automattic/jetpack-wp-build-polyfills": "workspace:*",
+ "@wordpress/components": "33.1.0",
+ "@wordpress/element": "6.46.0",
+ "@wordpress/i18n": "6.19.0",
+ "@wordpress/icons": "13.1.0",
+ "@wordpress/route": "0.12.0",
+ "@wordpress/ui": "0.13.0",
+ "@wordpress/url": "4.46.0",
+ "clsx": "2.1.1"
+ },
+ "devDependencies": {
+ "@babel/core": "7.29.0",
+ "@babel/preset-env": "7.29.2",
+ "@testing-library/dom": "10.4.1",
+ "@testing-library/jest-dom": "6.9.1",
+ "@testing-library/react": "16.3.2",
+ "@types/jest": "30.0.0",
+ "@types/node": "^24.12.0",
+ "@types/react": "18.3.28",
+ "@types/react-dom": "18.3.7",
+ "@typescript/native-preview": "7.0.0-dev.20260225.1",
+ "@wordpress/base-styles": "8.0.0",
+ "@wordpress/browserslist-config": "6.46.0",
+ "@wordpress/build": "0.14.0",
+ "@wordpress/theme": "0.13.0",
+ "browserslist": "^4.24.0",
+ "jest": "30.4.2",
+ "react": "18.3.1",
+ "react-dom": "18.3.1",
+ "sass-embedded": "1.97.3",
+ "sass-loader": "16.0.5",
+ "typescript": "5.9.3"
+ },
+ "wpPlugin": {
+ "name": "jetpack_seo",
+ "scriptGlobal": "jetpackSeo",
+ "packageNamespace": "jetpack-seo",
+ "handlePrefix": "jetpack-seo",
+ "pages": [
+ "jetpack-seo-dashboard"
+ ]
+ }
+}
diff --git a/projects/packages/seo/phpunit.11.xml.dist b/projects/packages/seo/phpunit.11.xml.dist
new file mode 100644
index 000000000000..24f0f1e361af
--- /dev/null
+++ b/projects/packages/seo/phpunit.11.xml.dist
@@ -0,0 +1,34 @@
+
+
+
+
+ tests/php
+
+
+
+
+
+ src
+
+
+
+
+
diff --git a/projects/packages/seo/phpunit.12.xml.dist b/projects/packages/seo/phpunit.12.xml.dist
new file mode 100644
index 000000000000..f7418373829b
--- /dev/null
+++ b/projects/packages/seo/phpunit.12.xml.dist
@@ -0,0 +1,36 @@
+
+
+
+
+ tests/php
+
+
+
+
+
+
+
+ src
+
+
+
+
+
diff --git a/projects/packages/seo/phpunit.8.xml.dist b/projects/packages/seo/phpunit.8.xml.dist
new file mode 100644
index 000000000000..3965963c485e
--- /dev/null
+++ b/projects/packages/seo/phpunit.8.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+ tests/php
+
+
+
diff --git a/projects/packages/seo/phpunit.9.xml.dist b/projects/packages/seo/phpunit.9.xml.dist
new file mode 100644
index 000000000000..3965963c485e
--- /dev/null
+++ b/projects/packages/seo/phpunit.9.xml.dist
@@ -0,0 +1,17 @@
+
+
+
+
+ tests/php
+
+
+
diff --git a/projects/packages/seo/routes/index/package.json b/projects/packages/seo/routes/index/package.json
new file mode 100644
index 000000000000..8c37b6ac5f81
--- /dev/null
+++ b/projects/packages/seo/routes/index/package.json
@@ -0,0 +1,22 @@
+{
+ "name": "_@jetpack-seo/index-route",
+ "version": "1.0.0",
+ "private": true,
+ "dependencies": {
+ "@automattic/jetpack-components": "workspace:*",
+ "@automattic/jetpack-script-data": "workspace:*",
+ "@types/react": "18.3.28",
+ "@wordpress/components": "33.1.0",
+ "@wordpress/element": "6.46.0",
+ "@wordpress/i18n": "6.19.0",
+ "@wordpress/icons": "13.1.0",
+ "@wordpress/route": "0.12.0",
+ "@wordpress/ui": "0.13.0",
+ "@wordpress/url": "4.46.0",
+ "clsx": "2.1.1"
+ },
+ "route": {
+ "path": "/",
+ "page": "jetpack-seo-dashboard"
+ }
+}
diff --git a/projects/packages/seo/routes/index/route.tsx b/projects/packages/seo/routes/index/route.tsx
new file mode 100644
index 000000000000..706b2bfd422a
--- /dev/null
+++ b/projects/packages/seo/routes/index/route.tsx
@@ -0,0 +1,4 @@
+// The SEO dashboard is a single screen with no sidebar inspector, so the route
+// needs no extra configuration. Tabs (Settings, Content, AI) will be added in
+// later PRs via `?tab=` + `useSearch`, mirroring the Podcast/Scan stages.
+export const route = {};
diff --git a/projects/packages/seo/routes/index/stage.tsx b/projects/packages/seo/routes/index/stage.tsx
new file mode 100644
index 000000000000..bb5b9fb8cb31
--- /dev/null
+++ b/projects/packages/seo/routes/index/stage.tsx
@@ -0,0 +1,6 @@
+import App from '../../_inc/app';
+
+// `@wordpress/build` mounts the route's exported `stage`. The SEO app owns its
+// own chrome (`AdminPage`) and reads its bootstrapped state synchronously, so
+// the stage is just the app root.
+export { App as stage };
diff --git a/projects/packages/seo/src/class-initializer.php b/projects/packages/seo/src/class-initializer.php
new file mode 100644
index 000000000000..d1f5625b7811
--- /dev/null
+++ b/projects/packages/seo/src/class-initializer.php
@@ -0,0 +1,286 @@
+id` matches
+ * this value, so we alias the screen id to it via `current_screen` without
+ * changing the user-facing URL.
+ */
+ const WP_BUILD_SLUG = 'jetpack-seo-dashboard';
+
+ /**
+ * Render function generated by `@wordpress/build` into
+ * `build/pages/jetpack-seo-dashboard/page-wp-admin.php`. Naming convention:
+ * `{wpPlugin.name}_{page-with-underscores}_wp_admin_render_page`.
+ */
+ const WP_BUILD_RENDER_FN = 'jetpack_seo_jetpack_seo_dashboard_wp_admin_render_page';
+
+ /**
+ * Key under `window.JetpackScriptData` the React app reads its state from
+ * (`window.JetpackScriptData.seo`). Must match the JS-side reader in
+ * `_inc/data/get-overview.ts`.
+ */
+ const SCRIPT_DATA_KEY = 'seo';
+
+ /**
+ * Whether the package has been initialized.
+ *
+ * @var bool
+ */
+ private static $initialized = false;
+
+ /**
+ * Initialize the package.
+ *
+ * Called from the Jetpack plugin's `late_initialization()` hook.
+ *
+ * @return void
+ */
+ public static function init() {
+ if ( self::$initialized ) {
+ return;
+ }
+ self::$initialized = true;
+
+ // Gate the entire SEO surface behind the feature flag.
+ if ( ! (bool) apply_filters( self::FEATURE_FILTER, false ) ) {
+ return;
+ }
+
+ // Gate the entire SEO surface on the `seo-tools` module being active,
+ // the same way other Jetpack modules do. When the module is off we
+ // register nothing — no admin menu, no assets — rather than registering
+ // everything and hiding the menu downstream.
+ if ( ! self::is_seo_tools_module_active() ) {
+ return;
+ }
+
+ // Priority 1: load the wp-build bundle (and define its render function)
+ // before `add_menu_item()` runs at the default priority and needs it.
+ add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
+ add_action( 'admin_menu', array( __CLASS__, 'add_menu_item' ), 10 );
+
+ /**
+ * Fires after the Jetpack SEO package is initialized.
+ *
+ * @since 0.1.0
+ */
+ do_action( 'jetpack_seo_init' );
+ }
+
+ /**
+ * Register the admin menu item.
+ *
+ * Uses Admin_Menu so the page is reachable on wp-admin across all site
+ * types. The render callback is wp-build's generated render function when
+ * the bundle is loaded (i.e. on the SEO page itself, after
+ * `maybe_load_wp_build()` ran at priority 1); otherwise it falls back to a
+ * bare mount node so the page never fatals on an unbuilt checkout.
+ *
+ * @return void
+ */
+ public static function add_menu_item() {
+ $callback = function_exists( self::WP_BUILD_RENDER_FN )
+ ? self::WP_BUILD_RENDER_FN
+ : array( __CLASS__, 'render_fallback' );
+
+ Admin_Menu::add_menu(
+ 'SEO',
+ 'SEO',
+ 'manage_options',
+ self::MENU_SLUG,
+ $callback,
+ 2
+ );
+ }
+
+ /**
+ * On the SEO admin page, load the wp-build bundle, alias the screen id so
+ * wp-build enqueues its assets, and bootstrap the app's initial state.
+ *
+ * Hooked at `admin_menu` priority 1 so polyfills register and the render
+ * function is defined before `add_menu_item()` runs at priority 10.
+ *
+ * @return void
+ */
+ public static function maybe_load_wp_build() {
+ if ( ! self::is_seo_admin_request() ) {
+ return;
+ }
+
+ self::load_wp_build();
+ add_action( 'current_screen', array( __CLASS__, 'alias_screen_id_for_wp_build' ) );
+ add_filter( 'jetpack_admin_js_script_data', array( __CLASS__, 'inject_script_data' ) );
+ }
+
+ /**
+ * Load wp-build's generated registration file and register the polyfills
+ * the bundle depends on. No-op on a fresh checkout before `pnpm build`, in
+ * which case `add_menu_item()` falls back to {@see self::render_fallback()}.
+ *
+ * @return void
+ */
+ private static function load_wp_build() {
+ $build_index = dirname( __DIR__ ) . '/build/build.php';
+
+ if ( ! file_exists( $build_index ) ) {
+ return;
+ }
+
+ require_once $build_index;
+
+ WP_Build_Polyfills::register(
+ 'jetpack-seo',
+ array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
+ );
+ }
+
+ /**
+ * Alias the current screen id to wp-build's expected slug so its
+ * auto-generated enqueue callback fires for our user-facing page.
+ *
+ * @param \WP_Screen|null $screen The current screen object (passed by WP).
+ * @return void
+ */
+ public static function alias_screen_id_for_wp_build( $screen ) {
+ if ( ! is_object( $screen ) ) {
+ return;
+ }
+
+ $screen->id = self::WP_BUILD_SLUG;
+ }
+
+ /**
+ * Bootstrap the React app's initial state onto `window.JetpackScriptData.seo`.
+ *
+ * Because wp-build pages load as ES modules, `wp_localize_script` can't
+ * attach data to them; the shared `jetpack_admin_js_script_data` filter
+ * (printed by the Script_Data package onto the `jetpack-script-data` handle
+ * the bundle already depends on) is the supported channel. Mirrors Podcast
+ * and Newsletter.
+ *
+ * @param array $data Script data being injected onto the page.
+ * @return array
+ */
+ public static function inject_script_data( $data ) {
+ if ( ! is_array( $data ) ) {
+ $data = array();
+ }
+
+ $data[ self::SCRIPT_DATA_KEY ]['overview'] = self::get_overview_data();
+
+ return $data;
+ }
+
+ /**
+ * Fallback render used when the wp-build artifact is missing (unbuilt
+ * checkout). Renders a bare wrapper so the page loads without the app.
+ *
+ * @return void
+ */
+ public static function render_fallback() {
+ echo '
SEO
';
+ }
+
+ /**
+ * Whether the current request targets the SEO admin page.
+ *
+ * @return bool
+ */
+ private static function is_seo_admin_request() {
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
+ return false;
+ }
+
+ // phpcs:ignore WordPress.Security.NonceVerification.Recommended
+ return self::MENU_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
+ }
+
+ /**
+ * Whether the `seo-tools` Jetpack module is currently active.
+ *
+ * @return bool
+ */
+ private static function is_seo_tools_module_active() {
+ if ( ! class_exists( 'Automattic\\Jetpack\\Modules' ) ) {
+ return false;
+ }
+ return ( new Modules() )->is_active( 'seo-tools' );
+ }
+
+ /**
+ * Build the aggregated Overview state the dashboard renders.
+ *
+ * @return array
+ */
+ 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() : '';
+
+ return 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.
+ '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(
+ 'seo_enabled_for_site' => $seo_enabled,
+ ),
+ );
+ }
+}
diff --git a/projects/packages/seo/tests/.phpcs.dir.xml b/projects/packages/seo/tests/.phpcs.dir.xml
new file mode 100644
index 000000000000..46951fe77b37
--- /dev/null
+++ b/projects/packages/seo/tests/.phpcs.dir.xml
@@ -0,0 +1,4 @@
+
+
+
+
diff --git a/projects/packages/seo/tests/jest.config.js b/projects/packages/seo/tests/jest.config.js
new file mode 100644
index 000000000000..b5ceacda1f7e
--- /dev/null
+++ b/projects/packages/seo/tests/jest.config.js
@@ -0,0 +1,7 @@
+const path = require( 'path' );
+const baseConfig = require( 'jetpack-js-tools/jest/config.base.js' );
+
+module.exports = {
+ ...baseConfig,
+ rootDir: path.join( __dirname, '..' ),
+};
diff --git a/projects/packages/seo/tests/php/InitializerTest.php b/projects/packages/seo/tests/php/InitializerTest.php
new file mode 100644
index 000000000000..457e1ed0bb37
--- /dev/null
+++ b/projects/packages/seo/tests/php/InitializerTest.php
@@ -0,0 +1,39 @@
+assertSame( 'jetpack-seo', Initializer::MENU_SLUG );
+ }
+
+ /**
+ * The package version constant is defined and non-empty.
+ */
+ public function test_package_version_constant_is_defined() {
+ $this->assertNotEmpty( Initializer::PACKAGE_VERSION );
+ }
+
+ /**
+ * The feature-flag filter name is the expected slug.
+ */
+ public function test_feature_filter_constant_is_defined() {
+ $this->assertSame( 'rsm_jetpack_seo', Initializer::FEATURE_FILTER );
+ }
+}
diff --git a/projects/packages/seo/tests/php/bootstrap.php b/projects/packages/seo/tests/php/bootstrap.php
new file mode 100644
index 000000000000..778fcdea63ce
--- /dev/null
+++ b/projects/packages/seo/tests/php/bootstrap.php
@@ -0,0 +1,12 @@
+=7.2"
+ },
+ "require-dev": {
+ "automattic/jetpack-changelogger": "@dev",
+ "automattic/jetpack-test-environment": "@dev",
+ "automattic/phpunit-select-config": "@dev",
+ "yoast/phpunit-polyfills": "^4.0.0"
+ },
+ "suggest": {
+ "automattic/jetpack-autoloader": "Allow for better interoperability with other plugins that use this package."
+ },
+ "type": "jetpack-library",
+ "extra": {
+ "autotagger": true,
+ "mirror-repo": "Automattic/jetpack-seo",
+ "textdomain": "jetpack-seo",
+ "changelogger": {
+ "link-template": "https://github.com/Automattic/jetpack-seo/compare/${old}...${new}"
+ },
+ "branch-alias": {
+ "dev-trunk": "0.1.x-dev"
+ },
+ "version-constants": {
+ "::PACKAGE_VERSION": "src/class-initializer.php"
+ }
+ },
+ "autoload": {
+ "classmap": [
+ "src/"
+ ]
+ },
+ "scripts": {
+ "phpunit": [
+ "phpunit-select-config phpunit.#.xml.dist --colors=always"
+ ],
+ "test-php": [
+ "@composer phpunit"
+ ],
+ "test-js": [
+ "pnpm run test"
+ ],
+ "build-development": [
+ "pnpm run build"
+ ],
+ "build-production": [
+ "NODE_ENV=production pnpm run build"
+ ],
+ "watch": [
+ "Composer\\Config::disableProcessTimeout",
+ "pnpm run watch"
+ ]
+ },
+ "license": [
+ "GPL-2.0-or-later"
+ ],
+ "description": "Jetpack SEO — the visibility command center for WordPress sites in the agentic web.",
+ "transport-options": {
+ "relative": true
+ }
+ },
{
"name": "automattic/jetpack-stats",
"version": "dev-trunk",
@@ -5803,6 +5875,7 @@
"automattic/jetpack-roles": 20,
"automattic/jetpack-scan-page": 20,
"automattic/jetpack-search": 20,
+ "automattic/jetpack-seo": 20,
"automattic/jetpack-stats": 20,
"automattic/jetpack-stats-admin": 20,
"automattic/jetpack-status": 20,