From 99b735f6d48075ad12ae5337d5be3c86fec5054a Mon Sep 17 00:00:00 2001 From: Dave Martin Date: Fri, 31 Jul 2026 16:14:02 -0400 Subject: [PATCH 1/2] Newsletter Mode: add WordPress.com page shell and routes --- pnpm-lock.yaml | 12 ++ .../changelog/add-newsletter-mode-shell | 4 + .../packages/jetpack-mu-wpcom/jest.config.cjs | 9 + .../packages/jetpack-mu-wpcom/package.json | 9 +- .../newsletter-mode-onboarding/package.json | 12 ++ .../newsletter-mode-onboarding/stage.tsx | 23 +++ .../routes/newsletter-mode-stats/package.json | 12 ++ .../routes/newsletter-mode-stats/stage.tsx | 23 +++ .../src/class-jetpack-mu-wpcom.php | 1 + .../src/features/newsletter-mode/README.md | 24 +++ .../newsletter-mode/class-newsletter-mode.php | 150 +++++++++++++++ .../newsletter-mode/js/page-shell.test.tsx | 19 ++ .../newsletter-mode/js/page-shell.tsx | 29 +++ .../features/newsletter-mode/js/style.scss | 6 + .../newsletter-mode/newsletter-mode.php | 12 ++ .../features/newsletter-mode/tsconfig.json | 9 + .../newsletter-mode/Newsletter_Mode_Test.php | 182 ++++++++++++++++++ .../packages/jetpack-mu-wpcom/tsconfig.json | 1 + 18 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 projects/packages/jetpack-mu-wpcom/changelog/add-newsletter-mode-shell create mode 100644 projects/packages/jetpack-mu-wpcom/jest.config.cjs create mode 100644 projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-onboarding/package.json create mode 100644 projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-onboarding/stage.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-stats/package.json create mode 100644 projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-stats/stage.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/README.md create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/class-newsletter-mode.php create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/page-shell.test.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/page-shell.tsx create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/style.scss create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/newsletter-mode.php create mode 100644 projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/tsconfig.json create mode 100644 projects/packages/jetpack-mu-wpcom/tests/php/features/newsletter-mode/Newsletter_Mode_Test.php diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e8099307dc2d..cb5542542495 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3066,12 +3066,21 @@ importers: '@playwright/test': specifier: 1.60.0 version: 1.60.0 + '@testing-library/dom': + specifier: 10.4.1 + version: 10.4.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) '@tsconfig/strictest': specifier: 2.0.5 version: 2.0.5 '@types/canvas-confetti': specifier: 1.9.0 version: 1.9.0 + '@types/jest': + specifier: 30.0.0 + version: 30.0.0 '@types/node': specifier: ^24.12.0 version: 24.13.3 @@ -3102,6 +3111,9 @@ importers: c8: specifier: ^11.0.0 version: 11.0.0 + jest: + specifier: 30.4.2 + version: 30.4.2(@types/node@24.13.3) package-directory: specifier: ^8.1.0 version: 8.2.0 diff --git a/projects/packages/jetpack-mu-wpcom/changelog/add-newsletter-mode-shell b/projects/packages/jetpack-mu-wpcom/changelog/add-newsletter-mode-shell new file mode 100644 index 000000000000..c483a3bbadc9 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/changelog/add-newsletter-mode-shell @@ -0,0 +1,4 @@ +Significance: minor +Type: added + +Newsletter Mode: Add the gated WordPress.com page shell and routes. diff --git a/projects/packages/jetpack-mu-wpcom/jest.config.cjs b/projects/packages/jetpack-mu-wpcom/jest.config.cjs new file mode 100644 index 000000000000..baa661f56d14 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/jest.config.cjs @@ -0,0 +1,9 @@ +const baseConfig = require( 'jetpack-js-tools/jest/config.base.js' ); + +module.exports = { + ...baseConfig, + testMatch: [ + '/src/features/newsletter-mode/**/*.test.[jt]s?(x)', + '/routes/newsletter-mode-*/**/*.test.[jt]s?(x)', + ], +}; diff --git a/projects/packages/jetpack-mu-wpcom/package.json b/projects/packages/jetpack-mu-wpcom/package.json index 729c049f38c3..9b7e5fc7c992 100644 --- a/projects/packages/jetpack-mu-wpcom/package.json +++ b/projects/packages/jetpack-mu-wpcom/package.json @@ -25,7 +25,9 @@ "clean": "rm -rf src/build/ src/build-module/ build/", "e2e-tests": "playwright test --config src/features/verbum-comments/playwright.config.ts", "sync:newspack-blocks": "./bin/sync-newspack-blocks.sh", - "test": "node --test --test-reporter spec src/features/write/test/undo-history.test.mjs", + "test": "pnpm run test:node && pnpm run test:jest", + "test:jest": "jest", + "test:node": "node --test --test-reporter spec src/features/write/test/undo-history.test.mjs", "test-coverage": "c8 --report-dir=\"$COVERAGE_DIR\" --temp-directory=\"$ARTIFACTS_DIR/v8\" pnpm run test", "typecheck": "tsgo --build", "watch": "pnpm run build-production-js && pnpm webpack watch" @@ -120,8 +122,11 @@ "@babel/runtime": "7.29.7", "@lezer/generator": "1.8.0", "@playwright/test": "1.60.0", + "@testing-library/dom": "10.4.1", + "@testing-library/react": "16.3.2", "@tsconfig/strictest": "2.0.5", "@types/canvas-confetti": "1.9.0", + "@types/jest": "30.0.0", "@types/node": "^24.12.0", "@types/react": "^18.3.28", "@types/react-dom": "18.3.7", @@ -132,6 +137,7 @@ "babel-plugin-transform-rename-properties": "0.1.0", "browserslist": "^4.24.0", "c8": "^11.0.0", + "jest": "30.4.2", "package-directory": "^8.1.0", "postcss": "8.5.21", "postcss-loader": "8.2.0", @@ -151,6 +157,7 @@ "packageNamespace": "jetpack-mu-wpcom", "handlePrefix": "jetpack-mu-wpcom", "pages": [ + "newsletter-mode", "site-setup" ] } diff --git a/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-onboarding/package.json b/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-onboarding/package.json new file mode 100644 index 000000000000..ff6d15471345 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-onboarding/package.json @@ -0,0 +1,12 @@ +{ + "version": "1.0.0", + "private": true, + "dependencies": { + "@types/react": "^18.3.28", + "@wordpress/i18n": "6.24.0" + }, + "route": { + "path": "/", + "page": "newsletter-mode" + } +} diff --git a/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-onboarding/stage.tsx b/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-onboarding/stage.tsx new file mode 100644 index 000000000000..43298aa12826 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-onboarding/stage.tsx @@ -0,0 +1,23 @@ +import { __ } from '@wordpress/i18n'; +import { NewsletterModePageShell } from '../../src/features/newsletter-mode/js/page-shell'; + +/** + * Newsletter Mode onboarding route placeholder. + * + * @return Onboarding route stage. + */ +const Stage = (): JSX.Element => ( + +
+

+ { __( 'Newsletter setup', 'jetpack-mu-wpcom' ) } +

+

{ __( 'This page is ready for its feature content.', 'jetpack-mu-wpcom' ) }

+
+
+); + +export { Stage as stage }; diff --git a/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-stats/package.json b/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-stats/package.json new file mode 100644 index 000000000000..0cd0b2b0ac65 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-stats/package.json @@ -0,0 +1,12 @@ +{ + "version": "1.0.0", + "private": true, + "dependencies": { + "@types/react": "^18.3.28", + "@wordpress/i18n": "6.24.0" + }, + "route": { + "path": "/stats", + "page": "newsletter-mode" + } +} diff --git a/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-stats/stage.tsx b/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-stats/stage.tsx new file mode 100644 index 000000000000..983b88faf658 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/routes/newsletter-mode-stats/stage.tsx @@ -0,0 +1,23 @@ +import { __ } from '@wordpress/i18n'; +import { NewsletterModePageShell } from '../../src/features/newsletter-mode/js/page-shell'; + +/** + * Newsletter Mode stats route placeholder. + * + * @return Stats route stage. + */ +const Stage = (): JSX.Element => ( + +
+

+ { __( 'Stats dashboard', 'jetpack-mu-wpcom' ) } +

+

{ __( 'This page is ready for its feature content.', 'jetpack-mu-wpcom' ) }

+
+
+); + +export { Stage as stage }; diff --git a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php index 08f385e86433..9ae9b8b29cad 100644 --- a/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php +++ b/projects/packages/jetpack-mu-wpcom/src/class-jetpack-mu-wpcom.php @@ -444,6 +444,7 @@ public static function load_wpcom_sites_features() { } require_once __DIR__ . '/features/gutenberg-rtc/gutenberg-rtc.php'; + require_once __DIR__ . '/features/newsletter-mode/newsletter-mode.php'; require_once __DIR__ . '/features/wpcom-contact-form-flags/wpcom-contact-form-flags.php'; } diff --git a/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/README.md b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/README.md new file mode 100644 index 000000000000..8327b92fb991 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/README.md @@ -0,0 +1,24 @@ +# Newsletter Mode + +Newsletter Mode is a WordPress.com-only, per-site experience. Its page and hooks +remain unavailable until the site is explicitly enrolled. + +Access to the Newsletter Mode shell requires the `publish_posts` capability. + +## Manual enrollment + +Select the target site by site ID in the WordPress.com WP-CLI environment, then +enable the package-local option in that site's context: + +```bash +wp option update wpcom_newsletter_mode_enabled 1 +``` + +Disable the option to remove the Newsletter Mode entry point: + +```bash +wp option delete wpcom_newsletter_mode_enabled +``` + +Defining `WPCOM_NEWSLETTER_MODE_DISABLED` as a truthy value disables the feature +even when the per-site option remains enabled. diff --git a/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/class-newsletter-mode.php b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/class-newsletter-mode.php new file mode 100644 index 000000000000..55ce00c81308 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/class-newsletter-mode.php @@ -0,0 +1,150 @@ + + +import { render, screen } from '@testing-library/react'; +import { stage as OnboardingStage } from '../../../../routes/newsletter-mode-onboarding/stage'; +import { stage as StatsStage } from '../../../../routes/newsletter-mode-stats/stage'; + +describe.each( [ + [ 'onboarding', OnboardingStage, 'Newsletter setup' ], + [ 'stats', StatsStage, 'Stats dashboard' ], +] )( '%s route', ( _route, Stage, placeholderTitle ) => { + it( 'renders its content inside the shared AdminPage shell', () => { + render( ); + + expect( screen.getByRole( 'heading', { level: 1, name: 'Newsletter' } ) ).toBeVisible(); + expect( screen.getByRole( 'heading', { level: 2, name: placeholderTitle } ) ).toBeVisible(); + expect( screen.getByText( 'Create, grow, and manage your newsletter.' ) ).toBeVisible(); + expect( screen.getByRole( 'contentinfo', { name: 'Jetpack' } ) ).toBeVisible(); + } ); +} ); diff --git a/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/page-shell.tsx b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/page-shell.tsx new file mode 100644 index 000000000000..ceb3459f38c5 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/page-shell.tsx @@ -0,0 +1,29 @@ +import AdminPage from '@automattic/jetpack-components/admin-page'; +import { __ } from '@wordpress/i18n'; +import './style.scss'; +import type { ReactNode } from 'react'; + +type NewsletterModePageShellProps = { + children: ReactNode; +}; + +/** + * Shared Newsletter Mode page chrome. + * + * Route stages provide their own content while this component keeps the page + * title, footer, and other shared chrome consistent. + * + * @param props - Component props. + * @param props.children - Route content rendered inside the shell. + * @return Newsletter Mode page shell. + */ +export function NewsletterModePageShell( { children }: NewsletterModePageShellProps ): JSX.Element { + return ( + + { children } + + ); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/style.scss b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/style.scss new file mode 100644 index 000000000000..e24da2b232b1 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/js/style.scss @@ -0,0 +1,6 @@ +.wpcom-newsletter-mode-page { + min-block-size: 12rem; + padding-block: 2rem; + padding-inline: 2rem; + background: var(--wpds-color-background-surface-neutral); +} diff --git a/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/newsletter-mode.php b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/newsletter-mode.php new file mode 100644 index 000000000000..a7b9f32d9092 --- /dev/null +++ b/projects/packages/jetpack-mu-wpcom/src/features/newsletter-mode/newsletter-mode.php @@ -0,0 +1,12 @@ +assertFalse( Newsletter_Mode::is_enabled() ); + } + + /** + * The documented site option enrolls a site. + */ + public function test_site_option_enables_mode() { + update_option( Newsletter_Mode::OPTION_ENABLED, 1 ); + + $this->assertTrue( Newsletter_Mode::is_enabled() ); + } + + /** + * The emergency switch wins over per-site enrollment. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] + public function test_emergency_constant_disables_enrolled_site() { + define( Newsletter_Mode::DISABLE_CONSTANT, true ); + update_option( Newsletter_Mode::OPTION_ENABLED, 1 ); + + $this->assertFalse( Newsletter_Mode::is_available() ); + $this->assertFalse( Newsletter_Mode::is_enabled() ); + } + + /** + * Disabled sites register no page hook. + */ + public function test_init_registers_no_hooks_when_disabled() { + set_current_screen( 'dashboard' ); + + Newsletter_Mode::init(); + + $this->assertFalse( has_action( 'admin_menu', array( Newsletter_Mode::class, 'register_menu' ) ) ); + } + + /** + * Enrolled sites register the page hook. + */ + public function test_init_registers_menu_hook_when_enabled() { + update_option( Newsletter_Mode::OPTION_ENABLED, 1 ); + set_current_screen( 'dashboard' ); + + Newsletter_Mode::init(); + + $this->assertSame( 10, has_action( 'admin_menu', array( Newsletter_Mode::class, 'register_menu' ) ) ); + } + + /** + * Enrolled sites register no hooks outside WP Admin. + */ + public function test_init_registers_no_hooks_outside_admin() { + update_option( Newsletter_Mode::OPTION_ENABLED, 1 ); + + Newsletter_Mode::init(); + + $this->assertFalse( has_action( 'admin_menu', array( Newsletter_Mode::class, 'register_menu' ) ) ); + } + + /** + * Subscribers do not receive the Newsletter Mode menu entry. + */ + public function test_register_menu_requires_shell_capability() { + update_option( Newsletter_Mode::OPTION_ENABLED, 1 ); + $user_id = wp_insert_user( + array( + 'user_login' => 'newsletter_mode_subscriber_' . wp_rand(), + 'user_pass' => wp_generate_password(), + 'user_email' => 'newsletter-mode-subscriber-' . wp_rand() . '@example.com', + 'role' => 'subscriber', + ) + ); + wp_set_current_user( $user_id ); + + Newsletter_Mode::register_menu(); + + $this->assertFalse( + has_action( 'toplevel_page_' . Newsletter_Mode::MENU_SLUG, '__return_empty_string' ) + ); + } + + /** + * Contributors do not receive the Newsletter Mode menu entry. + */ + public function test_register_menu_excludes_contributors() { + update_option( Newsletter_Mode::OPTION_ENABLED, 1 ); + $user_id = wp_insert_user( + array( + 'user_login' => 'newsletter_mode_contributor_' . wp_rand(), + 'user_pass' => wp_generate_password(), + 'user_email' => 'newsletter-mode-contributor-' . wp_rand() . '@example.com', + 'role' => 'contributor', + ) + ); + wp_set_current_user( $user_id ); + + Newsletter_Mode::register_menu(); + + $this->assertFalse( + has_action( 'toplevel_page_' . Newsletter_Mode::MENU_SLUG, '__return_empty_string' ) + ); + } + + /** + * Authors receive the page with a safe fallback before build callbacks load. + */ + public function test_register_menu_allows_authors_with_fallback_callback() { + update_option( Newsletter_Mode::OPTION_ENABLED, 1 ); + $user_id = wp_insert_user( + array( + 'user_login' => 'newsletter_mode_author_' . wp_rand(), + 'user_pass' => wp_generate_password(), + 'user_email' => 'newsletter-mode-author-' . wp_rand() . '@example.com', + 'role' => 'author', + ) + ); + wp_set_current_user( $user_id ); + + Newsletter_Mode::register_menu(); + + $this->assertSame( + 10, + has_action( 'toplevel_page_' . Newsletter_Mode::MENU_SLUG, '__return_empty_string' ) + ); + } +} diff --git a/projects/packages/jetpack-mu-wpcom/tsconfig.json b/projects/packages/jetpack-mu-wpcom/tsconfig.json index 827e925e4af0..ee55ff260b1a 100644 --- a/projects/packages/jetpack-mu-wpcom/tsconfig.json +++ b/projects/packages/jetpack-mu-wpcom/tsconfig.json @@ -4,6 +4,7 @@ "references": [ { "path": "./src/features/ai-launchpad/tsconfig.json" }, { "path": "./src/features/code-editor/tsconfig.json" }, + { "path": "./src/features/newsletter-mode/tsconfig.json" }, { "path": "./src/features/verbum-comments/tsconfig.json" }, { "path": "./src/features/wpcom-blocks/tsconfig.json" } ] From d3e7ff9d823032cae5f5a8350ec42548db798a17 Mon Sep 17 00:00:00 2001 From: Dave Martin Date: Fri, 31 Jul 2026 16:56:37 -0400 Subject: [PATCH 2/2] Update Newsletter_Mode_Test.php --- .../newsletter-mode/Newsletter_Mode_Test.php | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/projects/packages/jetpack-mu-wpcom/tests/php/features/newsletter-mode/Newsletter_Mode_Test.php b/projects/packages/jetpack-mu-wpcom/tests/php/features/newsletter-mode/Newsletter_Mode_Test.php index 202a9ec13915..2aabee52c4d7 100644 --- a/projects/packages/jetpack-mu-wpcom/tests/php/features/newsletter-mode/Newsletter_Mode_Test.php +++ b/projects/packages/jetpack-mu-wpcom/tests/php/features/newsletter-mode/Newsletter_Mode_Test.php @@ -113,6 +113,23 @@ public function test_init_registers_no_hooks_outside_admin() { $this->assertFalse( has_action( 'admin_menu', array( Newsletter_Mode::class, 'register_menu' ) ) ); } + /** + * The package loader includes the feature bootstrap and initializes enrolled sites. + * + * @runInSeparateProcess + * @preserveGlobalState disabled + */ + #[RunInSeparateProcess] + #[PreserveGlobalState( false )] + public function test_package_loader_initializes_newsletter_mode() { + update_option( Newsletter_Mode::OPTION_ENABLED, 1 ); + set_current_screen( 'dashboard' ); + + Jetpack_Mu_Wpcom::load_wpcom_sites_features(); + + $this->assertSame( 10, has_action( 'admin_menu', array( Newsletter_Mode::class, 'register_menu' ) ) ); + } + /** * Subscribers do not receive the Newsletter Mode menu entry. */