This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,155 @@
# 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).
## [1.3.0] - 2026-06-29
### Changed
- Centralize podcatcher host allowlist in PHP script data and preload mount-time REST responses to drop first-render round-trips. [#49969]
- Show a "Connect Jetpack" prompt on Stats and Episodes when the site isn't connected, instead of an upsell or an error. [#49989]
- Resolve the premium gate over the Jetpack connection so self-hosted Growth sites unlock paid surfaces, and point the upsell at the Growth plan. [#49942]
- Update package dependencies. [#49271]
### Fixed
- Don't show the "Connect Jetpack" prompt on Simple and Atomic sites, which have no Jetpack site connection. [#50022]
## [1.2.0] - 2026-06-25
### Added
- Register the Podcast submenu under the Jetpack menu on self-hosted sites when the module is active. [#49918]
### Changed
- Defer podcast REST endpoint instantiation so the endpoint classes only load on REST API requests. [#49803]
- Update package dependencies. [#49831]
## [1.1.1] - 2026-06-22
### Changed
- Update the Apple Podcasts category list to match the current taxonomy, validate topic input, and recommend a subcategory when a broad category is selected. [#49789]
## [1.1.0] - 2026-06-18
### Added
- Add a persistent "Create episode" button to the `/podcast` page header and a server-side prefill that assigns the configured category (and, on Premium, inserts the Podcast Episode block) when `post-new.php?podcast_episode=1` is opened.
- Create AI Podcast: Limit the "from specific posts" selection to 25 posts and surface a hint indicating the cap.
- Allow the package to load on self-hosted Jetpack sites behind the default-off `jetpack_podcast_for_the_world` filter.
- Ignore a deleted podcast category and add a raw show image URL accessor.
- Episode block: Add email renderer for the WooCommerce Email Editor.
### Changed
- Add a dedicated wpcom/v2 REST endpoint for site settings and load/save them through it from the dashboard, reachable on Simple, WoA, and self-hosted from a single definition.
- Align the distribution readiness notices with their settings field labels so it's clear which field each one refers to.
- Drop stale references to the removed jetpack_podcast_untangle gate and at-pressable-podcasting bridge from package docs.
- Gate the Posts to Podcast sub-feature to WordPress.com Simple and WoA hosts so it does not load on self-hosted Jetpack once the rest of the package becomes available there.
- Simplify REST endpoint and settings internals by dropping singleton init guards and single-use constants. No functional change.
- Posts to Podcast: Make the Create AI Podcast page available to connected users.
- Update package dependencies.
### Removed
- Remove the jetpack_podcast_untangle gate now that the legacy podcasting stack and at-pressable-podcasting bridge are gone; the package owns the experience unconditionally.
### Fixed
- Add a TypeScript config so dashboard route files type-check correctly.
- Create AI Podcast: Register the Media submenu without an `is_admin()` guard so it appears in the Calypso nav, not just wp-admin.
- Create AI Podcast: Require a connected WordPress.com account.
- Fix the "Create episode" button hover state to match standard Jetpack primary buttons.
- Fix a podcast app creating a new listing when visiting your feed instead of just confirming the one you already started.
- Fix empty episode descriptions in the feed when no excerpt is set.
- Resolve the blog ID via `Connection_Manager::get_site_id()` for feed enclosure URLs and tracks events so Atomic sites use the correct WPCOM site ID instead of 1.
## [1.0.2] - 2026-05-20
### Changed
- Podcast: default jetpack_podcast_untangle to true. The new package now owns the experience on every Simple and Atomic site by default; the filter stays as an escape hatch for forcing the legacy stack back on.
### Fixed
- Podcast: surface a Show notes textarea in the Podcast Episode block (synced with the post Excerpt) so authors know where the episode description in Apple Podcasts / Spotify / Pocket Casts comes from. Strip non-podcast RSS chrome from the feed: drop content:encoded (full post body + image EXIF), media:content (avatar + post images), per-item categories, and the comments-link cluster.
## [1.0.1] - 2026-05-20
### Added
- Podcast feed: surface per-episode metadata (transcript, location, license, people, soundbites, alternate enclosures, episode/season numbers) from the Podcast Episode block.
### Changed
- Podcast: rename the Distribution "Live" state badge to "Submitted" — the underlying signal is a crawler hit, not directory publication.
- Podcast Episode: swap inline chapters editor for a chapters JSON file uploader; feed now emits podcast:chapters.
### Fixed
- Podcast: proxy stats and Pocket Casts submit through Jetpack REST routes so the dashboard works on Atomic.
- Podcast: stop leaking body content into the episode RSS description, and drop the broken chapters JSON upload button in favor of a URL-only field with a soft warning when the URL doesn't look like JSON.
- Podcast feed: skip items without an enclosure and restore episode descriptions.
- Podcast feed: strip the blavatar, site-icon, and rss-cloud channel tags from the podcast RSS feed so the output stays iTunes-compliant once the untangle filter is flipped globally.
- Podcast settings + block polish from the regression sweep: decode HTML entities in the episode block title preview, defer Podcast Topics saves until focus leaves the field, persist empty Summary values, and align the block's season/episode number inputs with the feed's positive-integer requirement.
- Reduce podcast setup to a single click when you create a new category from the setup modal.
## [1.0.0] - 2026-05-19
### Security
- Podcast: escape title overrides, descriptions, and iTunes category attribute values for the RSS feed to prevent malformed XML. [#48876]
### Added
- Add a Free and Premium plan card to the podcast welcome screen so users can see what podcasting includes per plan before they enable. [#48800]
- Add the Podcast Episode block. Embeds a single podcast episode from an audio or video file with Podcasting 2.0 metadata. Registration is gated behind the `jetpack_podcast_untangle` filter (default off). [#48546]
- Create AI Podcast: emit client-side tracks events for page view, generation request, episode plays, draft opens, pagination, and the quota banner / upgrade CTAs. [#48900]
- Dashboard: gate the Episodes tab on Premium product access, with a blurred locked-preview overlay for free users. [#48885]
- Default the untangle gate to enabled for A8C-proxied requests so Automatticians dogfood the new package on Simple and Atomic. [#48699]
- Distribution tab: show Pending/Live state badges next to each podcast directory. [#48915]
- Pocket Casts: replace the 3-step submit modal with a one-click Relay API flow that reflects pending/submitted state on the button and surfaces rejection reasons inline. [#48732]
- Podcast: add product-access gate (Podcast_Gate::has_product_access) and grandfather sticker constant. [#48702]
- Podcast Welcome: require a category when enabling podcasting. [#48825]
- Posts to Podcast: Add an editor modal inviting eligible sites to create a podcast episode after publishing a post. [#48902]
- Posts to Podcast: new Media > Create AI Podcast wp-admin page for generating podcast episode drafts from posts via the wpcom-side pipeline. Pick posts to include or use a recent-posts window, steer the output with a free-form prompt, watch a remaining-credits indicator backed by the quota-snapshot endpoint, and resume polling across page reloads. The page is plain PHP plus a vanilla-JS island — no React or wp-build chassis for this surface. Feature is wpcom-only; self-hosted Jetpack sites don't see the menu. [#48774]
- Stats tab: render show- and episode-level podcast download stats. [#48614]
### Changed
- Build: Run webpack and wp-build scripts concurrently. [#48794]
- Create AI Podcast: map 429 responses (including non-JSON edge rate-limit pages) to "Out of credits" and other non-JSON failures to "An unexpected error occurred." instead of "The response is not a valid JSON response."; decode HTML entities in the posts-picker titles so values like " " no longer render literally; add an "Experimental" badge to the intro banner; instrument the generation poller and post-publish promo with Tracks events for funnel analysis. [#48949]
- Create AI Podcast: visual polish, floating toast notices, dismissible notices, generated episodes list, server-side bootstrap, and credits panel with reset messaging. [#48900]
- Distribution: refresh Apple Podcasts, Spotify, YouTube Music, Amazon Music, and Podcast Index logos with current brand marks. Rename the YouTube directory to YouTube Music. Map matching slugs in the Stats "By app" and "Top app" labels. [#48879]
- Episodes stats: detect 402 responses from the episode stats endpoint as a Premium-required state. [#48703]
- Episode stats: dispatch the premium-required state on the `podcast_premium_required` error code instead of HTTP 402. [#48885]
- Exclude development files from production builds. [#47365]
- Podcast: grandfather Premium access by site registration date; drop the sticker-based grandfathering path. [#48894]
- Podcast: narrow grandfather rule to sites registered before the cutoff that are also on a paid plan. [#48905]
- Podcast: serve square cover art in the feed by center-cropping via Photon, regardless of source aspect ratio. [#48938]
- Podcast: visual polish on the Stats tab — keep horizontal padding at narrow widths, lighter card headers, and integer-only axis ticks on the Downloads chart. [#48722]
- Podcast dashboard: opt into the shared `jp-admin-page-tabs--minimal` modifier so the tab strip aligns with the page header and labels use the design-system font size. [#48908]
- Podcast dashboard: reorder tabs so Stats appears first, followed by Episodes, Distribution, and Settings. [#48789]
- Podcast Episode: enrich front-end schema.org markup and make chapter / soundbite list items click-to-seek in the audio player. [#48793]
- Podcast Episode block: delegate the untangle gate to Podcast::is_enabled() so the block honors the same default as the rest of the package. [#48907]
- Podcast Episodes: fall back to the show cover image when an episode has no featured image. [#48790]
- Podcast Episodes: open the episode stats drilldown from a play-count click in the Episodes tab. [#48792]
- Podcast Settings: create new categories inline without leaving the podcast dashboard. [#48791]
- Podcast stats: rebuild summary tiles and bar list rows on @wordpress/components primitives. [#48742]
- Podcast stats dashboard: replace period dropdown with Calypso Stats date range picker (presets, calendar, custom from/to). [#48742]
- Polish the podcast setup flow: pin the post-setup destination to Settings, add a lead-in to the category picker modal, and surface a "+ Create episode" CTA on the empty Episodes state. [#48882]
- Settings: Add a "Cover image" subheading above the cover image control and rename the "Podcast category" section to "Post category". [#48880]
- Stats: Use Studio Blue (#3858e9) for bar colors on every surface, matching Calypso defaults. [#48888]
- Stats tab: Align Top episodes, By app, and Locations cards with the WordPress.com Stats card module look (real border, larger header, and 24px padding). [#48761]
- Update welcome screen copy to lead with the blog and newsletter story, and refresh the feature boxes and how-it-works steps. [#48796]
### Fixed
- Always show the Disable podcasting card on the settings tab, and return the user to the welcome screen after disabling, so the back-out flow works before a category has been chosen. [#48797]
- Create AI Podcast: Clarify that free trial credits do not reset. [#48957]
- Create AI Podcast: serve the generated-episodes list directly from the site database so it works on Atomic installs. [#48900]
- Distribution: revert Podcast Index logo to previous version. [#48922]
- Podcast: enqueue WP media library so the cover image selector loads. [#48720]
- Podcast: skip rewriting the RSS enclosure URL through the stats endpoint when the URL does not resolve to a local attachment, so externally hosted enclosures stay playable. [#48878]
- Polish Podcast dashboard styles: match placeholder thumbnail size to populated thumbnails, and space out the Distribution tab's warning notice. [#48920]
## 0.1.0 - 2026-05-11
### Added
- Add initial package gated behind the `jetpack_podcast_untangle` filter. [#48556]
- Dashboard: Add an empty wp-build dashboard scaffold and the "Jetpack > Podcast" wp-admin entry. [#48557]
- Dashboard: Fill in the four tab panels: Welcome onboarding, Settings form, Episodes list, and Distribution submission flow. [#48667]
- Feed: Register `<itunes:*>` / `<googleplay:*>` channel and item tags, podcatcher detection, and stats-tracked enclosure URLs for the configured podcast category. [#48658]
- Settings: Register the `podcasting_*` option schema with REST exposure and Jetpack Sync opt-in. [#48597]
- Tracks: Record podcast publishing, media uploads, status changes, podcatcher show-URL submissions, and settings saves. [#48665]
### Changed
- Dashboard: Replace the wp-build placeholder with page chrome and tab navigation. [#48559]
- Dashboard: Slim down wp-build wiring to the Backup pattern. [#48600]
[1.3.0]: https://github.com/Automattic/jetpack-podcast/compare/v1.2.0...v1.3.0
[1.2.0]: https://github.com/Automattic/jetpack-podcast/compare/v1.1.1...v1.2.0
[1.1.1]: https://github.com/Automattic/jetpack-podcast/compare/v1.1.0...v1.1.1
[1.1.0]: https://github.com/Automattic/jetpack-podcast/compare/v1.0.2...v1.1.0
[1.0.2]: https://github.com/Automattic/jetpack-podcast/compare/v1.0.1...v1.0.2
[1.0.1]: https://github.com/Automattic/jetpack-podcast/compare/v1.0.0...v1.0.1
[1.0.0]: https://github.com/Automattic/jetpack-podcast/compare/v0.1.0...v1.0.0
@@ -0,0 +1,43 @@
<?php
/**
* Main entry point for auto-generated asset registration.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
// Load script module registration.
$modules_file = __DIR__ . '/modules.php';
if ( file_exists( $modules_file ) ) {
require_once $modules_file;
}
// Load script registration.
$scripts_file = __DIR__ . '/scripts.php';
if ( file_exists( $scripts_file ) ) {
require_once $scripts_file;
}
// Load style registration.
$styles_file = __DIR__ . '/styles.php';
if ( file_exists( $styles_file ) ) {
require_once $styles_file;
}
// Load routes registration.
$routes_file = __DIR__ . '/routes.php';
if ( file_exists( $routes_file ) ) {
require_once $routes_file;
}
// Load widgets registration.
$widgets_file = __DIR__ . '/widgets.php';
if ( file_exists( $widgets_file ) ) {
require_once $widgets_file;
}
// Load pages registration.
$pages_file = __DIR__ . '/pages.php';
if ( file_exists( $pages_file ) ) {
require_once $pages_file;
}
@@ -0,0 +1,14 @@
<?php
/**
* Plugin constants - Auto-generated by build process.
* Do not edit this file manually.
*
* Returns an array of constants for use in other templates.
*
* @package jetpack_podcast
*/
return array(
'version' => '1.3.0',
'build_url' => plugin_dir_url( __FILE__ ),
);
@@ -0,0 +1,63 @@
<?php
/**
* Script module registration - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
/**
* Register all script modules.
*/
function jetpack_podcast_register_script_modules() {
// Ensure this only runs once. wp_default_scripts can fire multiple times,
// and each wp_deregister_script_module() call also dequeues the module.
// If a module was enqueued between calls, repeated deregister/register
// cycles would lose the enqueue state.
static $already_registered = false;
if ( $already_registered ) {
return;
}
$already_registered = true;
// Load build constants
$constants_file = __DIR__ . '/constants.php';
if ( ! file_exists( $constants_file ) ) {
return;
}
$build_constants = require $constants_file;
$modules_dir = __DIR__ . '/modules';
$modules_file = $modules_dir . '/registry.php';
if ( ! file_exists( $modules_file ) ) {
return;
}
$modules = require $modules_file;
$base_url = $build_constants['build_url'] . 'modules/';
foreach ( $modules as $module ) {
// WASM-only modules (e.g., vips worker) only have .min.js; use it even when SCRIPT_DEBUG is `true`.
$extension = '.min.js';
$asset_path = $modules_dir . '/' . $module['asset'];
$asset = file_exists( $asset_path ) ? require $asset_path : array();
// Deregister first to override any previously registered version
// (e.g., Core's default modules when running as a plugin).
wp_deregister_script_module( $module['id'] );
wp_register_script_module(
$module['id'],
$base_url . $module['path'] . $extension,
$asset['module_dependencies'] ?? array(),
$asset['version'] ?? false,
array(
'fetchpriority' => 'low',
'in_footer' => true,
)
);
}
}
add_action( 'wp_default_scripts', 'jetpack_podcast_register_script_modules' );
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-commands', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-editor', 'wp-element', 'wp-html-entities', 'wp-i18n', 'wp-keyboard-shortcuts', 'wp-keycodes', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/lazy-editor', 'import' => 'dynamic'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => '188faf6c64fdd1b4');
@@ -0,0 +1,11 @@
<?php
/**
* Script module registry - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
return array(
);
@@ -0,0 +1,16 @@
<?php
/**
* Pages loader - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
foreach ( [
__DIR__ . '/pages/jetpack-podcast-dashboard/page.php',
__DIR__ . '/pages/jetpack-podcast-dashboard/page-wp-admin.php',
] as $file ) {
if ( file_exists( $file ) ) {
require_once $file;
}
}
@@ -0,0 +1 @@
// Empty module loader for page dependencies
@@ -0,0 +1,317 @@
<?php
/**
* Page: jetpack-podcast-dashboard (wp-admin integrated)
* Auto-generated by build process.
* Do not edit this file manually.
*
* This version integrates with the standard WordPress admin interface,
* keeping the wp-admin sidebar and scripts/styles intact.
*
* @package jetpack_podcast
*/
// Global storage for jetpack-podcast-dashboard routes and menu items
global $jetpack_podcast_jetpack_podcast_dashboard_wp_admin_routes, $jetpack_podcast_jetpack_podcast_dashboard_wp_admin_menu_items;
$jetpack_podcast_jetpack_podcast_dashboard_wp_admin_routes = array();
$jetpack_podcast_jetpack_podcast_dashboard_wp_admin_menu_items = array();
/**
* Register a route for the jetpack-podcast-dashboard-wp-admin page.
*
* @param string $path Route path (e.g., '/types/$type/edit/$id').
* @param string|null $content_module Script module ID for content (stage/inspector).
* @param string|null $route_module Script module ID for route lifecycle hooks.
*/
function jetpack_podcast_register_jetpack_podcast_dashboard_wp_admin_route( $path, $content_module = null, $route_module = null ) {
global $jetpack_podcast_jetpack_podcast_dashboard_wp_admin_routes;
$route = array( 'path' => $path );
if ( ! empty( $content_module ) ) {
$route['content_module'] = $content_module;
}
if ( ! empty( $route_module ) ) {
$route['route_module'] = $route_module;
}
$jetpack_podcast_jetpack_podcast_dashboard_wp_admin_routes[] = $route;
}
/**
* Register a menu item for the jetpack-podcast-dashboard-wp-admin page.
* Note: Menu items are registered but not displayed in single-page mode.
*
* @param string $id Menu item ID.
* @param string $label Display label.
* @param string $to Route path to navigate to.
* @param string $parent_id Optional. Parent menu item ID.
*/
function jetpack_podcast_register_jetpack_podcast_dashboard_wp_admin_menu_item( $id, $label, $to, $parent_id = '' ) {
global $jetpack_podcast_jetpack_podcast_dashboard_wp_admin_menu_items;
$menu_item = array(
'id' => $id,
'label' => $label,
'to' => $to,
);
if ( ! empty( $parent_id ) ) {
$menu_item['parent'] = $parent_id;
}
$jetpack_podcast_jetpack_podcast_dashboard_wp_admin_menu_items[] = $menu_item;
}
/**
* Get all registered routes for the jetpack-podcast-dashboard-wp-admin page.
*
* @return array Array of route objects.
*/
function jetpack_podcast_get_jetpack_podcast_dashboard_wp_admin_routes() {
global $jetpack_podcast_jetpack_podcast_dashboard_wp_admin_routes;
return $jetpack_podcast_jetpack_podcast_dashboard_wp_admin_routes ?? array();
}
/**
* Get all registered menu items for the jetpack-podcast-dashboard-wp-admin page.
*
* @return array Array of menu item objects.
*/
function jetpack_podcast_get_jetpack_podcast_dashboard_wp_admin_menu_items() {
global $jetpack_podcast_jetpack_podcast_dashboard_wp_admin_menu_items;
return $jetpack_podcast_jetpack_podcast_dashboard_wp_admin_menu_items ?? array();
}
/**
* Preload REST API data for the jetpack-podcast-dashboard-wp-admin page.
* Automatically called during page rendering.
*/
function jetpack_podcast_jetpack_podcast_dashboard_wp_admin_preload_data() {
// Define paths to preload - same for all pages
// Please also change packages/core-data/src/entities.js when changing this.
$preload_paths = array(
'/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
// Use rest_preload_api_request to gather the preloaded data
$preload_data = array_reduce(
$preload_paths,
'rest_preload_api_request',
array()
);
// Register the preloading middleware with wp-api-fetch
wp_add_inline_script(
'wp-api-fetch',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
wp_json_encode( $preload_data )
),
'after'
);
}
/**
* Enqueue scripts and styles for the jetpack-podcast-dashboard-wp-admin page.
* Hooked to admin_enqueue_scripts.
*
* @param string $hook_suffix The current admin page.
*/
function jetpack_podcast_jetpack_podcast_dashboard_wp_admin_enqueue_scripts( $hook_suffix ) {
// Check all possible ways this page can be accessed:
// 1. Menu page via admin.php?page=jetpack-podcast-dashboard-wp-admin (plugin)
// 2. Direct file via jetpack-podcast-dashboard.php (Core) - screen ID will be 'jetpack-podcast-dashboard'
$current_screen = get_current_screen();
$is_our_page = (
( isset( $_GET['page'] ) && 'jetpack-podcast-dashboard-wp-admin' === $_GET['page'] ) || // phpcs:ignore WordPress.Security.NonceVerification.Recommended
( $current_screen && 'jetpack-podcast-dashboard' === $current_screen->id )
);
if ( ! $is_our_page ) {
return;
}
// Load build constants
$build_constants = require __DIR__ . '/../../constants.php';
/**
* Fires when the jetpack-podcast-dashboard admin page is initialized so extensions can register routes and menu items.
*/
do_action( 'jetpack-podcast-dashboard-wp-admin_init' );
// Preload REST API data
jetpack_podcast_jetpack_podcast_dashboard_wp_admin_preload_data();
// Get all registered routes
$routes = jetpack_podcast_get_jetpack_podcast_dashboard_wp_admin_routes();
// Get boot module asset file for dependencies
$asset_file = __DIR__ . '/../../modules/boot/index.min.asset.php';
if ( file_exists( $asset_file ) ) {
$asset = require $asset_file;
// This script serves two purposes:
// 1. It ensures all the globals that are made available to the modules are loaded.
// 2. It initializes the boot module as an inline script.
wp_register_script( 'jetpack-podcast-dashboard-wp-admin-prerequisites', '', $asset['dependencies'], $asset['version'], true );
/*
* Add inline script to initialize the app using initSinglePage (no menuItems).
* The dynamic import is deferred until DOMContentLoaded so that all classic
* script dependencies of @wordpress/boot (wp-private-apis, wp-components,
* wp-theme, etc.) have finished parsing and executing before the boot module
* evaluates. Otherwise, a modulepreloaded @wordpress/boot can win the race
* against the classic-script-printing pass on fast CDN-fronted hosts in
* Chrome, evaluating before wp.theme.privateApis is defined and throwing
* "Cannot unlock an undefined object". See <https://core.trac.wordpress.org/ticket/65103>.
*/
$init_js_function = <<<'JS'
( mountId, routes ) => {
const run = async () => {
const mod = await import( "@wordpress/boot" );
mod.initSinglePage( { mountId, routes } );
};
if ( document.readyState === "loading" ) {
document.addEventListener( "DOMContentLoaded", run );
} else {
run();
}
}
JS;
wp_add_inline_script(
'jetpack-podcast-dashboard-wp-admin-prerequisites',
sprintf(
'( %s )( %s, %s );',
$init_js_function,
wp_json_encode( 'jetpack-podcast-dashboard-wp-admin-app', JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES )
)
);
// Register prerequisites style by filtering script dependencies to find registered styles
$style_dependencies = array_filter(
$asset['dependencies'],
function ( $handle ) {
return wp_style_is( $handle, 'registered' );
}
);
wp_register_style( 'jetpack-podcast-dashboard-wp-admin-prerequisites', false, $style_dependencies, $asset['version'] );
// Build dependencies for jetpack-podcast-dashboard-wp-admin module
$boot_dependencies = array(
array(
'import' => 'static',
'id' => '@wordpress/boot',
),
);
// Add all registered routes as dependencies
foreach ( $routes as $route ) {
if ( isset( $route['route_module'] ) ) {
$boot_dependencies[] = array(
'import' => 'static',
'id' => $route['route_module'],
);
}
if ( isset( $route['content_module'] ) ) {
$boot_dependencies[] = array(
'import' => 'dynamic',
'id' => $route['content_module'],
);
}
}
/**
* Filters the boot script-module dependencies for the
* jetpack-podcast-dashboard-wp-admin page.
*
* Surfaces extending this page can append entries to the boot
* dependency list. Each entry is an array with 'import' (string
* 'static' or 'dynamic') and 'id' (script-module handle) keys.
*
* @param array $boot_dependencies Boot dependencies for the page.
*/
$boot_dependencies = apply_filters(
'jetpack-podcast-dashboard-wp-admin_boot_dependencies',
$boot_dependencies
);
// Dummy script module to ensure dependencies are loaded
wp_register_script_module(
'jetpack-podcast-dashboard-wp-admin',
$build_constants['build_url'] . 'pages/jetpack-podcast-dashboard/loader.js',
$boot_dependencies
);
// Enqueue the boot scripts and styles
wp_enqueue_script( 'jetpack-podcast-dashboard-wp-admin-prerequisites' );
wp_enqueue_script_module( 'jetpack-podcast-dashboard-wp-admin' );
wp_enqueue_style( 'jetpack-podcast-dashboard-wp-admin-prerequisites' );
}
}
/**
* Render the jetpack-podcast-dashboard-wp-admin page.
* Call this function from add_menu_page or add_submenu_page.
* This renders within the normal WordPress admin interface.
*/
function jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page() {
?>
<style>
/* Critical styles to prevent layout shifts - inlined for immediate application */
#wpwrap {
overflow-y: auto;
}
body {
background: #fff;
}
/* Reset wp-admin padding */
#wpcontent {
padding-inline-start: 0;
}
#wpbody-content {
padding-bottom: 0;
}
/* Hide legacy admin elements */
#wpbody-content > div:not(.boot-layout-container):not(#screen-meta) {
display: none;
}
#wpfooter {
display: none;
}
/* Accessibility regions */
.a11y-speak-region {
inset-inline-start: -1px;
top: -1px;
}
/* Admin menu indicators */
ul#adminmenu a.wp-has-current-submenu::after,
ul#adminmenu > li.current > a.current::after {
border-inline-end-color: #fff;
}
/* Media frame fix */
.media-frame select.attachment-filters:last-of-type {
width: auto;
max-width: 100%;
}
/* Responsive overflow fix for #wpwrap */
@media (min-width: 782px) {
#wpwrap {
overflow-y: initial;
}
}
</style>
<div id="jetpack-podcast-dashboard-wp-admin-app" class="boot-layout-container"></div>
<?php
}
// Hook the enqueue function to admin_enqueue_scripts
add_action( 'admin_enqueue_scripts', 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_enqueue_scripts' );
@@ -0,0 +1,318 @@
<?php
/**
* Page: jetpack-podcast-dashboard
* Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
// Global storage for jetpack-podcast-dashboard routes and menu items
global $jetpack_podcast_jetpack_podcast_dashboard_routes, $jetpack_podcast_jetpack_podcast_dashboard_menu_items;
$jetpack_podcast_jetpack_podcast_dashboard_routes = array();
$jetpack_podcast_jetpack_podcast_dashboard_menu_items = array();
/**
* Register a route for the jetpack-podcast-dashboard page.
*
* @param string $path Route path (e.g., '/types/$type/edit/$id').
* @param string|null $content_module Script module ID for content (stage/inspector).
* @param string|null $route_module Script module ID for route lifecycle hooks.
*/
function jetpack_podcast_register_jetpack_podcast_dashboard_route( $path, $content_module = null, $route_module = null ) {
global $jetpack_podcast_jetpack_podcast_dashboard_routes;
$route = array( 'path' => $path );
if ( ! empty( $content_module ) ) {
$route['content_module'] = $content_module;
}
if ( ! empty( $route_module ) ) {
$route['route_module'] = $route_module;
}
$jetpack_podcast_jetpack_podcast_dashboard_routes[] = $route;
}
/**
* Register a menu item for the jetpack-podcast-dashboard page.
*
* @param string $id Menu item ID.
* @param string $label Display label.
* @param string $to Route path to navigate to.
* @param string $parent_id Optional. Parent menu item ID.
* @param string $parent_type Optional. Parent type: 'drilldown' or 'dropdown'.
*/
function jetpack_podcast_register_jetpack_podcast_dashboard_menu_item( $id, $label, $to, $parent_id = '', $parent_type = '' ) {
global $jetpack_podcast_jetpack_podcast_dashboard_menu_items;
$menu_item = array(
'id' => $id,
'label' => $label,
'to' => $to,
);
if ( ! empty( $parent_id ) ) {
$menu_item['parent'] = $parent_id;
}
if ( ! empty( $parent_type ) && in_array( $parent_type, array( 'drilldown', 'dropdown' ), true ) ) {
$menu_item['parent_type'] = $parent_type;
}
$jetpack_podcast_jetpack_podcast_dashboard_menu_items[] = $menu_item;
}
/**
* Get all registered routes for the jetpack-podcast-dashboard page.
*
* @return array Array of route objects.
*/
function jetpack_podcast_get_jetpack_podcast_dashboard_routes() {
global $jetpack_podcast_jetpack_podcast_dashboard_routes;
return $jetpack_podcast_jetpack_podcast_dashboard_routes ?? array();
}
/**
* Get all registered menu items for the jetpack-podcast-dashboard page.
*
* @return array Array of menu item objects.
*/
function jetpack_podcast_get_jetpack_podcast_dashboard_menu_items() {
global $jetpack_podcast_jetpack_podcast_dashboard_menu_items;
return $jetpack_podcast_jetpack_podcast_dashboard_menu_items ?? array();
}
/**
* Preload REST API data for the jetpack-podcast-dashboard page.
* Automatically called during page rendering.
*/
function jetpack_podcast_jetpack_podcast_dashboard_preload_data() {
// Define paths to preload - same for all pages
// Please also change packages/core-data/src/entities.js when changing this.
$preload_paths = array(
'/?_fields=description,gmt_offset,home,image_sizes,image_size_threshold,name,site_icon,site_icon_url,site_logo,timezone_string,url,page_for_posts,page_on_front,show_on_front',
array( '/wp/v2/settings', 'OPTIONS' ),
);
// Use rest_preload_api_request to gather the preloaded data
$preload_data = array_reduce(
$preload_paths,
'rest_preload_api_request',
array()
);
// Register the preloading middleware with wp-api-fetch
wp_add_inline_script(
'wp-api-fetch',
sprintf(
'wp.apiFetch.use( wp.apiFetch.createPreloadingMiddleware( %s ) );',
wp_json_encode( $preload_data )
),
'after'
);
}
/**
* Render the jetpack-podcast-dashboard page.
* Call this function from add_menu_page or add_submenu_page.
*/
function jetpack_podcast_jetpack_podcast_dashboard_render_page() {
// Load build constants
$build_constants = require __DIR__ . '/../../constants.php';
// Set current screen
set_current_screen();
// Remove unwanted deprecated handler
remove_action( 'admin_head', 'wp_admin_bar_header' );
// Remove unwanted scripts and styles that were enqueued during `admin_init`
foreach ( wp_scripts()->queue as $script ) {
wp_dequeue_script( $script );
}
foreach ( wp_styles()->queue as $style ) {
wp_dequeue_style( $style );
}
/**
* Fires when the jetpack-podcast-dashboard page is initialized so extensions can register routes and menu items.
*/
do_action( 'jetpack-podcast-dashboard_init' );
// Enqueue command palette assets for boot-based pages
if ( function_exists( 'wp_enqueue_command_palette_assets' ) ) {
wp_enqueue_command_palette_assets();
}
// Preload REST API data
jetpack_podcast_jetpack_podcast_dashboard_preload_data();
// Get all registered routes and menu items
$menu_items = jetpack_podcast_get_jetpack_podcast_dashboard_menu_items();
$routes = jetpack_podcast_get_jetpack_podcast_dashboard_routes();
// Get boot module asset file for dependencies
$asset_file = __DIR__ . '/../../modules/boot/index.min.asset.php';
if ( file_exists( $asset_file ) ) {
$asset = require $asset_file;
// This script serves two purposes:
// 1. It ensures all the globals that are made available to the modules are loaded.
// 2. It initializes the boot module as an inline script.
wp_register_script( 'jetpack-podcast-dashboard-prerequisites', '', $asset['dependencies'], $asset['version'], true );
// Add inline script to initialize the app
$init_modules = [];
wp_add_inline_script(
'jetpack-podcast-dashboard-prerequisites',
sprintf(
'import("@wordpress/boot").then(mod => mod.init({mountId: "%s", menuItems: %s, routes: %s, initModules: %s, dashboardLink: "%s"}));',
'jetpack-podcast-dashboard-app',
wp_json_encode( $menu_items, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( $routes, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
wp_json_encode( $init_modules, JSON_HEX_TAG | JSON_UNESCAPED_SLASHES ),
esc_url( admin_url( '/' ) )
)
);
// Register prerequisites style by filtering script dependencies to find registered styles
$style_dependencies = array_filter(
$asset['dependencies'],
function ( $handle ) {
return wp_style_is( $handle, 'registered' );
}
);
wp_register_style( 'jetpack-podcast-dashboard-prerequisites', false, $style_dependencies, $asset['version'] );
// Build dependencies for jetpack-podcast-dashboard module
$boot_dependencies = array(
array(
'import' => 'static',
'id' => '@wordpress/boot',
),
);
// Add init modules as static dependencies
// No init modules configured
// Add all registered routes as dependencies
foreach ( $routes as $route ) {
if ( isset( $route['route_module'] ) ) {
$boot_dependencies[] = array(
'import' => 'static',
'id' => $route['route_module'],
);
}
if ( isset( $route['content_module'] ) ) {
$boot_dependencies[] = array(
'import' => 'dynamic',
'id' => $route['content_module'],
);
}
}
/**
* Filters the boot script-module dependencies for the
* jetpack-podcast-dashboard page.
*
* Surfaces extending this page can append entries to the boot
* dependency list. Each entry is an array with 'import' (string
* 'static' or 'dynamic') and 'id' (script-module handle) keys.
*
* @param array $boot_dependencies Boot dependencies for the page.
*/
$boot_dependencies = apply_filters(
'jetpack-podcast-dashboard_boot_dependencies',
$boot_dependencies
);
// Dummy script module to ensure dependencies are loaded
wp_register_script_module(
'jetpack-podcast-dashboard',
$build_constants['build_url'] . 'pages/jetpack-podcast-dashboard/loader.js',
$boot_dependencies
);
// Enqueue the boot scripts and styles
wp_enqueue_script( 'jetpack-podcast-dashboard-prerequisites' );
wp_enqueue_script_module( 'jetpack-podcast-dashboard' );
wp_enqueue_style( 'jetpack-podcast-dashboard-prerequisites' );
}
// Output the HTML
?>
<!DOCTYPE html>
<html <?php language_attributes(); ?>>
<head>
<meta charset="<?php bloginfo( 'charset' ); ?>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title><?php echo esc_html( get_admin_page_title() ); ?></title>
<style>
html {
background: #f1f1f1;
color: #444;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif;
font-size: 13px;
line-height: 1.4em;
}
body {
margin: 0;
}
#wpadminbar { display: none; }
</style>
<?php
global $hook_suffix;
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
$hook_suffix = 'jetpack-podcast-dashboard';
// BEGIN see wp-admin/admin-header.php
print_admin_styles();
print_head_scripts();
/** This action is documented in wp-admin/admin-header.php */
do_action( "admin_head-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
/** This action is documented in wp-admin/admin-header.php */
do_action( 'admin_head' );
// END see wp-admin/admin-header.php
?>
</head>
<body class="jetpack-podcast-dashboard">
<div id="jetpack-podcast-dashboard-app" style="height: 100vh; box-sizing: border-box;"></div>
<?php
// BEGIN see wp-admin/admin-footer.php
/** This action is documented in wp-admin/admin-footer.php */
do_action( 'admin_footer', '' );
// Print import map first so it's available for inline scripts
wp_script_modules()->print_import_map();
print_footer_scripts();
wp_script_modules()->print_enqueued_script_modules();
wp_script_modules()->print_script_module_preloads();
wp_script_modules()->print_script_module_data();
/** This action is documented in wp-admin/admin-footer.php */
do_action( "admin_footer-{$hook_suffix}" ); // phpcs:ignore WordPress.NamingConventions.ValidHookName.UseUnderscores
// END see wp-admin/admin-footer.php
?>
</body>
</html>
<?php
exit;
}
/**
* Intercept admin_init to render the page early.
* This bypasses the default WordPress admin template.
*/
function jetpack_podcast_jetpack_podcast_dashboard_intercept_render() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET['page'] ) && 'jetpack-podcast-dashboard' === $_GET['page'] ) {
jetpack_podcast_jetpack_podcast_dashboard_render_page();
exit;
}
}
// Hook the interceptor to admin_init
add_action( 'admin_init', 'jetpack_podcast_jetpack_podcast_dashboard_intercept_render' );
@@ -0,0 +1,113 @@
<?php
/**
* Routes registration - Auto-generated by build process.
* Registers all routes on their respective page init hooks.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
// Load routes registry
$routes_file = __DIR__ . '/routes/registry.php';
if ( ! file_exists( $routes_file ) ) {
return;
}
$routes = require $routes_file;
// Group routes by page and store in globals for page-specific functions
$routes_by_page = array();
foreach ( $routes as $route ) {
$page_slug = $route['page'];
if ( ! isset( $routes_by_page[ $page_slug ] ) ) {
$routes_by_page[ $page_slug ] = array();
}
$routes_by_page[ $page_slug ][] = $route;
}
// Store routes data in globals for each page
foreach ( $routes_by_page as $page_slug => $page_routes ) {
$page_slug_underscore = str_replace( '-', '_', $page_slug );
$global_name = 'jetpack_podcast_' . $page_slug_underscore . '_routes_data';
$GLOBALS[ $global_name ] = $page_routes;
}
/**
* Generic helper function to register routes for a page.
*
* @param array $page_routes Array of route data for the page.
* @param string $register_function_name Name of the function to call for registering each route.
*/
function jetpack_podcast_register_page_routes( $page_routes, $register_function_name ) {
// Load build constants
$build_constants = require __DIR__ . '/constants.php';
foreach ( $page_routes as $route ) {
$content_handle = null;
$route_handle = null;
// Register content module if exists
if ( $route['has_content'] ) {
$content_asset_path = __DIR__ . "/routes/{$route['name']}/content.min.asset.php";
if ( file_exists( $content_asset_path ) ) {
$content_asset = require $content_asset_path;
$content_handle = 'jetpack-podcast/routes/' . $route['name'] . '/content';
$extension = '.min.js';
// Deregister first to override any previously registered version
// (e.g., Core's default modules when running as a plugin).
wp_deregister_script_module( $content_handle );
wp_register_script_module(
$content_handle,
$build_constants['build_url'] . 'routes/' . $route['name'] . '/content' . $extension,
$content_asset['module_dependencies'] ?? array(),
$content_asset['version'] ?? false
);
}
}
// Register route module if exists
if ( $route['has_route'] ) {
$route_asset_path = __DIR__ . "/routes/{$route['name']}/route.min.asset.php";
if ( file_exists( $route_asset_path ) ) {
$route_asset = require $route_asset_path;
$route_handle = 'jetpack-podcast/routes/' . $route['name'] . '/route';
$extension = '.min.js';
// Deregister first to override any previously registered version
// (e.g., Core's default modules when running as a plugin).
wp_deregister_script_module( $route_handle );
wp_register_script_module(
$route_handle,
$build_constants['build_url'] . 'routes/' . $route['name'] . '/route' . $extension,
$route_asset['module_dependencies'] ?? array(),
$route_asset['version'] ?? false
);
}
}
// Register route with page
if ( function_exists( $register_function_name ) ) {
call_user_func( $register_function_name, $route['path'], $content_handle, $route_handle );
}
}
}
// Page-specific route registration functions
// Page-specific route registration functions for jetpack-podcast-dashboard
/**
* Register routes for jetpack-podcast-dashboard page (full-page mode).
*/
function jetpack_podcast_register_jetpack_podcast_dashboard_page_routes() {
global $jetpack_podcast_jetpack_podcast_dashboard_routes_data;
jetpack_podcast_register_page_routes( $jetpack_podcast_jetpack_podcast_dashboard_routes_data, 'jetpack_podcast_register_jetpack_podcast_dashboard_route' );
}
add_action( 'jetpack-podcast-dashboard_init', 'jetpack_podcast_register_jetpack_podcast_dashboard_page_routes' );
/**
* Register routes for jetpack-podcast-dashboard page (wp-admin mode).
*/
function jetpack_podcast_register_jetpack_podcast_dashboard_wp_admin_page_routes() {
global $jetpack_podcast_jetpack_podcast_dashboard_routes_data;
jetpack_podcast_register_page_routes( $jetpack_podcast_jetpack_podcast_dashboard_routes_data, 'jetpack_podcast_register_jetpack_podcast_dashboard_wp_admin_route' );
}
add_action( 'jetpack-podcast-dashboard-wp-admin_init', 'jetpack_podcast_register_jetpack_podcast_dashboard_wp_admin_page_routes' );
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react', 'react-dom', 'react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-date', 'wp-deprecated', 'wp-element', 'wp-html-entities', 'wp-i18n', 'wp-keycodes', 'wp-media-utils', 'wp-notices', 'wp-primitives', 'wp-private-apis', 'wp-theme', 'wp-url', 'wp-warning'), 'module_dependencies' => array(array('id' => '@wordpress/a11y', 'import' => 'static'), array('id' => '@wordpress/route', 'import' => 'static')), 'version' => 'b31ca04869c0bb8fb826');
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => 'bdca920315a6e5637299');
@@ -0,0 +1 @@
var o={};export{o as route};
@@ -0,0 +1,17 @@
<?php
/**
* Route registry - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
return array(
array(
'name' => 'dashboard',
'path' => '/',
'page' => 'jetpack-podcast-dashboard',
'has_route' => true,
'has_content' => true,
)
);
@@ -0,0 +1,85 @@
<?php
/**
* Script registration - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
/**
* Registers a script according to `wp_register_script`. Honors this request by
* reassigning internal dependency properties of any script handle already
* registered by that name. It does not deregister the original script, to
* avoid losing inline scripts which may have been attached.
*
* @param WP_Scripts $scripts WP_Scripts instance.
* @param string $handle Name of the script. Should be unique.
* @param string $src Full URL of the script, or path of the script relative to the WordPress root directory.
* @param array $deps Optional. An array of registered script handles this script depends on. Default empty array.
* @param string|bool|null $ver Optional. String specifying script version number, if it has one, which is added to the URL
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param bool $in_footer Optional. Whether to enqueue the script before </body> instead of in the <head>.
* Default 'false'.
*/
function jetpack_podcast_override_script( $scripts, $handle, $src, $deps = array(), $ver = false, $in_footer = false ) {
$script = $scripts->query( $handle, 'registered' );
if ( $script ) {
/*
* In many ways, this is a reimplementation of `wp_register_script` but
* bypassing consideration of whether a script by the given handle had
* already been registered.
*/
// See: `_WP_Dependency::__construct` .
$script->src = $src;
$script->deps = $deps;
$script->ver = $ver;
$script->args = $in_footer ? 1 : null;
} else {
$scripts->add( $handle, $src, $deps, $ver, ( $in_footer ? 1 : null ) );
}
if ( in_array( 'wp-i18n', $deps, true ) ) {
$scripts->set_translations( $handle );
}
}
/**
* Register all package scripts.
*/
function jetpack_podcast_register_package_scripts( $scripts ) {
// Load build constants
$build_constants = require __DIR__ . '/constants.php';
$default_version = ! SCRIPT_DEBUG ? $build_constants['version'] : time();
$extension = '.min.js';
$scripts_dir = __DIR__ . '/scripts';
$scripts_file = $scripts_dir . '/registry.php';
if ( ! file_exists( $scripts_file ) ) {
return;
}
$scripts_data = require $scripts_file;
$plugin_dir = dirname( __FILE__ );
foreach ( $scripts_data as $script_data ) {
$asset_file = $scripts_dir . '/' . $script_data['asset'];
$asset = file_exists( $asset_file ) ? require $asset_file : array();
$dependencies = $asset['dependencies'] ?? array();
$version = $asset['version'] ?? $default_version;
jetpack_podcast_override_script(
$scripts,
$script_data['handle'],
$build_constants['build_url'] . 'scripts/' . $script_data['path'] . $extension,
$dependencies,
$version,
true
);
}
}
add_action( 'wp_default_scripts', 'jetpack_podcast_register_package_scripts' );
@@ -0,0 +1,11 @@
<?php
/**
* Script registry - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
return array(
);
@@ -0,0 +1,73 @@
<?php
/**
* Style registration - Auto-generated by build process.
* Do not edit this file manually.
*
* Note: These styles can be overridden by calling jetpack_podcast_override_style()
* in lib/client-assets.php for complex cases (multiple files, conditional deps, etc.)
*
* @package jetpack_podcast
*/
/**
* Registers a style according to `wp_register_style`. Honors this request by
* deregistering any style by the same handler before registration.
*
* @param WP_Styles $styles WP_Styles instance.
* @param string $handle Name of the stylesheet. Should be unique.
* @param string $src Full URL of the stylesheet, or path of the stylesheet relative to the WordPress root directory.
* @param array $deps Optional. An array of registered stylesheet handles this stylesheet depends on. Default empty array.
* @param string|bool|null $ver Optional. String specifying stylesheet version number, if it has one, which is added to the URL
* as a query string for cache busting purposes. If version is set to false, a version
* number is automatically added equal to current installed WordPress version.
* If set to null, no version is added.
* @param string $media Optional. The media for which this stylesheet has been defined.
* Default 'all'. Accepts media types like 'all', 'print' and 'screen', or media queries like
* '(orientation: portrait)' and '(max-width: 640px)'.
*/
function jetpack_podcast_override_style( $styles, $handle, $src, $deps = array(), $ver = false, $media = 'all' ) {
$style = $styles->query( $handle, 'registered' );
if ( $style ) {
$styles->remove( $handle );
}
$styles->add( $handle, $src, $deps, $ver, $media );
}
/**
* Register all package styles (simple cases only).
* Complex cases should be manually registered in lib/client-assets.php.
*
* @param WP_Styles $styles WP_Styles instance.
*/
function jetpack_podcast_register_package_styles( $styles ) {
// Load build constants
$build_constants = require __DIR__ . '/constants.php';
$default_version = ! SCRIPT_DEBUG ? $build_constants['version'] : time();
$suffix = '.min';
$styles_dir = __DIR__ . '/styles';
$styles_file = $styles_dir . '/registry.php';
if ( ! file_exists( $styles_file ) ) {
return;
}
$styles_data = require $styles_file;
$plugin_dir = dirname( __FILE__ );
foreach ( $styles_data as $style_data ) {
jetpack_podcast_override_style(
$styles,
$style_data['handle'],
$build_constants['build_url'] . 'styles/' . $style_data['path'] . $suffix . '.css',
$style_data['dependencies'],
$default_version
);
// Enable RTL support (WordPress automatically loads -rtl.css variant)
$styles->add_data( $style_data['handle'], 'rtl', 'replace' );
$styles->add_data( $style_data['handle'], 'suffix', $suffix );
}
}
add_action( 'wp_default_styles', 'jetpack_podcast_register_package_styles' );
@@ -0,0 +1,11 @@
<?php
/**
* Style registry - Auto-generated by build process.
* Do not edit this file manually.
*
* @package jetpack_podcast
*/
return array(
);
@@ -0,0 +1,116 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "jetpack/podcast-episode",
"title": "Podcast Episode",
"description": "Embed a single podcast episode from an audio or video file, with Podcasting 2.0 metadata.",
"keywords": [ "audio", "podcast", "episode" ],
"version": "1.0.0",
"textdomain": "jetpack-podcast",
"category": "embed",
"icon": "<svg viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'><path d='M12 2a5 5 0 0 0-5 5v5a5 5 0 0 0 10 0V7a5 5 0 0 0-5-5zm0 2a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V7a3 3 0 0 1 3-3zm-7 8a1 1 0 0 1 1 1 6 6 0 0 0 12 0 1 1 0 1 1 2 0 8 8 0 0 1-7 7.93V22h3v2H8v-2h3v-1.07A8 8 0 0 1 4 13a1 1 0 0 1 1-1z'/></svg>",
"usesContext": [ "postId", "postType" ],
"supports": {
"spacing": {
"padding": true,
"margin": true
},
"anchor": true,
"customClassName": true,
"className": true,
"html": false,
"multiple": true,
"reusable": true
},
"attributes": {
"mediaId": {
"type": "integer"
},
"mediaUrl": {
"type": "string"
},
"mediaType": {
"type": "string",
"enum": [ "audio", "video" ]
},
"mediaMimeType": {
"type": "string"
},
"episodeNumber": {
"type": "integer"
},
"seasonNumber": {
"type": "integer"
},
"episodeType": {
"type": "string",
"enum": [ "full", "trailer", "bonus" ],
"default": "full"
},
"explicit": {
"type": "boolean",
"default": false
},
"duration": {
"type": "string",
"default": ""
},
"transcriptUrl": {
"type": "string",
"default": ""
},
"transcriptType": {
"type": "string",
"enum": [ "text/vtt", "text/html", "application/srt", "application/json" ],
"default": "text/vtt"
},
"chaptersUrl": {
"type": "string",
"default": ""
},
"chaptersType": {
"type": "string",
"default": "application/json+chapters"
},
"locationName": {
"type": "string",
"default": ""
},
"license": {
"type": "string",
"default": ""
},
"licenseUrl": {
"type": "string",
"default": ""
},
"people": {
"type": "array",
"default": []
},
"showPoster": {
"type": "boolean",
"default": true
},
"coverArt": {
"type": "object",
"default": {}
},
"soundbites": {
"type": "array",
"default": []
},
"alternateEnclosures": {
"type": "array",
"default": []
}
},
"example": {
"attributes": {
"episodeNumber": 1,
"seasonNumber": 1,
"episodeType": "full",
"duration": "11:25"
}
}
}
@@ -0,0 +1 @@
<?php return array('dependencies' => array('jetpack-script-data', 'jetpack-shared-stores', 'react', 'react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-compose', 'wp-core-data', 'wp-data', 'wp-date', 'wp-dom-ready', 'wp-element', 'wp-hooks', 'wp-html-entities', 'wp-i18n', 'wp-notices', 'wp-plugins', 'wp-polyfill', 'wp-primitives', 'wp-url'), 'version' => '67f9f9f9fa903ec1a116');
@@ -0,0 +1,87 @@
/*!**************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** css ../../../node_modules/.pnpm/css-loader@6.11.0_webpack@5.107.2/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[2].use[1]!../../../node_modules/.pnpm/sass-loader@16.0.5_sass-embedded@1.97.3_webpack@5.107.2/node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[2].use[2]!../../js-packages/shared-extension-utils/src/with-has-warning-is-interactive-class-names/style.scss ***!
\**************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************/
.block-editor-block-list__layout .block-editor-block-list__block.has-warning.is-interactive > * {
pointer-events: auto;
user-select: auto;
}
.block-editor-block-list__layout .block-editor-block-list__block.has-warning.is-interactive::after {
content: none;
}
/*!***************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** css ../../../node_modules/.pnpm/css-loader@6.11.0_webpack@5.107.2/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[2].use[1]!../../../node_modules/.pnpm/sass-loader@16.0.5_sass-embedded@1.97.3_webpack@5.107.2/node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[2].use[2]!./src/blocks/podcast-episode/editor.scss ***!
\***************************************************************************************************************************************************************************************************************************************************************************************************************************************/
@charset "UTF-8";
/**
* Podcast Episode block — editor-only styles.
*
* These rules target sidebar controls (InspectorControls) which Gutenberg
* portals out of the block tree, so they cannot be nested under the block
* wrapper class. Class names are uniquely prefixed `jetpack-podcast-episode__`
* to keep root-level selectors collision-safe.
*/
.jetpack-podcast-episode__person-editor {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: 100%;
padding: 12px;
margin-bottom: 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.jetpack-podcast-episode__person-editor--alt {
background: #f6f7f7;
}
.jetpack-podcast-episode__cover-picker {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
width: 100%;
}
.jetpack-podcast-episode__cover-button {
box-sizing: border-box;
display: block;
width: 100%;
height: auto;
min-height: 0;
padding: 0;
border: 1px solid #ddd;
border-radius: 4px;
background: transparent;
overflow: hidden;
cursor: pointer;
}
.jetpack-podcast-episode__cover-button:hover, .jetpack-podcast-episode__cover-button:focus-visible {
border-color: var(--wp-admin-theme-color, #007cba);
}
.jetpack-podcast-episode__cover-button img {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.jetpack-podcast-episode__cover-button--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
border-style: dashed;
}
.jetpack-podcast-episode__cover-button--empty span {
color: var(--wp-admin-theme-color, #007cba);
font-weight: 500;
}
.jetpack-podcast-episode__cover-actions {
display: flex;
gap: 12px;
}
/*# sourceMappingURL=editor.css.map*/
@@ -0,0 +1 @@
{"version":3,"file":"podcast-episode/editor.css","mappings":";;;AAKC;EACC;EACA;AAJF;AAOC;EACC;AALF,C;;;;ACNA,gBAAgB;AAAhB;;;;;;;EAAA;AASA;EACC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;AACD;;AAIA;EACC;AADD;;AAMA;EACC;EACA;EACA;EACA;EACA;AAHD;;AASA;EACC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;AAND;AAQC;EAEC;AAPF;AAaC;EACC;EACA;EACA;EACA;AAXF;;AAeA;EACC;EACA;EACA;EACA;EACA;AAZD;AAcC;EACC;EACA;AAZF;;AAgBA;EACC;EACA;AAbD,C","sources":["webpack://@automattic/jetpack-podcast/../../js-packages/shared-extension-utils/src/with-has-warning-is-interactive-class-names/style.scss","webpack://@automattic/jetpack-podcast/./src/blocks/podcast-episode/editor.scss"],"sourcesContent":[".block-editor-block-list__layout\n// Override core styles inherited from `.has-warning`:\n// we do want blocks with upgrade nudge warning to be interactive\n.block-editor-block-list__block.has-warning.is-interactive {\n\n\t> * {\n\t\tpointer-events: auto;\n\t\tuser-select: auto;\n\t}\n\n\t&::after {\n\t\tcontent: none;\n\t}\n}\n","/**\n * Podcast Episode block — editor-only styles.\n *\n * These rules target sidebar controls (InspectorControls) which Gutenberg\n * portals out of the block tree, so they cannot be nested under the block\n * wrapper class. Class names are uniquely prefixed `jetpack-podcast-episode__`\n * to keep root-level selectors collision-safe.\n */\n\n.jetpack-podcast-episode__person-editor {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 8px;\n\tbox-sizing: border-box;\n\twidth: 100%;\n\tpadding: 12px;\n\tmargin-bottom: 12px;\n\tborder: 1px solid #ddd;\n\tborder-radius: 4px;\n}\n\n// Alternate the row background so a stack of person editors reads as\n// distinct cards instead of merging visually.\n.jetpack-podcast-episode__person-editor--alt {\n\tbackground: #f6f7f7;\n}\n\n// Featured-image-style cover picker: full-width clickable preview that\n// opens the media library, with Replace / Remove links underneath.\n.jetpack-podcast-episode__cover-picker {\n\tdisplay: flex;\n\tflex-direction: column;\n\tgap: 4px;\n\tmargin-bottom: 8px;\n\twidth: 100%;\n}\n\n// `<Button>` defaults to `display: inline-flex; height: 36px;` which makes\n// the image overflow the bounds when we drop it inside. Force the button to\n// act as a block container that grows to wrap whatever it holds.\n.jetpack-podcast-episode__cover-button {\n\tbox-sizing: border-box;\n\tdisplay: block;\n\twidth: 100%;\n\theight: auto;\n\tmin-height: 0;\n\tpadding: 0;\n\tborder: 1px solid #ddd;\n\tborder-radius: 4px;\n\tbackground: transparent;\n\toverflow: hidden;\n\tcursor: pointer;\n\n\t&:hover,\n\t&:focus-visible {\n\t\tborder-color: var(--wp-admin-theme-color, #007cba);\n\t}\n\n\t// Mirror what the feed serves: Photon center-crops the cover to a square,\n\t// so the picker preview does too (via aspect-ratio + object-fit) to avoid\n\t// the editor showing one composition and the feed showing another.\n\timg {\n\t\tdisplay: block;\n\t\twidth: 100%;\n\t\taspect-ratio: 1;\n\t\tobject-fit: cover;\n\t}\n}\n\n.jetpack-podcast-episode__cover-button--empty {\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tmin-height: 92px;\n\tborder-style: dashed;\n\n\tspan {\n\t\tcolor: var(--wp-admin-theme-color, #007cba);\n\t\tfont-weight: 500;\n\t}\n}\n\n.jetpack-podcast-episode__cover-actions {\n\tdisplay: flex;\n\tgap: 12px;\n}\n"],"names":[],"sourceRoot":""}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,85 @@
/*!**************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** css ../../../node_modules/.pnpm/css-loader@6.11.0_webpack@5.107.2/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[2].use[1]!../../../node_modules/.pnpm/sass-loader@16.0.5_sass-embedded@1.97.3_webpack@5.107.2/node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[2].use[2]!../../js-packages/shared-extension-utils/src/with-has-warning-is-interactive-class-names/style.scss ***!
\**************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************************/
.block-editor-block-list__layout .block-editor-block-list__block.has-warning.is-interactive > * {
pointer-events: auto;
user-select: auto;
}
.block-editor-block-list__layout .block-editor-block-list__block.has-warning.is-interactive::after {
content: none;
}
/*!***************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** css ../../../node_modules/.pnpm/css-loader@6.11.0_webpack@5.107.2/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[2].use[1]!../../../node_modules/.pnpm/sass-loader@16.0.5_sass-embedded@1.97.3_webpack@5.107.2/node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[2].use[2]!./src/blocks/podcast-episode/editor.scss ***!
\***************************************************************************************************************************************************************************************************************************************************************************************************************************************/
@charset "UTF-8";
/**
* Podcast Episode block — editor-only styles.
*
* These rules target sidebar controls (InspectorControls) which Gutenberg
* portals out of the block tree, so they cannot be nested under the block
* wrapper class. Class names are uniquely prefixed `jetpack-podcast-episode__`
* to keep root-level selectors collision-safe.
*/
.jetpack-podcast-episode__person-editor {
display: flex;
flex-direction: column;
gap: 8px;
box-sizing: border-box;
width: 100%;
padding: 12px;
margin-bottom: 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.jetpack-podcast-episode__person-editor--alt {
background: #f6f7f7;
}
.jetpack-podcast-episode__cover-picker {
display: flex;
flex-direction: column;
gap: 4px;
margin-bottom: 8px;
width: 100%;
}
.jetpack-podcast-episode__cover-button {
box-sizing: border-box;
display: block;
width: 100%;
height: auto;
min-height: 0;
padding: 0;
border: 1px solid #ddd;
border-radius: 4px;
background: transparent;
overflow: hidden;
cursor: pointer;
}
.jetpack-podcast-episode__cover-button:hover, .jetpack-podcast-episode__cover-button:focus-visible {
border-color: var(--wp-admin-theme-color, #007cba);
}
.jetpack-podcast-episode__cover-button img {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
}
.jetpack-podcast-episode__cover-button--empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 92px;
border-style: dashed;
}
.jetpack-podcast-episode__cover-button--empty span {
color: var(--wp-admin-theme-color, #007cba);
font-weight: 500;
}
.jetpack-podcast-episode__cover-actions {
display: flex;
gap: 12px;
}
@@ -0,0 +1 @@
<?php return array('dependencies' => array(), 'version' => 'dfaabac72072197094b1');
@@ -0,0 +1,271 @@
/*!**************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** css ../../../node_modules/.pnpm/css-loader@6.11.0_webpack@5.107.2/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[2].use[1]!../../../node_modules/.pnpm/sass-loader@16.0.5_sass-embedded@1.97.3_webpack@5.107.2/node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[2].use[2]!./src/blocks/podcast-episode/style.scss ***!
\**************************************************************************************************************************************************************************************************************************************************************************************************************************************/
/**
* Podcast Episode block shared styles (editor + front-end).
*/
/**
* Colors
*/
/**
* SCSS Variables.
*
* Please use variables from this sheet to ensure consistency across the UI.
* Don't add to this sheet unless you're pretty sure the value will be reused in many places.
* For example, don't add rules to this sheet that affect block visuals. It's purely for UI.
*/
/**
* Fonts & basic variables.
*/
/**
* Typography
*/
/**
* Grid System.
* https://make.wordpress.org/design/2019/10/31/proposal-a-consistent-spacing-system-for-wordpress/
*/
/**
* Radius scale.
*/
/**
* Elevation scale.
*/
/**
* Dimensions.
*/
/**
* Mobile specific styles
*/
/**
* Editor styles.
*/
/**
* Block & Editor UI.
*/
/**
* Block paddings.
*/
/**
* React Native specific.
* These variables do not appear to be used anywhere else.
*/
/**
* Breakpoints & Media Queries
*/
/**
* Typography
*/
/**
* Converts a hex value into the rgb equivalent.
*
* @param {string} hex - the hexadecimal value to convert
* @return {string} comma separated rgb values
*/
/**
* Long content fade mixin
*
* Creates a fading overlay to signify that the content is longer
* than the space allows.
*/
/**
* Breakpoint mixins
*/
/**
* Focus styles.
*/
/**
* Applies editor left position to the selector passed as argument
*/
/**
* Styles that are reused verbatim in a few places
*/
/**
* Allows users to opt-out of animations via OS-level preferences.
*/
/**
* Reset default styles for JavaScript UI based pages.
* This is a WP-admin agnostic reset
*/
/**
* Reset the WP Admin page styles for Gutenberg-like pages.
*/
/**
* Creates a checkerboard pattern background to indicate transparency.
* @param {String} $size - The size of the squares in the checkerboard pattern. Default is 12px.
*/
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode {
display: flex;
gap: 16px;
align-items: flex-start;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 6px;
background-color: #fff;
}
@media (max-width: 600px) {
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode {
flex-direction: column;
}
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__poster {
flex: 0 0 auto;
margin: 0;
inline-size: 160px;
max-inline-size: 40%;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__poster img {
inline-size: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: calc(6px - 2px);
display: block;
}
@media (max-width: 600px) {
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__poster {
inline-size: 100%;
max-inline-size: 100%;
}
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__body {
flex: 1 1 auto;
min-inline-size: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__meta-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
font-size: 0.85em;
color: #757575;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__badge {
display: inline-flex;
align-items: center;
padding-inline: 8px;
padding-block: 2px;
font-size: 0.75em;
font-weight: 600;
line-height: 1.4;
border-radius: 999px;
background-color: #f0f0f0;
color: #1e1e1e;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__badge--trailer {
background-color: #e7f5ff;
color: #0073aa;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__badge--bonus {
background-color: #fff4e6;
color: #b35900;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__badge--explicit {
background-color: #1e1e1e;
color: #fff;
font-family: monospace;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__title {
margin: 0;
font-size: 1.4em;
line-height: 1.3;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__byline {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0;
font-size: 0.9em;
color: #757575;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__player {
margin-block: 4px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__player audio,
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__player video {
inline-size: 100%;
max-inline-size: 100%;
display: block;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__player video {
border-radius: calc(6px - 2px);
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__summary {
margin: 0;
font-weight: 500;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__description {
margin: 0;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__description p:last-child {
margin-block-end: 0;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__people {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__person {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__person img {
inline-size: 28px;
block-size: 28px;
border-radius: 50%;
object-fit: cover;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__person-role {
color: #757575;
font-size: 0.85em;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite {
display: flex;
gap: 12px;
align-items: baseline;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite-time {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
color: #757575;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite-button {
display: flex;
gap: 12px;
align-items: baseline;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
border-radius: 2px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite-button:hover .jetpack-podcast-episode__soundbite-title {
text-decoration: underline;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite-button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.9em;
}
/*# sourceMappingURL=style.css.map*/
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
/******/ (() => { // webpackBootstrap
/******/ "use strict";
/******/ // The require scope
/******/ var __webpack_require__ = {};
/******/
/************************************************************************/
/******/ /* webpack/runtime/make namespace object */
/******/ (() => {
/******/ // define __esModule on exports
/******/ __webpack_require__.r = (exports) => {
/******/ if(typeof Symbol !== 'undefined' && Symbol.toStringTag) {
/******/ Object.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });
/******/ }
/******/ Object.defineProperty(exports, '__esModule', { value: true });
/******/ };
/******/ })();
/******/
/************************************************************************/
var __webpack_exports__ = {};
/*!***********************************************!*\
!*** ./src/blocks/podcast-episode/style.scss ***!
\***********************************************/
__webpack_require__.r(__webpack_exports__);
// extracted by mini-css-extract-plugin
/******/ })()
;
//# sourceMappingURL=style.js.map
@@ -0,0 +1 @@
{"version":3,"file":"podcast-episode/style.js","mappings":";;UAAA;UACA;;;;;WCDA;WACA;WACA;WACA,uDAAuD,iBAAiB;WACxE;WACA,gDAAgD,aAAa;WAC7D,E;;;;;;;;;ACNA","sources":["webpack://@automattic/jetpack-podcast/webpack/bootstrap","webpack://@automattic/jetpack-podcast/webpack/runtime/make namespace object","webpack://@automattic/jetpack-podcast/./src/blocks/podcast-episode/style.scss?24b2"],"sourcesContent":["// The require scope\nvar __webpack_require__ = {};\n\n","// define __esModule on exports\n__webpack_require__.r = (exports) => {\n\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n\t}\n\tObject.defineProperty(exports, '__esModule', { value: true });\n};","// extracted by mini-css-extract-plugin\nexport {};"],"names":[],"sourceRoot":""}
@@ -0,0 +1,269 @@
/*!**************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** css ../../../node_modules/.pnpm/css-loader@6.11.0_webpack@5.107.2/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[2].use[1]!../../../node_modules/.pnpm/sass-loader@16.0.5_sass-embedded@1.97.3_webpack@5.107.2/node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[2].use[2]!./src/blocks/podcast-episode/style.scss ***!
\**************************************************************************************************************************************************************************************************************************************************************************************************************************************/
/**
* Podcast Episode block shared styles (editor + front-end).
*/
/**
* Colors
*/
/**
* SCSS Variables.
*
* Please use variables from this sheet to ensure consistency across the UI.
* Don't add to this sheet unless you're pretty sure the value will be reused in many places.
* For example, don't add rules to this sheet that affect block visuals. It's purely for UI.
*/
/**
* Fonts & basic variables.
*/
/**
* Typography
*/
/**
* Grid System.
* https://make.wordpress.org/design/2019/10/31/proposal-a-consistent-spacing-system-for-wordpress/
*/
/**
* Radius scale.
*/
/**
* Elevation scale.
*/
/**
* Dimensions.
*/
/**
* Mobile specific styles
*/
/**
* Editor styles.
*/
/**
* Block & Editor UI.
*/
/**
* Block paddings.
*/
/**
* React Native specific.
* These variables do not appear to be used anywhere else.
*/
/**
* Breakpoints & Media Queries
*/
/**
* Typography
*/
/**
* Converts a hex value into the rgb equivalent.
*
* @param {string} hex - the hexadecimal value to convert
* @return {string} comma separated rgb values
*/
/**
* Long content fade mixin
*
* Creates a fading overlay to signify that the content is longer
* than the space allows.
*/
/**
* Breakpoint mixins
*/
/**
* Focus styles.
*/
/**
* Applies editor left position to the selector passed as argument
*/
/**
* Styles that are reused verbatim in a few places
*/
/**
* Allows users to opt-out of animations via OS-level preferences.
*/
/**
* Reset default styles for JavaScript UI based pages.
* This is a WP-admin agnostic reset
*/
/**
* Reset the WP Admin page styles for Gutenberg-like pages.
*/
/**
* Creates a checkerboard pattern background to indicate transparency.
* @param {String} $size - The size of the squares in the checkerboard pattern. Default is 12px.
*/
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode {
display: flex;
gap: 16px;
align-items: flex-start;
padding: 16px;
border: 1px solid #e0e0e0;
border-radius: 6px;
background-color: #fff;
}
@media (max-width: 600px) {
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode {
flex-direction: column;
}
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__poster {
flex: 0 0 auto;
margin: 0;
inline-size: 160px;
max-inline-size: 40%;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__poster img {
inline-size: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: calc(6px - 2px);
display: block;
}
@media (max-width: 600px) {
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__poster {
inline-size: 100%;
max-inline-size: 100%;
}
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__body {
flex: 1 1 auto;
min-inline-size: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__meta-line {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0;
font-size: 0.85em;
color: #757575;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__badge {
display: inline-flex;
align-items: center;
padding-inline: 8px;
padding-block: 2px;
font-size: 0.75em;
font-weight: 600;
line-height: 1.4;
border-radius: 999px;
background-color: #f0f0f0;
color: #1e1e1e;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__badge--trailer {
background-color: #e7f5ff;
color: #0073aa;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__badge--bonus {
background-color: #fff4e6;
color: #b35900;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__badge--explicit {
background-color: #1e1e1e;
color: #fff;
font-family: monospace;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__title {
margin: 0;
font-size: 1.4em;
line-height: 1.3;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__byline {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin: 0;
font-size: 0.9em;
color: #757575;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__player {
margin-block: 4px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__player audio,
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__player video {
inline-size: 100%;
max-inline-size: 100%;
display: block;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__player video {
border-radius: calc(6px - 2px);
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__summary {
margin: 0;
font-weight: 500;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__description {
margin: 0;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__description p:last-child {
margin-block-end: 0;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__people {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__person {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.9em;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__person img {
inline-size: 28px;
block-size: 28px;
border-radius: 50%;
object-fit: cover;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__person-role {
color: #757575;
font-size: 0.85em;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite {
display: flex;
gap: 12px;
align-items: baseline;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite-time {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
color: #757575;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite-button {
display: flex;
gap: 12px;
align-items: baseline;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
text-align: inherit;
cursor: pointer;
border-radius: 2px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite-button:hover .jetpack-podcast-episode__soundbite-title {
text-decoration: underline;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__soundbite-button:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
.wp-block-jetpack-podcast-episode .jetpack-podcast-episode__links {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 12px;
font-size: 0.9em;
}
@@ -0,0 +1 @@
<?php return array('dependencies' => array('wp-polyfill'), 'version' => '39ab3988a35aaea6dbf9');
@@ -0,0 +1,90 @@
/******/ (() => { // webpackBootstrap
/*!********************************************!*\
!*** ./src/blocks/podcast-episode/view.ts ***!
\********************************************/
/* wp:polyfill */
/* wp:polyfill esnext.iterator.constructor */
/* wp:polyfill esnext.iterator.for-each */
/**
* Front-end view script for the Podcast Episode block.
*
* Chapters and soundbites render as real `<button data-start-time="…">`
* elements so the browser handles keyboard, focus, and a11y natively. We
* just need one delegated click listener per episode to seek the player.
*/
// Concurrent clicks coalesce into a single pending target so the user lands
// on the last button pressed instead of seeing each intermediate seek flash by.
const pendingSeeks = new WeakMap();
const awaitingMetadata = new WeakSet();
const applyPendingSeek = media => {
const target = pendingSeeks.get(media);
if (typeof target !== 'number') {
return;
}
pendingSeeks.delete(media);
try {
media.currentTime = target;
} catch {
// Setting currentTime may still throw if the media never got past
// HAVE_NOTHING — ignore and let play() start where it can.
}
const playResult = media.play();
if (playResult && typeof playResult.catch === 'function') {
playResult.catch(() => {
// Autoplay restrictions can still reject even after a user click.
});
}
};
const seekAndPlay = (media, seconds) => {
pendingSeeks.set(media, seconds);
if (media.readyState >= 1) {
applyPendingSeek(media);
return;
}
// With preload="none" the element is in readyState 0 (HAVE_NOTHING) until
// the resource selection algorithm runs. Setting currentTime now would throw
// INVALID_STATE_ERR per the HTML spec, so wait for loadedmetadata first.
// A second click while we're already waiting just updates pendingSeeks; the
// single listener applies the latest target.
if (awaitingMetadata.has(media)) {
return;
}
awaitingMetadata.add(media);
media.addEventListener('loadedmetadata', () => {
awaitingMetadata.delete(media);
applyPendingSeek(media);
}, {
once: true
});
media.load();
};
const wireEpisode = root => {
const media = root.querySelector('.jetpack-podcast-episode__audio, .jetpack-podcast-episode__video');
if (!media) {
return;
}
root.addEventListener('click', event => {
const button = event.target?.closest('button[data-start-time]');
if (!button) {
return;
}
const seconds = Number(button.dataset.startTime);
if (Number.isNaN(seconds) || seconds < 0) {
return;
}
seekAndPlay(media, seconds);
});
};
const init = () => {
document.querySelectorAll('.wp-block-jetpack-podcast-episode').forEach(wireEpisode);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
/******/ })()
;
//# sourceMappingURL=view.js.map
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-components', 'wp-compose', 'wp-data', 'wp-editor', 'wp-element', 'wp-i18n', 'wp-plugins'), 'version' => 'fa446dfe22353d5498e0');
@@ -0,0 +1,138 @@
/*!*************************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** css ../../../node_modules/.pnpm/css-loader@6.11.0_webpack@5.107.2/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[2].use[1]!../../../node_modules/.pnpm/sass-loader@16.0.5_sass-embedded@1.97.3_webpack@5.107.2/node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[2].use[2]!./src/editor/post-publish-podcast-promo/style.scss ***!
\*************************************************************************************************************************************************************************************************************************************************************************************************************************************************/
.jetpack-post-publish-podcast-promo-modal {
max-width: 409px;
border-radius: 8px;
overflow: hidden;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__header {
position: absolute;
z-index: 1;
inset-inline-start: 0;
inset-inline-end: 0;
height: auto;
margin: 0;
padding: 8px;
border-bottom: 0;
background-color: transparent;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__header button {
inset-inline-start: unset;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__header button svg {
fill: #fff;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__content {
padding: 0;
margin-block-start: 0;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__content::before {
margin: 0;
}
.jetpack-post-publish-podcast-promo-modal__hero {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 44px 32px 36px;
background: radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.2), transparent 55%), linear-gradient(135deg, #1d3a8a 0%, #3858e9 48%, #7a37c8 100%);
overflow: hidden;
}
.jetpack-post-publish-podcast-promo-modal__hero::before {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px), linear-gradient(90deg, rgba(255, 255, 255, 0.18) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(circle at 50% 50%, #000 0%, transparent 72%);
pointer-events: none;
}
.jetpack-post-publish-podcast-promo-modal__hero::after {
content: "";
position: absolute;
inset-block-end: -88px;
inset-inline-end: -32px;
width: 220px;
height: 220px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.07);
pointer-events: none;
}
.jetpack-post-publish-podcast-promo-modal__soundwave {
position: absolute;
z-index: 1;
inset-inline: 24px;
width: calc(100% - 48px);
height: 96px;
overflow: visible;
pointer-events: none;
}
.jetpack-post-publish-podcast-promo-modal__soundwave rect {
fill: rgba(255, 255, 255, 0.64);
}
.jetpack-post-publish-podcast-promo-modal__soundwave rect:nth-child(3n) {
fill: rgba(255, 255, 255, 0.38);
}
.jetpack-post-publish-podcast-promo-modal__hero-art {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(4px);
}
.jetpack-post-publish-podcast-promo-modal__body {
padding: 24px 32px 32px;
}
.jetpack-post-publish-podcast-promo-modal__title {
margin: 0;
color: #1e1e1e;
font-size: 20px;
font-weight: 600;
line-height: 1.3;
}
.jetpack-post-publish-podcast-promo-modal__description {
color: #1e1e1e;
font-size: 13px;
line-height: 1.6;
margin: 12px 0 0;
}
.jetpack-post-publish-podcast-promo-modal__actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
margin-block-start: 24px;
}
.jetpack-post-publish-podcast-promo-modal__primary-action.components-button {
justify-content: center;
height: auto;
padding: 12px 24px;
border-radius: 4px;
background-color: #3858e9;
font-size: 14px;
font-weight: 500;
}
.jetpack-post-publish-podcast-promo-modal__primary-action.components-button:hover {
background-color: #2c48c7;
}
.jetpack-post-publish-podcast-promo-modal__primary-action.components-button:focus {
box-shadow: 0 0 0 2px #3858e9;
}
/*# sourceMappingURL=editor.css.map*/
@@ -0,0 +1 @@
{"version":3,"file":"post-publish-podcast-promo/editor.css","mappings":";;;AAAA;EACC;EACA;EACA;AACD;AACC;EACC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;AACF;AACE;EACC;AACH;AACG;EACC;AACJ;AAIC;EACC;EACA;AAFF;AAIE;EACC;AAFH;;AAOA;EACC;EACA;EACA;EACA;EACA;EACA,uJACC;EAED;AAND;;AASA;EACC;EACA;EACA;EACA,yJACC;EAED;EACA;EACA;AARD;;AAWA;EACC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;AARD;;AAWA;EACC;EACA;EACA;EACA;EACA;EACA;EACA;AARD;AAUC;EACC;AARF;AAWC;EACC;AATF;;AAaA;EACC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;AAVD;;AAaA;EACC;AAVD;;AAaA;EACC;EACA;EACA;EACA;EACA;AAVD;;AAaA;EACC;EACA;EACA;EACA;AAVD;;AAaA;EACC;EACA;EACA;EACA;EACA;AAVD;;AAaA;EACC;EACA;EACA;EACA;EACA;EACA;EACA;AAVD;AAYC;EACC;AAVF;AAaC;EACC;AAXF,C","sources":["webpack://@automattic/jetpack-podcast/./src/editor/post-publish-podcast-promo/style.scss"],"sourcesContent":[".jetpack-post-publish-podcast-promo-modal {\n\tmax-width: 409px;\n\tborder-radius: 8px;\n\toverflow: hidden;\n\n\t.components-modal__header {\n\t\tposition: absolute;\n\t\tz-index: 1;\n\t\tinset-inline-start: 0;\n\t\tinset-inline-end: 0;\n\t\theight: auto;\n\t\tmargin: 0;\n\t\tpadding: 8px;\n\t\tborder-bottom: 0;\n\t\tbackground-color: transparent;\n\n\t\tbutton {\n\t\t\tinset-inline-start: unset;\n\n\t\t\tsvg {\n\t\t\t\tfill: #fff;\n\t\t\t}\n\t\t}\n\t}\n\n\t.components-modal__content {\n\t\tpadding: 0;\n\t\tmargin-block-start: 0;\n\n\t\t&::before {\n\t\t\tmargin: 0;\n\t\t}\n\t}\n}\n\n.jetpack-post-publish-podcast-promo-modal__hero {\n\tposition: relative;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\tpadding: 44px 32px 36px;\n\tbackground:\n\t\tradial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.2), transparent 55%),\n\t\tlinear-gradient(135deg, #1d3a8a 0%, #3858e9 48%, #7a37c8 100%);\n\toverflow: hidden;\n}\n\n.jetpack-post-publish-podcast-promo-modal__hero::before {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset: 0;\n\tbackground-image:\n\t\tlinear-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px),\n\t\tlinear-gradient(90deg, rgba(255, 255, 255, 0.18) 1px, transparent 1px);\n\tbackground-size: 40px 40px;\n\tmask-image: radial-gradient(circle at 50% 50%, #000 0%, transparent 72%);\n\tpointer-events: none;\n}\n\n.jetpack-post-publish-podcast-promo-modal__hero::after {\n\tcontent: \"\";\n\tposition: absolute;\n\tinset-block-end: -88px;\n\tinset-inline-end: -32px;\n\twidth: 220px;\n\theight: 220px;\n\tborder-radius: 50%;\n\tbackground: rgba(255, 255, 255, 0.07);\n\tpointer-events: none;\n}\n\n.jetpack-post-publish-podcast-promo-modal__soundwave {\n\tposition: absolute;\n\tz-index: 1;\n\tinset-inline: 24px;\n\twidth: calc(100% - 48px);\n\theight: 96px;\n\toverflow: visible;\n\tpointer-events: none;\n\n\trect {\n\t\tfill: rgba(255, 255, 255, 0.64);\n\t}\n\n\trect:nth-child(3n) {\n\t\tfill: rgba(255, 255, 255, 0.38);\n\t}\n}\n\n.jetpack-post-publish-podcast-promo-modal__hero-art {\n\tposition: relative;\n\tz-index: 2;\n\tdisplay: flex;\n\talign-items: center;\n\tjustify-content: center;\n\twidth: 100px;\n\theight: 100px;\n\tborder-radius: 50%;\n\tbackground: rgba(255, 255, 255, 0.14);\n\tbackdrop-filter: blur(4px);\n}\n\n.jetpack-post-publish-podcast-promo-modal__body {\n\tpadding: 24px 32px 32px;\n}\n\n.jetpack-post-publish-podcast-promo-modal__title {\n\tmargin: 0;\n\tcolor: #1e1e1e;\n\tfont-size: 20px;\n\tfont-weight: 600;\n\tline-height: 1.3;\n}\n\n.jetpack-post-publish-podcast-promo-modal__description {\n\tcolor: #1e1e1e;\n\tfont-size: 13px;\n\tline-height: 1.6;\n\tmargin: 12px 0 0;\n}\n\n.jetpack-post-publish-podcast-promo-modal__actions {\n\tdisplay: flex;\n\tflex-direction: column;\n\talign-items: stretch;\n\tgap: 12px;\n\tmargin-block-start: 24px;\n}\n\n.jetpack-post-publish-podcast-promo-modal__primary-action.components-button {\n\tjustify-content: center;\n\theight: auto;\n\tpadding: 12px 24px;\n\tborder-radius: 4px;\n\tbackground-color: #3858e9;\n\tfont-size: 14px;\n\tfont-weight: 500;\n\n\t&:hover {\n\t\tbackground-color: #2c48c7;\n\t}\n\n\t&:focus {\n\t\tbox-shadow: 0 0 0 2px #3858e9;\n\t}\n}\n"],"names":[],"sourceRoot":""}
@@ -0,0 +1,136 @@
/*!*************************************************************************************************************************************************************************************************************************************************************************************************************************************************!*\
!*** css ../../../node_modules/.pnpm/css-loader@6.11.0_webpack@5.107.2/node_modules/css-loader/dist/cjs.js??ruleSet[1].rules[2].use[1]!../../../node_modules/.pnpm/sass-loader@16.0.5_sass-embedded@1.97.3_webpack@5.107.2/node_modules/sass-loader/dist/cjs.js??ruleSet[1].rules[2].use[2]!./src/editor/post-publish-podcast-promo/style.scss ***!
\*************************************************************************************************************************************************************************************************************************************************************************************************************************************************/
.jetpack-post-publish-podcast-promo-modal {
max-width: 409px;
border-radius: 8px;
overflow: hidden;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__header {
position: absolute;
z-index: 1;
inset-inline-start: 0;
inset-inline-end: 0;
height: auto;
margin: 0;
padding: 8px;
border-bottom: 0;
background-color: transparent;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__header button {
inset-inline-start: unset;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__header button svg {
fill: #fff;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__content {
padding: 0;
margin-block-start: 0;
}
.jetpack-post-publish-podcast-promo-modal .components-modal__content::before {
margin: 0;
}
.jetpack-post-publish-podcast-promo-modal__hero {
position: relative;
display: flex;
align-items: center;
justify-content: center;
padding: 44px 32px 36px;
background: radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.2), transparent 55%), linear-gradient(-135deg, #1d3a8a 0%, #3858e9 48%, #7a37c8 100%);
overflow: hidden;
}
.jetpack-post-publish-podcast-promo-modal__hero::before {
content: "";
position: absolute;
inset: 0;
background-image: linear-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px), linear-gradient(-90deg, rgba(255, 255, 255, 0.18) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(circle at 50% 50%, #000 0%, transparent 72%);
pointer-events: none;
}
.jetpack-post-publish-podcast-promo-modal__hero::after {
content: "";
position: absolute;
inset-block-end: -88px;
inset-inline-end: -32px;
width: 220px;
height: 220px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.07);
pointer-events: none;
}
.jetpack-post-publish-podcast-promo-modal__soundwave {
position: absolute;
z-index: 1;
inset-inline: 24px;
width: calc(100% - 48px);
height: 96px;
overflow: visible;
pointer-events: none;
}
.jetpack-post-publish-podcast-promo-modal__soundwave rect {
fill: rgba(255, 255, 255, 0.64);
}
.jetpack-post-publish-podcast-promo-modal__soundwave rect:nth-child(3n) {
fill: rgba(255, 255, 255, 0.38);
}
.jetpack-post-publish-podcast-promo-modal__hero-art {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
width: 100px;
height: 100px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.14);
backdrop-filter: blur(4px);
}
.jetpack-post-publish-podcast-promo-modal__body {
padding: 24px 32px 32px;
}
.jetpack-post-publish-podcast-promo-modal__title {
margin: 0;
color: #1e1e1e;
font-size: 20px;
font-weight: 600;
line-height: 1.3;
}
.jetpack-post-publish-podcast-promo-modal__description {
color: #1e1e1e;
font-size: 13px;
line-height: 1.6;
margin: 12px 0 0;
}
.jetpack-post-publish-podcast-promo-modal__actions {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 12px;
margin-block-start: 24px;
}
.jetpack-post-publish-podcast-promo-modal__primary-action.components-button {
justify-content: center;
height: auto;
padding: 12px 24px;
border-radius: 4px;
background-color: #3858e9;
font-size: 14px;
font-weight: 500;
}
.jetpack-post-publish-podcast-promo-modal__primary-action.components-button:hover {
background-color: #2c48c7;
}
.jetpack-post-publish-podcast-promo-modal__primary-action.components-button:focus {
box-shadow: 0 0 0 2px #3858e9;
}
@@ -0,0 +1,91 @@
<?php
/**
* Shared preset arrays for the Create AI Podcast page.
*
* Values mirror what `dashboard/create/presets.ts` exported before the page
* moved to this stack. PHP renders the form's <select> options from these
* arrays; the JS island receives the same arrays via wp_localize_script so
* status notices can label runs by preset id.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast;
/**
* Date-range presets for the "from a window" source mode.
*
* @return array<int, array{id:string,label:string,unit:string,n:int}>
*/
function window_presets(): array {
return array(
array(
'id' => 'last-7-days',
'label' => __( 'Last 7 days', 'jetpack-podcast' ),
'unit' => 'days',
'n' => 7,
),
array(
'id' => 'last-14-days',
'label' => __( 'Last 14 days', 'jetpack-podcast' ),
'unit' => 'days',
'n' => 14,
),
array(
'id' => 'last-30-days',
'label' => __( 'Last 30 days', 'jetpack-podcast' ),
'unit' => 'days',
'n' => 30,
),
array(
'id' => 'last-3-months',
'label' => __( 'Last 3 months', 'jetpack-podcast' ),
'unit' => 'months',
'n' => 3,
),
);
}
/**
* Episode-length presets.
*
* @return array<int, array{id:string,label:string}>
*/
function length_presets(): array {
return array(
array(
'id' => 'short',
'label' => __( 'Short (~3 min)', 'jetpack-podcast' ),
),
array(
'id' => 'medium',
'label' => __( 'Medium (~7 min)', 'jetpack-podcast' ),
),
array(
'id' => 'long',
'label' => __( 'Long (~12 min)', 'jetpack-podcast' ),
),
);
}
/**
* Voice-style presets.
*
* @return array<int, array{id:string,label:string}>
*/
function voice_presets(): array {
return array(
array(
'id' => 'witty',
'label' => __( 'Witty', 'jetpack-podcast' ),
),
array(
'id' => 'earnest',
'label' => __( 'Earnest', 'jetpack-podcast' ),
),
array(
'id' => 'professional',
'label' => __( 'Professional', 'jetpack-podcast' ),
),
);
}
@@ -0,0 +1,896 @@
/*!
* Create AI Podcast page styles.
*
* Card-based layout aligned with wp-admin's color palette. Scoped under
* .jetpack-create-ai-podcast. Inherits wp-admin form styles for native
* controls (select, button, etc.); custom rules add the card surfaces,
* section headers, credits readout, posts checklist, status notice, and
* disabled-form state.
*/
.jetpack-create-ai-podcast {
max-width: 720px;
}
.jetpack-create-ai-podcast__page-title {
margin: 0 0 20px;
}
/* --- Intro banner -------------------------------------------------------- */
.jetpack-create-ai-podcast__intro {
position: relative;
display: flex;
gap: 24px;
align-items: center;
justify-content: space-between;
padding: 24px 28px;
margin: 0 0 20px;
border-radius: 14px;
color: #fff;
background:
radial-gradient(circle at 0% 0%, rgba(255, 255, 255, 0.2), transparent 55%),
linear-gradient(135deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
box-shadow:
0 10px 24px rgba(34, 113, 177, 0.25),
0 2px 4px rgba(15, 23, 42, 0.08);
overflow: hidden;
}
.jetpack-create-ai-podcast__intro::after {
content: "";
position: absolute;
right: -40px;
bottom: -60px;
width: 200px;
height: 200px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.07);
pointer-events: none;
}
.jetpack-create-ai-podcast__intro-body {
position: relative;
z-index: 1;
max-width: 540px;
}
.jetpack-create-ai-podcast__intro-badges {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
margin: 0 0 8px;
}
.jetpack-create-ai-podcast__intro-eyebrow {
display: inline-flex;
align-items: center;
gap: 6px;
margin: 0;
padding: 4px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.16);
font-size: 11px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #fff;
}
.jetpack-create-ai-podcast__intro-eyebrow--experimental {
background: rgba(255, 196, 80, 0.28);
color: #ffe6a8;
}
.jetpack-create-ai-podcast__intro-wpmark {
flex: none;
display: inline-block;
width: 14px;
height: 14px;
background-color: currentColor;
mask-image: url(data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgY2xhc3M9ImdyaWRpY29uIGdyaWRpY29ucy1teS1zaXRlcyIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBkPSJNMTIgMkM2LjQ3NyAyIDIgNi40NzcgMiAxMnM0LjQ3NyAxMCAxMCAxMCAxMC00LjQ3NyAxMC0xMFMxNy41MjMgMiAxMiAyTTMuNSAxMmMwLTEuMjMyLjI2NC0yLjQwMi43MzYtMy40NTlMOC4yOTEgMTkuNjVBOC41IDguNSAwIDAgMSAzLjUgMTJtOC41IDguNTAxYy0uODM0IDAtMS42NC0uMTIyLTIuNDAxLS4zNDZsMi41NTEtNy40MTEgMi42MTMgNy4xNThhMSAxIDAgMCAwIC4wNjEuMTE3IDguNSA4LjUgMCAwIDEtMi44MjQuNDgybTEuMTcyLTEyLjQ4NmMuNTEyLS4wMjcuOTczLS4wODEuOTczLS4wODEuNDU4LS4wNTQuNDA0LS43MjctLjA1NC0uNzAxIDAgMC0xLjM3Ny4xMDgtMi4yNjYuMTA4LS44MzUgMC0yLjIzOS0uMTA4LTIuMjM5LS4xMDgtLjQ1OS0uMDI2LS41MTIuNjc0LS4wNTQuNzAxIDAgMCAuNDM0LjA1NC44OTIuMDgxbDEuMzI0IDMuNjI5LTEuODYgNS41NzktMy4wOTYtOS4yMDhjLjUxMi0uMDI3Ljk3My0uMDgxLjk3My0uMDgxLjQ1OC0uMDU0LjQwMy0uNzI3LS4wNTUtLjcwMSAwIDAtMS4zNzYuMTA4LTIuMjY1LjEwOC0uMTYgMC0uMzQ3LS4wMDQtLjU0Ny0uMDFBOC40OSA4LjQ5IDAgMCAxIDEyIDMuNWMyLjIxMyAwIDQuMjI4Ljg0NiA1Ljc0IDIuMjMyLS4wMzctLjAwMi0uMDcyLS4wMDctLjExLS4wMDctLjgzNSAwLTEuNDI3LjcyNy0xLjQyNyAxLjUwOSAwIC43MDEuNDA0IDEuMjkzLjgzNSAxLjk5NC4zMjMuNTY2LjcwMSAxLjI5My43MDEgMi4zNDQgMCAuNzI3LS4yOCAxLjU3Mi0uNjQ3IDIuNzQ4bC0uODQ4IDIuODMzem0zLjEwMSAxMS4zMzIgMi41OTYtNy41MDZjLjQ4NS0xLjIxMy42NDYtMi4xODIuNjQ2LTMuMDQ1cS0uMDAyLS40NjktLjA1Ny0uODc0QTguNDYgOC40NiAwIDAgMSAyMC41IDEyYTguNDkgOC40OSAwIDAgMS00LjIyNyA3LjM0NyIvPjwvc3ZnPg==);
mask-position: center;
mask-repeat: no-repeat;
mask-size: contain;
}
.jetpack-create-ai-podcast__intro-title {
margin: 0 0 8px;
font-size: 22px;
line-height: 1.25;
font-weight: 600;
color: #fff;
}
.jetpack-create-ai-podcast__intro-text {
margin: 0;
font-size: 14px;
line-height: 1.5;
color: rgba(255, 255, 255, 0.92);
}
.jetpack-create-ai-podcast__intro-art {
position: relative;
z-index: 1;
flex: none;
display: flex;
align-items: center;
justify-content: center;
width: 96px;
height: 96px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.12);
backdrop-filter: blur(4px);
}
/* --- Cards ---------------------------------------------------------------- */
.jetpack-create-ai-podcast__card {
background: #fff;
border: 1px solid #dcdcde;
border-radius: 8px;
padding: 20px 24px;
margin: 0 0 16px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
}
.jetpack-create-ai-podcast__card--accent {
border-left: 4px solid #2271b1;
padding-left: 20px;
}
.jetpack-create-ai-podcast__card-title {
margin: 0 0 16px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #50575e;
}
/* --- Credits readout ------------------------------------------------------ */
.jetpack-create-ai-podcast__credits {
font-size: 14px;
color: #1d2327;
}
.jetpack-create-ai-podcast__credits[data-state="hidden"] {
display: none;
}
.jetpack-create-ai-podcast__credits[data-state="not-available"] {
border-left: 4px solid #d63638;
padding-left: 20px;
}
.jetpack-create-ai-podcast__credits[data-state="not-available"] p {
margin: 0;
}
.jetpack-create-ai-podcast__credits-header {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
margin-bottom: 10px;
}
.jetpack-create-ai-podcast__credits-label {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #50575e;
}
.jetpack-create-ai-podcast__credits-count {
font-size: 18px;
font-weight: 600;
color: #1d2327;
font-variant-numeric: tabular-nums;
}
.jetpack-create-ai-podcast__credits-bar {
height: 8px;
border-radius: 999px;
background: #f0f0f1;
overflow: hidden;
}
.jetpack-create-ai-podcast__credits-fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
border-radius: 999px;
transition: width 320ms cubic-bezier(0.2, 0.8, 0.2, 1), background 200ms ease;
}
.jetpack-create-ai-podcast__credits-fill[data-tone="warning"] {
background: linear-gradient(90deg, #dba617, #a47800);
}
.jetpack-create-ai-podcast__credits-fill[data-tone="danger"] {
background: linear-gradient(90deg, #d63638, #b32d2e);
}
.jetpack-create-ai-podcast__credits-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px 8px;
margin-top: 10px;
font-size: 12px;
color: #646970;
}
.jetpack-create-ai-podcast__credits-meta-sep {
color: #c3c4c7;
}
.jetpack-create-ai-podcast__credits-meta-reset {
font-weight: 500;
color: #50575e;
}
.jetpack-create-ai-podcast__credits-banner {
display: flex;
align-items: center;
gap: 14px;
margin-top: 16px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid transparent;
background: #fff;
flex-wrap: wrap;
}
.jetpack-create-ai-podcast__credits-banner[data-state="low"] {
border-color: rgba(219, 166, 23, 0.32);
background: rgba(219, 166, 23, 0.08);
}
.jetpack-create-ai-podcast__credits-banner[data-state="out"] {
border-color: rgba(214, 54, 56, 0.32);
background: rgba(214, 54, 56, 0.08);
}
.jetpack-create-ai-podcast__credits-banner-body {
flex: 1 1 240px;
min-width: 0;
}
.jetpack-create-ai-podcast__credits-banner-title {
margin: 0 0 2px;
font-size: 13px;
font-weight: 600;
color: #1d2327;
}
.jetpack-create-ai-podcast__credits-banner-message {
margin: 0;
font-size: 12px;
line-height: 1.45;
color: #50575e;
}
.jetpack-create-ai-podcast__credits-banner-cta {
flex: none;
}
@media (prefers-reduced-motion: reduce) {
.jetpack-create-ai-podcast__credits-fill {
transition: none;
}
}
/* --- Form fields ---------------------------------------------------------- */
.jetpack-create-ai-podcast__field {
margin: 0 0 16px;
}
.jetpack-create-ai-podcast__field:last-child {
margin-bottom: 0;
}
.jetpack-create-ai-podcast__field label {
display: block;
margin: 0 0 6px;
font-weight: 600;
font-size: 14px;
color: #1d2327;
}
.jetpack-create-ai-podcast__field select,
.jetpack-create-ai-podcast__field input[type="search"],
.jetpack-create-ai-podcast__field textarea {
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
/* --- Radio group ---------------------------------------------------------- */
.jetpack-create-ai-podcast__radio-group {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 20px;
}
.jetpack-create-ai-podcast__radio {
flex: 1 1 200px;
display: flex;
align-items: center;
gap: 10px;
padding: 12px 16px;
border: 1px solid #dcdcde;
border-radius: 6px;
background: #f6f7f7;
cursor: pointer;
font-size: 14px;
color: #1d2327;
transition: border-color 120ms ease, background-color 120ms ease;
}
.jetpack-create-ai-podcast__radio:hover {
border-color: #2271b1;
background: #fff;
}
.jetpack-create-ai-podcast__radio:has(input:checked) {
border-color: #2271b1;
background: #fff;
box-shadow: inset 0 0 0 1px #2271b1;
}
.jetpack-create-ai-podcast__radio input[type="radio"] {
margin: 0;
}
.jetpack-create-ai-podcast__field-hint {
margin: 4px 0 0;
font-size: 12px;
color: #646970;
}
/* --- Posts picker --------------------------------------------------------- */
.jetpack-create-ai-podcast__posts {
margin-top: 8px;
max-height: 320px;
overflow-y: auto;
border: 1px solid #dcdcde;
border-radius: 6px;
background: #fff;
padding: 8px 12px;
}
.jetpack-create-ai-podcast__posts ul {
margin: 0;
padding: 0;
list-style: none;
}
.jetpack-create-ai-podcast__posts li {
margin: 0;
padding: 6px 0;
border-bottom: 1px solid #f0f0f1;
}
.jetpack-create-ai-podcast__posts li:last-child {
border-bottom: 0;
}
.jetpack-create-ai-podcast__posts label {
font-weight: 400;
margin: 0;
display: flex;
gap: 8px;
align-items: baseline;
font-size: 14px;
color: #1d2327;
cursor: pointer;
}
.jetpack-create-ai-podcast__posts .date {
color: #646970;
font-size: 13px;
margin-left: auto;
}
.jetpack-create-ai-podcast__posts-loading {
display: flex;
align-items: center;
gap: 10px;
margin: 0;
padding: 12px 0;
color: #646970;
font-size: 14px;
}
.jetpack-create-ai-podcast__posts-loading::before {
content: "";
width: 14px;
height: 14px;
border: 2px solid #dcdcde;
border-top-color: #2271b1;
border-radius: 50%;
animation: jetpack-create-ai-podcast-spin 800ms linear infinite;
}
@keyframes jetpack-create-ai-podcast-spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: reduce) {
.jetpack-create-ai-podcast__posts-loading::before,
.jetpack-create-ai-podcast__status-spinner {
animation: none;
}
}
/* --- Actions row ---------------------------------------------------------- */
.jetpack-create-ai-podcast__advanced {
margin-top: 20px;
padding: 16px 18px;
border: 1px dashed #dcdcde;
border-radius: 10px;
background: #fafafb;
opacity: 0.95;
}
.jetpack-create-ai-podcast__advanced-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.jetpack-create-ai-podcast__advanced-title {
margin: 0;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #50575e;
}
.jetpack-create-ai-podcast__soon-pill {
display: inline-flex;
align-items: center;
padding: 3px 10px;
border-radius: 999px;
background: linear-gradient(135deg, #1d3a8a 0%, #2271b1 50%, #7a37c8 100%);
color: #fff;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.jetpack-create-ai-podcast__advanced .jetpack-create-ai-podcast__field label {
color: #646970;
}
.jetpack-create-ai-podcast__advanced :is(select, textarea, input):disabled {
background-color: #fff;
color: #8c8f94;
cursor: not-allowed;
}
.jetpack-create-ai-podcast__actions {
margin-top: 20px;
padding-top: 16px;
border-top: 1px solid #f0f0f1;
display: flex;
gap: 12px;
}
/* --- Disabled form state -------------------------------------------------- */
.jetpack-create-ai-podcast__form[data-disabled="true"] {
opacity: 0.6;
pointer-events: none;
}
/* --- Status notice (floating toast) -------------------------------------- */
.jetpack-create-ai-podcast__status {
position: fixed;
top: 56px;
right: 24px;
z-index: 100000;
max-width: calc(100vw - 48px);
width: 360px;
pointer-events: none;
}
.jetpack-create-ai-podcast__status[data-state="idle"] {
display: none;
}
.jetpack-create-ai-podcast__status-card {
position: relative;
display: flex;
align-items: flex-start;
gap: 12px;
background: #fff;
border: 1px solid rgba(0, 0, 0, 0.06);
border-radius: 12px;
padding: 14px 16px 14px 20px;
box-shadow:
0 12px 32px rgba(15, 23, 42, 0.12),
0 2px 6px rgba(15, 23, 42, 0.06);
pointer-events: auto;
animation: jetpack-create-ai-podcast-toast-in 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
overflow: hidden;
}
.jetpack-create-ai-podcast__status-card::before {
content: "";
position: absolute;
top: 0;
left: 0;
bottom: 0;
width: 4px;
background: #2271b1;
}
.jetpack-create-ai-podcast__status-card[data-tone="success"]::before {
background: #00a32a;
}
.jetpack-create-ai-podcast__status-card[data-tone="error"]::before {
background: #d63638;
}
.jetpack-create-ai-podcast__status-spinner {
flex: none;
display: inline-block;
width: 16px;
height: 16px;
margin-top: 2px;
border: 2px solid rgba(34, 113, 177, 0.25);
border-top-color: #2271b1;
border-radius: 50%;
animation: jetpack-create-ai-podcast-spin 800ms linear infinite;
}
.jetpack-create-ai-podcast__status-body {
flex: 1;
min-width: 0;
}
.jetpack-create-ai-podcast__status-message {
margin: 0;
font-size: 13px;
line-height: 1.5;
color: #1d2327;
font-weight: 600;
}
.jetpack-create-ai-podcast__status-subtext {
margin: 4px 0 0;
font-size: 12px;
line-height: 1.5;
color: #50575e;
}
.jetpack-create-ai-podcast__status-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-top: 10px;
}
.jetpack-create-ai-podcast__status-link {
text-decoration: underline;
font-size: 13px;
}
.jetpack-create-ai-podcast__status-dismiss {
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: -4px -4px 0 0;
padding: 0;
background: transparent;
border: 0;
border-radius: 6px;
color: #50575e;
cursor: pointer;
transition: background-color 120ms ease, color 120ms ease;
}
.jetpack-create-ai-podcast__status-dismiss:hover,
.jetpack-create-ai-podcast__status-dismiss:focus-visible {
background: rgba(15, 23, 42, 0.06);
color: #1d2327;
}
.jetpack-create-ai-podcast__status-dismiss:focus-visible {
outline: 2px solid #2271b1;
outline-offset: 2px;
}
@keyframes jetpack-create-ai-podcast-toast-in {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.jetpack-create-ai-podcast__status-card {
animation: none;
}
}
/* --- Generated episodes list --------------------------------------------- */
.jetpack-create-ai-podcast__episodes-empty {
margin: 0;
color: #50575e;
font-size: 13px;
}
.jetpack-create-ai-podcast__episodes-items {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.jetpack-create-ai-podcast__episode {
display: flex;
flex-direction: column;
gap: 10px;
padding: 14px 16px;
background: #fff;
border: 1px solid #dcdcde;
border-radius: 8px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.04);
}
.jetpack-create-ai-podcast__episode-header {
display: flex;
align-items: center;
gap: 10px;
justify-content: space-between;
}
.jetpack-create-ai-podcast__episode-title {
font-size: 14px;
font-weight: 600;
color: #1d2327;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.jetpack-create-ai-podcast__episode-status {
flex: none;
display: inline-flex;
align-items: center;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
background: #f0f0f1;
color: #50575e;
}
.jetpack-create-ai-podcast__episode-status[data-status="publish"] {
background: rgba(0, 163, 42, 0.12);
color: #0a6b1c;
}
.jetpack-create-ai-podcast__episode-player {
width: 100%;
display: block;
}
.jetpack-create-ai-podcast__episode-actions {
display: flex;
gap: 12px;
}
.jetpack-create-ai-podcast__episode-edit {
text-decoration: underline;
font-size: 13px;
}
/* Pagination */
.jetpack-create-ai-podcast__pagination {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 16px;
padding-top: 14px;
border-top: 1px solid #f0f0f1;
}
.jetpack-create-ai-podcast__pagination-summary {
font-size: 12px;
color: #646970;
}
.jetpack-create-ai-podcast__pagination-controls {
display: inline-flex;
align-items: center;
gap: 4px;
}
.jetpack-create-ai-podcast__pagination-button {
min-width: 32px;
height: 32px;
padding: 0 10px;
border: 1px solid #dcdcde;
border-radius: 6px;
background: #fff;
color: #1d2327;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
.jetpack-create-ai-podcast__pagination-button:hover:not(:disabled) {
border-color: #2271b1;
color: #2271b1;
}
.jetpack-create-ai-podcast__pagination-button:focus-visible {
outline: 2px solid #2271b1;
outline-offset: 2px;
}
.jetpack-create-ai-podcast__pagination-button:disabled {
color: #a7aaad;
background: #f6f7f7;
cursor: not-allowed;
}
.jetpack-create-ai-podcast__pagination-button[aria-current="page"] {
background: #1d2327;
border-color: #1d2327;
color: #fff;
cursor: default;
}
.jetpack-create-ai-podcast__pagination-button--prev,
.jetpack-create-ai-podcast__pagination-button--next {
font-weight: 600;
}
.jetpack-create-ai-podcast__pagination-ellipsis {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 24px;
color: #8c8f94;
font-size: 13px;
}
/* Loading skeleton */
.jetpack-create-ai-podcast__episodes-items--skeleton .jetpack-create-ai-podcast__episode {
pointer-events: none;
}
.jetpack-create-ai-podcast__skeleton {
display: block;
border-radius: 6px;
background: linear-gradient(90deg, #eceef0 0%, #f6f7f7 50%, #eceef0 100%);
background-size: 200% 100%;
animation: jetpack-create-ai-podcast-shimmer 1.4s ease-in-out infinite;
}
.jetpack-create-ai-podcast__skeleton--title {
height: 14px;
width: 60%;
max-width: 280px;
}
.jetpack-create-ai-podcast__skeleton--pill {
height: 18px;
width: 64px;
border-radius: 999px;
}
.jetpack-create-ai-podcast__skeleton--player {
height: 42px;
width: 100%;
}
.jetpack-create-ai-podcast__skeleton--link {
height: 12px;
width: 80px;
}
@keyframes jetpack-create-ai-podcast-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
@media (prefers-reduced-motion: reduce) {
.jetpack-create-ai-podcast__skeleton {
animation: none;
}
}
/* --- Narrow viewport tweaks ---------------------------------------------- */
@media (max-width: 600px) {
.jetpack-create-ai-podcast {
max-width: none;
}
.jetpack-create-ai-podcast__card {
padding: 16px;
}
.jetpack-create-ai-podcast__radio {
flex: 1 1 100%;
}
.jetpack-create-ai-podcast__actions .button-hero {
flex: 1;
}
.jetpack-create-ai-podcast__status {
top: 12px;
right: 12px;
left: 12px;
width: auto;
max-width: none;
}
.jetpack-create-ai-podcast__intro {
flex-direction: column;
align-items: flex-start;
padding: 20px;
}
.jetpack-create-ai-podcast__intro-art {
width: 72px;
height: 72px;
}
}
@@ -0,0 +1,116 @@
{
"$schema": "https://schemas.wp.org/trunk/block.json",
"apiVersion": 3,
"name": "jetpack/podcast-episode",
"title": "Podcast Episode",
"description": "Embed a single podcast episode from an audio or video file, with Podcasting 2.0 metadata.",
"keywords": [ "audio", "podcast", "episode" ],
"version": "1.0.0",
"textdomain": "jetpack-podcast",
"category": "embed",
"icon": "<svg viewBox='0 0 24 24' width='24' height='24' xmlns='http://www.w3.org/2000/svg'><path d='M12 2a5 5 0 0 0-5 5v5a5 5 0 0 0 10 0V7a5 5 0 0 0-5-5zm0 2a3 3 0 0 1 3 3v5a3 3 0 0 1-6 0V7a3 3 0 0 1 3-3zm-7 8a1 1 0 0 1 1 1 6 6 0 0 0 12 0 1 1 0 1 1 2 0 8 8 0 0 1-7 7.93V22h3v2H8v-2h3v-1.07A8 8 0 0 1 4 13a1 1 0 0 1 1-1z'/></svg>",
"usesContext": [ "postId", "postType" ],
"supports": {
"spacing": {
"padding": true,
"margin": true
},
"anchor": true,
"customClassName": true,
"className": true,
"html": false,
"multiple": true,
"reusable": true
},
"attributes": {
"mediaId": {
"type": "integer"
},
"mediaUrl": {
"type": "string"
},
"mediaType": {
"type": "string",
"enum": [ "audio", "video" ]
},
"mediaMimeType": {
"type": "string"
},
"episodeNumber": {
"type": "integer"
},
"seasonNumber": {
"type": "integer"
},
"episodeType": {
"type": "string",
"enum": [ "full", "trailer", "bonus" ],
"default": "full"
},
"explicit": {
"type": "boolean",
"default": false
},
"duration": {
"type": "string",
"default": ""
},
"transcriptUrl": {
"type": "string",
"default": ""
},
"transcriptType": {
"type": "string",
"enum": [ "text/vtt", "text/html", "application/srt", "application/json" ],
"default": "text/vtt"
},
"chaptersUrl": {
"type": "string",
"default": ""
},
"chaptersType": {
"type": "string",
"default": "application/json+chapters"
},
"locationName": {
"type": "string",
"default": ""
},
"license": {
"type": "string",
"default": ""
},
"licenseUrl": {
"type": "string",
"default": ""
},
"people": {
"type": "array",
"default": []
},
"showPoster": {
"type": "boolean",
"default": true
},
"coverArt": {
"type": "object",
"default": {}
},
"soundbites": {
"type": "array",
"default": []
},
"alternateEnclosures": {
"type": "array",
"default": []
}
},
"example": {
"attributes": {
"episodeNumber": 1,
"seasonNumber": 1,
"episodeType": "full",
"duration": "11:25"
}
}
}
@@ -0,0 +1,760 @@
<?php
/**
* Podcast Episode block.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Blocks;
use Automattic\Jetpack\Status\Request;
/**
* Registers and renders the Podcast Episode block.
*
* The caller (Podcast::init()) is responsible for the host gate.
*/
class Podcast_Episode_Block {
/**
* Editor script handle.
*/
const EDITOR_HANDLE = 'jetpack-podcast-episode-editor';
/**
* Front-end + editor shared style handle. Side-loaded by
* `Assets::register_script` from the sibling `style.css` bundle.
*/
const STYLE_HANDLE = 'jetpack-podcast-episode-style';
/**
* Front-end view script handle. Enqueued from the render callback
* because the block.json `viewScript` field can't resolve to the
* package's dist directory from the deployed src location.
*/
const VIEW_HANDLE = 'jetpack-podcast-episode-view';
/**
* Wire the block's actions.
*/
public static function register_hooks() {
add_action( 'init', array( __CLASS__, 'register_block' ), 9 );
add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'load_editor_scripts' ), 9 );
}
/**
* Register the block.
*
* Also registers the front-end style bundle (built separately from the
* editor bundle so it actually ships on the public post page) and hands
* the handle to `register_block_type` via the `style` arg, which auto-
* enqueues it whenever the block is rendered.
*/
public static function register_block() {
// Assets::register_script side-loads the sibling style.css and
// registers a style handle under the same name. The accompanying
// (essentially empty) style.js handle is registered too but never
// enqueued — only the style is passed to register_block_type below.
Assets::register_script(
self::STYLE_HANDLE,
'../../../dist/blocks/podcast-episode/style.js',
__FILE__,
array(
'css_path' => '../../../dist/blocks/podcast-episode/style.css',
)
);
Blocks::jetpack_register_block(
__DIR__,
array(
'render_callback' => array( __CLASS__, 'render_block' ),
'style' => self::STYLE_HANDLE,
'render_email_callback' => array( __CLASS__, 'render_email' ),
)
);
}
/**
* Register and enqueue the front-end view script that wires chapter and
* soundbite buttons to the audio player. Called from the render callback
* so the script only ships on pages that actually contain the block.
*
* `Assets::register_script` dedups internally, so calling this for each
* rendered instance of the block is safe.
*/
private static function enqueue_view_script() {
Assets::register_script(
self::VIEW_HANDLE,
'../../../dist/blocks/podcast-episode/view.js',
__FILE__,
array(
'in_footer' => true,
'enqueue' => true,
)
);
}
/**
* Enqueue the bundled editor script + style from the package's dist/.
*/
public static function load_editor_scripts() {
Assets::register_script(
self::EDITOR_HANDLE,
'../../../dist/blocks/podcast-episode/editor.js',
__FILE__,
array(
'in_footer' => true,
'enqueue' => true,
'textdomain' => 'jetpack-podcast',
)
);
// Add the script_loader_src rewrite only while the editor script is
// in flight, so the filter doesn't run on every front-end script load.
add_filter( 'script_loader_src', array( __CLASS__, 'filter_editor_script_src' ), 10, 2 );
}
/**
* Rewrite the editor script src to match the admin scheme.
*
* On WPCOM sites with a custom domain mapping that lacks SSL, `home_url()`
* (and therefore `plugins_url()`) returns `http://mapped-domain.test` even
* though wp-admin is served from `.wordpress.com` over HTTPS. The script
* URL then trips the browser's mixed-content block. Routing through the
* canonical `script_loader_src` filter keeps the URL valid in both cases
* without mutating `$wp_scripts->registered` directly.
*
* @param string $src Script source URL.
* @param string $handle Script handle.
* @return string
*/
public static function filter_editor_script_src( $src, $handle ) {
if ( self::EDITOR_HANDLE !== $handle ) {
return $src;
}
$admin_scheme = wp_parse_url( admin_url(), PHP_URL_SCHEME );
if ( ! $admin_scheme ) {
return $src;
}
return set_url_scheme( $src, $admin_scheme );
}
/**
* Format a seconds value as a MM:SS or HH:MM:SS label. Negative input clamps to 0.
*
* @param float|int|string $seconds Seconds value (Podcasting 2.0 soundbite startTime/duration are floats).
* @return string Formatted label (always non-empty; "0:00" for zero input).
*/
private static function format_seconds_label( $seconds ) {
$total = (int) floor( max( 0, (float) $seconds ) );
$hours = (int) floor( $total / 3600 );
$minutes = (int) floor( ( $total % 3600 ) / 60 );
$secs = $total % 60;
if ( $hours > 0 ) {
return sprintf( '%d:%02d:%02d', $hours, $minutes, $secs );
}
return sprintf( '%d:%02d', $minutes, $secs );
}
/**
* Resolve the episode cover art URL: episode override post featured
* image show-level `podcasting_image` option empty string.
*
* @param array $attributes Block attributes.
* @param \WP_Post $post Episode post.
* @param string $size Featured-image size to request.
* @return string
*/
private static function resolve_cover_art_url( array $attributes, $post, $size ) {
if ( isset( $attributes['coverArt'] ) && is_array( $attributes['coverArt'] ) && ! empty( $attributes['coverArt']['url'] ) ) {
return esc_url_raw( $attributes['coverArt']['url'] );
}
$featured_id = (int) get_post_thumbnail_id( $post );
if ( $featured_id ) {
$featured_url = (string) wp_get_attachment_image_url( $featured_id, $size );
if ( '' !== $featured_url ) {
return $featured_url;
}
}
return (string) get_option( 'podcasting_image', '' );
}
/**
* Render callback.
*
* Pulls title, author, and date from the surrounding post the post is
* the episode. Cover art falls back to the show-level `podcasting_image`
* option when the block has no episode-specific override.
*
* @param array $attributes Block attributes.
* @param string $content Inner content (fallback direct-link markup from save.js).
* @param \WP_Block $block The parsed block instance, used to read post context.
* @return string
*/
public static function render_block( $attributes, $content, $block = null ) {
// Outside the frontend, fall back to the saved direct link so RSS / email / REST export stays
// simple and predictable.
if ( ! Request::is_frontend() ) {
return $content;
}
if ( empty( $attributes['mediaUrl'] ) ) {
return '';
}
// Resolve the post that backs this episode. Prefer block context (set by Query Loop / singular
// templates / post-bound block contexts) and fall back to the global loop for direct theme
// rendering. With no resolvable post, the block has nothing to display.
$post_id = 0;
if ( $block && isset( $block->context['postId'] ) ) {
$post_id = (int) $block->context['postId'];
}
if ( ! $post_id ) {
$post_id = (int) get_the_ID();
}
if ( ! $post_id ) {
return '';
}
$post = get_post( $post_id );
if ( ! $post ) {
return '';
}
$media_url = esc_url_raw( $attributes['mediaUrl'] );
if ( ! wp_http_validate_url( $media_url ) ) {
return '';
}
$media_type = isset( $attributes['mediaType'] ) && 'video' === $attributes['mediaType'] ? 'video' : 'audio';
$mime_type = isset( $attributes['mediaMimeType'] ) ? (string) $attributes['mediaMimeType'] : '';
$episode_number = isset( $attributes['episodeNumber'] ) ? (int) $attributes['episodeNumber'] : 0;
$season_number = isset( $attributes['seasonNumber'] ) ? (int) $attributes['seasonNumber'] : 0;
$episode_type = isset( $attributes['episodeType'] ) ? (string) $attributes['episodeType'] : 'full';
$is_explicit = ! empty( $attributes['explicit'] );
$duration = isset( $attributes['duration'] ) ? (string) $attributes['duration'] : '';
$show_poster = ! isset( $attributes['showPoster'] ) || ! empty( $attributes['showPoster'] );
$transcript_url = isset( $attributes['transcriptUrl'] ) ? esc_url_raw( $attributes['transcriptUrl'] ) : '';
$location_name = isset( $attributes['locationName'] ) ? (string) $attributes['locationName'] : '';
$license = isset( $attributes['license'] ) ? (string) $attributes['license'] : '';
$license_url = isset( $attributes['licenseUrl'] ) ? esc_url_raw( $attributes['licenseUrl'] ) : '';
$people = isset( $attributes['people'] ) && is_array( $attributes['people'] ) ? $attributes['people'] : array();
$soundbites = isset( $attributes['soundbites'] ) && is_array( $attributes['soundbites'] ) ? $attributes['soundbites'] : array();
$alternate_enclosures = isset( $attributes['alternateEnclosures'] ) && is_array( $attributes['alternateEnclosures'] ) ? $attributes['alternateEnclosures'] : array();
// Only ship the click-to-seek script on episodes that actually have soundbites to wire.
// Chapters are hosted as an external JSON file and consumed by Podcasting 2.0 players directly.
if ( ! empty( $soundbites ) ) {
self::enqueue_view_script();
}
if ( '' !== $transcript_url && ! wp_http_validate_url( $transcript_url ) ) {
$transcript_url = '';
}
$author_id = (int) $post->post_author;
$title = get_the_title( $post );
$author_name = get_the_author_meta( 'display_name', $author_id );
$author_url = esc_url_raw( (string) get_the_author_meta( 'url', $author_id ) );
$publish_date_iso = get_the_date( 'c', $post );
$publish_date = get_the_date( '', $post );
$episode_url = get_permalink( $post );
$transcript_type = isset( $attributes['transcriptType'] ) ? (string) $attributes['transcriptType'] : '';
// Show-level data backs the `partOfSeries` PodcastSeries reference so
// search engines can connect the episode to its parent show.
$show_title = (string) get_option( 'podcasting_title', '' );
$show_image_url = (string) get_option( 'podcasting_image', '' );
$show_email = (string) get_option( 'podcasting_email', '' );
// Cover art chain resolved unconditionally so schema metadata always carries
// the image; the `$show_poster` toggle only gates the visible figure and the
// video poster.
$image_url = self::resolve_cover_art_url( $attributes, $post, 'full' );
// AudioObject/VideoObject @type for the embedded media.
$media_object_type = 'video' === $media_type ? 'VideoObject' : 'AudioObject';
$wrapper_attributes = get_block_wrapper_attributes();
ob_start();
?>
<div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- get_block_wrapper_attributes() returns pre-escaped attribute output. ?>>
<article class="jetpack-podcast-episode" itemscope itemtype="https://schema.org/PodcastEpisode">
<?php if ( $episode_url ) : ?>
<link itemprop="url" href="<?php echo esc_url( $episode_url ); ?>" />
<?php endif; ?>
<?php if ( $image_url && $show_poster ) : ?>
<figure class="jetpack-podcast-episode__poster">
<img
src="<?php echo esc_url( $image_url ); ?>"
alt=""
itemprop="image"
loading="lazy"
/>
</figure>
<?php elseif ( $image_url ) : ?>
<meta itemprop="image" content="<?php echo esc_url( $image_url ); ?>" />
<?php endif; ?>
<div class="jetpack-podcast-episode__body">
<?php if ( $season_number || $episode_number || 'full' !== $episode_type || $is_explicit ) : ?>
<p class="jetpack-podcast-episode__meta-line">
<?php if ( $season_number ) : ?>
<span class="jetpack-podcast-episode__season" itemprop="partOfSeason" itemscope itemtype="https://schema.org/PodcastSeason">
<meta itemprop="seasonNumber" content="<?php echo esc_attr( (string) $season_number ); ?>" />
<?php
/* translators: %d: season number. */
echo esc_html( sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number ) );
?>
</span>
<?php endif; ?>
<?php if ( $episode_number ) : ?>
<span class="jetpack-podcast-episode__episode-number">
<meta itemprop="episodeNumber" content="<?php echo esc_attr( (string) $episode_number ); ?>" />
<?php
/* translators: %d: episode number. */
echo esc_html( sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number ) );
?>
</span>
<?php endif; ?>
<?php if ( 'trailer' === $episode_type ) : ?>
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--trailer"><?php esc_html_e( 'Trailer', 'jetpack-podcast' ); ?></span>
<?php elseif ( 'bonus' === $episode_type ) : ?>
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--bonus"><?php esc_html_e( 'Bonus', 'jetpack-podcast' ); ?></span>
<?php endif; ?>
<?php if ( $is_explicit ) : ?>
<span class="jetpack-podcast-episode__badge jetpack-podcast-episode__badge--explicit" title="<?php esc_attr_e( 'Explicit content', 'jetpack-podcast' ); ?>"><?php echo esc_html( _x( 'E', 'short label for explicit content', 'jetpack-podcast' ) ); ?></span>
<?php endif; ?>
</p>
<?php endif; ?>
<?php if ( $title ) : ?>
<h3 class="jetpack-podcast-episode__title" itemprop="name"><?php echo esc_html( $title ); ?></h3>
<?php endif; ?>
<?php if ( $author_name || $publish_date || $duration ) : ?>
<p class="jetpack-podcast-episode__byline">
<?php if ( $author_name ) : ?>
<span class="jetpack-podcast-episode__author" itemprop="author" itemscope itemtype="https://schema.org/Person">
<?php if ( $author_url ) : ?>
<a href="<?php echo esc_url( $author_url ); ?>" itemprop="url">
<span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
</a>
<?php else : ?>
<span itemprop="name"><?php echo esc_html( $author_name ); ?></span>
<?php endif; ?>
</span>
<?php endif; ?>
<?php if ( $publish_date ) : ?>
<time
class="jetpack-podcast-episode__date"
datetime="<?php echo esc_attr( $publish_date_iso ); ?>"
itemprop="datePublished"
>
<?php echo esc_html( $publish_date ); ?>
</time>
<?php endif; ?>
<?php if ( $duration ) : ?>
<span class="jetpack-podcast-episode__duration"><?php echo esc_html( $duration ); ?></span>
<?php endif; ?>
</p>
<?php endif; ?>
<div
class="jetpack-podcast-episode__player"
itemprop="<?php echo 'video' === $media_type ? 'video' : 'audio'; ?>"
itemscope
itemtype="https://schema.org/<?php echo esc_attr( $media_object_type ); ?>"
>
<meta itemprop="contentUrl" content="<?php echo esc_url( $media_url ); ?>" />
<?php if ( $mime_type ) : ?>
<meta itemprop="encodingFormat" content="<?php echo esc_attr( $mime_type ); ?>" />
<?php endif; ?>
<?php if ( $duration ) : ?>
<meta itemprop="duration" content="<?php echo esc_attr( $duration ); ?>" />
<?php endif; ?>
<?php if ( 'video' === $media_type ) : ?>
<video
class="jetpack-podcast-episode__video"
controls
preload="none"
src="<?php echo esc_url( $media_url ); ?>"
<?php
if ( $image_url && $show_poster ) :
?>
poster="<?php echo esc_url( $image_url ); ?>"<?php endif; ?>
<?php
if ( $mime_type ) :
?>
data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
></video>
<?php else : ?>
<audio
class="jetpack-podcast-episode__audio"
controls
preload="none"
src="<?php echo esc_url( $media_url ); ?>"
<?php
if ( $mime_type ) :
?>
data-mime="<?php echo esc_attr( $mime_type ); ?>"<?php endif; ?>
></audio>
<?php endif; ?>
</div>
<?php if ( ! empty( $soundbites ) ) : ?>
<ul class="jetpack-podcast-episode__soundbites">
<?php
foreach ( $soundbites as $soundbite ) :
if ( ! is_array( $soundbite ) || ! isset( $soundbite['startTime'] ) ) {
continue;
}
$start_label = self::format_seconds_label( $soundbite['startTime'] );
$soundbite_title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : '';
$start_seconds = (int) floor( max( 0, (float) $soundbite['startTime'] ) );
$end_seconds = isset( $soundbite['duration'] )
? $start_seconds + (int) floor( max( 0, (float) $soundbite['duration'] ) )
: null;
?>
<li
class="jetpack-podcast-episode__soundbite"
itemprop="hasPart"
itemscope
itemtype="https://schema.org/Clip"
>
<meta itemprop="startOffset" content="<?php echo esc_attr( (string) $start_seconds ); ?>" />
<?php if ( null !== $end_seconds ) : ?>
<meta itemprop="endOffset" content="<?php echo esc_attr( (string) $end_seconds ); ?>" />
<?php endif; ?>
<button
type="button"
class="jetpack-podcast-episode__soundbite-button"
data-start-time="<?php echo esc_attr( (string) $start_seconds ); ?>"
>
<time class="jetpack-podcast-episode__soundbite-time"><?php echo esc_html( $start_label ); ?></time>
<?php if ( '' !== $soundbite_title ) : ?>
<span class="jetpack-podcast-episode__soundbite-title" itemprop="name"><?php echo esc_html( $soundbite_title ); ?></span>
<?php endif; ?>
</button>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ( ! empty( $alternate_enclosures ) ) : ?>
<ul class="jetpack-podcast-episode__alternates">
<?php
foreach ( $alternate_enclosures as $alt ) :
if ( ! is_array( $alt ) || empty( $alt['url'] ) ) {
continue;
}
$alt_url = esc_url_raw( (string) $alt['url'] );
if ( ! wp_http_validate_url( $alt_url ) ) {
continue;
}
$alt_type = isset( $alt['type'] ) ? (string) $alt['type'] : '';
$alt_title = isset( $alt['title'] ) ? trim( (string) $alt['title'] ) : '';
$alt_lang = isset( $alt['lang'] ) ? (string) $alt['lang'] : '';
$alt_bitrate = isset( $alt['bitrate'] ) ? (int) $alt['bitrate'] : 0;
$details = array();
if ( '' !== $alt_lang ) {
$details[] = $alt_lang;
}
if ( $alt_bitrate > 0 ) {
$details[] = sprintf(
/* translators: %d: bitrate in kilobits per second. */
__( '%d kbps', 'jetpack-podcast' ),
(int) round( $alt_bitrate / 1000 )
);
}
if ( '' !== $alt_type ) {
$details[] = $alt_type;
}
$details_label = $details ? ' (' . implode( ', ', $details ) . ')' : '';
$display_label = '' !== $alt_title ? $alt_title : __( 'Alternative version', 'jetpack-podcast' );
?>
<li class="jetpack-podcast-episode__alternate">
<a href="<?php echo esc_url( $alt_url ); ?>"<?php echo '' !== $alt_lang ? ' hreflang="' . esc_attr( $alt_lang ) . '"' : ''; ?>>
<?php echo esc_html( $display_label . $details_label ); ?>
</a>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ( ! empty( $people ) ) : ?>
<ul class="jetpack-podcast-episode__people">
<?php
foreach ( $people as $person ) :
if ( ! is_array( $person ) || empty( $person['name'] ) ) {
continue;
}
$person_name = (string) $person['name'];
$person_role = isset( $person['role'] ) ? (string) $person['role'] : '';
$person_href = isset( $person['href'] ) ? esc_url_raw( $person['href'] ) : '';
$person_img = isset( $person['img'] ) ? esc_url_raw( $person['img'] ) : '';
?>
<li class="jetpack-podcast-episode__person" itemprop="contributor" itemscope itemtype="https://schema.org/Person">
<?php if ( $person_img ) : ?>
<img src="<?php echo esc_url( $person_img ); ?>" alt="" loading="lazy" />
<?php endif; ?>
<?php if ( $person_href ) : ?>
<a href="<?php echo esc_url( $person_href ); ?>" itemprop="url">
<span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
</a>
<?php else : ?>
<span itemprop="name"><?php echo esc_html( $person_name ); ?></span>
<?php endif; ?>
<?php if ( $person_role ) : ?>
<span class="jetpack-podcast-episode__person-role"><?php echo esc_html( $person_role ); ?></span>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<?php endif; ?>
<?php if ( '' !== $show_title ) : ?>
<div class="jetpack-podcast-episode__series" itemprop="partOfSeries" itemscope itemtype="https://schema.org/PodcastSeries">
<meta itemprop="name" content="<?php echo esc_attr( $show_title ); ?>" />
<?php if ( $show_image_url ) : ?>
<meta itemprop="image" content="<?php echo esc_url( $show_image_url ); ?>" />
<?php endif; ?>
<?php if ( $show_email ) : ?>
<span itemprop="publisher" itemscope itemtype="https://schema.org/Organization">
<meta itemprop="email" content="<?php echo esc_attr( $show_email ); ?>" />
</span>
<?php endif; ?>
</div>
<?php endif; ?>
<?php if ( $transcript_url || $location_name || $license ) : ?>
<ul class="jetpack-podcast-episode__links">
<?php if ( $transcript_url ) : ?>
<li itemprop="transcript" itemscope itemtype="https://schema.org/MediaObject">
<meta itemprop="contentUrl" content="<?php echo esc_url( $transcript_url ); ?>" />
<?php if ( '' !== $transcript_type ) : ?>
<meta itemprop="encodingFormat" content="<?php echo esc_attr( $transcript_type ); ?>" />
<?php endif; ?>
<a href="<?php echo esc_url( $transcript_url ); ?>" class="jetpack-podcast-episode__transcript-link">
<?php esc_html_e( 'Read transcript', 'jetpack-podcast' ); ?>
</a>
</li>
<?php endif; ?>
<?php if ( $location_name ) : ?>
<li class="jetpack-podcast-episode__location" itemprop="contentLocation"><?php echo esc_html( $location_name ); ?></li>
<?php endif; ?>
<?php if ( $license ) : ?>
<li class="jetpack-podcast-episode__license">
<?php
/* translators: %s: license identifier (e.g. "CC-BY-4.0"). */
$license_label = sprintf( _x( 'License: %s', 'episode metadata license label', 'jetpack-podcast' ), $license );
?>
<?php if ( $license_url ) : ?>
<a href="<?php echo esc_url( $license_url ); ?>" itemprop="license"><?php echo esc_html( $license_label ); ?></a>
<?php else : ?>
<?php echo esc_html( $license_label ); ?>
<?php endif; ?>
</li>
<?php endif; ?>
</ul>
<?php endif; ?>
</div>
</article>
</div>
<?php
return ob_get_clean();
}
/**
* Render the block for email via the WooCommerce Email Editor.
*
* Email clients can't run the interactive player, so render a static
* episode card cover art, title, byline, duration linking back to
* the episode post, where the full player lives.
*
* @param string $block_content The original block HTML content.
* @param array $parsed_block The parsed block data including attributes.
* @param object $rendering_context Email rendering context.
* @return string
*/
public static function render_email( $block_content, array $parsed_block, $rendering_context ) {
if ( ! isset( $parsed_block['attrs'] ) || ! is_array( $parsed_block['attrs'] ) ||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper' ) ||
! class_exists( '\Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper' ) ) {
return '';
}
$attrs = $parsed_block['attrs'];
if ( empty( $attrs['mediaUrl'] ) || ! wp_http_validate_url( $attrs['mediaUrl'] ) ) {
return '';
}
$post = get_post();
if ( ! $post ) {
return '';
}
$post_url = get_permalink( $post );
if ( empty( $post_url ) ) {
return '';
}
$title = get_the_title( $post );
$author_name = get_the_author_meta( 'display_name', (int) $post->post_author );
$publish_date = get_the_date( '', $post );
$duration = isset( $attrs['duration'] ) ? trim( (string) $attrs['duration'] ) : '';
$season_number = isset( $attrs['seasonNumber'] ) ? (int) $attrs['seasonNumber'] : 0;
$episode_number = isset( $attrs['episodeNumber'] ) ? (int) $attrs['episodeNumber'] : 0;
$image_url = self::resolve_cover_art_url( $attrs, $post, 'thumbnail' );
if ( '' !== $image_url && ! wp_http_validate_url( $image_url ) ) {
$image_url = '';
}
$cta_label = isset( $attrs['mediaType'] ) && 'video' === $attrs['mediaType']
? __( 'Watch the episode', 'jetpack-podcast' )
: __( 'Listen to the episode', 'jetpack-podcast' );
$meta_parts = array();
if ( $season_number ) {
/* translators: %d: season number. */
$meta_parts[] = sprintf( __( 'Season %d', 'jetpack-podcast' ), $season_number );
}
if ( $episode_number ) {
/* translators: %d: episode number. */
$meta_parts[] = sprintf( __( 'Episode %d', 'jetpack-podcast' ), $episode_number );
}
$byline_parts = array_filter( array( $author_name, $publish_date, $duration ) );
$body = '';
if ( $meta_parts ) {
$body .= sprintf(
'<p style="margin: 0 0 4px; font-size: 12px; line-height: 1.4; text-transform: uppercase; letter-spacing: 0.5px; color: #757575;">%s</p>',
esc_html( implode( ' · ', $meta_parts ) )
);
}
if ( $title ) {
$body .= sprintf(
'<h3 style="margin: 0 0 4px; font-size: 18px; line-height: 1.3;"><a href="%s" style="color: inherit; text-decoration: none;">%s</a></h3>',
esc_url( $post_url ),
esc_html( $title )
);
}
if ( $byline_parts ) {
$body .= sprintf(
'<p style="margin: 0 0 12px; font-size: 13px; line-height: 1.4; color: #757575;">%s</p>',
esc_html( implode( ' · ', $byline_parts ) )
);
}
$body .= sprintf(
'<p style="margin: 0; font-size: 14px;"><a href="%s" style="font-weight: 600;">&#9654;&nbsp; %s</a></p>',
esc_url( $post_url ),
esc_html( $cta_label )
);
$cells = '';
if ( '' !== $image_url ) {
$image_link = sprintf(
'<a href="%s"><img src="%s" alt="" width="96" height="96" style="display: block; width: 96px; height: 96px; border-radius: 4px;" /></a>',
esc_url( $post_url ),
esc_url( $image_url )
);
// Padding lives on an inner wrapper, not the cell: the engine's
// mobile media query zeroes `.layout-flex-item` horizontal padding
// when it stacks the card, which would otherwise flush the content
// against the border.
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
'<div style="padding: 16px 0 0 16px;">' . $image_link . '</div>',
array(
'class' => 'layout-flex-item',
'width' => '96',
'valign' => 'top',
'style' => 'width: 96px;',
)
);
}
// Wrap the body in a nested table rather than dropping the loose <p>/<h3>
// straight into the layout cell. Core blocks (e.g. Media_Text) never put
// bare block-level elements in a cell — they table-wrap content so it
// survives email pipelines intact. The padding rides on the nested cell,
// which the engine's mobile `.layout-flex-item td` rule leaves untouched,
// so it also stays put when the card stacks.
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$body_table = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
$body,
array( 'style' => 'width: 100%; border-collapse: collapse;' ),
array( 'style' => 'padding: 16px;' )
);
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$cells .= \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_cell(
$body_table,
array(
'class' => 'layout-flex-item',
'valign' => 'top',
)
);
// Cap the card to the email layout width when the context exposes it
// (method_exists guards against older email editor versions).
$target_width = 600;
if ( method_exists( $rendering_context, 'get_layout_width_without_padding' ) ) {
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$layout_width = (int) \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::parse_value( $rendering_context->get_layout_width_without_padding() );
if ( $layout_width > 0 ) {
$target_width = $layout_width;
}
}
// Preserve the vertical gap from email_attrs — the engine's spacing
// preprocessor sets `margin-top`; horizontal root padding is applied
// to the callback output by the engine itself.
$email_attrs = $parsed_block['email_attrs'] ?? array();
$table_margin_style = (string) \WP_Style_Engine::compile_css( array_intersect_key( $email_attrs, array_flip( array( 'margin', 'margin-top' ) ) ), '' );
// `layout-flex-wrapper` opts the card into the engine's own mobile
// stacking: its template-canvas.css media query collapses
// `.layout-flex-wrapper`/`.layout-flex-item` to full-width blocks under
// 660px, so no custom media query (or template-style filter) is needed.
// border-collapse must stay `separate` for the rounded card border to render.
$table_style = sprintf(
'%s width: 100%%; max-width: %dpx; border-collapse: separate; border: 1px solid #ddd; border-radius: 6px;',
$table_margin_style ? $table_margin_style : 'margin: 16px 0;',
$target_width
);
// Append user-set block supports (padding, border, colors) so editor
// styling overrides the card defaults.
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
$user_styles = \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Styles_Helper::get_block_styles( $attrs, $rendering_context, array( 'padding', 'border', 'background-color', 'color' ) );
if ( ! empty( $user_styles['css'] ) ) {
$table_style .= ' ' . $user_styles['css'];
}
// @phan-suppress-next-line PhanUndeclaredClassMethod -- Optional WooCommerce dependency, checked with class_exists() above.
return \Automattic\WooCommerce\EmailEditor\Integrations\Utils\Table_Wrapper_Helper::render_table_wrapper(
$cells,
array(
'class' => 'jetpack-podcast-episode-email-card layout-flex-wrapper',
'style' => $table_style,
),
array(),
array(),
false
);
}
}
@@ -0,0 +1,238 @@
<?php
/**
* Registers the Jetpack Podcast wp-admin page and loads the wp-build dashboard.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Admin_UI\Admin_Menu;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Status\Host;
use Automattic\Jetpack\WP_Build_Polyfills\WP_Build_Polyfills;
/**
* Adds the "Jetpack > Podcast" wp-admin screen.
*/
class Admin_Page {
const ADMIN_PAGE_SLUG = 'jetpack-podcast';
/**
* Query var the checkout return URL carries so the gate busts its cached
* purchases lookup the instant a buyer lands back on the dashboard. Kept in
* sync with the `podcast_purchased` literal in `withPurchaseReturnMarker()`
* (`src/dashboard/upgrade.ts`).
*/
const PURCHASE_RETURN_QUERY_VAR = 'podcast_purchased';
/**
* Where the Podcast item sits in the Jetpack submenu on self-hosted.
*
* Placed after Subscribers (15) and Newsletter (10) to mirror the order
* `wpcom-admin-menu.php` uses on Simple/Atomic.
*/
const MENU_POSITION = 16;
/**
* Slug emitted by `@wordpress/build`. wp-build's auto-generated enqueue
* callback only fires when `$screen->id` matches this value, so we alias
* the screen id via `current_screen` without changing the user-facing URL.
*/
const WP_BUILD_SLUG = 'jetpack-podcast-dashboard';
/**
* Whether `init()` has already wired its hooks.
*
* @var bool
*/
private static $initialized = false;
/**
* Wire admin hooks. Idempotent.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
add_action( 'admin_menu', array( __CLASS__, 'maybe_load_wp_build' ), 1 );
// On Simple/Atomic, wpcom-admin-menu.php builds the Jetpack menu at
// priority 999999 and calls add_wp_admin_submenu() itself. Self-hosted
// has no such file, so we register our own. Priority 999 queues the item
// before Admin_Menu's priority-1000 callback.
if ( ! ( new Host() )->is_wpcom_platform() ) {
add_action( 'admin_menu', array( __CLASS__, 'add_wp_admin_submenu' ), 999 );
}
}
/**
* Register the Podcast submenu under the Jetpack menu.
*/
public static function add_wp_admin_submenu() {
// Prefer the wp-build render function once it's defined (by
// maybe_load_wp_build() at admin_menu priority 1); fall back otherwise.
$wp_build_render = 'jetpack_podcast_jetpack_podcast_dashboard_wp_admin_render_page';
$callback = function_exists( $wp_build_render ) ? $wp_build_render : array( __CLASS__, 'render' );
if ( ( new Host() )->is_wpcom_platform() ) {
$page_suffix = add_submenu_page(
'jetpack',
/** "Podcast" is a product name, do not translate. */
'Podcast',
'Podcast',
'manage_options',
self::ADMIN_PAGE_SLUG,
$callback
);
} else {
$page_suffix = Admin_Menu::add_menu(
/** "Podcast" is a product name, do not translate. */
'Podcast',
'Podcast',
'manage_options',
self::ADMIN_PAGE_SLUG,
$callback,
self::MENU_POSITION
);
}
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'admin_init' ) );
}
}
/**
* Wire admin-init actions once we know the Podcast page is loading.
*/
public static function admin_init() {
// MediaUpload (cover-image-control) reads wp.media.view — only defined after this runs.
add_action( 'admin_enqueue_scripts', 'wp_enqueue_media' );
}
/**
* Hooked at admin_menu priority 1 so polyfills register before
* `wp_default_scripts` fires and the wp-build render function is defined
* before `add_wp_admin_submenu()` runs (priority 999 on self-hosted, 999999
* on Simple/Atomic).
*/
public static function maybe_load_wp_build() {
if ( ! self::is_podcast_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_podcast_script_data' ) );
}
/**
* Add the podcast gate boolean to `window.JetpackScriptData`.
*
* Hooked from `maybe_load_wp_build()` so it only runs when the request is
* for the podcast admin page.
*
* @param array $data Script data being injected.
* @return array
*/
public static function inject_podcast_script_data( $data ) {
if ( ! is_array( $data ) ) {
$data = array();
}
$is_wpcom = ( new Host() )->is_wpcom_platform();
if ( ! $is_wpcom && empty( $data['site']['wpcom']['blog_id'] ) ) {
$blog_id = (int) Connection_Manager::get_site_id( true );
if ( $blog_id > 0 ) {
$data['site']['wpcom']['blog_id'] = $blog_id;
}
}
// A buyer returning from checkout carries the purchase marker; bust the
// cached purchases lookup so the gate re-reads `/upgrades` and unlocks
// the paid surfaces now instead of after the transient expires.
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( isset( $_GET[ self::PURCHASE_RETURN_QUERY_VAR ] ) ) {
Podcast_Gate::flush_purchases_cache();
}
// Self-hosted upsells the Growth plan; WordPress.com keeps Premium.
// `product_slug` is fed straight to the checkout URL; `plan_name` is a
// product name shown in the locked-preview copy (not translated).
$data['podcast'] = array(
'has_product_access' => Podcast_Gate::has_product_access(),
'is_connected' => $is_wpcom || ( new Connection_Manager( 'jetpack' ) )->is_connected(),
'show_url_hosts' => Settings::SHOW_URL_HOSTS,
'show_url_max_length' => Settings::SHOW_URL_MAX_LENGTH,
// Settings only: categories rejects per_page=-1 server-side, stats is a live relay.
'preload' => rest_preload_api_request( array(), '/wpcom/v2/podcast/settings' ),
'upgrade' => array(
'product_slug' => $is_wpcom ? 'premium' : 'jetpack_growth_yearly',
'plan_name' => $is_wpcom ? 'Premium' : 'Growth',
),
);
return $data;
}
/**
* The build artifact may be absent on a fresh checkout before
* `pnpm build` has run; in that case `add_wp_admin_submenu()` falls back
* to `render()` so the page still loads (just without the React app).
*/
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-podcast',
array_merge( WP_Build_Polyfills::SCRIPT_HANDLES, WP_Build_Polyfills::MODULE_IDS )
);
}
/**
* Alias the current screen id to wp-build's expected slug.
*
* @param \WP_Screen|null $screen The current screen object (passed by WP).
*/
public static function alias_screen_id_for_wp_build( $screen ) {
if ( ! is_object( $screen ) ) {
return;
}
$screen->id = self::WP_BUILD_SLUG;
}
/**
* Fallback render used when the wp-build artifact is missing.
*/
public static function render() {
?>
<div class="wrap">
<h1>Podcast</h1>
</div>
<?php
}
/**
* Whether the current request targets the Podcast admin page.
*/
private static function is_podcast_admin_request() {
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! is_admin() || ! isset( $_GET['page'] ) ) {
return false;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
return self::ADMIN_PAGE_SLUG === sanitize_text_field( wp_unslash( $_GET['page'] ) );
}
}
@@ -0,0 +1,902 @@
<?php
/**
* Create AI Podcast wp-admin page under Media.
*
* Standalone PHP page (no wp-build chassis, no React). Renders a static
* form server-side; a single vanilla-JS island fetches quota, drives the
* posts picker, submits the generate request, polls the job, and resumes
* across reloads via localStorage.
*
* Bootstrapped from `Podcast::init()` after the Host (Simple/WoA) gate has
* already been checked upstream.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
require_once __DIR__ . '/admin-pages/create-ai-podcast/presets.php';
use Automattic\Jetpack\Assets;
use Automattic\Jetpack\Status\Host;
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\length_presets;
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\voice_presets;
use function Automattic\Jetpack\Podcast\Admin_Pages\Create_AI_Podcast\window_presets;
/**
* Registers the Media > Create AI Podcast submenu and renders the page.
*/
class Create_AI_Podcast_Page {
const PAGE_SLUG = 'create-ai-podcast';
const SCRIPT_HANDLE = 'jetpack-create-ai-podcast';
const STYLE_HANDLE = 'jetpack-create-ai-podcast';
const EPISODES_PER_PAGE = 5;
/**
* Maximum number of posts that can be selected when generating from specific posts.
*/
const MAX_SELECTED_POSTS = 25;
const POST_PUBLISH_PROMO_SCRIPT_HANDLE = 'jetpack-post-publish-podcast-promo';
const POST_PUBLISH_PROMO_DISMISSED_OPTION = 'jetpack_posts_to_podcast_post_publish_promo_dismissed';
const POST_PUBLISH_PROMO_MIN_POSTS = 5;
const POST_PUBLISH_PROMO_MIN_VISITORS = 50;
/**
* Whether `init()` has wired its hooks.
*
* @var bool
*/
private static $initialized = false;
/**
* Wire admin hooks. Idempotent.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
add_action( 'admin_menu', array( __CLASS__, 'register_menu' ) );
add_action( 'enqueue_block_editor_assets', array( __CLASS__, 'enqueue_post_publish_promo_assets' ) );
}
/**
* Register the Media > Create AI Podcast submenu.
*/
public static function register_menu() {
$page_suffix = add_submenu_page(
'upload.php',
__( 'Create AI Podcast', 'jetpack-podcast' ),
__( 'Create AI Podcast', 'jetpack-podcast' ),
'upload_files',
self::PAGE_SLUG,
array( __CLASS__, 'render' )
);
if ( $page_suffix ) {
add_action( 'load-' . $page_suffix, array( __CLASS__, 'on_load' ) );
}
}
/**
* Wire enqueue once we know the Create AI Podcast page is loading.
*/
public static function on_load() {
add_action( 'admin_enqueue_scripts', array( __CLASS__, 'enqueue_assets' ) );
add_action( 'admin_head-media_page_create-ai-podcast', array( __CLASS__, 'render_resource_hints' ) );
}
/**
* On wpcom Simple sites every wp.apiFetch call is routed through the
* wpcom-proxy iframe at public-api.wordpress.com. The iframe load adds a
* full DNS + TLS round-trip before our prefetched quota/episodes requests
* can leave the page. Preconnect shaves that off.
*/
public static function render_resource_hints() {
?>
<link rel="preconnect" href="https://public-api.wordpress.com" crossorigin>
<link rel="dns-prefetch" href="//public-api.wordpress.com">
<?php
}
/**
* Enqueue the static JS island, the page stylesheet, and the localized data bundle.
*/
public static function enqueue_assets() {
$base_url = plugins_url( 'admin-pages/create-ai-podcast/', __FILE__ );
$base_path = __DIR__ . '/admin-pages/create-ai-podcast/';
wp_enqueue_style(
self::STYLE_HANDLE,
$base_url . 'style.css',
array(),
(string) ( file_exists( $base_path . 'style.css' ) ? filemtime( $base_path . 'style.css' ) : '0.1.0' )
);
wp_enqueue_script(
self::SCRIPT_HANDLE,
$base_url . 'index.js',
array( 'wp-api-fetch', 'wp-i18n' ),
(string) ( file_exists( $base_path . 'index.js' ) ? filemtime( $base_path . 'index.js' ) : '0.1.0' ),
true
);
wp_localize_script(
self::SCRIPT_HANDLE,
'jetpackCreateAiPodcast',
self::build_localized_data()
);
}
/**
* Enqueue the post-publish modal in the post block editor for eligible sites.
*/
public static function enqueue_post_publish_promo_assets() {
if (
! self::is_post_block_editor()
|| self::is_current_post_published_for_post_publish_promo()
|| ! self::is_post_publish_promo_site_eligible()
) {
return;
}
Assets::register_script(
self::POST_PUBLISH_PROMO_SCRIPT_HANDLE,
'../dist/blocks/post-publish-podcast-promo/editor.js',
__FILE__,
array(
'enqueue' => true,
'in_footer' => true,
'textdomain' => 'jetpack-podcast',
)
);
wp_add_inline_script(
self::POST_PUBLISH_PROMO_SCRIPT_HANDLE,
'window.jetpackPostPublishPodcastPromo = ' . wp_json_encode(
array(
'createUrl' => admin_url( 'upload.php?page=' . self::PAGE_SLUG ),
'dismissPath' => Posts_To_Podcast_Endpoint::get_post_publish_promo_dismiss_rest_path(),
),
JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT
) . ';',
'before'
);
}
/**
* Whether the current editor post has already been published.
*/
private static function is_current_post_published_for_post_publish_promo(): bool {
$post = get_post();
return $post instanceof \WP_Post
&& 'post' === $post->post_type
&& 'publish' === $post->post_status;
}
/**
* Whether the current screen is the post block editor.
*/
private static function is_post_block_editor(): bool {
if ( ! function_exists( 'get_current_screen' ) ) {
return false;
}
$screen = get_current_screen();
return ! empty( $screen )
&& 'post' === $screen->base
&& 'post' === $screen->post_type
&& $screen->is_block_editor();
}
/**
* Whether the site has enough published posts to generate a better episode.
*/
private static function has_enough_recent_posts_for_post_publish_promo(): bool {
/**
* Filters the minimum posts published in the last month needed for the Posts to Podcast post-publish promo.
*
* @since 1.0.0
*
* @param int $minimum Minimum number of published posts.
*/
$minimum = (int) apply_filters(
'jetpack_posts_to_podcast_post_publish_promo_min_published_posts',
self::POST_PUBLISH_PROMO_MIN_POSTS
);
$minimum = max( 1, $minimum );
$published_posts = get_posts(
array(
'fields' => 'ids',
'no_found_rows' => true,
'post_status' => 'publish',
'post_type' => 'post',
'posts_per_page' => $minimum,
'suppress_filters' => false,
'date_query' => array(
array(
'after' => '1 month ago',
'inclusive' => true,
),
),
)
);
$total = count( $published_posts );
$post = get_post();
if ( $post && 'post' === $post->post_type && 'publish' !== $post->post_status ) {
++$total;
}
return $total >= $minimum;
}
/**
* Whether the site has visitors who could benefit from a podcast episode.
*/
private static function has_visitors_for_post_publish_promo(): bool {
$visitors = self::get_post_publish_promo_visitor_count();
/**
* Filters the minimum visitors in the last week needed for the Posts to Podcast post-publish promo.
*
* @since 1.0.0
*
* @param int $minimum Minimum number of visitors.
*/
$minimum = (int) apply_filters(
'jetpack_posts_to_podcast_post_publish_promo_min_visitors',
self::POST_PUBLISH_PROMO_MIN_VISITORS
);
return $visitors >= max( 1, $minimum );
}
/**
* Fetch the last week's visitor count from Jetpack Stats when available.
*/
private static function get_post_publish_promo_visitor_count(): int {
$host = new Host();
if ( $host->is_wpcom_simple() ) {
return self::get_wpcom_simple_post_publish_promo_visitor_count();
}
if ( class_exists( '\Automattic\Jetpack\Stats\WPCOM_Stats' ) ) {
$wpcom_stats = new \Automattic\Jetpack\Stats\WPCOM_Stats();
$stats = $wpcom_stats->get_visits(
array(
'unit' => 'day',
'quantity' => 7,
'stat_fields' => 'visitors',
)
);
if ( ! is_wp_error( $stats ) && is_array( $stats ) ) {
return self::sum_visits_field( $stats, 'visitors' );
}
}
return 0;
}
/**
* Fetch the last week's visitor count directly on WordPress.com Simple.
*/
private static function get_wpcom_simple_post_publish_promo_visitor_count(): int {
if ( ! function_exists( 'stats_get_visitors' ) ) {
return 0;
}
$visitors = stats_get_visitors( get_current_blog_id(), gmdate( 'Y-m-d' ), 7, 1 );
return is_array( $visitors ) ? (int) array_sum( $visitors ) : 0;
}
/**
* Sum a metric from the Stats visits response.
*
* @param array $stats Stats visits response.
* @param string $field Field to sum.
* @return int
*/
private static function sum_visits_field( array $stats, string $field ): int {
if ( ! isset( $stats['data'] ) || ! is_array( $stats['data'] ) ) {
return 0;
}
$fields = isset( $stats['fields'] ) && is_array( $stats['fields'] ) ? $stats['fields'] : array();
$index = array_search( $field, $fields, true );
if ( false === $index ) {
return 0;
}
$total = 0;
foreach ( $stats['data'] as $row ) {
if ( is_array( $row ) && isset( $row[ $index ] ) ) {
$total += (int) $row[ $index ];
}
}
return $total;
}
/**
* Whether the site is relevant for the post-publish promo.
*/
public static function is_post_publish_promo_site_eligible(): bool {
$host = new Host();
if ( $host->is_p2_site() ) {
return false;
}
if ( get_user_option( self::POST_PUBLISH_PROMO_DISMISSED_OPTION, get_current_user_id() ) ) {
return false;
}
return self::has_enough_recent_posts_for_post_publish_promo() && self::has_visitors_for_post_publish_promo();
}
/**
* Build the data bundle passed to the JS island via wp_localize_script.
*
* @return array<string, mixed>
*/
private static function build_localized_data(): array {
$max_posts = self::MAX_SELECTED_POSTS;
return array(
'maxPosts' => $max_posts,
'endpoints' => array(
'enqueue' => '/wpcom/v2/posts-to-podcast',
'job' => '/wpcom/v2/posts-to-podcast/jobs/',
'quota' => '/wpcom/v2/posts-to-podcast',
'posts' => '/wp/v2/posts',
'episodes' => '/wpcom/v2/posts-to-podcast/episodes',
),
'blogId' => self::resolve_blog_id(),
'bootstrap' => self::bootstrap_data(),
'presets' => array(
'window' => window_presets(),
'length' => length_presets(),
'voice' => voice_presets(),
),
'poll' => array(
'fastMs' => 3000,
'slowMs' => 10000,
'switchMs' => 30000,
'timeoutMs' => 5 * 60 * 1000,
),
'i18n' => array(
'submitting' => __( 'Submitting…', 'jetpack-podcast' ),
'polling' => __( 'Generating your episode…', 'jetpack-podcast' ),
'pollingSubtext' => __( "This usually takes about 3 minutes. You can leave this page and come back — we'll keep working in the background.", 'jetpack-podcast' ),
'succeeded' => __( 'Episode draft ready.', 'jetpack-podcast' ),
'editDraft' => __( 'Edit draft', 'jetpack-podcast' ),
'failed' => __( 'Generation failed.', 'jetpack-podcast' ),
'timedOut' => __( 'Generation is taking longer than expected. Check your drafts.', 'jetpack-podcast' ),
'tryAgain' => __( 'Try again', 'jetpack-podcast' ),
'dismiss' => __( 'Dismiss', 'jetpack-podcast' ),
'notAvailable' => __( 'Create AI Podcast isn\'t available on your current plan.', 'jetpack-podcast' ),
// translators: 1: number of credits used, 2: total credits available.
'creditsUsed' => __( '%1$d of %2$d credits used.', 'jetpack-podcast' ),
'creditsLabel' => __( 'Credits', 'jetpack-podcast' ),
// translators: 1: number of credits used, 2: total credits available.
'creditsCount' => __( '%1$d / %2$d', 'jetpack-podcast' ),
'creditsUnlimited' => __( 'Unlimited generations available.', 'jetpack-podcast' ),
// translators: %d: credits remaining.
'creditsRemaining' => __( '%d remaining', 'jetpack-podcast' ),
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
'creditsResetSummary' => __( 'Resets %s', 'jetpack-podcast' ),
'creditsResetMonthly' => __( 'Resets monthly', 'jetpack-podcast' ),
'relativeToday' => __( 'today', 'jetpack-podcast' ),
'relativeTomorrow' => __( 'tomorrow', 'jetpack-podcast' ),
// translators: %d: number of days until reset.
'relativeDays' => __( 'in %d days', 'jetpack-podcast' ),
// translators: %s: formatted date, e.g. "May 20, 2026".
'relativeOn' => __( 'on %s', 'jetpack-podcast' ),
'trialBannerTitle' => __( 'Try before you buy', 'jetpack-podcast' ),
'trialBannerMessage' => __( 'Generate a podcast from your posts and see how it sounds on your site. Free trial is limited to one podcast episode.', 'jetpack-podcast' ),
'runningLowTitle' => __( 'Running low', 'jetpack-podcast' ),
'runningLowMessage' => __( 'Upgrade your plan to keep generating without waiting for the monthly refresh.', 'jetpack-podcast' ),
'outOfCreditsTitle' => __( 'Out of credits', 'jetpack-podcast' ),
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
'outOfCreditsWait' => __( 'Your credits will refresh %s.', 'jetpack-podcast' ),
// translators: %s: relative time, e.g. "in 12 days" or "tomorrow".
'outOfCreditsUpgrade' => __( 'Upgrade your plan for more credits, or wait until they refresh %s.', 'jetpack-podcast' ),
'outOfTrialCredits' => __( 'You have used your one-time trial credit. Upgrade your plan for more credits.', 'jetpack-podcast' ),
'noPostsFound' => __( 'No posts match.', 'jetpack-podcast' ),
'loadingPosts' => __( 'Loading posts…', 'jetpack-podcast' ),
'pickPosts' => __( 'Select at least one post to continue.', 'jetpack-podcast' ),
// translators: %d: maximum number of posts that can be selected.
'maxPostsReached' => sprintf( __( 'You can select up to %d posts.', 'jetpack-podcast' ), $max_posts ),
'upgradeCta' => __( 'Upgrade plan', 'jetpack-podcast' ),
'episodesTitle' => __( 'Generated podcasts', 'jetpack-podcast' ),
'episodesEmpty' => __( 'No generated podcasts yet.', 'jetpack-podcast' ),
'episodesLoading' => __( 'Loading podcasts…', 'jetpack-podcast' ),
'editPost' => __( 'Edit post', 'jetpack-podcast' ),
'statusDraft' => __( 'Draft', 'jetpack-podcast' ),
'statusPublished' => __( 'Published', 'jetpack-podcast' ),
// translators: 1: range start, 2: range end, 3: total count. Example: "Showing 15 of 12"
'paginationSummary' => __( 'Showing %1$d%2$d of %3$d', 'jetpack-podcast' ),
'paginationPrev' => __( 'Previous', 'jetpack-podcast' ),
'paginationNext' => __( 'Next', 'jetpack-podcast' ),
// translators: %d: page number. Example: "Go to page 3"
'paginationGoTo' => __( 'Go to page %d', 'jetpack-podcast' ),
'paginationLabel' => __( 'Episodes pagination', 'jetpack-podcast' ),
'unexpectedError' => __( 'An unexpected error occurred.', 'jetpack-podcast' ),
'outOfCreditsError' => __( 'Out of credits.', 'jetpack-podcast' ),
),
);
}
/**
* Resolve the wpcom blog id for this site. On Atomic Jetpack stores it
* under the `id` option; on Simple sites it's the current blog id.
*
* @return int
*/
private static function resolve_blog_id(): int {
if ( class_exists( '\\Jetpack_Options' ) ) {
$id = (int) \Jetpack_Options::get_option( 'id' );
if ( $id > 0 ) {
return $id;
}
}
return (int) get_current_blog_id();
}
/**
* Pre-warm the two initial reads (quota + episodes) server-side via
* rest_do_request so the client can render with data immediately instead
* of waiting on the wpcom-proxy iframe for the first paint. Failures fall
* through silently the JS island falls back to fetch when an entry is
* null or absent.
*
* @return array{quota: array|null, episodes: array|null}
*/
private static function bootstrap_data(): array {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return self::bootstrap_data_wpcom();
}
return self::bootstrap_data_via_proxy();
}
/**
* Atomic / self-hosted path: hit the local Jetpack-side proxy via
* rest_do_request, which forwards to wpcom over HTTPS using the current
* user's Jetpack token.
*
* @return array
*/
private static function bootstrap_data_via_proxy(): array {
$bootstrap = array(
'quota' => null,
'episodes' => self::empty_episodes_envelope(),
);
$quota_request = new \WP_REST_Request( 'GET', '/wpcom/v2/posts-to-podcast' );
$quota_response = rest_do_request( $quota_request );
if ( $quota_response instanceof \WP_REST_Response ) {
if ( ! $quota_response->is_error() ) {
$bootstrap['quota'] = $quota_response->get_data();
} else {
$status = (int) $quota_response->get_status();
if ( 403 === $status || 404 === $status ) {
$bootstrap['quota'] = array( 'notAvailable' => true );
} else {
$bootstrap['quota'] = array(
'quota' => 0,
'used' => 0,
);
}
}
}
$episodes_request = new \WP_REST_Request( 'GET', '/wpcom/v2/posts-to-podcast/episodes' );
$episodes_request->set_param( 'page', 1 );
$episodes_request->set_param( 'per_page', self::EPISODES_PER_PAGE );
$episodes_response = rest_do_request( $episodes_request );
if ( $episodes_response instanceof \WP_REST_Response && ! $episodes_response->is_error() ) {
$bootstrap['episodes'] = self::normalize_episodes_payload( $episodes_response->get_data() );
}
return $bootstrap;
}
/**
* Replicate the wpcom-side endpoint's active-job payload shape so the
* client can resume polling the "Generating…" notice across reloads on
* Simple sites, the same way it does on Atomic through the proxy.
*
* @param int $blog_id Current blog id.
*
* @return array|\stdClass
*/
private static function build_active_job_payload_wpcom( int $blog_id ) {
if ( ! function_exists( 'posts_to_podcast_get_active_job_record' ) ) {
return new \stdClass();
}
$record = posts_to_podcast_get_active_job_record( $blog_id );
if ( null === $record ) {
return new \stdClass();
}
$status_map = array(
'queued' => 'pending',
'succeeded' => 'complete',
'failed' => 'failed',
);
$raw = function_exists( 'get_job_status' ) ? get_job_status( $record['id'] ) : 'queued';
$status = $status_map[ $raw ] ?? 'pending';
$payload = array(
'jobId' => (int) $record['id'],
'status' => $status,
'createdAt' => gmdate( 'c', (int) $record['queued_at'] ),
);
if ( 'complete' === $status && function_exists( 'posts_to_podcast_get_job_result' ) ) {
$post_id = posts_to_podcast_get_job_result( $record['id'] );
if ( null !== $post_id ) {
$payload['postId'] = $post_id;
$payload['editUrl'] = (string) get_edit_post_link( $post_id, 'raw' );
}
}
return $payload;
}
/**
* Replicate the wpcom-side endpoint's upgrade URL builder so the Out of
* credits banner can surface an Upgrade plan CTA even on Simple sites
* (where we don't go through the REST proxy). Returns the Calypso
* checkout URL for the next tier up, or empty when the site is already
* on the top podcast tier.
*
* @param int $blog_id Current blog id.
*
* @return string
*/
private static function build_upgrade_url_wpcom( int $blog_id ): string {
if ( ! class_exists( '\\WPCOM_Features' ) || ! function_exists( 'wpcom_site_has_feature' ) ) {
return '';
}
if ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_3, $blog_id ) ) {
return '';
}
if ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_2, $blog_id ) ) {
$plan = 'business';
} elseif ( wpcom_site_has_feature( \WPCOM_Features::POSTS_TO_PODCAST_TIER_1, $blog_id ) ) {
$plan = 'premium';
} else {
$plan = 'personal';
}
$site_slug = class_exists( '\\WPCOM_Masterbar' )
? \WPCOM_Masterbar::get_calypso_site_slug( $blog_id )
: '';
if ( '' === $site_slug ) {
return '';
}
return sprintf( 'https://wordpress.com/checkout/%s/%s', $site_slug, $plan );
}
/**
* Default empty episodes envelope used while the page is still loading
* data or when an upstream request errors.
*
* @return array
*/
private static function empty_episodes_envelope(): array {
return array(
'items' => array(),
'total' => 0,
'page' => 1,
'perPage' => self::EPISODES_PER_PAGE,
'totalPages' => 0,
);
}
/**
* Accept either the new envelope shape (preferred) or the legacy bare
* array (older sandboxes that haven't shipped the pagination upgrade yet)
* and return the envelope.
*
* @param mixed $payload Upstream response body.
*
* @return array
*/
private static function normalize_episodes_payload( $payload ): array {
if ( is_array( $payload ) && isset( $payload['items'] ) && is_array( $payload['items'] ) ) {
return array(
'items' => array_values( $payload['items'] ),
'total' => isset( $payload['total'] ) ? (int) $payload['total'] : count( $payload['items'] ),
'page' => isset( $payload['page'] ) ? max( 1, (int) $payload['page'] ) : 1,
'perPage' => isset( $payload['perPage'] ) ? max( 1, (int) $payload['perPage'] ) : self::EPISODES_PER_PAGE,
'totalPages' => isset( $payload['totalPages'] ) ? max( 0, (int) $payload['totalPages'] ) : 0,
);
}
if ( is_array( $payload ) ) {
$items = array_values( $payload );
return array(
'items' => $items,
'total' => count( $items ),
'page' => 1,
'perPage' => self::EPISODES_PER_PAGE,
'totalPages' => count( $items ) > 0 ? 1 : 0,
);
}
return self::empty_episodes_envelope();
}
/**
* Simple (wpcom) path: rest_do_request can't reach the posts-to-podcast
* endpoint here the wpcom REST plugin loader gates the endpoint files
* behind REST_API_PLUGINS, which isn't set in admin context. Call the
* underlying wpcom helpers directly. Permissions are still enforced via
* the admin caps required to render this page.
*
* @return array
*/
private static function bootstrap_data_wpcom(): array {
$bootstrap = array(
'quota' => null,
'episodes' => self::empty_episodes_envelope(),
);
if ( ! function_exists( 'require_lib' ) ) {
return $bootstrap;
}
require_lib( 'posts-to-podcast' );
$blog_id = (int) get_current_blog_id();
if ( function_exists( '\\Automattic\\Posts_To_Podcast\\get_usage' ) || function_exists( 'posts_to_podcast_get_usage' ) ) {
$usage = function_exists( 'posts_to_podcast_get_usage' )
? posts_to_podcast_get_usage( $blog_id )
: array();
$bootstrap['quota'] = array(
'quota' => $usage,
'activeJob' => self::build_active_job_payload_wpcom( $blog_id ),
'upgradeUrl' => self::build_upgrade_url_wpcom( $blog_id ),
);
}
$per_page = self::EPISODES_PER_PAGE;
$query = new \WP_Query(
array(
'post_type' => 'post',
'post_status' => array( 'draft', 'publish' ),
'posts_per_page' => $per_page,
'paged' => 1,
'orderby' => 'date',
'order' => 'DESC',
'update_post_term_cache' => false,
'meta_query' => array(
array(
'key' => 'posts_to_podcast_metadata',
'compare' => 'EXISTS',
),
),
)
);
$items = array();
foreach ( $query->posts as $post ) {
$raw_meta = get_post_meta( $post->ID, 'posts_to_podcast_metadata', true );
$meta = is_string( $raw_meta ) ? json_decode( $raw_meta, true ) : null;
$audio = ( is_array( $meta ) && isset( $meta['audio'] ) && is_array( $meta['audio'] ) ) ? $meta['audio'] : array();
$title = wp_strip_all_tags(
html_entity_decode( (string) get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' )
);
if ( '' === trim( $title ) ) {
// translators: Fallback shown in the Generated podcasts list when a draft has an empty title.
$title = __( '(no title)', 'jetpack-podcast' );
}
$items[] = array(
'id' => $post->ID,
'title' => $title,
'status' => $post->post_status,
'date' => mysql2date( 'c', $post->post_date_gmt, false ),
'editUrl' => get_edit_post_link( $post->ID, 'raw' ),
'mediaUrl' => isset( $audio['url'] ) ? esc_url_raw( (string) $audio['url'] ) : '',
'mediaType' => 'audio',
'mediaMime' => isset( $audio['mimeType'] ) ? (string) $audio['mimeType'] : '',
'duration' => isset( $audio['durationSeconds'] ) ? (int) round( (float) $audio['durationSeconds'] ) : 0,
);
}
$total = (int) $query->found_posts;
$bootstrap['episodes'] = array(
'items' => $items,
'total' => $total,
'page' => 1,
'perPage' => $per_page,
'totalPages' => (int) ceil( $total / $per_page ),
);
return $bootstrap;
}
/**
* Render the page chrome and the static form HTML.
*/
public static function render() {
$window = window_presets();
$length = length_presets();
$voice = voice_presets();
?>
<div class="wrap jetpack-create-ai-podcast">
<h1 class="jetpack-create-ai-podcast__page-title">
<?php echo esc_html__( 'Create AI Podcast', 'jetpack-podcast' ); ?>
</h1>
<div id="jetpack-create-ai-podcast-app">
<section class="jetpack-create-ai-podcast__intro" role="region" aria-labelledby="jetpack-create-ai-podcast-intro-title">
<div class="jetpack-create-ai-podcast__intro-body">
<div class="jetpack-create-ai-podcast__intro-badges">
<p class="jetpack-create-ai-podcast__intro-eyebrow">
<span class="jetpack-create-ai-podcast__intro-wpmark" aria-hidden="true"></span>
<span><?php echo esc_html__( 'WordPress.com exclusive', 'jetpack-podcast' ); ?></span>
</p>
<p class="jetpack-create-ai-podcast__intro-eyebrow jetpack-create-ai-podcast__intro-eyebrow--experimental">
<span><?php echo esc_html__( 'Experimental', 'jetpack-podcast' ); ?></span>
</p>
</div>
<h2 id="jetpack-create-ai-podcast-intro-title" class="jetpack-create-ai-podcast__intro-title">
<?php echo esc_html__( 'Turn your posts into a podcast episode', 'jetpack-podcast' ); ?>
</h2>
<p class="jetpack-create-ai-podcast__intro-text">
<?php echo esc_html__( 'Pick a date range or a few specific posts and well generate a two-host conversation, complete with narration and a ready-to-publish draft. Edit, refine, and hit Publish when youre happy.', 'jetpack-podcast' ); ?>
</p>
</div>
<div class="jetpack-create-ai-podcast__intro-art" aria-hidden="true">
<svg viewBox="0 0 64 64" width="80" height="80" xmlns="http://www.w3.org/2000/svg">
<defs>
<linearGradient id="jetpack-create-ai-podcast-grad" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>
<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>
</linearGradient>
</defs>
<circle cx="32" cy="32" r="28" fill="url(#jetpack-create-ai-podcast-grad)"/>
<path fill="#fff" d="M32 14a8 8 0 0 0-8 8v10a8 8 0 0 0 16 0V22a8 8 0 0 0-8-8zm-12 18a1.5 1.5 0 0 1 3 0 9 9 0 0 0 18 0 1.5 1.5 0 0 1 3 0 12 12 0 0 1-10.5 11.9V48h4.5v3h-12v-3H30v-2.1A12 12 0 0 1 20 32z"/>
</svg>
</div>
</section>
<div
class="jetpack-create-ai-podcast__card jetpack-create-ai-podcast__credits"
data-region="credits"
></div>
<form class="jetpack-create-ai-podcast__form" data-region="form">
<section class="jetpack-create-ai-podcast__card">
<h2 class="jetpack-create-ai-podcast__card-title">
<?php echo esc_html__( 'Source', 'jetpack-podcast' ); ?>
</h2>
<div class="jetpack-create-ai-podcast__radio-group" role="radiogroup">
<label class="jetpack-create-ai-podcast__radio">
<input type="radio" name="source" value="window" checked>
<span><?php echo esc_html__( 'From a date range', 'jetpack-podcast' ); ?></span>
</label>
<label class="jetpack-create-ai-podcast__radio">
<input type="radio" name="source" value="posts">
<span><?php echo esc_html__( 'From specific posts', 'jetpack-podcast' ); ?></span>
</label>
</div>
<div class="jetpack-create-ai-podcast__field" data-source="window">
<label for="jetpack-create-ai-podcast-window">
<?php echo esc_html__( 'Date range', 'jetpack-podcast' ); ?>
</label>
<select id="jetpack-create-ai-podcast-window" name="window">
<?php foreach ( $window as $opt ) : ?>
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="jetpack-create-ai-podcast__field" data-source="posts" hidden>
<label for="jetpack-create-ai-podcast-posts-search">
<?php echo esc_html__( 'Search posts', 'jetpack-podcast' ); ?>
</label>
<input
type="search"
id="jetpack-create-ai-podcast-posts-search"
placeholder="<?php echo esc_attr__( 'Type to filter…', 'jetpack-podcast' ); ?>"
>
<p class="jetpack-create-ai-podcast__field-hint">
<?php
echo esc_html(
sprintf(
/* translators: %d: maximum number of posts that can be selected. */
__( 'You can choose up to %d posts.', 'jetpack-podcast' ),
self::MAX_SELECTED_POSTS
)
);
?>
</p>
<div class="jetpack-create-ai-podcast__posts" data-region="posts"></div>
</div>
<div class="jetpack-create-ai-podcast__advanced" aria-labelledby="jetpack-create-ai-podcast-advanced-title">
<div class="jetpack-create-ai-podcast__advanced-header">
<h3 id="jetpack-create-ai-podcast-advanced-title" class="jetpack-create-ai-podcast__advanced-title">
<?php echo esc_html__( 'Customize', 'jetpack-podcast' ); ?>
</h3>
<span class="jetpack-create-ai-podcast__soon-pill">
<?php echo esc_html__( 'Coming soon', 'jetpack-podcast' ); ?>
</span>
</div>
<div class="jetpack-create-ai-podcast__field">
<label for="jetpack-create-ai-podcast-length">
<?php echo esc_html__( 'Length', 'jetpack-podcast' ); ?>
</label>
<select id="jetpack-create-ai-podcast-length" name="length" disabled data-locked-disabled="true">
<?php foreach ( $length as $opt ) : ?>
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="jetpack-create-ai-podcast__field">
<label for="jetpack-create-ai-podcast-voice">
<?php echo esc_html__( 'Voice', 'jetpack-podcast' ); ?>
</label>
<select id="jetpack-create-ai-podcast-voice" name="voice" disabled data-locked-disabled="true">
<?php foreach ( $voice as $opt ) : ?>
<option value="<?php echo esc_attr( $opt['id'] ); ?>"><?php echo esc_html( $opt['label'] ); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="jetpack-create-ai-podcast__field">
<label for="jetpack-create-ai-podcast-prompt">
<?php echo esc_html__( 'Prompt (optional)', 'jetpack-podcast' ); ?>
</label>
<textarea
id="jetpack-create-ai-podcast-prompt"
name="prompt"
rows="3"
disabled
data-locked-disabled="true"
placeholder="<?php echo esc_attr__( 'Steer the tone, framing, or focus of the episode…', 'jetpack-podcast' ); ?>"
></textarea>
</div>
</div>
<div class="jetpack-create-ai-podcast__actions">
<button type="submit" class="button button-primary button-hero">
<?php echo esc_html__( 'Generate', 'jetpack-podcast' ); ?>
</button>
</div>
</section>
</form>
<div class="jetpack-create-ai-podcast__status" aria-live="polite" data-region="status"></div>
<section
class="jetpack-create-ai-podcast__card jetpack-create-ai-podcast__episodes"
data-region="episodes"
aria-busy="false"
>
<h2 class="jetpack-create-ai-podcast__card-title">
<?php echo esc_html__( 'Generated podcasts', 'jetpack-podcast' ); ?>
</h2>
<div class="jetpack-create-ai-podcast__episodes-list" data-region="episodes-list"></div>
</section>
</div>
</div>
<?php
}
}
@@ -0,0 +1,131 @@
<?php
/**
* Server-side prefill for `post-new.php?podcast_episode=1`.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
/**
* Prefills the new-post screen with the configured podcast category and, on
* Premium, an inserted Podcast Episode block.
*/
class New_Episode_Prefill {
const QUERY_VAR = 'podcast_episode';
/**
* ID of the auto-draft we've already handled this request.
*
* @var int
*/
private static $handled_post_id = 0;
/**
* Register hooks.
*/
public static function init() {
add_action( 'admin_init', array( __CLASS__, 'maybe_register_handlers' ) );
}
/**
* Bind on `admin_init` so `$pagenow` is settled.
*/
public static function maybe_register_handlers() {
global $pagenow;
if ( 'post-new.php' !== $pagenow ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( ! isset( $_GET[ self::QUERY_VAR ] ) ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended
if ( '1' !== sanitize_text_field( wp_unslash( $_GET[ self::QUERY_VAR ] ) ) ) {
return;
}
if ( (int) get_option( 'podcasting_category_id', 0 ) <= 0 ) {
return;
}
add_action( 'wp_insert_post', array( __CLASS__, 'assign_category' ), 10, 3 );
if ( Podcast_Gate::has_product_access() ) {
add_filter( 'default_content', array( __CLASS__, 'prefill_block_content' ), 10, 2 );
}
}
/**
* Assign the configured podcast category to the new auto-draft.
*
* Narrowed to the initial auto-draft so user-driven saves later in the
* session aren't re-overridden; self-unhooks so sibling auto-drafts in the
* same request are left alone.
*
* @param int $post_id Post ID.
* @param \WP_Post $post Post object.
* @param bool $update True for updates, false for inserts.
*/
public static function assign_category( $post_id, $post, $update ) {
if ( $update ) {
return;
}
if ( ! ( $post instanceof \WP_Post ) ) {
return;
}
if ( ! self::is_supported_post( $post ) || 'auto-draft' !== $post->post_status ) {
return;
}
if ( ! current_user_can( 'edit_post', $post_id ) ) {
return;
}
$category_id = (int) get_option( 'podcasting_category_id', 0 );
if ( $category_id <= 0 ) {
return;
}
wp_set_post_categories( $post_id, array( $category_id ) );
self::$handled_post_id = (int) $post_id;
remove_action( 'wp_insert_post', array( __CLASS__, 'assign_category' ), 10 );
}
/**
* Inject a Podcast Episode block as the new post's initial content.
*
* No-op if another plugin has already filled `$content`, so this composes
* politely.
*
* @param string $content Default post content.
* @param \WP_Post $post Post object.
* @return string
*/
public static function prefill_block_content( $content, $post ) {
if ( ! self::is_supported_post( $post ) ) {
return $content;
}
if ( self::$handled_post_id > 0 && (int) $post->ID !== self::$handled_post_id ) {
return $content;
}
if ( '' !== trim( (string) $content ) ) {
return $content;
}
remove_filter( 'default_content', array( __CLASS__, 'prefill_block_content' ), 10 );
return "<!-- wp:jetpack/podcast-episode /-->\n";
}
/**
* Whether the post is a core `post` we prefill for.
*
* @param mixed $post Candidate post object.
* @return bool
*/
private static function is_supported_post( $post ) {
return $post instanceof \WP_Post && 'post' === $post->post_type;
}
}
@@ -0,0 +1,177 @@
<?php
/**
* Podcast product-access gate.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Client;
use Automattic\Jetpack\Current_Plan;
use Automattic\Jetpack\Status\Host;
use Jetpack_Options;
/**
* Premium podcast feature gate.
*
* Resolves the paid surfaces (episode dashboard, stats, episode block) two
* ways depending on the host:
*
* - WordPress.com (Simple/WoA): the `podcasting` plan feature via
* `Current_Plan::supports`, plus the launch-day grandfather rule. Reads
* request-scoped state, so callers gating a different blog must
* `switch_to_blog` first.
* - Self-hosted Jetpack: the site's purchased plan over the Jetpack
* connection. Per PODS-123, the Growth (and Complete) plans unlock the paid
* surfaces; everything else is feed-only.
*/
class Podcast_Gate {
const FEATURE_SLUG = 'podcasting';
/**
* Launch-day cutoff for the paying-blog grandfather rule. Paid blogs
* registered before this date keep Premium podcast features without
* needing the `podcasting` plan feature. WordPress.com only.
*/
const GRANDFATHER_CUTOFF_DATE = '2026-05-18';
/**
* Transient holding the cached `/upgrades` response. Short-lived (30s, set
* below): mainly dedupes the lookup across a single page load. A buyer
* returning from checkout busts it outright via `flush_purchases_cache()`,
* so the TTL only bounds the unlikely case where that signal is missed.
*/
const PURCHASES_TRANSIENT = 'jetpack_podcast_site_purchases';
/**
* Request-scoped memo of the purchases lookup (including failures, so a
* failed fetch isn't retried mid-request). Null until first resolved.
*
* @var array|null
*/
private static $purchases_cache = null;
/**
* Whether the current site can use the paid podcast surfaces.
*
* @return bool
*/
public static function has_product_access(): bool {
if ( ! ( new Host() )->is_wpcom_platform() ) {
return self::self_hosted_has_paid_plan();
}
$blog_id = get_current_blog_id();
if ( $blog_id <= 0 ) {
return false;
}
if ( self::is_grandfathered( $blog_id ) ) {
return true;
}
return (bool) Current_Plan::supports( self::FEATURE_SLUG );
}
/**
* Drop the cached purchases lookup so the next access check re-reads
* `/upgrades`. Called when a buyer returns from checkout so a fresh plan
* unlocks the paid surfaces immediately rather than after the TTL.
*/
public static function flush_purchases_cache(): void {
delete_transient( self::PURCHASES_TRANSIENT );
self::$purchases_cache = null;
}
/**
* Whether a self-hosted Jetpack site owns a Growth (or Complete) plan.
*
* Mirrors the bundle-detection pattern used by My Jetpack's Growth/Security
* products: match purchased product slugs rather than the `podcasting`
* feature, which maps to all Jetpack sites on WordPress.com and so can't
* distinguish free from paid here.
*/
private static function self_hosted_has_paid_plan(): bool {
foreach ( self::get_site_current_purchases() as $purchase ) {
$slug = is_array( $purchase ) && isset( $purchase['product_slug'] ) ? $purchase['product_slug'] : '';
// Growth and Complete bundles unlock the paid surfaces; matched as
// prefixes so every billing term/tier counts.
foreach ( array( 'jetpack_growth', 'jetpack_complete' ) as $prefix ) {
if ( is_string( $slug ) && 0 === strpos( $slug, $prefix ) ) {
return true;
}
}
}
return false;
}
/**
* The site's current purchases from WordPress.com (`/upgrades`).
*
* Fails closed: an unreachable or malformed response returns no purchases
* and isn't written to the transient, so the next request retries rather
* than serving a stale empty result.
*
* @return array List of purchase entries (associative arrays); empty on failure.
*/
private static function get_site_current_purchases(): array {
if ( null !== self::$purchases_cache ) {
return self::$purchases_cache;
}
$cached = get_transient( self::PURCHASES_TRANSIENT );
if ( is_array( $cached ) ) {
self::$purchases_cache = $cached;
return self::$purchases_cache;
}
$response = Client::wpcom_json_api_request_as_blog(
sprintf( '/upgrades?site=%d', (int) Jetpack_Options::get_option( 'id' ) ),
'1.2',
array( 'method' => 'GET' )
);
if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
self::$purchases_cache = array();
return self::$purchases_cache;
}
$decoded = json_decode( wp_remote_retrieve_body( $response ), true );
if ( ! is_array( $decoded ) ) {
self::$purchases_cache = array();
return self::$purchases_cache;
}
// 30s: short enough that a plan change shows up quickly even if the
// checkout-return bust is missed, long enough to dedupe a page load.
set_transient( self::PURCHASES_TRANSIENT, $decoded, 30 );
self::$purchases_cache = $decoded;
return self::$purchases_cache;
}
/**
* Whether the blog is grandfathered: registered before the cutoff AND on a paid plan.
*
* @param int $blog_id Blog ID.
*/
protected static function is_grandfathered( int $blog_id ): bool {
if ( ! function_exists( 'get_blog_details' ) ) {
return false;
}
$details = get_blog_details( $blog_id );
if ( ! $details || empty( $details->registered ) ) {
return false;
}
$registered_ts = strtotime( $details->registered );
if ( false === $registered_ts || $registered_ts >= strtotime( self::GRANDFATHER_CUTOFF_DATE ) ) {
return false;
}
$plan = Current_Plan::get();
return ! empty( $plan['class'] ) && 'free' !== $plan['class'];
}
}
@@ -0,0 +1,130 @@
<?php
/**
* Main loader for the Jetpack Podcast package.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
use Automattic\Jetpack\Status\Host;
/**
* Loads Jetpack Podcast on Simple and Atomic sites. The package owns the
* podcasting experience outright.
*/
class Podcast {
const PACKAGE_VERSION = '1.3.0';
/**
* Whether the class has been initialized.
*
* @var bool
*/
private static $initialized = false;
/**
* Initialize the package.
*
* Always loads on Simple and WoA. On self-hosted Jetpack it stays dormant
* unless opted in via the `jetpack_podcast_for_the_world` filter.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
$host = new Host();
/**
* Allow the Podcast package to load on self-hosted Jetpack sites.
*
* @since 1.1.1
*
* @param bool $enabled Whether to load the package on self-hosted. Default false.
*/
$for_the_world = (bool) apply_filters( 'jetpack_podcast_for_the_world', false );
if ( ! $host->is_wpcom_simple() && ! $host->is_woa_site() && ! $for_the_world ) {
return;
}
Podcast_Episode_Block::register_hooks();
Podcast_Stats_Endpoint::init();
Podcast_Distribution_Endpoint::init();
Podcast_Settings_Endpoint::init();
Settings::register();
// Wire the RSS feed customizations (`<itunes:*>` + `<podcast:*>` tags,
// stats-tracked enclosure URLs) for the configured podcast category.
Customize_Feed::init();
Tracks::init();
if ( is_admin() ) {
Admin_Page::init();
New_Episode_Prefill::init();
}
if ( $host->is_wpcom_simple() || $host->is_woa_site() ) {
// Register the local REST routes before request-local rollout gates.
// Requests from public-api.wordpress.com may not satisfy those gates,
// but the wpcom/v2 routes still need to exist so permission and
// callback checks can handle the request.
Posts_To_Podcast_Endpoint::init();
// Posts to Podcast lives behind its own filter so the Create AI
// Podcast page can ship independently of the rest of the package.
//
// Note: no `is_admin()` guard here. The submenu must also register when
// Calypso builds its nav via the `wpcom/v2/admin-menu` REST endpoint,
// which fires `admin_menu` by loading `wp-admin/menu.php` but runs as a
// REST request where `is_admin()` is false. The hooks `init()` wires
// (`admin_menu`, `enqueue_block_editor_assets`) self-gate, so this is a
// no-op on non-admin/non-editor requests.
if ( self::is_posts_to_podcast_enabled() ) {
Create_AI_Podcast_Page::init();
}
}
}
/**
* Whether the Posts to Podcast feature (Create AI Podcast page + REST
* proxy) is enabled for the current request.
*
* Defaults to true for connected WordPress.com users, and can be flipped
* globally via the `jetpack_posts_to_podcast` filter.
*/
public static function is_posts_to_podcast_enabled() {
/**
* Master switch for the Posts to Podcast (Create AI Podcast) feature.
*
* @since 0.1.0
*
* @param bool $enabled Whether to enable Posts to Podcast.
*/
$enabled = self::is_user_connected( get_current_user_id() );
return (bool) apply_filters( 'jetpack_posts_to_podcast', $enabled );
}
/**
* Whether a user is connected to WordPress.com.
*
* @param int $user_id User ID.
* @return bool
*/
private static function is_user_connected( $user_id ) {
if ( defined( 'IS_WPCOM' ) && IS_WPCOM ) {
return true;
}
return ( new Connection_Manager( 'jetpack' ) )->is_user_connected( $user_id );
}
}
@@ -0,0 +1,437 @@
<?php
/**
* Podcast settings: option schema, sanitizers, and Jetpack Sync opt-in.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
/**
* Registers the `podcasting_*` options with their `sanitize_callback`s and
* `show_in_rest` so they keep appearing in core `/wp/v2/settings`. The dashboard
* now reads and writes them through the dedicated {@see Podcast_Settings_Endpoint}
* (`wpcom/v2/podcast/settings`); the core exposure stays for now and is removed
* in a follow-up once WPCOM's settings-controller test is decoupled.
*
* Array-shaped options merge against stored values on sanitize, not replace
* the SPA can PATCH partial entries without losing the rest.
*/
class Settings {
/**
* Per-podcatcher hostname allowlist for `podcasting_show_urls`. `www.` is
* stripped before comparison.
*
* @var array<string, string[]>
*/
const SHOW_URL_HOSTS = array(
'pocketcasts' => array( 'pca.st', 'pocketcasts.com' ),
'apple' => array( 'podcasts.apple.com' ),
'spotify' => array( 'open.spotify.com' ),
'youtube' => array( 'youtube.com', 'm.youtube.com', 'youtu.be', 'music.youtube.com' ),
'amazon' => array(
'music.amazon.com',
'music.amazon.co.uk',
'music.amazon.de',
'music.amazon.co.jp',
'music.amazon.com.au',
'music.amazon.fr',
'music.amazon.ca',
'music.amazon.es',
),
'podcastindex' => array( 'podcastindex.org' ),
);
const SHOW_URL_MAX_LENGTH = 2048;
/**
* Drives `register_settings()` and the sync whitelist.
*
* @var string[]
*/
const OPTION_NAMES = array(
'podcasting_category_id',
'podcasting_title',
'podcasting_talent_name',
'podcasting_summary',
'podcasting_copyright',
'podcasting_explicit',
'podcasting_image',
'podcasting_image_id',
'podcasting_category_1',
'podcasting_category_2',
'podcasting_category_3',
'podcasting_email',
'podcasting_show_urls',
'podcasting_show_states',
);
/**
* Whether `register()` has wired its hooks.
*
* @var bool
*/
private static $registered = false;
/**
* Wire option registrations + Jetpack Sync opt-in. Idempotent.
*/
public static function register() {
if ( self::$registered ) {
return;
}
self::$registered = true;
add_action( 'admin_init', array( __CLASS__, 'register_settings' ) );
add_action( 'rest_api_init', array( __CLASS__, 'register_settings' ) );
add_filter(
'jetpack_sync_options_whitelist',
static function ( $options ) {
return array_merge( $options, self::OPTION_NAMES );
}
);
}
/**
* `register_setting()` calls. Hooked on `admin_init` and `rest_api_init`.
*/
public static function register_settings() {
$media_settings = array(
array( 'podcasting_category_id', 'integer', 0, 'absint' ),
array( 'podcasting_title', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_talent_name', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_summary', 'string', '', 'sanitize_textarea_field' ),
array( 'podcasting_copyright', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_category_1', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_category_2', 'string', '', 'sanitize_text_field' ),
array( 'podcasting_category_3', 'string', '', 'sanitize_text_field' ),
);
// Registered under WP core's `media` group to match WPCOM's legacy Media
// Settings form, so it keeps accepting these.
foreach ( $media_settings as list( $name, $type, $default, $sanitize ) ) {
register_setting(
'media',
$name,
array(
'type' => $type,
'default' => $default,
'sanitize_callback' => $sanitize,
'show_in_rest' => true,
)
);
}
register_setting(
'media',
'podcasting_image',
array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'esc_url_raw',
'show_in_rest' => array(
'schema' => array(
'type' => 'string',
'default' => '',
'format' => 'uri',
),
),
)
);
register_setting(
'media',
'podcasting_explicit',
array(
'type' => 'boolean',
'default' => false,
'sanitize_callback' => array( __CLASS__, 'sanitize_explicit' ),
'show_in_rest' => true,
)
);
// Registered under WP core's `options` group: REST-only settings that
// WPCOM never wired into the Settings API.
register_setting(
'options',
'podcasting_email',
array(
'type' => 'string',
'default' => '',
'sanitize_callback' => 'sanitize_email',
'show_in_rest' => true,
)
);
register_setting(
'options',
'podcasting_image_id',
array(
'type' => 'integer',
'default' => 0,
'sanitize_callback' => 'absint',
'show_in_rest' => true,
)
);
$podcatcher_keys = array_keys( self::SHOW_URL_HOSTS );
$empty_map = array_fill_keys( $podcatcher_keys, '' );
register_setting(
'options',
'podcasting_show_urls',
array(
'type' => 'object',
'default' => array(),
'sanitize_callback' => array( __CLASS__, 'sanitize_show_urls' ),
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'default' => $empty_map,
'properties' => array_fill_keys(
$podcatcher_keys,
array(
'type' => 'string',
'format' => 'uri',
'maxLength' => self::SHOW_URL_MAX_LENGTH,
)
),
),
),
)
);
register_setting(
'options',
'podcasting_show_states',
array(
'type' => 'object',
'default' => array(),
'sanitize_callback' => array( __CLASS__, 'sanitize_show_states' ),
'show_in_rest' => array(
'schema' => array(
'type' => 'object',
'default' => $empty_map,
'properties' => array_fill_keys(
$podcatcher_keys,
array(
'type' => 'string',
'enum' => array( '', 'pending', 'active' ),
)
),
),
),
)
);
}
/**
* Stable, fully-padded settings payload for the REST endpoint. Every
* `OPTION_NAMES` key is present; the two podcatcher maps are padded to all
* known directories with empty strings so the SPA always sees a fixed shape.
*
* @return array<string, mixed>
*/
public static function get_all(): array {
$empty_map = array_fill_keys( array_keys( self::SHOW_URL_HOSTS ), '' );
$show_urls = (array) get_option( 'podcasting_show_urls', array() );
$show_states = (array) get_option( 'podcasting_show_states', array() );
return array(
'podcasting_category_id' => (int) get_option( 'podcasting_category_id', 0 ),
'podcasting_title' => (string) get_option( 'podcasting_title', '' ),
'podcasting_talent_name' => (string) get_option( 'podcasting_talent_name', '' ),
'podcasting_summary' => (string) get_option( 'podcasting_summary', '' ),
'podcasting_copyright' => (string) get_option( 'podcasting_copyright', '' ),
'podcasting_explicit' => self::sanitize_explicit( get_option( 'podcasting_explicit', false ) ),
'podcasting_image' => self::raw_show_image_url(),
'podcasting_image_id' => (int) get_option( 'podcasting_image_id', 0 ),
'podcasting_category_1' => (string) get_option( 'podcasting_category_1', '' ),
'podcasting_category_2' => (string) get_option( 'podcasting_category_2', '' ),
'podcasting_category_3' => (string) get_option( 'podcasting_category_3', '' ),
'podcasting_email' => (string) get_option( 'podcasting_email', '' ),
'podcasting_show_urls' => array_merge( $empty_map, array_intersect_key( $show_urls, $empty_map ) ),
'podcasting_show_states' => array_merge( $empty_map, array_intersect_key( $show_states, $empty_map ) ),
);
}
/**
* Per-key type map for the endpoint's update args. Type coercion only the
* registered `sanitize_callback`s do the real validation on write, so a single
* bad field can't 400 the whole partial patch.
*
* @return array<string, array<string, mixed>>
*/
public static function rest_schema_properties(): array {
return array(
'podcasting_category_id' => array( 'type' => 'integer' ),
'podcasting_title' => array( 'type' => 'string' ),
'podcasting_talent_name' => array( 'type' => 'string' ),
'podcasting_summary' => array( 'type' => 'string' ),
'podcasting_copyright' => array( 'type' => 'string' ),
'podcasting_explicit' => array( 'type' => array( 'boolean', 'string' ) ),
'podcasting_image' => array( 'type' => 'string' ),
'podcasting_image_id' => array( 'type' => 'integer' ),
'podcasting_category_1' => array( 'type' => 'string' ),
'podcasting_category_2' => array( 'type' => 'string' ),
'podcasting_category_3' => array( 'type' => 'string' ),
'podcasting_email' => array( 'type' => 'string' ),
'podcasting_show_urls' => array( 'type' => 'object' ),
'podcasting_show_states' => array( 'type' => 'object' ),
);
}
/**
* Show cover image URL: `podcasting_image_id` resolved to its attachment
* URL when it points at an image, otherwise the raw `podcasting_image`
* option. Never Photon-routed feed rendering applies its own resize.
*
* @return string Image URL, or '' when not configured.
*/
public static function raw_show_image_url(): string {
$image_id = (int) get_option( 'podcasting_image_id', 0 );
if ( $image_id > 0 && wp_attachment_is_image( $image_id ) ) {
$url = wp_get_attachment_url( $image_id );
if ( false !== $url ) {
return $url;
}
}
return (string) get_option( 'podcasting_image', '' );
}
/**
* `'yes'` (any case) or boolean true true; everything else false. The
* feed only emits true/false; the legacy `'clean'` value collapses to false
* because the WPCOM feed builder already treats it that way.
*
* @param mixed $value Raw input.
* @return bool
*/
public static function sanitize_explicit( $value ) {
if ( is_string( $value ) ) {
return in_array( strtolower( $value ), array( 'yes', 'true', '1' ), true );
}
return true === $value || 1 === $value;
}
/**
* Merge a partial show-URLs patch into the stored value. Empty string for a
* known key removes that entry; URLs failing the per-podcatcher hostname
* allowlist are silently dropped (the SPA validates the same allowlist).
*
* @param mixed $input Incoming patch.
* @return array<string, string>
*/
public static function sanitize_show_urls( $input ) {
$current = array_filter(
array_intersect_key( (array) get_option( 'podcasting_show_urls', array() ), self::SHOW_URL_HOSTS ),
static function ( $value ) {
return is_string( $value ) && '' !== $value;
}
);
if ( ! is_array( $input ) ) {
return $current;
}
foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
$value = is_string( $value ) ? trim( $value ) : '';
if ( '' === $value ) {
unset( $current[ $key ] );
continue;
}
$cleaned = self::sanitize_show_url( $key, $value );
if ( null !== $cleaned ) {
$current[ $key ] = $cleaned;
}
}
return $current;
}
/**
* Merge a partial show-states patch into the stored value. Values outside
* the allowed `'pending'`/`'active'` set are dropped; empty string clears a
* stored entry. `'active'` `'pending'` is
* refused so a stale SPA cache can't downgrade a state that `Feed_Detection`
* promoted via real UA evidence (explicit `''` clears still work).
*
* @param mixed $input Incoming patch.
* @return array<string, string>
*/
public static function sanitize_show_states( $input ) {
$current = array_filter(
array_intersect_key( (array) get_option( 'podcasting_show_states', array() ), self::SHOW_URL_HOSTS ),
static function ( $value ) {
return is_string( $value ) && '' !== $value;
}
);
if ( ! is_array( $input ) ) {
return $current;
}
foreach ( array_intersect_key( $input, self::SHOW_URL_HOSTS ) as $key => $value ) {
$value = is_string( $value ) ? trim( $value ) : '';
if ( '' === $value ) {
unset( $current[ $key ] );
continue;
}
if ( ! in_array( $value, array( 'pending', 'active' ), true ) ) {
continue;
}
if ( 'pending' === $value && isset( $current[ $key ] ) && 'active' === $current[ $key ] ) {
continue;
}
$current[ $key ] = $value;
}
return $current;
}
/**
* Validate a URL against the per-podcatcher hostname allowlist.
*
* @param string $key Podcatcher key.
* @param string $url Candidate URL.
* @return string|null Cleaned URL, or null if the host isn't in the allowlist.
*/
private static function sanitize_show_url( $key, $url ) {
if ( ! isset( self::SHOW_URL_HOSTS[ $key ] ) ) {
return null;
}
if ( ! is_string( $url ) || strlen( $url ) > self::SHOW_URL_MAX_LENGTH ) {
return null;
}
$cleaned = esc_url_raw( $url, array( 'https' ) );
if ( '' === $cleaned ) {
return null;
}
if ( ! wp_http_validate_url( $cleaned ) ) {
return null;
}
$host = wp_parse_url( $cleaned, PHP_URL_HOST );
if ( ! is_string( $host ) || '' === $host ) {
return null;
}
$host = strtolower( $host );
if ( 0 === strpos( $host, 'www.' ) ) {
$host = substr( $host, 4 );
}
return in_array( $host, self::SHOW_URL_HOSTS[ $key ], true ) ? $cleaned : null;
}
}
@@ -0,0 +1,410 @@
<?php
/**
* Tracks instrumentation for Jetpack Podcast.
*
* @package automattic/jetpack-podcast
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Podcast\Feed\Customize_Feed;
use Throwable;
use WP_Post;
use WP_Query;
use WP_User;
/**
* Records podcast lifecycle events. Event names stay `wpcom_*` so analytics
* queries cover Simple, Atomic, and self-hosted Jetpack feeds without a rewrite.
* Dispatch tries `tracks_record_event` (Simple) and falls back to
* `\Automattic\Jetpack\Tracking::tracks_record_event` (Atomic). Neither is a
* hard dep silently no-ops when neither is reachable.
*/
class Tracks {
/**
* Wire the recorder hooks.
*/
public static function init(): void {
// `wp_after_insert_post` runs after terms + meta are saved — required
// because Gutenberg/REST publishes set terms after `transition_post_status`.
add_action( 'wp_after_insert_post', array( __CLASS__, 'record_episode_published' ), 10, 4 );
add_action( 'add_attachment', array( __CLASS__, 'record_media_uploaded' ) );
add_action( 'add_option_podcasting_category_id', array( __CLASS__, 'record_category_added' ), 10, 2 );
add_action( 'update_option_podcasting_category_id', array( __CLASS__, 'record_category_updated' ), 10, 3 );
add_action( 'add_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_added' ), 10, 2 );
add_action( 'update_option_podcasting_show_urls', array( __CLASS__, 'record_show_url_updated' ), 10, 3 );
add_action( 'jetpack_podcast_settings_saved', array( __CLASS__, 'record_settings_saved' ) );
}
/**
* Emit `wpcom_podcast_episode_published` (and `wpcom_podcast_show_launched`
* once per site) when a podcast-category post enters `publish`.
*
* @param int $post_id Post ID.
* @param WP_Post|null $post Post object.
* @param bool $update Whether this is an update.
* @param WP_Post|null $post_before Previous post state.
*/
public static function record_episode_published( $post_id, $post, $update, $post_before ): void {
unset( $post_id, $update );
try {
if ( ! $post instanceof WP_Post ) {
return;
}
if ( 'publish' !== $post->post_status ) {
return;
}
if ( $post_before instanceof WP_Post && 'publish' === $post_before->post_status ) {
return;
}
if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
return;
}
// @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
if ( function_exists( 'is_headstart_post' ) && is_headstart_post( $post ) ) {
return;
}
if ( in_array( $post->post_type, array( 'attachment', 'revision', 'nav_menu_item' ), true ) ) {
return;
}
$category_id = Customize_Feed::resolve_category_id();
if ( 0 === $category_id ) {
return;
}
if ( ! in_category( $category_id, $post ) ) {
return;
}
// Match the RSS feed's definition of an episode — must carry
// audio, not just sit in the podcast category.
if ( ! self::has_podcast_media( $post ) ) {
return;
}
$is_first = self::is_first_episode_for_site( $category_id, (int) $post->ID );
self::record_event(
'wpcom_podcast_episode_published',
array(
'post_id' => (int) $post->ID,
'is_first_episode_for_site' => $is_first,
),
self::identity_for_post( $post )
);
// Atomic INSERT — only one concurrent caller per site wins, so
// `show_launched` fires exactly once per site.
if ( $is_first && add_option( 'podcast_show_launched_tracked', time(), '', false ) ) {
self::record_event(
'wpcom_podcast_show_launched',
array( 'post_id' => (int) $post->ID ),
self::identity_for_post( $post )
);
}
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Tracks is best-effort — never break a publish.
}
}
/**
* Emit `wpcom_podcast_media_uploaded` for audio/video attachments on a
* podcasting-enabled site.
*
* @param int $attachment_id Attachment post ID.
*/
public static function record_media_uploaded( $attachment_id ): void {
try {
if ( defined( 'WP_IMPORTING' ) && WP_IMPORTING ) {
return;
}
$attachment = get_post( (int) $attachment_id );
// @phan-suppress-next-line PhanUndeclaredFunction -- wpcom Simple-only; guarded above.
if ( $attachment && function_exists( 'is_headstart_post' ) && is_headstart_post( $attachment ) ) {
return;
}
if ( 0 === Customize_Feed::resolve_category_id() ) {
return;
}
$mime_type = (string) get_post_mime_type( (int) $attachment_id );
if ( '' === $mime_type ) {
return;
}
if ( 0 !== strpos( $mime_type, 'audio/' ) && 0 !== strpos( $mime_type, 'video/' ) ) {
return;
}
self::record_event(
'wpcom_podcast_media_uploaded',
array(
'attachment_id' => (int) $attachment_id,
'mime_type' => $mime_type,
)
);
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Tracks is best-effort.
}
}
/**
* `add_option_podcasting_category_id` callback first-ever write of the
* option (previous value treated as 0).
*
* @param string $option Option name.
* @param mixed $value Newly stored value.
*/
public static function record_category_added( $option, $value ): void {
unset( $option );
try {
self::maybe_record_status_change( 0, (int) $value );
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Tracks is best-effort.
}
}
/**
* `update_option_podcasting_category_id` callback every change to an
* existing row.
*
* @param mixed $old_value Previous stored value.
* @param mixed $value Newly stored value.
* @param string $option Option name.
*/
public static function record_category_updated( $old_value, $value, $option ): void {
unset( $option );
try {
self::maybe_record_status_change( (int) $old_value, (int) $value );
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Tracks is best-effort.
}
}
/**
* `add_option_podcasting_show_urls` callback. No prior row exists, so
* every entry is a first-time entry.
*
* @param string $option Option name.
* @param mixed $new_value Newly stored value.
*/
public static function record_show_url_added( $option, $new_value ): void {
unset( $option );
self::maybe_record_show_url_addition( array(), $new_value );
}
/**
* `update_option_podcasting_show_urls` callback. Compare the new value
* against the prior array to find the first directory that transitioned
* from absent/empty to a non-empty URL.
*
* @param mixed $old_value Previous stored value (expected: array).
* @param mixed $new_value Newly stored value.
* @param string $option Option name.
*/
public static function record_show_url_updated( $old_value, $new_value, $option ): void {
unset( $option );
self::maybe_record_show_url_addition( is_array( $old_value ) ? $old_value : array(), $new_value );
}
/**
* Emit `wpcom_podcasting_show_url_saved` for the first podcatcher key
* that transitions from absent/empty to a non-empty string.
*
* @param array $old_value Previous map of directory => url.
* @param mixed $new_value Newly stored value.
*/
private static function maybe_record_show_url_addition( array $old_value, $new_value ): void {
try {
if ( ! is_array( $new_value ) ) {
return;
}
foreach ( $new_value as $app => $url ) {
if ( ! is_string( $url ) || '' === $url ) {
continue;
}
$previous = isset( $old_value[ $app ] ) && is_string( $old_value[ $app ] ) ? $old_value[ $app ] : '';
if ( '' !== $previous ) {
continue;
}
self::record_event(
'wpcom_podcasting_show_url_saved',
array( 'app' => (string) $app )
);
return;
}
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Tracks is best-effort.
}
}
/**
* Emit `wpcom_podcasting_settings_saved` after a podcast settings write.
*
* Fired off the `jetpack_podcast_settings_saved` action that
* {@see Podcast_Settings_Endpoint::update_item()} triggers, so it's agnostic
* to the REST transport the endpoint already gates on a saved option.
*/
public static function record_settings_saved(): void {
try {
// Skip user-supplied free-text fields — keep PII out of tracks.
$pii = array( 'podcasting_email', 'podcasting_talent_name' );
$state = array();
foreach ( Settings::OPTION_NAMES as $name ) {
if ( in_array( $name, $pii, true ) ) {
continue;
}
$state[ $name ] = get_option( $name, '' );
}
self::record_event( 'wpcom_podcasting_settings_saved', $state );
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Tracks is best-effort.
}
}
/**
* Emit `wpcom_podcasting_status_changed` (enabled / disabled / changed)
* when the `podcasting_category_id` option transitions.
*
* @param int $old_value Previous category ID (0 == disabled).
* @param int $new_value New category ID (0 == disabled).
*/
private static function maybe_record_status_change( int $old_value, int $new_value ): void {
if ( $old_value === $new_value ) {
return;
}
if ( 0 === $old_value && 0 !== $new_value ) {
$status = 'enabled';
} elseif ( 0 !== $old_value && 0 === $new_value ) {
$status = 'disabled';
} else {
$status = 'changed';
}
// `WPCOM_Store_API` on Simple, `Current_Plan` on Atomic — same dual
// pattern as `Masterbar\Dashboard_Switcher_Tracking::get_plan()`.
$plan = class_exists( '\WPCOM_Store_API' )
? \WPCOM_Store_API::get_current_plan( (int) get_current_blog_id() )
: ( class_exists( '\Automattic\Jetpack\Current_Plan' ) ? \Automattic\Jetpack\Current_Plan::get() : array() );
self::record_event(
'wpcom_podcasting_status_changed',
array(
'status' => $status,
'surface' => 'option_write',
'previous_category_id' => $old_value,
'new_category_id' => $new_value,
'user_id' => (int) get_current_user_id(),
'product_slug' => (string) ( $plan['product_slug'] ?? '' ),
)
);
/** This action is documented in projects/packages/forms/src/contact-form/class-util.php */
do_action( 'jetpack_bump_stats_extras', 'wpcom-podcasting-status', $status );
}
/**
* Identity for the publish event. Scheduled/cron publishes have no
* logged-in user fall back to the post author.
*
* @param WP_Post $post Post being published.
*/
private static function identity_for_post( WP_Post $post ): WP_User {
if ( ! empty( $post->post_author ) ) {
$user = get_userdata( (int) $post->post_author );
if ( $user instanceof WP_User ) {
return $user;
}
}
return wp_get_current_user();
}
/**
* Filters out posts in the podcast category that aren't actually episodes.
* `core/audio` block + classic-editor attached audio cover the supported
* authoring paths.
*
* @param WP_Post $post Post being checked.
*/
private static function has_podcast_media( WP_Post $post ): bool {
return has_block( 'core/audio', $post )
|| ! empty( get_attached_media( 'audio', $post->ID ) );
}
/**
* True when no other published post exists in the podcast category.
*
* @param int $category_id Configured podcast category ID.
* @param int $current_post_id Post being published (excluded from the check).
*/
private static function is_first_episode_for_site( int $category_id, int $current_post_id ): bool {
$existing = new WP_Query(
array(
'post_status' => 'publish',
'post_type' => 'post',
'cat' => $category_id,
'post__not_in' => array( $current_post_id ),
'posts_per_page' => 1,
'fields' => 'ids',
'no_found_rows' => true,
'suppress_filters' => true,
)
);
return empty( $existing->posts );
}
/**
* Dispatch a tracks event. Auto-injects `blog_id` and defaults `$user`
* to the current user.
*
* @param string $event_name Tracks event name.
* @param array $properties Event properties.
* @param WP_User|null $user Identity override; defaults to current user.
* @return mixed
*/
private static function record_event( string $event_name, array $properties, ?WP_User $user = null ) {
try {
$user = $user ?? wp_get_current_user();
$properties['blog_id'] = (int) Connection_Manager::get_site_id( true );
if ( ! function_exists( 'tracks_record_event' ) && function_exists( 'require_lib' ) ) {
require_lib( 'tracks/client' );
}
if ( function_exists( 'tracks_record_event' ) ) {
return tracks_record_event( $user, $event_name, $properties );
}
if ( class_exists( '\Automattic\Jetpack\Tracking' ) ) {
return ( new \Automattic\Jetpack\Tracking() )->tracks_record_event( $user, $event_name, $properties );
}
} catch ( Throwable $e ) { // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch
// Tracks is best-effort.
}
return null;
}
}
@@ -0,0 +1,110 @@
<?php
/**
* Local Jetpack-side REST proxy for podcast distribution submissions.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Client;
use Jetpack_Options;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Response;
use WP_REST_Server;
/**
* Forwards podcast distribution `wp.apiFetch` calls from the dashboard SPA to
* the wpcom-side podcast-distribution endpoints. Same rationale as
* Podcast_Stats_Endpoint: wpcom-proxy-request can't authenticate from Atomic
* origins, so we proxy server-to-server with the user's token.
*/
class Podcast_Distribution_Endpoint extends WP_REST_Controller {
use Relay_Response;
/**
* Wire up routes.
*/
public static function init() {
add_action( 'rest_api_init', array( self::class, 'register' ) );
}
/**
* Registers the REST routes on the `rest_api_init` hook.
*
* Instantiated here, rather than eagerly, so the endpoint class only loads
* on requests that reach `rest_api_init`. Static so the callback can be
* unregistered.
*/
public static function register() {
( new self() )->register_routes();
}
/**
* Register the Pocket Casts submit proxy route.
*/
public function register_routes() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'podcast-distribution';
register_rest_route(
$this->namespace,
$this->rest_base . '/pocket-casts/submit',
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'submit_pocket_casts' ),
'permission_callback' => array( $this, 'permission_check' ),
)
);
}
/**
* Permission callback. Submitting a feed mutates the site's distribution
* state, so require the same edit permission the dashboard SPA itself uses.
*
* @return true|WP_Error
*/
public function permission_check() {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to submit podcasts for this site.', 'jetpack-podcast' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* POST /wpcom/v2/podcast-distribution/pocket-casts/submit forward to wpcom.
*
* @return WP_REST_Response|WP_Error
*/
public function submit_pocket_casts() {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error(
'site-not-connected',
__( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ),
array( 'status' => 400 )
);
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/podcast-distribution/pocket-casts/submit', $blog_id ),
'2',
array(
// Pocket Casts relay can be slow; keep a generous timeout.
'method' => 'POST',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 30,
),
null,
'wpcom'
);
return $this->relay_response( $response );
}
}
@@ -0,0 +1,133 @@
<?php
/**
* Dedicated REST endpoint for `podcasting_*` site settings.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Reads and writes the `podcasting_*` options for the dashboard SPA over its own
* `wpcom/v2/podcast/settings` route. Schema and sanitizers live in {@see Settings}.
*
* Registered through the WPCOM REST API v2 plugin framework
* ({@see wpcom_rest_api_v2_load_plugin()}), so a single definition is reachable on
* Simple and WoA via the `public-api.wordpress.com` proxy and, once Podcast ships in
* the Jetpack plugin, same-origin on self-hosted sites no per-platform relay.
*/
class Podcast_Settings_Endpoint extends WP_REST_Controller {
/**
* Wire the routes onto `rest_api_init`. The framework instantiates this once.
*/
public function __construct() {
$this->namespace = 'wpcom/v2';
add_action( 'rest_api_init', array( $this, 'register_routes' ) );
}
/**
* Register the endpoint through the WPCOM REST API v2 framework. The loader
* ships with the Jetpack plugin core-api, present in every context Podcast runs
* in (Simple/WoA today, the Jetpack plugin once Podcast moves there); guarded so
* the package no-ops rather than fatals if it's somehow absent.
*/
public static function init() {
if ( function_exists( 'wpcom_rest_api_v2_load_plugin' ) ) {
wpcom_rest_api_v2_load_plugin( self::class );
}
}
/**
* Register the GET (full record) + writable (partial patch) routes.
*
* Update args only coerce top-level types the registered `sanitize_callback`s
* do the real validation on write, so one bad field can't 400 the whole patch.
*/
public function register_routes() {
register_rest_route(
$this->namespace,
'podcast/settings',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'get_item' ),
'permission_callback' => array( $this, 'permission_check' ),
),
array(
'methods' => WP_REST_Server::EDITABLE,
'callback' => array( $this, 'update_item' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => Settings::rest_schema_properties(),
),
)
);
}
/**
* Site admins only same gate as the wp-admin Podcast dashboard.
*
* @return true|WP_Error
*/
public function permission_check() {
if ( ! current_user_can( 'manage_options' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to manage podcast settings for this site.', 'jetpack-podcast' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* GET the full, padded settings record.
*
* @param WP_REST_Request $request Unused.
* @return WP_REST_Response
*/
public function get_item( $request ) {
unset( $request );
return rest_ensure_response( Settings::get_all() );
}
/**
* PUT/POST/PATCH apply a partial patch and return the full merged record.
*
* Only keys actually present in the request are written, so absent keys can
* never clobber stored values. Array-shaped options merge on sanitize.
*
* @param WP_REST_Request $request Incoming request.
* @return WP_REST_Response
*/
public function update_item( $request ) {
$saved = false;
foreach ( Settings::OPTION_NAMES as $name ) {
$value = $request->get_param( $name );
if ( null === $value ) {
continue;
}
if ( update_option( $name, $value ) ) {
$saved = true;
}
}
if ( $saved ) {
/**
* Fires after a podcast settings write changes at least one option.
*
* @since 1.1.1
*/
do_action( 'jetpack_podcast_settings_saved' );
}
return rest_ensure_response( Settings::get_all() );
}
}
@@ -0,0 +1,271 @@
<?php
/**
* Local Jetpack-side REST proxy for podcast stats.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Client;
use Jetpack_Options;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Forwards podcast stats `wp.apiFetch` calls from the dashboard SPA to the
* wpcom-side podcast-stats endpoints. Browser-side wpcom-proxy-request only
* authenticates from wpcom origins, so Atomic admin (which is the whole point
* of this proxy) needs the request to go server-to-server with the blog
* token. Simple sites pass through `Client::wpcom_json_api_request_as_user`
* via the in-process IS_WPCOM short-circuit, so the round-trip cost is negligible.
*/
class Podcast_Stats_Endpoint extends WP_REST_Controller {
use Relay_Response;
/**
* Wire up routes.
*/
public static function init() {
add_action( 'rest_api_init', array( self::class, 'register' ) );
}
/**
* Registers the REST routes on the `rest_api_init` hook.
*
* Instantiated here, rather than eagerly, so the endpoint class only loads
* on requests that reach `rest_api_init`. Static so the callback can be
* unregistered.
*/
public static function register() {
( new self() )->register_routes();
}
/**
* Register the four stats proxy routes the dashboard SPA consumes.
*/
public function register_routes() {
$this->namespace = 'wpcom/v2';
$this->rest_base = 'podcast-stats';
// Period summary (top apps/countries/episodes for a date range).
register_rest_route(
$this->namespace,
$this->rest_base,
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_summary' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'from' => array(
'type' => 'string',
'required' => false,
),
'to' => array(
'type' => 'string',
'required' => false,
),
'limit' => array(
'type' => 'integer',
'required' => false,
),
),
)
);
// Period-independent overview (all-time totals, preset windows, top day).
register_rest_route(
$this->namespace,
$this->rest_base . '/overview',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_overview' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'limit' => array(
'type' => 'integer',
'required' => false,
),
),
)
);
// Plays + duration for a batch of episode post IDs.
register_rest_route(
$this->namespace,
$this->rest_base . '/episode-totals',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_episode_totals' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'post_ids' => array(
'type' => 'string',
'required' => true,
),
),
)
);
// Per-episode detail stats for a date range.
register_rest_route(
$this->namespace,
$this->rest_base . '/episode/(?P<post_id>\d+)',
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_episode_detail' ),
'permission_callback' => array( $this, 'permission_check' ),
'args' => array(
'post_id' => array(
'type' => 'integer',
'required' => true,
),
'from' => array(
'type' => 'string',
'required' => false,
),
'to' => array(
'type' => 'string',
'required' => false,
),
),
)
);
}
/**
* Permission callback. Stats are surfaced in the dashboard SPA which is
* limited to users who can edit the site's posts.
*
* @return true|WP_Error
*/
public function permission_check() {
if ( ! current_user_can( 'edit_posts' ) ) {
return new WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to view podcast stats for this site.', 'jetpack-podcast' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
/**
* GET /wpcom/v2/podcast-stats period summary.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_summary( WP_REST_Request $request ) {
return $this->forward(
'podcast-stats',
array(
'from' => $request->get_param( 'from' ),
'to' => $request->get_param( 'to' ),
'limit' => $request->get_param( 'limit' ),
)
);
}
/**
* GET /wpcom/v2/podcast-stats/overview period-independent overview.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_overview( WP_REST_Request $request ) {
return $this->forward(
'podcast-stats/overview',
array(
'limit' => $request->get_param( 'limit' ),
)
);
}
/**
* GET /wpcom/v2/podcast-stats/episode-totals plays + duration per episode.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_episode_totals( WP_REST_Request $request ) {
return $this->forward(
'podcast-stats/episode-totals',
array(
'post_ids' => $request->get_param( 'post_ids' ),
)
);
}
/**
* GET /wpcom/v2/podcast-stats/episode/{post_id} per-episode detail.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_episode_detail( WP_REST_Request $request ) {
$post_id = (int) $request['post_id'];
return $this->forward(
sprintf( 'podcast-stats/episode/%d', $post_id ),
array(
'from' => $request->get_param( 'from' ),
'to' => $request->get_param( 'to' ),
)
);
}
/**
* Forward a GET to `public-api.wordpress.com/wpcom/v2/sites/{blog_id}/{sub_path}`
* as the current user, dropping query args whose value is null/''.
*
* @param string $sub_path Sub-path under `/sites/{blog_id}/` (no leading slash).
* @param array $query Query args to append.
*
* @return WP_REST_Response|WP_Error
*/
private function forward( $sub_path, $query ) {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error(
'site-not-connected',
__( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ),
array( 'status' => 400 )
);
}
$query = array_filter(
$query,
static function ( $value ) {
return null !== $value && '' !== $value;
}
);
$path = sprintf( '/sites/%d/%s', $blog_id, $sub_path );
if ( ! empty( $query ) ) {
$path = add_query_arg( $query, $path );
}
$response = Client::wpcom_json_api_request_as_user(
$path,
'2',
array(
'method' => 'GET',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 15,
),
null,
'wpcom'
);
return $this->relay_response( $response );
}
}
@@ -0,0 +1,366 @@
<?php
/**
* Local Jetpack-side REST endpoint for the Posts to Podcast feature.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use Automattic\Jetpack\Connection\Client;
use Jetpack_Options;
use WP_Error;
use WP_REST_Controller;
use WP_REST_Request;
use WP_REST_Response;
use WP_REST_Server;
/**
* Forwards `wp.apiFetch` calls from the wp-admin Create tab to the wpcom-side
* endpoint as the current user (the upstream endpoint requires user identity).
*/
class Posts_To_Podcast_Endpoint extends WP_REST_Controller {
use Relay_Response;
const SUPPORTED_LENGTHS = array( 'short', 'medium', 'long' );
const SUPPORTED_VOICE_PRESETS = array( 'witty', 'earnest', 'professional' );
const REST_NAMESPACE = 'wpcom/v2';
const REST_BASE = 'posts-to-podcast';
const POST_PUBLISH_PROMO_DISMISS_REST_ROUTE = 'post-publish-promo/dismiss';
/**
* Whether `init()` has wired its hooks.
*
* @var bool
*/
private static $initialized = false;
/**
* Wire up routes. Idempotent.
*/
public static function init() {
if ( self::$initialized ) {
return;
}
self::$initialized = true;
$instance = new self();
add_action( 'rest_api_init', array( $instance, 'register_routes' ) );
}
/**
* Get the REST API path used by apiFetch for post-publish promo dismissal.
*
* @return string
*/
public static function get_post_publish_promo_dismiss_rest_path() {
return '/' . self::REST_NAMESPACE . '/' . self::REST_BASE . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE;
}
/**
* Register feature info, enqueue, job-status, and promo dismissal routes.
*/
public function register_routes() {
$this->namespace = self::REST_NAMESPACE;
$this->rest_base = self::REST_BASE;
register_rest_route(
$this->namespace,
$this->rest_base,
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_feature_info' ),
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
),
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'enqueue_generation' ),
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
'args' => array(
'window' => array(
'type' => 'object',
'required' => false,
'description' => __( 'Either { unit: days|weeks|months, n: <positive int> } or { from, to } as ISO-8601 dates. Required when postIds is omitted.', 'jetpack-podcast' ),
),
'postIds' => array(
'type' => 'array',
'required' => false,
'items' => array( 'type' => 'integer' ),
'maxItems' => 25,
'description' => __( 'Explicit list of published post IDs to draw from (up to 25). Required when window is omitted.', 'jetpack-podcast' ),
),
'length' => array(
'type' => 'string',
'required' => true,
'enum' => self::SUPPORTED_LENGTHS,
'description' => __( 'Length preset id.', 'jetpack-podcast' ),
),
'voicePreset' => array(
'type' => 'string',
'required' => true,
'enum' => self::SUPPORTED_VOICE_PRESETS,
'description' => __( 'Voice preset id.', 'jetpack-podcast' ),
),
'prompt' => array(
'type' => 'string',
'required' => false,
'description' => __( 'Optional free-form instructions appended to the generation prompt.', 'jetpack-podcast' ),
),
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/jobs/(?P<job_id>\d+)',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_job_status' ),
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
'args' => array(
'job_id' => array(
'type' => 'integer',
'required' => true,
),
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/episodes',
array(
array(
'methods' => WP_REST_Server::READABLE,
'callback' => array( $this, 'read_episodes' ),
'permission_callback' => array( Posts_To_Podcast_Helper::class, 'get_status_permission_check' ),
'args' => array(
'page' => array(
'type' => 'integer',
'default' => 1,
'minimum' => 1,
),
'per_page' => array(
'type' => 'integer',
'default' => 5,
'minimum' => 1,
'maximum' => 50,
),
),
),
)
);
register_rest_route(
$this->namespace,
$this->rest_base . '/' . self::POST_PUBLISH_PROMO_DISMISS_REST_ROUTE,
array(
'methods' => WP_REST_Server::CREATABLE,
'callback' => array( $this, 'dismiss_post_publish_promo' ),
'permission_callback' => function () {
return current_user_can( 'edit_posts' );
},
)
);
}
/**
* Return posts that embed a `jetpack/podcast-episode` block the surface
* this feature creates on success newest first. Drafts and published
* posts only; trashed/auto-drafts are excluded.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response
*/
public function read_episodes( WP_REST_Request $request ) {
$page = max( 1, (int) $request->get_param( 'page' ) );
$per_page = max( 1, min( 50, (int) $request->get_param( 'per_page' ) ) );
$query = new \WP_Query(
array(
'post_type' => 'post',
'post_status' => array( 'draft', 'publish' ),
'posts_per_page' => $per_page,
'paged' => $page,
'orderby' => 'date',
'order' => 'DESC',
'update_post_term_cache' => false,
'meta_query' => array(
array(
'key' => 'posts_to_podcast_metadata',
'compare' => 'EXISTS',
),
),
)
);
$items = array();
foreach ( $query->posts as $post ) {
$raw_meta = get_post_meta( $post->ID, 'posts_to_podcast_metadata', true );
$meta = is_string( $raw_meta ) ? json_decode( $raw_meta, true ) : null;
$audio = ( is_array( $meta ) && isset( $meta['audio'] ) && is_array( $meta['audio'] ) ) ? $meta['audio'] : array();
$title = wp_strip_all_tags(
html_entity_decode( (string) get_the_title( $post ), ENT_QUOTES | ENT_HTML5, 'UTF-8' )
);
if ( '' === trim( $title ) ) {
// translators: Fallback shown in the Generated podcasts list when a draft has an empty title.
$title = __( '(no title)', 'jetpack-podcast' );
}
$items[] = array(
'id' => $post->ID,
'title' => $title,
'status' => $post->post_status,
'date' => mysql2date( 'c', $post->post_date_gmt, false ),
'editUrl' => get_edit_post_link( $post->ID, 'raw' ),
'mediaUrl' => isset( $audio['url'] ) ? esc_url_raw( (string) $audio['url'] ) : '',
'mediaType' => 'audio',
'mediaMime' => isset( $audio['mimeType'] ) ? (string) $audio['mimeType'] : '',
'duration' => isset( $audio['durationSeconds'] ) ? (int) round( (float) $audio['durationSeconds'] ) : 0,
);
}
$total = (int) $query->found_posts;
$total_pages = $per_page > 0 ? (int) ceil( $total / $per_page ) : 0;
return rest_ensure_response(
array(
'items' => $items,
'total' => $total,
'page' => $page,
'perPage' => $per_page,
'totalPages' => $total_pages,
)
);
}
/**
* Persist post-publish promo dismissal for the current user and site.
*
* @return WP_REST_Response
*/
public function dismiss_post_publish_promo() {
update_user_option( get_current_user_id(), Create_AI_Podcast_Page::POST_PUBLISH_PROMO_DISMISSED_OPTION, 1 );
return rest_ensure_response(
array(
'dismissed' => true,
)
);
}
/**
* Forward GET to the wpcom-side endpoint and return feature info
* (remaining credits, plan, supported presets).
*
* @return WP_REST_Response|WP_Error
*/
public function read_feature_info() {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
'2',
array(
'method' => 'GET',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 15,
),
null,
'wpcom'
);
return $this->relay_response( $response );
}
/**
* Forward POST to the wpcom-side endpoint and return the queued job descriptor.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function enqueue_generation( WP_REST_Request $request ) {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
}
$body_payload = array(
'length' => $request->get_param( 'length' ),
'voicePreset' => $request->get_param( 'voicePreset' ),
);
$window = $request->get_param( 'window' );
if ( null !== $window ) {
$body_payload['window'] = $window;
}
$post_ids = $request->get_param( 'postIds' );
if ( is_array( $post_ids ) && ! empty( $post_ids ) ) {
$body_payload['postIds'] = array_values( array_map( 'intval', $post_ids ) );
}
$prompt = $request->get_param( 'prompt' );
if ( is_string( $prompt ) && '' !== $prompt ) {
$body_payload['prompt'] = $prompt;
}
if ( ! isset( $body_payload['window'] ) && ! isset( $body_payload['postIds'] ) ) {
return new WP_Error( 'missing-source', __( 'One of window or postIds is required.', 'jetpack-podcast' ), array( 'status' => 400 ) );
}
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/posts-to-podcast', $blog_id ),
'2',
array(
'method' => 'POST',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 30,
),
wp_json_encode( $body_payload, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE ),
'wpcom'
);
return $this->relay_response( $response );
}
/**
* Forward GET to the wpcom-side polling endpoint and return the job record.
*
* @param WP_REST_Request $request Full details about the request.
*
* @return WP_REST_Response|WP_Error
*/
public function read_job_status( WP_REST_Request $request ) {
$blog_id = (int) Jetpack_Options::get_option( 'id' );
if ( ! $blog_id ) {
return new WP_Error( 'site-not-connected', __( 'Site is not connected to WordPress.com.', 'jetpack-podcast' ), array( 'status' => 400 ) );
}
$job_id = (int) $request['job_id'];
$response = Client::wpcom_json_api_request_as_user(
sprintf( '/sites/%d/posts-to-podcast/jobs/%d', $blog_id, $job_id ),
'2',
array(
'method' => 'GET',
'headers' => array( 'content-type' => 'application/json' ),
'timeout' => 15,
),
null,
'wpcom'
);
return $this->relay_response( $response );
}
}
@@ -0,0 +1,51 @@
<?php
/**
* Helper for the Posts to Podcast feature.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
/**
* Gating + permission helpers for the Posts to Podcast REST endpoint.
*/
class Posts_To_Podcast_Helper {
/**
* Whether the Posts to Podcast feature is active for the current request.
*
* @return bool
*/
public static function is_enabled() {
/**
* Filter to allow disabling the Posts to Podcast feature on a per-site basis.
* Defaults to true wherever the podcast package is active; flip this to false
* to hide the section during a staged rollout without disabling the package.
*
* @since 0.1.0
*
* @param bool $enabled Whether the feature is enabled. Default true.
*/
return (bool) apply_filters( 'jetpack_posts_to_podcast_is_enabled', true );
}
/**
* Permission callback for the local proxy REST endpoint.
*
* @param \WP_REST_Request $request Full details about the request.
*
* @return true|\WP_Error
*/
public static function get_status_permission_check( $request ) { // phpcs:ignore Generic.CodeAnalysis.UnusedFunctionParameter, VariableAnalysis.CodeAnalysis.VariableAnalysis.UnusedVariable
if ( ! current_user_can( 'edit_posts' ) ) {
return new \WP_Error(
'rest_forbidden',
__( 'Sorry, you are not allowed to use this feature on this site.', 'jetpack-podcast' ),
array( 'status' => rest_authorization_required_code() )
);
}
return true;
}
}
@@ -0,0 +1,46 @@
<?php
/**
* Shared helper for the podcast REST proxy endpoints: relays a
* `Connection\Client` response back to the local REST client.
*
* @package automattic/jetpack-podcast
*/
namespace Automattic\Jetpack\Podcast;
use WP_Error;
use WP_REST_Response;
/**
* Used by the wpcom/v2 proxy controllers (Podcast_Stats_Endpoint,
* Podcast_Distribution_Endpoint, Posts_To_Podcast_Endpoint) to round-trip the
* upstream wpcom response: preserves status code and decodes the JSON body so
* `apiFetch` on the client surfaces 4xx/5xx errors with their `{code, message}`
* payloads intact.
*/
trait Relay_Response {
/**
* Relay an upstream Connection\Client response back to the local REST client.
* Preserves the upstream HTTP status code so 4xx/5xx mappings flow through.
*
* @param array|WP_Error $response The raw response from Connection\Client.
*
* @return WP_REST_Response|WP_Error
*/
private function relay_response( $response ) {
if ( is_wp_error( $response ) ) {
return $response;
}
$code = (int) wp_remote_retrieve_response_code( $response );
$body = wp_remote_retrieve_body( $response );
$decoded = json_decode( $body, true );
$rest_response = rest_ensure_response( null === $decoded ? $body : $decoded );
if ( $code >= 100 && $code < 600 ) {
$rest_response->set_status( $code );
}
return $rest_response;
}
}
@@ -0,0 +1,489 @@
<?php
/**
* Adds podcast tags + tracked enclosure URLs to the RSS feed for the
* configured podcast category.
*
* @package automattic/jetpack-podcast
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\Podcast\Feed;
use Automattic\Jetpack\Connection\Manager as Connection_Manager;
use Automattic\Jetpack\Podcast\Settings;
use WP_Post;
/**
* Hooks into RSS2 rendering when the current request is the podcast category
* feed, adding `<itunes:*>` + `<podcast:*>` tags at channel and item level
* and rewriting `<enclosure>` URLs through the WPCOM stats endpoint.
*/
class Customize_Feed {
/**
* Whether `init()` has wired its hooks.
*
* @var bool
*/
private static $registered = false;
/**
* Wire the late-binding `wp` action that decides whether to register the
* feed-modification hooks for this request. Idempotent.
*/
public static function init() {
if ( self::$registered ) {
return;
}
self::$registered = true;
add_action( 'wp', array( __CLASS__, 'maybe_register_feed_hooks' ) );
// `the_posts` fires during query execution — before the `wp` action —
// so it has to be registered up-front and self-gated to the podcast
// feed query, rather than wired conditionally in `maybe_register_feed_hooks`.
add_filter( 'the_posts', array( __CLASS__, 'filter_posts_with_enclosure' ), 10, 2 );
}
/**
* Register the RSS2 hooks if this request is the configured podcast feed.
* Also fires `Feed_Detection` while we're here same gating, no need to
* walk the post query twice.
*/
public static function maybe_register_feed_hooks() {
if ( ! is_feed() ) {
return;
}
$category_id = self::resolve_category_id();
if ( 0 === $category_id || ! is_category( $category_id ) ) {
return;
}
// Strip channel-level tags that conflict with the iTunes-compliant
// header: blavatar / site-icon `<image>` duplicates `<itunes:image>`,
// and `<cloud …/>` from rsscloud isn't part of the podcast spec.
remove_action( 'rss2_head', 'rss2_blavatar' );
remove_action( 'rss2_head', 'rss2_site_icon' );
remove_action( 'rss2_head', 'rsscloud_add_rss_cloud_element' );
add_action( 'rss2_ns', array( __CLASS__, 'output_namespaces' ) );
add_filter( 'wp_title_rss', array( __CLASS__, 'feed_title' ) );
add_filter( 'bloginfo_rss', array( __CLASS__, 'feed_description' ), 10, 2 );
add_action( 'rss2_head', array( __CLASS__, 'output_channel_tags' ) );
add_action( 'rss2_item', array( __CLASS__, 'output_item_tags' ) );
add_filter( 'rss_enclosure', array( __CLASS__, 'rewrite_enclosure' ) );
// Prune RSS chrome that podcatchers don't read. Cuts payload size and
// keeps incidental post data (body content, gravatar URLs, image EXIF,
// comments metadata) out of a feed whose only job is to deliver
// podcast episode metadata + the audio enclosure.
//
// - option_rss_use_excerpt -> suppresses content:encoded (full post body, incl. EXIF in image attrs).
// - comments_open + get_comments_number -> together suppress per-item comments / wfw:commentRss / slash:comments.
// - the_category_rss -> suppresses per-item category tags (channel itunes:category is the podcatcher signal).
// - removing wpcom mrss.php hooks -> suppresses media:content for author gravatar + post images.
add_filter( 'option_rss_use_excerpt', '__return_true' );
add_filter( 'comments_open', '__return_false' );
add_filter( 'get_comments_number', '__return_zero' );
add_filter( 'the_category_rss', '__return_empty_string' );
remove_action( 'rss2_item', 'mrss_item', 10 );
remove_action( 'rss2_item', 'mrss_news_item' );
Feed_Detection::detect_and_record();
}
/**
* Add iTunes and Podcasting 2.0 XML namespaces to the `<rss>` open tag.
*/
public static function output_namespaces() {
echo "\n\t" . 'xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd"' . "\n";
echo "\t" . 'xmlns:podcast="https://podcastindex.org/namespace/1.0"' . "\n";
}
/**
* Override the feed title with `podcasting_title`, falling back to
* `Blog Name » Category Name`.
*
* @param string $title Existing title.
* @return string
*/
public static function feed_title( $title ) {
$override = (string) get_option( 'podcasting_title', '' );
if ( '' !== $override ) {
return esc_xml( $override );
}
$category = get_category( self::resolve_category_id() );
if ( $category && ! is_wp_error( $category ) ) {
return esc_xml( get_bloginfo( 'name' ) ) . ' &#187; ' . esc_xml( $category->name );
}
return esc_xml( $title );
}
/**
* Replace the `bloginfo_rss('description')` value with `podcasting_summary`.
*
* `bloginfo_rss()` echoes the filter return value directly, so we strip and
* escape here matches the channel-level `<itunes:summary>` treatment and
* keeps stray markup in the option from leaking into `<description>`.
*
* @param string $value Existing value.
* @param string $field Field being requested.
* @return string
*/
public static function feed_description( $value, $field ) {
if ( 'description' !== $field ) {
return $value;
}
return esc_xml( wp_strip_all_tags( (string) get_option( 'podcasting_summary', '' ) ) );
}
/**
* Channel-level podcast tags (rss2_head).
*/
public static function output_channel_tags() {
$summary = (string) get_option( 'podcasting_summary', '' );
if ( '' !== $summary ) {
echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $summary ) ) . "</itunes:summary>\n";
}
$author = (string) get_option( 'podcasting_talent_name', '' );
if ( '' !== $author ) {
echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
}
$email = wp_strip_all_tags( (string) get_option( 'podcasting_email', '' ) );
if ( '' !== $email ) {
echo '<itunes:owner><itunes:email>' . esc_xml( $email ) . "</itunes:email></itunes:owner>\n";
}
$copyright = (string) get_option( 'podcasting_copyright', '' );
if ( '' !== $copyright ) {
echo '<copyright>' . esc_xml( wp_strip_all_tags( $copyright ) ) . "</copyright>\n";
}
/**
* Explicit content flag
*/
echo '<itunes:explicit>' . esc_html( self::explicit_string() ) . "</itunes:explicit>\n";
$image = self::show_image_url();
if ( '' !== $image ) {
echo '<itunes:image href="' . esc_url( $image ) . '" />' . "\n";
}
echo self::category_tag( (string) get_option( 'podcasting_category_1', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
echo self::category_tag( (string) get_option( 'podcasting_category_2', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
echo self::category_tag( (string) get_option( 'podcasting_category_3', '' ) ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
}
/**
* Item-level podcast tags (rss2_item).
*/
public static function output_item_tags() {
global $post;
if ( ! $post instanceof WP_Post ) {
return;
}
$author = get_the_author();
if ( '' === $author ) {
$author = (string) get_option( 'podcasting_talent_name', '' );
}
if ( '' !== $author ) {
echo '<itunes:author>' . esc_xml( wp_strip_all_tags( $author ) ) . "</itunes:author>\n";
}
// Re-applying `the_excerpt_rss` so `<itunes:summary>` matches whatever
// the item's `<description>` ends up emitting — `get_the_excerpt()`
// doesn't run the filter chain itself.
$excerpt = (string) apply_filters( 'the_excerpt_rss', get_the_excerpt() );
if ( '' !== $excerpt ) {
echo '<itunes:summary>' . esc_xml( wp_strip_all_tags( $excerpt ) ) . "</itunes:summary>\n";
}
// Per-item cover art: prefer the block's `coverArt`, fall back to the
// post's featured image. Either way, photon-resize to 3000×3000 to
// honour Apple's square-cover requirement. When neither is present
// the channel-level `<itunes:image>` applies as default per spec.
$attrs = Episode_Block_Tags::get_block_attrs( $post );
$cover_url = isset( $attrs['coverArt']['url'] ) ? trim( (string) $attrs['coverArt']['url'] ) : '';
$item_image = '' !== $cover_url ? self::maybe_photon( $cover_url ) : self::episode_image_url( $post->ID );
if ( '' !== $item_image ) {
echo '<itunes:image href="' . esc_url( $item_image ) . '" />' . "\n";
}
// Block-driven iTunes + Podcasting 2.0 tags. Legacy audio posts
// without the block contribute nothing — they keep their pre-block
// behavior intact aside from the cover art handled above.
if ( ! empty( $attrs ) ) {
Episode_Block_Tags::render_from_attrs( $attrs );
}
}
/**
* Rewrite the enclosure URL through the WPCOM stats endpoint and append
* `<itunes:duration>` when resolvable. Duration is looked up against the
* *original* attachment URL the stats URL is synthetic.
*
* @param string $enclosure Generated enclosure markup.
* @return string
*/
public static function rewrite_enclosure( $enclosure ) {
global $post;
if ( ! preg_match( '/url="([^"]*)"/i', $enclosure, $match ) ) {
return $enclosure;
}
$original_url = $match[1];
$post_obj = $post instanceof WP_Post ? $post : null;
/**
* Whether to rewrite the enclosure through the WPCOM stats endpoint.
* Token-gated feeds (notably WPCOM's `private-podcasts.php`) opt out
* the stats URL is a deterministic public endpoint that would
* bypass any token gating on the feed itself.
*
* @param bool $enable Default true.
* @param WP_Post|null $post The post being rendered.
*/
$enable = (bool) apply_filters( 'wpcom_podcasting_enable_play_tracking', true, $post_obj );
// Skip rewrite for externally hosted enclosures — the stats endpoint 404s anything that isn't a local attachment.
$attachment_id = attachment_url_to_postid( $original_url );
if ( null !== $post_obj && $enable && $attachment_id > 0 ) {
// `null` when the site isn't connected; passed through so the filter can still inject a value.
$default_blog_id = Connection_Manager::get_site_id( true );
/**
* Override the blog ID baked into the stats URL.
*
* @param int|null $blog_id Default Jetpack connection site ID, or null when unavailable.
* @param WP_Post $post The post being rendered.
*/
$blog_id = (int) apply_filters( 'wpcom_podcasting_tracked_blog_id', $default_blog_id, $post_obj );
// Bail when we can't resolve a real blog ID — emit the original URL rather than a guaranteed-404 stats URL.
if ( $blog_id > 0 ) {
$stats_url = self::build_stats_url( $blog_id, (int) $post_obj->ID, $original_url );
$enclosure = preg_replace_callback(
'/url="[^"]*"/i',
/**
* Replace the matched `url="…"` attribute with the stats URL.
* `$matches` is required by `preg_replace_callback`'s callable
* signature but ignored we always emit the same value.
*
* @param array $matches Regex matches.
* @return string
*/
static function ( array $matches ) use ( $stats_url ) {
unset( $matches );
return 'url="' . esc_url( $stats_url ) . '"';
},
$enclosure,
1
);
}
}
if ( 0 === $attachment_id ) {
return $enclosure;
}
$metadata = wp_get_attachment_metadata( $attachment_id );
$duration = is_array( $metadata ) ? absint( $metadata['length'] ?? 0 ) : 0;
return 0 === $duration
? $enclosure
: $enclosure . '<itunes:duration>' . $duration . "</itunes:duration>\n";
}
/**
* A podcast item without an enclosure is invalid per Apple's spec and can
* take down the whole submission. The `enclosure` post meta is what
* `rss_enclosure()` reads, so it's the authoritative signal here too.
*
* @param WP_Post[] $posts Posts about to be looped over.
* @param \WP_Query $query Query that produced them.
* @return WP_Post[]
*/
public static function filter_posts_with_enclosure( $posts, $query ) {
if ( ! $query->is_main_query() || ! $query->is_feed() || ! $query->is_category() ) {
return $posts;
}
$category_id = self::resolve_category_id();
if ( 0 === $category_id ) {
return $posts;
}
$queried = $query->get_queried_object();
if ( ! $queried || ! isset( $queried->term_id ) || (int) $queried->term_id !== $category_id ) {
return $posts;
}
return array_values(
array_filter(
$posts,
static function ( $post ) {
return $post instanceof WP_Post
&& ! empty( get_post_meta( $post->ID, 'enclosure', false ) );
}
)
);
}
/**
* Stored explicit value, normalized to the `'true'`/`'false'` strings the
* iTunes spec requires. Reuses `Settings::sanitize_explicit`
* so legacy `'yes'`/`'no'`/`'clean'` and modern boolean storage both work.
*
* @return string
*/
public static function explicit_string(): string {
return Settings::sanitize_explicit( get_option( 'podcasting_explicit', false ) ) ? 'true' : 'false';
}
/**
* Show-level cover image URL `Settings::raw_show_image_url()` routed
* through Photon at 3000×3000 when available.
*
* @return string
*/
private static function show_image_url(): string {
$url = Settings::raw_show_image_url();
return '' === $url ? '' : self::maybe_photon( $url );
}
/**
* Build the WPCOM stats URL for a given episode. The endpoint redirects
* to the audio file after recording the play the package never serves
* it, only points at it. Audio extensions outside the recognized set
* fall back to `mp3` to keep the URL shape uniform (matches the Podtrac
* / Megaphone / Art19 convention).
*
* @param int $blog_id WPCOM blog ID (Atomic should override via the
* `wpcom_podcasting_tracked_blog_id` filter).
* @param int $post_id Episode post ID.
* @param string $original_url Original enclosure URL extension is pulled from here.
* @return string
*/
private static function build_stats_url( int $blog_id, int $post_id, string $original_url ): string {
$path = (string) wp_parse_url( $original_url, PHP_URL_PATH );
$ext = (string) preg_replace( '/[^a-z0-9]/', '', strtolower( (string) pathinfo( $path, PATHINFO_EXTENSION ) ) );
if ( ! in_array( $ext, array( 'mp3', 'm4a', 'm4b', 'aac', 'ogg', 'oga', 'opus', 'wav', 'flac', 'mp4', 'm4v', 'mov' ), true ) ) {
$ext = 'mp3';
}
return sprintf(
'https://public-api.wordpress.com/wpcom/v2/sites/%d/podcast-play/%d.%s',
$blog_id,
$post_id,
$ext
);
}
/**
* Resolve the configured podcast category ID. Prefers the numeric
* `podcasting_category_id`, falling back to a slug lookup against the
* legacy `podcasting_archive` option older sites pre-date numeric
* storage and only have the slug. Returns 0 when neither resolves.
*
* A numeric ID whose term was deleted means "not configured" the slug
* is not consulted in that case.
*
* @return int
*/
public static function resolve_category_id(): int {
$category_id = (int) get_option( 'podcasting_category_id', 0 );
if ( $category_id > 0 ) {
$category = get_category( $category_id );
return ( $category && ! is_wp_error( $category ) && isset( $category->term_id ) ) ? (int) $category->term_id : 0;
}
$slug = (string) get_option( 'podcasting_archive', '' );
if ( '' === $slug ) {
return 0;
}
$term = get_term_by( 'slug', $slug, 'category' );
return ( $term && ! is_wp_error( $term ) && isset( $term->term_id ) ) ? (int) $term->term_id : 0;
}
/**
* Episode-level image URL the post's featured image, Photon-resized,
* or `''` when no featured image is set. Used as the fallback per-item
* cover when the block doesn't supply its own.
*
* @param int $post_id Episode post ID.
* @return string
*/
private static function episode_image_url( int $post_id ): string {
if ( ! has_post_thumbnail( $post_id ) ) {
return '';
}
$src = wp_get_attachment_image_src( get_post_thumbnail_id( $post_id ), 'full' );
if ( ! is_array( $src ) || empty( $src[0] ) ) {
return '';
}
return self::maybe_photon( $src[0] );
}
/**
* Route through Photon at exactly 3000×3000 so the feed always serves a
* square cover, regardless of the source aspect ratio. `resize` center-crops
* (unlike `fit`, which only constrains within the box); Apple's spec wants
* 14003000 px square art and rejects non-square covers.
*
* @param string $url Image URL.
* @return string
*/
public static function maybe_photon( string $url ): string {
if ( ! function_exists( 'jetpack_photon_url' ) ) {
return $url;
}
// @phan-suppress-next-line PhanUndeclaredFunction -- Provided by Jetpack's Photon module at runtime; guarded by `function_exists` above.
return (string) jetpack_photon_url( $url, array( 'resize' => '3000,3000' ), 'https' );
}
/**
* Build a single `<itunes:category>` tag from a stored option value. The
* stored format is one of:
* - `''` (no category)
* - `'Foo'` single category
* - `'Foo,Bar'` category Foo with subcategory Bar
*
* Includes a back-compat translation pass for a few legacy values that were
* stored in non-canonical shapes before validation tightened.
*
* @param string $stored Raw option value.
* @return string Empty string if no category, otherwise an XML fragment.
*/
public static function category_tag( string $stored ): string {
static $legacy_aliases = array(
'Education,Education' => 'Education',
'Education,Education Technology' => 'Education,Educational Technology',
'Tech News' => 'Technology,Tech News',
'Sports &amp; Recreation,Technology' => 'Technology',
'Sports &amp; Recreation,Gadgets' => 'Technology,Gadgets',
'Sports,Football' => 'Sports,American Football',
'Sports,Soccer' => 'Sports,Football (Soccer)',
);
$category = $legacy_aliases[ $stored ] ?? $stored;
if ( '' === $category ) {
return '';
}
// `ent2ncr()` normalises named HTML entities (e.g. `&nbsp;`, `&copy;`) into
// numeric character references so an attribute value containing them stays
// well-formed XML after esc_attr().
$splits = explode( ',', $category );
if ( 2 === count( $splits ) ) {
return '<itunes:category text="' . ent2ncr( esc_attr( $splits[0] ) ) . '">' . "\n"
. "\t" . '<itunes:category text="' . ent2ncr( esc_attr( $splits[1] ) ) . '" />' . "\n"
. "</itunes:category>\n";
}
return '<itunes:category text="' . ent2ncr( esc_attr( $category ) ) . '" />' . "\n";
}
}
@@ -0,0 +1,340 @@
<?php
/**
* Emits iTunes + Podcasting 2.0 item-level tags sourced from the
* `jetpack/podcast-episode` block's attrs.
*
* @package automattic/jetpack-podcast
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\Podcast\Feed;
use WP_Post;
/**
* Block-attr XML translation for a single episode. Kept separate from
* `Customize_Feed` (which only wires rss2_* hooks) so the mapping logic stays
* focused and independently testable.
*/
class Episode_Block_Tags {
/**
* Emit item-level tags for a post if it contains a podcast-episode block.
* Posts without the block contribute nothing legacy audio items keep
* their pre-block behavior intact.
*
* @param WP_Post $post Episode post.
*/
public static function render( WP_Post $post ): void {
$attrs = self::get_block_attrs( $post );
if ( empty( $attrs ) ) {
return;
}
self::render_from_attrs( $attrs );
}
/**
* Testable seam emit tags for a literal attrs array, skipping the block
* parse. Each emit is independent and no-ops on missing/blank values.
*
* @param array<string, mixed> $attrs Block attrs.
*/
public static function render_from_attrs( array $attrs ): void {
self::emit_episode_number( $attrs );
self::emit_season_number( $attrs );
self::emit_episode_type( $attrs );
self::emit_explicit_override( $attrs );
self::emit_transcript( $attrs );
self::emit_chapters( $attrs );
self::emit_location( $attrs );
self::emit_license( $attrs );
self::emit_people( $attrs );
self::emit_soundbites( $attrs );
self::emit_alternate_enclosures( $attrs );
}
/**
* `<podcast:chapters url="…" type="…" />` from the block's `chaptersUrl`
* / `chaptersType` attrs. Skip if no URL is set.
*
* @param array $attrs Block attrs.
*/
private static function emit_chapters( array $attrs ): void {
$url = isset( $attrs['chaptersUrl'] ) ? trim( (string) $attrs['chaptersUrl'] ) : '';
if ( '' === $url ) {
return;
}
$type = isset( $attrs['chaptersType'] ) ? (string) $attrs['chaptersType'] : 'application/json+chapters';
echo '<podcast:chapters url="' . esc_url( $url ) . '" type="' . esc_attr( $type ) . '" />' . "\n";
}
/**
* Extract attrs from the first `jetpack/podcast-episode` block in the
* post's content. Returns an empty array if no such block exists.
*
* First-wins: a post containing multiple `jetpack/podcast-episode` blocks
* is semantically odd (one item = one episode) so we don't try to merge.
*
* @param WP_Post $post Episode post.
* @return array<string, mixed>
*/
public static function get_block_attrs( WP_Post $post ): array {
// Skip the parse_blocks() regex pass entirely if our specific block
// marker isn't even in the content — tighter than has_blocks(), which
// only checks for `<!-- wp:`.
if ( false === strpos( $post->post_content, '<!-- wp:jetpack/podcast-episode' ) ) {
return array();
}
foreach ( parse_blocks( $post->post_content ) as $block ) {
if ( isset( $block['blockName'] ) && 'jetpack/podcast-episode' === $block['blockName'] ) {
return isset( $block['attrs'] ) && is_array( $block['attrs'] ) ? $block['attrs'] : array();
}
}
return array();
}
/**
* `<itunes:episode>` + `<podcast:episode>` for positive integer values.
*
* @param array $attrs Block attrs.
*/
private static function emit_episode_number( array $attrs ): void {
$value = $attrs['episodeNumber'] ?? 0;
if ( (int) $value <= 0 ) {
return;
}
echo '<itunes:episode>' . (int) $value . "</itunes:episode>\n";
echo '<podcast:episode>' . (int) $value . "</podcast:episode>\n";
}
/**
* `<itunes:season>` + `<podcast:season>` for positive integer values.
*
* @param array $attrs Block attrs.
*/
private static function emit_season_number( array $attrs ): void {
$value = $attrs['seasonNumber'] ?? 0;
if ( (int) $value <= 0 ) {
return;
}
echo '<itunes:season>' . (int) $value . "</itunes:season>\n";
echo '<podcast:season>' . (int) $value . "</podcast:season>\n";
}
/**
* `<itunes:episodeType>` for `trailer` / `bonus`. `full` is Apple's
* default and emitting it for every item would be noise.
*
* @param array $attrs Block attrs.
*/
private static function emit_episode_type( array $attrs ): void {
$value = isset( $attrs['episodeType'] ) ? (string) $attrs['episodeType'] : '';
if ( ! in_array( $value, array( 'trailer', 'bonus' ), true ) ) {
return;
}
echo '<itunes:episodeType>' . esc_xml( $value ) . "</itunes:episodeType>\n";
}
/**
* `<itunes:explicit>` only when the per-episode value differs from the
* channel default emitting a matching value on every item is noisy
* and redundant per Apple's spec.
*
* @param array $attrs Block attrs.
*/
private static function emit_explicit_override( array $attrs ): void {
if ( ! array_key_exists( 'explicit', $attrs ) ) {
return;
}
$item_value = (bool) $attrs['explicit'] ? 'true' : 'false';
$channel_value = Customize_Feed::explicit_string();
if ( $item_value === $channel_value ) {
return;
}
echo '<itunes:explicit>' . esc_html( $item_value ) . "</itunes:explicit>\n";
}
/**
* `<podcast:transcript url="…" type="…" />`. Type is validated against
* the enum from `block.json` so a hand-edited attr can't smuggle in an
* arbitrary MIME type.
*
* @param array $attrs Block attrs.
*/
private static function emit_transcript( array $attrs ): void {
$url = isset( $attrs['transcriptUrl'] ) ? (string) $attrs['transcriptUrl'] : '';
if ( '' === trim( $url ) ) {
return;
}
$type = isset( $attrs['transcriptType'] ) ? (string) $attrs['transcriptType'] : 'text/vtt';
$allowed_types = array( 'text/vtt', 'text/html', 'application/srt', 'application/json' );
if ( ! in_array( $type, $allowed_types, true ) ) {
$type = 'text/vtt';
}
echo '<podcast:transcript url="' . esc_url( $url ) . '" type="' . esc_attr( $type ) . "\" />\n";
}
/**
* `<podcast:location name="…" />`. Geo coordinates aren't captured by
* the block yet, so we emit name-only for v1.
*
* @param array $attrs Block attrs.
*/
private static function emit_location( array $attrs ): void {
$name = isset( $attrs['locationName'] ) ? trim( (string) $attrs['locationName'] ) : '';
if ( '' === $name ) {
return;
}
echo '<podcast:location>' . esc_xml( $name ) . "</podcast:location>\n";
}
/**
* `<podcast:license>` name as text content, optional `url` attribute.
* If only the URL is set we still need a name for valid markup, so skip.
*
* @param array $attrs Block attrs.
*/
private static function emit_license( array $attrs ): void {
$name = isset( $attrs['license'] ) ? trim( (string) $attrs['license'] ) : '';
$url = isset( $attrs['licenseUrl'] ) ? trim( (string) $attrs['licenseUrl'] ) : '';
if ( '' === $name ) {
return;
}
if ( '' === $url ) {
echo '<podcast:license>' . esc_xml( $name ) . "</podcast:license>\n";
return;
}
echo '<podcast:license url="' . esc_url( $url ) . '">' . esc_xml( $name ) . "</podcast:license>\n";
}
/**
* One `<podcast:person>` per entry. Lenient on role/group the spec
* defines a taxonomy but most validators only warn on unknown values;
* passing them through keeps editor freedom and matches the v1 plan.
*
* @param array $attrs Block attrs.
*/
private static function emit_people( array $attrs ): void {
if ( empty( $attrs['people'] ) || ! is_array( $attrs['people'] ) ) {
return;
}
foreach ( $attrs['people'] as $person ) {
if ( ! is_array( $person ) ) {
continue;
}
$name = isset( $person['name'] ) ? trim( (string) $person['name'] ) : '';
if ( '' === $name ) {
continue;
}
$tag = '<podcast:person';
if ( ! empty( $person['role'] ) ) {
$tag .= ' role="' . esc_attr( (string) $person['role'] ) . '"';
}
if ( ! empty( $person['group'] ) ) {
$tag .= ' group="' . esc_attr( (string) $person['group'] ) . '"';
}
if ( ! empty( $person['href'] ) ) {
$tag .= ' href="' . esc_url( (string) $person['href'] ) . '"';
}
if ( ! empty( $person['img'] ) ) {
$tag .= ' img="' . esc_url( (string) $person['img'] ) . '"';
}
$tag .= '>' . esc_xml( $name ) . "</podcast:person>\n";
echo $tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
}
}
/**
* One `<podcast:soundbite>` per entry. Order is preserved the P2.0
* spec doesn't mandate sorting and editors might intend a specific
* sequence.
*
* @param array $attrs Block attrs.
*/
private static function emit_soundbites( array $attrs ): void {
if ( empty( $attrs['soundbites'] ) || ! is_array( $attrs['soundbites'] ) ) {
return;
}
foreach ( $attrs['soundbites'] as $soundbite ) {
if ( ! is_array( $soundbite )
|| ! isset( $soundbite['startTime'] )
|| ! isset( $soundbite['duration'] )
) {
continue;
}
$start = (float) $soundbite['startTime'];
$duration = (float) $soundbite['duration'];
if ( $duration <= 0 ) {
continue;
}
$title = isset( $soundbite['title'] ) ? trim( (string) $soundbite['title'] ) : '';
$tag = '<podcast:soundbite startTime="' . esc_attr( (string) $start )
. '" duration="' . esc_attr( (string) $duration ) . '"';
if ( '' === $title ) {
$tag .= " />\n";
} else {
$tag .= '>' . esc_xml( $title ) . "</podcast:soundbite>\n";
}
echo $tag; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
}
}
/**
* One `<podcast:alternateEnclosure>` per entry, wrapping nested
* `<podcast:source>` children. The block currently stores each entry as a
* single URL with type/bitrate metadata, but the spec models a list of
* sources accept both shapes so a future block change doesn't need a
* second pass here.
*
* @param array $attrs Block attrs.
*/
private static function emit_alternate_enclosures( array $attrs ): void {
if ( empty( $attrs['alternateEnclosures'] ) || ! is_array( $attrs['alternateEnclosures'] ) ) {
return;
}
foreach ( $attrs['alternateEnclosures'] as $alt ) {
if ( ! is_array( $alt ) ) {
continue;
}
// `type` is required per spec; skip the entry rather than emit invalid markup.
$type = isset( $alt['type'] ) ? trim( (string) $alt['type'] ) : '';
if ( '' === $type ) {
continue;
}
$sources = array();
if ( ! empty( $alt['sources'] ) && is_array( $alt['sources'] ) ) {
foreach ( $alt['sources'] as $src ) {
if ( is_array( $src ) && ! empty( $src['uri'] ) ) {
$sources[] = (string) $src['uri'];
} elseif ( is_string( $src ) && '' !== $src ) {
$sources[] = $src;
}
}
} elseif ( ! empty( $alt['url'] ) ) {
$sources[] = (string) $alt['url'];
}
if ( empty( $sources ) ) {
continue;
}
$opener = '<podcast:alternateEnclosure type="' . esc_attr( $type ) . '"';
if ( ! empty( $alt['length'] ) ) {
$opener .= ' length="' . (int) $alt['length'] . '"';
}
if ( ! empty( $alt['bitrate'] ) ) {
$opener .= ' bitrate="' . (int) $alt['bitrate'] . '"';
}
$opener .= ">\n";
echo $opener; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Pre-escaped XML fragment.
foreach ( $sources as $uri ) {
echo "\t<podcast:source uri=\"" . esc_url( $uri ) . "\" />\n";
}
echo "</podcast:alternateEnclosure>\n";
}
}
}
@@ -0,0 +1,108 @@
<?php
/**
* Records when podcast directory crawlers fetch the feed.
*
* @package automattic/jetpack-podcast
*/
declare( strict_types = 1 );
namespace Automattic\Jetpack\Podcast\Feed;
use Automattic\Jetpack\Podcast\Settings;
/**
* Promotes an existing pending podcatcher `podcasting_show_states` entry to
* `'active'` the first time we see its UA fetch the feed. Idempotent
* thereafter.
*/
class Feed_Detection {
/**
* UA needle podcatcher slug. First match wins, so order matters: more
* specific brand needles (e.g. `GooglePodcasts`, `YouTubeMusic`) come
* before generic ones (e.g. Apple's `Podcasts/`) that they share a
* substring with.
*
* Cross-referenced against opawg/user-agents-v2 (the OPAWG community
* dataset used by Podtrac, Megaphone, Art19, Chartable). When directories
* change UAs or new ones emerge, sync the relevant entries here:
* https://github.com/opawg/user-agents-v2/blob/master/src/{apps,bots}.json
*
* @var array<string, string>
*/
private const NEEDLES = array(
// YouTube / Google Podcasts — listed first so `GooglePodcasts/`
// doesn't get caught by Apple's broader `Podcasts/` needle below.
'Google-Podcast' => 'youtube',
'YouTube-Podcast' => 'youtube',
'GooglePodcasts' => 'youtube',
'GoogleChirp' => 'youtube',
'YouTubeMusic' => 'youtube',
// Apple Podcasts (iOS app, Mac app, automated checks, HomePod, Apple TV).
'iTMS' => 'apple',
'AppleCoreMedia' => 'apple',
'Podcasts/' => 'apple',
'iTunes' => 'apple',
'AirPodcasts/' => 'apple',
// Spotify — substring catches `Spotify/…`, `spotify-rss-…`, all variants.
'Spotify' => 'spotify',
// Pocket Casts.
'Pocket Casts' => 'pocketcasts',
'PocketCasts' => 'pocketcasts',
// Amazon — `AmazonMusic` is the listening app; `Amazon Music Podcast`
// (with spaces) is the actual feed crawler. Keep both.
'AmazonMusic' => 'amazon',
'Amazon Music Podcast' => 'amazon',
// Podcast Index — substring `PodcastIndex` catches `Podcastindex.org/`,
// `PodcastIndexer/`, `PodcastIndexManager/`, `PodcastIndex Classifier/`.
'PodcastIndex' => 'podcastindex',
);
/**
* Inspect the current request's User-Agent and, if it's a directory
* crawler with an existing pending state, mark its state `'active'`.
* No-op if the UA is missing, not in the directory allowlist, or the
* matched directory has no pending state.
*/
public static function detect_and_record(): void {
$ua = isset( $_SERVER['HTTP_USER_AGENT'] )
? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) )
: '';
if ( '' === $ua ) {
return;
}
$slug = null;
foreach ( self::NEEDLES as $needle => $candidate ) {
if ( false !== stripos( $ua, $needle ) ) {
$slug = $candidate;
break;
}
}
if ( null === $slug || ! isset( Settings::SHOW_URL_HOSTS[ $slug ] ) ) {
return;
}
$states = get_option( 'podcasting_show_states', array() );
if ( ! is_array( $states ) ) {
$states = array();
}
if ( ! isset( $states[ $slug ] ) || 'pending' !== $states[ $slug ] ) {
return;
}
// Concurrent first-fetches from different apps can race the read-modify-write.
// Benign: the loser's next poll (multi-hour cadence) re-writes its key. Worst
// case is one app sitting in `pending` for one extra poll cycle.
$states[ $slug ] = 'active';
update_option( 'podcasting_show_states', $states );
}
}