Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: minor
Type: added

Newsletter Mode: Add the gated WordPress.com page shell and routes.
9 changes: 9 additions & 0 deletions projects/packages/jetpack-mu-wpcom/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const baseConfig = require( 'jetpack-js-tools/jest/config.base.js' );

module.exports = {
...baseConfig,
testMatch: [
'<rootDir>/src/features/newsletter-mode/**/*.test.[jt]s?(x)',
'<rootDir>/routes/newsletter-mode-*/**/*.test.[jt]s?(x)',
],
Comment on lines +5 to +8

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since testMatch is an allowlist, only newsletter-mode paths run. Any future *.test.tsx added elsewhere in jetpack-mu-wpcom silently won't execute — no failure, just silence, which is an easy one to lose a day to.

A short comment explaining the deliberate scoping would be enough, or broaden the match and exclude what you don't want.

};
9 changes: 8 additions & 1 deletion projects/packages/jetpack-mu-wpcom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

test now runs test:node && test:jest, but test-coverage is still the c8 wrapper — and c8 doesn't meaningfully instrument Jest, so the new .tsx files will produce no coverage data.

The other Jest-based packages in the monorepo use Jest's own coverage instead:

"test-coverage": "pnpm run test --coverage"

That's my best guess at why the "Code coverage requirement" check is currently red, though I didn't run the coverage job to confirm. Since the Coverage tests to be added later label is on, this may well be a deliberate defer — feel free to ignore if so.

"typecheck": "tsgo --build",
"watch": "pnpm run build-production-js && pnpm webpack watch"
Expand Down Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -151,6 +157,7 @@
"packageNamespace": "jetpack-mu-wpcom",
"handlePrefix": "jetpack-mu-wpcom",
"pages": [
"newsletter-mode",
"site-setup"
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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 => (
<NewsletterModePageShell>
<section
className="wpcom-newsletter-mode-page"
aria-labelledby="wpcom-newsletter-mode-onboarding-title"
>
<h2 id="wpcom-newsletter-mode-onboarding-title">
{ __( 'Newsletter setup', 'jetpack-mu-wpcom' ) }
</h2>
<p>{ __( 'This page is ready for its feature content.', 'jetpack-mu-wpcom' ) }</p>
</section>
</NewsletterModePageShell>
);

export { Stage as stage };
Original file line number Diff line number Diff line change
@@ -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"
}
}
Original file line number Diff line number Diff line change
@@ -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 => (
<NewsletterModePageShell>
<section
className="wpcom-newsletter-mode-page"
aria-labelledby="wpcom-newsletter-mode-stats-title"
>
<h2 id="wpcom-newsletter-mode-stats-title">
{ __( 'Stats dashboard', 'jetpack-mu-wpcom' ) }
</h2>
<p>{ __( 'This page is ready for its feature content.', 'jetpack-mu-wpcom' ) }</p>
</section>
</NewsletterModePageShell>
);

export { Stage as stage };
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
<?php
/**
* Newsletter Mode page shell.
*
* @package automattic/jetpack-mu-wpcom
*/

namespace Automattic\Jetpack\Jetpack_Mu_Wpcom;

use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;

/**
* Registers the gated Newsletter Mode page and its wp-build assets.
*
* @since $$next-version$$
*/
class Newsletter_Mode {

/**
* Per-site enrollment option.
*/
const OPTION_ENABLED = 'wpcom_newsletter_mode_enabled';

/**
* Emergency constant that disables the feature even for enrolled sites.
*/
const DISABLE_CONSTANT = 'WPCOM_NEWSLETTER_MODE_DISABLED';

/**
* WP Build page ID.
*/
const PAGE_ID = 'newsletter-mode';

/**
* WP Admin menu slug generated for a WP Build page.
*/
const MENU_SLUG = self::PAGE_ID . '-wp-admin';

/**
* Render callback generated by wp-build in build/pages/newsletter-mode/page-wp-admin.php.
*/
const RENDER_CALLBACK = 'jetpack_mu_wpcom_newsletter_mode_wp_admin_render_page';

/**
* Capability required to enter the shell.
*
* Features added later retain the capabilities of their corresponding
* wp-admin screens and APIs.
*/
const ACCESS_CAPABILITY = 'publish_posts';

/**
* Initialize Newsletter Mode for an enrolled site.
*/
public static function init() {
if ( ! is_admin() || wp_doing_ajax() ) {
return;
}

if ( ! self::is_enabled() ) {
return;
}

add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) );

