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 } ) => ( + + +); + +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,