if ( ! self::is_page_request() || ! current_user_can( self::ACCESS_CAPABILITY ) ) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The capability gets evaluated twice, at two different points in the request, and the two can disagree.

This check runs at plugins_loaded (via load_wpcom_sites_features()). add_menu_page() then checks the same capability again at admin_menu — and init fires in between (wp-settings.php: plugins_loaded at L622, init at L771).

So if anything grants publish_posts through a user_has_cap / map_meta_cap filter registered on init — common in membership and role-editor plugins — the two disagree: build.php never loads here, but the menu still registers at admin_menu with the __return_empty_string fallback. The user sees "Newsletter", clicks it, and gets a completely blank page with no error.

Loading build.php early isn't the thing to change — the generated registrars hook wp_default_scripts / wp_default_styles, so it genuinely has to be required before WP_Scripts is first instantiated. It's the early capability check that could go:

if ( ! self::is_page_request() ) {
	return;
}

add_menu_page() already gates access, which is what ai-launchpad relies on. Registering assets for a user who can't reach the page is harmless — nothing enqueues them. It also avoids resolving and caching the current user before init as a side effect.

return;
}

self::load_wp_build();
}

/**
* Whether the emergency kill switch allows Newsletter Mode to run.
*
* @return bool
*/
public static function is_available() {
return ! defined( self::DISABLE_CONSTANT ) || ! constant( self::DISABLE_CONSTANT );
}

/**
* Whether the current site is enrolled and the feature is available.
*
* Enroll the current site from its WP-CLI context with:
* `wp option update wpcom_newsletter_mode_enabled 1`.
*
* @return bool
*/
public static function is_enabled() {
return self::is_available() && (bool) get_option( self::OPTION_ENABLED, false );
}

/**
* Register the top-level Newsletter menu page for eligible users.
*/
public static function register_menu() {
if ( ! self::is_enabled() ) {
return;
}

// The generated callback only exists after build/build.php is loaded on
// the Newsletter Mode request. The fallback keeps menu registration safe
// in source checkouts and when build artifacts are unavailable.
$callback = function_exists( self::RENDER_CALLBACK )
? self::RENDER_CALLBACK
: '__return_empty_string';

add_menu_page(
__( 'Newsletter', 'jetpack-mu-wpcom' ),
__( 'Newsletter', 'jetpack-mu-wpcom' ),
self::ACCESS_CAPABILITY,
self::MENU_SLUG,
$callback,
'dashicons-email-alt',
3
);
}

/**
* Load wp-build's generated registrations for the Newsletter Mode page.
*/
private static function load_wp_build() {
$wp_build_index = dirname( __DIR__, 3 ) . '/build/build.php';

if ( ! file_exists( $wp_build_index ) ) {
return;
}

require_once $wp_build_index;

WP_Build_Polyfills::register(
'jetpack-mu-wpcom',
array_merge(
WP_Build_Polyfills::SCRIPT_HANDLES,
WP_Build_Polyfills::MODULE_IDS
)
);
}

/**
* Whether this request targets the Newsletter Mode wp-build page.
*
* @return bool
*/
private static function is_page_request() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only routing parameter.
return is_admin() && isset( $_GET['page'] ) && self::MENU_SLUG === $_GET['page'];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// <reference types="jest" />

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: the filename doesn't quite describe what's under test — this renders the two route stages and never exercises NewsletterModePageShell directly. Something like routes.test.tsx would match, or keep the name and add an actual page-shell unit test alongside.


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( <Stage /> );

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();
} );
} );
Original file line number Diff line number Diff line change
@@ -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 (
<AdminPage
title={ __( 'Newsletter', 'jetpack-mu-wpcom' ) }
subTitle={ __( 'Create, grow, and manage your newsletter.', 'jetpack-mu-wpcom' ) }
>
{ children }
</AdminPage>
);
}
Loading
Loading